Compare commits

..
257 Commits
Author SHA1 Message Date
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
Omar López 1722edbd36 chore: v4.4.9 (#16617)
release.yml / release (push) Canceled after 0s
2024-09-12 20:08:07 +00:00
Amit Sharma e68ab3b123 fix: prevent linking duplicate apple calendars (#16539)
* fix: prevent linking duplicate apple calendars

* fix: update when new password

* revert unnecessary change

* fix: use array.find
2024-09-13 01:08:38 +05:30
cf16cd3ed2 feat: Implement Dub.co for conversion tracking (#16165)
* FEAT: Implement Dub.co for conversion tracking

* add link creation, @vercel/functions

* use refer.cal.com/:username instead of go.cal.com/r/:username

* sale conversion tracking

* add copy referral link

* limit to IS_CALCOM only

* Update yarn.lock

* fix DubAnalytics

* use workaround for isNewUser

* pass req to getOptions

* Update next-auth-options.ts

* fix ts errors

* only show DubAnalytics outside EU

* add Dub Analytics to /signup

* use WEBSITE_URL instead

* on-demand generate links

* add migration

* add check for existing link + change fetch method to poast

* remove refer.cal.com from PoweredByCal

* limit DubAnalytics to /signup only

* simplify generate-referral-link

* restore yarn.lock

* add yarn with dub sdk in

* add yarn with dub sdk in

* yarn

* Update yarn.lock

---------

Co-authored-by: sean <sean@brydon.io>
Co-authored-by: Peer Richelsen <peer@cal.com>
2024-09-12 12:33:10 -07:00
Keith Williams f4bc7d2ea4 chore: Remove PR reviewed automation (#16615) 2024-09-13 03:25:10 +09:00
Amit Sharma f5f7242f02 fix: use current user email (#16577)
* fix: use current user email

* dont fallback to email param
2024-09-12 16:32:49 +00:00
8f5ac26bb1 fix: profile page flickering and refactor (#16555)
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-12 10:12:54 -04:00
Calcom BotandReplexica b983adb16a feat: update translations (#16603)
Co-authored-by: Replexica <support@replexica.com>
2024-09-12 14:06:47 +00:00
Abhijeet SinghandAmit Sharma 8ce018995a fix: (managed-event-booking): slug conflict handling (#16540)
* fix(managed-event-booking): slug conflict handling

* refactor logic

---------

Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>
2024-09-12 10:05:05 -04:00
Maximilian FranzkeandAnik Dhabal Babu f11086e9f8 fix(ally): button (#16588)
The close button would actually need to be a `button` instead of a `span` HTML element to be both screenreader and keyboard user accessible, as well as having an accessible label.

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-09-12 13:29:27 +05:30
Rajiv SahalandMorgan 91f5d49cd4 feat: update v2 calendar services logic to handle multiple calendars (#16478)
* update calendar logic to handle multiple calendars

* add method to link calendar to credential

* remove logs

* update platform libraries

* update calendar logic to handle multiple calendars

* update credentials repository

* add selected calendars repository to destination calendars module

* add method to get user selected calendars and rename fn

* chore: PR feedback

* resolve merge conflicts

* fixup

* make sure we cover all edge cases for apple calendar

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2024-09-12 06:44:01 +00:00
Benny Joo 55e1e0fa3d chore: Remove passing optional prisma select prop to Repository classes (#16600) 2024-09-11 18:14:19 -07:00
4a59841b02 feat: booking with phone number (#14461)
Co-authored-by: Omar López <zomars@me.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-11 17:56:04 -07:00
Benny Joo 74a9f3c19e hotfix: availability/[schedule] (#16598) 2024-09-11 18:11:10 +00:00
Syed Ali Shahbaz 89250422ea fix: API V1 slots add explicit orgSlug null fallback (#16593)
* add explicit orgSlug null fallback

* do it in schema

* to nullish
2024-09-11 16:14:03 +00:00
Keith Williams 2f2f4dba60 chore: v4.4.8 (#16597)
release.yml / release (push) Canceled after 0s
2024-09-12 00:19:00 +09:00
Calcom BotandReplexica b292cafe7b feat: update translations (#16592)
Co-authored-by: Replexica <support@replexica.com>
2024-09-11 14:56:28 +00:00
Keith Williams 82053e0c7c perf: Include no shows in main status query (#16594)
* perf: Include no shows in main status query

* Removed the Promise.all call
2024-09-11 14:16:46 +00:00
Udit Takkar 860d173f0a feat: instant meeting browser notifcations (#16480)
* feat: instant meeting browser notifcations

* chore: support multiple notifications

* chore: save progress

* fix: add test notification and move file

* fix: type err

* chore: feedback improvements

* chore: undo DENY
2024-09-11 18:02:14 +04:00
Udit TakkarandSyed Ali Shahbaz 5bc0e1265d fix: use meeting id to get transcripts (#16320)
* fix: use meeting id to get transcripts

* chore

---------

Co-authored-by: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com>
2024-09-11 22:05:56 +09:00
Hariom Balhara e362c37ff3 fix: Location Change to Organizer Default Conferencing App (#16379)
* Fix

* Dont include test files

* Fix location change

* Self review fixes

* Add unit test

* Add more test

* Add a bookingScenario as well
2024-09-11 21:31:53 +09:00
4d53f327d9 feat: V2 API endpoint create phone call (#16528)
* refactor: V2 API endpoint create phone call

* fix: type err

* fix: type and build err

* chore: change default value

* chore: move it to another route

* test: for create phone call

* chore: undo constant

* chore: remove test

* fix: make begin_message optional

* chore: improvements

* chore: begin message

* chore: remove unused import

* chore: bump platform libraries with handleCreatePhoneCall

* chore: improvement

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Benny Joo <sldisek783@gmail.com>
Co-authored-by: Morgan Vernay <morgan@cal.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2024-09-11 11:11:54 +00:00
61f436be2a fix: wrong reschedule link for an org member for a booking on event outside the org (#15260)
* fix: wrong reschedule link

* test

* fix

* test

* test

* refactor: get org id of booking

* fix: redirect URL to handle movedToProfile username based on domain context

* Revert not needed changes

* Revert unnecessary changes now

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Hariom <hariombalhara@gmail.com>
2024-09-11 04:47:52 +00:00
Benny JooandUdit Takkar d1a0fc6604 chore: App router migration - Fix /event-types/[type] page, move getServerSideProps to /lib for event-types pages (#16457)
* event-types: refactor and extract client code into /modules

* revert

* remove res in getServerSideProps

* remove res in getServerSideProps 2

* Create EventTypeAppDir and fix error in event-types/[type]

* add comments

* fix type-check

* fix 1 more type-check

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-09-11 02:41:46 +00:00
Benny JooandAnik Dhabal Babu 976013512c perf: Server-Side Data Fetching in App Router: /workflows (#16563)
* wip

* complete server side fetching for app router

* fix type error

* fix

* address comments

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-09-10 19:25:59 -07:00
Benny Joo d9777a3011 perf: Server-Side Data Fetching in App Router: /availability (#16536)
* wip

* add method to OrganizationRepository

* server side fetching for availability

* fix error

* server side fetching finish

* remove log

* fix

* fix

* fix type

* no need to export

* fix type

* address nit comments
2024-09-10 19:23:42 -07:00
Benny JooandUdit Takkar 7725adedc8 chore: App router migration (org/[orgSlug]/instant-meeting), add missing page (#16448)
* Create event repository

* Use repository in event.handler

* fix type check

* migrate org/instant-meeting

* add export default

* fix metadata

* move getServerSideProps from @server/lib to @lib

* fix

* fix

* revert unneeded changes

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-09-10 18:43:50 -07:00
Anik Dhabal Babu 7269b3ea60 revert: booking rejection reason from booking success page (#16584) 2024-09-11 03:51:36 +05:30
Anik Dhabal Babu c168df4df0 fix: booking response field's label is not translated (#16582) 2024-09-10 23:39:32 +02:00
Keith Williams be9b0fb9a8 perf: Insights - put teamId filter first (#16581) 2024-09-10 19:40:19 +00:00
VijayandAnik Dhabal Babu c056cabbbd fix: to allow paypal install on team when user already installed (#16553)
* fix to allow paypal install on team when user already installed

* handle if parseInt fails

* Update add.ts

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-09-11 00:06:10 +09:00
97630e8add fix: prevent creating multiple platform orgs (#16564)
* fix: prevent creating multiple platform orgs

* fixup! fix: prevent creating multiple platform orgs

* fixup! fixup! fix: prevent creating multiple platform orgs

* Bust cache in CI

* Update schema.prisma

---------

Co-authored-by: Hariom <hariombalhara@gmail.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-09-11 00:05:22 +09:00
VijayandAnik Dhabal Babu a5443ccfbe fix to show updated values in eventTypes list (#16569)
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-09-11 00:05:02 +09:00
Alex van Andel 6f2986e42e chore: Release v4.4.7 (#16574)
release.yml / release (push) Canceled after 0s
2024-09-10 16:00:47 +01:00
Alex van Andel db345de99e fix: Add missing URL encoding to scheduledBy (#16573) 2024-09-10 13:49:23 +00:00
Keith WilliamsandAnik Dhabal Babu bde40dceb3 chore: Remove separate OR queries for Insights (#16568)
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-09-10 13:31:36 +00:00
Hariom BalharaandAnik Dhabal Babu 1a86e7c506 Skip COEP tests (#16572)
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-09-10 13:12:59 +00:00
Benny JooandKeith Williams 9ed1624111 chore: App router migration - settings/platform/**/*, settings/organizations/new and settings/organizations/[id]/* (#16334)
* remove WizardLayoutAppDir

* refactor organizations/new and add platform/new

* refactor organizations/[id]/*

* add APP_ROUTER_SETTINGS_ORG_ENABLED

* fix type error

* fix organizations/new page

* remove code not needed and fix type errors

* Fix Metadata utils

* remove unnecessary code

* remove unneeded code

* wip

* do not remove buildWrappedOnboardTeamMembersPage

* fix type error

* do not introduce changes to yarn.lock

* fix wrong route name

* fix onboard members

* fix platform pages

* fix settings/platform main view

* add requiresLicense to WithLayout

* refactor

* add requiresLicense to platform/new

* Add LicenseRequired back to platform/new

* add env variable

* fix onboard-members

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-09-09 17:19:44 -07:00
Benny Joo 5dff1c6df0 chore: App router migration - /attributes, /privacy, /dsync, /license-key under /settings route group (#16406)
* migrate license-key/new

* migrate settings/org/dsync

* migrate settings/org/privacy

* migrate org attributes

* remove res in getServerSideProps

* Fix SettingsLayoutAppDir

* fix attributes pages

* fix

* fix
2024-09-09 17:19:12 -07:00
Benny JooandJoe Au-Yeung 706d7dff42 perf: Server-Side Data Fetching in App Router: SettingsLayout / AdminLayout (#16537)
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
2024-09-09 14:38:51 -07:00
Keith Williams 71443ef23d perf: use isTeamBooking filter in all of Insights (#16561) 2024-09-09 18:50:20 +00:00
Keith Williams 17201334b6 Modified the view to use UNION for user/team bookings (#16558) 2024-09-10 03:05:38 +09:00
Peer Richelsen bc82ff4d21 Update and rename bug_report.yml to bug_report.md (#16556)
* Update and rename bug_report.yml to bug_report.md

* Update bug_report.md

* Update bug_report.md
2024-09-09 17:13:02 +00:00
Benny Joo 71ae3f157a chore: Fix bundle error occurring in Next.js Bundle Analysis CI job (#16548)
* fix bundle error

* revert
2024-09-09 22:25:25 +09:00
Rajiv SahalandKeith Williams 8784be067f chore: fix input DTOs for v2 calendar busy times endpoint (#16526)
* add class-transformer package to type

* update input dtos for get busy times and fix type issue

* fixup

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-09-09 09:26:16 +00:00
84a2e55125 chore: App router migration - general settings (#16312)
* added lockedSMS to future routes

* add orgMigrations routes to future

* correct metadata

* small fix

* add orgMigrations to future

* Move components to client components

* Remove tRPC element from edit user RSC

* Get username for meta

* Remove suspense query

* Remove orgMigrations from app router

* Type fix

* Revert "Remove suspense query"

This reverts commit eadd814f6e4a5d6856d9218342b7909c22fe62c6.

* Handle suspenseQuery in app router

* User edit page, fetch data server side

* Update yarn.lock

* Export getFixedT

* Set PageWrapper as root layout for settings

* Settings Layout accepts strings for shell heading

* Add OOO to app router settings

* Refactor layout for my-account pages

* Remove instances of pages router from my-account

* Refactor security pages

* Add billing to app router

* Add admin API link to layout

* Add api keys page

* Webhooks WIP

* Refactor SettingsHeader to client component

* Add webhook pages

* Refactor API keys page

* Add admin app page

* Type fix

* fix types

* fix developer/webhooks/[id] param value type error

* remove unnecessary code

* do not pass t prop to CreateNewWebhookButton

* fix type errors in webhook-edit-view

* fix the remaining type errors

* do not use prisma directly in generateMetadata

* remove use client if unnecessary

* Remove unused shell heading from SettingsLayoutAppDir

* improve metadata

* fix billing page

* fix import in settings/teams

* Use next `notFound()`

* fix type check

* fix type check

* remove unused code

* Fix calendar setting page

* Separate settings pages into route groups

* Refactor admin settings pages

* Remove meta instance from billing page route

* Update settings layoutAppDir

* Refactor developer settings pages

* Refactor out of office

* Refactor my account settings

* Refactor admin api page

* Refactor security pages

* Type fix

* fix styling in settings layout

---------

Co-authored-by: Somay Chauhan <somaychauhan98@gmail.com>
Co-authored-by: Omar López <zomars@me.com>
Co-authored-by: Benny Joo <sldisek783@gmail.com>
2024-09-09 01:15:03 -04:00
Calcom BotandReplexica 21e23f32ac feat: update translations (#16532)
Co-authored-by: Replexica <support@replexica.com>
2024-09-07 19:30:52 -03:00
Anik Dhabal Babu 33aa9420af fix: fixed the flaky e2e tests that failed the most times and refactor (#16364)
* fix: fixed the flaky tests that failed maximum times

* Update action-based.e2e.ts

* another

* add

* add another

* update

* fix

* add

* fix

* update

* update and add

* fix

* update

* update

* chnages

* chore: updates

* reafactor and following best practice

* fix

* add and update

* update

* update and fix

* fix type

* another test

* update

* Update organization-invitation.e2e.ts

* refactor and update

* fix

* final update
2024-09-07 18:52:55 -03:00
Hariom Balhara ef0ba48f49 fix: Invalid options for Select and MultiSelect when both legacy and new options(i.e. with and without id are present) (#16509)
* Fix invalid select options

* Add test for multiselect filling and fix bug with csv reporting

* Fix options getting duplicated

* Add getQueryBuilderConfig tests
2024-09-07 03:07:47 +00:00
Emilio Degiovanni 6e54c459a4 fix: #16083. booking location editable (#16142) 2024-09-06 20:01:35 +00:00
f5c16f0d6b feat: OOO webhook and zapier (#15434)
* zapier and webhook

* instructions for zapier

* Fix types

* fix types

* revert adding zap template

* Update packages/app-store/zapier/README.md

Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>

* Update packages/app-store/zapier/README.md

Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>

* Update packages/app-store/zapier/README.md

Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>

* Update packages/features/webhooks/lib/scheduleTrigger.ts

Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>

* wip

* Fixes from comments, no more sendPayloadNoBooking

* fix subscriberOptions teamIds param type

* types

* Update packages/features/webhooks/lib/sendPayload.ts

* Re add zapierPayload to don't break old zaps

* instead of metadata use oooEntry inside webhookPayload

* undo comment message

* Types

* reset changes on yarn.lock

* review changes

* fix types

* fix types

* tentative fix for types in webhook

* type improvements

* fix description + clean up

* revert yarn.lock changes

* allow custom template for ooo entry

* type fixes

* type fix

* fix donwloadLinks in payload

* simplify some types

* allow array or number for teamId

* same payload for test trigger

* code clean up

* fix no show e2e test

* translate text for webhook payload and fix conditional

* add test for ooo_created webhooks

* reset files

* merge fix

* fix test data test id

* remove unused variable

* only trigger for accepted memberships

* remove unused variable

---------

Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Omar López <zomars@me.com>
2024-09-06 15:48:05 -04:00
Morgan 105e7d3e28 chore: booker atom username / teams props enabled queries (#16514) 2024-09-06 12:38:37 -07:00
Morgan 21ef1900e9 chore: get platform me from trpc instead of apiv2 (#16515) 2024-09-06 12:36:47 -07:00
Peer Richelsen f07b73dc79 chore: updated cookie usage i18n (#16530) 2024-09-06 18:36:02 +00:00
Benny Joo d4b0516235 chore: App router migration - (/more, /maintenance, /connect-and-join, /upgrade) finish migration (#16497)
* more: extract client component into /modules folder

* maintenance: extract client component into /modules folder

* upgrade: extract client component into /modules folder

* improve WithLayout wrapper

* connect-and-join: extract client component into /modules folder

* add env vars
2024-09-06 17:22:05 +00:00
Calcom BotandReplexica 7bfad11ff9 feat: update translations (#16524)
Co-authored-by: Replexica <support@replexica.com>
2024-09-07 02:01:17 +09:00
d2f7a427d0 chore: App-router-migration(/apps) fix metadata, extract components into /module and finish migration (#16354)
* fix metadata for apps page

* migrate apps route to modules

* migrate apps/[slug]

* migrate apps/[slug]/setup

* migrate apps/categories/**/*

* add apps/installed page to app router

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

* migrate apps/installed/**/*

* Add apps/installation/[[...step]] to App router

* fix imports

* add missing use client directive

* finish migration

* fix metadata

* fix metadata for apps/categories

* fix type for installation/[[...step]]

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

* remove unnecessary code

* refactor

* fix apps/installation

* move getServerSideProps to lib

* fix import of apps/installation

* fix import for app slug page

* refactor

* fix installation page in app router

* remove res in getServerSideProps

* replace setHeader with NextResponse header setHeader

* refactor installed pages

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2024-09-06 15:40:19 +00:00
Morgan a910eb96ad chore: improve typing of runSeparateQueriesForOrStatements (#16525)
* chore: improve typing of runSeparateQueriesForOrStatements

* fixup! chore: improve typing of runSeparateQueriesForOrStatements

* fixup! fixup! chore: improve typing of runSeparateQueriesForOrStatements

* fixup! fixup! fixup! chore: improve typing of runSeparateQueriesForOrStatements
2024-09-06 14:50:33 +00:00
Keith Williams c080458faa fix: Broken preview deployments (#16523) 2024-09-06 12:58:41 +00:00
Keith Williams 5c75cfc17f chore: make generic function for separate or queries (#16522)
* trying to create generic function for ors

* chore: Add generic function to run separate queries for OR

* Refactored getTotalNoShows to use separate queries

* Finished noshows

* Made the no shows logic handle single result
2024-09-06 12:27:05 +00:00
Keith Williams 4280f556cb chore: Add last batch of languages to translate with AI (#16520) 2024-09-06 12:26:22 +00:00
Syed Ali Shahbaz 21368bb130 Update FeedbackTable.tsx (#16521) 2024-09-06 11:21:07 +00:00
Benny Joo e9076c8e3e Refactor AppProvidersAppDir (#16496) 2024-09-06 08:53:25 +00:00
2471ee801b feat: hashed link retrieval from event types v1 api (#16437)
* add hashed link retrieval from event types api v1

* remove id, eventTypeId from hashedLink field

* Update event-type.ts

* Update event-type.ts

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2024-09-06 06:47:23 +00:00
+6 9fc9876b3f refactor: allow managed event tests parallel runs (#16113)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: Replexica <support@replexica.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Udit Takkar <udit222001@gmail.com>
Co-authored-by: Peer Richelsen <peer@cal.com>
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@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: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com>
Co-authored-by: zomars <zomars@me.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
2024-09-06 05:32:16 +00:00
Omar López 34ca3bd22d fix: flakyness on booking questions tests (#16513) 2024-09-06 07:20:25 +05:30
Anik Dhabal Babu bebe1dd7b4 chore: improved lastused login method (#16511) 2024-09-05 23:19:24 +05:30
fced9a6355 feat: Add step in team creation form for selecting event type and create a team event (#15286)
* feat: Create team event on a new team creation

* fix

* fixed failed tests

* move team event type form

* fix type error

* requested chnages

* update

* some update and refactor

* fix e2e test

* Update useCreateEventType.ts

* update

* fix type error

* fix e2e

* update

---------

Co-authored-by: Somay Chauhan <somaychauhan98@gmail.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-09-05 12:33:18 -04:00
Keith Williams 6b57dd6794 chore: v4.4.6 (#16507)
release.yml / release (push) Canceled after 0s
2024-09-05 16:21:34 +00:00
Calcom BotandReplexica eb4c58186b feat: update translations (#16508)
Co-authored-by: Replexica <support@replexica.com>
2024-09-05 16:13:40 +00:00
Keith Williams 17e438ab9f chore: Add next batch of languages to AI translations (#16506) 2024-09-06 00:07:26 +09:00
Keith Williams 458e6a3abf perf: Separate OR clauses from Insights counts (#16502)
* perf: Separate OR clauses from Insights counts

* fixed bug with delete statement

* Fixed types

* Reverted unneeded change

* Need to use += so we capture data from all queries
2024-09-05 23:01:54 +09:00
Benny Joo 963d22648b chore: App-router-migration(/availability) add pages / extract components into /module (#16404)
* migrate availability/[schedule]

* add use client directive

* migrate main availability page

* migrate availability/troubleshoot page

* add env var

* extract AvailabilitySettingsWebWrapper into /modules

* fix build

* add missing default

* add missing export

* fix troubleshoot

* use params, not searchParams in generateMetadata

* fix schedule page in app router
2024-09-05 12:37:18 +00:00
fabcf2055b feat: Ability to Hyperlink text in Checkbox on Additional Questions (#15194)
* feat: Ability to Hyperlink text in Checkbox on Additional Questions

* update

* fix and update

* fix type error

* fix and update

* dpdate

* fix type error

* fix unit test

* Update

* fix tests

* revert test

* remove yarn.lock

* remove schema.prisma

* update

* remove log

* revert

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>
2024-09-05 09:05:45 +00:00
Lauris Skraucis 633697a812 fix: v2 api fix recurring booking (#16472)
* fix: v2 POST recurring booking

* refactor: have default recurringEventId for recurring booking
2024-09-05 08:37:26 +00:00
sean-brydonandMorgan Vernay 2de33fe1bf feat: attributes v2 api (#16461)
* WIP

* services

* controllers + outputs

* unit tests for repositories

* use hard coded enum type

* use prisma import

* remove redunant input file

* fix packagejson

* rename to spec files

* fix typo in option service file name

* move membership call to org membership service

* move logic to service > repo

* fix membership query

* attribute controller e2e spec

* add options e2e test

* add jest watch for e2e

---------

Co-authored-by: Morgan Vernay <morgan@cal.com>
2024-09-05 10:15:12 +02:00
Omar López 36e79bf69c ci: adds basic visual regression testing for icons (#16408) 2024-09-04 16:28:42 -04:00
Joe Au-Yeung 2e36cadce4 fix: Add jitter when Outlook requests are throttled (#16484) 2024-09-04 13:25:31 -07:00
Syed Ali Shahbaz 9ef532019f fix: v1 API Teams access (#16403) 2024-09-04 19:13:38 +00:00
Benny Joo 176dd1a7f3 edit deploy script to enable future/auth (#16492) 2024-09-04 19:09:57 +00:00
e9c71abd6f fix: meeting url missing in workflow email (#16434)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
2024-09-04 17:56:20 +00:00
Imamuzzaki Abu Salam eb472cba36 chore: Remove obsolete docker compose version (#16419) 2024-09-04 13:02:30 -03:00
Benny Joo 7d77554696 chore: App router migration - Add env variables for auth/forgot-password/*, auth/login, auth/logout, auth/new, `auth/saml-idp (#16488)
* Add env variables for auth/forgot-password, auth/login, auth/logout

* Add env variables for auth/new, auth/saml-idp
2024-09-04 15:20:40 +00:00
Morgan 31bfa9e32e fix: api v2 booking controller handle error codes (#16486)
* fix: api v2 booking controller handle error codes

* fixup! fix: api v2 booking controller handle error codes

* fixup! Merge branch 'main' into fix-booking-controller-errors-api-v2
2024-09-04 10:56:00 -03:00
Benny Joo 26be88fc56 chore: App-router-migration /auth/error, /auth/platform, /auth/oauth2 (#16445)
* preparePageMetadata: improve logic for title

* add missing pages: oauth2 / platform

* auth/error: add to app router and extract to /modules
2024-09-04 13:15:19 +00:00
Benny Joo 86cf8feb55 chore: App-router-migration /auth/new and /auth/saml-idp (#16447)
* auth/new: add to app router

* auth/saml-idp: add to app router

* fix
2024-09-04 13:05:38 +00:00
Lauris Skraucis 1edd255846 fix: use dbWrite in OrganizationsTeamsRepository (#16485) 2024-09-04 11:17:13 +00:00
Lauris Skraucis f538877349 chore: v2 bump platform libraries (#16473) 2024-09-04 11:14:05 +00:00
Keith Williams d759669c1e chore: Remove unnecessary log (#16426) 2024-09-04 11:09:10 +00:00
Peer Richelsen 71ce87ca21 Update bug_report.yml (#16089) 2024-09-04 11:05:43 +00:00
Calcom BotandReplexica 7eae004aa2 feat: update translations (#16477)
Co-authored-by: Replexica <support@replexica.com>
2024-09-04 10:39:16 +00:00
Udit TakkarandMorgan 6cd427bc78 feat: availability in instant meeting (#16424)
* chore: save progress

* feat: add isAvailable functionality

* fix: type error

* chore: add in builder

* tests: add unit tests

* chore: improvements

* chore: tests

* chore

* chore: remove log

* fix: tets

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2024-09-03 14:28:15 +00:00
Darshil MharaurandAmit Sharma b8d67c5a41 fix: Adding yearly title to preview page (#16441)
* initial commit

* added year tag to the occurences field

* Revert yarn.lock

---------

Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>
2024-09-03 09:34:05 -04:00
Calcom BotandReplexica 42953548b6 feat: update translations (#16475)
Co-authored-by: Replexica <support@replexica.com>
2024-09-03 13:20:21 +00:00
Hariom Balhara 28c631e108 test: Unit Tests for FormBuilderField and BookingFields components (#16162)
* Remove use of location from FormBuilder

* Add tests

* FormBuilderField and BookingFields tests

* More tests

* Remove always true if condition

* Fix ui import mockig that got broken after the last merge
2024-09-03 08:58:34 -04:00
Keith Williams 263a96e660 chore: Add 3 more languages to AI translations (#16469) 2024-09-03 13:34:50 +01:00
Benny Joo d059b1399b chore: App router migration - /auth/forgot-password/*, /auth/login, /auth/logout (#16467)
* migrate auth/forgot-password/*, auth/login, auth/logout

* revert

* fix forgot password

* fix folder structure

* fix logout

* fix middleware

* remove TODO log

* refactor logout
2024-09-03 18:53:16 +09:00
Benny Joo 12a9c74eb6 chore: App router migration - Add method to BookingRepository & Extract /videos/**/* into /modules and /lib (#16466)
* meeting-ended: refactor and extract client code into /modules

* meeting-not-started: refactor and extract client code into /modules

* meeting-not-found: fix getData

* BookingRepository: add a new method

* video: refactor and extract client code into /modules

* refactor

* refactor getServerSideProps

* use findBookingByUid for more getServerSideProps functions

* rename findBookingByUid -> findBookingByUidWithOptionalSelect

* add safety
2024-09-03 18:53:02 +09:00
Keith Williams 6bb60e36fd chore: Log cold starts of /v1/slots (#16468) 2024-09-03 06:54:12 +00:00
Calcom BotandReplexica c1eb2512b6 feat: update translations (#16462)
Co-authored-by: Replexica <support@replexica.com>
2024-09-03 15:43:44 +09:00
Benny Joo 55d75a9786 chore: App router migration (/workflows) fix metadata and remove duplicate file for getStaticProps (#16452)
* Fix metadata

* add use client directive to workflow component
2024-09-02 23:00:48 +05:30
779eb19e7a feat: browser push notifications (#14888)
* feat: web push notifications feature

* Revert yarn.lock changes

* added new env variables requirement in .env.example

* moved useNotifications hook in packages/lib/hooks

* fix: bug

* use i18n

* chore: move to new file

* chore: add yarn.lock

* Update .env.example

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

* chore: add instruction for brave browser

* fix: tooltip

* chore: use enum

* chore

* small update

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Udit Takkar <udit222001@gmail.com>
Co-authored-by: Peer Richelsen <peer@cal.com>
Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
Co-authored-by: unknown <adhabal2002@gmail.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-09-02 14:39:51 +00:00
Hariom BalharaandUdit Takkar 5d8ed130de Fix unconfirmed bookings blocking slots being fetched for all troubleshooters (#16439)
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-09-02 16:12:11 +05:30
Calcom BotandReplexica 971d9ff0d8 feat: update translations (#16436)
Co-authored-by: Replexica <support@replexica.com>
2024-08-31 15:17:34 +00:00
Anik Dhabal Babu b6bcc43655 feat: add "somewhere else" as a location option (#15108)
* feat: add somewhere else as a location

* update

* update

* Update BookingFields.tsx

* update

* update

* chore: changes

* fix
2024-08-30 16:38:09 -04:00
Peer Richelsen b576fc8c26 feat: synthflow app (#16420) 2024-08-30 16:38:01 +00:00
Kacper 821843bc6c Fallback to next app default language (en) when user didn't specify locale. (#16425) 2024-08-30 21:37:10 +05:30
Lauris Skraucis d19e3e53f0 fix: recurring booking idempotency key after cancel (#16414)
* fix: recurring booking idempotency key after cancel

* refactor: move logic to updateMany of prisma middleware
2024-08-30 12:07:53 +00:00
Morgan 40fee7af51 chore: reorganize event type files (#16393)
* chore: reorganize event type files

* fixup! chore: reorganize event type files

* fixup! Merge branch 'main' into refactor-event-types-components-folder
2024-08-30 10:41:37 +00:00
Keith Williams f3462776e8 perf: Improve loading existing bookings via API (#16410) 2024-08-30 11:10:44 +01:00
Calcom BotandReplexica 57e9ef7320 feat: update translations (#16401)
Co-authored-by: Replexica <support@replexica.com>
2024-08-29 23:26:01 +00:00
88f46a6b20 fix: Broken reschedule page for migrated user's booking through request-reschedule (#16068)
Co-authored-by: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>
Co-authored-by: zomars <zomars@me.com>
2024-08-29 14:35:32 -07:00
Omar López 17f537774c chore: v4.4.5 (#16402)
release.yml / release (push) Canceled after 0s
2024-08-29 14:26:05 -07:00
3aa6878c27 fix: Set teamMemberEmail server side for CRM RR Skip (#16367)
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: zomars <zomars@me.com>
2024-08-29 14:16:48 -07:00
Benny JooandJoe Au-Yeung ae55e5e264 chore: App Router Migration - Ensure isBookingPage is correctly flagged before being passed to PageWrapper (#16362)
* Ensure adding isBookingPage is done in components in /modules, not in /pages

* Detect isBookingPage prop coming from Page and add it to page wrapper app dir

* fix build

* fix

---------

Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
2024-08-29 17:03:02 -04:00
Hariom Balhara 36ecece181 fix: Add version to embed.js and preview.js (#16182) 2024-08-29 20:54:41 +00:00
Hariom Balhara 1a0448910b fix: Flicker b/w Dark and Light theme (#16042) 2024-08-29 13:14:12 -07:00
ad5d645347 feat: Implement editable OOO events feature (#15932)
* feat: Implement edit ooo feature

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

* refactor: Refactor button text and gap between buttons

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

* fix: Fix entry create and edit edge cases

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

* chore: Change "edit ooo event" modal title

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

* chore: Add tooltips to entry eidt and delete buttons

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

* feat: Reset form on form close event

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

* refactor: Refactor reset-form function

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

* chore: Rename create or edit OOO as suggested

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

* refactor: Refactor component and function names

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

* feat: Improve ooo events email notifications

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

* fix: Fix email templates

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

* feat: Add e2e test for ooo event edit functionality

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

* refactor: Create separate test for edit ooo event

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

* fix: Remove extra user from redirect user configuration test

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

* chore: code refactor

* remove log

* chore: Update ooo-input-schema uuid data type

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

* fix: Address feedbacks

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

* chore: code splitting and refactor

---------

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>
Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-08-29 17:28:35 +00:00
Calcom BotandReplexica 54f7c8328d feat: update translations (#16400)
Co-authored-by: Replexica <support@replexica.com>
2024-08-29 17:13:06 +00:00
Keith Williams 36a1715ece perf: run parallel bookings queries (#16398) 2024-08-29 16:37:18 +00:00
Keith Williams edaa6b1de8 feat: Add more AI translation languages (#16381)
* feat: Add more AI translation languages

* Removed pull-request: true

* checking out the PRs i18n.json as well

* Update i18n.json

* Update i18n.json
2024-08-29 17:18:48 +01:00
Calcom BotandReplexica f47b2066c4 feat: update translations (#16396)
Co-authored-by: Replexica <support@replexica.com>
2024-08-29 15:58:25 +00:00
Keith Williams 7337b3de1e fix: GitHub access token for Replexica (#16395) 2024-08-29 15:02:05 +00:00
Keith Williams 933ffcbf69 chore: Move Replexica to run on pushes to main (#16394) 2024-08-29 14:22:42 +00:00
Syed Ali ShahbazandMorgan cf50566a10 feat: Org Webhooks API V2 (#16274)
* --init

* add is webhook in org guard

* --

* doc

* e2e

* --feedback

* --

* fix typo

* delete unnecessary file

* fix v2 e2e

* fix v2 e2e

* fix e2e

* fix v2 e2e

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2024-08-29 17:52:42 +04:00
Sy SagarandAmit Sharma 4424aa5b38 chore: Bulk update desc (#16343)
* chore(locale-en): update bulk modal description

* chore: schedule bulk-modal description

---------

Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>
2024-08-29 08:16:13 -04:00
Udit TakkarandJoe Au-Yeung 19634073e9 refactor: improve team members page performance (#16155)
* fix: simplify workflow page and improve load time

* chore: use new endpoint

* chore: save progress

* refactor: code

* refactor: remove not requried code

* chore: remove schema

* chore: fix typ

* chore: improve

* chore: change name

* chore: remove unused

* chore: remove page

* refactor: teams page

* feat: add auto scroll

* chore: create validate unique invite

* fix: auth check

* fix: optimistic update

* chore

* fix: add loading

* fix: improvements

* chore: remove

* chore

* chore: fix teams page

* fix: team profile page

* fix: appearance page

* fix: sso view

* fix: type err

* feat: defer loading connected Apps

* fix: type err

* fix: type error

* fix: type err

* fix: connectedApps type

* chore: move

* chore: missing export

* feat: add search by name

* fix: display role change

* fix: use setInfiniteData

* chore: save progress

* test: add unit tests for loading members

* fix: test

* chore: update name

* fix: bugs and improvements

* chore: change variable name

* test: add tests for checkCanAccessMembers

* refactor: performance

---------

Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
2024-08-29 13:32:54 +05:30
Keith Williams 4284e86e51 perf: Removed unused attendees count (#16391) 2024-08-29 01:13:48 -04:00
Max Prilutskiy f7c1d62bb8 chore: [skip i18n] switch pr's i18n job to in-branch mode (#16390) 2024-08-29 01:32:03 +00:00
795a228a44 fix: allow all sign in methods when being invited (#13436)
* allow all sign in methods

* changes

* update

* add checks

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
2024-08-28 15:24:13 +00:00
Nicolaos MoscholiosandAnik Dhabal Babu 0190ca6711 fix: use userPhone instead of phone for switch case (#16384)
* fix: use `userPhone` instead of `phone` for switch case

Currently this switch case never gets to the last case ("phone") as the type expected is wrong.

Here the type may have value `userPhone` and not `phone`.

* fix another case

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-08-28 13:37:02 +00:00
Morgan ee8cc81862 fix: api v2 booking controller with api key (#16358)
* fix: api v2 booking controller with api key

* add test

* fixup! add test

* fixup! Merge branch 'fix-booking-controller-api-key' of github.com:calcom/cal.com into fix-booking-controller-api-key

* fixup! Merge branch 'fix-booking-controller-api-key' of github.com:calcom/cal.com into fix-booking-controller-api-key
2024-08-28 16:05:14 +03:00
Joe Au-Yeung 6b244fc5b5 fix: Move future/workflow/[workflow] getStaticProps into separate file (#16124) 2024-08-28 08:53:32 -04:00
Udit Takkarandsean-brydon 356ecfe2fa chore: display first 3 organizers photo in round robin event (#16331)
* chore: display random organizer photo in round robin event

* fix: avatar on instant booking

---------

Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
2024-08-28 12:31:06 +00:00
Keith Williams bd410cf09f chore: Use pull-request feature from Replexica (#16383) 2024-08-28 19:24:56 +09:00
Keith WilliamsandReplexica 3c5ab5e8d2 chore: add replexica to ci (#16306)
* chore: Add replexica for AI i18n

* Add write permission

* moved i18n to repo root

* Added checkout to i18n job

* Added contents:read

* Added actions: write for testing

* Updated contents: write

* feat: add missing translations

* Checking out the i18n files from PR head sha

* Added the rest of the languages

* Added i18n to list of required checks

* Added a comment explaining the custom common.json checkout

* Commented out languages to not crush Replexica usage

* Add a fetch-depth: 2

* Supporting fetch of external fork branch

* Put back the dynamic sha

* Added a check for null so this can run manually and not just on PRs

* Removed comments

* feat: update translations

* Reverted de and fr to re-run with new settings

* feat: update translations

* Reverted de and fr to re-run with new settings

* feat: update translations

* Removed crowdin

* Limited languages to major ones for 1st pass

* feat: update translations

* Fixing es test

* Reverted yarn.lock changes

---------

Co-authored-by: Replexica <support@replexica.com>
2024-08-28 11:16:47 +03:00
Udit Takkar d938e67e65 fix: require confirmation cal video bug (#16376)
* fix: require confirmation cal video bug

* chore: update location

* fix: tests

* chore

* test: add confirm booking test

* chore: add location in test

* fix: types
2024-08-27 22:40:00 +00:00
sean-brydon 6a90518231 use goblin url (#16370) 2024-08-27 22:30:18 +00:00
Benny Joo 47e8907184 chore: App Router Migration - Fix metadata util used in App Router and remove duplicate code (#16346) 2024-08-27 13:28:09 -04:00
Keith Williams d6096f459b chore: v4.4.4 (#16372)
release.yml / release (push) Canceled after 0s
2024-08-27 22:38:34 +09:00
Keith Williams 8839f08257 fix: API v2 E2E Docker rate limiting (#16374) 2024-08-27 12:25:53 +00:00
Hariom Balhara 6104add6bd fix: More Stability for FormBuilder by unit tests (#16129)
* Remove use of location from FormBuilder

* Add tests

* Improve error message in test

* Bust cache
2024-08-27 08:14:19 -04:00
Johannes ZellnerandAnik Dhabal Babu 7aae55f417 fix: Avoid typescript error for Nextjs usePathname() (#16368)
* Avoid typescript error for Nextjs usePathname()

#fixes 16323

* Update Shell.tsx

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-08-27 07:46:55 +00:00
Keith Williams ef1b5067c2 fix: Undefined user in insights data (#16365)
* fix: Null user in insights data

* renaming
2024-08-26 17:43:04 -07:00
Anik Dhabal Babu 4caa095d52 fix: color restrictions issue (#16360)
* fix: color restrictions issue

* Update EventAdvancedTab.tsx
2024-08-27 07:59:09 +09:00
sean-brydonandAlex van Andel c8e20b0c57 feat: block pending meetings for requires confirmation (#16335)
* feat: block pending meetings for requires confirmation

* add i18n

* fix:typecheck

* fix(requires-confirmation-block): Fixes type error

* fix(requires-confirmation-block): Fixes type error

* Tweak to set BookingWhereInput as the explicit type instead of as

* fix: type error

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2024-08-26 09:54:58 -04:00
Anik Dhabal Babu 2fb4289798 feat: tracking and displaying last used login method (#16356) 2024-08-26 12:13:42 +00:00
6529b49c3c feat: bookingFields identifier supports - and _ characters (#16353)
* now bookingFields identifier supports - and _ characters but no special characters

* fix: unit test

---------

Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-08-26 12:15:02 +02:00
758423ecf9 fix: Text for cookie consent checkbox in various languages (#16355)
* Fix text for cookie consent checkbox in various languages

* update

---------

Co-authored-by: unknown <adhabal2002@gmail.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2024-08-26 09:03:20 +00:00
Somay ChauhanandMorgan Vernay ef11755488 feat: add recurringEvent attribute to api/v2/event-types (#16251)
* feat: added recurringEvent attribute to api/v2/event-types

* changed `recurringEvent` to `recurrence`

* Update CHANGELOG.md

* remove /dist/ import

* change `as` to `satisfies`

* testing some changes

* Revert "testing some changes"

This reverts commit 464d2611ed19dbd6ae390332b4d6dd5884bcae3b.

* Update recurrence.input.ts

* fix: apiv2 tsconfig enums

* Update CHANGELOG.md

* removed dev imports

* Update package.json

* Update api-request.spec.ts

* Update api-request.spec.ts

* added recurrence to update-event-type.input

---------

Co-authored-by: Morgan Vernay <morgan@cal.com>
2024-08-26 11:42:35 +03:00
Steve Xu 03051a2bb1 fix: fix some missing translation (#16351) 2024-08-26 10:38:23 +02:00
CodeShakingSheep 7a57f57e99 chore: Make URLs for terms and privacy policy customizable via .env vars (#16350) 2024-08-26 10:34:07 +02:00
b6d31129a3 feat: Add rescheduledBy & canceledBy fields in the DB (#15337)
* feat: Add rescheduledBy & canceledBy fields in the DB

* fix: type check

* fix: type check

* fix: use session user email for reschedule

* fix: unit test

* feat: db field email validation

* feat: rescheduledBy and cancelledBy in webhooks

* revert unrelated changes.

* make session user secondary, default to Anonymous

* if condition not required

* Make cancelledBy optional

Co-authored-by: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com>

* update cancel booking type

* fix: update cancelledBy in db when requesting reschedule

* remove default value for fields

* fix: type check

* feat: manage fields via api v1

* fix: add fields in booking read api v1

* test: expand to cover new fields

* fix: use cancelledBy param on booking page

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2024-08-25 11:31:46 +02:00
Steve XuandAnik Dhabal Babu 34075ee303 fix: multiple_duration_timeUnit_short Chinese translation (#16339)
* fix: multiple_duration_timeUnit_short Chinese translation

* fix: i18n

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-08-25 09:30:33 +00:00
Steve Xu 89f711d929 fix: fixing i18n key signing_up_terms is lost (#16338)
* fix: fixing i18n key signing_up_terms is lost

* fix: fixing i18n key signing_up_terms is lost
2024-08-25 10:52:30 +02:00
623f0c7d93 fix: visiting organization without access shows empty page (#15103)
* fix : visiting organization without access shows empty page

* feat: add serversideprops for navigating user before page load

* Update packages/features/ee/organizations/pages/settings/getServerSidePropsMembers.tsx

* fix: workaround e2e fix

---------

Co-authored-by: Shaik-Sirajuddin <sirajudddinshaik30@gmail.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Hariom <hariombalhara@gmail.com>
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2024-08-24 09:05:50 +00:00
Rajiv SahalandMorgan c67877cc51 chore: rename calendar settings atom (#16329)
* 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

* fixing naming

* fix naming

* rename atom from `SelectedCalendarSettings` to `SelectedCalendarsSettings`

* revert stripe controllers commit

* fixup

* bring back stripe module

* remove stripe atom

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2024-08-23 10:40:22 +00:00
Alex van Andel fd50728169 Revert "fix: uncaught types errors in invite members team handler (#16109)" (#16322)
This reverts commit a091c60618.
2024-08-23 05:40:30 +00:00
Omar López 74567909b2 fix: Migrates E2E reports to external repo (#16326) 2024-08-22 14:28:54 -07:00
sean-brydonandKeith Williams d84f08c998 actually delete the item if no options or input is found (#16298)
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-08-22 23:28:49 +09:00
1389 changed files with 75570 additions and 25543 deletions
+32 -1
View File
@@ -333,7 +333,11 @@ 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_SECURITY_ENABLED=0
APP_ROUTER_SETTINGS_ADMIN_ENABLED=0
APP_ROUTER_SETTINGS_PLATFORM_ENABLED=0
APP_ROUTER_SETTINGS_ORG_ENABLED=0
APP_ROUTER_APPS_INSTALLED_CATEGORY_ENABLED=0
APP_ROUTER_APPS_SLUG_ENABLED=0
APP_ROUTER_APPS_SLUG_SETUP_ENABLED=0
@@ -341,13 +345,28 @@ 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
APP_ROUTER_AUTH_LOGIN_ENABLED=0
APP_ROUTER_AUTH_LOGOUT_ENABLED=0
APP_ROUTER_AUTH_NEW_ENABLED=0
APP_ROUTER_AUTH_SAML_ENABLED=0
APP_ROUTER_AUTH_ERROR_ENABLED=0
APP_ROUTER_AUTH_PLATFORM_ENABLED=0
APP_ROUTER_AUTH_OAUTH2_ENABLED=0
APP_ROUTER_MORE_ENABLED=0
APP_ROUTER_MAINTENANCE_ENABLED=0
APP_ROUTER_UPGRADE_ENABLED=0
APP_ROUTER_CONNECT_AND_JOIN_ENABLED=0
# disable setry server source maps
SENTRY_DISABLE_SERVER_WEBPACK_PLUGIN=1
@@ -362,10 +381,22 @@ TASKER_ENABLE_EMAILS=0
# Ratelimiting via unkey
UNKEY_ROOT_KEY=
# Used for Cal.ai Enterprise Voice AI Agents
# https://retellai.com
RETELL_AI_KEY=
# Used to disallow emails as being added as guests on bookings
BLACKLISTED_GUEST_EMAILS=
# Used to allow browser push notifications
# You can use: 'npx web-push generate-vapid-keys' to generate these keys
NEXT_PUBLIC_VAPID_PUBLIC_KEY=
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=
+39
View File
@@ -0,0 +1,39 @@
---
name: Bug report
about: Report any issues with the platform
title: ""
labels: ["🐛 bug"]
assignees: ""
---
Found a bug? Please fill out the sections below. 👍
### Issue Summary
A summary of the issue. This needs to be a clear detailed-rich summary.
### Steps to Reproduce
1. (for example) Went to ...
2. Clicked on...
3. ...
Any other relevant information. For example, why do you consider this a bug and what did you expect to happen instead?
### Actual Results
- What's happening right now that is different from what is expected
### Expected Results
- This is an ideal result that the system should get after the tests are performed
### Technical details
- Browser version, screen recording, console logs, network requests: You can make a recording with [Bird Eats Bug](https://birdeatsbug.com/).
- Node.js version
- Anything else that you think could be an issue.
### Evidence
- How was this tested? This is quite mandatory in terms of bugs. Providing evidence of your testing with screenshots or/and videos is an amazing way to prove the bug and a troubleshooting chance to find the solution.
-81
View File
@@ -1,81 +0,0 @@
name: Bug report
description: "Please make sure you have checked for any duplicate issues."
title: "(bug)"
labels: ["🐛 bug"]
assignees: []
body:
- type: textarea
id: issue-summary
attributes:
label: Issue Summary
description: A summary of the issue. This needs to be a clear detailed-rich summary.
validations:
required: true
- type: textarea
id: steps-to-reproduce
attributes:
label: Steps to Reproduce
value: |
1. (for example) Went to ...
2. Clicked on...
3. ...
validations:
required: true
- type: textarea
id: expected-behavior
attributes:
label: Expected behavior
description: A clear and concise description of what you expected to happen.
validations:
required: true
- type: textarea
id: other-information
attributes:
label: Other information
validations:
required: false
- type: textarea
id: screenshots
attributes:
label: Screenshots
description: If applicable, add screenshots to help explain your problem.
validations:
required: false
- type: checkboxes
id: environment
attributes:
label: Environment
options:
- label: app.cal.com
- label: Self-hosted Cal
- type: textarea
id: desktop-version
attributes:
label: Desktop (please complete the following information)
description: |
examples:
- **OS**: [e.g. iOS]
- **Browser**: [e.g. chrome, safari]
- **Version**: [e.g. 22]
value: |
- OS:
- Node:
- npm:
render: markdown
validations:
required: true
- type: markdown
id: nodejs-version
attributes:
value: |
#### Node.JS version
[e.g. v18.15.0]
- type: markdown
id: anything-else
attributes:
value: |
#### Anything else?
- Screen recording, console logs, network requests: You can make a recording with [Loom](https://www.loom.com).
- Anything else that you think could be an issue?
@@ -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.
+7
View File
@@ -4,6 +4,11 @@ on:
delete:
branches-ignore: [main, gh-pages]
permissions:
contents: write
issues: write
pull-requests: write
# ensures that currently running Playwright workflow of deleted branch gets cancelled
concurrency:
group: ${{ github.event.ref }}
@@ -20,7 +25,9 @@ jobs:
- name: Checkout GitHub Pages Branch
uses: actions/checkout@v2
with:
repository: calcom/test-results
ref: gh-pages
token: ${{ secrets.GH_ACCESS_TOKEN }}
- name: Set Git User
# see: https://github.com/actions/checkout/issues/13#issuecomment-724415212
run: |
-42
View File
@@ -1,42 +0,0 @@
name: Crowdin Action
on:
push:
branches:
- main
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
synchronize-with-crowdin:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
token: ${{ secrets.GH_ACCESS_TOKEN }}
- name: crowdin action
uses: crowdin/github-action@v1.13.0
with:
# upload sources
upload_sources: true
# upload translations (& auto-approve 'em)
upload_translations_args: '--auto-approve-imported'
upload_translations: true
push_translations: true
# download translations
download_translations: true
# GH config
commit_message: "New Crowdin translations by Github Action"
localization_branch_name: main
create_pull_request: false
env:
GITHUB_TOKEN: ${{ secrets.GH_ACCESS_TOKEN }}
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
+4 -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 }}
@@ -45,6 +45,9 @@ jobs:
- 5432:5432
redis:
image: redis:latest
credentials:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
ports:
- 6379:6379
options: >-
+25
View File
@@ -0,0 +1,25 @@
name: Run i18n AI automation
on:
push:
branches:
- main
concurrency:
group: ${{ github.workflow }}-main
cancel-in-progress: false
jobs:
i18n:
name: Run i18n
runs-on: buildjet-2vcpu-ubuntu-2204
permissions:
actions: write
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v4
- uses: replexica/replexica@main
env:
GH_TOKEN: ${{ secrets.GH_ACCESS_TOKEN }}
with:
api-key: ${{ secrets.CI_REPLEXICA_API_KEY }}
pull-request: true
-28
View File
@@ -1,28 +0,0 @@
name: PR Reviewed
on:
pull_request_review:
types: [submitted]
jobs:
label-pr:
runs-on: ubuntu-latest
permissions:
actions: read
issues: write
pull-requests: write
steps:
- name: Label PR as ready for E2E
if: github.event.review.state == 'approved'
uses: actions/github-script@v4
with:
script: |
await github.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
labels: process.env.labels.split(', '),
})
env:
github_token: ${{ secrets.READY_FOR_E2E_PAT }}
labels: 'ready-for-e2e'
+2 -1
View File
@@ -131,7 +131,7 @@ jobs:
if: ${{ needs.check-label.outputs.run-e2e == 'true' && needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
uses: ./.github/workflows/api-v2-production-build.yml
secrets: inherit
build-atoms:
name: Production builds
needs: [changes, check-label, deps]
@@ -224,6 +224,7 @@ jobs:
build,
build-api-v1,
build-api-v2,
build-atoms,
e2e,
e2e-api-v2,
e2e-embed,
+3 -1
View File
@@ -16,7 +16,9 @@ jobs:
- name: Checkout GitHub Pages Branch
uses: actions/checkout@v4
with:
repository: calcom/test-results
ref: gh-pages
token: ${{ secrets.GH_ACCESS_TOKEN }}
- name: Set Git User
# see: https://github.com/actions/checkout/issues/13#issuecomment-724415212
run: |
@@ -65,7 +67,7 @@ jobs:
- name: Output Report URL as Workflow Annotation
id: url
run: |
FULL_HTML_REPORT_URL=https://calcom.github.io/cal.com/$HTML_REPORT_URL_PATH
FULL_HTML_REPORT_URL=https://calcom.github.io/test-results/$HTML_REPORT_URL_PATH
echo "::notice title=📋 Published Playwright Test Report::$FULL_HTML_REPORT_URL"
echo "link=$FULL_HTML_REPORT_URL" >> $GITHUB_OUTPUT
- name: Find Comment
+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
+31 -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";
@@ -14,6 +21,8 @@ const schemaBookingBaseBodyParams = Booking.pick({
startTime: true,
endTime: true,
status: true,
rescheduledBy: true,
cancelledBy: true,
}).partial();
export const schemaBookingCreateBodyParams = extendedBookingCreateBody.merge(schemaQueryUserId.partial());
@@ -33,6 +42,8 @@ const schemaBookingEditParams = z
title: z.string().optional(),
startTime: iso8601.optional(),
endTime: iso8601.optional(),
cancelledBy: z.string().email({ message: "Invalid Email" }).optional(),
rescheduledBy: z.string().email({ message: "Invalid Email" }).optional(),
// Not supporting responses in edit as that might require re-triggering emails
// responses
})
@@ -42,7 +53,23 @@ 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({
@@ -83,9 +110,12 @@ export const schemaBookingReadPublic = Booking.extend({
timeZone: true,
attendees: true,
user: true,
eventType: true,
payment: true,
metadata: true,
status: true,
responses: true,
fromReschedule: true,
cancelledBy: true,
rescheduledBy: true,
});
@@ -168,6 +168,7 @@ export const schemaEventTypeReadPublic = EventType.pick({
metadata: jsonSchema.nullable(),
customInputs: customInputSchema.array().optional(),
link: z.string().optional(),
hashedLink: z.object({ link: z.string() }).optional().nullable(),
bookingFields: eventTypeBookingFields.optional().nullable(),
})
);
@@ -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
View File
@@ -10,6 +10,7 @@ export const schemaTeamBaseBodyParams = Team.omit({ id: true, createdAt: true })
isPlatform: true,
smsLockState: true,
smsLockReviewedByAdmin: true,
bookingLimits: true,
});
const schemaTeamRequiredParams = z.object({
+1 -1
View File
@@ -30,7 +30,7 @@ enum locales {
RO = "ro",
NL = "nl",
PT_BR = "pt-BR",
// ES_419 = "es-419", // Disabled until Crowdin reaches at least 80% completion
ES_419 = "es-419",
KO = "ko",
JA = "ja",
PL = "pl",
@@ -30,40 +30,59 @@ async function authMiddleware(req: NextApiRequest) {
}
}
const userWithBookingsAndTeamIds = await prisma.user.findUnique({
const user = await prisma.user.findUnique({
where: { id: userId },
include: {
select: {
email: true,
bookings: true,
teams: {
select: {
teamId: true,
},
});
if (!user) throw new HttpError({ statusCode: 404, message: "User not found" });
const filteredBookings = user?.bookings?.filter((booking) => booking.id === id);
const userIsHost = !!filteredBookings?.length;
const bookingsAsAttendee = prisma.booking.findMany({
where: {
id,
attendees: { some: { email: user.email } },
},
});
const bookingsAsEventTypeOwner = prisma.booking.findMany({
where: {
id,
eventType: {
owner: { id: userId },
},
},
});
const bookingsAsTeamOwnerOrAdmin = prisma.booking.findMany({
where: {
id,
eventType: {
team: {
members: {
some: { userId, role: { in: ["ADMIN", "OWNER"] }, accepted: true },
},
},
},
},
});
if (!userWithBookingsAndTeamIds) throw new HttpError({ statusCode: 404, message: "User not found" });
const [resultOne, resultTwo, resultThree] = await Promise.all([
bookingsAsAttendee,
bookingsAsEventTypeOwner,
bookingsAsTeamOwnerOrAdmin,
]);
const userBookingIds = userWithBookingsAndTeamIds.bookings.map((booking) => booking.id);
const teamBookingsAsOwnerOrAdmin = [...resultOne, ...resultTwo, ...resultThree];
const userHasTeamBookings = !!teamBookingsAsOwnerOrAdmin.length;
if (!userBookingIds.includes(id)) {
const teamBookings = await prisma.booking.findUnique({
where: {
id: id,
eventType: {
team: {
id: {
in: userWithBookingsAndTeamIds.teams.map((team) => team.teamId),
},
},
},
},
});
if (!teamBookings) {
throw new HttpError({ statusCode: 403, message: "You are not authorized" });
}
}
if (!userIsHost && !userHasTeamBookings)
throw new HttpError({ statusCode: 403, message: "You are not authorized" });
}
export default authMiddleware;
+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);
@@ -6,6 +6,9 @@ import prisma from "@calcom/prisma";
import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt";
import checkParentEventOwnership from "../_utils/checkParentEventOwnership";
import checkTeamEventEditPermission from "../_utils/checkTeamEventEditPermission";
/**
* @swagger
* /event-types/{id}:
@@ -49,9 +52,18 @@ async function checkPermissions(req: NextApiRequest) {
const { userId, isSystemWideAdmin } = req;
const { id } = schemaQueryIdParseInt.parse(req.query);
if (isSystemWideAdmin) return;
/** Only event type owners can delete it */
const eventType = await prisma.eventType.findFirst({ where: { id, userId } });
const eventType = await prisma.eventType.findFirst({ where: { id } });
if (!eventType) throw new HttpError({ statusCode: 403, message: "Forbidden" });
/** Only event type owners or team owners for team events can delete it */
if (eventType.teamId) return await checkTeamEventEditPermission(req, { teamId: eventType.teamId });
if (eventType.parentId) return await checkParentEventOwnership(req);
if (eventType.userId && eventType.userId !== userId)
throw new HttpError({ statusCode: 403, message: "Forbidden" });
}
export default defaultResponder(deleteHandler);
@@ -51,6 +51,7 @@ export async function getHandler(req: NextApiRequest) {
where: { id },
include: {
customInputs: true,
hashedLink: { select: { link: true } },
team: { select: { slug: true } },
hosts: { select: { userId: true, isFixed: true } },
owner: { select: { username: true, id: true } },
@@ -56,6 +56,7 @@ async function getHandler(req: NextApiRequest) {
},
include: {
customInputs: true,
hashedLink: { select: { link: true } },
team: { select: { slug: true } },
hosts: { select: { userId: true, isFixed: true } },
owner: { select: { username: true, id: true } },
@@ -18,12 +18,8 @@ export default async function checkParentEventOwnership(req: NextApiRequest) {
/** These are already parsed upstream, we can assume they're good here. */
const parentId = Number(body.parentId);
const parentEventType = await prisma.eventType.findUnique({
where: {
id: parentId,
},
select: {
teamId: true,
},
where: { id: parentId },
select: { teamId: true },
});
if (!parentEventType) {
@@ -44,7 +40,8 @@ export default async function checkParentEventOwnership(req: NextApiRequest) {
where: {
teamId: parentEventType.teamId,
userId: userId,
OR: [{ role: "OWNER" }, { role: "ADMIN" }],
role: { in: ["ADMIN", "OWNER"] },
accepted: true,
},
});
@@ -10,21 +10,17 @@ export default async function checkTeamEventEditPermission(
req: NextApiRequest,
body: Pick<z.infer<typeof schemaEventTypeCreateBodyParams>, "teamId" | "userId">
) {
const { isSystemWideAdmin } = req;
let userId = req.userId;
if (isSystemWideAdmin && body.userId) {
userId = body.userId;
}
if (body.teamId) {
const membership = await prisma.membership.findFirst({
where: {
userId,
userId: req.userId,
teamId: body.teamId,
accepted: true,
role: { in: ["ADMIN", "OWNER"] },
},
});
if (!membership?.role || !["ADMIN", "OWNER"].includes(membership.role)) {
if (!membership) {
throw new HttpError({
statusCode: 403,
message: "No permission to operate on event-type for this team",
+11 -2
View File
@@ -3,6 +3,7 @@ import utc from "dayjs/plugin/utc";
import type { NextApiRequest, NextApiResponse } from "next";
import dayjs from "@calcom/dayjs";
import { IS_PRODUCTION } from "@calcom/lib/constants";
import { isSupportedTimeZone } from "@calcom/lib/date-fns";
import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server";
@@ -17,14 +18,22 @@ import { getHTTPStatusCodeFromError } from "@trpc/server/http";
dayjs.extend(utc);
dayjs.extend(timezone);
let isColdStart = true;
async function handler(req: NextApiRequest, res: NextApiResponse) {
try {
const { usernameList, ...rest } = req.query;
if (isColdStart && IS_PRODUCTION) {
console.log("Cold start of /v1/slots detected");
isColdStart = false;
}
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
@@ -41,7 +41,7 @@ export async function canUserAccessTeamWithRole(
/** If not ADMIN then we check if the actual user belongs to team and matches the required role */
if (!isSystemWideAdmin) args.where = { ...args.where, members: { some: { userId, role } } };
const team = await prisma.team.findFirst(args);
if (!team) throw new HttpError({ statusCode: 401, message: `Unauthorized: ${role.toString()} required` });
if (!team) throw new HttpError({ statusCode: 401, message: `Unauthorized: OWNER or ADMIN role required` });
return team;
}
@@ -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 });
+3
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,
};
@@ -215,6 +217,7 @@ const generateTeamCheckoutSession = async ({
metadata: {
pendingPaymentTeamId,
ownerId,
dubCustomerId: ownerId, // pass the userId during checkout creation for sales conversion tracking: https://d.to/conversions/stripe
},
});
-3
View File
@@ -1,6 +1,3 @@
# Set the version of docker compose to use
version: "3.9"
# The containers that compose the project
services:
db:
@@ -1,90 +1,332 @@
import prismock from "../../../../../../tests/libs/__mocks__/prisma";
import type { Request, Response } from "express";
import type { NextApiRequest, NextApiResponse } from "next";
import { createMocks } from "node-mocks-http";
import { describe, it, expect } from "vitest";
import { describe, it, expect, test } from "vitest";
import type { HttpError } from "@calcom/lib/http-error";
import prisma from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import authMiddleware from "../../../pages/api/bookings/[id]/_auth-middleware";
type CustomNextApiRequest = NextApiRequest & Request;
type CustomNextApiResponse = NextApiResponse & Response;
describe("Bookings auth middleware", () => {
it("Returns 403 when user has no permission to the booking", async () => {
const trialUser = await prisma.user.findFirstOrThrow({ where: { email: "trial@example.com" } });
const proUser = await prisma.user.findFirstOrThrow({ where: { email: "pro@example.com" } });
const booking = await prisma.booking.findFirstOrThrow({ where: { userId: proUser.id } });
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
body: {},
query: {
id: booking.id,
describe("Booking ownership and access in Middleware", () => {
const adminUserId = 1;
const ownerUserId = 2;
const memberUserId = 3;
const orgOwnerUserId = 4;
const adminUserEmail = "admin@example.com";
const ownerUserEmail = "owner@example.com";
const memberUserEmail = "member@example.com";
const orgOwnerUserEmail = "org-owner@example.com";
// mock user data
function buildMockData() {
//Create Users
prismock.user.create({
data: {
id: adminUserId,
username: "admin",
name: "Admin User",
email: adminUserEmail,
},
});
prismock.user.create({
data: {
id: ownerUserId,
username: "owner",
name: "Owner User",
email: ownerUserEmail,
},
});
prismock.user.create({
data: {
id: orgOwnerUserId,
username: "org-owner",
name: "Org Owner",
email: orgOwnerUserEmail,
},
});
prismock.user.create({
data: {
id: memberUserId,
username: "member",
name: "Member User",
email: memberUserEmail,
bookings: {
create: {
id: 2,
uid: "2",
title: "Booking 2",
eventTypeId: 1,
startTime: "2024-08-30T06:45:00.000Z",
endTime: "2024-08-30T07:45:00.000Z",
attendees: {
create: {
name: "Member User",
email: memberUserEmail,
timeZone: "UTC",
},
},
},
},
},
});
//create team
prismock.team.create({
data: {
id: 1,
name: "Team 1",
slug: "team1",
members: {
createMany: {
data: [
{
userId: adminUserId,
role: MembershipRole.ADMIN,
accepted: true,
},
{
userId: ownerUserId,
role: MembershipRole.OWNER,
accepted: true,
},
{
userId: memberUserId,
role: MembershipRole.MEMBER,
accepted: true,
},
],
},
},
},
});
//create Org
prismock.team.create({
data: {
id: 2,
name: "Org",
slug: "org",
isOrganization: true,
children: {
connect: {
id: 1,
},
},
members: {
createMany: {
data: [
{
userId: orgOwnerUserId,
role: MembershipRole.OWNER,
accepted: true,
},
{
userId: memberUserId,
role: MembershipRole.MEMBER,
accepted: true,
},
{
userId: ownerUserId,
role: MembershipRole.MEMBER,
accepted: true,
},
{
userId: adminUserId,
role: MembershipRole.MEMBER,
accepted: true,
},
],
},
},
},
});
//create eventTypes
prismock.eventType.create({
data: {
id: 1,
title: "Event 1",
slug: "event",
length: 60,
bookings: {
connect: {
id: 2,
},
},
},
});
prismock.eventType.create({
data: {
id: 2,
title: "Event 2",
slug: "event",
length: 60,
teamId: 1,
bookings: {
connect: {
id: 1,
},
},
},
});
//link eventType to teams
prismock.eventType.update({
where: {
id: 1,
},
data: {
owner: {
connect: {
id: ownerUserId,
},
},
},
});
prismock.eventType.update({
where: {
id: 2,
},
data: {
team: {
connect: {
id: 1,
},
},
},
});
//link team to org
prismock.team.update({
where: {
id: 1,
},
data: {
parentId: 2,
},
});
// Call Prisma to create booking with attendees
prismock.booking.create({
data: {
id: 1,
uid: "1",
title: "Booking 1",
userId: 1,
startTime: "2024-08-30T06:45:00.000Z",
endTime: "2024-08-30T07:45:00.000Z",
eventTypeId: 2,
attendees: {
create: {
name: "Admin User",
email: adminUserEmail,
timeZone: "UTC",
},
},
},
});
}
req.userId = trialUser.id;
try {
await authMiddleware(req);
} catch (error) {
const httpError = error as HttpError;
expect(httpError.statusCode).toBe(403);
}
test("should not throw error for bookings where user is an attendee", async () => {
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
query: {
id: 2,
},
prisma: prismock,
});
buildMockData();
req.userId = memberUserId;
await expect(authMiddleware(req)).resolves.not.toThrow();
});
it("No error is thrown when user is the booking user", async () => {
const proUser = await prisma.user.findFirstOrThrow({ where: { email: "pro@example.com" } });
const booking = await prisma.booking.findFirstOrThrow({ where: { userId: proUser.id } });
test("should throw error for bookings where user is not an attendee", async () => {
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
body: {},
query: {
id: booking.id,
id: 1,
},
prisma: prismock,
});
buildMockData();
req.userId = memberUserId;
req.userId = proUser.id;
await authMiddleware(req);
await expect(authMiddleware(req)).rejects.toThrow();
});
it("No error is thrown when user is system-wide admin", async () => {
const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "admin@example.com" } });
const proUser = await prisma.user.findFirstOrThrow({ where: { email: "pro@example.com" } });
const booking = await prisma.booking.findFirstOrThrow({ where: { userId: proUser.id } });
test("should not throw error for booking where user is the event type owner", async () => {
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
body: {},
query: {
id: booking.id,
id: 2,
},
prisma: prismock,
});
buildMockData();
req.userId = ownerUserId;
await expect(authMiddleware(req)).resolves.not.toThrow();
});
req.userId = adminUser.id;
test("should not throw error for booking where user is team owner or admin", async () => {
const { req: req1 } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
query: {
id: 1,
},
prisma: prismock,
});
const { req: req2 } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
query: {
id: 1,
},
prisma: prismock,
});
buildMockData();
req1.userId = adminUserId;
req2.userId = ownerUserId;
await expect(authMiddleware(req1)).resolves.not.toThrow();
await expect(authMiddleware(req2)).resolves.not.toThrow();
});
test("should throw error for booking where user is not team owner or admin", async () => {
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
query: {
id: 1,
},
prisma: prismock,
});
buildMockData();
req.userId = memberUserId;
await expect(authMiddleware(req)).rejects.toThrow();
});
test("should not throw error when user is system-wide admin", async () => {
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
query: {
id: 2,
},
prisma: prismock,
});
buildMockData();
req.userId = adminUserId;
req.isSystemWideAdmin = true;
await authMiddleware(req);
});
it("No error is thrown when user is org-wide admin", async () => {
const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "owner1-acme@example.com" } });
const memberUser = await prisma.user.findFirstOrThrow({ where: { email: "member1-acme@example.com" } });
const booking = await prisma.booking.findFirstOrThrow({ where: { userId: memberUser.id } });
it("should throw error when user is org-wide admin", async () => {
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
body: {},
query: {
id: booking.id,
id: 1,
},
prisma: prismock,
});
req.userId = adminUser.id;
buildMockData();
req.userId = orgOwnerUserId;
req.isOrganizationOwnerOrAdmin = true;
await authMiddleware(req);
@@ -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);
});
});
});
});
@@ -0,0 +1,94 @@
import prismaMock from "../../../../../../../tests/libs/__mocks__/prismaMock";
import type { Request, Response } from "express";
import type { NextApiRequest, NextApiResponse } from "next";
import { createMocks } from "node-mocks-http";
import { describe, expect, test, beforeEach, vi } from "vitest";
import { buildEventType } from "@calcom/lib/test/builder";
import { MembershipRole } from "@calcom/prisma/enums";
import handler from "../../../../pages/api/event-types/[id]/_delete";
type CustomNextApiRequest = NextApiRequest & Request;
type CustomNextApiResponse = NextApiResponse & Response;
describe("DELETE /api/event-types/[id]", () => {
const eventTypeId = 1234567;
const teamId = 9999;
const adminUser = 1111;
const memberUser = 2222;
beforeEach(() => {
vi.resetAllMocks();
// Mocking membership.findFirst
prismaMock.membership.findFirst.mockImplementation(({ where }) => {
const { userId, teamId, accepted, role } = where;
const mockData = [
{ userId: 1111, teamId: teamId, accepted: true, role: MembershipRole.ADMIN },
{ userId: 2222, teamId: teamId, accepted: true, role: MembershipRole.MEMBER },
];
// Return the correct user based on the query conditions
return mockData.find(
(membership) =>
membership.userId === userId &&
membership.teamId === teamId &&
membership.accepted === accepted &&
role.in.includes(membership.role)
);
});
// Mocking eventType.findFirst
prismaMock.eventType.findFirst.mockResolvedValue(
buildEventType({
id: eventTypeId,
teamId,
})
);
// Mocking team.findUnique
prismaMock.team.findUnique.mockResolvedValue({
id: teamId,
members: [
{ userId: memberUser, role: MembershipRole.MEMBER, teamId: teamId },
{ userId: adminUser, role: MembershipRole.ADMIN, teamId: teamId },
],
});
});
describe("Error", async () => {
test("Fails to remove event type if user is not OWNER/ADMIN of team associated with event type", async () => {
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "DELETE",
body: {},
query: {
id: eventTypeId,
},
});
// Assign userId to the request objects
req.userId = memberUser;
await handler(req, res);
expect(res.statusCode).toBe(403); // Check if the deletion was successful
});
});
describe("Success", async () => {
test("Removes event type if user is owner of team associated with event type", async () => {
// Mocks for DELETE request
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "DELETE",
body: {},
query: {
id: eventTypeId,
},
});
// Assign userId to the request objects
req.userId = adminUser;
await handler(req, res);
expect(res.statusCode).toBe(200); // Check if the deletion was successful
});
});
});
+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
-2
View File
@@ -1,5 +1,3 @@
version: '3.8'
services:
redis:
image: redis:latest
+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 -3
View File
@@ -10,15 +10,17 @@
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"dev:build:watch": "yarn workspace @calcom/platform-constants build:watch & yarn workspace @calcom/platform-utils build:watch & yarn workspace @calcom/platform-types build:watch",
"dev:build": "yarn workspace @calcom/platform-constants build && yarn workspace @calcom/platform-utils build && yarn workspace @calcom/platform-types build && yarn workspace @calcom/platform-enums build",
"dev:build": "yarn workspace @calcom/platform-constants build && yarn workspace @calcom/platform-enums build && yarn workspace @calcom/platform-utils build && yarn workspace @calcom/platform-types build ",
"dev": "yarn dev:build && docker-compose up -d && yarn copy-swagger-module && yarn start --watch",
"dev:no-docker": "yarn dev:build && yarn copy-swagger-module && yarn start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node ./dist/apps/api/v2/src/main.js",
"test": "yarn dev:build && jest",
"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",
"copy-swagger-module": "ts-node -r tsconfig-paths/register swagger/copy-swagger-module.ts"
@@ -26,7 +28,7 @@
"dependencies": {
"@calcom/platform-constants": "*",
"@calcom/platform-enums": "*",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.28",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.40",
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
+4 -2
View File
@@ -1,4 +1,5 @@
import appConfig from "@/config/app";
import { CustomThrottlerGuard } from "@/lib/throttler-guard";
import { AppLoggerMiddleware } from "@/middleware/app.logger.middleware";
import { RewriterMiddleware } from "@/middleware/app.rewrites.middleware";
import { JsonBodyMiddleware } from "@/middleware/body/json.body.middleware";
@@ -15,7 +16,7 @@ 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 { seconds, ThrottlerModule } from "@nestjs/throttler";
import { ThrottlerStorageRedisService } from "nestjs-throttler-storage-redis";
import { AppController } from "./app.controller";
@@ -32,6 +33,7 @@ import { AppController } from "./app.controller";
BullModule.forRoot({
redis: `${process.env.REDIS_URL}${process.env.NODE_ENV === "production" ? "?tls=true" : ""}`,
}),
// Rate limiting here is handled by the CustomThrottlerGuard
ThrottlerModule.forRootAsync({
imports: [RedisModule],
inject: [RedisService],
@@ -59,7 +61,7 @@ import { AppController } from "./app.controller";
},
{
provide: APP_GUARD,
useClass: ThrottlerGuard,
useClass: CustomThrottlerGuard,
},
],
})
@@ -1,4 +1,5 @@
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";
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
@@ -10,7 +11,7 @@ import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule, RedisModule, TokensModule, BillingModule],
providers: [TokensRepository, OAuthFlowService, OAuthClientRepository],
controllers: [BookingsController],
providers: [TokensRepository, OAuthFlowService, OAuthClientRepository, ApiKeyRepository],
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";
@@ -14,6 +14,7 @@ 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 { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
@@ -21,9 +22,9 @@ import { withApiAuth } from "test/utils/withApiAuth";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
import { handleNewBooking } from "@calcom/platform-libraries";
import { ApiSuccessResponse, ApiResponse } from "@calcom/platform-types";
import { ApiSuccessResponse, ApiResponse, ApiErrorResponse } from "@calcom/platform-types";
describe("Bookings Endpoints", () => {
describe("Bookings Endpoints 2024-04-15", () => {
describe("User Authenticated", () => {
let app: INestApplication;
@@ -31,6 +32,8 @@ describe("Bookings Endpoints", () => {
let bookingsRepositoryFixture: BookingsRepositoryFixture;
let schedulesService: SchedulesService_2024_04_15;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
let apiKeyString: string;
const userEmail = "bookings-controller-e2e@api.com";
let user: User;
@@ -56,11 +59,13 @@ describe("Bookings Endpoints", () => {
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
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",
@@ -106,7 +111,7 @@ describe("Bookings Endpoints", () => {
guests: [],
};
const body: CreateBookingInput = {
const body: CreateBookingInput_2024_04_15 = {
start: bookingStart,
end: bookingEnd,
eventTypeId: bookingEventTypeId,
@@ -139,6 +144,105 @@ describe("Bookings Endpoints", () => {
});
});
it("should fail to create a booking with no_available_users_found_error", async () => {
const bookingStart = "2040-05-21T09:30:00.000Z";
const bookingEnd = "2040-05-21T10:30:00.000Z";
const bookingEventTypeId = eventTypeId;
const bookingTimeZone = "Europe/London";
const bookingLanguage = "en";
const bookingHashedLink = "";
const bookingMetadata = {
timeFormat: "12",
meetingType: "organizer-phone",
};
const bookingResponses = {
name: "tester",
email: "tester@example.com",
location: {
value: "link",
optionValue: "",
},
notes: "test",
guests: [],
};
const body: CreateBookingInput_2024_04_15 = {
start: bookingStart,
end: bookingEnd,
eventTypeId: bookingEventTypeId,
timeZone: bookingTimeZone,
language: bookingLanguage,
metadata: bookingMetadata,
hashedLink: bookingHashedLink,
responses: bookingResponses,
};
return request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.expect(400)
.then(async (response) => {
const responseBody: ApiErrorResponse = response.body;
expect(responseBody.error.message).toEqual("no_available_users_found_error");
});
});
it("should create a booking with api key to get owner id", async () => {
const bookingStart = "2040-05-22T09:30:00.000Z";
const bookingEnd = "2040-05-22T10:30:00.000Z";
const bookingEventTypeId = eventTypeId;
const bookingTimeZone = "Europe/London";
const bookingLanguage = "en";
const bookingHashedLink = "";
const bookingMetadata = {
timeFormat: "12",
meetingType: "organizer-phone",
};
const bookingResponses = {
name: "tester",
email: "tester@example.com",
location: {
value: "link",
optionValue: "",
},
notes: "test",
guests: [],
};
const body: CreateBookingInput_2024_04_15 = {
start: bookingStart,
end: bookingEnd,
eventTypeId: bookingEventTypeId,
timeZone: bookingTimeZone,
language: bookingLanguage,
metadata: bookingMetadata,
hashedLink: bookingHashedLink,
responses: bookingResponses,
};
return request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.expect(201)
.then(async (response) => {
const responseBody: ApiSuccessResponse<Awaited<ReturnType<typeof handleNewBooking>>> =
response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.userPrimaryEmail).toBeDefined();
expect(responseBody.data.userPrimaryEmail).toEqual(userEmail);
expect(responseBody.data.id).toBeDefined();
expect(responseBody.data.uid).toBeDefined();
expect(responseBody.data.startTime).toEqual(bookingStart);
expect(responseBody.data.eventTypeId).toEqual(bookingEventTypeId);
expect(responseBody.data.user.timeZone).toEqual(bookingTimeZone);
expect(responseBody.data.metadata).toEqual(bookingMetadata);
createdBooking = responseBody.data;
});
});
describe("should reschedule a booking", () => {
it("should reschedule with updated start time, end time & metadata", async () => {
const newBookingStart = "2040-05-21T12:30:00.000Z";
@@ -162,7 +266,7 @@ describe("Bookings Endpoints", () => {
guests: [],
};
const body: CreateBookingInput = {
const body: CreateBookingInput_2024_04_15 = {
rescheduleUid: createdBooking.uid,
start: newBookingStart,
end: newBookingEnd,
@@ -201,19 +305,23 @@ describe("Bookings Endpoints", () => {
return request(app.getHttpServer())
.get("/v2/bookings?filters[status]=upcoming")
.then((response) => {
const responseBody: GetBookingsOutput = response.body;
const fetchedBooking = responseBody.data.bookings[0];
const responseBody: GetBookingsOutput_2024_04_15 = response.body;
expect(responseBody.data.bookings.length).toEqual(1);
expect(responseBody.data.bookings.length).toEqual(2);
const fetchedBooking = responseBody.data.bookings.find(
(booking) => booking.id === createdBooking.id
);
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(fetchedBooking).toBeDefined();
expect(fetchedBooking.id).toEqual(createdBooking.id);
expect(fetchedBooking.uid).toEqual(createdBooking.uid);
expect(fetchedBooking.startTime).toEqual(createdBooking.startTime);
expect(fetchedBooking.endTime).toEqual(createdBooking.endTime);
expect(fetchedBooking.user?.email).toEqual(userEmail);
if (fetchedBooking) {
expect(fetchedBooking.id).toEqual(createdBooking.id);
expect(fetchedBooking.uid).toEqual(createdBooking.uid);
expect(fetchedBooking.startTime).toEqual(createdBooking.startTime);
expect(fetchedBooking.endTime).toEqual(createdBooking.endTime);
expect(fetchedBooking.user?.email).toEqual(userEmail);
}
});
});
@@ -221,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,10 +1,12 @@
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 { API_VERSIONS_VALUES } from "@/lib/api-versions";
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 { 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";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
@@ -28,26 +30,33 @@ import {
NotFoundException,
UseGuards,
} from "@nestjs/common";
import { ApiQuery, ApiTags as DocsTags } from "@nestjs/swagger";
import { ConfigService } from "@nestjs/config";
import { ApiQuery, ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
import { User } from "@prisma/client";
import { Request } from "express";
import { NextApiRequest } from "next/types";
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,
getBookingInfo,
handleCancelBooking,
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";
@@ -75,33 +84,35 @@ 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(
private readonly oAuthFlowService: OAuthFlowService,
private readonly prismaReadService: PrismaReadService,
private readonly oAuthClientRepository: OAuthClientRepository,
private readonly billingService: BillingService
private readonly billingService: BillingService,
private readonly config: ConfigService,
private readonly apiKeyRepository: ApiKeyRepository
) {}
@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,
@@ -118,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) {
@@ -148,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();
@@ -179,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();
@@ -212,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,
@@ -233,13 +244,20 @@ 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();
try {
const recurringEventId = uuidv4();
for (const recurringEvent of req.body) {
if (!recurringEvent.recurringEventId) {
recurringEvent.recurringEventId = recurringEventId;
}
}
const createdBookings: BookingResponse[] = await handleNewRecurringBooking(
await this.createNextApiBookingRequest(req, oAuthClientId)
await this.createNextApiRecurringBookingRequest(req, oAuthClientId)
);
createdBookings.forEach(async (booking) => {
@@ -264,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();
@@ -296,9 +314,17 @@ export class BookingsController {
private async getOwnerId(req: Request): Promise<number | undefined> {
try {
const accessToken = req.get("Authorization")?.replace("Bearer ", "");
if (accessToken) {
return this.oAuthFlowService.getOwnerId(accessToken);
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);
@@ -338,6 +364,24 @@ export class BookingsController {
return req as unknown as NextApiRequest & { userId?: number } & OAuthRequestParams;
}
private async createNextApiRecurringBookingRequest(
req: BookingRequest,
oAuthClientId?: string,
platformBookingLocation?: string
): Promise<NextApiRequest & { userId?: number } & OAuthRequestParams> {
const userId = (await this.getOwnerId(req)) ?? -1;
const oAuthParams = oAuthClientId
? await this.getOAuthClientsParams(oAuthClientId)
: DEFAULT_PLATFORM_PARAMS;
Object.assign(req, {
userId,
...oAuthParams,
platformBookingLocation,
noEmail: !oAuthParams.arePlatformEmailsEnabled,
});
return req as unknown as NextApiRequest & { userId?: number } & OAuthRequestParams;
}
private handleBookingErrors(
err: Error | HttpError | unknown,
type?: "recurring" | `instant` | "no-show"
@@ -353,6 +397,9 @@ export class BookingsController {
if (err instanceof Error) {
const error = err as Error;
if (Object.values(ErrorCode).includes(error.message as unknown as ErrorCode)) {
throw new HttpException(error.message, 400);
}
throw new InternalServerErrorException(error?.message ?? errMsg);
}
@@ -40,7 +40,7 @@ class Response {
notes?: string;
}
export class CreateBookingInput {
export class CreateBookingInput_2024_04_15 {
@IsString()
@IsOptional()
end?: string;
@@ -59,10 +59,6 @@ export class CreateBookingInput {
@IsOptional()
rescheduleUid?: string;
@IsString()
@IsOptional()
recurringEventId?: string;
@IsTimeZone()
timeZone!: string;
@@ -1,9 +1,9 @@
import { CreateBookingInput } from "@/ee/bookings/inputs/create-booking.input";
import { IsBoolean, IsNumber, IsOptional } from "class-validator";
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;
@@ -21,4 +21,8 @@ export class CreateRecurringBookingInput extends CreateBookingInput {
@IsOptional()
@IsNumber()
currentRecurringIndex?: number;
@IsString()
@IsOptional()
recurringEventId?: string;
}
@@ -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,86 @@
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 getByIdsWithAttendeesAndUser(ids: number[]) {
return this.dbRead.prisma.booking.findMany({
where: {
id: {
in: ids,
},
},
include: {
attendees: true,
user: true,
},
});
}
async getByUid(bookingUid: string) {
return this.dbRead.prisma.booking.findUnique({
where: {
uid: bookingUid,
},
});
}
async getByIdWithAttendeesAndUser(id: number) {
return this.dbRead.prisma.booking.findUnique({
where: {
id,
},
include: {
attendees: true,
user: true,
},
});
}
async getByUidWithAttendeesAndUser(uid: string) {
return this.dbRead.prisma.booking.findUnique({
where: {
uid,
},
include: {
attendees: true,
user: true,
},
});
}
async getRecurringByUidWithAttendeesAndUser(uid: string) {
return this.dbRead.prisma.booking.findMany({
where: {
recurringEventId: uid,
},
include: {
attendees: true,
user: 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,222 @@
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 { GetBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/get-booking.output";
import { GetBookingsOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/get-bookings.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 {
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 { GetBookingsOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/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";
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,
} 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,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 GetBookingOutput_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,26 @@
import { ApiProperty, ApiExtraModels, getSchemaPath } from "@nestjs/swagger";
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 GetBookingsOutput_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({
type: "array",
items: {
oneOf: [
{ $ref: getSchemaPath(BookingOutput_2024_08_13) },
{ $ref: getSchemaPath(RecurringBookingOutput_2024_08_13) },
],
},
description:
"Array of booking data, which can contain either BookingOutput objects or RecurringBookingOutput objects",
})
@ValidateNested({ each: true })
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,274 @@
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.getByIdWithAttendeesAndUser(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.getRecurringByUidWithAttendeesAndUser(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.getByIdWithAttendeesAndUser(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.getByUidWithAttendeesAndUser(uid);
if (booking) {
const isRecurring = !!booking.recurringEventId;
if (isRecurring) {
return this.outputService.getOutputRecurringBooking(booking);
}
return this.outputService.getOutputBooking(booking);
}
const recurringBooking = await this.bookingsRepository.getRecurringByUidWithAttendeesAndUser(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.getByIdsWithAttendeesAndUser(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,
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.getByIdWithAttendeesAndUser(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.getByUidWithAttendeesAndUser(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,418 @@
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 { 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().optional(),
count: z.number().int().optional(),
freq: z.nativeEnum(Frequency).optional(),
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.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.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;
const repeatsTimes = occurrence.count;
// note(Lauris): timeBetween 0=yearly, 1=monthly and 2=weekly
const timeBetween = occurrence.freq;
if (!repeatsTimes) {
throw new Error("Repeats times is required");
}
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.getByUidWithAttendeesAndUser(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.getRecurringByUidWithAttendeesAndUser(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,114 @@
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(),
});
type DatabaseBooking = Booking & {
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,
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,
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,
meetingUrl: databaseBooking.location,
absentHost: !!databaseBooking.noShowHost,
};
return plainToClass(BookingOutput_2024_08_13, booking, { strategy: "excludeAll" });
}
async getOutputRecurringBookings(databaseBookings: DatabaseBooking[]) {
const transformed = [];
for (const booking of databaseBookings) {
const databaseBooking = await this.bookingsRepository.getByIdWithAttendeesAndUser(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,
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,
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,
meetingUrl: databaseBooking.location,
recurringBookingUid: databaseBooking.recurringEventId,
absentHost: !!databaseBooking.noShowHost,
};
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,29 @@ 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)
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)
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 +113,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 +126,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 +149,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 +179,7 @@ export class CalendarsController {
@UseGuards(ApiAuthGuard)
@Post("/:calendar/credentials")
@ApiOperation({ summary: "Sync credentials" })
async syncCredentials(
@GetUser() user: User,
@Param("calendar") calendar: string,
@@ -167,7 +193,7 @@ export class CalendarsController {
default:
throw new BadRequestException(
"Invalid calendar type, available calendars are: ",
CALENDARS.join(", ")
CREDENTIAL_CALENDARS.join(", ")
);
}
}
@@ -176,6 +202,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 +222,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[];
@@ -5,7 +5,8 @@ import { BadRequestException, UnauthorizedException } from "@nestjs/common";
import { Injectable } from "@nestjs/common";
import { SUCCESS_STATUS, APPLE_CALENDAR_TYPE, APPLE_CALENDAR_ID } from "@calcom/platform-constants";
import { symmetricEncrypt, CalendarService } from "@calcom/platform-libraries";
import { symmetricEncrypt, CalendarService, symmetricDecrypt } from "@calcom/platform-libraries";
import { Credential } from "@calcom/prisma/client";
@Injectable()
export class AppleCalendarService implements CredentialSyncCalendarApp {
@@ -61,26 +62,70 @@ export class AppleCalendarService implements CredentialSyncCalendarApp {
if (username.length <= 1 || password.length <= 1)
throw new BadRequestException(`Username or password cannot be empty`);
const data = {
type: APPLE_CALENDAR_TYPE,
key: symmetricEncrypt(
JSON.stringify({ username, password }),
process.env.CALENDSO_ENCRYPTION_KEY || ""
),
userId: userId,
teamId: null,
appId: APPLE_CALENDAR_ID,
invalid: false,
};
const existingAppleCalendarCredentials = await this.credentialRepository.getAllUserCredentialsByTypeAndId(
APPLE_CALENDAR_TYPE,
userId
);
let hasMatchingUsernameAndPassword = false;
if (existingAppleCalendarCredentials.length > 0) {
const hasCalendarWithGivenCredentials = existingAppleCalendarCredentials.find(
(calendarCredential: Credential) => {
const decryptedKey = JSON.parse(
symmetricDecrypt(calendarCredential.key as string, process.env.CALENDSO_ENCRYPTION_KEY || "")
);
if (decryptedKey.username === username) {
if (decryptedKey.password === password) {
hasMatchingUsernameAndPassword = true;
}
return true;
}
}
);
if (!!hasCalendarWithGivenCredentials && hasMatchingUsernameAndPassword) {
return {
status: SUCCESS_STATUS,
};
}
if (!!hasCalendarWithGivenCredentials && !hasMatchingUsernameAndPassword) {
await this.credentialRepository.upsertAppCredential(
APPLE_CALENDAR_TYPE,
symmetricEncrypt(JSON.stringify({ username, password }), process.env.CALENDSO_ENCRYPTION_KEY || ""),
userId,
hasCalendarWithGivenCredentials.id
);
return {
status: SUCCESS_STATUS,
};
}
}
try {
const data = {
type: APPLE_CALENDAR_TYPE,
key: symmetricEncrypt(
JSON.stringify({ username, password }),
process.env.CALENDSO_ENCRYPTION_KEY || ""
),
userId: userId,
teamId: null,
appId: APPLE_CALENDAR_ID,
invalid: false,
};
const dav = new CalendarService({
id: 0,
...data,
user: { email: userEmail },
});
await dav?.listCalendars();
await this.credentialRepository.createAppCredential(APPLE_CALENDAR_TYPE, data.key, userId);
await this.credentialRepository.upsertAppCredential(APPLE_CALENDAR_TYPE, data.key, userId);
} catch (reason) {
throw new BadRequestException(`Could not add this apple calendar account: ${reason}`);
}
@@ -4,8 +4,8 @@ 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";
import {
Injectable,
@@ -13,11 +13,12 @@ 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";
import { z } from "zod";
import { APPS_TYPE_ID_MAPPING } from "@calcom/platform-constants";
import { getConnectedDestinationCalendars, getBusyCalendarTimes } from "@calcom/platform-libraries";
import { Calendar } from "@calcom/platform-types";
import { PrismaClient } from "@calcom/prisma";
@@ -31,9 +32,8 @@ 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
) {}
async getCalendars(userId: number) {
@@ -145,4 +145,32 @@ export class CalendarsService {
throw new NotFoundException("Calendar credentials not found");
}
}
async createAndLinkCalendarEntry(
userId: number,
externalId: string,
key: Prisma.InputJsonValue,
calendarType: keyof typeof APPS_TYPE_ID_MAPPING,
credentialId?: number | null
) {
const credential = await this.credentialsRepository.upsertAppCredential(
calendarType,
key,
userId,
credentialId
);
await this.selectedCalendarsRepository.upsertSelectedCalendar(
externalId,
credential.id,
userId,
calendarType
);
}
async checkCalendarCredentialValidity(userId: number, credentialId: number, type: string) {
const credential = await this.credentialsRepository.getUserCredentialById(userId, credentialId, type);
return !credential?.invalid;
}
}
@@ -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 } };
}
@@ -131,11 +131,6 @@ export class GoogleCalendarService implements OAuthCalendarApp {
const token = await oAuth2Client.getToken(parsedCode);
// Google oAuth Credentials are stored in token.tokens
const key = token.tokens;
const credential = await this.credentialRepository.createAppCredential(
GOOGLE_CALENDAR_TYPE,
key as Prisma.InputJsonValue,
ownerId
);
oAuth2Client.setCredentials(key);
@@ -149,10 +144,39 @@ export class GoogleCalendarService implements OAuthCalendarApp {
const primaryCal = cals.data.items?.find((cal) => cal.primary);
if (primaryCal?.id) {
await this.selectedCalendarsRepository.createSelectedCalendar(
primaryCal.id,
credential.id,
const alreadyExistingSelectedCalendar = await this.selectedCalendarsRepository.getUserSelectedCalendar(
ownerId,
GOOGLE_CALENDAR_TYPE,
primaryCal.id
);
if (alreadyExistingSelectedCalendar) {
const isCredentialValid = await this.calendarsService.checkCalendarCredentialValidity(
ownerId,
alreadyExistingSelectedCalendar.credentialId ?? 0,
GOOGLE_CALENDAR_TYPE
);
// user credential probably got expired in this case
if (!isCredentialValid) {
await this.calendarsService.createAndLinkCalendarEntry(
ownerId,
alreadyExistingSelectedCalendar.externalId,
key as Prisma.InputJsonValue,
GOOGLE_CALENDAR_TYPE,
alreadyExistingSelectedCalendar.credentialId
);
}
return {
url: redir || origin,
};
}
await this.calendarsService.createAndLinkCalendarEntry(
ownerId,
primaryCal.id,
key as Prisma.InputJsonValue,
GOOGLE_CALENDAR_TYPE
);
}
@@ -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,
};
}
}
@@ -168,16 +168,39 @@ export class OutlookService implements OAuthCalendarApp {
const defaultCalendar = await this.getDefaultCalendar(office365OAuthCredentials.access_token);
if (defaultCalendar?.id) {
const credential = await this.credentialRepository.createAppCredential(
const alreadyExistingSelectedCalendar = await this.selectedCalendarsRepository.getUserSelectedCalendar(
ownerId,
OFFICE_365_CALENDAR_TYPE,
office365OAuthCredentials,
ownerId
defaultCalendar.id
);
await this.selectedCalendarsRepository.createSelectedCalendar(
defaultCalendar.id,
credential.id,
if (alreadyExistingSelectedCalendar) {
const isCredentialValid = await this.calendarsService.checkCalendarCredentialValidity(
ownerId,
alreadyExistingSelectedCalendar.credentialId ?? 0,
OFFICE_365_CALENDAR_TYPE
);
// user credential probably got expired in this case
if (!isCredentialValid) {
await this.calendarsService.createAndLinkCalendarEntry(
ownerId,
alreadyExistingSelectedCalendar.externalId,
office365OAuthCredentials,
OFFICE_365_CALENDAR_TYPE,
alreadyExistingSelectedCalendar.credentialId
);
}
return {
url: redir || origin,
};
}
await this.calendarsService.createAndLinkCalendarEntry(
ownerId,
defaultCalendar.id,
office365OAuthCredentials,
OFFICE_365_CALENDAR_TYPE
);
}
@@ -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 } 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,6 +236,36 @@ 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,
lockTimeZoneToggleOnBookingPage: true,
color: {
darkThemeHex: "#292929",
lightThemeHex: "#fafafa",
},
customName: `{Event type title} between {Organiser} and {Scheduler}`,
};
return request(app.getHttpServer())
@@ -245,7 +281,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);
@@ -253,10 +288,344 @@ 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.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.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!";
@@ -278,6 +647,32 @@ 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,
lockTimeZoneToggleOnBookingPage: true,
color: {
darkThemeHex: "#292929",
lightThemeHex: "#fafafa",
},
customName: `{Event type title} betweennnnnnnnnnn {Organiser} and {Scheduler}`,
};
return request(app.getHttpServer())
@@ -303,6 +698,18 @@ 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.lockTimeZoneToggleOnBookingPage).toEqual(
body.lockTimeZoneToggleOnBookingPage
);
expect(updatedEventType.color).toEqual(body.color);
eventType.title = newTitle;
eventType.scheduleId = secondSchedule.id;
@@ -311,6 +718,14 @@ 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.lockTimeZoneToggleOnBookingPage = updatedEventType.lockTimeZoneToggleOnBookingPage;
eventType.color = updatedEventType.color;
});
});
@@ -351,36 +766,18 @@ describe("Event types Endpoints", () => {
expect(fetchedEventType.bookingLimitsDuration).toEqual(eventType.bookingLimitsDuration);
expect(fetchedEventType.offsetStart).toEqual(eventType.offsetStart);
expect(fetchedEventType.bookingWindow).toEqual(eventType.bookingWindow);
});
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.lockTimeZoneToggleOnBookingPage).toEqual(
eventType.lockTimeZoneToggleOnBookingPage
);
expect(fetchedEventType.color).toEqual(eventType.color);
});
it(`/GET/event-types by username and eventSlug`, async () => {
@@ -406,6 +803,18 @@ 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.lockTimeZoneToggleOnBookingPage).toEqual(
eventType.lockTimeZoneToggleOnBookingPage
);
expect(fetchedEventType.color).toEqual(eventType.color);
});
it(`/GET/:id not existing`, async () => {
@@ -460,6 +869,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,
@@ -529,7 +948,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",
@@ -622,11 +1041,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",
@@ -739,7 +1158,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: [
@@ -4,35 +4,10 @@ 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 { getEventTypeById } from "@calcom/platform-libraries";
import { InputEventTransformed_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"
> & {
length: number;
slug: string;
locations?: ReturnType<typeof transformApiEventTypeLocations>;
bookingFields?: ReturnType<typeof transformApiEventTypeBookingFields>;
};
@Injectable()
export class EventTypesRepository_2024_06_14 {
constructor(
@@ -41,7 +16,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,
@@ -56,7 +34,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 },
});
}
@@ -66,7 +56,7 @@ export class EventTypesRepository_2024_06_14 {
id: eventTypeId,
userId,
},
include: { users: true, schedule: true },
include: { users: true, schedule: true, destinationCalendar: true },
});
}
@@ -75,7 +65,7 @@ export class EventTypesRepository_2024_06_14 {
where: {
userId,
},
include: { users: true, schedule: true },
include: { users: true, schedule: true, destinationCalendar: true },
});
}
@@ -97,7 +87,14 @@ export class EventTypesRepository_2024_06_14 {
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 },
});
}
@@ -109,7 +106,7 @@ export class EventTypesRepository_2024_06_14 {
slug: slug,
},
},
include: { users: true, schedule: true },
include: { users: true, schedule: true, destinationCalendar: true },
});
}
@@ -0,0 +1,71 @@
import { ApiProperty as DocsProperty } from "@nestjs/swagger";
import { Transform } from "class-transformer";
import { IsString, IsBoolean, IsOptional, IsEnum, Matches } from "class-validator";
export enum TemplateType {
CHECK_IN_APPOINTMENT = "CHECK_IN_APPOINTMENT",
CUSTOM_TEMPLATE = "CUSTOM_TEMPLATE",
}
export class CreatePhoneCallInput {
@IsString()
@Matches(/^\+[1-9]\d{1,14}$/, {
message:
"Invalid phone number format. Expected format: +<CountryCode><PhoneNumber> with no spaces or separators.",
})
@DocsProperty({ description: "Your phone number" })
yourPhoneNumber!: string;
@IsString()
@Matches(/^\+[1-9]\d{1,14}$/, {
message:
"Invalid phone number format. Expected format: +<CountryCode><PhoneNumber> with no spaces or separators.",
})
@DocsProperty({ description: "Number to call" })
numberToCall!: string;
@IsString()
@DocsProperty({ description: "CAL API Key" })
calApiKey!: string;
@IsBoolean()
@DocsProperty({ description: "Enabled status", default: true })
enabled = true;
@IsEnum(TemplateType)
@DocsProperty({ description: "Template type", enum: TemplateType })
templateType: TemplateType = TemplateType.CUSTOM_TEMPLATE;
@IsOptional()
@IsString()
@DocsProperty({ description: "Scheduler name" })
schedulerName?: string;
@IsOptional()
@IsString()
@Transform(({ value }) => (value ? value : undefined))
@DocsProperty({ description: "Guest name" })
guestName?: string;
@IsOptional()
@IsString()
@Transform(({ value }) => (value ? value : undefined))
@DocsProperty({ description: "Guest email" })
guestEmail?: string;
@IsOptional()
@IsString()
@Transform(({ value }) => (value ? value : undefined))
@DocsProperty({ description: "Guest company" })
guestCompany?: string;
@IsOptional()
@IsString()
@DocsProperty({ description: "Begin message" })
beginMessage?: string;
@IsOptional()
@IsString()
@DocsProperty({ description: "General prompt" })
generalPrompt?: string;
}
@@ -0,0 +1,28 @@
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsEnum, IsString, ValidateNested } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
class Data {
@IsString()
@ApiProperty()
callId!: string;
@IsString()
@ApiProperty()
agentId!: string;
}
export class CreatePhoneCallOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
type: Data,
})
@ValidateNested()
@Type(() => Data)
data!: Data;
}
@@ -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,21 +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,
...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
@@ -61,14 +44,7 @@ export class EventTypesService_2024_06_14 {
await updateEventType({
input: {
id: eventTypeCreated.id,
bookingLimits,
durationLimits,
periodType,
periodDays,
periodCountCalendarDays,
periodStartDate,
periodEndDate,
...bodyTransformed,
...body,
},
ctx: {
user: eventTypeUser,
@@ -84,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.");
@@ -107,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) {
@@ -115,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) {
@@ -145,17 +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) {
@@ -188,7 +172,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) {
@@ -216,12 +200,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) {
@@ -237,12 +221,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
@@ -257,7 +241,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,15 +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,
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"] = [
{
@@ -25,66 +107,267 @@ 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 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,24 +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,
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,
@@ -57,11 +72,19 @@ type Input = Pick<
| "periodCountCalendarDays"
| "periodStartDate"
| "periodEndDate"
| "requiresBookerEmailVerification"
| "hideCalendarNotes"
| "eventTypeColor"
| "seatsShowAttendees"
| "requiresConfirmationWillBlockSlot"
| "eventName"
| "destinationCalendar"
| "useEventTypeDestinationCalendarEmail"
>;
@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,
@@ -73,8 +96,6 @@ export class OutputEventTypesService_2024_06_14 {
beforeEventBuffer,
afterEventBuffer,
slug,
schedulingType,
requiresConfirmation,
price,
currency,
lockTimeZoneToggleOnBookingPage,
@@ -86,17 +107,34 @@ export class OutputEventTypesService_2024_06_14 {
scheduleId,
onlyShowFirstAvailableSlot,
offsetStart,
requiresBookerEmailVerification,
hideCalendarNotes,
seatsShowAttendees,
useEventTypeDestinationCalendarEmail,
} = databaseEventType;
const locations = this.transformLocations(databaseEventType.locations);
const customName = databaseEventType?.eventName ?? undefined;
const bookingFields = databaseEventType.bookingFields
? this.transformBookingFields(BookingFieldsSchema.parse(databaseEventType.bookingFields))
: [];
const recurringEvent = this.transformRecurringEvent(databaseEventType.recurringEvent);
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,
@@ -104,6 +142,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,
@@ -114,22 +153,18 @@ export class OutputEventTypesService_2024_06_14 {
description: description || "",
locations,
bookingFields,
recurringEvent,
recurrence,
disableGuests,
slotInterval,
minimumBookingNotice,
beforeEventBuffer,
afterEventBuffer,
schedulingType,
metadata,
requiresConfirmation,
price,
currency,
lockTimeZoneToggleOnBookingPage,
seatsPerTimeSlot,
forwardParamsSuccessRedirect,
successRedirectUrl,
seatsShowAvailabilityCount,
isInstantEvent,
users,
scheduleId,
@@ -138,23 +173,42 @@ export class OutputEventTypesService_2024_06_14 {
onlyShowFirstAvailableSlot,
offsetStart,
bookingWindow,
bookerLayouts,
confirmationPolicy,
requiresBookerEmailVerification,
hideCalendarNotes,
color,
seats,
customName,
destinationCalendar,
useDestinationCalendarEmail: useEventTypeDestinationCalendarEmail,
};
}
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;
return parseRecurringEvent(recurringEvent);
const recurringEventParsed = parseRecurringEvent(recurringEvent);
if (!recurringEventParsed) return null;
return transformRecurrenceInternalToApi(recurringEventParsed);
}
transformMetadata(metadata: any) {
@@ -180,10 +234,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,
],
})
@@ -1,10 +1,20 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsEnum } from "class-validator";
import { IsEnum, IsNumber, IsString } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
export class ProviderVerifyClientData {
@IsString()
clientId!: string;
@IsNumber()
organizationId!: number;
@IsString()
name!: string;
}
export class ProviderVerifyClientOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
data!: ProviderVerifyClientData;
}
@@ -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();
@@ -40,12 +41,18 @@ export class CalProviderController {
return {
status: SUCCESS_STATUS,
data: {
clientId: oAuthClient.id,
organizationId: oAuthClient.organizationId,
name: oAuthClient.name,
},
};
}
@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,7 +23,7 @@ import {
Patch,
UseGuards,
} from "@nestjs/common";
import { ApiResponse, ApiTags as DocsTags } from "@nestjs/swagger";
import { ApiResponse, ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
import { Throttle } from "@nestjs/throttler";
import { SCHEDULE_READ, SCHEDULE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
@@ -36,7 +36,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) {}
@@ -17,7 +17,7 @@ import {
Patch,
UseGuards,
} from "@nestjs/common";
import { ApiResponse, ApiTags as DocsTags } from "@nestjs/swagger";
import { ApiHeader, ApiOperation, ApiResponse, ApiTags as DocsTags } from "@nestjs/swagger";
import { Throttle } from "@nestjs/throttler";
import { SCHEDULE_READ, SCHEDULE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
@@ -38,11 +38,38 @@ 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: `
The point of creating schedules is for event types to be available at specific times.
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. Managed user can then change the default schedule via \`AvailabilitySettings\` atom.
2. If you did not, then we assume you want that user has specific schedule right away. You should create default schedule by specifying
\`"isDefault": true\` in the request body. Until the user has a default schedule that user can't be booked or manage his / her schedule via the AvailabilitySettings atom.
Second goal is to create other schedules that event types can point to, so that when that event is booked availability is not checked against the default schedule but against that specific schedule.
After creating a non default schedule you can update 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 24 hour format e.g. 08:00, 15:00 etc.
`,
})
async createSchedule(
@GetUser() user: UserWithProfile,
@Body() bodySchedule: CreateScheduleInput_2024_06_11
@@ -59,9 +86,10 @@ export class SchedulesController_2024_06_11 {
@Permissions([SCHEDULE_READ])
@ApiResponse({
status: 200,
description: "Returns the default schedule",
description: "Returns the default schedule of the authenticated user",
type: GetDefaultScheduleOutput_2024_06_11,
})
@ApiOperation({ summary: "Get default schedule (of the authenticated user)" })
async getDefaultSchedule(@GetUser() user: UserWithProfile): Promise<GetScheduleOutput_2024_06_11> {
const schedule = await this.schedulesService.getUserScheduleDefault(user.id);
@@ -74,6 +102,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 +117,7 @@ export class SchedulesController_2024_06_11 {
@Get("/")
@Permissions([SCHEDULE_READ])
@ApiOperation({ summary: "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 +129,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 +150,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

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