Compare commits

...
292 Commits
Author SHA1 Message Date
GitHub Actions b3536728e0 chore: release v4.7.12
Post release / release (push) Canceled after 0s
2024-11-26 20:22:01 +00:00
Eunjae LeeandOmar López e22fec93b1 fix: provide fixed widths for DataTable columns (#17853)
Co-authored-by: Omar López <zomars@me.com>
2024-11-26 20:17:33 +00:00
Joe Au-Yeung f81716fbb2 feat: Salesforce - pass company booking response to the Lead field (#17860)
* Catch undefined app option properties

* Add `getCompanyNameFromBookingResponse` method

* Pass bookingResponse when Lead is being created

* Pass bookingResponse from CRMManager

* Handle empty company response

* Return the company value
2024-11-26 19:14:44 +00:00
Anik Dhabal Babu 0b66ddc6db fix: unable to remove banner (#17857) 2024-11-26 18:42:41 +00:00
Somay Chauhan 382a27f134 fix: round robin host reschedule error (#17855) 2024-11-26 18:08:58 +00:00
Calcom BotandReplexica ac5ee225d5 feat: update translations via @replexica (#17854)
* chore: sync with main

* feat: update translations via @replexica

---------

Co-authored-by: Replexica <support@replexica.com>
2024-11-26 16:46:54 +00:00
Anik Dhabal Babu d8fd6f5c25 chore: exclude emails form field refactor (#17824)
* chore: exclude emails refactor

* Update
2024-11-26 21:30:28 +05:30
Benny Joo 0e333aa71c fix: use tags icon for attr (#17841) 2024-11-26 12:52:21 -03:00
Carina WollendorferandCarinaWolli ae70786b2b fix: send attendee email with event updates of reassignment (#17836)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2024-11-26 10:33:00 -05:00
Benny Joo 2df553b58b chore: use app router for settings/platform/members page (#17849)
* use i18n strings

* migrate platform/members page
2024-11-26 15:33:01 +01:00
Morgan d4afa1436d chore: make throttler logs verbose (#17845) 2024-11-26 09:42:27 +00:00
Morgan 88efa0a2b2 fix: oauth client users controller permissions (#17822)
* fix: oauth client users controller permissions

* fixup! fix: oauth client users controller permissions

* fixup! fixup! fix: oauth client users controller permissions

* fixup! fixup! fixup! fix: oauth client users controller permissions
2024-11-26 09:41:03 +00:00
Calcom BotandReplexica b563e41623 feat: update translations via @replexica (#17842)
Co-authored-by: Replexica <support@replexica.com>
2024-11-25 22:17:54 +00:00
Benny Joo f097f852ad chore: settings/organizations - remove pages router and use app router (#16774)
* remove pages router and use app router

* remove future/settings/organizations files

* fix billing page

* sync SettingsLayoutAppDirClient and fix members page

* fix attribute pages

* remove isAppDir where not needed anymore

* add CTA button for members view
2024-11-25 16:49:00 -05:00
Joe Au-Yeung aea8588a21 fix: Salesforce owner check, handle no contact email (#17834) 2024-11-25 19:38:06 +00:00
Benny Joo 7e0c684348 fix: UI issue in Phone Input dropdown (#17837) 2024-11-25 19:35:09 +00:00
Keith Williams 21956f096d chore: Add check to skip DB migrations as part of build (#17827) 2024-11-25 16:33:51 -03:00
Benny Joo d1adfa3bf9 fix (#17812) 2024-11-25 14:07:55 -05:00
Benny JooandKeith Williams 62976a4c77 feat: AI description - Backend for adding translation objects to EventType model (#17657)
* feat: AI description - DB model + frontend + backend (fetch only)

* fix types and add validation to backend

* improve log

* improve

* import type

* add EventTypeTranslationRepository

* fix replexica error

* fix replexica error

* fix

* fix test

* wip

* wip

* add createManyDescriptionTranslations method

* improve logic

* refactor

* update replexica type

* improve typing

* Renamed descriptionTranslations to fieldTranslations

* log errors in replexica service

* handle translations in background

* don't create, upsert instead

* make sure that description is updated

* simplify

* fix

* Update packages/trpc/server/routers/viewer/eventTypes/update.handler.ts

* improve filter logic in frontend

* implement background job using Tasker

* update schema

* update schema again

* rename

* rename id to uid

* add migration

* fix type

* update

* update schema and migration sql

* fix migration

* fix type check

* fix type check 2

* improve find logic in EventMeta

* add more validation

* add logger

* rename

* upgrade replexica/sdk to 0.7.0

* upgrade replexica to 0.7.0

* use batchLocalizeText

* add comments

* override browser locale if user is a signed in calcom user

* fix type in replexica

* fix migration sql

* fix type

* use dynamic import

* do not use batchLocalizeText

* fix

* add more validations

* fix type error

* address comment

* update locales

* update locales

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-11-25 18:37:42 +00:00
GitHub Actions 93489709a8 chore: release v4.7.11
Post release / release (push) Canceled after 0s
2024-11-25 16:35:44 +00:00
Amit Sharma 050102fde2 fix: OOO redirect, email wrong formatting (#17829) 2024-11-25 16:11:44 +00:00
Morgan c1112ab2ab fix: extract clientId header manually apiv2 (Revert) (#17832)
This reverts commit b0bf87f3ed.
2024-11-25 15:59:39 +00:00
Morgan 34d4c3d775 fix: api v2 webhook issue (#17831) 2024-11-25 15:52:00 +00:00
Somay Chauhan e477e5892c feat: added customClassnames for event-setup tab (#17240)
* feat: added customClassnames for event-setup tab

* Update EventTypePlatformWrapper.tsx

* better classnames naming

* added classNames to common types

* updated classnames to use the common exported types

* udpate

* better classnames

* better classname names

* classnames -> classNames

* added default styling

* Update Locations.tsx

* Update event-types.tsx

* update

* locationSelectCustomClassNames -> LocationSelectCustomClassNames

* Update documentation.json

* fix: better classnames

* Update types.ts
2024-11-25 09:59:09 +00:00
ad44ca9f2a fix: change order of eventtypes and routingforms (#17615)
* change order of eventtypes and routingforms

* for workflows

* for filtered workflows

* update slug of first expected event-type

* updated due to latest merges

* test to ensure new eventtype added is first in the list

* test for workflows

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2024-11-25 06:25:05 -03:00
Somay ChauhanandMorgan 868cf91c35 refactor: conferencing apps atoms web wrapper (#17732)
* conferencing apps atoms web wrapper

* Update DisconnectIntegrationModal.tsx

* formating changes

* handleBulkUpdateDefaultLocation -> bulkUpdateFunction

* fix: type error for handleRemoveApp

* fix: type error for handleRemoveApp

* refactor: removed ConferencingView component and migrated its functionality to ConferencingAppsViewWebWrapper

* refactor: moved AppList, AppListCard to packages/features

* fix: type changes

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2024-11-25 09:53:38 +02:00
Lauris Skraucis 8b9693ea60 fix: v2 booking location (#17817)
* fix: create-booking.input dont require location to be url

* fix: return video call url for booking location

* try to debug test

* remove test file
2024-11-25 07:34:59 +00:00
Somay Chauhan 935115d7c1 feat: added custom classnames for event-limits tab (#17478)
* added custom classnames for event limits tab

* added classNames to common types

* updated classnames to use the common exported types

* undo

* Update EventTypePlatformWrapper.tsx

* better clasnames naming

* added example classnames for demo

* fix: better calssnames

bookingFrequencyLimitSection-> bookingFrequencyLimit
totalDurationLimitSection-> totalDurationLimit
futureBookingLimitSection-> futureBookingLimit
offsetStartTimesSection-> offsetStartTimes

* suffix->addOn

* Update types.ts
2024-11-25 07:13:58 +00:00
Somay Chauhan eae74c568c feat: added custom classnames for event-advanced tab (#17479)
* feat: added custom classnames to event-advanced tab

* added more calssnames

* added classNames to common types

* undo extra changes

* Update EventTypePlatformWrapper.tsx

* Update types.ts

* Update EventTypePlatformWrapper.tsx

* better classnames

* Update EventTypePlatformWrapper.tsx

* addeed CustomEventTypeModalClassNames

* added example classnames for demo and some changes

* fix: better classnames

* timezoneLockToggle -> timezoneLock

* Update types.ts

* Update CustomEventTypeModal.tsx
2024-11-25 12:11:27 +05:30
Somay Chauhan 9e9dd6070d feat: added custom classnames to event-assignment tab atom (#17527)
* added classNames to common types

* Update types.ts

* feat: added custom classnames to event-assignment tab atom

* Update EventTypePlatformWrapper.tsx

* update: better classnames

* added example clasnames for demo

* fix: classnames

* fix: type check

* fixed classnames

* fix: better calssnames

assingAllTeamMembersToggle -> assingAllTeamMembers
enableWeightsToggle -> enableWeights
assingAllTeamMembersToggle -> assingAllTeamMembers
2024-11-25 06:10:22 +01:00
Amit SharmaandPeer Richelsen 5af10a2f61 feat: past and overlapping ooo (#17373)
* feat: past and overlapping ooo

* use existing props

* added e2e test

* disallow duplicates added tests

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2024-11-24 18:58:22 +00:00
Peer Richelsen 3936a0ae94 target blank for settings link (#17815) 2024-11-23 21:12:44 +00:00
Morgan c2a3d8b8df fix: extract trpc and react-awesome-query-builder from atoms (#17801)
* wip

* fix: externalize react-awesome-query-builder

* fix: move useSession to webWrapper

* fixup! Merge branch 'main' into fix-platform-event-types-segment

* fixup! fixup! Merge branch 'main' into fix-platform-event-types-segment

* fixup! fixup! fixup! Merge branch 'main' into fix-platform-event-types-segment

* fixup! fixup! fixup! fixup! Merge branch 'main' into fix-platform-event-types-segment
2024-11-23 12:29:16 +00:00
GitHub Actions 9bb66035c8 chore: release v4.7.10
Post release / release (push) Canceled after 0s
2024-11-22 23:01:45 +00:00
Joe Au-Yeung 226325062e Conditionally get the first assignment reason (#17808) 2024-11-22 22:23:15 +00:00
Carina Wollendorfer b61661214a fix: improvements for canceling workflow reminders (#17807) 2024-11-22 13:55:30 -07:00
Omar López 9f2047f203 fix: Got back to access token for release action
Signed-off-by: Omar López <zomars@me.com>
2024-11-22 13:53:19 -07:00
Omar López 9b0db345b0 Revert "Add new release actions"
This reverts commit 05c8b53444.
2024-11-22 13:50:27 -07:00
GitHub Actions a84a2b3152 chore: release 4.7.9
Post release / release (push) Canceled after 0s
2024-11-22 20:43:40 +00:00
Omar López 087dbefec2 fix: add missing version plugin for release action 2024-11-22 13:35:52 -07:00
Omar López 46ba793521 fix: draft release setup 2024-11-22 13:32:29 -07:00
Omar López 21ab9bf109 fix: draft release setup 2024-11-22 13:30:15 -07:00
Omar López 05c8b53444 Add new release actions
Signed-off-by: Omar López <zomars@me.com>
2024-11-22 13:27:33 -07:00
Joe Au-Yeung e7495e311b feat: Render assignment reason on routing form report (#17791) 2024-11-22 10:57:16 -07:00
Omar López 9c10ecf8ea ci: Add new release actions (#17789) 2024-11-22 10:08:26 -07:00
Lauris Skraucis 9fa73aca86 fix: v2 booking confirmation emails not sent (#17764)
* fix: round robin recurring don't send email if noEmail

* fix: noEmail: false for non platform requests

* fix: booking e2e tests

* refactor: move bookings e2e tests in folder and uniform name

* test: emails

* test: team emails

* reschedule and cancel emails with api key

* test: round robin reassign emails

* test: confirm and decline emails

* test: confirm and decline emails

* fix tests

* test: recurring booking emails

* remove unused imports and spies

* fix test

* remove log

* chore: bump libraries

* try to fix ci tests
2024-11-22 18:19:56 +02:00
Alex van Andel 8e7ce1216c Revert "feat: install gcal + gvideo by default for google signups (#17773)" (#17803)
release.yml / release (push) Canceled after 0s
This reverts commit fcd06c80e3.
2024-11-22 15:55:11 +00:00
Alex van Andel 4ddbdde553 chore: Release v4.7.8 2024-11-22 15:54:42 +00:00
Keith Williams cd24067f17 chore: Add ability to disable Sentry source maps (#17699)
* chore: Remove Sentry source maps

* Using .env to control Sentry source maps

* Trying with sourcemaps property

* Turned silent off for testing

* Completely turned off devtool
2024-11-22 15:19:52 +00:00
Alex van Andel 040adb2ddf chore: bump lockfile (#17802) 2024-11-22 15:14:39 +00:00
Somay Chauhan 4d578d2cdf feat: added customClassnames to event-availability tab (#17268)
* feat: added customClassnames to event-availability tab

* added classNames to common types

* use common classnames type

* Update types.ts

* update name

* fix: better classnames

* better calssnames

* Update types.ts
2024-11-22 10:35:20 +01:00
Anwar Sadath 1cf84a270c feat: Expose booking created at in v2 API response (#17452)
* feat: Expose booking created at in booking detailed view and v2 API response

* Fix tests

* Fix spacing

* Add API v2 tests to verify createdAt is present while creating a booking and fetching a booking

* Add test to validate createdAt after rescheduling a booking
2024-11-22 13:50:11 +05:30
Somay Chauhan d03651312e feat: added customClassnames for event-recurring tab (#17551)
* added classNames to common types

* Update types.ts

* feat: added custom classnames to event-recurring tab

* Update EventTypePlatformWrapper.tsx

* customClassnames -> customClassNames

* added example classnames for demo

* fixed classnames
2024-11-22 12:30:02 +05:30
Alex van Andel 4b34aec13d chore: Release v4.7.7 (#17786)
release.yml / release (push) Canceled after 0s
2024-11-21 22:47:11 +00:00
Anik Dhabal Babu 7ab8fdbcb4 fix: short text booking questions does not save we we click add (#17778) 2024-11-21 22:46:44 +00:00
Morgan b0bf87f3ed fix: extract clientId header manually apiv2 (#17783) 2024-11-21 22:46:13 +00:00
Joe Au-Yeung 1490642260 feat: Record Salesforce ownership assignment (#17775) 2024-11-21 14:22:39 -07:00
Udit Takkar 04b49acbb0 chore: add rescheduled by property (#17631)
* refactor: add rescheduledBy

* chore

* fix: bug

* test: add test for rescheduled booking

* fix: test
2024-11-21 11:51:19 -05:00
Joe Au-Yeung ff79e5c70e feat: Record assignment reasons for routing form and reassignment (#17767)
* Add assignment reason table

* Increment id

* Update migration

* Record assignment when manual reassignment is used

* Add comment to `getAttributesQueryValue`

* Add `getUsersAttributes`

* Record routing form routing reason

* Type fixes

* Remove unique property from bookingId

* Handle nested values
2024-11-21 16:27:19 +00:00
Alex van Andel 5d51c79739 fix: temporarily revert stricter schema so we can handle invalid DB e… (#17774) 2024-11-20 21:35:43 -07:00
Carina WollendorferandCarinaWolli 8bc2c08288 fix: no availability on reschedule with load balancing (#17770)
* don't filter if reschedule with same host

* check for reschedule

* fix filtering in handleNewBooking

* fix type error

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2024-11-21 01:22:43 +00:00
Omar López ade144aa20 ci: Re-drafts PR when changes are requested (#17137) 2024-11-21 00:59:11 +00:00
Benny Joo fcd06c80e3 feat: install gcal + gvideo by default for google signups (#17773)
* feat: install gcal + gvideo by default for google signups

* add upsertSelectedCalendar

* fix type check
2024-11-20 23:54:11 +00:00
Omar López 57bbba3d95 chore: v4.7.6 (#17766)
release.yml / release (push) Canceled after 0s
2024-11-20 13:22:47 -07:00
Amit Sharma d952e77b78 feat: Don't remove hosts when disabling 'Add all team members' (#17747)
* feat: Don't remove hosts when disabling 'Add all team members'

* retain existing host weights and priorities

* fix: merge changes, revert onInactive removal

* lint fix

* fix: weight priority dialog value
2024-11-20 17:54:42 +00:00
Anik Dhabal Babu 73989da3b7 fix: common schedule toggle for team event in never uncheckable (#17759) 2024-11-20 11:25:58 -05:00
Hariom Balhara a534d39351 Log error correctly (#17755) 2024-11-20 12:03:07 -03:00
Calcom BotandReplexica c16ef798f5 feat: update translations via @replexica (#17757)
* chore: sync with main

* feat: update translations via @replexica

---------

Co-authored-by: Replexica <support@replexica.com>
2024-11-20 14:03:09 +00:00
Lauris Skraucis 58cac84f4a feat: v2 expose booking metadata (#17708)
* feat: accept metadata and store it in database

* feat: accept metadata and store it in database

* feat: add BookingSeat.metadata column

* feat: handleNewBooking: store booking seat metadata when first time seated booking

* feat: handleSeats, createNewSeat store metadata when 2nd+ seated booking

* feat: return booking metadata in output

* tests: metadata

* test: seated bookings

* swagger

* fix: reassign bookings test

* test: use different metadata in recurring

* fix: test

* test invalid metadata

* refactor: output service

* readd new line

* chore: bump platform libraries

* refactor: metadata doc
2024-11-20 15:15:49 +02:00
Eunjae LeeandUdit Takkar 1404581f78 feat: add text filter on org member list (#17632)
* feat: improve text filters (WIP)

* move function to bottom

* apply some styles

* fix selection of TextFilterOptions

* rename value to operand

* remove unused file

* merge filters/filters into filters/utils

* fix regression of not putting url params correctly

* move makeWhereClause to filters/utils

* fix negative, empty, and not empty operators

* fix initial filtering from search state (url)

* fix type errors

* do not send an empty array to query

* update yarn.lock

* i18n for text filter operators

* extract logic as useColumnFilters()

* add missing import

* fix type error

* revert yarn.lock

* use i18n

* insensitive text match

* move data-table to @calcom/features

* fix type errors

* fix type errors

* fix type errors

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-11-20 09:38:10 +00:00
Anwar Sadath ff18b9a733 chore: Add support for new docker compose command to run V2 API (#17744) 2024-11-20 08:47:37 +00:00
Hariom BalharaandCarinaWolli 82e52a1948 feat: Attribute filter/Segment for Round Robin hosts (#17361)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2024-11-20 10:22:53 +05:30
Vijay e3abd35ba7 fix: upgrade prismock to 1.33.4 (#17735)
* upgrade prismock, removed 2 hacks, update 3 tests

* yarnlock changes
2024-11-19 23:20:27 +00:00
sean-brydon dfdb04acf4 chore: routing table nits (#17738)
* portal date preset selection with a high zindex

* increase height of data-table

* skeleton fixes
2024-11-19 23:04:45 +00:00
Omar López 352a6ba8cf fix: Better error handling for user session (#17753) 2024-11-19 22:57:37 +00:00
Eunjae Lee 997d6a75e8 fix: fix wrong dates on OOO edit dialog (#17736)
* fix: fix wrong dates on OOO edit dialog

* do not use utc() on the browser side
2024-11-19 19:54:12 -03:00
NizzyandKeith Williams 757e3a2faf fix: PostHog event tracking (#17653)
* posthog provider fix

* posthog fixes w/ trpc changes

* Update yarn.lock

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-11-19 22:52:18 +00:00
Omar López 1b457f82a1 chore: v4.7.5 (#17750)
release.yml / release (push) Canceled after 0s
2024-11-19 20:44:58 +00:00
Omar López 16573717b4 fix: identify org-wide features per user (#17010) 2024-11-19 15:21:38 -05:00
Omar LópezandAnik Dhabal Babu 97b920744e feat: adds last active timestamp (#17261)
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-11-20 00:38:20 +05:30
Joe Au-Yeung 6cfe9ff88d feat: Salesforce: Write booking question values to attendee record in Salesforce [CAL-4731] (#17650) 2024-11-19 10:30:34 -07:00
Anik Dhabal Babu 7c5292e13b fix (#17734) 2024-11-19 18:37:39 +05:30
Morgan 3cd4e34406 fix: platform libraries build vite.config (#17743) 2024-11-19 12:05:28 +00:00
sean-brydon a99eda21af chore: booking status reporting (#17733)
* move booking status into new column

* update i18n

* nowrap + badge for submitedAt to provide consistant feel to other dates
2024-11-19 11:24:45 +00:00
Omar López 03e2c6278d fix: cron api key checks (#17730) 2024-11-19 00:18:53 +00:00
Omar López 32aaa26bf1 chore: v4.7.4 (#17728)
release.yml / release (push) Canceled after 0s
2024-11-18 13:12:53 -07:00
Omar López 7c0ee9f186 revert: "feat: Populate gCal calendar cache via webhooks (#11928)"" (#17682) 2024-11-18 19:48:33 +00:00
Benny Joo 50df22928b fix: correct domain for link column in org member table (#17726) 2024-11-18 18:40:24 +00:00
Calcom BotandReplexica fbda8a38cb feat: update translations via @replexica (#17722)
* chore: sync with main

* feat: update translations via @replexica

---------

Co-authored-by: Replexica <support@replexica.com>
2024-11-18 14:56:01 +00:00
8194ffcd07 feat: Autofill feature (#15437)
* feat: Autofill feature

* Revert "feat: Autofill feature"

This reverts commit ac94257cbb516e140ed550ae804e725ca5d036ca.

* feat: store prev response in localStorage

* review fixes

* add tests

* remove unused import

---------

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: unknown <adhabal2002@gmail.com>
2024-11-18 20:04:02 +05:30
VijayandAnik Dhabal Babu 7b94822c8d fix: add default schedule while creating org member (#16855)
* add default schedule while creating org user

* naming as per review comments

* added e2e test

* move bookTeamEvent and expectPageNotFound to testUtils

* Update EventTeamAssignmentTab.tsx

* Update AddMembersWithSwitch.tsx

* Update utils.ts

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-11-18 20:03:24 +05:30
Syed Ali Shahbaz 9aa8f8a30c Update oauth.mdx (#17671) 2024-11-18 14:29:33 +00:00
Rajiv Sahal e206ead611 fix: calendar settings atom modifications (#17579)
* update selected calendars display when no calendar connected

* remove no calendar connected message
2024-11-18 11:05:34 -03:00
Udit Takkar ecac331fd1 perf: get event types (#17689)
* refactor: get event types

* chore

* test: add team and managed team event types

* perf: use Promise.all

* fix: type err
2024-11-18 11:05:12 -03:00
sean-brydon fb4cd7f0e4 feat: Add column visibility filters to routing insights table (#17715)
* add table as a child callback to we can pass it to columnVisibility

* add back failed bookings component
2024-11-18 11:03:58 -03:00
Rajiv Sahal f743db9eaa feat: enable all users to create platform team (#17707)
* add extra checks for platform to make sure user is genuine

* make slug unique for every team
2024-11-18 11:03:23 -03:00
Udit Takkar 0f3773d724 feat: add instant meeting parameter (#17647)
* feat: add instant meeting parameter

* feat: show modal only when parameter present

* chore: remove logs

* fix: type err

* fix: UI

* fix: remove useFieldArray
2024-11-18 19:29:10 +05:30
Eunjae Lee e6633050ba fix: provide explicit column width for member list (#17718) 2024-11-18 13:43:14 +00:00
Replexica Bot f1e972a213 feat: update translations via @replexica (#17712) 2024-11-18 13:01:07 +00:00
Morgan 12e78a7350 chore: add default reading direction to atoms wrapper (#17710) 2024-11-18 12:25:11 +00:00
Amit Sharmaandsean-brydon 277b3e0cf5 feat: DataTable component should use a "key" of data as the selection Key. (#17714)
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
2024-11-18 10:25:52 +00:00
Lauris Skraucis 2b1041460c feat: v2 subsequent recurring booking cancellation (#17645)
* refactor: cancelling all remaining seated recurring bookings not possible

* chore: document cancel booking endpoint

* chore: remove unused imports

* feat: cancel subsequent bookigns

* fix: flaky test

* refactor: getCanceledSubsequentBookings

* refactor: after cancellation return whole recurrence sequence
2024-11-18 08:17:48 +00:00
Keith Williams ce0d685849 chore: Remove faulty languages for Replexica (#17705) 2024-11-18 02:58:16 +00:00
cc5ce4e598 feat: update translations via @replexica (#17704)
* chore: sync with main

* feat: update translations via @replexica

* Update common.json

---------

Co-authored-by: Replexica <support@replexica.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-11-18 02:21:52 +00:00
Keith Williams 4123152bd5 feat: Added more supported languages (#17702) 2024-11-18 01:09:35 +00:00
Keith Williams 1b4469f6e5 chore: v4.7.3 (#17701)
release.yml / release (push) Canceled after 0s
2024-11-17 21:58:45 -03:00
Benny JooandKeith Williams 5401bcca4e feat: AI description - DB model + frontend + backend (fetch only) (#17651)
* feat: AI description - DB model + frontend + backend (fetch only)

* fix types and add validation to backend

* improve log

* improve

* import type

* fix replexica error

* fix

* fix test

* update replexica type

* Renamed descriptionTranslations to fieldTranslations

* Moved the eventTypeId column to 2nd

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-11-18 00:16:56 +00:00
Benny Joo f9afca2939 feat: Add link column to CSV file for org members (#17695)
* improve spacing

* feat: Add link column to CSV file for org members

* update tests
2024-11-17 10:53:43 +00:00
Alex van Andel fc1d553b74 chore: Release v4.7.2 (#17692)
release.yml / release (push) Canceled after 0s
2024-11-16 18:36:43 +00:00
Calcom BotandReplexica b8c2fdac44 feat: update translations via @replexica (#17691)
Co-authored-by: Replexica <support@replexica.com>
2024-11-16 18:32:52 +00:00
Abul Hassan SathuliandPrashant Varma e24c40f518 fix: how-to-setup-api-in-a-local-instance.mdx in docs with correction after api-v2 (#17667)
* Updating how-to-setup-api-in-a-local-instance.mdx with correction after api v2

After the implementation of api v2, some of the documents are not yet updated and could you make developers spend too much time on finding these things.

* fix: typo

---------

Co-authored-by: Prashant Varma <prashantvarma5083@gmail.com>
2024-11-16 18:27:02 +00:00
Alex van Andel c5cefc3e61 chore: Found a critical index missing on profileId (#17690) 2024-11-16 18:26:50 +00:00
Omar López 2c57ad0288 perf: optimize organization admin check (#17683) 2024-11-16 18:26:23 +00:00
sean-brydon af9bf14d9e fix: routing column filters (#17688)
* remove column filters

* remove column filters

* use a loading state while loading columns

* fix
2024-11-16 18:05:36 +00:00
sean-brydon b1a0cdc8e2 fix: large org routing insights UI issues (#17687)
* fix overflow colour in darkmode

* truncate/show overflow scroll indicator when overflowing on response values

* remove total created count

* overflow Y on failed response container

* fix darkmode styles on barlist

* radix hovercards to properly allow overflow in container

* column visible state

* remove constrained width on dropdowns to w-fit

* export type
2024-11-16 11:01:26 -03:00
Alex van AndelandPeer Richelsen 7fef3e7b85 chore: Release v4.7.1 (#17686)
release.yml / release (push) Canceled after 0s
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2024-11-16 10:00:16 +00:00
Hariom Balhara 384deab0ce test: findTeamMemberEmailFromCrm (#17685)
* We need to choose contact owner when crm app slug is not present

* Add unit tests for getTeamMemberEmailFromCrm module
2024-11-16 09:32:33 +00:00
Hariom Balhara 22da681f8c We need to choose contact owner when crm app slug is not present (#17684) 2024-11-16 09:02:24 +00:00
Omar López f4f4afad39 perf: optimize team admin check queries (#17681)
## What does this PR do?

Optimizes team and organization admin checks by:
- Removing unnecessary member data fetching in profile repository
- Simplifying team admin verification query to directly check membership
- Refactoring organization admin check to use the optimized team verification method

## Mandatory Tasks

- [x] I have self-reviewed the code
- [x] I have updated the developer docs in /docs
- [ ] I confirm automated tests are in place

## How should this be tested?

1. Log in as a team admin/owner
2. Verify team admin privileges still work correctly
3. Log in as an organization admin
4. Confirm organization admin permissions are maintained
5. Check that admin-only features remain accessible

Expected behavior:
- Team admins/owners should retain all their existing permissions
- Organization admins should maintain their administrative capabilities
- No degradation in admin functionality while improving query performance

## Checklist

- I haven't read the [contributing guide](https://github.com/calcom/cal.com/blob/main/CONTRIBUTING.md)
- My code doesn't follow the style guidelines of this project
- I haven't commented my code, particularly in hard-to-understand areas
- I haven't checked if my changes generate no new warnings
2024-11-15 19:00:18 -07:00
Keith Williams 1c05952077 chore: Remove playwright install for API v2 E2E (#17677) 2024-11-15 23:47:26 +00:00
Keith Williams 1df70e75ed chore: v4.7.0 (#17664)
release.yml / release (push) Canceled after 0s
2024-11-15 20:11:00 -03:00
Udit Takkar 8962f6f08e fix: update current cursor (#17626) 2024-11-15 16:07:17 -07:00
Keith Williams 6272c881c8 revert: "feat: Populate gCal calendar cache via webhooks (#11928)" (#17675)
This reverts commit d294a74aad.
2024-11-15 19:28:30 -03:00
Omar López bdf94c7f48 fix: removed calendar cache admin page (#17676) 2024-11-15 21:46:08 +00:00
b79c5e7c86 feat: update translations via @replexica (#17674)
* feat: update translations via @replexica

* feat: update translations via @replexica

* Delete packages/app-store/routing-forms/trpc/findTeamMembersMatchingAttributeLogic.handler.ts

* Delete packages/app-store/routing-forms/trpc/findTeamMembersMatchingAttributeLogic.schema.ts

---------

Co-authored-by: Replexica <support@replexica.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-11-15 21:34:07 +00:00
3eaccb8738 fix: Preview queue position and contact owner in preview (#17552)
* wip

* Add preview mode in router

* only fetch bookings of this month for weighted rr

* test set up

* only get bookings of current month

* reverts test setup

* first changes for weight adjustments

* reset booking count + adjust calibration

* depreciate weightAdjustment

* get only bookings created this month

* fix typo

* make sure createdAt for hosts is correct

* use earliest possibel date as fallback

* add missing createdAt date to tests

* fix typo

* clean up changes in tests

* fix typo

* change end date to current date

* fix: Fall back to empty host array when no hosts are found

* fix: Restructure code a little

* fixed test, incorrectly used now outdated var

* perf: remove Dayjs from getLuckyUser

* Refactor getHostsWithCalibration for optimised performance, intentionally break test as findMany is always an array

* Better mock for host.findMany

* Remove team-event-types.test.ts, move to appropriate package

* TypeScript cannot auto-infer that an array is non-empty when assigning to a var

* fix: Type Fixes and DistributionMethod enum add

* Optimise tests

* Added test to show that bookings made before a newHost was added affect the lucky user result

* Throw error when the usersWithHighestPriority is empty, which should never happen

* WIP

* remove comment

* update migrations

* get attributes weights and virtual queue data

* use attribute weights and use bookings of virtual queue only

* clean up migrations

* Add shortfall column and add tests

* code clean up from feedback

* wrapper function for getLuckyUser

* code clean up

* fetch routingFormResponse in handleNewBooking

* fix type errors

* fix type errors in tests

* fix getAttributesQueryValue import for tests

* fix totalWeight

* add test for attributes weights and virtual queues

* clean up code

* add test for prepareQueuesAndAttributesData

* remove console.log

* use lazy import

* Add more tests and more columns to matching members queue

* fix issue from merge

* always send usersAndTheirBookingShortfalls

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2024-11-15 20:39:46 +00:00
Anik Dhabal Babu 9273226fb4 test: Add test to validate error when 'in' field in where clause is undefine and contain null values (#17670) 2024-11-15 19:55:34 +00:00
sean-brydonandJoe Au-Yeung 7ce5886b4f feat: insights routing forms (#17583)
Co-authored-by: Joe Au-Yeung <j.auyeung419@gmail.com>
2024-11-15 14:54:51 -05:00
Anik Dhabal Babu e85c0e1a85 fix: unable to lock individual event type creation (#17668) 2024-11-15 15:58:19 -03:00
Omar LópezandUdit Takkar d294a74aad feat: Populate gCal calendar cache via webhooks (#11928)
* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* Update CalendarService.ts

* Type fixes

* WIP

* fix: improve cache hits

* Update CalendarService.ts

* Update CalendarService.ts

* Update CalendarService.ts

* Update CalendarService.ts

* Update CalendarService.ts

* Update CalendarService.ts

* Feedback

* Update CalendarService.ts

* Update CalendarService.ts

* Update _router.ts

* feedback

* WIP

* WIP

* Update schema.prisma

* feedback

* typefixes

* Update Calendar.d.ts

* fix: watches when adding a calendar

* Discard changes to packages/app-store/googlecalendar/api/add.ts

* WIP

Signed-off-by: Omar López <zomars@me.com>

* WIP

Signed-off-by: Omar López <zomars@me.com>

* WP

Signed-off-by: Omar López <zomars@me.com>

* Update calendar.ts

* Update calendar.ts

* Update callback.ts

* Update callback.ts

* Conflicts

* WIP

* WIP

* Update CalendarService.ts

* Cleanup

* Discard changes to packages/features/settings/layouts/SettingsLayout.tsx

* Update calendar-cache.repository.ts

* WIP

* Update getSelectedCalendarsToWatch.sql

* WIP

* Update CalendarService.ts

* Cleanup

* Discard changes to packages/app-store/googlecalendar/lib/CalendarService.ts

* Create CalendarService.wip.ts

* WIP

* Update CalendarService.ts

* Update getSelectedCalendarsToWatch.sql

* Delete CalendarService.wip.ts

* test updates

* cleanup

Signed-off-by: Omar López <zomars@me.com>

* Update CalendarService.test.ts

* Update CalendarService.ts

* type fixes

* Update OAuthManager.ts

* Update CalendarService.ts

* Almost there

* Update CalendarService.test.ts

* Update calendar.ts

* Update callback.ts

* Update toggleFeatureFlag.handler.ts

* fix: feedback

* Update getSelectedCalendarsToWatch.sql

* Fix unit tests

---------

Signed-off-by: Omar López <zomars@me.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-11-15 14:59:37 -03:00
5012a16794 feat: attribute weights and virtual queues (#17567)
* only fetch bookings of this month for weighted rr

* test set up

* only get bookings of current month

* reverts test setup

* first changes for weight adjustments

* reset booking count + adjust calibration

* depreciate weightAdjustment

* get only bookings created this month

* fix typo

* make sure createdAt for hosts is correct

* use earliest possibel date as fallback

* add missing createdAt date to tests

* fix typo

* clean up changes in tests

* fix typo

* change end date to current date

* fix: Fall back to empty host array when no hosts are found

* fix: Restructure code a little

* fixed test, incorrectly used now outdated var

* perf: remove Dayjs from getLuckyUser

* Refactor getHostsWithCalibration for optimised performance, intentionally break test as findMany is always an array

* Better mock for host.findMany

* Remove team-event-types.test.ts, move to appropriate package

* TypeScript cannot auto-infer that an array is non-empty when assigning to a var

* fix: Type Fixes and DistributionMethod enum add

* Optimise tests

* Added test to show that bookings made before a newHost was added affect the lucky user result

* Throw error when the usersWithHighestPriority is empty, which should never happen

* WIP

* remove comment

* update migrations

* get attributes weights and virtual queue data

* use attribute weights and use bookings of virtual queue only

* clean up migrations

* code clean up from feedback

* wrapper function for getLuckyUser

* code clean up

* fetch routingFormResponse in handleNewBooking

* fix type errors

* fix type errors in tests

* fix getAttributesQueryValue import for tests

* fix totalWeight

* add test for attributes weights and virtual queues

* clean up code

* add test for prepareQueuesAndAttributesData

* remove console.log

* use lazy import

* use findUnique

* add fallback weight

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2024-11-15 17:36:46 +00:00
Calcom BotandReplexica 322ca2a12c feat: update translations via @replexica (#17666)
* feat: update translations via @replexica

* feat: update translations via @replexica

* feat: update translations via @replexica

---------

Co-authored-by: Replexica <support@replexica.com>
2024-11-15 16:50:57 +00:00
Udit Takkar 9b79c037c2 fix: remove cal video logo from select (#17665)
* fix: remove cal video logo from managed event types

* fix: do not fetch cal video logo
2024-11-15 16:45:44 +00:00
Alex van Andel f76696032e feat: Implements maxLeadThreshold (#17643)
* feat: Implements maxLeadThreshold which puts a maximum to the amount of lead a given host is allowed to be ahead

* Refactor getLuckyUser + implement in handleNewBooking + move logic to repo

* Decoupled @prisma from findQualifiedHosts

* Fix deep type error

* Add maxLeadThreshold to builder.ts

* Removed a console.log :O + fix handleChildrenEventTypes input

* Add distribution UI to enable balanced distribution/maximize availability

* 0-1-2 instead of 0-1-2-3 (3 bookings instead of 4)
2024-11-15 15:54:02 +00:00
Udit Takkar c6aa57177a fix: filter cal video logo (#17663)
* fix: filter cal video logo

* chore: message
2024-11-15 14:38:28 +00:00
Anik Dhabal Babu 7f2db48c72 fix: meeting not created on hubspot for contacts (#17661) 2024-11-15 13:18:17 +00:00
c59e733b5a feat: exclude emails and domains (#17591)
* feat: add constants

* feat: add excludeEmails field in baseSchema

* feat: add ExcludeEmails Input field in FieldEditDialog

* feat: add excludedEmails check in FormBuilderField

* chore: move validations in getBookingResponsesSchema

* Update i18n string to be more generic

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

* feat: add validation on excludeEmails field

* feat: add fieldSchema as resolver

* chore: remove comment

* chore: format comments

* feat: add tests for excluded email or domains

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Peer Richelsen <peer@cal.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-11-15 10:46:25 +00:00
sean-brydon e0713d78b3 fix: z index issue on dropdown menu (#17656)
* portal content

* fix(dropdown-z-index-issue): Fixes dropdown z index issue by portaling
2024-11-15 10:51:39 +01:00
Max Prilutskiy d868b325c4 feat(CAL-4730): add sk-SK language (#17652) 2024-11-15 09:41:36 +00:00
Keith Williams f8f6c31e40 chore: v4.6.24 (#17639)
release.yml / release (push) Canceled after 0s
2024-11-14 18:06:09 -03:00
Anik Dhabal Babu 7246fa4084 fix: unable to update host schedules (#17649) 2024-11-14 15:37:19 -05:00
Benny Joo 039a0b7a85 chore: remove wrong logs (#17648) 2024-11-14 18:57:51 +00:00
sean-brydon 12c5005305 chore: routingform org attribute seed (#17510)
* seed to org

* more org+routing form seed improvments

* add routing responses

* check if attributes already exist to prevent crash

* remove files not for PR

* fix type error

* fix connection

* assign manually

* fix seed

* update to match integration tests

* fix integration test to new user count
2024-11-14 14:50:26 -03:00
Sachin Siddhuandunknown 1ff6465384 fix: resolved troubleshoot-sidbar bug (#17613)
* resolved troubleshoot-sidbar bug

* fulfilled review changes

---------

Co-authored-by: unknown <sachinsiddhu112@gmail.com>
2024-11-14 13:26:55 -03:00
ae706bb6d6 feat: update translations via @replexica (#17629)
* feat: update translations via @replexica

* feat: update translations via @replexica

* Delete packages/ui/components/data-table/tanstack.d.ts

* Delete packages/lib/sync/services/CloseComService.ts

* Delete packages/lib/sync/SyncServiceManager.ts

---------

Co-authored-by: Replexica <support@replexica.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-11-14 16:19:03 +00:00
Lauris Skraucis 538a39328e fix: useOAuthClient is not initialized (#17637) 2024-11-14 15:35:45 +00:00
Joe Au-Yeung 3589767099 feat: Salesforce when create contacts under an account if creating leads is chosen [CAL-4713] (#17630) 2024-11-14 10:29:21 -05:00
Kartik SainiandUdit Takkar ff2754f663 feat: allow marking no show after booking has started (#17625)
* feat: allow marking no show after booking has started

* fix

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-11-14 09:55:39 +00:00
Rajiv Sahal 65484e7d01 fix: BookerEmbed atom localStorage issues (#17611) 2024-11-14 08:07:47 +00:00
Peer Richelsen 4d942a454a chore: typo in webhook (#17628) 2024-11-14 02:57:03 +00:00
Keith WilliamsandUdit Takkar 7dbcb62724 perf: Move /me stats to separate tRPC handler (#17206)
* perf: Move /me stats to separate tRPC handler

* Update packages/trpc/server/routers/loggedInViewer/meStats.handler.ts

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>

* Rename and skipping batch for myStats

* Refactor of counting team events

* Fixed type issues

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-11-13 20:55:57 -03:00
Benny Joo c197c33fd0 chore: upgrade @vercel/og lib (#17503)
## What does this PR do?

<!-- Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. -->

- housekeeping: having important packages up to date

<!-- Please provide a loom video for visual changes to speed up reviews
 Loom Video: https://www.loom.com/
-->

## Mandatory Tasks (DO NOT REMOVE)

- [x] I have self-reviewed the code (A decent size PR without self-review might be rejected).
- [x] N/A - I have updated the developer docs in /docs if this PR makes changes that would require a [documentation change](https://cal.com/docs). If N/A, write N/A here and check the checkbox.
- [x] I confirm automated tests are in place that prove my fix is effective or that my feature works.

## How should this be tested?

<!-- Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration. Write details that help to start the tests -->

- Are there environment variables that should be set?
- What are the minimal test data to have?
- What is expected (happy path) to have (input and output)?
- Any other important info that could help to test that PR

## Checklist

<!-- Remove bullet points below that don't apply to you -->

- I haven't read the [contributing guide](https://github.com/calcom/cal.com/blob/main/CONTRIBUTING.md)
- My code doesn't follow the style guidelines of this project
- I haven't commented my code, particularly in hard-to-understand areas
- I haven't checked if my changes generate no new warnings
2024-11-13 13:50:21 -07:00
Keith Williams 815065d67c feat: Add API v2 logs to axiom (#17595)
## What does this PR do?

Adds ability to send API v2 logs to Axiom.

## Mandatory Tasks (DO NOT REMOVE)

- [x] I have self-reviewed the code (A decent size PR without self-review might be rejected).
- [x] N/A - I have updated the developer docs in /docs if this PR makes changes that would require a [documentation change](https://cal.com/docs). If N/A, write N/A here and check the checkbox.
- [x] I confirm automated tests are in place that prove my fix is effective or that my feature works.

## How should this be tested?

- Add the AXIOM_DATASET and AXIOM_TOKEN variables and run the API locally. Ensure the logs flow through to Axiom.
2024-11-13 13:18:53 -07:00
Omar López 02cecd6707 chore: Cleanup Close.com (#16929) 2024-11-13 15:14:31 -05:00
Keith Williams 81386d4ec0 chore: v4.6.23 (#17620)
release.yml / release (push) Canceled after 0s
2024-11-13 14:40:22 -03:00
Eunjae LeeandKeith Williams 204e411585 fix: make actions column sticky in members list (#17554)
* fix: make actions column sticky in members list

* clean up import

* change type definition

* remove old export

* include tanstack.d.ts

* fix declaration file

* move type def to packages/types/tanstack-table.d.ts

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-11-13 16:36:01 +00:00
Chiranjeev VishnoiandAnik Dhabal Babu 1ec1554cb5 change telly url (#17618)
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-11-13 16:21:53 +00:00
Udit Takkar 241579eba6 refactor: change title (#17623)
* chore: change title

* chore: translation
2024-11-13 16:19:58 +00:00
Udit Takkar 8ccad29ab6 fix: skip triggering webhook in case of duplicate email (#17612)
* fix: skip sending webhook in case of duplicate response

* fix: type err

* fix: tests

* refactor: only check email

* fix: type err

* chore: changed it to 60 min

* fix: use lowercase
2024-11-13 15:50:26 +00:00
74b4d7a94a chore: Automated API v2 OpenAPI spec generation (#17046)
* chore: Automated API v2 OpenAPI spec generation

* chore: update docs

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: Morgan Vernay <morgan@cal.com>
2024-11-13 15:28:25 +00:00
5c6e60b726 fix: fetch next batch if event types are empty and cursor exists (#17109)
* fix: fetch next batch if event types are empty and cursor exists

* refactor: improve

* fix: add isFetchingForFirstTime

* chore: add number type

* fix: type err

* chore: add logs

* fix: type err

* fix: conflict

* fix: bug

* fix: type err

* chore: try changing type

* chore: add nul

* Update packages/trpc/server/routers/viewer/eventTypes/getEventTypesFromGroup.handler.ts

Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>

* chore: use log.info

---------

Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
Co-authored-by: Bailey Pumfleet <bailey@pumfleet.co.uk>
2024-11-13 14:37:03 +00:00
Alex van Andel 18c7c3c940 fix: Refactor publicPageUrl to use getBookerBaseUrlSync like elsewhere (#17607) 2024-11-13 08:55:01 -05:00
fbd113b976 feat: update translations via @replexica (#17619)
* feat: update translations via @replexica

* feat: update translations via @replexica

* feat: update translations via @replexica

---------

Co-authored-by: Replexica <support@replexica.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-11-13 13:53:51 +00:00
Udit Takkar c7f11c6795 fix: mark guest are no show (#17588)
* fix: mark guest are no show

* chore: missing import

* nit

* fix: type err
2024-11-13 19:03:55 +05:30
Peer Richelsen 84fb9bb371 chore: update Instant Meeting Error Message (#17617)
* Update common.json

* small copy changes
2024-11-13 13:08:05 +00:00
Lauris Skraucis 1aac70f643 feat: v2 confirm / decline booking endpoints (#17578)
* feat: v2 confirm / decline booking endpoints

* chore: bump platform libraries

* fix: type export

* tests: accepting and declining a booking

* refactor: sent emails abstract renderEmail with getHtml that is mocked in test
2024-11-13 11:20:43 +02:00
Lauris Skraucis 0dde8c1db9 feat: v2 multiple length event types and their bookings (#17598)
* feat: input lengthInMinutesOptions

* Revert "feat: input lengthInMinutesOptions"

This reverts commit f23fd8a3861097f0972cc6210b47f25511d85183.

* feat: handle lengthInMinutesOptions input

* feat: handle lengthInMinutesOptions event output

* fix: remove it.only

* feat: variable length bookings
2024-11-13 11:13:11 +02:00
Ankit YadavandOmar López 0da3a9fa11 fix: #17602 (#17604)
Co-authored-by: Omar López <zomars@me.com>
2024-11-12 20:20:38 +00:00
Alex van Andel eb8f5bd194 chore: Release v4.6.22 (#17600)
release.yml / release (push) Canceled after 0s
2024-11-12 18:18:37 +00:00
Morgan 82901fee94 fix: apiv2 updateNewTeamMemberEventTypes silently (#17555)
* fix: apiv2 updateNewTeamMemberEventTypes silently

* fixup! fix: apiv2 updateNewTeamMemberEventTypes silently
2024-11-12 09:32:21 -03:00
Udit Takkar 4e0e3a47f5 perf: routing forms loading time (#17518)
* perf: routing forms loading time

* chore: add TODO

* chore

* chore: add type

* chore: use Promise.all

* fix: type err

* fix: fetch id

* refactor: don't fetch team

* chore: add name
2024-11-12 17:32:42 +05:30
Syed Ali Shahbaz dc400d80d0 fix: split firstname lastname support, phone support & timezone specific startTime in success redirect params (#17593)
* init

* fixes

* fix type
2024-11-12 11:16:41 +00:00
Morgan 6889592d4f feat: add free platform plan (#17581)
* feat: add free platform plan

* chore: handle payment success / fail overdue

* chore: refactor and add tests
2024-11-12 11:14:51 +02:00
Benny Joo 95b833bc1c fix: avatars are not showing in preview image (#17592) 2024-11-12 07:57:06 +00:00
b06683458a feat: update translations via @replexica (#17585)
Co-authored-by: Replexica <support@replexica.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-11-11 22:20:17 -03:00
Alex van Andel 01d764c6d3 chore: Release v4.6.21 (#17586)
release.yml / release (push) Canceled after 0s
2024-11-12 01:09:55 +00:00
Joe Au-YeungandAlex van Andel 49333addc2 feat: Salesforce RR skip based on a user on a lookup field on an account (#17526)
* move types to types file

* Create salesforce routing form components

* Save salesforce data to routing form

* Fixes

* Add event type service

* Change commenting

* Pass data from routing form to CRM

* Init Salesforce routing form booking form handler

* Salesforce find user associated with lookup field

* Add looking up the contact owner

* If salesforce is disabled then don't change the Salesforce option

* Small refactor

* Refactor getting event type app metadata

* Refactor eventType service

* Type fix

* Clean up

* Add translations

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

---------

Co-authored-by: Alex van Andel <emrysal@users.noreply.github.com>
2024-11-12 01:09:30 +00:00
Anik Dhabal Babu 6c40d29745 fix: incorrect booking title for diff users (#17448)
* fix: incorrect booking title for diff users

* Update common.json

* update
2024-11-11 19:10:45 +00:00
Anik Dhabal BabuandUdit Takkar fc5ad53a6d fix: hidden phone booking field should never need validation (#17564)
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-11-11 15:06:38 +00:00
Anik Dhabal Babu 4d38c6d0c7 fix: Form link in Reroute Dialog gives 404 for members that aren't admin/owner of team (#17537)
* fix: Form link in Reroute Dialog gives 404 for members that aren't admin/owner of team

* NIT
2024-11-11 20:25:08 +05:30
Anik Dhabal Babu cd15335eed chore: plain text plugin for Editor (#17134)
* chore: plain text plugin for Editor

* fix build error
2024-11-11 20:23:12 +05:30
Anik Dhabal Babu aaa75100d4 chore: Routing form - Add submitted_at and Sort entries with latest on top (#17491)
* chore: Routing form - Add submitted_at and sort rows by this timestamp in reporting

* fix e2e

* fix e2e

* Update basic.e2e.ts

* Update basic.e2e.ts

* Update
2024-11-11 19:57:57 +05:30
Anik Dhabal Babu ae9063804d fix: Users details leak for team booking when the team is private (#17403)
* fix: host details leaking

* type error

* update

* update

* Update getPublicEvent.ts

* Update getPublicEvent.ts
2024-11-11 19:55:42 +05:30
Calcom BotandReplexica 30f9d9b980 feat: update translations via @replexica (#17577)
Co-authored-by: Replexica <support@replexica.com>
2024-11-11 13:57:50 +00:00
Anik Dhabal Babu 42a264380a fix (#17548) 2024-11-11 09:37:14 +00:00
Hariom BalharaandUdit Takkar 62aaba32cf fix: Routing Form - Small Improvements (#17538)
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-11-11 14:14:39 +05:30
Amit Sharmaandsean-brydon aaf630f01f feat: Delete attribute modal (#17557)
* feat: Delete attribute modal

* move Modal file

---------

Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
2024-11-11 08:33:19 +00:00
Rajiv Sahal 838e8b9e89 fix: iframe issue for atoms documentation (#17570)
* fix syntax for code examples

* update iframe styling
2024-11-11 08:28:02 +00:00
Kunall Banerjee ce373c475c fix: incorrect capitalization in company names (#17573) 2024-11-10 08:42:29 +00:00
Omar López ed662b7956 chore: v4.6.20 (#17571)
release.yml / release (push) Canceled after 0s
2024-11-09 16:48:45 -07:00
Rajiv Sahal 2e49110ce6 fix: docs loom iframe fixes (#17569) 2024-11-09 08:41:55 +00:00
Rajiv Sahal fc35f9de85 fix: atoms docs (#17556)
* fixup

* resolve merge conflicts

* fixup fixup

* trying some fixups
2024-11-09 07:39:48 +00:00
Joe Au-Yeung 9c67ee7375 fix: pass upId to JWT if logging in through SAML (#17566)
* pass `upId` to JWT if logging in through SAML

* Correct change
2024-11-08 22:43:56 +00:00
Syed Ali Shahbaz a1477dc7e1 feat: adds attendee and host names, and guest emails in forwarded params (#17530)
* adds attendee and host names in forwarded params

* address feedback

* add guest emails
2024-11-08 22:33:22 +05:30
25d39b4273 feat: api v2 reassign (#17460)
* adding input types and method to controller/service

* add output types

* add outputs and inputs

* feat: reassign automatically

* feat: reassign to user

* e2e test for reassign

* update platform libraries + experimental modules for jest

* fix test

---------

Co-authored-by: supalarry <laurisskraucis@gmail.com>
Co-authored-by: Lauris Skraucis <lauris.skraucis@gmail.com>
2024-11-08 12:42:59 +00:00
sean-brydon 4976e24e12 fix: move team availability search to serverside (#17549)
* move search to severside

* transform zod stirng to lowercase
2024-11-08 15:32:08 +05:30
Rajiv Sahal 5ae6e1e983 fix: loom iframe for atoms (#17550) 2024-11-08 09:37:29 +00:00
Omar López 6080ce0c85 v4.6.19 (#17524)
release.yml / release (push) Canceled after 0s
2024-11-07 13:24:04 -07:00
Morgan 79f0754bf7 chore: add sentry performance tracking api-v2 (#17519)
* chore: add sentry performance tracking api-v2

* fixup! chore: add sentry performance tracking api-v2
2024-11-07 09:38:05 -03:00
Vijay 540bd478dc fix: issues in 'updateNewTeamMemberEventTypes' while adding new team member. (#17513)
* fix due to error in zod.parse while adding new member

* updated zod parsing

* fix the linking with parent

* update fixture to accomodate assignAllTeamMembers for team and org scenarios

* added e2e test

* nit
2024-11-07 10:33:25 +00:00
9873e373dc feat: reset RR every month (#17472)
* only fetch bookings of this month for weighted rr

* test set up

* only get bookings of current month

* reverts test setup

* first changes for weight adjustments

* reset booking count + adjust calibration

* depreciate weightAdjustment

* get only bookings created this month

* fix typo

* make sure createdAt for hosts is correct

* use earliest possibel date as fallback

* add missing createdAt date to tests

* fix typo

* clean up changes in tests

* fix typo

* change end date to current date

* fix: Fall back to empty host array when no hosts are found

* fix: Restructure code a little

* fixed test, incorrectly used now outdated var

* perf: remove Dayjs from getLuckyUser

* Refactor getHostsWithCalibration for optimised performance, intentionally break test as findMany is always an array

* Better mock for host.findMany

* Remove team-event-types.test.ts, move to appropriate package

* TypeScript cannot auto-infer that an array is non-empty when assigning to a var

* fix: Type Fixes and DistributionMethod enum add

* Optimise tests

* Added test to show that bookings made before a newHost was added affect the lucky user result

* Throw error when the usersWithHighestPriority is empty, which should never happen

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2024-11-07 10:24:23 +00:00
Morgan 52093862ce fix: booker timezone from preference instead of session (#17520)
* fix: booker timezone from preference instead of session

* fixup! fix: booker timezone from preference instead of session
2024-11-07 09:42:38 +00:00
Rajiv Sahal e101f35b4f fix: event types atom docs (#17528)
* fix syntax for code examples

* fixup
2024-11-07 07:59:29 +00:00
Benny Joo 54cc2dc094 chore: cache meeting OG images for 1 day (#17525) 2024-11-07 00:19:20 +00:00
Alex van Andel 88e57d48fe chore: Release v4.6.18 (#17521)
release.yml / release (push) Canceled after 0s
2024-11-06 20:39:39 +00:00
Morgan 6619701c88 fix: event-type atom tabs controlled by prop (#17480)
* fix: event-type atom tabs controlled by prop

* fixup! fix: event-type atom tabs controlled by prop
2024-11-06 20:38:29 +00:00
Alex van Andel ebb8bb9c67 fix: saml does not add future members to eventtype (#17522)
* fix: trigger createOrUpdateMemberships from dsync so SAML signup triggers side-effects

* forEach does not wait for async/await done-ness
2024-11-06 20:38:08 +00:00
Alex van Andel 993bf23986 fix: common schedule toggling incorrectly (#17517)
* fix: common schedule toggling incorrectly

* type fixes to util.ts

* fix another type error
2024-11-06 20:21:25 +00:00
Benny Joo 4b38d73120 chore: Replace prisma actions Credential Repository / SelectedCalendar Repository methods (#17443)
* create lib/google.ts

* add updateAvatar method to UserRepository

* refactor googlecalendar callback

* add methods to CredentialRepository and SelectedCalendarRepository

* refactor

* remove not needed code

* refactor

* make credential repository and selected calendar repository more generic

* create GoogleRepository
2024-11-06 14:48:45 -05:00
Joe Au-Yeung 8f92c9b884 fix: Logic fix in Salesforce no-show (#17516) 2024-11-06 16:49:31 +00:00
Joe Au-Yeung edecc7e60e feat: Round Robin Skip If contact owner first available slot is more than 2 weeks, round robin as normal [CAL-4651] (#17416)
* If contact owner first available slot is more than 2 weeks. Normal RR

* If contact owner first available slot is more than 2 weeks. Normal RR

* Type fix

* Add test
2024-11-06 15:51:36 +00:00
sean-brydon 4eefb08a0d fix: copy to clipboard button (#17512)
* fixes copy to clipboard button

* fix type error
2024-11-06 15:11:55 +00:00
Calcom BotandReplexica 3f7e632d5c feat: update translations (#17474)
Co-authored-by: Replexica <support@replexica.com>
2024-11-06 10:08:15 -03:00
Peer RichelsenandhbjORbj ef0fd67646 chore: Update SettingsLayout.tsx (#17498)
* chore: Update SettingsLayout.tsx

* Update settings layout app dir

---------

Co-authored-by: hbjORbj <sldisek783@gmail.com>
2024-11-06 09:34:30 -03:00
Hariom Balhara 8e7567b6a3 fix: Don't let the modal close accidentally atleast when loading (#17508)
* Dont close during loading state

* Dont allow accidental close when loader is running
2024-11-06 09:34:04 -03:00
Amit Sharma 193855d60f fix: routing form: dont disable routing and reporting on mobile and fix mobile layout instead (#17444) 2024-11-06 09:29:51 -03:00
Vincent Lam 92fafa7344 feat: team availability - vertical selector and align times (#17502)
* - aligns timezones offset by minutes and add minutes to the label in the teams availability view
- added a vertical guidebar to view alligned schedules

* fixed type-check issues
2024-11-06 10:37:21 +00:00
Somay Chauhan 21aaff01fa fix: disable all emails modal crash on platform (#17485) 2024-11-06 10:43:57 +02:00
e07a28a2d1 feat: Salesforce - on booking write to fields on person record [CAL-4575] (#17477)
* Create data type

* Start FE component

* Rename and add new enums

* Add new schema for write on record options

* Add options on the frontend

* Backend write to custom fields

* refactor: Salesforce - on booking write to fields on person record (#17481)

* Add translations

* fix: type err

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Udit Takkar <udit222001@gmail.com>
2024-11-06 08:20:52 +00:00
Benny Joo 9a1b5ad149 fix: broken social media preview and title (#17504)
* fix: broken social media preview and title

* remove code not needed
2024-11-06 13:28:35 +05:30
Benny Joo 1c20a257d3 fix: missing responsive design in org members page (#17495) 2024-11-05 17:26:41 +00:00
Keith Williams 656fcafcde chore: v4.6.17 (#17493)
release.yml / release (push) Canceled after 0s
2024-11-05 14:05:23 -03:00
Nizzy 6d7080d9fc posthog vars (#17494) 2024-11-05 16:37:00 +00:00
Rajiv Sahal f943debda2 feat: docs for event type atom (#17466)
* init docs for event types atom

* images for event type atom

* update images for destination and selected calendar atoms

* update event types docs
2024-11-05 13:32:45 -03:00
Benny Joo 803fc488c5 feat: Add feature to download routing form reports (#17400)
* remove unused code

* add i18n for some strings in widget

* improve UI and add download button to routing forms reporting page

* add more margin

* add downloadAsCsv util

* integrate download button to routing forms report

* refactor

* address comment

* escape comma multiselect items

* fix

* preserve new lines and wrapping

* refactor

* ensure fetching all rows
2024-11-05 11:21:13 -05:00
Rajiv Sahal 423b284278 feat: docs for payment form atom (#17360)
* init docs for stripe payment form

* update payment form page

* fixup
2024-11-05 15:48:51 +00:00
sean-brydon f1111c9d42 show who a RR was reassigned to on auto (#17454) 2024-11-05 12:44:49 -03:00
Keith Williams ed6d88e30f perf: Allow bypassing 3rd party calendar busy times (#17492)
* perf: Allow bypassing 3rd party calendar busy times

* Fix typing issues

* Fixed API v1 build

* Another fix for API v1
2024-11-05 12:31:33 -03:00
Morgan e675ad0c6c fix: assign/remove team members to team event-types (#17483)
* fix: assign/remove team members to team event-types

* fixup! fix: assign/remove team members to team event-types

* fixup! fixup! fix: assign/remove team members to team event-types

* fixup! fixup! fixup! fix: assign/remove team members to team event-types
2024-11-05 17:19:43 +02:00
Udit Takkar a01d6aa932 feat: instant meeting modal improvements (#17489)
* feat: instant meeting modal improvements

* fix: type error
2024-11-05 09:02:30 -05:00
Lauris Skraucis f868b86e6c fix: creating recurring seated bookings for same timeslot (#17482)
* fix: creating multiple seated bookings for same timeslot

* add test
2024-11-05 15:38:21 +02:00
Peer Richelsen db0a7f3797 chore: Update build-an-app.mdx (#17486) 2024-11-05 13:38:07 +00:00
Morgan 5646229cd4 fix: cancelling invalid booking seat apiv2 (#17484) 2024-11-05 12:30:38 +01:00
Anik Dhabal Babu f39e817d86 fix: Update the common schedule toggle check logic (#17439) 2024-11-05 05:10:12 +00:00
Benny Joo 42c4c134fa chore: add missing i18n to metadata in settings/platform pages (#17476) 2024-11-05 02:37:12 +00:00
Benny Joo ffc4b69edd fix: api/routing-forms/responses (#17473)
* add i18n

* fix api/routing-forms/responses
2024-11-04 13:19:58 -06:00
Benny Joo 7263f3b00b chore: settings/platform, settings/license-key - remove pages router and use app router (#17384)
* remove pages router and use app router

* remove pages router and use app router for platform

* add billing page and plans page

* fix metadata for settings/platform/billing

* fix metadata for settings/platform/oauth-clients
2024-11-04 15:56:43 -03:00
ddfbe25903 feat: adding posthog libs for node + js (#16862)
* adding posthog libs for node + js

* updating lock

* move to init posthog client - pageview component

* adding identify in shell

* removing test event captures

* add signout tracking to dropdown as it was missed in merge

---------

Co-authored-by: sean <sean@brydon.io>
Co-authored-by: Bailey Pumfleet <bailey@pumfleet.co.uk>
2024-11-04 11:46:25 -05:00
Udit Takkar dd944bace1 chore: add webhook in payload (#17468)
* chore: add webhook in payload

* chore

* chore: add more fields

* add host email

* chore: change name
2024-11-04 16:12:34 +00:00
Keith Williams e4353af839 chore: v4.6.16 (#17465)
release.yml / release (push) Canceled after 0s
2024-11-04 10:57:04 -03:00
Keith Williams ff9936fdeb perf: Remove extra spans (#17464) 2024-11-04 13:50:49 +00:00
Benny Joo e74718ac66 fix: ensure fetching all members before exporting csv (#17461) 2024-11-04 11:35:37 +00:00
Udit Takkar a78f10223f chore: remove no option text (#17457) 2024-11-04 09:47:36 +00:00
Benny Joo c850af7409 feat: allow users to generate csv tables for org members (#17458)
* add downloadAsCsv util

* add sanitizeValue util

* write logic

* refactor

* add tests
2024-11-04 09:47:10 +00:00
Udit Takkar 06dccd7a73 fix: filter by team url (#17456) 2024-11-04 09:45:14 +00:00
Rajiv Sahal 3b59185f75 fix typo (#17451) 2024-11-04 08:39:42 +00:00
Morgan c907bcc646 fix: docs mdx syntax errors (#17450)
* fix: docs mdx syntax errors

* fixup! fix: docs mdx syntax errors
2024-11-04 08:20:33 +00:00
Keith Williams 6c5b12db57 perf: Add more specific spans to getBusyTimes (#17437)
* perf: Add more specific spans to getBusyTimes

* fix types
2024-11-03 12:21:35 -03:00
Benny Joo bcc1a8e279 fix: create a wrapper for google calendar fetching (#17440)
* fix: create a wrapper for google cal fetching

* improve log
2024-11-01 20:22:57 -03:00
Benny Joo a277b8515f fix (#17441) 2024-11-01 20:51:02 +00:00
Benny Joo 42a02cbb60 fix: update profile photo logic in google cal integration (#17438) 2024-11-01 16:21:42 -04:00
sean-brydon 0c60ad1c82 fix: no availability email frequency fix (#17430)
* pass teamID to only send to relevant team admins not org admins

* a lot more tests aroundthis

* improve test cases

* change reddis cache key to take into account teamID

* add start/endtime to template

* fix text in email

* add endTime to tests
2024-11-01 14:54:34 -04:00
Keith Williams 1add8e73ee Revert "perf: Cache app store imports (#17425)" (#17428)
This reverts commit b2d395de52.
2024-10-31 19:26:55 +00:00
Alex van Andel ff15ec77c3 chore: Release v4.6.15 (#17422)
release.yml / release (push) Canceled after 0s
2024-10-31 18:20:08 +00:00
Keith Williams b2d395de52 perf: Cache app store imports (#17425) 2024-10-31 18:19:07 +00:00
Joe Au-Yeung 9dd35c7ab3 feat: Salesforce only get active users (#17426)
* Add isActive boolean when querying owners

* Add conditional
2024-10-31 18:18:35 +00:00
Calcom BotandReplexica bd30bf335b feat: update translations (#17421)
Co-authored-by: Replexica <support@replexica.com>
2024-10-31 17:56:22 +00:00
Hariom BalharaandCarinaWolli ab65ce73c2 feat: Support Attribute Logic fallback (#17290)
* Add fallback

* Support attribute query fallback

* Refactor

* Add tests and cleanup SingleFofrm

* small text fixes

* With fallback in picture, we dont throw error in preview now instead we capture errors and show them gracefully

* Get attribute logic preview working without saving Fixes CAL-4582

* Abstract useRoutes out

* Update e2e

* Dont define Page component again and again

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2024-10-31 12:53:34 -04:00
Joe Au-Yeung 06f83e6d0d feat: Send Attendee No Show Data To Salesforce (#17423) 2024-10-31 16:39:58 +00:00
Udit Takkarandsean ebd5ca6ceb fix: people filter in /bookings and create OOO modal (#17396)
* fix: people filter in /bookings

* refactor: lazy loading people filters and OOO

* chore: type errr

* chore: type errr

* fallback to username if no name is set

---------

Co-authored-by: sean <sean@brydon.io>
2024-10-31 14:57:07 +00:00
Benny Joo bcaf067ed6 fix: routing-forms reporting - "in" operator logic is broken + overall refactor (#17398)
* fix: routing-forms reporting - "in" operator logic is broken

* fix

* refactor to make it more readable

* add tests
2024-10-31 14:09:10 +00:00
Morgan 120cfed8a1 fix: booker platform atom multi duration selector (#17419) 2024-10-31 13:14:58 +00:00
sean-brydonandPeer Richelsen e728ed74ce fix: add reassignedBy and reassign reason to both status (#17418)
* add reassignedBy and reassign reason to both status

* fix w-full issue in dialog

* fix types

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2024-10-31 12:32:05 +00:00
Keith Williams 64551a506a perf: Allow configuring Sentry:debug (#17420) 2024-10-31 12:15:07 +00:00
fee3b73bda feat: payment card atom (#17203)
* init stripe payment card atom

* cleanup

* update platform libraries

* fix import paths

* add function to get user payment info

* fixup

* resolve merge conflicts

* fixup fixup

* resolve merge conflicts

* fixup

* update payments page

* add props for event payment success and failure

* update props and fixup

* bump platform libraries

* fixup! bump platform libraries

* fixup! fixup! bump platform libraries

* resolve merge conflicts

* fix: add isActive to paymentsTab

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: Morgan Vernay <morgan@cal.com>
2024-10-31 11:21:13 +02:00
Carina WollendorferandCarinaWolli a5806cb100 fix: buffers in handleNewBooking (#17415)
* send missing buffer params

* add tests

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2024-10-31 08:48:36 +00:00
Anik Dhabal Babu f649f8b78d fix: huddle01 is not generating meeting link (#17417)
* fix: huddle01 not generating link

* update
2024-10-31 04:59:34 +00:00
Keith Williams ced7f282a5 perf: Reduce spans in highPerf temporarily (#17413)
* perf: Reduce spans in highPerf temporarily

* Just completely removed them
2024-10-30 16:15:51 +00:00
Keith Williams dfa4e10c83 perf: Add ability to configure Sentry maxSpans (#17411) 2024-10-30 13:54:22 +00:00
c3f03c8000 fix: stripe connect atom for teams (#17294)
* add teamId

* add team app credentials method

* add handler to get all user admin teams

* update stripe logic to handle teams

* enable teamId for stripe connect

* update stripe module

* implement PR feedback

* update stripe module

* add method to get credential by team id

* handle check for teamId

* abstract team and individual stripe connection checks into two separate parts

* hide badge for platform user

* update typing

* add logic to handle team stripe integration

* bump platform libraries

* bump platform libraries

---------

Co-authored-by: Morgan Vernay <morgan@cal.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2024-10-30 15:28:26 +02:00
Benny Joo aaa5531a3a fix: main google calendar not being connected (#17404) 2024-10-30 11:58:44 +00:00
Morgan 2784564427 Revert "chore: ensure idempotency cancel booking api v2" (#17407)
This reverts commit 85246f0754.
2024-10-30 11:37:17 +00:00
Keith Williams 4b6b9badf5 perf: Sentry perf tracing (#17202)
* chore: Add Sentry perf tracing

* Just to build

* perf: Add Sentry perf tracing

* Removed the extra integrations tracing config

* Got spans working

* Now only tracing individual endpoints

* Removed check for booking event type

* Fixed types

* Added back for spans

* Consolidate sentry imports

* Added more traces

* Moved the start of the span
2024-10-30 00:35:25 +00:00
Keith Williams 5a9197f357 chore: v4.6.14 (#17390)
release.yml / release (push) Canceled after 0s
2024-10-29 19:45:41 -03:00
Calcom BotandReplexica 315077f6c5 feat: update translations (#17393)
Co-authored-by: Replexica <support@replexica.com>
2024-10-29 21:47:57 +00:00
Joe Au-Yeung b8b270e4e5 feat: Prioritize Contacts Over Leads If They Exist [CAL-4640] (#17380) 2024-10-29 19:53:44 +00:00
Udit Takkar 04a49e74d4 fix: round robin re assignment google meet bug (#17378) 2024-10-29 15:27:37 -04:00
Joe Au-Yeung 8605fb4518 feat: Salesforce change owner on booking [CAL-4641] (#17391)
* Let user set owner name to change

* Add backend logic

* Add message

* Type fixes
2024-10-29 14:03:35 -04:00
sean-brydon 55671db5fd fix: virtualised member list for reassign (#17386)
* wip virtual list for reassignemnt

* order in alpha and prio order

* loading state

* add flex shrink so status symbol doesnt move

* add gap to each item to make hovering not feel squished

* a11y indexing tab group

* useInViewObserver hook and move to lib

* use correct intersection target

* fix type errors

* remove type
2024-10-29 17:47:43 +00:00
Benny Joo 1a55f2d8e2 chore: settings/admin - remove pages router and use app router & add i18n to metadata (#16773)
* remove pages router and  use app router

* remove testBothFutureAndLegacyRoutes for settings-admin e2e test

* add i18n for metadata
2024-10-29 12:49:21 -03:00
Morgan b82b39d12d chore: add logs api-v2 booking controllers (#17389)
* chore: add logs apiv2 booking controller

* chore: add logs apiv2 booking controller
2024-10-29 12:02:22 +00:00
Morgan 85246f0754 chore: ensure idempotency cancel booking api v2 (#17385) 2024-10-29 12:20:42 +02:00
Anik Dhabal Babu c5becb0990 fix: few ui fixes on booking page (#17383) 2024-10-29 07:45:06 +00:00
Calcom BotandReplexica 13d4102dc8 feat: update translations (#17231)
Co-authored-by: Replexica <support@replexica.com>
2024-10-28 21:50:48 +00:00
Keith Williams 8ab73caeb9 chore: v4.6.13 (#17362)
release.yml / release (push) Canceled after 0s
2024-10-28 17:57:19 -03:00
Joe Au-Yeung e6dbb19eff feat: Salesforce RR Skip Accounts - Match On Email Domain (#17350)
* If RR skip is set to account, match on email domain of existing contacts

* Revert changes on main merge

* Refactor
2024-10-28 13:54:32 -04:00
Avishay Maor 2efa593d2b Update RerouteDialog.test.tsx resolve lint errors (#17352) 2024-10-28 22:49:11 +05:30
Ali NawazandAnik Dhabal Babu dfc73ea307 fix: Inconsistent phrasing and remove duplicate words in localization (#17339)
* Fix inconsistent phrasing and remove duplicate words in localization strings in common.json

* Update common.json

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-10-28 14:10:55 +00:00
Lauris Skraucis ed6c94b941 docs: platform api calls (#17356) 2024-10-28 13:42:37 +00:00
Lauris Skraucis f8cbf4693b docs: AvailabilitySettings toggle animation fix (#17355) 2024-10-28 08:24:30 +00:00
Benny Joo 7d51741de0 fix: lint errors (#17351) 2024-10-27 08:05:27 +05:30
Anik Dhabal Babu 7e3f60bbdb fix: wrong attende details shows for seated event (#17338) 2024-10-26 11:46:59 +05:30
836 changed files with 53797 additions and 16939 deletions
+21 -5
View File
@@ -16,7 +16,7 @@
# - Acquire a commercial license to remove these terms by visiting: cal.com/sales
#
# To enable enterprise-only features please add your environment variable to the .env file then make your way to /auth/setup to select your license and follow instructions.
# To enable enterprise-only features please add your environment variable to the .env file then make your way to /auth/setup to select your license and follow instructions.
CALCOM_LICENSE_KEY=
# Signature token for the Cal.com License API (used for self-hosted integrations)
# We will give you a token when we provide you with a license key this ensure you and only you can communicate with the Cal.com License API for your license key
@@ -102,6 +102,11 @@ NEXT_PUBLIC_INTERCOM_APP_ID=
# Secret to enable Intercom Identity Verification
INTERCOM_SECRET=
# Posthog Config
NEXT_PUBLIC_POSTHOG_KEY=
NEXT_PUBLIC_POSTHOG_HOST=
# Zendesk Config
NEXT_PUBLIC_ZENDESK_KEY=
@@ -124,6 +129,10 @@ GOOGLE_LOGIN_ENABLED=false
# Needed to enable Google Calendar integration and Login with Google
# @see https://github.com/calcom/cal.com#obtaining-the-google-api-credentials
GOOGLE_API_CREDENTIALS=
# Token to verify incoming webhooks from Google Calendar
GOOGLE_WEBHOOK_TOKEN=
# Optional URL to override for tunelling webhooks. Defaults to NEXT_PUBLIC_WEBAPP_URL.
GOOGLE_WEBHOOK_URL=
# Inbox to send user feedback
SEND_FEEDBACK_EMAIL=
@@ -137,6 +146,11 @@ NEXT_PUBLIC_SENDGRID_SENDER_NAME=
# Sentry
# Used for capturing exceptions and logging messages
NEXT_PUBLIC_SENTRY_DSN=
SENTRY_DEBUG=
SENTRY_DISABLE_CLIENT_SOURCE_MAPS=
SENTRY_DISABLE_SERVER_SOURCE_MAPS=
SENTRY_MAX_SPANS=
SENTRY_TRACES_SAMPLE_RATE=
# Formbricks Experience Management Integration
NEXT_PUBLIC_FORMBRICKS_HOST_URL=https://app.formbricks.com
@@ -334,10 +348,6 @@ AB_TEST_BUCKET_PROBABILITY=50
# whether we redirect to the future/event-types from event-types or not
APP_ROUTER_EVENT_TYPES_ENABLED=0
APP_ROUTER_SETTINGS_DEVELOPER_ENABLED=0
APP_ROUTER_SETTINGS_ADMIN_ENABLED=0
APP_ROUTER_SETTINGS_MY_ACCOUNT_ENABLED=0
APP_ROUTER_SETTINGS_PLATFORM_ENABLED=0
APP_ROUTER_SETTINGS_ORG_ENABLED=0
APP_ROUTER_APPS_INSTALLED_CATEGORY_ENABLED=0
APP_ROUTER_APPS_SLUG_ENABLED=0
APP_ROUTER_APPS_SLUG_SETUP_ENABLED=0
@@ -385,6 +395,9 @@ UNKEY_ROOT_KEY=
# https://retellai.com
RETELL_AI_KEY=
# Used for the huddle01 integration
HUDDLE01_API_TOKEN=
# Used to disallow emails as being added as guests on bookings
BLACKLISTED_GUEST_EMAILS=
@@ -400,3 +413,6 @@ 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=
# Used to use Replexica SDK, a tool for real-time AI-powered localization
REPLEXICA_API_KEY=
+50
View File
@@ -0,0 +1,50 @@
name: Draft release
run-name: Draft release ${{ inputs.next_version }}
on:
workflow_dispatch:
inputs:
next_version:
required: true
type: string
description: 'Version name'
permissions:
contents: write
jobs:
draft_release:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: 'main'
token: ${{ secrets.GH_ACCESS_TOKEN }}
- name: Configure git
run: |
git config --local user.email "github-actions@github.com"
git config --local user.name "GitHub Actions"
- uses: ./.github/actions/yarn-install
- name: Bump version
run: |
cd apps/web
yarn version ${{ inputs.next_version }}
- name: Commit changes
run: |
git add .
git commit -m "chore: release v${{ inputs.next_version }}"
git push
- name: Draft release
run: gh release create v$VERSION --generate-notes --draft
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ inputs.next_version }}
-1
View File
@@ -65,7 +65,6 @@ jobs:
- uses: actions/checkout@v4
- uses: ./.github/actions/dangerous-git-checkout
- uses: ./.github/actions/yarn-install
- uses: ./.github/actions/yarn-playwright-install
- uses: ./.github/actions/cache-db
- name: Run Tests
working-directory: apps/api/v2
@@ -0,0 +1,21 @@
name: Changes Requested
on:
pull_request_review:
types: [submitted]
jobs:
changes-requested:
if: github.event.review.state == 'changes_requested'
runs-on: ubuntu-latest
steps:
- name: Explanation
run: echo "This workflow triggers a workflow_run; it's necessary because otherwise the repo secrets aren't available for 'pull_request_review' events from externally forked pull requests"
- name: Save PR number to context.json
run: |
printf '{
"pr_number": ${{ github.event.pull_request.number }}
}' >> context.json
- uses: actions/upload-artifact@v3
with:
name: context.json
path: ./
+25
View File
@@ -0,0 +1,25 @@
name: Post release
on:
workflow_dispatch:
push:
# Pattern matched against refs/tags
tags:
- "*"
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: 'main'
token: ${{ secrets.GH_ACCESS_TOKEN }}
- name: Configure git
run: |
git config --local user.email "github-actions@github.com"
git config --local user.name "GitHub Actions"
- run: git push origin +main:production
+104
View File
@@ -0,0 +1,104 @@
name: Re-draft
on:
workflow_run:
workflows:
- Changes Requested
types:
- completed
permissions:
contents: write
issues: write
pull-requests: write
statuses: write
jobs:
download-context:
if: ${{ github.event.workflow_run.conclusion == 'success' }}
runs-on: ubuntu-latest
steps:
- name: 'Download artifact'
uses: actions/github-script@v7
with:
script: |
let allArtifacts = await github.rest.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: context.payload.workflow_run.id,
});
let matchArtifact = allArtifacts.data.artifacts.filter((artifact) => {
return artifact.name == "context.json"
})[0];
let download = await github.rest.actions.downloadArtifact({
owner: context.repo.owner,
repo: context.repo.repo,
artifact_id: matchArtifact.id,
archive_format: 'zip',
});
let fs = require('fs');
fs.writeFileSync(`${process.env.GITHUB_WORKSPACE}/context.zip`, Buffer.from(download.data));
- name: 'Unzip artifact'
run: unzip context.zip
- name: 'Return Parsed JSON'
uses: actions/github-script@v7
id: return-parsed-json
with:
script: |
let fs = require('fs');
let data = fs.readFileSync('./context.json');
return JSON.parse(data);
outputs:
pr_number: ${{fromJSON(steps.return-parsed-json.outputs.result).pr_number}}
re-draft:
needs:
- download-context
permissions:
contents: write
issues: write
pull-requests: write
statuses: write
runs-on: ubuntu-latest
steps:
- run: echo "This PR was rejected"
- name: Convert PR to draft when changes are requested
uses: actions/github-script@v7
with:
script: |
async function getPullRequestId() {
const pull_number = ${{ needs.download-context.outputs.pr_number }};
const pullRequest = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number,
});
if (!pullRequest.data.node_id) throw new Error(`pullRequestId no found for '${pull_number}'`);
return pullRequest.data.node_id;
}
const query = `
mutation($id: ID!) {
convertPullRequestToDraft(input: { pullRequestId: $id }) {
pullRequest {
id
number
isDraft
}
}
}
`;
const pullRequestId = await getPullRequestId();
const variables = {
id: pullRequestId,
}
const response = await github.graphql(query, variables)
if (!response.convertPullRequestToDraft) {
throw new Error("Failed to convert pull request to draft");
}
console.info("Pull request successfully converted to draft.");
console.info(`Draft conversion response: ${JSON.stringify(response, null, 2)}`);
-17
View File
@@ -1,17 +0,0 @@
on:
workflow_dispatch:
push:
# Pattern matched against refs/tags
tags:
- "*" # Push events to every tag not containing /
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: main # Always checkout main even for tagged releases
fetch-depth: 0
token: ${{ secrets.GH_ACCESS_TOKEN }}
- run: git push origin +main:production
File diff suppressed because one or more lines are too long
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@calcom/prisma"
View File
+8
View File
@@ -0,0 +1,8 @@
undecided:
- calcom-monorepo
- "@calcom/app-store-cli"
- "@calcom/platform-constants"
- "@calcom/platform-enums"
- "@calcom/platform-types"
- "@calcom/platform-utils"
- "@calcom/prisma"
+8
View File
@@ -0,0 +1,8 @@
undecided:
- calcom-monorepo
- "@calcom/app-store-cli"
- "@calcom/platform-constants"
- "@calcom/platform-enums"
- "@calcom/platform-types"
- "@calcom/platform-utils"
- "@calcom/prisma"
+2
View File
@@ -5,5 +5,7 @@ plugins:
spec: "@yarnpkg/plugin-interactive-tools"
- path: .yarn/plugins/@yarnpkg/plugin-workspace-tools.cjs
spec: "@yarnpkg/plugin-workspace-tools"
- path: .yarn/plugins/@yarnpkg/plugin-version.cjs
spec: "@yarnpkg/plugin-version"
yarnPath: .yarn/releases/yarn-3.4.1.cjs
@@ -199,6 +199,7 @@ async function handler(req: NextApiRequest) {
eventTypeId,
userId,
returnDateOverrides: true,
bypassBusyCalendarTimes: false,
});
const team = await prisma.team.findUnique({
where: { id: teamId },
@@ -235,6 +236,7 @@ async function handler(req: NextApiRequest) {
dateTo,
eventTypeId,
returnDateOverrides: true,
bypassBusyCalendarTimes: false,
}),
};
});
@@ -81,7 +81,7 @@ describe("retrieveScopedAccessibleUsers tests", () => {
where: { email: "member2-acme@example.com" },
});
expect(accessibleUserIds.length).toBe(3);
expect(accessibleUserIds.length).toBe(11);
expect(accessibleUserIds).toContain(memberOneUser.id);
expect(accessibleUserIds).toContain(memberTwoUser.id);
expect(accessibleUserIds).toContain(adminUser.id);
+4
View File
@@ -28,3 +28,7 @@ API_KEY_PREFIX=cal_
GET_LICENSE_KEY_URL="https://console.cal.com/api/license"
IS_E2E=false
DOCS_URL=
# Axiom logging
AXIOM_DATASET=
AXIOM_TOKEN=
+1 -1
View File
@@ -10,7 +10,7 @@
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"setupFiles": ["<rootDir>/test/setEnvVars.ts"],
"setupFiles": ["<rootDir>/test/setEnvVars.ts", "jest-date-mock"],
"reporters": ["default", "jest-summarizing-reporter"],
"workerIdleMemoryLimit": "512MB",
"maxWorkers": 2
+9 -4
View File
@@ -11,7 +11,7 @@
"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-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": "yarn dev:build && ts-node scripts/docker-start.ts && 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",
@@ -19,16 +19,17 @@
"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 && NODE_OPTIONS='--max_old_space_size=8192' jest --ci --forceExit --config ./jest-e2e.json",
"test:e2e": "yarn dev:build && NODE_OPTIONS='--max_old_space_size=8192 --experimental-vm-modules' 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"
},
"dependencies": {
"@axiomhq/winston": "^1.2.0",
"@calcom/platform-constants": "*",
"@calcom/platform-enums": "*",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.54",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.63",
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
@@ -45,7 +46,9 @@
"@nestjs/platform-express": "^10.0.0",
"@nestjs/swagger": "^7.3.0",
"@nestjs/throttler": "6.2.1",
"@sentry/nestjs": "^8.37.1",
"@sentry/node": "^8.8.0",
"@sentry/profiling-node": "^8.37.1",
"body-parser": "^1.20.2",
"bull": "^4.12.4",
"class-transformer": "^0.5.1",
@@ -67,7 +70,8 @@
"rxjs": "^7.8.1",
"stripe": "^15.3.0",
"uuid": "^8.3.2",
"winston": "^3.11.0",
"winston": "^3.13.0",
"winston-transport": "^4.7.0",
"zod": "^3.22.4"
},
"devDependencies": {
@@ -83,6 +87,7 @@
"@types/passport-jwt": "^3.0.13",
"@types/supertest": "^2.0.12",
"jest": "^29.7.0",
"jest-date-mock": "^1.0.10",
"prettier": "^2.8.6",
"source-map-support": "^0.5.21",
"supertest": "^6.3.3",
+35
View File
@@ -0,0 +1,35 @@
import { execSync } from "child_process";
function checkCommandExists(command: string): boolean {
try {
execSync(`${command} --version`, { stdio: "ignore" });
return true;
} catch (e) {
return false;
}
}
try {
// Check if docker is installed
if (!checkCommandExists("docker")) {
throw new Error("Docker is not installed");
}
// Try docker compose first (new syntax)
try {
execSync("docker compose version", { stdio: "ignore" });
console.log("Starting containers with docker compose...");
execSync("docker compose up -d", { stdio: "inherit" });
} catch (e) {
// Fall back to docker-compose if the above fails
if (checkCommandExists("docker-compose")) {
console.log("Starting containers with docker-compose...");
execSync("docker-compose up -d", { stdio: "inherit" });
} else {
throw new Error("Neither 'docker compose' nor 'docker-compose' command is available");
}
}
} catch (error) {
console.error(`Error: ${error instanceof Error ? error.message : "Unknown error occurred"}`);
process.exit(1);
}
+7 -1
View File
@@ -17,13 +17,15 @@ import { ThrottlerStorageRedisService } from "@nest-lab/throttler-storage-redis"
import { BullModule } from "@nestjs/bull";
import { MiddlewareConsumer, Module, NestModule, RequestMethod } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { APP_GUARD, APP_INTERCEPTOR } from "@nestjs/core";
import { APP_GUARD, APP_INTERCEPTOR, APP_FILTER } from "@nestjs/core";
import { seconds, ThrottlerModule } from "@nestjs/throttler";
import { SentryModule, SentryGlobalFilter } from "@sentry/nestjs/setup";
import { AppController } from "./app.controller";
@Module({
imports: [
SentryModule.forRoot(),
ConfigModule.forRoot({
ignoreEnvFile: true,
isGlobal: true,
@@ -59,6 +61,10 @@ import { AppController } from "./app.controller";
],
controllers: [AppController],
providers: [
{
provide: APP_FILTER,
useClass: SentryGlobalFilter,
},
{
provide: ThrottlerStorageRedisService,
useFactory: (redisService: RedisService) => {
-4
View File
@@ -7,7 +7,6 @@ import type { ValidationError } from "@nestjs/common";
import { BadRequestException, ValidationPipe, VersioningType } from "@nestjs/common";
import { BaseExceptionFilter, HttpAdapterHost } from "@nestjs/core";
import type { NestExpressApplication } from "@nestjs/platform-express";
import * as Sentry from "@sentry/node";
import * as cookieParser from "cookie-parser";
import { Request } from "express";
import helmet from "helmet";
@@ -71,9 +70,6 @@ export const bootstrap = (app: NestExpressApplication): NestExpressApplication =
// Exception filters, new filters go at the bottom, keep the order
const { httpAdapter } = app.get(HttpAdapterHost);
if (process.env.SENTRY_DSN) {
Sentry.setupNestErrorHandler(app, new BaseExceptionFilter(httpAdapter));
}
app.useGlobalFilters(new PrismaExceptionFilter());
app.useGlobalFilters(new ZodExceptionFilter());
app.useGlobalFilters(new HttpExceptionFilter());
@@ -89,7 +89,7 @@ const DEFAULT_PLATFORM_PARAMS = {
@UseGuards(PermissionsGuard)
@DocsExcludeController(true)
export class BookingsController_2024_04_15 {
private readonly logger = new Logger("BookingsController");
private readonly logger = new Logger("BookingsController_2024_04_15");
constructor(
private readonly oAuthFlowService: OAuthFlowService,
@@ -333,7 +333,7 @@ export class BookingsController_2024_04_15 {
}
private async getOAuthClientsParams(clientId: string): Promise<OAuthRequestParams> {
const res = DEFAULT_PLATFORM_PARAMS;
const res = { ...DEFAULT_PLATFORM_PARAMS };
try {
const client = await this.oAuthClientRepository.getOAuthClient(clientId);
// fetch oAuthClient from db and use data stored in db to set these values
@@ -356,11 +356,19 @@ export class BookingsController_2024_04_15 {
oAuthClientId?: string,
platformBookingLocation?: string
): Promise<NextApiRequest & { userId?: number } & OAuthRequestParams> {
const requestId = req.get("X-Request-Id");
const clone = { ...req };
const userId = (await this.getOwnerId(req)) ?? -1;
const oAuthParams = oAuthClientId
? await this.getOAuthClientsParams(oAuthClientId)
: DEFAULT_PLATFORM_PARAMS;
this.logger.log(`createNextApiBookingRequest_2024_04_15`, {
requestId,
ownerId: userId,
platformBookingLocation,
oAuthClientId,
...oAuthParams,
});
Object.assign(clone, { userId, ...oAuthParams, platformBookingLocation });
clone.body = { ...clone.body, noEmail: !oAuthParams.arePlatformEmailsEnabled };
return clone as unknown as NextApiRequest & { userId?: number } & OAuthRequestParams;
@@ -376,6 +384,14 @@ export class BookingsController_2024_04_15 {
const oAuthParams = oAuthClientId
? await this.getOAuthClientsParams(oAuthClientId)
: DEFAULT_PLATFORM_PARAMS;
const requestId = req.get("X-Request-Id");
this.logger.log(`createNextApiRecurringBookingRequest_2024_04_15`, {
requestId,
ownerId: userId,
platformBookingLocation,
oAuthClientId,
...oAuthParams,
});
Object.assign(clone, {
userId,
...oAuthParams,
@@ -7,6 +7,7 @@ import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_20
import { ApiKeyRepository } from "@/modules/api-key/api-key-repository";
import { BillingModule } from "@/modules/billing/billing.module";
import { BookingSeatModule } from "@/modules/booking-seat/booking-seat.module";
import { BookingSeatRepository } from "@/modules/booking-seat/booking-seat.repository";
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";
@@ -27,6 +28,7 @@ import { Module } from "@nestjs/common";
OutputBookingsService_2024_08_13,
BookingsRepository_2024_08_13,
EventTypesRepository_2024_06_14,
BookingSeatRepository,
ApiKeyRepository,
],
controllers: [BookingsController_2024_08_13],
@@ -56,6 +56,17 @@ export class BookingsRepository_2024_08_13 {
});
}
async getByUidWithUser(bookingUid: string) {
return this.dbRead.prisma.booking.findUnique({
where: {
uid: bookingUid,
},
include: {
user: true,
},
});
}
async getByIdWithAttendeesAndUserAndEvent(id: number) {
return this.dbRead.prisma.booking.findUnique({
where: {
@@ -1,160 +0,0 @@
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();
});
});
});
@@ -2,6 +2,7 @@ import { BookingUidGuard } from "@/ee/bookings/2024-08-13/guards/booking-uid.gua
import { CancelBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/cancel-booking.output";
import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output";
import { MarkAbsentBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/mark-absent.output";
import { ReassignBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reassign-booking.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";
@@ -9,6 +10,7 @@ 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 { UserWithProfile } from "@/modules/users/users.repository";
import {
Controller,
Post,
@@ -46,12 +48,12 @@ import {
CreateBookingInputPipe,
CreateBookingInput,
GetBookingsInput_2024_08_13,
RescheduleBookingInput_2024_08_13,
CancelBookingInput_2024_08_13,
ReassignToUserBookingInput_2024_08_13,
MarkAbsentBookingInput_2024_08_13,
CreateBookingInput_2024_08_13,
CreateInstantBookingInput_2024_08_13,
CreateRecurringBookingInput_2024_08_13,
DeclineBookingInput_2024_08_13,
} from "@calcom/platform-types";
@Controller({
@@ -66,7 +68,7 @@ import {
required: true,
})
export class BookingsController_2024_08_13 {
private readonly logger = new Logger("BookingsController");
private readonly logger = new Logger("BookingsController_2024_08_13");
constructor(private readonly bookingsService: BookingsService_2024_08_13) {}
@@ -189,7 +191,12 @@ export class BookingsController_2024_08_13 {
@Post("/:bookingUid/cancel")
@UseGuards(BookingUidGuard)
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Cancel a booking" })
@ApiOperation({
summary: "Cancel a booking",
description: `:bookingUid can be :bookingUid of an usual booking, individual recurrence or recurring booking to cancel all recurrences.
For seated bookings to cancel one individual booking provide :bookingUid and :seatUid in the request body. For recurring seated bookings it is not possible to cancel all of them with 1 call
like with non-seated recurring bookings by providing recurring bookind uid - you have to cancel each recurrence booking by its bookingUid + seatUid.`,
})
async cancelBooking(
@Req() request: Request,
@Param("bookingUid") bookingUid: string,
@@ -227,4 +234,104 @@ export class BookingsController_2024_08_13 {
data: booking,
};
}
@Post("/:bookingUid/reassign")
@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: "Automatically reassign booking to a new host" })
async reassignBooking(
@Param("bookingUid") bookingUid: string,
@GetUser() user: UserWithProfile
): Promise<ReassignBookingOutput_2024_08_13> {
const booking = await this.bookingsService.reassignBooking(bookingUid, user);
return {
status: SUCCESS_STATUS,
data: booking,
};
}
@Post("/:bookingUid/reassign/:userId")
@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: "Reassign a booking to a specific user" })
async reassignBookingToUser(
@Param("bookingUid") bookingUid: string,
@Param("userId") userId: number,
@GetUser("id") reassignedById: number,
@Body() body: ReassignToUserBookingInput_2024_08_13
): Promise<ReassignBookingOutput_2024_08_13> {
const booking = await this.bookingsService.reassignBookingToUser(
bookingUid,
userId,
reassignedById,
body
);
return {
status: SUCCESS_STATUS,
data: booking,
};
}
@Post("/:bookingUid/confirm")
@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: "Confirm booking that requires a confirmation" })
async confirmBooking(
@Param("bookingUid") bookingUid: string,
@GetUser() user: UserWithProfile
): Promise<GetBookingOutput_2024_08_13> {
const booking = await this.bookingsService.confirmBooking(bookingUid, user);
return {
status: SUCCESS_STATUS,
data: booking,
};
}
@Post("/:bookingUid/decline")
@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: "Decline booking that requires a confirmation" })
async declineBooking(
@Param("bookingUid") bookingUid: string,
@Body() body: DeclineBookingInput_2024_08_13,
@GetUser() user: UserWithProfile
): Promise<GetBookingOutput_2024_08_13> {
const booking = await this.bookingsService.declineBooking(bookingUid, user, body.reason);
return {
status: SUCCESS_STATUS,
data: booking,
};
}
}
@@ -0,0 +1,291 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
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 { RescheduleBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reschedule-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 {
AttendeeScheduledEmail,
OrganizerScheduledEmail,
AttendeeRescheduledEmail,
OrganizerRescheduledEmail,
AttendeeCancelledEmail,
OrganizerCancelledEmail,
} from "@calcom/platform-libraries";
import {
CreateBookingInput_2024_08_13,
BookingOutput_2024_08_13,
RescheduleBookingInput_2024_08_13,
CancelBookingInput_2024_08_13,
} from "@calcom/platform-types";
import { Team } from "@calcom/prisma/client";
jest.spyOn(AttendeeScheduledEmail.prototype as any, "getHtml").mockImplementation(async function () {
return "<html><body>Mocked Email Content</body></html>";
});
jest.spyOn(OrganizerScheduledEmail.prototype as any, "getHtml").mockImplementation(async function () {
return "<html><body>Mocked Email Content</body></html>";
});
jest
.spyOn(AttendeeRescheduledEmail.prototype, "getHtml")
.mockImplementation(() => Promise.resolve("<p>email</p>"));
jest
.spyOn(OrganizerRescheduledEmail.prototype, "getHtml")
.mockImplementation(() => Promise.resolve("<p>email</p>"));
jest
.spyOn(AttendeeCancelledEmail.prototype, "getHtml")
.mockImplementation(() => Promise.resolve("<p>email</p>"));
jest
.spyOn(OrganizerCancelledEmail.prototype, "getHtml")
.mockImplementation(() => Promise.resolve("<p>email</p>"));
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;
let createdBooking: BookingOutput_2024_08_13;
let rescheduledBooking: BookingOutput_2024_08_13;
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();
});
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);
expect(AttendeeScheduledEmail.prototype.getHtml).toHaveBeenCalled();
expect(OrganizerScheduledEmail.prototype.getHtml).toHaveBeenCalled();
createdBooking = data;
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibily recurring bookings"
);
}
});
});
it("should reschedule a booking with api key", async () => {
const body: RescheduleBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2035, 0, 8, 14, 0, 0)).toISOString(),
reschedulingReason: "Flying to mars that day",
};
return request(app.getHttpServer())
.post(`/v2/bookings/${createdBooking.uid}/reschedule`)
.send(body)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201)
.then(async (response) => {
const responseBody: RescheduleBookingOutput_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)) {
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const data: BookingOutput_2024_08_13 = responseBody.data;
expect(data.reschedulingReason).toEqual(body.reschedulingReason);
expect(data.start).toEqual(body.start);
expect(data.end).toEqual(new Date(Date.UTC(2035, 0, 8, 15, 0, 0)).toISOString());
expect(data.rescheduledFromUid).toEqual(createdBooking.uid);
expect(data.id).toBeDefined();
expect(data.uid).toBeDefined();
expect(data.hosts[0].id).toEqual(user.id);
expect(data.status).toEqual(createdBooking.status);
expect(data.duration).toEqual(createdBooking.duration);
expect(data.eventTypeId).toEqual(createdBooking.eventTypeId);
expect(data.attendees[0]).toEqual(createdBooking.attendees[0]);
expect(data.location).toEqual(createdBooking.location);
expect(data.absentHost).toEqual(createdBooking.absentHost);
expect(data.metadata).toEqual(createdBooking.metadata);
expect(AttendeeRescheduledEmail.prototype.getHtml).toHaveBeenCalled();
expect(OrganizerRescheduledEmail.prototype.getHtml).toHaveBeenCalled();
rescheduledBooking = data;
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibily recurring bookings"
);
}
});
});
it("should cancel booking", async () => {
const body: CancelBookingInput_2024_08_13 = {
cancellationReason: "Going on a vacation",
};
const booking = await bookingsRepositoryFixture.getByUid(rescheduledBooking.uid);
expect(booking).toBeDefined();
expect(booking?.status).toEqual("ACCEPTED");
return request(app.getHttpServer())
.post(`/v2/bookings/${rescheduledBooking.uid}/cancel`)
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: CancelBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
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("cancelled");
expect(data.cancellationReason).toEqual(body.cancellationReason);
expect(data.start).toEqual(rescheduledBooking.start);
expect(data.end).toEqual(rescheduledBooking.end);
expect(data.duration).toEqual(rescheduledBooking.duration);
expect(data.eventTypeId).toEqual(rescheduledBooking.eventTypeId);
expect(data.attendees[0]).toEqual(rescheduledBooking.attendees[0]);
expect(data.location).toEqual(rescheduledBooking.location);
expect(data.absentHost).toEqual(rescheduledBooking.absentHost);
const cancelledBooking = await bookingsRepositoryFixture.getByUid(rescheduledBooking.uid);
expect(cancelledBooking).toBeDefined();
expect(cancelledBooking?.status).toEqual("CANCELLED");
expect(AttendeeCancelledEmail.prototype.getHtml).toHaveBeenCalled();
expect(OrganizerCancelledEmail.prototype.getHtml).toHaveBeenCalled();
});
});
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,249 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
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 { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { withApiAuth } from "test/utils/withApiAuth";
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants";
import { BookingOutput_2024_08_13, GetBookingOutput_2024_08_13 } from "@calcom/platform-types";
import { Booking, PlatformOAuthClient, Team } from "@calcom/prisma/client";
describe("Bookings Endpoints 2024-08-13", () => {
describe("Bookings confirmation", () => {
let app: INestApplication;
let organization: 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;
const userEmail = "bookings-controller-e2e@api.com";
let user: User;
let eventTypeId: number;
const eventTypeSlug = "peer-coding";
let createdBooking1: Booking;
let createdBooking2: Booking;
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);
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
organization = await teamRepositoryFixture.create({ name: "organization bookings" });
oAuthClient = await createOAuthClient(organization.id);
user = await userRepositoryFixture.create({
email: userEmail,
platformOAuthClients: {
connect: {
id: oAuthClient.id,
},
},
});
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: eventTypeSlug, length: 60 },
user.id
);
eventTypeId = event.id;
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
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;
}
it("should be defined", () => {
expect(userRepositoryFixture).toBeDefined();
expect(user).toBeDefined();
});
it("should confirm a booking", async () => {
const status = "PENDING";
createdBooking1 = await bookingsRepositoryFixture.create({
user: {
connect: {
id: user.id,
},
},
startTime: new Date(Date.UTC(2050, 0, 7, 13, 0, 0)),
endTime: new Date(Date.UTC(2050, 0, 7, 14, 0, 0)),
title: "peer coding",
uid: "peer-coding-one",
eventType: {
connect: {
id: eventTypeId,
},
},
location: "via 10, rome, italy",
customInputs: {},
metadata: {},
responses: {
name: "Bob",
email: "bob@gmail.com",
},
attendees: {
create: {
email: "bob@gmail.com",
name: "Bob",
locale: "it",
timeZone: "Europe/Rome",
},
},
status,
});
expect(createdBooking1.status).toEqual("PENDING");
return request(app.getHttpServer())
.post(`/v2/bookings/${createdBooking1.uid}/confirm`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: GetBookingOutput_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.uid).toBeDefined();
expect(data.status).toEqual("accepted");
const dbBooking = await bookingsRepositoryFixture.getByUid(data.uid);
expect(dbBooking?.status).toEqual("ACCEPTED");
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibily recurring bookings"
);
}
});
});
it("should decline a booking", async () => {
const status = "PENDING";
createdBooking2 = await bookingsRepositoryFixture.create({
user: {
connect: {
id: user.id,
},
},
startTime: new Date(Date.UTC(2050, 0, 7, 10, 0, 0)),
endTime: new Date(Date.UTC(2050, 0, 7, 11, 0, 0)),
title: "peer coding",
uid: "peer-coding-two",
eventType: {
connect: {
id: eventTypeId,
},
},
location: "via 10, rome, italy",
customInputs: {},
metadata: {},
responses: {
name: "Bob",
email: "bob@gmail.com",
},
attendees: {
create: {
email: "bob@gmail.com",
name: "Bob",
locale: "it",
timeZone: "Europe/Rome",
},
},
status,
});
expect(createdBooking2.status).toEqual("PENDING");
return request(app.getHttpServer())
.post(`/v2/bookings/${createdBooking2.uid}/decline`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: GetBookingOutput_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.uid).toBeDefined();
expect(data.status).toEqual("rejected");
const dbBooking = await bookingsRepositoryFixture.getByUid(data.uid);
expect(dbBooking?.status).toEqual("REJECTED");
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibily recurring bookings"
);
}
});
});
afterAll(async () => {
await oauthClientRepositoryFixture.delete(oAuthClient.id);
await teamRepositoryFixture.delete(organization.id);
await userRepositoryFixture.deleteByEmail(user.email);
await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email);
await app.close();
});
});
function responseDataIsBooking(data: any): data is BookingOutput_2024_08_13 {
return !Array.isArray(data) && typeof data === "object" && data && "id" in data;
}
});
@@ -0,0 +1,474 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
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 { RescheduleBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reschedule-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 { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
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 { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { withApiAuth } from "test/utils/withApiAuth";
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants";
import {
OrganizerScheduledEmail,
AttendeeScheduledEmail,
OrganizerRescheduledEmail,
AttendeeRescheduledEmail,
OrganizerCancelledEmail,
AttendeeCancelledEmail,
AttendeeRequestEmail,
OrganizerRequestEmail,
AttendeeDeclinedEmail,
} from "@calcom/platform-libraries";
import {
CreateBookingInput_2024_08_13,
BookingOutput_2024_08_13,
RescheduleBookingInput_2024_08_13,
GetBookingOutput_2024_08_13,
} from "@calcom/platform-types";
import { CancelBookingInput_2024_08_13 } from "@calcom/platform-types";
import { Team } from "@calcom/prisma/client";
jest
.spyOn(AttendeeScheduledEmail.prototype, "getHtml")
.mockImplementation(() => Promise.resolve("<p>email</p>"));
jest
.spyOn(OrganizerScheduledEmail.prototype, "getHtml")
.mockImplementation(() => Promise.resolve("<p>email</p>"));
jest
.spyOn(AttendeeRescheduledEmail.prototype, "getHtml")
.mockImplementation(() => Promise.resolve("<p>email</p>"));
jest
.spyOn(OrganizerRescheduledEmail.prototype, "getHtml")
.mockImplementation(() => Promise.resolve("<p>email</p>"));
jest
.spyOn(AttendeeCancelledEmail.prototype, "getHtml")
.mockImplementation(() => Promise.resolve("<p>email</p>"));
jest
.spyOn(OrganizerCancelledEmail.prototype, "getHtml")
.mockImplementation(() => Promise.resolve("<p>email</p>"));
jest
.spyOn(AttendeeRequestEmail.prototype, "getHtmlRequestEmail")
.mockImplementation(() => Promise.resolve("<p>email</p>"));
jest
.spyOn(OrganizerRequestEmail.prototype, "getHtmlRequestEmail")
.mockImplementation(() => Promise.resolve("<p>email</p>"));
jest
.spyOn(AttendeeDeclinedEmail.prototype, "getHtml")
.mockImplementation(() => Promise.resolve("<p>email</p>"));
type EmailSetup = {
user: User;
eventTypeId: number;
createdBookingUid: string;
rescheduledBookingUid: string;
};
describe("Bookings Endpoints 2024-08-13 confirm emails", () => {
let app: INestApplication;
let organization: Team;
let userRepositoryFixture: UserRepositoryFixture;
let bookingsRepositoryFixture: BookingsRepositoryFixture;
let schedulesService: SchedulesService_2024_04_15;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
let teamRepositoryFixture: TeamRepositoryFixture;
let emailsEnabledSetup: EmailSetup;
let emailsDisabledSetup: EmailSetup;
const authEmail = `admin-${Math.floor(Math.random() * 1000)}@example.com`;
beforeAll(async () => {
const moduleRef = await withApiAuth(
authEmail,
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);
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
organization = await teamRepositoryFixture.create({ name: "organization bookings" });
await setupEnabledEmails();
await setupDisabledEmails();
await userRepositoryFixture.create({
email: authEmail,
organization: {
connect: {
id: organization.id,
},
},
role: "ADMIN",
});
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
async function setupEnabledEmails() {
const oAuthClientEmailsEnabled = await createOAuthClient(organization.id, true);
const user = await userRepositoryFixture.create({
email: `alice-${Math.floor(Math.random() * 1000)}@gmail.com`,
platformOAuthClients: {
connect: {
id: oAuthClientEmailsEnabled.id,
},
},
});
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, requiresConfirmation: true },
user.id
);
emailsEnabledSetup = {
user,
eventTypeId: event.id,
createdBookingUid: "",
rescheduledBookingUid: "",
};
}
async function setupDisabledEmails() {
const oAuthClientEmailsDisabled = await createOAuthClient(organization.id, false);
const user = await userRepositoryFixture.create({
email: `bob-${Math.floor(Math.random() * 1000)}@gmail.com`,
platformOAuthClients: {
connect: {
id: oAuthClientEmailsDisabled.id,
},
},
});
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, requiresConfirmation: true },
user.id
);
emailsDisabledSetup = {
user,
eventTypeId: event.id,
createdBookingUid: "",
rescheduledBookingUid: "",
};
}
async function createOAuthClient(organizationId: number, emailsEnabled: boolean) {
const data = {
logo: "logo-url",
name: "name",
redirectUris: ["http://localhost:5555"],
permissions: 32,
areEmailsEnabled: emailsEnabled,
};
const secret = "secret";
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
return client;
}
describe("OAuth client managed user bookings - emails disabled", () => {
beforeEach(async () => {
jest.clearAllMocks();
});
it("should not send an email when creating a booking that requires confirmation", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(),
eventTypeId: emailsDisabledSetup.eventTypeId,
attendee: {
name: "Mr Proper",
email: "mr_proper@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
location: "https://meet.google.com/abc-def-ghi",
bookingFieldsResponses: {
customField: "customValue",
},
metadata: {
userId: "100",
},
};
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);
if (responseDataIsBooking(responseBody.data)) {
expect(responseBody.data.status).toEqual("pending");
expect(AttendeeScheduledEmail.prototype.getHtml).not.toHaveBeenCalled();
expect(OrganizerScheduledEmail.prototype.getHtml).not.toHaveBeenCalled();
expect(AttendeeRequestEmail.prototype.getHtmlRequestEmail).not.toHaveBeenCalled();
expect(OrganizerRequestEmail.prototype.getHtmlRequestEmail).not.toHaveBeenCalled();
emailsDisabledSetup.createdBookingUid = responseBody.data.uid;
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibily recurring bookings"
);
}
});
});
it("should not send an email when confirming a booking", async () => {
return request(app.getHttpServer())
.post(`/v2/bookings/${emailsDisabledSetup.createdBookingUid}/confirm`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: GetBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(AttendeeScheduledEmail.prototype.getHtml).not.toHaveBeenCalled();
expect(OrganizerScheduledEmail.prototype.getHtml).not.toHaveBeenCalled();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
emailsDisabledSetup.rescheduledBookingUid = responseBody.data.uid;
});
});
it("should not send an email when creating a booking that requires confirmation", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 10, 0, 0)).toISOString(),
eventTypeId: emailsDisabledSetup.eventTypeId,
attendee: {
name: "Mr Proper",
email: "mr_proper@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
location: "https://meet.google.com/abc-def-ghi",
bookingFieldsResponses: {
customField: "customValue",
},
metadata: {
userId: "100",
},
};
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);
if (responseDataIsBooking(responseBody.data)) {
expect(responseBody.data.status).toEqual("pending");
expect(AttendeeRequestEmail.prototype.getHtmlRequestEmail).not.toHaveBeenCalled();
expect(OrganizerRequestEmail.prototype.getHtmlRequestEmail).not.toHaveBeenCalled();
emailsDisabledSetup.createdBookingUid = responseBody.data.uid;
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibily recurring bookings"
);
}
});
});
it("should not send an email when declining a booking", async () => {
return request(app.getHttpServer())
.post(`/v2/bookings/${emailsDisabledSetup.createdBookingUid}/decline`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: GetBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(AttendeeDeclinedEmail.prototype.getHtml).not.toHaveBeenCalled();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
emailsDisabledSetup.rescheduledBookingUid = responseBody.data.uid;
});
});
});
describe("OAuth client managed user bookings - emails enabled", () => {
beforeEach(async () => {
jest.clearAllMocks();
});
it("should send an email when creating a booking that requires confirmation", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(),
eventTypeId: emailsEnabledSetup.eventTypeId,
attendee: {
name: "Mr Proper",
email: "mr_proper@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
location: "https://meet.google.com/abc-def-ghi",
bookingFieldsResponses: {
customField: "customValue",
},
metadata: {
userId: "100",
},
};
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);
if (responseDataIsBooking(responseBody.data)) {
expect(responseBody.data.status).toEqual("pending");
expect(AttendeeRequestEmail.prototype.getHtmlRequestEmail).toHaveBeenCalled();
expect(OrganizerRequestEmail.prototype.getHtmlRequestEmail).toHaveBeenCalled();
emailsEnabledSetup.createdBookingUid = responseBody.data.uid;
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibily recurring bookings"
);
}
});
});
it("should send an email when confirming a booking", async () => {
return request(app.getHttpServer())
.post(`/v2/bookings/${emailsEnabledSetup.createdBookingUid}/confirm`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: GetBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(AttendeeScheduledEmail.prototype.getHtml).toHaveBeenCalled();
expect(OrganizerScheduledEmail.prototype.getHtml).toHaveBeenCalled();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
emailsEnabledSetup.rescheduledBookingUid = responseBody.data.uid;
});
});
it("should send an email when creating a booking that requires confirmation", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 10, 0, 0)).toISOString(),
eventTypeId: emailsEnabledSetup.eventTypeId,
attendee: {
name: "Mr Proper",
email: "mr_proper@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
location: "https://meet.google.com/abc-def-ghi",
bookingFieldsResponses: {
customField: "customValue",
},
metadata: {
userId: "100",
},
};
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);
if (responseDataIsBooking(responseBody.data)) {
expect(responseBody.data.status).toEqual("pending");
expect(AttendeeRequestEmail.prototype.getHtmlRequestEmail).toHaveBeenCalled();
expect(OrganizerRequestEmail.prototype.getHtmlRequestEmail).toHaveBeenCalled();
emailsEnabledSetup.createdBookingUid = responseBody.data.uid;
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibily recurring bookings"
);
}
});
});
it("should send an email when declining a booking", async () => {
return request(app.getHttpServer())
.post(`/v2/bookings/${emailsEnabledSetup.createdBookingUid}/decline`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: GetBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(AttendeeDeclinedEmail.prototype.getHtml).toHaveBeenCalled();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
emailsEnabledSetup.rescheduledBookingUid = responseBody.data.uid;
});
});
});
afterAll(async () => {
await teamRepositoryFixture.delete(organization.id);
await userRepositoryFixture.deleteByEmail(emailsEnabledSetup.user.email);
await userRepositoryFixture.deleteByEmail(emailsDisabledSetup.user.email);
await userRepositoryFixture.deleteByEmail(authEmail);
await bookingsRepositoryFixture.deleteAllBookings(
emailsEnabledSetup.user.id,
emailsEnabledSetup.user.email
);
await bookingsRepositoryFixture.deleteAllBookings(
emailsDisabledSetup.user.id,
emailsDisabledSetup.user.email
);
await app.close();
});
function responseDataIsBooking(data: any): data is BookingOutput_2024_08_13 {
return !Array.isArray(data) && typeof data === "object" && data && "id" in data;
}
});
@@ -0,0 +1,979 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
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 { RescheduleBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reschedule-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 { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { UsersModule } from "@/modules/users/users.module";
import { createParamDecorator, 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 { 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 {
OrganizerScheduledEmail,
AttendeeScheduledEmail,
OrganizerRescheduledEmail,
AttendeeRescheduledEmail,
OrganizerCancelledEmail,
AttendeeCancelledEmail,
OrganizerReassignedEmail,
} from "@calcom/platform-libraries";
import {
CreateBookingInput_2024_08_13,
BookingOutput_2024_08_13,
RescheduleBookingInput_2024_08_13,
} from "@calcom/platform-types";
import { CancelBookingInput_2024_08_13 } from "@calcom/platform-types";
import { Team } from "@calcom/prisma/client";
jest
.spyOn(AttendeeScheduledEmail.prototype, "getHtml")
.mockImplementation(() => Promise.resolve("<p>email</p>"));
jest
.spyOn(OrganizerScheduledEmail.prototype, "getHtml")
.mockImplementation(() => Promise.resolve("<p>email</p>"));
jest
.spyOn(AttendeeRescheduledEmail.prototype, "getHtml")
.mockImplementation(() => Promise.resolve("<p>email</p>"));
jest
.spyOn(OrganizerRescheduledEmail.prototype, "getHtml")
.mockImplementation(() => Promise.resolve("<p>email</p>"));
jest
.spyOn(AttendeeCancelledEmail.prototype, "getHtml")
.mockImplementation(() => Promise.resolve("<p>email</p>"));
jest
.spyOn(OrganizerCancelledEmail.prototype, "getHtml")
.mockImplementation(() => Promise.resolve("<p>email</p>"));
jest
.spyOn(OrganizerReassignedEmail.prototype, "getHtml")
.mockImplementation(() => Promise.resolve("<p>email</p>"));
type EmailSetup = {
team: Team;
member1: User;
member2: User;
collectiveEventType: {
id: number;
createdBookingUid: string;
rescheduledBookingUid: string;
};
roundRobinEventType: {
id: number;
createdBookingUid: string;
rescheduledBookingUid: string;
currentHostId: number;
};
};
describe("Bookings Endpoints 2024-08-13 team emails", () => {
let app: INestApplication;
let organization: Team;
let userRepositoryFixture: UserRepositoryFixture;
let bookingsRepositoryFixture: BookingsRepositoryFixture;
let schedulesService: SchedulesService_2024_04_15;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
let teamRepositoryFixture: TeamRepositoryFixture;
let profileRepositoryFixture: ProfileRepositoryFixture;
let membershipsRepositoryFixture: MembershipRepositoryFixture;
let hostsRepositoryFixture: HostsRepositoryFixture;
let emailsEnabledSetup: EmailSetup;
let emailsDisabledSetup: EmailSetup;
const authEmail = "admin@example.com";
beforeAll(async () => {
const moduleRef = await withApiAuth(
authEmail,
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);
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 teamRepositoryFixture.create({ name: "organization bookings" });
await setupEnabledEmails();
await setupDisabledEmails();
await userRepositoryFixture.create({
email: authEmail,
organization: {
connect: {
id: organization.id,
},
},
});
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
async function setupEnabledEmails() {
const oAuthClientEmailsEnabled = await createOAuthClient(organization.id, true);
const team = await teamRepositoryFixture.create({
name: "team 1",
isOrganization: false,
parent: { connect: { id: organization.id } },
createdByOAuthClient: {
connect: {
id: oAuthClientEmailsEnabled.id,
},
},
});
const member1 = await userRepositoryFixture.create({
email: "alice@gmail.com",
platformOAuthClients: {
connect: {
id: oAuthClientEmailsEnabled.id,
},
},
});
const member2 = await userRepositoryFixture.create({
email: "bob@gmail.com",
platformOAuthClients: {
connect: {
id: oAuthClientEmailsEnabled.id,
},
},
});
const userSchedule: CreateScheduleInput_2024_04_15 = {
name: "working time",
timeZone: "Europe/Rome",
isDefault: true,
};
await schedulesService.createUserSchedule(member1.id, userSchedule);
await schedulesService.createUserSchedule(member2.id, userSchedule);
await profileRepositoryFixture.create({
uid: `usr-${member1.id}`,
username: member1.email,
organization: {
connect: {
id: organization.id,
},
},
user: {
connect: {
id: member1.id,
},
},
});
await profileRepositoryFixture.create({
uid: `usr-${member2.id}`,
username: member2.email,
organization: {
connect: {
id: organization.id,
},
},
user: {
connect: {
id: member2.id,
},
},
});
await membershipsRepositoryFixture.create({
role: "MEMBER",
user: { connect: { id: member1.id } },
team: { connect: { id: team.id } },
accepted: true,
});
await membershipsRepositoryFixture.create({
role: "MEMBER",
user: { connect: { id: member2.id } },
team: { connect: { id: team.id } },
accepted: true,
});
const collectiveEvent = await eventTypesRepositoryFixture.createTeamEventType({
schedulingType: "COLLECTIVE",
team: {
connect: { id: team.id },
},
title: "Collective Event Type",
slug: "collective-event-type",
length: 60,
assignAllTeamMembers: true,
bookingFields: [],
locations: [],
});
await hostsRepositoryFixture.create({
isFixed: true,
user: {
connect: {
id: member1.id,
},
},
eventType: {
connect: {
id: collectiveEvent.id,
},
},
});
await hostsRepositoryFixture.create({
isFixed: true,
user: {
connect: {
id: member2.id,
},
},
eventType: {
connect: {
id: collectiveEvent.id,
},
},
});
const roundRobinEvent = await eventTypesRepositoryFixture.createTeamEventType({
schedulingType: "ROUND_ROBIN",
team: {
connect: { id: team.id },
},
title: "Round Robin Event Type",
slug: "round-robin-event-type",
length: 60,
assignAllTeamMembers: false,
bookingFields: [],
locations: [],
});
await hostsRepositoryFixture.create({
isFixed: false,
user: {
connect: {
id: member1.id,
},
},
eventType: {
connect: {
id: roundRobinEvent.id,
},
},
});
await hostsRepositoryFixture.create({
isFixed: false,
user: {
connect: {
id: member2.id,
},
},
eventType: {
connect: {
id: roundRobinEvent.id,
},
},
});
emailsEnabledSetup = {
team,
member1: member1,
member2: member2,
collectiveEventType: {
id: collectiveEvent.id,
createdBookingUid: "",
rescheduledBookingUid: "",
},
roundRobinEventType: {
id: roundRobinEvent.id,
createdBookingUid: "",
rescheduledBookingUid: "",
currentHostId: 0,
},
};
}
async function setupDisabledEmails() {
const oAuthClientEmailsDisabled = await createOAuthClient(organization.id, false);
const team = await teamRepositoryFixture.create({
name: "team 2",
isOrganization: false,
parent: { connect: { id: organization.id } },
createdByOAuthClient: {
connect: {
id: oAuthClientEmailsDisabled.id,
},
},
});
const member1 = await userRepositoryFixture.create({
email: "charlie@gmail.com",
platformOAuthClients: {
connect: {
id: oAuthClientEmailsDisabled.id,
},
},
});
const member2 = await userRepositoryFixture.create({
email: "dean@gmail.com",
platformOAuthClients: {
connect: {
id: oAuthClientEmailsDisabled.id,
},
},
});
const userSchedule: CreateScheduleInput_2024_04_15 = {
name: "working time",
timeZone: "Europe/Rome",
isDefault: true,
};
await schedulesService.createUserSchedule(member1.id, userSchedule);
await schedulesService.createUserSchedule(member2.id, userSchedule);
await profileRepositoryFixture.create({
uid: `usr-${member1.id}`,
username: member1.email,
organization: {
connect: {
id: organization.id,
},
},
user: {
connect: {
id: member1.id,
},
},
});
await profileRepositoryFixture.create({
uid: `usr-${member2.id}`,
username: member2.email,
organization: {
connect: {
id: organization.id,
},
},
user: {
connect: {
id: member2.id,
},
},
});
await membershipsRepositoryFixture.create({
role: "MEMBER",
user: { connect: { id: member1.id } },
team: { connect: { id: team.id } },
accepted: true,
});
await membershipsRepositoryFixture.create({
role: "MEMBER",
user: { connect: { id: member2.id } },
team: { connect: { id: team.id } },
accepted: true,
});
const collectiveEvent = await eventTypesRepositoryFixture.createTeamEventType({
schedulingType: "COLLECTIVE",
team: {
connect: { id: team.id },
},
title: "Collective Event Type",
slug: "collective-event-type",
length: 60,
assignAllTeamMembers: true,
bookingFields: [],
locations: [],
});
await hostsRepositoryFixture.create({
isFixed: true,
user: {
connect: {
id: member1.id,
},
},
eventType: {
connect: {
id: collectiveEvent.id,
},
},
});
await hostsRepositoryFixture.create({
isFixed: true,
user: {
connect: {
id: member2.id,
},
},
eventType: {
connect: {
id: collectiveEvent.id,
},
},
});
const roundRobinEvent = await eventTypesRepositoryFixture.createTeamEventType({
schedulingType: "ROUND_ROBIN",
team: {
connect: { id: team.id },
},
title: "Round Robin Event Type",
slug: "round-robin-event-type",
length: 60,
assignAllTeamMembers: false,
bookingFields: [],
locations: [],
});
await hostsRepositoryFixture.create({
isFixed: false,
user: {
connect: {
id: member1.id,
},
},
eventType: {
connect: {
id: roundRobinEvent.id,
},
},
});
await hostsRepositoryFixture.create({
isFixed: false,
user: {
connect: {
id: member2.id,
},
},
eventType: {
connect: {
id: roundRobinEvent.id,
},
},
});
emailsDisabledSetup = {
team,
member1: member1,
member2: member2,
collectiveEventType: {
id: collectiveEvent.id,
createdBookingUid: "",
rescheduledBookingUid: "",
},
roundRobinEventType: {
id: roundRobinEvent.id,
createdBookingUid: "",
rescheduledBookingUid: "",
currentHostId: 0,
},
};
}
async function createOAuthClient(organizationId: number, emailsEnabled: boolean) {
const data = {
logo: "logo-url",
name: "name",
redirectUris: ["http://localhost:5555"],
permissions: 32,
areEmailsEnabled: emailsEnabled,
};
const secret = "secret";
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
return client;
}
beforeEach(async () => {
jest.clearAllMocks();
});
describe("OAuth client team bookings - emails disabled", () => {
describe("book", () => {
it("should not send an email when booking collective event", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 9, 0, 0)).toISOString(),
eventTypeId: emailsDisabledSetup.collectiveEventType.id,
attendee: {
name: "Mr Proper",
email: "mr_proper@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
location: "https://meet.google.com/abc-def-ghi",
bookingFieldsResponses: {
customField: "customValue",
},
metadata: {
userId: "100",
},
};
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);
if (responseDataIsBooking(responseBody.data)) {
expect(AttendeeScheduledEmail.prototype.getHtml).not.toHaveBeenCalled();
expect(OrganizerScheduledEmail.prototype.getHtml).not.toHaveBeenCalled();
emailsDisabledSetup.collectiveEventType.createdBookingUid = responseBody.data.uid;
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibily recurring bookings"
);
}
});
});
it("should not send an email when booking round robin event", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 10, 0, 0)).toISOString(),
eventTypeId: emailsDisabledSetup.roundRobinEventType.id,
attendee: {
name: "Mr Proper",
email: "mr_proper@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
location: "https://meet.google.com/abc-def-ghi",
bookingFieldsResponses: {
customField: "customValue",
},
metadata: {
userId: "100",
},
};
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);
if (responseDataIsBooking(responseBody.data)) {
expect(AttendeeScheduledEmail.prototype.getHtml).not.toHaveBeenCalled();
expect(OrganizerScheduledEmail.prototype.getHtml).not.toHaveBeenCalled();
emailsDisabledSetup.roundRobinEventType.createdBookingUid = responseBody.data.uid;
emailsDisabledSetup.roundRobinEventType.currentHostId = responseBody.data.hosts[0].id;
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibily recurring bookings"
);
}
});
});
});
describe("reschedule", () => {
it("should not send an email when rescheduling collective booking", async () => {
const body: RescheduleBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2035, 0, 8, 11, 0, 0)).toISOString(),
reschedulingReason: "Flying to mars that day",
};
return request(app.getHttpServer())
.post(`/v2/bookings/${emailsDisabledSetup.collectiveEventType.createdBookingUid}/reschedule`)
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201)
.then(async (response) => {
const responseBody: RescheduleBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(AttendeeScheduledEmail.prototype.getHtml).not.toHaveBeenCalled();
expect(OrganizerScheduledEmail.prototype.getHtml).not.toHaveBeenCalled();
expect(AttendeeRescheduledEmail.prototype.getHtml).not.toHaveBeenCalled();
expect(OrganizerRescheduledEmail.prototype.getHtml).not.toHaveBeenCalled();
emailsDisabledSetup.collectiveEventType.rescheduledBookingUid = responseBody.data.uid;
});
});
it("should not send an email when rescheduling round robin booking", async () => {
const body: RescheduleBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2035, 0, 8, 12, 0, 0)).toISOString(),
reschedulingReason: "Flying to mars that day",
};
return request(app.getHttpServer())
.post(`/v2/bookings/${emailsDisabledSetup.roundRobinEventType.createdBookingUid}/reschedule`)
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201)
.then(async (response) => {
const responseBody: RescheduleBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(AttendeeScheduledEmail.prototype.getHtml).not.toHaveBeenCalled();
expect(OrganizerScheduledEmail.prototype.getHtml).not.toHaveBeenCalled();
expect(AttendeeRescheduledEmail.prototype.getHtml).not.toHaveBeenCalled();
expect(OrganizerRescheduledEmail.prototype.getHtml).not.toHaveBeenCalled();
emailsDisabledSetup.roundRobinEventType.rescheduledBookingUid = responseBody.data.uid;
emailsDisabledSetup.roundRobinEventType.currentHostId = responseBody.data.hosts[0].id;
});
});
});
describe("reassign", () => {
it("should not send an email when manually reassigning round robin booking", async () => {
const reassignToId =
emailsDisabledSetup.roundRobinEventType.currentHostId === emailsDisabledSetup.member1.id
? emailsDisabledSetup.member2.id
: emailsDisabledSetup.member1.id;
return request(app.getHttpServer())
.post(
`/v2/bookings/${emailsDisabledSetup.roundRobinEventType.rescheduledBookingUid}/reassign/${reassignToId}`
)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: CancelBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(AttendeeCancelledEmail.prototype.getHtml).not.toHaveBeenCalled();
expect(OrganizerScheduledEmail.prototype.getHtml).not.toHaveBeenCalled();
emailsDisabledSetup.roundRobinEventType.currentHostId = reassignToId;
});
});
it("should not send an email when automatically reassigning round robin booking", async () => {
return request(app.getHttpServer())
.post(`/v2/bookings/${emailsDisabledSetup.roundRobinEventType.rescheduledBookingUid}/reassign`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: CancelBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(AttendeeCancelledEmail.prototype.getHtml).not.toHaveBeenCalled();
expect(OrganizerScheduledEmail.prototype.getHtml).not.toHaveBeenCalled();
expect(OrganizerReassignedEmail.prototype.getHtml).not.toHaveBeenCalled();
});
});
});
describe("cancel", () => {
it("should not send an email when cancelling a collective booking", async () => {
const body: CancelBookingInput_2024_08_13 = {
cancellationReason: "Going on a vacation",
};
return request(app.getHttpServer())
.post(`/v2/bookings/${emailsDisabledSetup.collectiveEventType.rescheduledBookingUid}/cancel`)
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: CancelBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(AttendeeCancelledEmail.prototype.getHtml).not.toHaveBeenCalled();
expect(OrganizerCancelledEmail.prototype.getHtml).not.toHaveBeenCalled();
});
});
});
it("should not send an email when cancelling a round robin booking", async () => {
const body: CancelBookingInput_2024_08_13 = {
cancellationReason: "Going on a vacation",
};
return request(app.getHttpServer())
.post(`/v2/bookings/${emailsDisabledSetup.roundRobinEventType.rescheduledBookingUid}/cancel`)
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: CancelBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(AttendeeCancelledEmail.prototype.getHtml).not.toHaveBeenCalled();
expect(OrganizerCancelledEmail.prototype.getHtml).not.toHaveBeenCalled();
});
});
});
describe("OAuth client team bookings - emails enabled", () => {
beforeEach(async () => {
jest.clearAllMocks();
});
describe("book", () => {
it("should send an email when booking collective event", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 9, 0, 0)).toISOString(),
eventTypeId: emailsEnabledSetup.collectiveEventType.id,
attendee: {
name: "Mr Proper",
email: "mr_proper@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
location: "https://meet.google.com/abc-def-ghi",
bookingFieldsResponses: {
customField: "customValue",
},
metadata: {
userId: "100",
},
};
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);
if (responseDataIsBooking(responseBody.data)) {
expect(AttendeeScheduledEmail.prototype.getHtml).toHaveBeenCalled();
expect(OrganizerScheduledEmail.prototype.getHtml).toHaveBeenCalled();
emailsEnabledSetup.collectiveEventType.createdBookingUid = responseBody.data.uid;
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibily recurring bookings"
);
}
});
});
it("should send an email when booking round robin event", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 10, 0, 0)).toISOString(),
eventTypeId: emailsEnabledSetup.roundRobinEventType.id,
attendee: {
name: "Mr Proper",
email: "mr_proper@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
location: "https://meet.google.com/abc-def-ghi",
bookingFieldsResponses: {
customField: "customValue",
},
metadata: {
userId: "100",
},
};
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);
if (responseDataIsBooking(responseBody.data)) {
expect(AttendeeScheduledEmail.prototype.getHtml).toHaveBeenCalled();
expect(OrganizerScheduledEmail.prototype.getHtml).toHaveBeenCalled();
emailsEnabledSetup.roundRobinEventType.createdBookingUid = responseBody.data.uid;
emailsEnabledSetup.roundRobinEventType.currentHostId = responseBody.data.hosts[0].id;
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibily recurring bookings"
);
}
});
});
});
describe("reschedule", () => {
it("should send an email when rescheduling collective booking", async () => {
const body: RescheduleBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2035, 0, 8, 11, 0, 0)).toISOString(),
reschedulingReason: "Flying to mars that day",
};
return request(app.getHttpServer())
.post(`/v2/bookings/${emailsEnabledSetup.collectiveEventType.createdBookingUid}/reschedule`)
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201)
.then(async (response) => {
const responseBody: RescheduleBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(AttendeeScheduledEmail.prototype.getHtml).not.toHaveBeenCalled();
expect(OrganizerScheduledEmail.prototype.getHtml).not.toHaveBeenCalled();
expect(AttendeeRescheduledEmail.prototype.getHtml).toHaveBeenCalled();
expect(OrganizerRescheduledEmail.prototype.getHtml).toHaveBeenCalled();
emailsEnabledSetup.collectiveEventType.rescheduledBookingUid = responseBody.data.uid;
});
});
it("should send an email when rescheduling round robin booking", async () => {
const body: RescheduleBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2035, 0, 8, 12, 0, 0)).toISOString(),
reschedulingReason: "Flying to mars that day",
};
return request(app.getHttpServer())
.post(`/v2/bookings/${emailsEnabledSetup.roundRobinEventType.createdBookingUid}/reschedule`)
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201)
.then(async (response) => {
const responseBody: RescheduleBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(AttendeeScheduledEmail.prototype.getHtml).not.toHaveBeenCalled();
expect(AttendeeRescheduledEmail.prototype.getHtml).not.toHaveBeenCalled();
expect(OrganizerRescheduledEmail.prototype.getHtml).toHaveBeenCalled();
expect(OrganizerScheduledEmail.prototype.getHtml).toHaveBeenCalled();
expect(OrganizerCancelledEmail.prototype.getHtml).toHaveBeenCalled();
emailsEnabledSetup.roundRobinEventType.rescheduledBookingUid = responseBody.data.uid;
emailsEnabledSetup.roundRobinEventType.currentHostId = responseBody.data.hosts[0].id;
});
});
});
describe("reassign", () => {
it("should send an email when manually reassigning round robin booking", async () => {
const reassignToId =
emailsEnabledSetup.roundRobinEventType.currentHostId === emailsEnabledSetup.member1.id
? emailsEnabledSetup.member2.id
: emailsEnabledSetup.member1.id;
return request(app.getHttpServer())
.post(
`/v2/bookings/${emailsEnabledSetup.roundRobinEventType.rescheduledBookingUid}/reassign/${reassignToId}`
)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: CancelBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(AttendeeCancelledEmail.prototype.getHtml).not.toHaveBeenCalled();
expect(OrganizerScheduledEmail.prototype.getHtml).toHaveBeenCalled();
emailsDisabledSetup.roundRobinEventType.currentHostId = reassignToId;
});
});
it("should send an email when automatically reassigning round robin booking", async () => {
return request(app.getHttpServer())
.post(`/v2/bookings/${emailsEnabledSetup.roundRobinEventType.rescheduledBookingUid}/reassign`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: CancelBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(AttendeeCancelledEmail.prototype.getHtml).not.toHaveBeenCalled();
expect(OrganizerScheduledEmail.prototype.getHtml).toHaveBeenCalled();
expect(OrganizerReassignedEmail.prototype.getHtml).toHaveBeenCalled();
});
});
});
describe("cancel", () => {
it("should send an email when cancelling a collective booking", async () => {
const body: CancelBookingInput_2024_08_13 = {
cancellationReason: "Going on a vacation",
};
return request(app.getHttpServer())
.post(`/v2/bookings/${emailsEnabledSetup.collectiveEventType.rescheduledBookingUid}/cancel`)
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: CancelBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(AttendeeCancelledEmail.prototype.getHtml).toHaveBeenCalled();
expect(OrganizerCancelledEmail.prototype.getHtml).toHaveBeenCalled();
});
});
it("should send an email when cancelling round robin booking", async () => {
const body: CancelBookingInput_2024_08_13 = {
cancellationReason: "Going on a vacation",
};
return request(app.getHttpServer())
.post(`/v2/bookings/${emailsEnabledSetup.roundRobinEventType.rescheduledBookingUid}/cancel`)
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: CancelBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(AttendeeCancelledEmail.prototype.getHtml).toHaveBeenCalled();
expect(OrganizerCancelledEmail.prototype.getHtml).toHaveBeenCalled();
});
});
});
});
afterAll(async () => {
await teamRepositoryFixture.delete(organization.id);
await userRepositoryFixture.deleteByEmail(emailsEnabledSetup.member1.email);
await userRepositoryFixture.deleteByEmail(emailsDisabledSetup.member1.email);
await bookingsRepositoryFixture.deleteAllBookings(
emailsEnabledSetup.member1.id,
emailsEnabledSetup.member1.email
);
await bookingsRepositoryFixture.deleteAllBookings(
emailsDisabledSetup.member1.id,
emailsDisabledSetup.member1.email
);
await app.close();
});
function responseDataIsBooking(data: any): data is BookingOutput_2024_08_13 {
return !Array.isArray(data) && typeof data === "object" && data && "id" in data;
}
});
@@ -0,0 +1,482 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
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 { RescheduleBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reschedule-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 { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
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 { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants";
import {
OrganizerScheduledEmail,
AttendeeScheduledEmail,
OrganizerRescheduledEmail,
AttendeeRescheduledEmail,
OrganizerCancelledEmail,
AttendeeCancelledEmail,
} from "@calcom/platform-libraries";
import {
CreateBookingInput_2024_08_13,
BookingOutput_2024_08_13,
RescheduleBookingInput_2024_08_13,
RecurringBookingOutput_2024_08_13,
} from "@calcom/platform-types";
import { CancelBookingInput_2024_08_13 } from "@calcom/platform-types";
import { Team } from "@calcom/prisma/client";
jest
.spyOn(AttendeeScheduledEmail.prototype, "getHtml")
.mockImplementation(() => Promise.resolve("<p>email</p>"));
jest
.spyOn(OrganizerScheduledEmail.prototype, "getHtml")
.mockImplementation(() => Promise.resolve("<p>email</p>"));
jest
.spyOn(AttendeeRescheduledEmail.prototype, "getHtml")
.mockImplementation(() => Promise.resolve("<p>email</p>"));
jest
.spyOn(OrganizerRescheduledEmail.prototype, "getHtml")
.mockImplementation(() => Promise.resolve("<p>email</p>"));
jest
.spyOn(AttendeeCancelledEmail.prototype, "getHtml")
.mockImplementation(() => Promise.resolve("<p>email</p>"));
jest
.spyOn(OrganizerCancelledEmail.prototype, "getHtml")
.mockImplementation(() => Promise.resolve("<p>email</p>"));
type EmailSetup = {
user: User;
eventTypeId: number;
recurringEventTypeId: number;
createdBookingUid: string;
rescheduledBookingUid: string;
};
describe("Bookings Endpoints 2024-08-13 user emails", () => {
let app: INestApplication;
let organization: Team;
let userRepositoryFixture: UserRepositoryFixture;
let bookingsRepositoryFixture: BookingsRepositoryFixture;
let schedulesService: SchedulesService_2024_04_15;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
let teamRepositoryFixture: TeamRepositoryFixture;
let emailsEnabledSetup: EmailSetup;
let emailsDisabledSetup: EmailSetup;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
})
.overrideGuard(PermissionsGuard)
.useValue({
canActivate: () => true,
})
.overrideGuard(ApiAuthGuard)
.useValue({
canActivate: () => true,
})
.compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
organization = await teamRepositoryFixture.create({ name: "organization bookings" });
await setupEnabledEmails();
await setupDisabledEmails();
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
async function setupEnabledEmails() {
const oAuthClientEmailsEnabled = await createOAuthClient(organization.id, true);
const user = await userRepositoryFixture.create({
email: `alice-${Math.floor(Math.random() * 1000)}@gmail.com`,
platformOAuthClients: {
connect: {
id: oAuthClientEmailsEnabled.id,
},
},
});
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
);
const recurringEvent = await eventTypesRepositoryFixture.create(
// note(Lauris): freq 2 means weekly, interval 1 means every week and count 3 means 3 weeks in a row
{
title: "peer coding recurring",
slug: "peer-coding-recurring",
length: 60,
recurringEvent: { freq: 2, count: 3, interval: 1 },
},
user.id
);
emailsEnabledSetup = {
user,
eventTypeId: event.id,
recurringEventTypeId: recurringEvent.id,
createdBookingUid: "",
rescheduledBookingUid: "",
};
}
async function setupDisabledEmails() {
const oAuthClientEmailsDisabled = await createOAuthClient(organization.id, false);
const user = await userRepositoryFixture.create({
email: `bob-${Math.floor(Math.random() * 1000)}@gmail.com`,
platformOAuthClients: {
connect: {
id: oAuthClientEmailsDisabled.id,
},
},
});
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
);
const recurringEvent = await eventTypesRepositoryFixture.create(
// note(Lauris): freq 2 means weekly, interval 1 means every week and count 3 means 3 weeks in a row
{
title: "peer coding recurring",
slug: "peer-coding-recurring",
length: 60,
recurringEvent: { freq: 2, count: 3, interval: 1 },
},
user.id
);
emailsDisabledSetup = {
user,
eventTypeId: event.id,
recurringEventTypeId: recurringEvent.id,
createdBookingUid: "",
rescheduledBookingUid: "",
};
}
async function createOAuthClient(organizationId: number, emailsEnabled: boolean) {
const data = {
logo: "logo-url",
name: "name",
redirectUris: ["http://localhost:5555"],
permissions: 32,
areEmailsEnabled: emailsEnabled,
};
const secret = "secret";
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
return client;
}
beforeEach(async () => {
jest.clearAllMocks();
});
describe("OAuth client managed user bookings - emails disabled", () => {
it("should not send an email when creating a booking", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(),
eventTypeId: emailsDisabledSetup.eventTypeId,
attendee: {
name: "Mr Proper",
email: "mr_proper@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
location: "https://meet.google.com/abc-def-ghi",
bookingFieldsResponses: {
customField: "customValue",
},
metadata: {
userId: "100",
},
};
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);
if (responseDataIsBooking(responseBody.data)) {
expect(AttendeeScheduledEmail.prototype.getHtml).not.toHaveBeenCalled();
expect(OrganizerScheduledEmail.prototype.getHtml).not.toHaveBeenCalled();
emailsDisabledSetup.createdBookingUid = responseBody.data.uid;
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibily recurring bookings"
);
}
});
});
it("should not send an email when creating a recurring booking", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 9, 0, 0)).toISOString(),
eventTypeId: emailsDisabledSetup.recurringEventTypeId,
attendee: {
name: "Mr Proper",
email: "mr_proper@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
location: "https://meet.google.com/abc-def-ghi",
bookingFieldsResponses: {
customField: "customValue",
},
metadata: {
userId: "100",
},
};
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);
if (responseDataIsRecurringBooking(responseBody.data)) {
expect(AttendeeScheduledEmail.prototype.getHtml).not.toHaveBeenCalled();
expect(OrganizerScheduledEmail.prototype.getHtml).not.toHaveBeenCalled();
} else {
throw new Error("Invalid response data - expected booking array but received single booking");
}
});
});
it("should not send an email when rescheduling a booking", async () => {
const body: RescheduleBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2035, 0, 8, 14, 0, 0)).toISOString(),
reschedulingReason: "Flying to mars that day",
};
return request(app.getHttpServer())
.post(`/v2/bookings/${emailsDisabledSetup.createdBookingUid}/reschedule`)
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201)
.then(async (response) => {
const responseBody: RescheduleBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(AttendeeScheduledEmail.prototype.getHtml).not.toHaveBeenCalled();
expect(OrganizerScheduledEmail.prototype.getHtml).not.toHaveBeenCalled();
expect(AttendeeRescheduledEmail.prototype.getHtml).not.toHaveBeenCalled();
expect(OrganizerRescheduledEmail.prototype.getHtml).not.toHaveBeenCalled();
emailsDisabledSetup.rescheduledBookingUid = responseBody.data.uid;
});
});
it("should not send an email when cancelling a booking", async () => {
const body: CancelBookingInput_2024_08_13 = {
cancellationReason: "Going on a vacation",
};
return request(app.getHttpServer())
.post(`/v2/bookings/${emailsDisabledSetup.rescheduledBookingUid}/cancel`)
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: CancelBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(AttendeeCancelledEmail.prototype.getHtml).not.toHaveBeenCalled();
expect(OrganizerCancelledEmail.prototype.getHtml).not.toHaveBeenCalled();
});
});
});
describe("OAuth client managed user bookings - emails enabled", () => {
it("should send an email when creating a booking", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(),
eventTypeId: emailsEnabledSetup.eventTypeId,
attendee: {
name: "Mr Proper",
email: "mr_proper@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
location: "https://meet.google.com/abc-def-ghi",
bookingFieldsResponses: {
customField: "customValue",
},
metadata: {
userId: "100",
},
};
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);
if (responseDataIsBooking(responseBody.data)) {
const data: BookingOutput_2024_08_13 = responseBody.data;
expect(AttendeeScheduledEmail.prototype.getHtml).toHaveBeenCalled();
expect(OrganizerScheduledEmail.prototype.getHtml).toHaveBeenCalled();
emailsEnabledSetup.createdBookingUid = responseBody.data.uid;
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibily recurring bookings"
);
}
});
});
it("should send an email when creating a recurring booking", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 9, 0, 0)).toISOString(),
eventTypeId: emailsEnabledSetup.recurringEventTypeId,
attendee: {
name: "Mr Proper",
email: "mr_proper@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
location: "https://meet.google.com/abc-def-ghi",
bookingFieldsResponses: {
customField: "customValue",
},
metadata: {
userId: "100",
},
};
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);
if (responseDataIsRecurringBooking(responseBody.data)) {
expect(AttendeeScheduledEmail.prototype.getHtml).toHaveBeenCalled();
expect(OrganizerScheduledEmail.prototype.getHtml).toHaveBeenCalled();
} else {
throw new Error("Invalid response data - expected booking array but received single booking");
}
});
});
it("should send an email when rescheduling a booking", async () => {
const body: RescheduleBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2035, 0, 8, 14, 0, 0)).toISOString(),
reschedulingReason: "Flying to mars that day",
};
return request(app.getHttpServer())
.post(`/v2/bookings/${emailsEnabledSetup.createdBookingUid}/reschedule`)
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201)
.then(async (response) => {
const responseBody: RescheduleBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(AttendeeScheduledEmail.prototype.getHtml).not.toHaveBeenCalled();
expect(OrganizerScheduledEmail.prototype.getHtml).not.toHaveBeenCalled();
expect(AttendeeRescheduledEmail.prototype.getHtml).toHaveBeenCalled();
expect(OrganizerRescheduledEmail.prototype.getHtml).toHaveBeenCalled();
emailsEnabledSetup.rescheduledBookingUid = responseBody.data.uid;
});
});
it("should send an email when cancelling a booking", async () => {
const body: CancelBookingInput_2024_08_13 = {
cancellationReason: "Going on a vacation",
};
return request(app.getHttpServer())
.post(`/v2/bookings/${emailsEnabledSetup.rescheduledBookingUid}/cancel`)
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: CancelBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(AttendeeCancelledEmail.prototype.getHtml).toHaveBeenCalled();
expect(OrganizerCancelledEmail.prototype.getHtml).toHaveBeenCalled();
});
});
});
afterAll(async () => {
await teamRepositoryFixture.delete(organization.id);
await userRepositoryFixture.deleteByEmail(emailsEnabledSetup.user.email);
await userRepositoryFixture.deleteByEmail(emailsDisabledSetup.user.email);
await bookingsRepositoryFixture.deleteAllBookings(
emailsEnabledSetup.user.id,
emailsEnabledSetup.user.email
);
await bookingsRepositoryFixture.deleteAllBookings(
emailsDisabledSetup.user.id,
emailsDisabledSetup.user.email
);
await app.close();
});
function responseDataIsBooking(data: any): data is BookingOutput_2024_08_13 {
return !Array.isArray(data) && typeof data === "object" && data && "id" in data;
}
function responseDataIsRecurringBooking(data: any): data is RecurringBookingOutput_2024_08_13[] {
return Array.isArray(data);
}
});
@@ -0,0 +1,335 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { ReassignBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reassign-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 { Booking, 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 } from "@calcom/platform-types";
import { PlatformOAuthClient, Team } from "@calcom/prisma/client";
describe("Bookings Endpoints 2024-08-13", () => {
describe("Reassign bookings", () => {
let app: INestApplication;
let organization: Team;
let team: 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 teamUser1: User;
let teamUser2: User;
let teamRoundRobinEventTypeId: number;
let roundRobinBooking: Booking;
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" });
oAuthClient = await createOAuthClient(organization.id);
team = await teamRepositoryFixture.create({
name: "team 1",
isOrganization: false,
parent: { connect: { id: organization.id } },
createdByOAuthClient: {
connect: {
id: oAuthClient.id,
},
},
});
teamUser1 = await userRepositoryFixture.create({
email: teamUserEmail,
locale: "it",
name: "orgUser1team1",
});
teamUser2 = await userRepositoryFixture.create({
email: teamUserEmail2,
locale: "it",
name: "orgUser2team1",
});
const userSchedule: CreateScheduleInput_2024_04_15 = {
name: "working time",
timeZone: "Europe/Rome",
isDefault: true,
};
await schedulesService.createUserSchedule(teamUser1.id, userSchedule);
await schedulesService.createUserSchedule(teamUser2.id, userSchedule);
await profileRepositoryFixture.create({
uid: `usr-${teamUser1.id}`,
username: teamUserEmail,
organization: {
connect: {
id: organization.id,
},
},
user: {
connect: {
id: teamUser1.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",
team: { connect: { id: team.id } },
user: { connect: { id: teamUser1.id } },
accepted: true,
});
await membershipsRepositoryFixture.create({
role: "MEMBER",
team: { connect: { id: team.id } },
user: { connect: { id: teamUser2.id } },
accepted: true,
});
const team1EventType = await eventTypesRepositoryFixture.createTeamEventType({
schedulingType: "ROUND_ROBIN",
team: {
connect: { id: team.id },
},
users: {
connect: [{ id: teamUser1.id }, { id: teamUser2.id }],
},
title: "Round Robin Event Type",
slug: "round-robin-event-type",
length: 60,
assignAllTeamMembers: false,
bookingFields: [],
locations: [{ type: "inPerson", address: "via 10, rome, italy" }],
});
teamRoundRobinEventTypeId = team1EventType.id;
await hostsRepositoryFixture.create({
isFixed: false,
user: {
connect: {
id: teamUser1.id,
},
},
eventType: {
connect: {
id: team1EventType.id,
},
},
});
await hostsRepositoryFixture.create({
isFixed: false,
user: {
connect: {
id: teamUser2.id,
},
},
eventType: {
connect: {
id: team1EventType.id,
},
},
});
roundRobinBooking = await bookingsRepositoryFixture.create({
user: {
connect: {
id: teamUser1.id,
},
},
startTime: new Date(Date.UTC(2050, 0, 7, 13, 0, 0)),
endTime: new Date(Date.UTC(2050, 0, 7, 14, 0, 0)),
title: "round robin coding lets goo",
uid: "round-robin-coding",
eventType: {
connect: {
id: teamRoundRobinEventTypeId,
},
},
location: "via 10, rome, italy",
customInputs: {},
metadata: {},
responses: {
name: "Bob",
email: "bob@gmail.com",
},
attendees: {
create: {
email: "bob@gmail.com",
name: "Bob",
locale: "it",
timeZone: "Europe/Rome",
},
},
});
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
it("should reassign round robin booking", async () => {
const booking = await bookingsRepositoryFixture.getByUid(roundRobinBooking.uid);
expect(booking?.userId).toEqual(teamUser1.id);
return request(app.getHttpServer())
.post(`/v2/bookings/${roundRobinBooking.uid}/reassign`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: ReassignBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
const data: ReassignBookingOutput_2024_08_13["data"] = responseBody.data;
expect(data.bookingUid).toEqual(roundRobinBooking.uid);
expect(data.reassignedTo).toEqual({
id: teamUser2.id,
name: teamUser2.name,
email: teamUser2.email,
});
const reassigned = await bookingsRepositoryFixture.getByUid(roundRobinBooking.uid);
expect(reassigned?.userId).toEqual(teamUser2.id);
});
});
it("should reassign round robin booking to a specific user", async () => {
const booking = await bookingsRepositoryFixture.getByUid(roundRobinBooking.uid);
expect(booking?.userId).toEqual(teamUser2.id);
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(),
eventTypeId: teamRoundRobinEventTypeId,
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/${roundRobinBooking.uid}/reassign/${teamUser1.id}`)
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: ReassignBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
const data: ReassignBookingOutput_2024_08_13["data"] = responseBody.data;
expect(data.bookingUid).toEqual(roundRobinBooking.uid);
expect(data.reassignedTo).toEqual({
id: teamUser1.id,
name: teamUser1.name,
email: teamUser1.email,
});
const reassigned = await bookingsRepositoryFixture.getByUid(roundRobinBooking.uid);
expect(reassigned?.userId).toEqual(teamUser1.id);
});
});
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(teamUser1.email);
await userRepositoryFixture.deleteByEmail(teamUserEmail2);
await bookingsRepositoryFixture.deleteAllBookings(teamUser1.id, teamUser1.email);
await bookingsRepositoryFixture.deleteAllBookings(teamUser2.id, teamUser2.email);
await app.close();
});
});
});
@@ -0,0 +1,706 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output";
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { UsersModule } from "@/modules/users/users.module";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import { User } from "@prisma/client";
import * as request from "supertest";
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { withApiAuth } from "test/utils/withApiAuth";
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants";
import { AttendeeCancelledEmail, OrganizerCancelledEmail } from "@calcom/platform-libraries";
import {
CreateRecurringBookingInput_2024_08_13,
RecurringBookingOutput_2024_08_13,
} from "@calcom/platform-types";
import { CancelBookingInput_2024_08_13 } from "@calcom/platform-types";
import { PlatformOAuthClient, Team } from "@calcom/prisma/client";
describe("Bookings Endpoints 2024-08-13", () => {
describe("Creating recurring bookings", () => {
let app: INestApplication;
let organization: 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;
const userEmail = "bookings-controller-e2e@api.com";
let user: User;
const maxRecurrenceCount = 3;
let recurringEventTypeId: 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);
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
organization = await teamRepositoryFixture.create({ name: "organization bookings" });
oAuthClient = await createOAuthClient(organization.id);
user = await userRepositoryFixture.create({
email: userEmail,
platformOAuthClients: {
connect: {
id: oAuthClient.id,
},
},
});
const userSchedule: CreateScheduleInput_2024_04_15 = {
name: "working time",
timeZone: "Europe/Rome",
isDefault: true,
};
await schedulesService.createUserSchedule(user.id, userSchedule);
const recurringEvent = await eventTypesRepositoryFixture.create(
// note(Lauris): freq 2 means weekly, interval 1 means every week and count 3 means 3 weeks in a row
{
title: "peer coding recurring",
slug: "peer-coding-recurring",
length: 60,
recurringEvent: { freq: 2, count: maxRecurrenceCount, interval: 1 },
},
user.id
);
recurringEventTypeId = recurringEvent.id;
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
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;
}
it("should not create recurring booking with recurrenceCount larger than event type recurrence count", async () => {
const recurrenceCount = 1000;
const body: CreateRecurringBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 1, 4, 13, 0, 0)).toISOString(),
eventTypeId: recurringEventTypeId,
attendee: {
name: "Mr Proper Recurring",
email: "mr_proper_recurring@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
location: "https://meet.google.com/abc-def-ghi",
recurrenceCount,
};
return request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(400);
});
it("should create a recurring booking with recurrenceCount smaller than event type recurrence count", async () => {
const recurrenceCount = maxRecurrenceCount - 1;
const body: CreateRecurringBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 1, 4, 13, 0, 0)).toISOString(),
eventTypeId: recurringEventTypeId,
attendee: {
name: "Mr Proper Recurring",
email: "mr_proper_recurring@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
location: "https://meet.google.com/abc-def-ghi",
recurrenceCount,
};
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(responseDataIsRecurringBooking(responseBody.data)).toBe(true);
if (responseDataIsRecurringBooking(responseBody.data)) {
const data: RecurringBookingOutput_2024_08_13[] = responseBody.data;
expect(data.length).toEqual(maxRecurrenceCount - 1);
const firstBooking = data[0];
expect(firstBooking.id).toBeDefined();
expect(firstBooking.uid).toBeDefined();
expect(firstBooking.hosts[0].id).toEqual(user.id);
expect(firstBooking.status).toEqual("accepted");
expect(firstBooking.start).toEqual(new Date(Date.UTC(2030, 1, 4, 13, 0, 0)).toISOString());
expect(firstBooking.end).toEqual(new Date(Date.UTC(2030, 1, 4, 14, 0, 0)).toISOString());
expect(firstBooking.duration).toEqual(60);
expect(firstBooking.eventTypeId).toEqual(recurringEventTypeId);
expect(firstBooking.attendees[0]).toEqual({
name: body.attendee.name,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
});
expect(firstBooking.location).toEqual(body.location);
expect(firstBooking.recurringBookingUid).toBeDefined();
expect(firstBooking.absentHost).toEqual(false);
const secondBooking = data[1];
expect(secondBooking.id).toBeDefined();
expect(secondBooking.uid).toBeDefined();
expect(secondBooking.hosts[0].id).toEqual(user.id);
expect(secondBooking.status).toEqual("accepted");
expect(secondBooking.start).toEqual(new Date(Date.UTC(2030, 1, 11, 13, 0, 0)).toISOString());
expect(secondBooking.end).toEqual(new Date(Date.UTC(2030, 1, 11, 14, 0, 0)).toISOString());
expect(secondBooking.duration).toEqual(60);
expect(secondBooking.eventTypeId).toEqual(recurringEventTypeId);
expect(secondBooking.recurringBookingUid).toBeDefined();
expect(secondBooking.attendees[0]).toEqual({
name: body.attendee.name,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
});
expect(secondBooking.location).toEqual(body.location);
expect(secondBooking.absentHost).toEqual(false);
} else {
throw new Error(
"Invalid response data - expected recurring booking but received non array response"
);
}
});
});
it("should create a recurring booking with recurrenceCount equal to event type recurrence count", async () => {
const recurrenceCount = maxRecurrenceCount;
const body: CreateRecurringBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 1, 4, 13, 0, 0)).toISOString(),
eventTypeId: recurringEventTypeId,
attendee: {
name: "Mr Proper Recurring",
email: "mr_proper_recurring@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
location: "https://meet.google.com/abc-def-ghi",
recurrenceCount,
};
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(responseDataIsRecurringBooking(responseBody.data)).toBe(true);
if (responseDataIsRecurringBooking(responseBody.data)) {
const data: RecurringBookingOutput_2024_08_13[] = responseBody.data;
expect(data.length).toEqual(maxRecurrenceCount);
const firstBooking = data[0];
expect(firstBooking.id).toBeDefined();
expect(firstBooking.uid).toBeDefined();
expect(firstBooking.hosts[0].id).toEqual(user.id);
expect(firstBooking.status).toEqual("accepted");
expect(firstBooking.start).toEqual(new Date(Date.UTC(2030, 1, 4, 13, 0, 0)).toISOString());
expect(firstBooking.end).toEqual(new Date(Date.UTC(2030, 1, 4, 14, 0, 0)).toISOString());
expect(firstBooking.duration).toEqual(60);
expect(firstBooking.eventTypeId).toEqual(recurringEventTypeId);
expect(firstBooking.attendees[0]).toEqual({
name: body.attendee.name,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
});
expect(firstBooking.location).toEqual(body.location);
expect(firstBooking.meetingUrl).toEqual(body.location);
expect(firstBooking.recurringBookingUid).toBeDefined();
expect(firstBooking.absentHost).toEqual(false);
const secondBooking = data[1];
expect(secondBooking.id).toBeDefined();
expect(secondBooking.uid).toBeDefined();
expect(secondBooking.hosts[0].id).toEqual(user.id);
expect(secondBooking.status).toEqual("accepted");
expect(secondBooking.start).toEqual(new Date(Date.UTC(2030, 1, 11, 13, 0, 0)).toISOString());
expect(secondBooking.end).toEqual(new Date(Date.UTC(2030, 1, 11, 14, 0, 0)).toISOString());
expect(secondBooking.duration).toEqual(60);
expect(secondBooking.eventTypeId).toEqual(recurringEventTypeId);
expect(secondBooking.recurringBookingUid).toBeDefined();
expect(secondBooking.attendees[0]).toEqual({
name: body.attendee.name,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
});
expect(secondBooking.location).toEqual(body.location);
expect(secondBooking.absentHost).toEqual(false);
const thirdBooking = data[2];
expect(thirdBooking.id).toBeDefined();
expect(thirdBooking.uid).toBeDefined();
expect(thirdBooking.hosts[0].id).toEqual(user.id);
expect(thirdBooking.status).toEqual("accepted");
expect(thirdBooking.start).toEqual(new Date(Date.UTC(2030, 1, 18, 13, 0, 0)).toISOString());
expect(thirdBooking.end).toEqual(new Date(Date.UTC(2030, 1, 18, 14, 0, 0)).toISOString());
expect(thirdBooking.duration).toEqual(60);
expect(thirdBooking.eventTypeId).toEqual(recurringEventTypeId);
expect(thirdBooking.recurringBookingUid).toBeDefined();
expect(thirdBooking.attendees[0]).toEqual({
name: body.attendee.name,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
});
expect(thirdBooking.location).toEqual(body.location);
expect(thirdBooking.absentHost).toEqual(false);
} else {
throw new Error(
"Invalid response data - expected recurring booking but received non array response"
);
}
});
});
afterEach(async () => {
await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email);
});
afterAll(async () => {
await oauthClientRepositoryFixture.delete(oAuthClient.id);
await teamRepositoryFixture.delete(organization.id);
await userRepositoryFixture.deleteByEmail(user.email);
await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email);
await app.close();
});
});
function responseDataIsRecurringBooking(data: any): data is RecurringBookingOutput_2024_08_13[] {
return Array.isArray(data);
}
describe("Recurring bookings cancel all subsequent bookings", () => {
let app: INestApplication;
let organization: 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;
const userEmail = "bookings-controller-e2e@api.com";
let user: User;
const maxRecurrenceCount = 4;
let recurringEventTypeId: number;
let recurringBooking: RecurringBookingOutput_2024_08_13[];
beforeAll(async () => {
jest.spyOn(AttendeeCancelledEmail.prototype as any, "getHtml").mockImplementation(async function () {
return "<html><body>Mocked Email Content</body></html>";
});
jest.spyOn(OrganizerCancelledEmail.prototype as any, "getHtml").mockImplementation(async function () {
return "<html><body>Mocked Email Content</body></html>";
});
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);
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
organization = await teamRepositoryFixture.create({ name: "organization bookings" });
oAuthClient = await createOAuthClient(organization.id);
user = await userRepositoryFixture.create({
email: userEmail,
platformOAuthClients: {
connect: {
id: oAuthClient.id,
},
},
});
const userSchedule: CreateScheduleInput_2024_04_15 = {
name: "working time",
timeZone: "Europe/Rome",
isDefault: true,
};
await schedulesService.createUserSchedule(user.id, userSchedule);
const recurringEvent = await eventTypesRepositoryFixture.create(
// note(Lauris): freq 2 means weekly, interval 1 means every week and count 3 means 3 weeks in a row
{
title: "peer coding recurring",
slug: "peer-coding-recurring",
length: 60,
recurringEvent: { freq: 2, count: maxRecurrenceCount, interval: 1 },
},
user.id
);
recurringEventTypeId = recurringEvent.id;
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
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;
}
it("should create a recurring booking", async () => {
const body: CreateRecurringBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 1, 4, 13, 0, 0)).toISOString(),
eventTypeId: recurringEventTypeId,
attendee: {
name: "Mr Proper Recurring",
email: "mr_proper_recurring@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
location: "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(responseDataIsRecurringBooking(responseBody.data)).toBe(true);
if (responseDataIsRecurringBooking(responseBody.data)) {
const data: RecurringBookingOutput_2024_08_13[] = responseBody.data;
expect(data.length).toEqual(maxRecurrenceCount);
recurringBooking = data;
} else {
throw new Error(
"Invalid response data - expected recurring booking but received non array response"
);
}
});
});
it("should cancel all recurrences after a specific recurrence", async () => {
const recurringBookingUid = recurringBooking[2].recurringBookingUid;
const bookings = await bookingsRepositoryFixture.getByRecurringBookingUid(recurringBookingUid);
for (const booking of bookings) {
expect(booking.status).toEqual("ACCEPTED");
}
// note(Lauris): cancel all recurrences after and including third recurrence in the series of 4 recurrences
// aka cancel 3rd and 4th recurrences.
const thirdRecurrenceUid = recurringBooking[2].uid;
const fourthRecurringUid = recurringBooking[3].uid;
const body: CancelBookingInput_2024_08_13 = {
cancelSubsequentBookings: true,
};
return request(app.getHttpServer())
.post(`/v2/bookings/${thirdRecurrenceUid}/cancel`)
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsRecurringBooking(responseBody.data)).toBe(true);
if (responseDataIsRecurringBooking(responseBody.data)) {
const data: RecurringBookingOutput_2024_08_13[] = responseBody.data;
expect(data.length).toEqual(4);
const firstRecurrence = data.find((booking) => booking.uid === recurringBooking[0].uid);
expect(firstRecurrence).toBeDefined();
expect(firstRecurrence?.status).toEqual("accepted");
const secondRecurrence = data.find((booking) => booking.uid === recurringBooking[1].uid);
expect(secondRecurrence).toBeDefined();
expect(secondRecurrence?.status).toEqual("accepted");
const thirdRecurrence = data.find((booking) => booking.uid === thirdRecurrenceUid);
expect(thirdRecurrence).toBeDefined();
expect(thirdRecurrence?.status).toEqual("cancelled");
const fourthRecurrence = data.find((booking) => booking.uid === fourthRecurringUid);
expect(fourthRecurrence).toBeDefined();
expect(fourthRecurrence?.status).toEqual("cancelled");
const bookings = await bookingsRepositoryFixture.getByRecurringBookingUid(recurringBookingUid);
const bookingFirst = bookings.find((booking) => booking.uid === recurringBooking[0].uid);
expect(bookingFirst?.status).toEqual("ACCEPTED");
const bookingSecond = bookings.find((booking) => booking.uid === recurringBooking[1].uid);
expect(bookingSecond?.status).toEqual("ACCEPTED");
const bookingThird = bookings.find((booking) => booking.uid === recurringBooking[2].uid);
expect(bookingThird?.status).toEqual("CANCELLED");
const bookingFourth = bookings.find((booking) => booking.uid === recurringBooking[3].uid);
expect(bookingFourth?.status).toEqual("CANCELLED");
} else {
throw new Error(
"Invalid response data - expected recurring booking but received non array response"
);
}
});
});
afterAll(async () => {
await oauthClientRepositoryFixture.delete(oAuthClient.id);
await teamRepositoryFixture.delete(organization.id);
await userRepositoryFixture.deleteByEmail(user.email);
await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email);
await app.close();
});
});
describe("Recurring bookings cancel all remaining bookings (bookings with start time greater than this moment)", () => {
let app: INestApplication;
let organization: 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;
const userEmail = "bookings-controller-e2e@api.com";
let user: User;
const maxRecurrenceCount = 4;
let recurringEventTypeId: number;
let recurringBooking: RecurringBookingOutput_2024_08_13[];
beforeAll(async () => {
jest.spyOn(AttendeeCancelledEmail.prototype as any, "getHtml").mockImplementation(async function () {
return "<html><body>Mocked Email Content</body></html>";
});
jest.spyOn(OrganizerCancelledEmail.prototype as any, "getHtml").mockImplementation(async function () {
return "<html><body>Mocked Email Content</body></html>";
});
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);
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
organization = await teamRepositoryFixture.create({ name: "organization bookings" });
oAuthClient = await createOAuthClient(organization.id);
user = await userRepositoryFixture.create({
email: userEmail,
platformOAuthClients: {
connect: {
id: oAuthClient.id,
},
},
});
const userSchedule: CreateScheduleInput_2024_04_15 = {
name: "working time",
timeZone: "Europe/Rome",
isDefault: true,
};
await schedulesService.createUserSchedule(user.id, userSchedule);
const recurringEvent = await eventTypesRepositoryFixture.create(
// note(Lauris): freq 2 means weekly, interval 1 means every week and count 3 means 3 weeks in a row
{
title: "peer coding recurring",
slug: "peer-coding-recurring",
length: 60,
recurringEvent: { freq: 2, count: maxRecurrenceCount, interval: 1 },
},
user.id
);
recurringEventTypeId = recurringEvent.id;
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
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;
}
it("should create a recurring booking", async () => {
const body: CreateRecurringBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2040, 1, 3, 13, 0, 0)).toISOString(),
eventTypeId: recurringEventTypeId,
attendee: {
name: "Mr Proper Recurring",
email: "mr_proper_recurring@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
location: "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(responseDataIsRecurringBooking(responseBody.data)).toBe(true);
if (responseDataIsRecurringBooking(responseBody.data)) {
const data: RecurringBookingOutput_2024_08_13[] = responseBody.data;
expect(data.length).toEqual(maxRecurrenceCount);
recurringBooking = data;
} else {
throw new Error(
"Invalid response data - expected recurring booking but received non array response"
);
}
});
});
it("should cancel all remaning recurrences", async () => {
const recurringBookingUid = recurringBooking[0].recurringBookingUid;
return request(app.getHttpServer())
.post(`/v2/bookings/${recurringBookingUid}/cancel`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsRecurringBooking(responseBody.data)).toBe(true);
if (responseDataIsRecurringBooking(responseBody.data)) {
const data: RecurringBookingOutput_2024_08_13[] = responseBody.data;
expect(data.length).toEqual(4);
for (const booking of data) {
expect(booking.status).toEqual("cancelled");
}
} else {
throw new Error(
"Invalid response data - expected recurring booking but received non array response"
);
}
});
});
afterAll(async () => {
await oauthClientRepositoryFixture.delete(oAuthClient.id);
await teamRepositoryFixture.delete(organization.id);
await userRepositoryFixture.deleteByEmail(user.email);
await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email);
await app.close();
});
});
});
@@ -93,6 +93,11 @@ describe("Bookings Endpoints 2024-08-13", () => {
user = await userRepositoryFixture.create({
email: userEmail,
platformOAuthClients: {
connect: {
id: oAuthClient.id,
},
},
});
const userSchedule: CreateScheduleInput_2024_04_15 = {
@@ -163,6 +168,9 @@ describe("Bookings Endpoints 2024-08-13", () => {
bookingFieldsResponses: {
codingLanguage: "TypeScript",
},
metadata: {
userId: "100",
},
};
return request(app.getHttpServer())
@@ -205,6 +213,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
name: body.attendee.name,
...body.bookingFieldsResponses,
},
metadata: body.metadata,
});
expect(data.location).toBeDefined();
expect(data.absentHost).toEqual(false);
@@ -230,6 +239,9 @@ describe("Bookings Endpoints 2024-08-13", () => {
bookingFieldsResponses: {
codingLanguage: "Rust",
},
metadata: {
userId: "200",
},
};
return request(app.getHttpServer())
@@ -274,6 +286,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
name: createdSeatedBooking.attendees[0].name,
...createdSeatedBooking.attendees[0].bookingFieldsResponses,
},
metadata: createdSeatedBooking.attendees[0].metadata,
});
const secondAttendee = data.attendees.find((attendee) => attendee.name === nameAttendeeTwo);
expect(secondAttendee).toEqual({
@@ -286,6 +299,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
name: body.attendee.name,
...body.bookingFieldsResponses,
},
metadata: body.metadata,
});
expect(data.location).toBeDefined();
expect(data.absentHost).toEqual(false);
@@ -314,6 +328,9 @@ describe("Bookings Endpoints 2024-08-13", () => {
codingLanguage: "TypeScript",
},
recurrenceCount,
metadata: {
userId: "300",
},
};
return request(app.getHttpServer())
@@ -359,6 +376,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
name: body.attendee.name,
...body.bookingFieldsResponses,
},
metadata: body.metadata,
});
expect(firstBooking.location).toEqual(body.location);
expect(firstBooking.absentHost).toEqual(false);
@@ -392,6 +410,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
name: body.attendee.name,
...body.bookingFieldsResponses,
},
metadata: body.metadata,
});
expect(secondBooking.location).toEqual(body.location);
expect(secondBooking.absentHost).toEqual(false);
@@ -565,6 +584,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
name: attendee?.name,
...attendee?.bookingFieldsResponses,
},
metadata: attendee?.metadata,
});
expect(data.location).toBeDefined();
expect(data.absentHost).toEqual(false);
@@ -642,4 +662,391 @@ describe("Bookings Endpoints 2024-08-13", () => {
await app.close();
});
});
describe("Recurring seated bookings creation", () => {
let app: INestApplication;
let organization: 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;
const userEmail = "seated-bookings-controller-e2e@api.com";
let user: User;
const seatedTventTypeSlug = "peer-coding-seated";
const recurringSeatedTventTypeSlug = "peer-coding-recurring-seated";
let seatedEventTypeId: number;
let recurringSeatedEventTypeId: number;
const maxRecurrenceCount = 3;
let createdSeatedBooking: CreateSeatedBookingOutput_2024_08_13;
let createdRecurringSeatedBooking: CreateRecurringSeatedBookingOutput_2024_08_13[];
const emailAttendeeOne = "mr_proper_first@gmail.com";
const nameAttendeeOne = "Mr Proper First";
const emailAttendeeTwo = "mr_proper_second@gmail.com";
const nameAttendeeTwo = "Mr Proper Second";
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);
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
organization = await teamRepositoryFixture.create({ name: "organization bookings" });
oAuthClient = await createOAuthClient(organization.id);
user = await userRepositoryFixture.create({
email: userEmail,
platformOAuthClients: {
connect: {
id: oAuthClient.id,
},
},
});
const userSchedule: CreateScheduleInput_2024_04_15 = {
name: "working time",
timeZone: "Europe/Rome",
isDefault: true,
};
await schedulesService.createUserSchedule(user.id, userSchedule);
const seatedEvent = await eventTypesRepositoryFixture.create(
{
title: "peer coding",
slug: seatedTventTypeSlug,
length: 60,
seatsPerTimeSlot: 5,
seatsShowAttendees: true,
seatsShowAvailabilityCount: true,
locations: [{ type: "inPerson", address: "via 10, rome, italy" }],
},
user.id
);
seatedEventTypeId = seatedEvent.id;
const recurringSeatedEvent = await eventTypesRepositoryFixture.create(
// note(Lauris): freq 2 means weekly, interval 1 means every week and count 3 means 3 weeks in a row
{
title: "peer coding recurring",
slug: recurringSeatedTventTypeSlug,
length: 60,
recurringEvent: { freq: 2, count: maxRecurrenceCount, interval: 1 },
seatsPerTimeSlot: 5,
seatsShowAttendees: true,
seatsShowAvailabilityCount: true,
locations: [{ type: "inPerson", address: "via 10, rome, italy" }],
},
user.id
);
recurringSeatedEventTypeId = recurringSeatedEvent.id;
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
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;
}
it("should book a recurring event type with seats for the first time", async () => {
const recurrenceCount = 2;
const body: CreateRecurringBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 11, 0, 0)).toISOString(),
eventTypeId: recurringSeatedEventTypeId,
attendee: {
name: nameAttendeeOne,
email: emailAttendeeOne,
timeZone: "Europe/Rome",
language: "it",
},
location: "https://meet.google.com/abc-def-ghi",
bookingFieldsResponses: {
codingLanguage: "TypeScript",
},
metadata: {
userId: "1000",
},
recurrenceCount,
};
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(responseDataIsCreateRecurringSeatedBooking(responseBody.data)).toBe(true);
if (responseDataIsCreateRecurringSeatedBooking(responseBody.data)) {
const data: CreateRecurringSeatedBookingOutput_2024_08_13[] = responseBody.data;
expect(data.length).toEqual(recurrenceCount);
const firstBooking = data[0];
expect(firstBooking.seatUid).toBeDefined();
const seatUid = firstBooking.seatUid;
expect(firstBooking.id).toBeDefined();
expect(firstBooking.uid).toBeDefined();
expect(firstBooking.hosts[0].id).toEqual(user.id);
expect(firstBooking.status).toEqual("accepted");
expect(firstBooking.start).toEqual(body.start);
expect(firstBooking.end).toEqual(
DateTime.fromISO(body.start, { zone: "utc" }).plus({ hours: 1 }).toISO()
);
expect(firstBooking.duration).toEqual(60);
expect(firstBooking.eventTypeId).toEqual(recurringSeatedEventTypeId);
expect(firstBooking.eventType).toEqual({
id: recurringSeatedEventTypeId,
slug: recurringSeatedTventTypeSlug,
});
expect(firstBooking.attendees.length).toEqual(1);
expect(firstBooking.attendees[0]).toEqual({
name: body.attendee.name,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
seatUid,
bookingFieldsResponses: {
name: body.attendee.name,
...body.bookingFieldsResponses,
},
metadata: body.metadata,
});
expect(firstBooking.location).toEqual(body.location);
expect(firstBooking.absentHost).toEqual(false);
const secondBooking = data[1];
expect(secondBooking.seatUid).toBeDefined();
const secondSeatUid = secondBooking.seatUid;
expect(secondBooking.id).toBeDefined();
expect(secondBooking.uid).toBeDefined();
expect(secondBooking.hosts[0].id).toEqual(user.id);
expect(secondBooking.status).toEqual("accepted");
const expectedStart = DateTime.fromISO(body.start, { zone: "utc" }).plus({ weeks: 1 }).toISO();
expect(secondBooking.start).toEqual(expectedStart);
expect(secondBooking.end).toEqual(
DateTime.fromISO(expectedStart!, { zone: "utc" }).plus({ hours: 1 }).toISO()
);
expect(secondBooking.duration).toEqual(60);
expect(secondBooking.eventTypeId).toEqual(recurringSeatedEventTypeId);
expect(secondBooking.eventType).toEqual({
id: recurringSeatedEventTypeId,
slug: recurringSeatedTventTypeSlug,
});
expect(secondBooking.attendees.length).toEqual(1);
expect(secondBooking.attendees[0]).toEqual({
name: body.attendee.name,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
seatUid: secondSeatUid,
bookingFieldsResponses: {
name: body.attendee.name,
...body.bookingFieldsResponses,
},
metadata: body.metadata,
});
expect(secondBooking.location).toEqual(body.location);
expect(secondBooking.absentHost).toEqual(false);
createdRecurringSeatedBooking = data;
} else {
throw new Error(
"Invalid response data - expected recurring booking but received non array response"
);
}
});
});
it("should book a recurring event type with seats for the second time", async () => {
const recurrenceCount = 2;
const body: CreateRecurringBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 11, 0, 0)).toISOString(),
eventTypeId: recurringSeatedEventTypeId,
attendee: {
name: nameAttendeeTwo,
email: emailAttendeeTwo,
timeZone: "Europe/Rome",
language: "it",
},
location: "https://meet.google.com/abc-def-ghi",
bookingFieldsResponses: {
codingLanguage: "TypeScript",
},
metadata: {
userId: "2000",
},
recurrenceCount,
};
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(responseDataIsCreateRecurringSeatedBooking(responseBody.data)).toBe(true);
if (responseDataIsCreateRecurringSeatedBooking(responseBody.data)) {
const data: CreateRecurringSeatedBookingOutput_2024_08_13[] = responseBody.data;
expect(data.length).toEqual(recurrenceCount);
const firstBooking = data[0];
expect(firstBooking.seatUid).toBeDefined();
const seatUid = firstBooking.seatUid;
expect(firstBooking.id).toBeDefined();
expect(firstBooking.uid).toBeDefined();
expect(firstBooking.hosts[0].id).toEqual(user.id);
expect(firstBooking.status).toEqual("accepted");
expect(firstBooking.start).toEqual(body.start);
expect(firstBooking.end).toEqual(
DateTime.fromISO(body.start, { zone: "utc" }).plus({ hours: 1 }).toISO()
);
expect(firstBooking.duration).toEqual(60);
expect(firstBooking.eventTypeId).toEqual(recurringSeatedEventTypeId);
expect(firstBooking.eventType).toEqual({
id: recurringSeatedEventTypeId,
slug: recurringSeatedTventTypeSlug,
});
expect(firstBooking.attendees.length).toEqual(2);
expect(firstBooking.attendees[0]).toEqual({
name: nameAttendeeOne,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
seatUid: createdRecurringSeatedBooking[0].seatUid,
bookingFieldsResponses: {
name: nameAttendeeOne,
...body.bookingFieldsResponses,
},
metadata: createdRecurringSeatedBooking[0].attendees[0].metadata,
});
expect(firstBooking.attendees[1]).toEqual({
name: nameAttendeeTwo,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
seatUid,
bookingFieldsResponses: {
name: nameAttendeeTwo,
...body.bookingFieldsResponses,
},
metadata: body.metadata,
});
expect(firstBooking.location).toEqual(body.location);
expect(firstBooking.absentHost).toEqual(false);
const secondBooking = data[1];
expect(secondBooking.seatUid).toBeDefined();
const secondSeatUid = secondBooking.seatUid;
expect(secondBooking.id).toBeDefined();
expect(secondBooking.uid).toBeDefined();
expect(secondBooking.hosts[0].id).toEqual(user.id);
expect(secondBooking.status).toEqual("accepted");
const expectedStart = DateTime.fromISO(body.start, { zone: "utc" }).plus({ weeks: 1 }).toISO();
expect(secondBooking.start).toEqual(expectedStart);
expect(secondBooking.end).toEqual(
DateTime.fromISO(expectedStart!, { zone: "utc" }).plus({ hours: 1 }).toISO()
);
expect(secondBooking.duration).toEqual(60);
expect(secondBooking.eventTypeId).toEqual(recurringSeatedEventTypeId);
expect(secondBooking.eventType).toEqual({
id: recurringSeatedEventTypeId,
slug: recurringSeatedTventTypeSlug,
});
expect(secondBooking.attendees.length).toEqual(2);
expect(secondBooking.attendees[0]).toEqual({
name: nameAttendeeOne,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
seatUid: createdRecurringSeatedBooking[1].seatUid,
bookingFieldsResponses: {
name: nameAttendeeOne,
...body.bookingFieldsResponses,
},
metadata: createdRecurringSeatedBooking[0].attendees[0].metadata,
});
expect(secondBooking.attendees[1]).toEqual({
name: nameAttendeeTwo,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
seatUid: secondSeatUid,
bookingFieldsResponses: {
name: nameAttendeeTwo,
...body.bookingFieldsResponses,
},
metadata: body.metadata,
});
expect(secondBooking.location).toEqual(body.location);
expect(secondBooking.absentHost).toEqual(false);
createdRecurringSeatedBooking = data;
} else {
throw new Error(
"Invalid response data - expected recurring booking but received non array response"
);
}
});
});
function responseDataIsCreateRecurringSeatedBooking(
data: any
): data is CreateRecurringSeatedBookingOutput_2024_08_13[] {
return Array.isArray(data);
}
function responseDataIsGetSeatedBooking(data: any): data is GetSeatedBookingOutput_2024_08_13 {
return data?.attendees?.every((attendee: any) => attendee?.hasOwnProperty("seatUid"));
}
function responseDataIsGetRecurringSeatedBooking(
data: any
): data is GetRecurringSeatedBookingOutput_2024_08_13[] {
return Array.isArray(data);
}
afterAll(async () => {
await oauthClientRepositoryFixture.delete(oAuthClient.id);
await teamRepositoryFixture.delete(organization.id);
await userRepositoryFixture.deleteByEmail(user.email);
await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email);
await app.close();
});
});
});
@@ -85,30 +85,50 @@ describe("Bookings Endpoints 2024-08-13", () => {
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
organization = await organizationsRepositoryFixture.create({ name: "organization team bookings" });
oAuthClient = await createOAuthClient(organization.id);
team1 = await teamRepositoryFixture.create({
name: "team 1",
isOrganization: false,
parent: { connect: { id: organization.id } },
createdByOAuthClient: {
connect: {
id: oAuthClient.id,
},
},
});
team2 = await teamRepositoryFixture.create({
name: "team 2",
isOrganization: false,
parent: { connect: { id: organization.id } },
createdByOAuthClient: {
connect: {
id: oAuthClient.id,
},
},
});
oAuthClient = await createOAuthClient(organization.id);
teamUser = await userRepositoryFixture.create({
email: teamUserEmail,
locale: "it",
name: "orgUser1team1",
platformOAuthClients: {
connect: {
id: oAuthClient.id,
},
},
});
teamUser2 = await userRepositoryFixture.create({
email: teamUserEmail2,
locale: "es",
name: "orgUser2team1",
platformOAuthClients: {
connect: {
id: oAuthClient.id,
},
},
});
const userSchedule: CreateScheduleInput_2024_04_15 = {
@@ -14,6 +14,7 @@ import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import { User } from "@prisma/client";
import { advanceTo, clear } from "jest-date-mock";
import * as request from "supertest";
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
@@ -95,6 +96,11 @@ describe("Bookings Endpoints 2024-08-13", () => {
user = await userRepositoryFixture.create({
email: userEmail,
platformOAuthClients: {
connect: {
id: oAuthClient.id,
},
},
});
const userSchedule: CreateScheduleInput_2024_04_15 = {
@@ -178,6 +184,136 @@ describe("Bookings Endpoints 2024-08-13", () => {
});
describe("create bookings", () => {
describe("invalid metadata", () => {
it("should not be able to create a booking with metadata with more than 50 keys", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(),
eventTypeId,
attendee: {
name: "Mr Proper",
email: "mr_proper@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
location: "https://meet.google.com/abc-def-ghi",
bookingFieldsResponses: {
customField: "customValue",
},
metadata: {
key1: "1",
key2: "2",
key3: "3",
key4: "4",
key5: "5",
key6: "6",
key7: "7",
key8: "8",
key9: "9",
key10: "10",
key11: "11",
key12: "12",
key13: "13",
key14: "14",
key15: "15",
key16: "16",
key17: "17",
key18: "18",
key19: "19",
key20: "20",
key21: "21",
key22: "22",
key23: "23",
key24: "24",
key25: "25",
key26: "26",
key27: "27",
key28: "28",
key29: "29",
key30: "30",
key31: "31",
key32: "32",
key33: "33",
key34: "34",
key35: "35",
key36: "36",
key37: "37",
key38: "38",
key39: "39",
key40: "40",
key41: "41",
key42: "42",
key43: "43",
key44: "44",
key45: "45",
key46: "46",
key47: "47",
key48: "48",
key49: "49",
key50: "50",
key51: "51",
},
};
return request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(400);
});
it("should not be able to create a booking with metadata with a key longer than 40 characters", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(),
eventTypeId,
attendee: {
name: "Mr Proper",
email: "mr_proper@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
location: "https://meet.google.com/abc-def-ghi",
bookingFieldsResponses: {
customField: "customValue",
},
metadata: {
aaaaaaaaaabbbbbbbbbbccccccccccdddddddddde: "1",
},
};
return request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(400);
});
it("should not be able to create a booking with metadata with a value longer than 500 characters", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(),
eventTypeId,
attendee: {
name: "Mr Proper",
email: "mr_proper@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
location: "https://meet.google.com/abc-def-ghi",
bookingFieldsResponses: {
customField: "customValue",
},
metadata: {
key: `${"a".repeat(501)}`,
},
};
return request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(400);
});
});
it("should create a booking", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(),
@@ -192,14 +328,19 @@ describe("Bookings Endpoints 2024-08-13", () => {
bookingFieldsResponses: {
customField: "customValue",
},
metadata: {
userId: "100",
},
};
const beforeCreate = new Date();
return request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201)
.then(async (response) => {
const afterCreate = new Date();
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
@@ -233,6 +374,13 @@ describe("Bookings Endpoints 2024-08-13", () => {
email: body.attendee.email,
...body.bookingFieldsResponses,
});
// Check createdAt date is between the time of the request and after the request
const createdAtDate = new Date(data.createdAt);
expect(createdAtDate.getTime()).toBeGreaterThanOrEqual(beforeCreate.getTime());
expect(createdAtDate.getTime()).toBeLessThanOrEqual(afterCreate.getTime());
expect(data.metadata).toEqual(body.metadata);
createdBooking = data;
} else {
throw new Error(
@@ -253,6 +401,9 @@ describe("Bookings Endpoints 2024-08-13", () => {
language: "it",
},
location: "https://meet.google.com/abc-def-ghi",
metadata: {
userId: "1000",
},
};
return request(app.getHttpServer())
@@ -288,6 +439,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
expect(firstBooking.location).toEqual(body.location);
expect(firstBooking.recurringBookingUid).toBeDefined();
expect(firstBooking.absentHost).toEqual(false);
expect(firstBooking.metadata).toEqual(body.metadata);
const secondBooking = data[1];
expect(secondBooking.id).toBeDefined();
@@ -307,6 +459,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
});
expect(secondBooking.location).toEqual(body.location);
expect(secondBooking.absentHost).toEqual(false);
expect(secondBooking.metadata).toEqual(body.metadata);
const thirdBooking = data[2];
expect(thirdBooking.id).toBeDefined();
@@ -326,6 +479,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
});
expect(thirdBooking.location).toEqual(body.location);
expect(thirdBooking.absentHost).toEqual(false);
expect(thirdBooking.metadata).toEqual(body.metadata);
createdRecurringBooking = data;
} else {
@@ -362,6 +516,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
expect(data.attendees[0]).toEqual(createdBooking.attendees[0]);
expect(data.location).toEqual(createdBooking.location);
expect(data.absentHost).toEqual(createdBooking.absentHost);
expect(data.createdAt).toEqual(createdBooking.createdAt);
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibily recurring bookings"
@@ -835,12 +990,14 @@ describe("Bookings Endpoints 2024-08-13", () => {
reschedulingReason: "Flying to mars that day",
};
const beforeCreate = new Date();
return request(app.getHttpServer())
.post(`/v2/bookings/${createdBooking.uid}/reschedule`)
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201)
.then(async (response) => {
const afterCreate = new Date();
const responseBody: RescheduleBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
@@ -860,6 +1017,13 @@ describe("Bookings Endpoints 2024-08-13", () => {
expect(data.attendees[0]).toEqual(createdBooking.attendees[0]);
expect(data.location).toEqual(createdBooking.location);
expect(data.absentHost).toEqual(createdBooking.absentHost);
expect(data.metadata).toEqual(createdBooking.metadata);
// When a booking is rescheduled, a new booking is created and the old booking is cancelled.
// We want to make sure the createdAt date of the new booking is between the beforeCreate and afterCreate dates.
const createdAtDate = new Date(data.createdAt);
expect(createdAtDate.getTime()).toBeGreaterThanOrEqual(beforeCreate.getTime());
expect(createdAtDate.getTime()).toBeLessThanOrEqual(afterCreate.getTime());
rescheduledBooking = data;
});
@@ -913,6 +1077,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
expect(data.attendees[0]).toEqual(createdRecurringBooking[0].attendees[0]);
expect(data.location).toEqual(createdRecurringBooking[0].location);
expect(data.absentHost).toEqual(createdRecurringBooking[0].absentHost);
expect(data.metadata).toEqual(createdRecurringBooking[0].metadata);
const oldBooking = await bookingsRepositoryFixture.getByUid(createdRecurringBooking[0].uid);
expect(oldBooking).toBeDefined();
@@ -952,6 +1117,14 @@ describe("Bookings Endpoints 2024-08-13", () => {
});
describe("mark absent", () => {
beforeAll(() => {
advanceTo(new Date(2035, 0, 9, 15, 0, 0));
});
afterAll(() => {
clear();
});
it("should mark host absent", async () => {
const body: MarkAbsentBookingInput_2024_08_13 = {
host: true,
@@ -1018,6 +1191,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
});
});
});
describe("cancel bookings", () => {
it("should cancel booking", async () => {
const body: CancelBookingInput_2024_08_13 = {
@@ -1127,293 +1301,4 @@ describe("Bookings Endpoints 2024-08-13", () => {
function responseDataIsRecurringBooking(data: any): data is RecurringBookingOutput_2024_08_13[] {
return Array.isArray(data);
}
describe("Recurring bookings", () => {
let app: INestApplication;
let organization: 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;
const userEmail = "bookings-controller-e2e@api.com";
let user: User;
const maxRecurrenceCount = 3;
let recurringEventTypeId: 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);
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
organization = await teamRepositoryFixture.create({ name: "organization bookings" });
oAuthClient = await createOAuthClient(organization.id);
user = await userRepositoryFixture.create({
email: userEmail,
});
const userSchedule: CreateScheduleInput_2024_04_15 = {
name: "working time",
timeZone: "Europe/Rome",
isDefault: true,
};
await schedulesService.createUserSchedule(user.id, userSchedule);
const recurringEvent = await eventTypesRepositoryFixture.create(
// note(Lauris): freq 2 means weekly, interval 1 means every week and count 3 means 3 weeks in a row
{
title: "peer coding recurring",
slug: "peer-coding-recurring",
length: 60,
recurringEvent: { freq: 2, count: maxRecurrenceCount, interval: 1 },
},
user.id
);
recurringEventTypeId = recurringEvent.id;
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
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;
}
it("should not create recurring booking with recurrenceCount larger than event type recurrence count", async () => {
const recurrenceCount = 1000;
const body: CreateRecurringBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 1, 4, 13, 0, 0)).toISOString(),
eventTypeId: recurringEventTypeId,
attendee: {
name: "Mr Proper Recurring",
email: "mr_proper_recurring@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
location: "https://meet.google.com/abc-def-ghi",
recurrenceCount,
};
return request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(400);
});
it("should create a recurring booking with recurrenceCount smaller than event type recurrence count", async () => {
const recurrenceCount = maxRecurrenceCount - 1;
const body: CreateRecurringBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 1, 4, 13, 0, 0)).toISOString(),
eventTypeId: recurringEventTypeId,
attendee: {
name: "Mr Proper Recurring",
email: "mr_proper_recurring@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
location: "https://meet.google.com/abc-def-ghi",
recurrenceCount,
};
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(responseDataIsRecurringBooking(responseBody.data)).toBe(true);
if (responseDataIsRecurringBooking(responseBody.data)) {
const data: RecurringBookingOutput_2024_08_13[] = responseBody.data;
expect(data.length).toEqual(maxRecurrenceCount - 1);
const firstBooking = data[0];
expect(firstBooking.id).toBeDefined();
expect(firstBooking.uid).toBeDefined();
expect(firstBooking.hosts[0].id).toEqual(user.id);
expect(firstBooking.status).toEqual("accepted");
expect(firstBooking.start).toEqual(new Date(Date.UTC(2030, 1, 4, 13, 0, 0)).toISOString());
expect(firstBooking.end).toEqual(new Date(Date.UTC(2030, 1, 4, 14, 0, 0)).toISOString());
expect(firstBooking.duration).toEqual(60);
expect(firstBooking.eventTypeId).toEqual(recurringEventTypeId);
expect(firstBooking.attendees[0]).toEqual({
name: body.attendee.name,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
});
expect(firstBooking.location).toEqual(body.location);
expect(firstBooking.recurringBookingUid).toBeDefined();
expect(firstBooking.absentHost).toEqual(false);
const secondBooking = data[1];
expect(secondBooking.id).toBeDefined();
expect(secondBooking.uid).toBeDefined();
expect(secondBooking.hosts[0].id).toEqual(user.id);
expect(secondBooking.status).toEqual("accepted");
expect(secondBooking.start).toEqual(new Date(Date.UTC(2030, 1, 11, 13, 0, 0)).toISOString());
expect(secondBooking.end).toEqual(new Date(Date.UTC(2030, 1, 11, 14, 0, 0)).toISOString());
expect(secondBooking.duration).toEqual(60);
expect(secondBooking.eventTypeId).toEqual(recurringEventTypeId);
expect(secondBooking.recurringBookingUid).toBeDefined();
expect(secondBooking.attendees[0]).toEqual({
name: body.attendee.name,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
});
expect(secondBooking.location).toEqual(body.location);
expect(secondBooking.absentHost).toEqual(false);
} else {
throw new Error(
"Invalid response data - expected recurring booking but received non array response"
);
}
});
});
it("should create a recurring booking with recurrenceCount equal to event type recurrence count", async () => {
const recurrenceCount = maxRecurrenceCount;
const body: CreateRecurringBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 1, 4, 13, 0, 0)).toISOString(),
eventTypeId: recurringEventTypeId,
attendee: {
name: "Mr Proper Recurring",
email: "mr_proper_recurring@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
location: "https://meet.google.com/abc-def-ghi",
recurrenceCount,
};
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(responseDataIsRecurringBooking(responseBody.data)).toBe(true);
if (responseDataIsRecurringBooking(responseBody.data)) {
const data: RecurringBookingOutput_2024_08_13[] = responseBody.data;
expect(data.length).toEqual(maxRecurrenceCount);
const firstBooking = data[0];
expect(firstBooking.id).toBeDefined();
expect(firstBooking.uid).toBeDefined();
expect(firstBooking.hosts[0].id).toEqual(user.id);
expect(firstBooking.status).toEqual("accepted");
expect(firstBooking.start).toEqual(new Date(Date.UTC(2030, 1, 4, 13, 0, 0)).toISOString());
expect(firstBooking.end).toEqual(new Date(Date.UTC(2030, 1, 4, 14, 0, 0)).toISOString());
expect(firstBooking.duration).toEqual(60);
expect(firstBooking.eventTypeId).toEqual(recurringEventTypeId);
expect(firstBooking.attendees[0]).toEqual({
name: body.attendee.name,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
});
expect(firstBooking.location).toEqual(body.location);
expect(firstBooking.meetingUrl).toEqual(body.location);
expect(firstBooking.recurringBookingUid).toBeDefined();
expect(firstBooking.absentHost).toEqual(false);
const secondBooking = data[1];
expect(secondBooking.id).toBeDefined();
expect(secondBooking.uid).toBeDefined();
expect(secondBooking.hosts[0].id).toEqual(user.id);
expect(secondBooking.status).toEqual("accepted");
expect(secondBooking.start).toEqual(new Date(Date.UTC(2030, 1, 11, 13, 0, 0)).toISOString());
expect(secondBooking.end).toEqual(new Date(Date.UTC(2030, 1, 11, 14, 0, 0)).toISOString());
expect(secondBooking.duration).toEqual(60);
expect(secondBooking.eventTypeId).toEqual(recurringEventTypeId);
expect(secondBooking.recurringBookingUid).toBeDefined();
expect(secondBooking.attendees[0]).toEqual({
name: body.attendee.name,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
});
expect(secondBooking.location).toEqual(body.location);
expect(secondBooking.absentHost).toEqual(false);
const thirdBooking = data[2];
expect(thirdBooking.id).toBeDefined();
expect(thirdBooking.uid).toBeDefined();
expect(thirdBooking.hosts[0].id).toEqual(user.id);
expect(thirdBooking.status).toEqual("accepted");
expect(thirdBooking.start).toEqual(new Date(Date.UTC(2030, 1, 18, 13, 0, 0)).toISOString());
expect(thirdBooking.end).toEqual(new Date(Date.UTC(2030, 1, 18, 14, 0, 0)).toISOString());
expect(thirdBooking.duration).toEqual(60);
expect(thirdBooking.eventTypeId).toEqual(recurringEventTypeId);
expect(thirdBooking.recurringBookingUid).toBeDefined();
expect(thirdBooking.attendees[0]).toEqual({
name: body.attendee.name,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
});
expect(thirdBooking.location).toEqual(body.location);
expect(thirdBooking.absentHost).toEqual(false);
} else {
throw new Error(
"Invalid response data - expected recurring booking but received non array response"
);
}
});
});
afterEach(async () => {
await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email);
});
afterAll(async () => {
await oauthClientRepositoryFixture.delete(oAuthClient.id);
await teamRepositoryFixture.delete(organization.id);
await userRepositoryFixture.deleteByEmail(user.email);
await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email);
await app.close();
});
});
});
@@ -0,0 +1,253 @@
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 { EventType, 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 { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { 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 { PlatformOAuthClient, Team } from "@calcom/prisma/client";
describe("Bookings Endpoints 2024-08-13", () => {
describe("User bookings", () => {
let app: INestApplication;
let organization: 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;
const userEmail = "bookings-controller-e2e@api.com";
let user: User;
let variableLengthEventType: EventType;
const variableLengthEventTypeSlug = "variable-length-event";
let normalEventType: EventType;
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);
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
organization = await teamRepositoryFixture.create({ name: "organization bookings" });
oAuthClient = await createOAuthClient(organization.id);
user = await userRepositoryFixture.create({
email: userEmail,
platformOAuthClients: {
connect: {
id: oAuthClient.id,
},
},
});
const userSchedule: CreateScheduleInput_2024_04_15 = {
name: "working time",
timeZone: "Europe/Rome",
isDefault: true,
};
await schedulesService.createUserSchedule(user.id, userSchedule);
variableLengthEventType = await eventTypesRepositoryFixture.create(
{
title: "variable length event",
slug: variableLengthEventTypeSlug,
length: 15,
metadata: { multipleDuration: [15, 30, 60] },
},
user.id
);
normalEventType = await eventTypesRepositoryFixture.create(
{ title: "normal event", slug: "normal-event", length: 15 },
user.id
);
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
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;
}
it("should be defined", () => {
expect(userRepositoryFixture).toBeDefined();
expect(user).toBeDefined();
});
describe("create bookings", () => {
it("should not be able to specify length of booking for non variable length event type", async () => {
const lengthInMinutes = 30;
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 10, 0, 0)).toISOString(),
eventTypeId: normalEventType.id,
lengthInMinutes,
attendee: {
name: "Mr Proper",
email: "mr_proper@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
location: "https://meet.google.com/abc-def-ghi",
bookingFieldsResponses: {
customField: "customValue",
},
};
return request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(400);
});
it("should create a booking with default length", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 10, 0, 0)).toISOString(),
eventTypeId: variableLengthEventType.id,
attendee: {
name: "Mr Proper",
email: "mr_proper@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
location: "https://meet.google.com/abc-def-ghi",
bookingFieldsResponses: {
customField: "customValue",
},
};
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.status).toEqual("accepted");
expect(data.start).toEqual(body.start);
expect(data.end).toEqual(
new Date(Date.UTC(2030, 0, 8, 10, variableLengthEventType.length, 0)).toISOString()
);
expect(data.duration).toEqual(variableLengthEventType.length);
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibily recurring bookings"
);
}
});
});
it("should create a booking with specified length that is not default length", async () => {
const lengthInMinutes = 30;
const body: CreateBookingInput_2024_08_13 = {
lengthInMinutes,
start: new Date(Date.UTC(2030, 0, 8, 11, 0, 0)).toISOString(),
eventTypeId: variableLengthEventType.id,
attendee: {
name: "Mr Proper",
email: "mr_proper@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
location: "https://meet.google.com/abc-def-ghi",
bookingFieldsResponses: {
customField: "customValue",
},
};
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.status).toEqual("accepted");
expect(data.start).toEqual(body.start);
expect(data.end).toEqual(new Date(Date.UTC(2030, 0, 8, 11, lengthInMinutes, 0)).toISOString());
expect(data.duration).toEqual(lengthInMinutes);
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibily recurring bookings"
);
}
});
});
});
afterAll(async () => {
await oauthClientRepositoryFixture.delete(oAuthClient.id);
await teamRepositoryFixture.delete(organization.id);
await userRepositoryFixture.deleteByEmail(user.email);
await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email);
await app.close();
});
});
function responseDataIsBooking(data: any): data is BookingOutput_2024_08_13 {
return !Array.isArray(data) && typeof data === "object" && data && "id" in data;
}
});
@@ -0,0 +1,21 @@
import { ApiProperty, 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 { ReassignBookingOutput_2024_08_13 as ReassignBookingOutputData_2024_08_13 } from "@calcom/platform-types";
export class ReassignBookingOutput_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(ReassignBookingOutputData_2024_08_13) }],
description:
"Booking data, which can be either a ReassignAutoBookingOutput object or a ReassignManualBookingOutput object",
})
@ValidateNested()
@Type(() => Object)
data!: ReassignBookingOutputData_2024_08_13;
}
@@ -3,7 +3,10 @@ import { InputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/servic
import { OutputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output.service";
import { 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 { BookingSeatRepository } from "@/modules/booking-seat/booking-seat.repository";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { UsersService } from "@/modules/users/services/users.service";
import { UsersRepository, UserWithProfile } from "@/modules/users/users.repository";
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
import { BadRequestException } from "@nestjs/common";
import { Request } from "express";
@@ -14,7 +17,10 @@ import {
getAllUserBookings,
handleInstantMeeting,
handleCancelBooking,
roundRobinReassignment,
roundRobinManualReassignment,
handleMarkNoShow,
confirmBookingHandler,
} from "@calcom/platform-libraries";
import {
CreateBookingInput_2024_08_13,
@@ -22,8 +28,8 @@ import {
CreateRecurringBookingInput_2024_08_13,
GetBookingsInput_2024_08_13,
CreateInstantBookingInput_2024_08_13,
CancelBookingInput_2024_08_13,
MarkAbsentBookingInput_2024_08_13,
ReassignToUserBookingInput_2024_08_13,
BookingOutput_2024_08_13,
RecurringBookingOutput_2024_08_13,
GetSeatedBookingOutput_2024_08_13,
@@ -46,9 +52,12 @@ export class BookingsService_2024_08_13 {
private readonly inputService: InputBookingsService_2024_08_13,
private readonly outputService: OutputBookingsService_2024_08_13,
private readonly bookingsRepository: BookingsRepository_2024_08_13,
private readonly bookingSeatRepository: BookingSeatRepository,
private readonly eventTypesRepository: EventTypesRepository_2024_06_14,
private readonly prismaReadService: PrismaReadService,
private readonly billingService: BillingService
private readonly billingService: BillingService,
private readonly usersService: UsersService,
private readonly usersRepository: UsersRepository
) {}
async createBooking(request: Request, body: CreateBookingInput) {
@@ -107,7 +116,7 @@ export class BookingsService_2024_08_13 {
const bookingRequest = await this.inputService.createRecurringBookingRequest(request, body);
const bookings = await handleNewRecurringBooking(bookingRequest);
return this.outputService.getOutputCreateRecurringSeatedBookings(
bookings.map((booking) => ({ id: booking.id || 0, seatUid: booking.seatReferenceUid || "" }))
bookings.map((booking) => ({ uid: booking.uid || "", seatUid: booking.seatReferenceUid || "" }))
);
}
@@ -282,11 +291,42 @@ export class BookingsService_2024_08_13 {
}
async cancelBooking(request: Request, bookingUid: string, body: CancelBookingInput) {
if (this.inputService.isCancelSeatedBody(body)) {
const seat = await this.bookingSeatRepository.getByReferenceUid(body.seatUid);
if (!seat) {
throw new BadRequestException(
"Invalid seatUid: this seat does not exist or has already been cancelled."
);
}
if (seat && bookingUid !== seat.booking.uid) {
throw new BadRequestException("Invalid seatUid: this seat does not belong to this booking.");
}
}
const bookingRequest = await this.inputService.createCancelBookingRequest(request, bookingUid, body);
await handleCancelBooking(bookingRequest);
if ("cancelSubsequentBookings" in body && body.cancelSubsequentBookings) {
return this.getAllRecurringBookingsByIndividualUid(bookingUid);
}
return this.getBooking(bookingUid);
}
private async getAllRecurringBookingsByIndividualUid(bookingUid: string) {
const booking = await this.bookingsRepository.getByUid(bookingUid);
const recurringBookingUid = booking?.recurringEventId;
if (!recurringBookingUid) {
throw new BadRequestException(
`Booking with bookingUid=${bookingUid} is not part of a recurring booking.`
);
}
return await this.getBooking(recurringBookingUid);
}
async markAbsent(bookingUid: string, bookingOwnerId: number, body: MarkAbsentBookingInput_2024_08_13) {
const bodyTransformed = this.inputService.transformInputMarkAbsentBooking(body);
@@ -342,4 +382,113 @@ export class BookingsService_2024_08_13 {
fromReschedule: oldBookingUid,
});
}
async reassignBooking(bookingUid: string, requestUser: UserWithProfile) {
const booking = await this.bookingsRepository.getByUid(bookingUid);
if (!booking) {
throw new NotFoundException(`Booking with uid=${bookingUid} was not found in the database`);
}
const emailsEnabled = booking.eventTypeId ? await this.getEmailsEnabled(booking.eventTypeId) : true;
const profile = this.usersService.getUserMainProfile(requestUser);
await roundRobinReassignment({
bookingId: booking.id,
orgId: profile?.organizationId || null,
emailsEnabled,
});
const reassigned = await this.bookingsRepository.getByUidWithUser(bookingUid);
if (!reassigned) {
throw new NotFoundException(`Reassigned booking with uid=${bookingUid} was not found in the database`);
}
return this.outputService.getOutputReassignedBooking(reassigned);
}
async getEmailsEnabled(eventTypeId: number) {
const oAuthParams = await this.inputService.getOAuthClientParams(eventTypeId);
const emailsEnabled = oAuthParams ? oAuthParams.arePlatformEmailsEnabled : true;
return emailsEnabled;
}
async reassignBookingToUser(
bookingUid: string,
newUserId: number,
reassignedById: number,
body: ReassignToUserBookingInput_2024_08_13
) {
const booking = await this.bookingsRepository.getByUid(bookingUid);
if (!booking) {
throw new NotFoundException(`Booking with uid=${bookingUid} was not found in the database`);
}
const user = await this.usersRepository.findByIdWithProfile(newUserId);
if (!user) {
throw new NotFoundException(`User with id=${newUserId} was not found in the database`);
}
const emailsEnabled = booking.eventTypeId ? await this.getEmailsEnabled(booking.eventTypeId) : true;
const profile = this.usersService.getUserMainProfile(user);
const reassigned = await roundRobinManualReassignment({
bookingId: booking.id,
newUserId,
orgId: profile?.organizationId || null,
reassignReason: body.reason,
reassignedById,
emailsEnabled,
});
return this.outputService.getOutputReassignedBooking(reassigned);
}
async confirmBooking(bookingUid: string, requestUser: UserWithProfile) {
const booking = await this.bookingsRepository.getByUid(bookingUid);
if (!booking) {
throw new NotFoundException(`Booking with uid=${bookingUid} was not found in the database`);
}
const emailsEnabled = booking.eventTypeId ? await this.getEmailsEnabled(booking.eventTypeId) : true;
await confirmBookingHandler({
ctx: {
user: requestUser,
},
input: {
bookingId: booking.id,
confirmed: true,
recurringEventId: booking.recurringEventId,
emailsEnabled,
},
});
return this.getBooking(bookingUid);
}
async declineBooking(bookingUid: string, requestUser: UserWithProfile, reason?: string) {
const booking = await this.bookingsRepository.getByUid(bookingUid);
if (!booking) {
throw new NotFoundException(`Booking with uid=${bookingUid} was not found in the database`);
}
const emailsEnabled = booking.eventTypeId ? await this.getEmailsEnabled(booking.eventTypeId) : true;
await confirmBookingHandler({
ctx: {
user: requestUser,
},
input: {
bookingId: booking.id,
confirmed: false,
recurringEventId: booking.recurringEventId,
reason,
emailsEnabled,
},
});
return this.getBooking(bookingUid);
}
}
@@ -18,7 +18,7 @@ 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 { EventTypeMetaDataSchema } from "@calcom/platform-libraries";
import {
CancelBookingInput,
CancelBookingInput_2024_08_13,
@@ -32,18 +32,10 @@ import {
RescheduleBookingInput_2024_08_13,
RescheduleSeatedBookingInput_2024_08_13,
} from "@calcom/platform-types";
import { EventType, PlatformOAuthClient } from "@calcom/prisma/client";
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;
@@ -90,22 +82,53 @@ export class InputBookingsService_2024_08_13 {
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 oAuthClientParams = await this.getOAuthClientParams(body.eventTypeId);
const newRequest = { ...request };
const userId = (await this.createBookingRequestOwnerId(request)) ?? undefined;
const oAuthParams = oAuthClientId
? await this.createBookingRequestOAuthClientParams(oAuthClientId)
: DEFAULT_PLATFORM_PARAMS;
const location = request.body.location || request.body.meetingUrl;
Object.assign(newRequest, { userId, ...oAuthParams, platformBookingLocation: location });
this.logger.log(`createBookingRequest_2024_04_15`, {
requestId: request.get("X-Request-Id"),
ownerId: userId,
location,
oAuthClientParams,
});
newRequest.body = { ...bodyTransformed, noEmail: !oAuthParams.arePlatformEmailsEnabled };
if (oAuthClientParams) {
Object.assign(newRequest, { userId, ...oAuthClientParams, platformBookingLocation: location });
newRequest.body = { ...bodyTransformed, noEmail: !oAuthClientParams.arePlatformEmailsEnabled };
} else {
Object.assign(newRequest, { userId, platformBookingLocation: location });
newRequest.body = { ...bodyTransformed, noEmail: false };
}
return newRequest as unknown as BookingRequest;
}
async getOAuthClientParams(eventTypeId: number) {
const eventType = await this.eventTypesRepository.getEventTypeById(eventTypeId);
let oAuthClient: PlatformOAuthClient | null = null;
if (eventType?.userId) {
oAuthClient = await this.oAuthClientRepository.getByUserId(eventType.userId);
} else if (eventType?.teamId) {
oAuthClient = await this.oAuthClientRepository.getByTeamId(eventType.teamId);
}
if (oAuthClient) {
return {
platformClientId: oAuthClient.id,
platformCancelUrl: oAuthClient.bookingCancelRedirectUri,
platformRescheduleUrl: oAuthClient.bookingRescheduleRedirectUri,
platformBookingUrl: oAuthClient.bookingRedirectUri,
arePlatformEmailsEnabled: oAuthClient.areEmailsEnabled,
};
}
return undefined;
}
async transformInputCreateBooking(inputBooking: CreateBookingInput_2024_08_13) {
const eventType = await this.eventTypesRepository.getEventTypeByIdWithOwnerAndTeam(
inputBooking.eventTypeId
@@ -115,10 +138,13 @@ export class InputBookingsService_2024_08_13 {
throw new NotFoundException(`Event type with id=${inputBooking.eventTypeId} not found`);
}
this.validateBookingLengthInMinutes(inputBooking, eventType);
const lengthInMinutes = inputBooking.lengthInMinutes ?? eventType.length;
const startTime = DateTime.fromISO(inputBooking.start, { zone: "utc" }).setZone(
inputBooking.attendee.timeZone
);
const endTime = startTime.plus({ minutes: eventType.length });
const endTime = startTime.plus({ minutes: lengthInMinutes });
return {
start: startTime.toISO(),
@@ -126,9 +152,7 @@ export class InputBookingsService_2024_08_13 {
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: {},
metadata: inputBooking.metadata || {},
hasHashedBookingLink: false,
guests: inputBooking.guests,
// note(Lauris): responses with name and email are required by the handleNewBooking
@@ -142,27 +166,48 @@ export class InputBookingsService_2024_08_13 {
};
}
validateBookingLengthInMinutes(inputBooking: CreateBookingInput_2024_08_13, eventType: EventType) {
const eventTypeMetadata = EventTypeMetaDataSchema.parse(eventType.metadata);
if (inputBooking.lengthInMinutes && !eventTypeMetadata?.multipleDuration) {
throw new BadRequestException(
"Can't specify 'lengthInMinutes' because event type does not have multiple possible lengths. Please, remove the 'lengthInMinutes' field from the request."
);
}
if (
inputBooking.lengthInMinutes &&
!eventTypeMetadata?.multipleDuration?.includes(inputBooking.lengthInMinutes)
) {
throw new BadRequestException(
`Provided 'lengthInMinutes' is not one of the possible lengths for the event type. The possible lengths are: ${eventTypeMetadata?.multipleDuration?.join(
", "
)}`
);
}
}
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 oAuthClientParams = await this.getOAuthClientParams(body.eventTypeId);
const newRequest = { ...request };
const userId = (await this.createBookingRequestOwnerId(request)) ?? undefined;
const oAuthParams = oAuthClientId
? await this.createBookingRequestOAuthClientParams(oAuthClientId)
: DEFAULT_PLATFORM_PARAMS;
const location = request.body.location || request.body.meetingUrl;
Object.assign(newRequest, {
userId,
...oAuthParams,
platformBookingLocation: location,
noEmail: !oAuthParams.arePlatformEmailsEnabled,
});
if (oAuthClientParams) {
Object.assign(newRequest, {
userId,
...oAuthClientParams,
platformBookingLocation: location,
noEmail: !oAuthClientParams.arePlatformEmailsEnabled,
});
} else {
Object.assign(newRequest, { userId, platformBookingLocation: location });
}
newRequest.body = bodyTransformed.map((event) => ({
...event,
@@ -211,9 +256,7 @@ export class InputBookingsService_2024_08_13 {
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: {},
metadata: inputBooking.metadata || {},
hasHashedBookingLink: false,
guests: inputBooking.guests,
// note(Lauris): responses with name and email are required by the handleNewBooking
@@ -253,18 +296,20 @@ export class InputBookingsService_2024_08_13 {
const bodyTransformed = this.isRescheduleSeatedBody(body)
? await this.transformInputRescheduleSeatedBooking(bookingUid, body)
: await this.transformInputRescheduleBooking(bookingUid, body);
const oAuthClientId = request.get(X_CAL_CLIENT_ID);
const oAuthClientParams = await this.getOAuthClientParams(bodyTransformed.eventTypeId);
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 };
if (oAuthClientParams) {
Object.assign(newRequest, { userId, ...oAuthClientParams, platformBookingLocation: location });
newRequest.body = { ...bodyTransformed, noEmail: !oAuthClientParams.arePlatformEmailsEnabled };
} else {
Object.assign(newRequest, { userId, platformBookingLocation: location });
newRequest.body = { ...bodyTransformed, noEmail: false };
}
return newRequest as unknown as BookingRequest;
}
@@ -313,9 +358,7 @@ export class InputBookingsService_2024_08_13 {
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: {},
metadata: seat.metadata || {},
hasHashedBookingLink: false,
guests: [],
responses: { ...bookingResponses },
@@ -354,9 +397,7 @@ export class InputBookingsService_2024_08_13 {
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: {},
metadata: booking.metadata || {},
hasHashedBookingLink: false,
guests: bookingResponses.guests,
responses: { ...bookingResponses, rescheduledReason: inputBooking.reschedulingReason },
@@ -391,24 +432,6 @@ export class InputBookingsService_2024_08_13 {
}
}
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,
@@ -441,17 +464,26 @@ export class InputBookingsService_2024_08_13 {
const bodyTransformed = this.isCancelSeatedBody(body)
? await this.transformInputCancelSeatedBooking(bookingUid, body)
: await this.transformInputCancelBooking(bookingUid, body);
const oAuthClientId = request.get(X_CAL_CLIENT_ID);
const booking = await this.bookingsRepository.getByUid(bodyTransformed.uid);
if (!booking) {
throw new NotFoundException(`Booking with uid=${bookingUid} not found`);
}
const oAuthClientParams = booking.eventTypeId
? await this.getOAuthClientParams(booking.eventTypeId)
: undefined;
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 };
if (oAuthClientParams) {
Object.assign(newRequest, { userId, ...oAuthClientParams });
newRequest.body = { ...bodyTransformed, noEmail: !oAuthClientParams.arePlatformEmailsEnabled };
} else {
Object.assign(newRequest, { userId });
newRequest.body = { ...bodyTransformed, noEmail: false };
}
return newRequest as unknown as BookingRequest;
}
@@ -461,23 +493,38 @@ export class InputBookingsService_2024_08_13 {
}
async transformInputCancelBooking(bookingUid: string, inputBooking: CancelBookingInput_2024_08_13) {
let allRemainingBookings = false;
let uid = bookingUid;
const recurringBooking = await this.bookingsRepository.getRecurringByUidWithAttendeesAndUserAndEvent(
bookingUid
);
const recurringBooking = await this.bookingsRepository.getRecurringByUid(bookingUid);
// note(Lauris): isRecurring means that recurringEventId was passed as uid. isRecurring does not refer to the uid of 1 individual booking within a recurring booking consisting of many bookings.
// That is what recurringEventId refers to.
const isRecurringUid = !!recurringBooking.length;
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;
if (isRecurringUid && inputBooking.cancelSubsequentBookings) {
throw new BadRequestException(
"Cannot cancel subsequent bookings for recurring event - you have to provide uid of one of the individual bookings of a recurring booking."
);
}
if (isRecurringUid) {
return {
// note(Lauris): set uid as one of the oldest individual recurring ids
uid: recurringBooking[0].uid,
cancellationReason: inputBooking.cancellationReason,
allRemainingBookings: true,
};
}
if (inputBooking.cancelSubsequentBookings) {
return {
uid: bookingUid,
cancellationReason: inputBooking.cancellationReason,
cancelSubsequentBookings: true,
};
}
return {
uid,
uid: bookingUid,
cancellationReason: inputBooking.cancellationReason,
allRemainingBookings,
allRemainingBookings: false,
};
}
@@ -485,23 +532,12 @@ export class InputBookingsService_2024_08_13 {
bookingUid: string,
inputBooking: CancelSeatedBookingInput_2024_08_13
) {
let allRemainingBookings = false;
let uid = bookingUid;
const recurringBooking = await this.bookingsRepository.getRecurringByUidWithAttendeesAndUserAndEvent(
bookingUid
);
if (recurringBooking.length) {
// note(Lauirs): this means that bookingUid is equal to recurringEventId on individual bookings of recurring one aka main recurring event
allRemainingBookings = true;
// note(Lauirs): we need to set uid as one of the individual recurring ids, not the main recurring event id
uid = recurringBooking[0].uid;
}
// note(Lauris): for recurring seated booking it is not possible to cancel all remaining bookings
// for an individual person, so api users need to call booking by booking using uid + seatUid to cancel it.
return {
uid,
uid: bookingUid,
cancellationReason: "",
allRemainingBookings,
allRemainingBookings: false,
seatReferenceUid: inputBooking.seatUid,
};
}
@@ -4,12 +4,14 @@ import { plainToClass } from "class-transformer";
import { DateTime } from "luxon";
import { z } from "zod";
import { bookingMetadataSchema } from "@calcom/platform-libraries";
import {
BookingOutput_2024_08_13,
CreateRecurringSeatedBookingOutput_2024_08_13,
CreateSeatedBookingOutput_2024_08_13,
GetRecurringSeatedBookingOutput_2024_08_13,
GetSeatedBookingOutput_2024_08_13,
ReassignBookingOutput_2024_08_13,
RecurringBookingOutput_2024_08_13,
SeatedAttendee,
} from "@calcom/platform-types";
@@ -48,7 +50,15 @@ type DatabaseBooking = Booking & {
noShow: boolean | null;
bookingSeat?: BookingSeat | null;
}[];
} & { user: { id: number; name: string | null; email: string } | null };
user: { id: number; name: string | null; email: string } | null;
createdAt: Date;
};
type BookingWithUser = Booking & { user: { id: number; name: string | null; email: string } | null };
type DatabaseMetadata = z.infer<typeof bookingMetadataSchema>;
const seatedBookingMetadataSchema = z.object({}).catchall(z.string());
@Injectable()
export class OutputBookingsService_2024_08_13 {
@@ -58,8 +68,9 @@ export class OutputBookingsService_2024_08_13 {
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 metadata = bookingMetadataSchema.parse(databaseBooking.metadata);
const location = metadata?.videoCallUrl || databaseBooking.location;
const booking = {
id: databaseBooking.id,
@@ -85,18 +96,28 @@ export class OutputBookingsService_2024_08_13 {
absent: !!attendee.noShow,
})),
guests: bookingResponses.guests,
location: databaseBooking.location,
location,
// note(Lauris): meetingUrl is deprecated
meetingUrl: databaseBooking.location,
meetingUrl: location,
absentHost: !!databaseBooking.noShowHost,
createdAt: databaseBooking.createdAt,
};
const bookingTransformed = plainToClass(BookingOutput_2024_08_13, booking, { strategy: "excludeAll" });
// note(Lauris): I don't know why plainToClass erases bookings responses so attaching manually
// note(Lauris): I don't know why plainToClass erases bookings responses and metadata so attaching manually
bookingTransformed.bookingFieldsResponses = bookingResponses;
bookingTransformed.metadata = this.getUserDefinedMetadata(metadata);
return bookingTransformed;
}
getUserDefinedMetadata(databaseMetadata: DatabaseMetadata) {
if (databaseMetadata === null) return {};
const { videoCallUrl, ...userDefinedMetadata } = databaseMetadata;
return userDefinedMetadata;
}
async getOutputRecurringBookings(bookingsIds: number[]) {
const transformed = [];
@@ -116,8 +137,9 @@ export class OutputBookingsService_2024_08_13 {
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 metadata = bookingMetadataSchema.parse(databaseBooking.metadata);
const location = metadata?.videoCallUrl || databaseBooking.location;
const booking = {
id: databaseBooking.id,
@@ -143,15 +165,22 @@ export class OutputBookingsService_2024_08_13 {
absent: !!attendee.noShow,
})),
guests: bookingResponses.guests,
location: databaseBooking.location,
location,
// note(Lauris): meetingUrl is deprecated
meetingUrl: databaseBooking.location,
meetingUrl: location,
recurringBookingUid: databaseBooking.recurringEventId,
absentHost: !!databaseBooking.noShowHost,
bookingFieldsResponses: databaseBooking.responses,
createdAt: databaseBooking.createdAt,
};
return plainToClass(RecurringBookingOutput_2024_08_13, booking, { strategy: "excludeAll" });
const bookingTransformed = plainToClass(RecurringBookingOutput_2024_08_13, booking, {
strategy: "excludeAll",
});
// note(Lauris): I don't know why plainToClass erases bookings responses and metadata so attaching manually
bookingTransformed.bookingFieldsResponses = bookingResponses;
bookingTransformed.metadata = this.getUserDefinedMetadata(metadata);
return bookingTransformed;
}
getOutputCreateSeatedBooking(
@@ -166,6 +195,8 @@ export class OutputBookingsService_2024_08_13 {
const dateStart = DateTime.fromISO(databaseBooking.startTime.toISOString());
const dateEnd = DateTime.fromISO(databaseBooking.endTime.toISOString());
const duration = dateEnd.diff(dateStart, "minutes").minutes;
const metadata = bookingMetadataSchema.parse(databaseBooking.metadata);
const location = metadata?.videoCallUrl || databaseBooking.location;
const booking = {
id: databaseBooking.id,
@@ -182,10 +213,11 @@ export class OutputBookingsService_2024_08_13 {
// note(Lauris): eventTypeId is deprecated
eventTypeId: databaseBooking.eventTypeId,
attendees: [],
location: databaseBooking.location,
location,
// note(Lauris): meetingUrl is deprecated
meetingUrl: databaseBooking.location,
meetingUrl: location,
absentHost: !!databaseBooking.noShowHost,
createdAt: databaseBooking.createdAt,
};
const parsed = plainToClass(GetSeatedBookingOutput_2024_08_13, booking, { strategy: "excludeAll" });
@@ -205,6 +237,7 @@ export class OutputBookingsService_2024_08_13 {
};
const attendeeParsed = plainToClass(SeatedAttendee, attendeeData, { strategy: "excludeAll" });
attendeeParsed.bookingFieldsResponses = responses || {};
attendeeParsed.metadata = seatedBookingMetadataSchema.parse(attendee.bookingSeat?.metadata);
// note(Lauris): as of now email is not returned for privacy
delete attendeeParsed.bookingFieldsResponses.email;
@@ -230,14 +263,14 @@ export class OutputBookingsService_2024_08_13 {
return transformed.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime());
}
async getOutputCreateRecurringSeatedBookings(bookings: { id: number; seatUid: string }[]) {
async getOutputCreateRecurringSeatedBookings(bookings: { uid: string; seatUid: string }[]) {
const transformed = [];
for (const booking of bookings) {
const databaseBooking =
await this.bookingsRepository.getByIdWithAttendeesWithBookingSeatAndUserAndEvent(booking.id);
await this.bookingsRepository.getByUidWithAttendeesWithBookingSeatAndUserAndEvent(booking.uid);
if (!databaseBooking) {
throw new Error(`Booking with id=${booking.id} was not found in the database`);
throw new Error(`Booking with uid=${booking.uid} was not found in the database`);
}
transformed.push(this.getOutputCreateRecurringSeatedBooking(databaseBooking, booking.seatUid));
}
@@ -257,6 +290,8 @@ export class OutputBookingsService_2024_08_13 {
const dateStart = DateTime.fromISO(databaseBooking.startTime.toISOString());
const dateEnd = DateTime.fromISO(databaseBooking.endTime.toISOString());
const duration = dateEnd.diff(dateStart, "minutes").minutes;
const metadata = bookingMetadataSchema.parse(databaseBooking.metadata);
const location = metadata?.videoCallUrl || databaseBooking.location;
const booking = {
id: databaseBooking.id,
@@ -274,11 +309,12 @@ export class OutputBookingsService_2024_08_13 {
// note(Lauris): eventTypeId is deprecated
eventTypeId: databaseBooking.eventTypeId,
attendees: [],
location: databaseBooking.location,
location,
// note(Lauris): meetingUrl is deprecated
meetingUrl: databaseBooking.location,
meetingUrl: location,
recurringBookingUid: databaseBooking.recurringEventId,
absentHost: !!databaseBooking.noShowHost,
createdAt: databaseBooking.createdAt,
};
const parsed = plainToClass(GetRecurringSeatedBookingOutput_2024_08_13, booking, {
@@ -300,12 +336,25 @@ export class OutputBookingsService_2024_08_13 {
};
const attendeeParsed = plainToClass(SeatedAttendee, attendeeData, { strategy: "excludeAll" });
attendeeParsed.bookingFieldsResponses = responses || {};
attendeeParsed.metadata = seatedBookingMetadataSchema.parse(attendee.bookingSeat?.metadata);
// note(Lauris): as of now email is not returned for privacy
delete attendeeParsed.bookingFieldsResponses.email;
return attendeeParsed;
});
return parsed;
}
getOutputReassignedBooking(
databaseBooking: Pick<BookingWithUser, "uid" | "user">
): ReassignBookingOutput_2024_08_13 {
return {
bookingUid: databaseBooking.uid,
reassignedTo: {
id: databaseBooking?.user?.id || 0,
name: databaseBooking?.user?.name || "unknown",
email: databaseBooking?.user?.email || "unknown",
},
};
}
}
@@ -212,6 +212,7 @@ describe("Event types Endpoints", () => {
slug: "coding-class",
description: "Let's learn how to code like a pro.",
lengthInMinutes: 60,
lengthInMinutesOptions: [30, 60, 90],
locations: [
{
type: "integration",
@@ -300,6 +301,7 @@ describe("Event types Endpoints", () => {
expect(createdEventType.title).toEqual(body.title);
expect(createdEventType.description).toEqual(body.description);
expect(createdEventType.lengthInMinutes).toEqual(body.lengthInMinutes);
expect(createdEventType.lengthInMinutesOptions).toEqual(body.lengthInMinutesOptions);
expect(createdEventType.locations).toEqual(body.locations);
expect(createdEventType.ownerId).toEqual(user.id);
expect(createdEventType.scheduleId).toEqual(firstSchedule.id);
@@ -360,6 +362,7 @@ describe("Event types Endpoints", () => {
expect(fetchedEventType?.title).toEqual(eventType.title);
expect(fetchedEventType?.description).toEqual(eventType.description);
expect(fetchedEventType?.lengthInMinutes).toEqual(eventType.lengthInMinutes);
expect(fetchedEventType?.lengthInMinutesOptions).toEqual(eventType.lengthInMinutesOptions);
expect(fetchedEventType?.locations).toEqual(eventType.locations);
expect(fetchedEventType?.bookingFields).toEqual(eventType.bookingFields);
expect(fetchedEventType?.ownerId).toEqual(user.id);
@@ -665,6 +668,7 @@ describe("Event types Endpoints", () => {
const body: UpdateEventTypeInput_2024_06_14 = {
title: newTitle,
scheduleId: secondSchedule.id,
lengthInMinutesOptions: [15, 30],
bookingFields: [
nameBookingField,
{
@@ -733,6 +737,7 @@ describe("Event types Endpoints", () => {
expect(updatedEventType.id).toEqual(eventType.id);
expect(updatedEventType.title).toEqual(newTitle);
expect(updatedEventType.lengthInMinutesOptions).toEqual(body.lengthInMinutesOptions);
expect(updatedEventType.description).toEqual(eventType.description);
expect(updatedEventType.lengthInMinutes).toEqual(eventType.lengthInMinutes);
expect(updatedEventType.locations).toEqual(eventType.locations);
@@ -772,6 +777,7 @@ describe("Event types Endpoints", () => {
eventType.title = newTitle;
eventType.scheduleId = secondSchedule.id;
eventType.lengthInMinutesOptions = updatedEventType.lengthInMinutesOptions;
eventType.bookingLimitsCount = updatedEventType.bookingLimitsCount;
eventType.onlyShowFirstAvailableSlot = updatedEventType.onlyShowFirstAvailableSlot;
eventType.bookingLimitsDuration = updatedEventType.bookingLimitsDuration;
@@ -819,6 +825,7 @@ describe("Event types Endpoints", () => {
expect(fetchedEventType.title).toEqual(eventType.title);
expect(fetchedEventType.description).toEqual(eventType.description);
expect(fetchedEventType.lengthInMinutes).toEqual(eventType.lengthInMinutes);
expect(fetchedEventType.lengthInMinutesOptions).toEqual(eventType.lengthInMinutesOptions);
expect(fetchedEventType.locations).toEqual(eventType.locations);
expect(fetchedEventType.bookingFields).toEqual(eventType.bookingFields);
expect(fetchedEventType.ownerId).toEqual(user.id);
@@ -857,6 +864,7 @@ describe("Event types Endpoints", () => {
expect(fetchedEventType?.title).toEqual(eventType.title);
expect(fetchedEventType?.description).toEqual(eventType.description);
expect(fetchedEventType?.lengthInMinutes).toEqual(eventType.lengthInMinutes);
expect(fetchedEventType?.lengthInMinutesOptions).toEqual(eventType.lengthInMinutesOptions);
expect(fetchedEventType?.locations).toEqual(eventType.locations);
expect(fetchedEventType?.bookingFields).toEqual(eventType.bookingFields);
expect(fetchedEventType?.ownerId).toEqual(user.id);
@@ -104,6 +104,7 @@ export class InputEventTypesService_2024_06_14 {
const {
lengthInMinutes,
lengthInMinutesOptions,
locations,
bookingFields,
bookingLimitsCount,
@@ -135,6 +136,7 @@ export class InputEventTypesService_2024_06_14 {
bookerLayouts: this.transformInputBookerLayouts(bookerLayouts),
requiresConfirmationThreshold:
confirmationPolicyTransformed?.requiresConfirmationThreshold ?? undefined,
multipleDuration: lengthInMinutesOptions,
},
requiresConfirmation: confirmationPolicyTransformed?.requiresConfirmation ?? undefined,
requiresConfirmationWillBlockSlot:
@@ -152,6 +154,7 @@ export class InputEventTypesService_2024_06_14 {
async transformInputUpdateEventType(inputEventType: UpdateEventTypeInput_2024_06_14, eventTypeId: number) {
const {
lengthInMinutes,
lengthInMinutesOptions,
locations,
bookingFields,
bookingLimitsCount,
@@ -191,6 +194,7 @@ export class InputEventTypesService_2024_06_14 {
bookerLayouts: this.transformInputBookerLayouts(bookerLayouts),
requiresConfirmationThreshold:
confirmationPolicyTransformed?.requiresConfirmationThreshold ?? undefined,
multipleDuration: lengthInMinutesOptions,
},
recurringEvent: recurrence ? this.transformInputRecurrignEvent(recurrence) : undefined,
requiresConfirmation: confirmationPolicyTransformed?.requiresConfirmation ?? undefined,
@@ -153,6 +153,7 @@ export class OutputEventTypesService_2024_06_14 {
id,
ownerId,
lengthInMinutes: length,
lengthInMinutesOptions: metadata.multipleDuration,
title,
slug,
description: description || "",
+4
View File
@@ -10,6 +10,8 @@ export type Environment = {
DATABASE_URL: string;
JWT_SECRET: string;
SENTRY_DSN: string;
SENTRY_TRACES_SAMPLE_RATE?: number;
SENTRY_PROFILES_SAMPLE_RATE?: number;
LOG_LEVEL: keyof typeof logLevels;
REDIS_URL: string;
STRIPE_API_KEY: string;
@@ -26,6 +28,8 @@ export type Environment = {
RATE_LIMIT_DEFAULT_LIMIT_ACCESS_TOKEN: number;
RATE_LIMIT_DEFAULT_LIMIT: number;
RATE_LIMIT_DEFAULT_BLOCK_DURATION_MS: number;
AXIOM_DATASET: string;
AXIOM_TOKEN: string;
};
export const getEnv = <K extends keyof Environment>(key: K, fallback?: Environment[K]): Environment[K] => {
+7 -4
View File
@@ -1,12 +1,15 @@
import { getEnv } from "@/env";
import * as Sentry from "@sentry/node";
import * as Sentry from "@sentry/nestjs";
import { nodeProfilingIntegration } from "@sentry/profiling-node";
if (process.env.SENTRY_DSN) {
// Ensure to call this before requiring any other modules!
Sentry.init({
dsn: getEnv("SENTRY_DSN"),
// Add Performance Monitoring by setting tracesSampleRate
// We recommend adjusting this value in production
// todo: Evaluate? tracesSampleRate: 1.0
integrations: [nodeProfilingIntegration()],
// Performance Monitoring
tracesSampleRate: getEnv("SENTRY_TRACES_SAMPLE_RATE") ?? 1.0, // Capture 100% of the transactions
// Set sampling rate for profiling - this is relative to tracesSampleRate
profilesSampleRate: getEnv("SENTRY_PROFILES_SAMPLE_RATE") ?? 1.0,
});
}
+20 -20
View File
@@ -1,19 +1,14 @@
import { WinstonTransport as AxiomTransport } from "@axiomhq/winston";
import type { LoggerOptions } from "winston";
import { format, transports } from "winston";
import { format, transports as Transports, config } from "winston";
import type Transport from "winston-transport";
const formattedTimestamp = format.timestamp({
format: "YYYY-MM-DD HH:mm:ss.SSS",
});
const colorizer = format.colorize({
colors: {
fatal: "red",
error: "red",
warn: "yellow",
info: "blue",
debug: "white",
trace: "grey",
},
colors: config.npm.colors,
});
const WINSTON_DEV_FORMAT = format.combine(
@@ -24,25 +19,30 @@ const WINSTON_DEV_FORMAT = format.combine(
);
const WINSTON_PROD_FORMAT = format.combine(format.errors({ stack: true }), formattedTimestamp, format.json());
export const logLevels = {
fatal: 0,
error: 1,
warn: 2,
info: 3,
debug: 4,
trace: 5,
} as const;
export const logLevels = config.npm.levels;
export const loggerConfig = (): LoggerOptions => {
const isProduction = process.env.NODE_ENV === "production";
const transports: Transport[] = [];
transports.push(new Transports.Console());
if (!!process.env.AXIOM_TOKEN && !!process.env.AXIOM_DATASET) {
transports.push(
new AxiomTransport({
token: process.env.AXIOM_TOKEN,
dataset: process.env.AXIOM_DATASET,
})
);
}
return {
levels: logLevels,
level: process.env.LOG_LEVEL ?? "info",
format: isProduction ? WINSTON_PROD_FORMAT : WINSTON_DEV_FORMAT,
transports: [new transports.Console()],
exceptionHandlers: [new transports.Console()],
rejectionHandlers: [new transports.Console()],
transports,
exceptionHandlers: transports,
rejectionHandlers: transports,
defaultMeta: {
service: "cal-platform-api",
},
+8 -8
View File
@@ -56,7 +56,7 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
const request = context.switchToHttp().getRequest<Request>();
const response = context.switchToHttp().getResponse<Response>();
const tracker = await this.getTracker(request);
this.logger.log(
this.logger.verbose(
`Tracker "${tracker}" generated based on: Bearer token "${request.get(
"Authorization"
)}", OAuth client ID "${request.get(X_CAL_CLIENT_ID)}" and IP "${request.ip}"`
@@ -89,7 +89,7 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
private async handleNonApiKeyRequest(tracker: string, response: Response): Promise<boolean> {
const rateLimit = this.getDefaultRateLimit(tracker);
this.logger.log(`Tracker "${tracker}" uses default rate limits because it is not tracking api key:
this.logger.verbose(`Tracker "${tracker}" uses default rate limits because it is not tracking api key:
${JSON.stringify(rateLimit, null, 2)}
`);
@@ -135,7 +135,7 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
const cachedRateLimits = await this.storageService.redis.get(cacheKey);
if (cachedRateLimits) {
this.logger.log(`Tracker "${tracker}" rate limits retrieved from redis cache:
this.logger.verbose(`Tracker "${tracker}" rate limits retrieved from redis cache:
${cachedRateLimits}
`);
return rateLimitsSchema.parse(JSON.parse(cachedRateLimits));
@@ -158,13 +158,13 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
});
if (rateLimits) {
this.logger.log(`Tracker "${tracker}" rate limits retrieved from database:
this.logger.verbose(`Tracker "${tracker}" rate limits retrieved from database:
${JSON.stringify(rateLimits, null, 2)}`);
}
if (!rateLimits || rateLimits.length === 0) {
rateLimits = [this.getDefaultRateLimit(tracker)];
this.logger.log(`Tracker "${tracker}" rate limits not found in database. Using default rate limits:
this.logger.verbose(`Tracker "${tracker}" rate limits not found in database. Using default rate limits:
${JSON.stringify(rateLimits, null, 2)}`);
}
@@ -194,10 +194,10 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
);
response.setHeader(`X-RateLimit-Reset-${nameFirstUpper}`, timeToBlockExpire || timeToExpire);
this.logger.log(
this.logger.verbose(
`Tracker "${tracker}" rate limit "${name}" incremented. isBlocked ${isBlocked}, totalHits ${totalHits}, timeToExpire ${timeToExpire}, timeToBlockExpire ${timeToBlockExpire}`
);
this.logger.log(
this.logger.verbose(
`Tracker "${tracker}" rate limit "${name}" response headers:
X-RateLimit-Limit-${nameFirstUpper}: ${limit},
X-RateLimit-Remaining-${nameFirstUpper}: ${timeToBlockExpire ? 0 : Math.max(0, limit - totalHits)},
@@ -227,7 +227,7 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
return `ip_${request.ip}`;
}
this.logger.log(`no tracker found: ${request.url}`);
this.logger.verbose(`no tracker found: ${request.url}`);
return "unknown";
}
}
+12 -4
View File
@@ -1,4 +1,5 @@
import type { AppConfig } from "@/config/type";
import { getEnv } from "@/env";
import { Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { NestFactory } from "@nestjs/core";
@@ -107,13 +108,20 @@ async function generateSwagger(app: NestExpressApplication<Server>) {
const document = SwaggerModule.createDocument(app, config);
document.paths = groupAndSortPathsByFirstTag(document.paths);
const outputFile = "./swagger/documentation.json";
const swaggerOutputFile = "./swagger/documentation.json";
const docsOutputFile = "../../../docs/api-reference/v2/openapi.json";
const stringifiedContents = JSON.stringify(document, null, 2);
if (fs.existsSync(outputFile)) {
fs.unlinkSync(outputFile);
if (fs.existsSync(swaggerOutputFile)) {
fs.unlinkSync(swaggerOutputFile);
}
fs.writeFileSync(outputFile, JSON.stringify(document, null, 2), { encoding: "utf8" });
fs.writeFileSync(swaggerOutputFile, stringifiedContents, { encoding: "utf8" });
if (fs.existsSync(docsOutputFile) && getEnv("NODE_ENV") === "development") {
fs.unlinkSync(docsOutputFile);
fs.writeFileSync(docsOutputFile, stringifiedContents, { encoding: "utf8" });
}
if (!process.env.DOCS_URL) {
SwaggerModule.setup("docs", app, document, {
@@ -21,4 +21,15 @@ export class AppsRepository {
},
});
}
async createTeamAppCredential(type: string, key: Prisma.InputJsonValue, teamId: number, appId: string) {
return this.dbWrite.prisma.credential.create({
data: {
type: type,
key: key,
teamId: teamId,
appId: appId,
},
});
}
}
@@ -1,5 +1,6 @@
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository";
import { AtomsRepository } from "@/modules/atoms/atoms.repository";
import { AtomsController } from "@/modules/atoms/controllers/atoms.controller";
import { EventTypesAtomService } from "@/modules/atoms/services/event-types-atom.service";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
@@ -17,6 +18,7 @@ import { Module } from "@nestjs/common";
MembershipsRepository,
CredentialsRepository,
UsersRepository,
AtomsRepository,
UsersService,
SchedulesRepository_2024_06_11,
],
@@ -0,0 +1,17 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
import { paymentDataSelect } from "@calcom/platform-libraries";
@Injectable()
export class AtomsRepository {
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
async getRawPayment(uid: string) {
return await this.dbRead.prisma.payment.findFirst({
where: { uid },
select: paymentDataSelect,
});
}
}
@@ -72,6 +72,18 @@ export class AtomsController {
};
}
@Get("payment/:uid")
@Version(VERSION_NEUTRAL)
@UseGuards(ApiAuthGuard)
async getUserPaymentInfoById(@Param("uid") uid: string): Promise<ApiResponse<unknown>> {
const data = await this.eventTypesService.getUserPaymentInfo(uid);
return {
status: SUCCESS_STATUS,
data,
};
}
@Patch("event-types/:eventTypeId")
@Version(VERSION_NEUTRAL)
@UseGuards(ApiAuthGuard)
@@ -1,4 +1,5 @@
import { EventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/event-types.service";
import { AtomsRepository } from "@/modules/atoms/atoms.repository";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { OrganizationsTeamsRepository } from "@/modules/organizations/repositories/organizations-teams.repository";
@@ -17,6 +18,7 @@ import {
getEnabledAppsFromCredentials,
getAppFromSlug,
MembershipRole,
EventTypeMetaDataSchema,
} from "@calcom/platform-libraries";
import type {
App,
@@ -27,6 +29,7 @@ import type {
TDependencyData,
CredentialPayload,
} from "@calcom/platform-libraries";
import { getClientSecretFromPayment } from "@calcom/platform-libraries";
import { PrismaClient } from "@calcom/prisma/client";
type EnabledAppType = App & {
@@ -40,6 +43,7 @@ export class EventTypesAtomService {
constructor(
private readonly membershipsRepository: MembershipsRepository,
private readonly credentialsRepository: CredentialsRepository,
private readonly atomsRepository: AtomsRepository,
private readonly organizationsTeamsRepository: OrganizationsTeamsRepository,
private readonly usersService: UsersService,
private readonly dbWrite: PrismaWriteService,
@@ -274,4 +278,43 @@ export class EventTypesAtomService {
);
return apps[0];
}
async getUserPaymentInfo(uid: string) {
const rawPayment = await this.atomsRepository.getRawPayment(uid);
if (!rawPayment) throw new NotFoundException(`Payment with uid ${uid} not found`);
const { data, booking: _booking, ...restPayment } = rawPayment;
const payment = {
...restPayment,
data: data as Record<string, unknown>,
};
if (!_booking) throw new NotFoundException(`Booking with uid ${uid} not found`);
const { startTime, endTime, eventType, ...restBooking } = _booking;
const booking = {
...restBooking,
startTime: startTime.toString(),
endTime: endTime.toString(),
};
if (!eventType) throw new NotFoundException(`Event type with uid ${uid} not found`);
if (eventType.users.length === 0 && !!!eventType.team)
throw new NotFoundException(`No users found or no team present for event type with uid ${uid}`);
const [user] = eventType?.users.length
? eventType.users
: [{ name: null, theme: null, hideBranding: null, username: null }];
const profile = {
name: eventType.team?.name || user?.name || null,
theme: (!eventType.team?.name && user?.theme) || null,
hideBranding: eventType.team?.hideBranding || user?.hideBranding || null,
};
return {
user,
eventType: {
...eventType,
metadata: EventTypeMetaDataSchema.parse(eventType.metadata),
},
booking,
payment,
clientSecret: getClientSecretFromPayment(payment),
profile,
};
}
}
@@ -46,9 +46,9 @@ export class BillingProcessor {
return;
}
const stripeSubscription = await this.stripeService.stripe.subscriptions.retrieve(
billingSubscription.subscriptionId
);
const stripeSubscription = await this.stripeService
.getStripe()
.subscriptions.retrieve(billingSubscription.subscriptionId);
if (!stripeSubscription?.id) {
this.logger.error(`Failed to retrieve stripe subscription (${billingSubscription.subscriptionId})`, {
teamId,
@@ -69,7 +69,7 @@ export class BillingProcessor {
return;
}
await this.stripeService.stripe.subscriptionItems.createUsageRecord(meteredItem.id, {
await this.stripeService.getStripe().subscriptionItems.createUsageRecord(meteredItem.id, {
action: "increment",
quantity: 1,
timestamp: "now",
@@ -1,10 +1,11 @@
import { PlatformPlan } from "@/modules/billing/types";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
import { Injectable, Logger } from "@nestjs/common";
@Injectable()
export class BillingRepository {
private readonly logger = new Logger("BillingRepository");
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
getBillingForTeam = (teamId: number) =>
@@ -30,6 +31,35 @@ export class BillingRepository {
billingCycleEnd: billingEnd,
subscriptionId,
plan: plan.toString(),
overdue: false,
},
});
}
async updateBillingOverdue(subId: string, cusId: string, overdue: boolean) {
try {
return this.dbWrite.prisma.platformBilling.update({
where: {
subscriptionId: subId,
customerId: cusId,
},
data: {
overdue,
},
});
} catch (err) {
this.logger.error("Could not update billing overdue", {
subId,
cusId,
err,
});
}
}
async deleteBilling(id: number) {
return this.dbWrite.prisma.platformBilling.delete({
where: {
id,
},
});
}
@@ -0,0 +1,211 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { StripeService } from "@/modules/stripe/stripe.service";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
import { UserWithProfile } from "@/modules/users/users.repository";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import Stripe from "stripe";
import * as request from "supertest";
import { PlatformBillingRepositoryFixture } from "test/fixtures/repository/billing.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 { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { withApiAuth } from "test/utils/withApiAuth";
import { Team, PlatformOAuthClient, PlatformBilling } from "@calcom/prisma/client";
describe("Platform Billing Controller (e2e)", () => {
let app: INestApplication;
const userEmail = "platform-billing-controller-e2e@api.com";
let user: UserWithProfile;
let billing: PlatformBilling;
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let profileRepositoryFixture: ProfileRepositoryFixture;
let membershipsRepositoryFixture: MembershipRepositoryFixture;
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
let oAuthClient: PlatformOAuthClient;
let platformBillingRepositoryFixture: PlatformBillingRepositoryFixture;
let organization: Team;
beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
})
).compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
platformBillingRepositoryFixture = new PlatformBillingRepositoryFixture(moduleRef);
organization = await organizationsRepositoryFixture.create({ name: "platform billing" });
user = await userRepositoryFixture.create({
email: userEmail,
username: userEmail,
});
await profileRepositoryFixture.create({
uid: `usr-${user.id}`,
username: userEmail,
organization: {
connect: {
id: organization.id,
},
},
user: {
connect: {
id: user.id,
},
},
});
await membershipsRepositoryFixture.create({
role: "OWNER",
team: { connect: { id: organization.id } },
user: { connect: { id: user.id } },
accepted: true,
});
billing = await platformBillingRepositoryFixture.create(organization.id);
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
afterAll(async () => {
userRepositoryFixture.deleteByEmail(user.email);
await app.close();
});
it("/billing/webhook (POST) should set billing free plan for org", () => {
jest.spyOn(StripeService.prototype, "getStripe").mockImplementation(
() =>
({
webhooks: {
constructEventAsync: async () => {
return {
type: "checkout.session.completed",
data: {
object: {
metadata: {
teamId: organization.id,
plan: "FREE",
},
mode: "subscription",
},
},
};
},
},
} as unknown as Stripe)
);
return request(app.getHttpServer())
.post("/v2/billing/webhook")
.expect(200)
.then(async (res) => {
const billing = await platformBillingRepositoryFixture.get(organization.id);
expect(billing?.plan).toEqual("FREE");
});
});
it("/billing/webhook (POST) failed payment should set billing free plan to overdue", () => {
jest.spyOn(StripeService.prototype, "getStripe").mockImplementation(
() =>
({
webhooks: {
constructEventAsync: async () => {
return {
type: "invoice.payment_failed",
data: {
object: {
customer: billing?.customerId,
subscription: billing?.subscriptionId,
},
},
};
},
},
} as unknown as Stripe)
);
return request(app.getHttpServer())
.post("/v2/billing/webhook")
.expect(200)
.then(async (res) => {
const billing = await platformBillingRepositoryFixture.get(organization.id);
expect(billing?.overdue).toEqual(true);
});
});
it("/billing/webhook (POST) success payment should set billing free plan to not overdue", () => {
jest.spyOn(StripeService.prototype, "getStripe").mockImplementation(
() =>
({
webhooks: {
constructEventAsync: async () => {
return {
type: "invoice.payment_succeeded",
data: {
object: {
customer: billing?.customerId,
subscription: billing?.subscriptionId,
},
},
};
},
},
} as unknown as Stripe)
);
return request(app.getHttpServer())
.post("/v2/billing/webhook")
.expect(200)
.then(async (res) => {
const billing = await platformBillingRepositoryFixture.get(organization.id);
expect(billing?.overdue).toEqual(false);
});
});
it("/billing/webhook (POST) should delete subscription", () => {
jest.spyOn(StripeService.prototype, "getStripe").mockImplementation(
() =>
({
webhooks: {
constructEventAsync: async () => {
return {
type: "customer.subscription.deleted",
data: {
object: {
metadata: {
teamId: organization.id,
plan: "FREE",
},
id: billing?.subscriptionId,
},
},
};
},
},
} as unknown as Stripe)
);
return request(app.getHttpServer())
.post("/v2/billing/webhook")
.expect(200)
.then(async (res) => {
const billing = await platformBillingRepositoryFixture.get(organization.id);
expect(billing).toBeNull();
});
});
});
@@ -102,13 +102,26 @@ export class BillingController {
@Req() request: Request,
@Headers("stripe-signature") stripeSignature: string
): Promise<ApiResponse> {
const event = await this.billingService.stripeService.stripe.webhooks.constructEventAsync(
request.body,
stripeSignature,
this.stripeWhSecret
);
const event = await this.billingService.stripeService
.getStripe()
.webhooks.constructEventAsync(request.body, stripeSignature, this.stripeWhSecret);
await this.billingService.createOrUpdateStripeSubscription(event);
switch (event.type) {
case "checkout.session.completed":
await this.billingService.handleStripeCheckoutEvents(event);
break;
case "customer.subscription.deleted":
await this.billingService.handleStripeSubscriptionDeleted(event);
break;
case "invoice.payment_failed":
await this.billingService.handleStripePaymentFailed(event);
break;
case "invoice.payment_succeeded":
await this.billingService.handleStripePaymentSuccess(event);
break;
default:
break;
}
return {
status: "success",
@@ -64,7 +64,7 @@ export class BillingService implements OnModuleDestroy {
}
async redirectToSubscribeCheckout(teamId: number, plan: PlatformPlan, customerId?: string) {
const { url } = await this.stripeService.stripe.checkout.sessions.create({
const { url } = await this.stripeService.getStripe().checkout.sessions.create({
customer: customerId,
line_items: [
{
@@ -101,7 +101,7 @@ export class BillingService implements OnModuleDestroy {
const teamWithBilling = await this.teamsRepository.findByIdIncludeBilling(teamId);
const customerId = teamWithBilling?.platformBilling?.customerId;
const { url } = await this.stripeService.stripe.checkout.sessions.create({
const { url } = await this.stripeService.getStripe().checkout.sessions.create({
customer: customerId,
success_url: `${this.webAppUrl}/settings/platform/`,
cancel_url: `${this.webAppUrl}/settings/platform/plans`,
@@ -131,41 +131,92 @@ export class BillingService implements OnModuleDestroy {
);
}
async createOrUpdateStripeSubscription(event: Stripe.Event) {
if (event.type === "checkout.session.completed") {
const subscription = event.data.object as Stripe.Checkout.Session;
if (!subscription.metadata?.teamId) {
return {
status: "success",
};
}
const teamId = Number.parseInt(subscription.metadata.teamId);
const plan = subscription.metadata.plan;
if (!plan || !teamId) {
this.logger.log("Webhook received but not pertaining to Platform, discarding.");
return {
status: "success",
};
}
if (subscription.mode === "subscription") {
await this.setSubscriptionForTeam(
async handleStripeSubscriptionDeleted(event: Stripe.Event) {
const subscription = event.data.object as Stripe.Subscription;
const teamId = subscription?.metadata?.teamId;
const plan = PlatformPlan[subscription?.metadata?.plan?.toUpperCase() as keyof typeof PlatformPlan];
if (teamId && plan) {
const currentBilling = await this.billingRepository.getBillingForTeam(Number.parseInt(teamId));
if (currentBilling?.subscriptionId === subscription.id) {
await this.billingRepository.deleteBilling(currentBilling.id);
this.logger.log(`Stripe Subscription deleted`, {
customerId: currentBilling.customerId,
subscriptionId: currentBilling.subscriptionId,
teamId,
subscription.subscription as string,
PlatformPlan[plan.toUpperCase() as keyof typeof PlatformPlan]
);
});
return;
}
if (subscription.mode === "setup") {
await this.updateStripeSubscriptionForTeam(teamId, plan as PlatformPlan);
}
return {
status: "success",
};
this.logger.log("No platform billing found.");
return;
}
this.logger.log("Webhook received but not pertaining to Platform, discarding.");
return;
}
getSubscriptionIdFromInvoice(invoice: Stripe.Invoice): string | null {
if (typeof invoice.subscription === "string") {
return invoice.subscription;
} else if (invoice.subscription && typeof invoice.subscription === "object") {
return invoice.subscription.id;
} else {
return null;
}
}
getCustomerIdFromInvoice(invoice: Stripe.Invoice): string | null {
if (typeof invoice.customer === "string") {
return invoice.customer;
} else if (invoice.customer && typeof invoice.customer === "object") {
return invoice.customer.id;
} else {
return null;
}
}
async handleStripePaymentSuccess(event: Stripe.Event) {
const invoice = event.data.object as Stripe.Invoice;
const subscriptionId = this.getSubscriptionIdFromInvoice(invoice);
const customerId = this.getCustomerIdFromInvoice(invoice);
if (subscriptionId && customerId) {
await this.billingRepository.updateBillingOverdue(subscriptionId, customerId, false);
}
}
async handleStripePaymentFailed(event: Stripe.Event) {
const invoice = event.data.object as Stripe.Invoice;
const subscriptionId = this.getSubscriptionIdFromInvoice(invoice);
const customerId = this.getCustomerIdFromInvoice(invoice);
if (subscriptionId && customerId) {
await this.billingRepository.updateBillingOverdue(subscriptionId, customerId, true);
}
}
async handleStripeCheckoutEvents(event: Stripe.Event) {
const checkoutSession = event.data.object as Stripe.Checkout.Session;
if (!checkoutSession.metadata?.teamId) {
return;
}
const teamId = Number.parseInt(checkoutSession.metadata.teamId);
const plan = checkoutSession.metadata.plan;
if (!plan || !teamId) {
this.logger.log("Webhook received but not pertaining to Platform, discarding.");
return;
}
if (checkoutSession.mode === "subscription") {
await this.setSubscriptionForTeam(
teamId,
checkoutSession.subscription as string,
PlatformPlan[plan.toUpperCase() as keyof typeof PlatformPlan]
);
}
if (checkoutSession.mode === "setup") {
await this.updateStripeSubscriptionForTeam(teamId, plan as PlatformPlan);
}
return;
}
async updateStripeSubscriptionForTeam(teamId: number, plan: PlatformPlan) {
@@ -175,9 +226,9 @@ export class BillingService implements OnModuleDestroy {
throw new NotFoundException("Team plan not found");
}
const existingUserSubscription = await this.stripeService.stripe.subscriptions.retrieve(
teamWithBilling?.platformBilling?.subscriptionId
);
const existingUserSubscription = await this.stripeService
.getStripe()
.subscriptions.retrieve(teamWithBilling?.platformBilling?.subscriptionId);
const currentLicensedItem = existingUserSubscription.items.data.find(
(item) => item.price?.recurring?.usage_type === "licensed"
);
@@ -193,21 +244,23 @@ export class BillingService implements OnModuleDestroy {
throw new NotFoundException("There is no overage item present in the subscription");
}
await this.stripeService.stripe.subscriptions.update(teamWithBilling?.platformBilling?.subscriptionId, {
items: [
{
id: currentLicensedItem.id,
price: this.billingConfigService.get(plan)?.base,
},
{
id: currentOverageItem.id,
price: this.billingConfigService.get(plan)?.overage,
clear_usage: false,
},
],
billing_cycle_anchor: "now",
proration_behavior: "create_prorations",
});
await this.stripeService
.getStripe()
.subscriptions.update(teamWithBilling?.platformBilling?.subscriptionId, {
items: [
{
id: currentLicensedItem.id,
price: this.billingConfigService.get(plan)?.base,
},
{
id: currentOverageItem.id,
price: this.billingConfigService.get(plan)?.overage,
clear_usage: false,
},
],
billing_cycle_anchor: "now",
proration_behavior: "create_prorations",
});
await this.setSubscriptionForTeam(
teamId,
+2 -1
View File
@@ -1,8 +1,9 @@
export enum PlatformPlan {
FREE = "FREE",
STARTER = "STARTER",
ESSENTIALS = "ESSENTIALS",
SCALE = "SCALE",
ENTERPRISE = "ENTERPRISE",
}
export type PlatformPlanType = "STARTER" | "ESSENTIALS" | "SCALE" | "ENTERPRISE";
export type PlatformPlanType = "FREE" | "STARTER" | "ESSENTIALS" | "SCALE" | "ENTERPRISE";
@@ -12,6 +12,9 @@ export class BookingSeatRepository {
where: {
referenceUid,
},
include: {
booking: { select: { uid: true } },
},
});
}
}
@@ -37,6 +37,10 @@ export class CredentialsRepository {
return this.dbWrite.prisma.credential.findFirst({ where: { type, userId } });
}
getByTypeAndTeamId(type: string, teamId: number) {
return this.dbWrite.prisma.credential.findFirst({ where: { type, teamId } });
}
getAllUserCredentialsByTypeAndId(type: string, userId: number) {
return this.dbRead.prisma.credential.findMany({ where: { type, userId } });
}
@@ -16,6 +16,7 @@ import { Test } from "@nestjs/testing";
import { PlatformOAuthClient, Team, User, EventType } from "@prisma/client";
import * as request from "supertest";
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
import { SchedulesRepositoryFixture } from "test/fixtures/repository/schedules.repository.fixture";
@@ -79,6 +80,7 @@ describe("OAuth Client Users Endpoints", () => {
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let schedulesRepositoryFixture: SchedulesRepositoryFixture;
let profilesRepositoryFixture: ProfileRepositoryFixture;
let membershipsRepositoryFixture: MembershipRepositoryFixture;
let postResponseData: CreateManagedUserOutput["data"];
@@ -103,26 +105,33 @@ describe("OAuth Client Users Endpoints", () => {
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
schedulesRepositoryFixture = new SchedulesRepositoryFixture(moduleRef);
profilesRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
platformAdmin = await userRepositoryFixture.create({ email: platformAdminEmail });
organization = await teamRepositoryFixture.create({ name: "organization" });
organization = await teamRepositoryFixture.create({
name: "organization",
isPlatform: true,
isOrganization: true,
});
oAuthClient = await createOAuthClient(organization.id);
await profilesRepositoryFixture.create({
uid: "asd-asd",
uid: "asd1qwwqeqw-asddsadasd",
username: platformAdminEmail,
organization: { connect: { id: organization.id } },
movedFromUser: {
connect: {
id: platformAdmin.id,
},
},
user: {
connect: { id: platformAdmin.id },
},
});
await membershipsRepositoryFixture.create({
role: "OWNER",
user: { connect: { id: platformAdmin.id } },
team: { connect: { id: organization.id } },
accepted: true,
});
await app.init();
});
@@ -370,7 +379,7 @@ describe("OAuth Client Users Endpoints", () => {
let teamRepositoryFixture: TeamRepositoryFixture;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let profileRepositoryFixture: ProfileRepositoryFixture;
let membershipsRepositoryFixture: MembershipRepositoryFixture;
let postResponseData: CreateManagedUserOutput["data"];
const userEmail = "oauth-client-users-user@gmail.com";
@@ -390,10 +399,11 @@ describe("OAuth Client Users Endpoints", () => {
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
organization = await teamRepositoryFixture.create({
name: "Testy Organization",
isOrganization: true,
isPlatform: true,
});
owner = await userRepositoryFixture.create({
@@ -417,6 +427,13 @@ describe("OAuth Client Users Endpoints", () => {
},
});
await membershipsRepositoryFixture.create({
role: "OWNER",
user: { connect: { id: owner.id } },
team: { connect: { id: organization.id } },
accepted: true,
});
oAuthClient1 = await createOAuthClient(organization.id);
oAuthClient2 = await createOAuthClient(organization.id);
@@ -510,7 +527,7 @@ describe("OAuth Client Users Endpoints", () => {
expect(oAuthClient1).toBeDefined();
});
it(`should create managed user and update team event-types of OAuthClient marked as assignAllTeamMembers: true`, async () => {
it(`should create managed user and update team event-types`, async () => {
const requestBody: CreateManagedUserInput = {
email: userEmail,
timeZone: userTimeZone,
@@ -532,48 +549,13 @@ describe("OAuth Client Users Endpoints", () => {
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
await userHasCorrectEventTypes(responseBody.data.user.id);
await teamHasCorrectEventTypes(team1.id);
expect(responseBody.data.user.name).toEqual(requestBody.name);
});
async function userHasCorrectEventTypes(userId: number) {
const eventTypes = await eventTypesRepositoryFixture.getAllUserEventTypes(userId);
expect(eventTypes?.length).toEqual(5);
// note(Lauris): managed event-types with assignAllTeamMembers: true
expect(eventTypes?.find((eventType) => eventType.slug === managedEventType1.slug)).toBeTruthy();
// note(Lauris): default event types
expect(
eventTypes?.find((eventType) => eventType.slug === DEFAULT_EVENT_TYPES.thirtyMinutes.slug)
).toBeTruthy();
expect(
eventTypes?.find((eventType) => eventType.slug === DEFAULT_EVENT_TYPES.sixtyMinutes.slug)
).toBeTruthy();
expect(
eventTypes?.find((eventType) => eventType.slug === DEFAULT_EVENT_TYPES.thirtyMinutesVideo.slug)
).toBeTruthy();
expect(
eventTypes?.find((eventType) => eventType.slug === DEFAULT_EVENT_TYPES.sixtyMinutesVideo.slug)
).toBeTruthy();
}
async function teamHasCorrectEventTypes(teamId: number) {
const eventTypes = await eventTypesRepositoryFixture.getAllTeamEventTypes(teamId);
expect(eventTypes?.length).toEqual(2);
// note(Lauris): managed event-types with assignAllTeamMembers: true
expect(eventTypes?.find((eventType) => eventType.slug === managedEventType1.slug)).toBeTruthy();
// note(Lauris): check if managed user added to collective event-type hosts given that it has assignAllTeamMembers: true
const collective = eventTypes?.find((eventType) => eventType.schedulingType === "COLLECTIVE");
expect(collective).toBeTruthy();
expect(collective?.hosts).toBeDefined();
expect(collective?.hosts?.length).toEqual(1);
expect(collective?.hosts[0].userId).toEqual(postResponseData.user.id);
}
afterAll(async () => {
@@ -1,6 +1,8 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { Locales } from "@/lib/enums/locales";
import { MembershipRoles } from "@/modules/auth/decorators/roles/membership-roles.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { OrganizationRolesGuard } from "@/modules/auth/guards/organization-roles/organization-roles.guard";
import { CreateManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/create-managed-user.output";
import { GetManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/get-managed-user.output";
import { GetManagedUsersOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/get-managed-users.output";
@@ -29,7 +31,7 @@ import {
NotFoundException,
} from "@nestjs/common";
import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
import { User } from "@prisma/client";
import { User, MembershipRole } from "@prisma/client";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { Pagination } from "@calcom/platform-types";
@@ -38,7 +40,7 @@ import { Pagination } from "@calcom/platform-types";
path: "/v2/oauth-clients/:clientId/users",
version: API_VERSIONS_VALUES,
})
@UseGuards(ApiAuthGuard, OAuthClientGuard)
@UseGuards(ApiAuthGuard, OAuthClientGuard, OrganizationRolesGuard)
@DocsTags("Platform / Managed Users")
export class OAuthClientUsersController {
private readonly logger = new Logger("UserController");
@@ -52,6 +54,7 @@ export class OAuthClientUsersController {
@Get("/")
@ApiOperation({ summary: "Get all managed users" })
@MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER])
async getManagedUsers(
@Param("clientId") oAuthClientId: string,
@Query() queryParams: Pagination
@@ -73,6 +76,7 @@ export class OAuthClientUsersController {
@Post("/")
@ApiOperation({ summary: "Create a managed user" })
@MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER])
async createUser(
@Param("clientId") oAuthClientId: string,
@Body() body: CreateManagedUserInput
@@ -104,6 +108,7 @@ export class OAuthClientUsersController {
@Get("/:userId")
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Get a managed user" })
@MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER])
async getUserById(
@Param("clientId") clientId: string,
@Param("userId") userId: number
@@ -119,6 +124,7 @@ export class OAuthClientUsersController {
@Patch("/:userId")
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Update a managed user" })
@MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER])
async updateUser(
@Param("clientId") clientId: string,
@Param("userId") userId: number,
@@ -138,6 +144,7 @@ export class OAuthClientUsersController {
@Delete("/:userId")
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Delete a managed user" })
@MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER])
async deleteUser(
@Param("clientId") clientId: string,
@Param("userId") userId: number
@@ -156,6 +163,7 @@ export class OAuthClientUsersController {
@Post("/:userId/force-refresh")
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Force refresh tokens" })
@MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER])
async forceRefresh(
@Param("userId") userId: number,
@Param("clientId") oAuthClientId: string
@@ -25,9 +25,9 @@ import {
import {
ApiTags as DocsTags,
ApiExcludeController as DocsExcludeController,
ApiExcludeEndpoint as DocsExcludeEndpoint,
ApiOperation as DocsOperation,
ApiOkResponse as DocsOkResponse,
ApiExcludeEndpoint as DocsExcludeEndpoint,
ApiBadRequestResponse as DocsBadRequestResponse,
ApiHeader as DocsHeader,
} from "@nestjs/swagger";
@@ -50,20 +50,7 @@ export class OAuthFlowController {
@Post("/authorize")
@HttpCode(HttpStatus.OK)
@UseGuards(NextAuthGuard)
@DocsOperation({
summary: "Authorize an OAuth client",
description:
"Redirects the user to the specified 'redirect_uri' with an authorization code in query parameter if the client is authorized successfully. The code is then exchanged for access and refresh tokens via the `/exchange` endpoint.",
})
@DocsOkResponse({
description:
"The user is redirected to the 'redirect_uri' with an authorization code in query parameter e.g. `redirectUri?code=secretcode.`",
})
@DocsBadRequestResponse({
description:
"Bad request if the OAuth client is not found, if the redirect URI is invalid, or if the user has already authorized the client.",
})
@DocsExcludeEndpoint(getEnv("NODE_ENV") === "production")
@DocsExcludeEndpoint()
async authorize(
@Param("clientId") clientId: string,
@Body() body: OAuthAuthorizeInput,
@@ -97,20 +84,7 @@ export class OAuthFlowController {
@Post("/exchange")
@HttpCode(HttpStatus.OK)
@DocsOperation({
summary: "Exchange authorization code for access tokens",
description:
"Exchanges the authorization code received from the `/authorize` endpoint for access and refresh tokens. The authorization code should be provided in the 'Authorization' header prefixed with 'Bearer '.",
})
@DocsOkResponse({
type: KeysResponseDto,
description: "Successfully exchanged authorization code for access and refresh tokens.",
})
@DocsBadRequestResponse({
description:
"Bad request if the authorization code is missing, invalid, or if the client ID and secret do not match.",
})
@DocsExcludeEndpoint(getEnv("NODE_ENV") === "production")
@DocsExcludeEndpoint()
async exchange(
@Headers("Authorization") authorization: string,
@Param("clientId") clientId: string,
@@ -99,4 +99,28 @@ export class OAuthClientRepository {
where: { id: clientId },
});
}
async getByUserId(userId: number) {
return this.dbRead.prisma.platformOAuthClient.findFirst({
where: {
users: {
some: {
id: userId,
},
},
},
});
}
async getByTeamId(teamId: number) {
return this.dbRead.prisma.platformOAuthClient.findFirst({
where: {
teams: {
some: {
id: teamId,
},
},
},
});
}
}
@@ -83,8 +83,6 @@ export class OAuthClientUsersService {
user.defaultScheduleId = defaultSchedule.id;
}
await this.organizationsTeamsService.addUserToPlatformTeamEvents(user.id, organizationId, oAuthClientId);
return {
user,
tokens: {
@@ -9,8 +9,9 @@ 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 { EventType, User } from "@prisma/client";
import * as request from "supertest";
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
@@ -26,6 +27,7 @@ describe("Organizations Teams Memberships Endpoints", () => {
describe("User Authentication - User is Org Admin", () => {
let app: INestApplication;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let teamsRepositoryFixture: TeamRepositoryFixture;
@@ -36,6 +38,8 @@ describe("Organizations Teams Memberships Endpoints", () => {
let org: Team;
let orgTeam: Team;
let nonOrgTeam: Team;
let teamEventType: EventType;
let managedEventType: EventType;
let membership: Membership;
let membership2: Membership;
let membershipCreatedViaApi: OrgTeamMembershipOutputDto;
@@ -66,6 +70,7 @@ describe("Organizations Teams Memberships Endpoints", () => {
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
user = await userRepositoryFixture.create({
email: userEmail,
@@ -97,6 +102,32 @@ describe("Organizations Teams Memberships Endpoints", () => {
parent: { connect: { id: org.id } },
});
teamEventType = await eventTypesRepositoryFixture.createTeamEventType({
schedulingType: "COLLECTIVE",
team: {
connect: { id: orgTeam.id },
},
title: "Collective Event Type",
slug: "collective-event-type",
length: 30,
assignAllTeamMembers: true,
bookingFields: [],
locations: [],
});
managedEventType = await eventTypesRepositoryFixture.createTeamEventType({
schedulingType: "MANAGED",
team: {
connect: { id: orgTeam.id },
},
title: "Managed Event Type",
slug: "managed-event-type",
length: 60,
assignAllTeamMembers: true,
bookingFields: [],
locations: [],
});
nonOrgTeam = await teamsRepositoryFixture.create({
name: "Non Org Team",
isOrganization: false,
@@ -264,7 +295,7 @@ describe("Organizations Teams Memberships Endpoints", () => {
.expect(404);
});
it("should create the membership of the org's team", async () => {
it("should have created the membership of the org's team and assigned team wide events", async () => {
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships`)
.send({
@@ -281,9 +312,22 @@ describe("Organizations Teams Memberships Endpoints", () => {
expect(membershipCreatedViaApi.role).toEqual("MEMBER");
expect(membershipCreatedViaApi.userId).toEqual(userToInviteViaApi.id);
expect(membershipCreatedViaApi.user.email).toEqual(userToInviteViaApi.email);
userHasCorrectEventTypes(membershipCreatedViaApi.userId);
});
});
async function userHasCorrectEventTypes(userId: number) {
const managedEventTypes = await eventTypesRepositoryFixture.getAllUserEventTypes(userId);
const teamEventTypes = await eventTypesRepositoryFixture.getAllTeamEventTypes(orgTeam.id);
expect(managedEventTypes?.length).toEqual(1);
expect(teamEventTypes?.length).toEqual(2);
const collectiveEvenType = teamEventTypes?.find((eventType) => eventType.slug === teamEventType.slug);
expect(collectiveEvenType).toBeTruthy();
const userHost = collectiveEvenType?.hosts.find((host) => host.userId === userId);
expect(userHost).toBeTruthy();
expect(managedEventTypes?.find((eventType) => eventType.slug === managedEventType.slug)).toBeTruthy();
}
it("should fail to create the membership of the org's team for a non org user", async () => {
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships`)
@@ -15,6 +15,7 @@ import {
OrgTeamMembershipsOutputResponseDto,
OrgTeamMembershipOutputResponseDto,
} from "@/modules/organizations/outputs/organization-teams-memberships.output";
import { OrganizationsEventTypesService } from "@/modules/organizations/services/event-types/organizations-event-types.service";
import { OrganizationsTeamsMembershipsService } from "@/modules/organizations/services/organizations-teams-memberships.service";
import {
Controller,
@@ -30,11 +31,13 @@ import {
HttpStatus,
HttpCode,
UnprocessableEntityException,
Logger,
} from "@nestjs/common";
import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
import { plainToClass } from "class-transformer";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { updateNewTeamMemberEventTypes } from "@calcom/platform-libraries";
import { SkipTakePagination } from "@calcom/platform-types";
@Controller({
@@ -44,8 +47,11 @@ import { SkipTakePagination } from "@calcom/platform-types";
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
@DocsTags("Orgs / Teams / Memberships")
export class OrganizationsTeamsMembershipsController {
private logger = new Logger("OrganizationsTeamsMembershipsController");
constructor(
private organizationsTeamsMembershipsService: OrganizationsTeamsMembershipsService,
private oganizationsEventTypesService: OrganizationsEventTypesService,
private readonly organizationsRepository: OrganizationsRepository
) {}
@@ -112,6 +118,9 @@ export class OrganizationsTeamsMembershipsController {
teamId,
membershipId
);
await this.oganizationsEventTypesService.deleteUserTeamEventTypesAndHosts(membership.userId, teamId);
return {
status: SUCCESS_STATUS,
data: plainToClass(OrgTeamMembershipOutputDto, membership, { strategy: "excludeAll" }),
@@ -129,15 +138,29 @@ export class OrganizationsTeamsMembershipsController {
@Param("membershipId", ParseIntPipe) membershipId: number,
@Body() data: UpdateOrgTeamMembershipDto
): Promise<OrgTeamMembershipOutputResponseDto> {
const membership = await this.organizationsTeamsMembershipsService.updateOrgTeamMembership(
const currentMembership = await this.organizationsTeamsMembershipsService.getOrgTeamMembership(
orgId,
teamId,
membershipId
);
const updatedMembership = await this.organizationsTeamsMembershipsService.updateOrgTeamMembership(
orgId,
teamId,
membershipId,
data
);
if (!currentMembership.accepted && updatedMembership.accepted) {
try {
await updateNewTeamMemberEventTypes(updatedMembership.userId, teamId);
} catch (err) {
this.logger.error("Could not update new team member eventTypes", err);
}
}
return {
status: SUCCESS_STATUS,
data: plainToClass(OrgTeamMembershipOutputDto, membership, { strategy: "excludeAll" }),
data: plainToClass(OrgTeamMembershipOutputDto, updatedMembership, { strategy: "excludeAll" }),
};
}
@@ -158,6 +181,13 @@ export class OrganizationsTeamsMembershipsController {
}
const membership = await this.organizationsTeamsMembershipsService.createOrgTeamMembership(teamId, data);
if (membership.accepted) {
try {
await updateNewTeamMemberEventTypes(user.id, teamId);
} catch (err) {
this.logger.error("Could not update new team member eventTypes", err);
}
}
return {
status: SUCCESS_STATUS,
data: plainToClass(OrgTeamMembershipOutputDto, membership, { strategy: "excludeAll" }),
@@ -469,34 +469,13 @@ describe("Organizations Users Endpoints", () => {
const userData = body.data;
expect(body.status).toBe(SUCCESS_STATUS);
userHasCorrectEventTypes(userData.id);
createdUser = userData;
teamHasCorrectEventTypes(team.id);
});
async function userHasCorrectEventTypes(userId: number) {
const eventTypes = await eventTypesRepositoryFixture.getAllUserEventTypes(userId);
expect(eventTypes?.length).toEqual(1);
// note(Lauris): managed event-types with assignAllTeamMembers: true
expect(eventTypes?.find((eventType) => eventType.slug === managedEventType.slug)).toBeTruthy();
}
async function teamHasCorrectEventTypes(teamId: number) {
const eventTypes = await eventTypesRepositoryFixture.getAllTeamEventTypes(teamId);
expect(eventTypes?.length).toEqual(2);
// note(Lauris): managed event-types with assignAllTeamMembers: true
expect(eventTypes?.find((eventType) => eventType.slug === managedEventType.slug)).toBeTruthy();
// note(Lauris): check if managed user added to collective event-type hosts given that it has assignAllTeamMembers: true
const collective = eventTypes?.find((eventType) => eventType.schedulingType === "COLLECTIVE");
expect(collective).toBeTruthy();
expect(collective?.hosts).toBeDefined();
expect(collective?.hosts?.length).toEqual(1);
expect(collective?.hosts[0].userId).toEqual(createdUser.id);
}
afterAll(async () => {
@@ -33,7 +33,7 @@ export class OrganizationsRepository {
}
async createNewBillingRelation(orgId: number, plan?: PlatformPlan) {
const { id } = await this.stripeService.stripe.customers.create({
const { id } = await this.stripeService.getStripe().customers.create({
metadata: {
createdBy: "oauth_client_no_csid", // mark in case this is needed in the future.
},
@@ -70,4 +70,26 @@ export class OrganizationsEventTypesRepository {
include: { children: true },
});
}
async deleteUserManagedTeamEventTypes(userId: number, teamId: number) {
return this.dbWrite.prisma.eventType.deleteMany({
where: {
parent: {
teamId,
},
userId,
},
});
}
async removeUserFromTeamEventTypesHosts(userId: number, teamId: number) {
return this.dbWrite.prisma.host.deleteMany({
where: {
userId,
eventType: {
teamId,
},
},
});
}
}
@@ -6,13 +6,15 @@ import { DatabaseTeamEventType } from "@/modules/organizations/services/event-ty
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { UsersService } from "@/modules/users/services/users.service";
import { UserWithProfile } from "@/modules/users/users.repository";
import { Injectable, NotFoundException } from "@nestjs/common";
import { Injectable, NotFoundException, Logger } from "@nestjs/common";
import { createEventType, updateEventType } from "@calcom/platform-libraries";
import { InputTeamEventTransformed_2024_06_14 } from "@calcom/platform-types";
@Injectable()
export class OrganizationsEventTypesService {
private readonly logger = new Logger("OrganizationsEventTypesService");
constructor(
private readonly eventTypesService: EventTypesService_2024_06_14,
private readonly dbWrite: PrismaWriteService,
@@ -145,4 +147,17 @@ export class OrganizationsEventTypesService {
return this.eventTypesRepository.deleteEventType(eventTypeId);
}
async deleteUserTeamEventTypesAndHosts(userId: number, teamId: number) {
try {
await this.organizationEventTypesRepository.deleteUserManagedTeamEventTypes(userId, teamId);
await this.organizationEventTypesRepository.removeUserFromTeamEventTypesHosts(userId, teamId);
} catch (err) {
this.logger.error("Could not remove user from all team event-types.", {
error: err,
userId,
teamId,
});
}
}
}
@@ -5,8 +5,6 @@ import { OrganizationsTeamsRepository } from "@/modules/organizations/repositori
import { UserWithProfile } from "@/modules/users/users.repository";
import { Injectable } from "@nestjs/common";
import { updateNewTeamMemberEventTypes } from "@calcom/platform-libraries";
@Injectable()
export class OrganizationsTeamsService {
constructor(
@@ -69,23 +67,4 @@ export class OrganizationsTeamsService {
}
return team;
}
async addUserToTeamEvents(userId: number, organizationId: number) {
const orgTeams = await this.organizationsTeamRepository.findOrgTeams(organizationId);
for (const team of orgTeams) {
await updateNewTeamMemberEventTypes(userId, team.id);
}
}
async addUserToPlatformTeamEvents(userId: number, organizationId: number, oAuthClientId: string) {
const oAuthClientTeams = await this.organizationsTeamRepository.getPlatformOrgTeams(
organizationId,
oAuthClientId
);
for (const team of oAuthClientTeams) {
await updateNewTeamMemberEventTypes(userId, team.id);
}
}
}
@@ -79,8 +79,6 @@ export class OrganizationsUsersService {
updateUserBody
);
await this.organizationsTeamsService.addUserToTeamEvents(user.id, org.id);
// Need to send email to new user to create password
await this.emailService.sendSignupToOrganizationEmail({
usernameOrEmail,
@@ -21,6 +21,7 @@ import {
Req,
BadRequestException,
Headers,
Param,
} from "@nestjs/common";
import { ApiTags as DocsTags, ApiOperation } from "@nestjs/swagger";
import { plainToClass } from "class-transformer";
@@ -46,7 +47,8 @@ export class StripeController {
@Headers("Authorization") authorization: string,
@GetUser() user: UserWithProfile,
@Query("redir") redir?: string | null,
@Query("errorRedir") errorRedir?: string | null
@Query("errorRedir") errorRedir?: string | null,
@Query("teamId") teamId?: string | null
): Promise<StripConnectOutputResponseDto> {
const origin = req.headers.origin;
const accessToken = authorization.replace("Bearer ", "");
@@ -56,6 +58,7 @@ export class StripeController {
fromApp: false,
returnTo: !!redir ? redir : origin,
accessToken,
teamId: Number(teamId) ?? null,
};
const stripeRedirectUrl = await this.stripeService.getStripeRedirectUrl(
@@ -99,6 +102,16 @@ export class StripeController {
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Check stripe connection" })
async check(@GetUser() user: UserWithProfile): Promise<StripCredentialsCheckOutputResponseDto> {
return await this.stripeService.checkIfStripeAccountConnected(user.id);
return await this.stripeService.checkIfIndividualStripeAccountConnected(user.id);
}
@Get("/check/:teamId")
@UseGuards(ApiAuthGuard)
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Check team stripe connection" })
async checkTeamStripeConnection(
@Param("teamId") teamId: string
): Promise<StripCredentialsCheckOutputResponseDto> {
return await this.stripeService.checkIfTeamStripeAccountConnected(Number(teamId));
}
}
@@ -1,5 +1,6 @@
import { AppsRepository } from "@/modules/apps/apps.repository";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { StripeController } from "@/modules/stripe/controllers/stripe.controller";
import { StripeService } from "@/modules/stripe/stripe.service";
@@ -10,7 +11,7 @@ import { ConfigModule } from "@nestjs/config";
@Module({
imports: [ConfigModule, PrismaModule],
exports: [StripeService],
providers: [StripeService, AppsRepository, CredentialsRepository, TokensRepository],
providers: [StripeService, AppsRepository, CredentialsRepository, TokensRepository, MembershipsRepository],
controllers: [StripeController],
})
export class StripeModule {}
@@ -1,13 +1,14 @@
import { AppConfig } from "@/config/type";
import { AppsRepository } from "@/modules/apps/apps.repository";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { getReturnToValueFromQueryState } from "@/modules/stripe/utils/getReturnToValueFromQueryState";
import { stripeInstance } from "@/modules/stripe/utils/newStripeInstance";
import { StripeData } from "@/modules/stripe/utils/stripeDataSchemas";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { Injectable, NotFoundException, BadRequestException, UnauthorizedException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import type { Prisma } from "@prisma/client";
import type { Prisma, Credential } from "@prisma/client";
import Stripe from "stripe";
import { z } from "zod";
@@ -17,9 +18,17 @@ import { stripeKeysResponseSchema } from "./utils/stripeDataSchemas";
import stringify = require("qs-stringify");
type IntegrationOAuthCallbackState = {
accessToken: string;
returnTo: string;
onErrorReturnTo: string;
fromApp: boolean;
teamId?: number | null;
};
@Injectable()
export class StripeService {
public stripe: Stripe;
private stripe: Stripe;
private redirectUri = `${this.config.get("api.url")}/stripe/save`;
constructor(
@@ -27,13 +36,18 @@ export class StripeService {
private readonly config: ConfigService,
private readonly appsRepository: AppsRepository,
private readonly credentialRepository: CredentialsRepository,
private readonly tokensRepository: TokensRepository
private readonly tokensRepository: TokensRepository,
private readonly membershipRepository: MembershipsRepository
) {
this.stripe = new Stripe(configService.get("stripe.apiKey", { infer: true }) ?? "", {
apiVersion: "2020-08-27",
});
}
getStripe() {
return this.stripe;
}
async getStripeRedirectUrl(state: string, userEmail?: string, userName?: string | null) {
const { client_id } = await this.getStripeAppKeys();
@@ -76,6 +90,7 @@ export class StripeService {
async saveStripeAccount(state: string, code: string, accessToken: string): Promise<{ url: string }> {
const userId = await this.tokensRepository.getAccessTokenOwnerId(accessToken);
const oAuthCallbackState: IntegrationOAuthCallbackState = JSON.parse(state);
if (!userId) {
throw new UnauthorizedException("Invalid Access token.");
@@ -92,6 +107,19 @@ export class StripeService {
data["default_currency"] = account.default_currency;
}
if (oAuthCallbackState.teamId) {
await this.checkIfUserHasAdminAccessToTeam(oAuthCallbackState.teamId, userId);
await this.appsRepository.createTeamAppCredential(
"stripe_payment",
data as unknown as Prisma.InputJsonObject,
oAuthCallbackState.teamId,
"stripe"
);
return { url: getReturnToValueFromQueryState(state) };
}
await this.appsRepository.createAppCredential(
"stripe_payment",
data as unknown as Prisma.InputJsonObject,
@@ -102,18 +130,39 @@ export class StripeService {
return { url: getReturnToValueFromQueryState(state) };
}
async checkIfStripeAccountConnected(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
async checkIfIndividualStripeAccountConnected(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
const stripeCredentials = await this.credentialRepository.getByTypeAndUserId("stripe_payment", userId);
if (!stripeCredentials) {
return await this.validateStripeCredentials(stripeCredentials);
}
async checkIfTeamStripeAccountConnected(teamId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
const stripeCredentials = await this.credentialRepository.getByTypeAndTeamId("stripe_payment", teamId);
return await this.validateStripeCredentials(stripeCredentials);
}
async checkIfUserHasAdminAccessToTeam(teamId: number, userId: number) {
const teamMembership = await this.membershipRepository.findMembershipByTeamId(teamId, userId);
const hasAdminAccessToTeam = teamMembership?.role === "ADMIN" || teamMembership?.role === "OWNER";
if (!hasAdminAccessToTeam) {
throw new BadRequestException("You must be team owner or admin to do this");
}
}
async validateStripeCredentials(
credentials?: Credential | null
): Promise<{ status: typeof SUCCESS_STATUS }> {
if (!credentials) {
throw new NotFoundException("Credentials for stripe not found.");
}
if (stripeCredentials.invalid) {
if (credentials.invalid) {
throw new BadRequestException("Invalid stripe credentials.");
}
const stripeKey = JSON.stringify(stripeCredentials.key);
const stripeKey = JSON.stringify(credentials.key);
const stripeKeyObject = JSON.parse(stripeKey);
const stripeAccount = await stripeInstance.accounts.retrieve(stripeKeyObject?.stripe_user_id);
@@ -82,7 +82,10 @@ export class WebhooksController {
}
@Get("/")
@ApiOperation({ summary: "Get all webooks (paginated)" })
@ApiOperation({
summary: "Get all webooks",
description: "Gets a paginated list of webhooks for the authenticated user.",
})
async getWebhooks(
@GetUser() user: UserWithProfile,
@Query() query: SkipTakePagination
+475 -116
View File
@@ -3162,6 +3162,7 @@
"post": {
"operationId": "BookingsController_2024_08_13_cancelBooking",
"summary": "Cancel a booking",
"description": ":bookingUid can be :bookingUid of an usual booking, individual recurrence or recurring booking to cancel all recurrences.\n For seated bookings to cancel one individual booking provide :bookingUid and :seatUid in the request body. For recurring seated bookings it is not possible to cancel all of them with 1 call\n like with non-seated recurring bookings by providing recurring bookind uid - you have to cancel each recurrence booking by its bookingUid + seatUid.",
"parameters": [
{
"name": "cal-api-version",
@@ -3257,6 +3258,230 @@
]
}
},
"/v2/bookings/{bookingUid}/reassign": {
"post": {
"operationId": "BookingsController_2024_08_13_reassignBooking",
"summary": "Automatically reassign booking to a new host",
"parameters": [
{
"name": "cal-api-version",
"in": "header",
"description": "Must be set to `2024-08-13`",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "bookingUid",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
},
{
"name": "Authorization",
"in": "header",
"description": "value must be `Bearer <token>` where `<token>` either managed user access token or api key prefixed with cal_",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ReassignBookingOutput_2024_08_13"
}
}
}
}
},
"tags": [
"Bookings"
]
}
},
"/v2/bookings/{bookingUid}/reassign/{userId}": {
"post": {
"operationId": "BookingsController_2024_08_13_reassignBookingToUser",
"summary": "Reassign a booking to a specific user",
"parameters": [
{
"name": "cal-api-version",
"in": "header",
"description": "Must be set to `2024-08-13`",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "bookingUid",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
},
{
"name": "userId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
},
{
"name": "Authorization",
"in": "header",
"description": "value must be `Bearer <token>` where `<token>` either managed user access token or api key prefixed with cal_",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ReassignToUserBookingInput_2024_08_13"
}
}
}
},
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ReassignBookingOutput_2024_08_13"
}
}
}
}
},
"tags": [
"Bookings"
]
}
},
"/v2/bookings/{bookingUid}/confirm": {
"post": {
"operationId": "BookingsController_2024_08_13_confirmBooking",
"summary": "Confirm booking that requires a confirmation",
"parameters": [
{
"name": "cal-api-version",
"in": "header",
"description": "Must be set to `2024-08-13`",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "bookingUid",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
},
{
"name": "Authorization",
"in": "header",
"description": "value must be `Bearer <token>` where `<token>` either managed user access token or api key prefixed with cal_",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/GetBookingOutput_2024_08_13"
}
}
}
}
},
"tags": [
"Bookings"
]
}
},
"/v2/bookings/{bookingUid}/decline": {
"post": {
"operationId": "BookingsController_2024_08_13_declineBooking",
"summary": "Decline booking that requires a confirmation",
"parameters": [
{
"name": "cal-api-version",
"in": "header",
"description": "Must be set to `2024-08-13`",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "bookingUid",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
},
{
"name": "Authorization",
"in": "header",
"description": "value must be `Bearer <token>` where `<token>` either managed user access token or api key prefixed with cal_",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DeclineBookingInput_2024_08_13"
}
}
}
},
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/GetBookingOutput_2024_08_13"
}
}
}
}
},
"tags": [
"Bookings"
]
}
},
"/v2/calendars/ics-feed/save": {
"post": {
"operationId": "CalendarsController_createIcsFeed",
@@ -4214,97 +4439,6 @@
]
}
},
"/v2/oauth/{clientId}/authorize": {
"post": {
"operationId": "OAuthFlowController_authorize",
"summary": "Authorize an OAuth client",
"description": "Redirects the user to the specified 'redirect_uri' with an authorization code in query parameter if the client is authorized successfully. The code is then exchanged for access and refresh tokens via the `/exchange` endpoint.",
"parameters": [
{
"name": "clientId",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/OAuthAuthorizeInput"
}
}
}
},
"responses": {
"200": {
"description": "The user is redirected to the 'redirect_uri' with an authorization code in query parameter e.g. `redirectUri?code=secretcode.`"
},
"400": {
"description": "Bad request if the OAuth client is not found, if the redirect URI is invalid, or if the user has already authorized the client."
}
},
"tags": [
"OAuth"
]
}
},
"/v2/oauth/{clientId}/exchange": {
"post": {
"operationId": "OAuthFlowController_exchange",
"summary": "Exchange authorization code for access tokens",
"description": "Exchanges the authorization code received from the `/authorize` endpoint for access and refresh tokens. The authorization code should be provided in the 'Authorization' header prefixed with 'Bearer '.",
"parameters": [
{
"name": "Authorization",
"required": true,
"in": "header",
"schema": {
"type": "string"
}
},
{
"name": "clientId",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ExchangeAuthorizationCodeInput"
}
}
}
},
"responses": {
"200": {
"description": "Successfully exchanged authorization code for access and refresh tokens.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/KeysResponseDto"
}
}
}
},
"400": {
"description": "Bad request if the authorization code is missing, invalid, or if the client ID and secret do not match."
}
},
"tags": [
"OAuth"
]
}
},
"/v2/oauth/{clientId}/refresh": {
"post": {
"operationId": "OAuthFlowController_refreshAccessToken",
@@ -5033,6 +5167,37 @@
]
}
},
"/v2/stripe/check/{teamId}": {
"get": {
"operationId": "StripeController_checkTeamStripeConnection",
"summary": "Check team stripe connection",
"parameters": [
{
"name": "teamId",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/StripCredentialsCheckOutputResponseDto"
}
}
}
}
},
"tags": [
"Stripe"
]
}
},
"/v2/timezones": {
"get": {
"operationId": "TimezonesController_getTimeZones",
@@ -5088,7 +5253,8 @@
},
"get": {
"operationId": "WebhooksController_getWebhooks",
"summary": "Get all webooks (paginated)",
"summary": "Get all webooks",
"description": "Gets a paginated list of webhooks for the authenticated user.",
"parameters": [
{
"name": "take",
@@ -5818,28 +5984,6 @@
}
}
},
"OAuthAuthorizeInput": {
"type": "object",
"properties": {
"redirectUri": {
"type": "string"
}
},
"required": [
"redirectUri"
]
},
"ExchangeAuthorizationCodeInput": {
"type": "object",
"properties": {
"clientSecret": {
"type": "string"
}
},
"required": [
"clientSecret"
]
},
"RefreshTokenInput": {
"type": "object",
"properties": {
@@ -6720,6 +6864,18 @@
"type": "number",
"example": 60
},
"lengthInMinutesOptions": {
"example": [
15,
30,
60
],
"description": "If you want that user can choose between different lengths of the event you can specify them here. Must include the provided `lengthInMinutes`.",
"type": "array",
"items": {
"type": "string"
}
},
"title": {
"type": "string",
"example": "Learn the secrets of masterchief!"
@@ -6943,6 +7099,7 @@
},
"required": [
"lengthInMinutes",
"lengthInMinutesOptions",
"title",
"slug"
]
@@ -8085,6 +8242,18 @@
"type": "number",
"example": 60
},
"lengthInMinutesOptions": {
"example": [
15,
30,
60
],
"description": "If you want that user can choose between different lengths of the event you can specify them here. Must include the provided `lengthInMinutes`.",
"type": "array",
"items": {
"type": "string"
}
},
"title": {
"type": "string",
"example": "Learn the secrets of masterchief!"
@@ -8308,6 +8477,7 @@
"required": [
"id",
"lengthInMinutes",
"lengthInMinutesOptions",
"title",
"slug",
"description",
@@ -8423,6 +8593,18 @@
"type": "number",
"example": 60
},
"lengthInMinutesOptions": {
"example": [
15,
30,
60
],
"description": "If you want that user can choose between different lengths of the event you can specify them here. Must include the provided `lengthInMinutes`.",
"type": "array",
"items": {
"type": "string"
}
},
"title": {
"type": "string",
"example": "Learn the secrets of masterchief!"
@@ -8643,7 +8825,10 @@
"hideCalendarEventDetails": {
"type": "boolean"
}
}
},
"required": [
"lengthInMinutesOptions"
]
},
"UpdateEventTypeOutput_2024_06_14": {
"type": "object",
@@ -9902,6 +10087,18 @@
"type": "number",
"example": 60
},
"lengthInMinutesOptions": {
"example": [
15,
30,
60
],
"description": "If you want that user can choose between different lengths of the event you can specify them here. Must include the provided `lengthInMinutes`.",
"type": "array",
"items": {
"type": "string"
}
},
"title": {
"type": "string",
"example": "Learn the secrets of masterchief!"
@@ -10138,6 +10335,7 @@
},
"required": [
"lengthInMinutes",
"lengthInMinutesOptions",
"title",
"slug",
"schedulingType",
@@ -10219,6 +10417,19 @@
"minimum": 1,
"example": 60
},
"lengthInMinutesOptions": {
"example": [
15,
30,
60
],
"description": "If you want that user can choose between different lengths of the event you can specify them here. Must include the provided `lengthInMinutes`.",
"type": "array",
"items": {
"type": "number",
"minimum": 1
}
},
"title": {
"type": "string",
"example": "Learn the secrets of masterchief!"
@@ -10644,6 +10855,18 @@
"type": "number",
"example": 60
},
"lengthInMinutesOptions": {
"example": [
15,
30,
60
],
"description": "If you want that user can choose between different lengths of the event you can specify them here. Must include the provided `lengthInMinutes`.",
"type": "array",
"items": {
"type": "string"
}
},
"title": {
"type": "string",
"example": "Learn the secrets of masterchief!"
@@ -10874,7 +11097,10 @@
"type": "boolean",
"description": "If true, all current and future team members will be assigned to this event type"
}
}
},
"required": [
"lengthInMinutesOptions"
]
},
"UpdateTeamEventTypeOutput": {
"type": "object",
@@ -12584,6 +12810,11 @@
"description": "The start time of the booking in ISO 8601 format in UTC timezone.",
"example": "2024-08-13T09:00:00Z"
},
"lengthInMinutes": {
"type": "number",
"example": 30,
"description": "If it is an event type that has multiple possible lengths that attendee can pick from, you can pass the desired booking length here.\n If not provided then event type default length will be used for the booking."
},
"eventTypeId": {
"type": "number",
"description": "The ID of the event type that is booked.",
@@ -12619,6 +12850,13 @@
"description": "Location for this booking. Displayed in email and calendar event.",
"example": "https://example.com/meeting"
},
"metadata": {
"type": "object",
"description": "You can store any additional data you want here. Metadata must have at most 50 keys, each key up to 40 characters, and string values up to 500 characters.",
"example": {
"key": "value"
}
},
"bookingFieldsResponses": {
"type": "object",
"description": "Booking field responses consisting of an object with booking field slug as keys and user response as values.",
@@ -12629,6 +12867,7 @@
},
"required": [
"start",
"lengthInMinutes",
"eventTypeId",
"attendee"
]
@@ -12641,6 +12880,11 @@
"description": "The start time of the booking in ISO 8601 format in UTC timezone.",
"example": "2024-08-13T09:00:00Z"
},
"lengthInMinutes": {
"type": "number",
"example": 30,
"description": "If it is an event type that has multiple possible lengths that attendee can pick from, you can pass the desired booking length here.\n If not provided then event type default length will be used for the booking."
},
"eventTypeId": {
"type": "number",
"description": "The ID of the event type that is booked.",
@@ -12676,6 +12920,13 @@
"description": "Location for this booking. Displayed in email and calendar event.",
"example": "https://example.com/meeting"
},
"metadata": {
"type": "object",
"description": "You can store any additional data you want here. Metadata must have at most 50 keys, each key up to 40 characters, and string values up to 500 characters.",
"example": {
"key": "value"
}
},
"bookingFieldsResponses": {
"type": "object",
"description": "Booking field responses consisting of an object with booking field slug as keys and user response as values.",
@@ -12691,6 +12942,7 @@
},
"required": [
"start",
"lengthInMinutes",
"eventTypeId",
"attendee",
"instant"
@@ -12704,6 +12956,11 @@
"description": "The start time of the booking in ISO 8601 format in UTC timezone.",
"example": "2024-08-13T09:00:00Z"
},
"lengthInMinutes": {
"type": "number",
"example": 30,
"description": "If it is an event type that has multiple possible lengths that attendee can pick from, you can pass the desired booking length here.\n If not provided then event type default length will be used for the booking."
},
"eventTypeId": {
"type": "number",
"description": "The ID of the event type that is booked.",
@@ -12739,6 +12996,13 @@
"description": "Location for this booking. Displayed in email and calendar event.",
"example": "https://example.com/meeting"
},
"metadata": {
"type": "object",
"description": "You can store any additional data you want here. Metadata must have at most 50 keys, each key up to 40 characters, and string values up to 500 characters.",
"example": {
"key": "value"
}
},
"bookingFieldsResponses": {
"type": "object",
"description": "Booking field responses consisting of an object with booking field slug as keys and user response as values.",
@@ -12754,6 +13018,7 @@
},
"required": [
"start",
"lengthInMinutes",
"eventTypeId",
"attendee"
]
@@ -12857,6 +13122,16 @@
"type": "boolean",
"example": true
},
"createdAt": {
"type": "string",
"example": "2024-08-13T15:30:00Z"
},
"metadata": {
"type": "object",
"example": {
"key": "value"
}
},
"attendees": {
"type": "array",
"items": {
@@ -12894,6 +13169,7 @@
"eventTypeId",
"eventType",
"absentHost",
"createdAt",
"attendees"
]
},
@@ -12979,6 +13255,16 @@
"type": "boolean",
"example": true
},
"createdAt": {
"type": "string",
"example": "2024-08-13T15:30:00Z"
},
"metadata": {
"type": "object",
"example": {
"key": "value"
}
},
"attendees": {
"type": "array",
"items": {
@@ -13020,6 +13306,7 @@
"eventTypeId",
"eventType",
"absentHost",
"createdAt",
"attendees",
"recurringBookingUid"
]
@@ -13278,6 +13565,78 @@
"data"
]
},
"ReassignedToDto": {
"type": "object",
"properties": {
"id": {
"type": "number",
"example": 123
},
"name": {
"type": "string",
"example": "John Doe"
},
"email": {
"type": "string",
"example": "john.doe@example.com"
}
},
"required": [
"id",
"name",
"email"
]
},
"ReassignBookingOutput_2024_08_13": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "success",
"enum": [
"success",
"error"
]
},
"data": {
"oneOf": [
{
"$ref": "#/components/schemas/ReassignBookingOutput_2024_08_13"
}
],
"description": "Booking data, which can be either a ReassignAutoBookingOutput object or a ReassignManualBookingOutput object",
"allOf": [
{
"$ref": "#/components/schemas/ReassignBookingOutput_2024_08_13"
}
]
}
},
"required": [
"status",
"data"
]
},
"ReassignToUserBookingInput_2024_08_13": {
"type": "object",
"properties": {
"reason": {
"type": "string",
"example": "Host has to take another call",
"description": "Reason for reassigning the booking"
}
}
},
"DeclineBookingInput_2024_08_13": {
"type": "object",
"properties": {
"reason": {
"type": "string",
"example": "Host has to take another call",
"description": "Reason for declining a booking that requires a confirmation"
}
}
},
"ReserveSlotInput": {
"type": "object",
"properties": {
@@ -23,6 +23,14 @@ export class PlatformBillingRepositoryFixture {
});
}
async get(orgId: number) {
return this.prismaWriteClient.platformBilling.findFirst({
where: {
id: orgId,
},
});
}
async deleteSubscriptionForTeam(teamId: number) {
// silently try to delete the subscription
try {
@@ -22,6 +22,14 @@ export class BookingsRepositoryFixture {
return this.prismaReadClient.booking.findUnique({ where: { uid: bookingUid } });
}
async getByRecurringBookingUid(recurringBookingUid: string) {
return this.prismaReadClient.booking.findMany({
where: {
recurringEventId: recurringBookingUid,
},
});
}
async create(booking: Prisma.BookingCreateInput) {
return this.prismaWriteClient.booking.create({ data: booking });
}
-4
View File
@@ -8,10 +8,6 @@ import { FUTURE_ROUTES_ENABLED_COOKIE_NAME, FUTURE_ROUTES_OVERRIDE_COOKIE_NAME }
const ROUTES: [URLPattern, boolean][] = [
["/event-types", process.env.APP_ROUTER_EVENT_TYPES_ENABLED === "1"] as const,
["/settings/developer/:path*", process.env.APP_ROUTER_SETTINGS_DEVELOPER_ENABLED === "1"] as const,
["/settings/admin/:path*", process.env.APP_ROUTER_SETTINGS_ADMIN_ENABLED === "1"] as const,
["/settings/my-account/:path*", process.env.APP_ROUTER_SETTINGS_MY_ACCOUNT_ENABLED === "1"] as const,
["/settings/platform/:path*", process.env.APP_ROUTER_SETTINGS_PLATFORM_ENABLED === "1"] as const,
["/settings/organizations/:path*", process.env.APP_ROUTER_SETTINGS_ORG_ENABLED === "1"] as const,
["/apps/installed/:category", process.env.APP_ROUTER_APPS_INSTALLED_CATEGORY_ENABLED === "1"] as const,
["/apps/:slug", process.env.APP_ROUTER_APPS_SLUG_ENABLED === "1"] as const,
["/apps/:slug/setup", process.env.APP_ROUTER_APPS_SLUG_SETUP_ENABLED === "1"] as const,
@@ -1,20 +0,0 @@
import { _generateMetadata } from "app/_utils";
import { FlagListingView } from "@calcom/features/flags/pages/flag-listing-view";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
export const generateMetadata = async () =>
await _generateMetadata(
() => "Feature Flags",
() => "Here you can toggle your Cal.com instance features."
);
const Page = async () => {
return (
<SettingsHeader title="Feature Flags" description="Here you can toggle your Cal.com instance features.">
<FlagListingView />
</SettingsHeader>
);
};
export default Page;
@@ -1,21 +0,0 @@
import { _generateMetadata } from "app/_utils";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import LockedSMSView from "~/settings/admin/locked-sms-view";
export const generateMetadata = async () =>
await _generateMetadata(
() => "Locked SMS",
() => "Lock or unlock SMS sending for users"
);
const Page = () => {
return (
<SettingsHeader title="Locked SMS" description="Lock or unlock SMS sending for users">
<LockedSMSView />
</SettingsHeader>
);
};
export default Page;
@@ -1,21 +0,0 @@
import { _generateMetadata } from "app/_utils";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import LegacyPage from "~/settings/admin/oauth-view";
export const generateMetadata = async () =>
await _generateMetadata(
() => "OAuth",
() => "Add new OAuth Clients"
);
const Page = () => {
return (
<SettingsHeader title="OAuth" description="Add new OAuth Clients">
<LegacyPage />
</SettingsHeader>
);
};
export default Page;
@@ -1,20 +0,0 @@
import { _generateMetadata } from "app/_utils";
import UsersAddView from "@calcom/features/ee/users/pages/users-add-view";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
export const generateMetadata = async () =>
await _generateMetadata(
() => "Add new user",
() => "Here you can add a new user."
);
const Page = () => {
return (
<SettingsHeader title="Add new user" description="Here you can add a new user">
<UsersAddView />
</SettingsHeader>
);
};
export default Page;
@@ -1,11 +0,0 @@
import { _generateMetadata } from "app/_utils";
import Page from "@calcom/ee/organizations/pages/settings/attributes/attributes-edit-view";
export const generateMetadata = async () =>
await _generateMetadata(
() => "Attribute",
(t) => t("edit_attribute_description")
);
export default Page;
@@ -1,11 +0,0 @@
import { _generateMetadata } from "app/_utils";
import Page from "@calcom/ee/organizations/pages/settings/attributes/attributes-create-view";
export const generateMetadata = async () =>
await _generateMetadata(
() => "Attribute",
() => "Create an attribute for your team members"
);
export default Page;
@@ -1,11 +0,0 @@
import { _generateMetadata } from "app/_utils";
import Page from "@calcom/ee/organizations/pages/settings/attributes/attributes-list-view";
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("attributes"),
(t) => t("attribute_meta_description")
);
export default Page;
@@ -1,5 +0,0 @@
import BillingPage, { generateMetadata } from "../../billing/page";
export { generateMetadata };
export default BillingPage;
@@ -5,7 +5,7 @@ import EditView from "~/settings/platform/oauth-clients/[clientId]/edit/edit-vie
export const generateMetadata = async () =>
await _generateMetadata(
() => "OAuth client updation form",
(t) => t("oAuth_client_updation_form"),
() => ""
);
@@ -5,7 +5,7 @@ import EditWebhooksView from "~/settings/platform/oauth-clients/[clientId]/edit/
export const generateMetadata = async () =>
await _generateMetadata(
() => "OAuth client updation form",
(t) => t("oAuth_client_updation_form"),
() => ""
);
+1
View File
@@ -25,6 +25,7 @@ export function WithLayout<T extends Record<string, any>>({
isBookingPage,
requiresLicense,
}: WithLayoutParams<T>) {
// eslint-disable-next-line react/display-name
return async <P extends "P" | "L">(p: P extends "P" ? PageProps : LayoutProps) => {
const h = headers();
const nonce = h.get("x-nonce") ?? undefined;
@@ -0,0 +1,23 @@
import { _generateMetadata, getFixedT } from "app/_utils";
import { getServerSessionForAppDir } from "@calcom/features/auth/lib/get-server-session-for-app-dir";
import { FlagListingView } from "@calcom/features/flags/pages/flag-listing-view";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("feature_flags"),
(t) => t("admin_flags_description")
);
const Page = async () => {
const session = await getServerSessionForAppDir();
const t = await getFixedT(session?.user.locale || "en");
return (
<SettingsHeader title={t("feature_flags")} description={t("admin_flags_description")}>
<FlagListingView />
</SettingsHeader>
);
};
export default Page;
@@ -0,0 +1,24 @@
import { _generateMetadata, getFixedT } from "app/_utils";
import { getServerSessionForAppDir } from "@calcom/features/auth/lib/get-server-session-for-app-dir";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import LockedSMSView from "~/settings/admin/locked-sms-view";
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("lockedSMS"),
(t) => t("admin_lockedSMS_description")
);
const Page = async () => {
const session = await getServerSessionForAppDir();
const t = await getFixedT(session?.user.locale || "en");
return (
<SettingsHeader title={t("lockedSMS")} description={t("admin_lockedSMS_description")}>
<LockedSMSView />
</SettingsHeader>
);
};
export default Page;
@@ -0,0 +1,24 @@
import { _generateMetadata, getFixedT } from "app/_utils";
import { getServerSessionForAppDir } from "@calcom/features/auth/lib/get-server-session-for-app-dir";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import LegacyPage from "~/settings/admin/oauth-view";
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("oAuth"),
(t) => t("admin_oAuth_description")
);
const Page = async () => {
const session = await getServerSessionForAppDir();
const t = await getFixedT(session?.user.locale || "en");
return (
<SettingsHeader title={t("oAuth")} description={t("admin_oAuth_description")}>
<LegacyPage />
</SettingsHeader>
);
};
export default Page;

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