Compare commits

...
99 Commits
Author SHA1 Message Date
Alex van Andel 90ce124bbb chore: Release v4.3.6 (#16136)
release.yml / release (push) Canceled after 0s
2024-08-08 15:21:10 +00:00
sean-brydonandAlex van Andel fba3f46779 fix: use singleton in license key service (#16131)
* use singleton

* fix typo

* Fix getInstance call

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2024-08-08 15:08:11 +00:00
Morgan b9a1522abc chore: oAuth Client Guard apv2 (#16130)
* chore: oAuh Client Guard apv2

* fixup! chore: oAuh Client Guard apv2

* fixup! Merge branch 'oauth-client-guard-' of github.com:calcom/cal.com into oauth-client-guard-

* fixup! fixup! Merge branch 'oauth-client-guard-' of github.com:calcom/cal.com into oauth-client-guard-

* fixup! fixup! fixup! Merge branch 'oauth-client-guard-' of github.com:calcom/cal.com into oauth-client-guard-
2024-08-08 14:45:22 +02:00
Syed Ali Shahbaz f92a95dc2e feat: Allow cancellation & reschedule on past bookings (#16118)
* option to cancel/reschedule in past bookings

* allows reschedule and cancellation on past bookings

* make headline checks readable

* --

* --

* review feedback
2024-08-08 10:59:57 +00:00
46acac5dd8 fix: Better UI for the setup page, When trying to reach an app that hasn't been already installed under an account (#16121)
* Better UI for the setup page

* Added comment

* Delete packages/prisma/.env

* fixed env file

* Delete yarn.lock

* some changes

* revert yarn.lock

* revert translation and update

* final update

* some improvement

* fix: breaking on small screen, design refactor

---------

Co-authored-by: unknown <adhabal2002@gmail.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>
2024-08-08 12:17:06 +02:00
8dbd96a2f9 fix: Unable to submit booking form if Attendee Phone is used and then booking Fields are saved (#15742)
* update for location identifier

* for typecheck err

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

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

* update to use custom label for location, remove usage of hideWhenOneOption, remove string conversion of booking field

* nit

* nit

* nit

* Use the dataStore only instead of modifying bookingFields directly from outside the FormBuilder

* Dont unset the actual label, instead have a different property noLabel for this

---------

Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>
Co-authored-by: Hariom <hariombalhara@gmail.com>
2024-08-08 14:57:58 +05:30
Joe Au-Yeung 3b186b19e6 fix: SCIM improvements (#16103)
* Add error logging for body

* Use user repository
2024-08-07 12:01:51 -04:00
sean-brydonandPeer Richelsen 53ec5daa1b fix: Hide personal booking theme when in an organization (#16117)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2024-08-07 12:19:34 +00:00
Imamuzzaki Abu SalamandAnik Dhabal Babu 96b58422fd feat: Add upcoming bookings feature to GET /api/bookings endpoint (#16060)
* feat: Add upcoming bookings feature to GET /api/bookings endpoint

- Added support for filtering upcoming bookings by adding the `upcoming` query parameter to the GET /api/bookings endpoint.
- When `upcoming` is set to "true", only upcoming bookings are returned.
- Regular users can now retrieve only their upcoming bookings by setting `upcoming` to "true".
- System-wide admins and organization admins can also retrieve only upcoming bookings by setting `upcoming` to "true".

* feat: Add status filter to booking API endpoint

This commit adds a new `status` filter to the booking API endpoint. The `status` filter allows filtering bookings by their status, overriding the `dateFrom` and `dateTo` filters. The only valid value for the `status` filter is "upcoming".

* Update _get.ts

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-08-07 13:08:04 +02:00
Omar LópezandUdit Takkar 25970e72de fix: uncaught errors in PaymentService (#16104)
* fix: uncaught errors in PaymentService

* fix: uncaught errors in WebhookService (#16105)

* fix: uncaught errors in WebhookService

* fix: uncaught errors in sendPayload (#16106)

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-08-07 10:56:17 +00:00
e5887ec0fa chore: Remove cal.ai email assistant (#16003)
* Delete apps/ai directory

* removed cal-ai app from app store

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2024-08-07 10:02:46 +00:00
Udit Takkar e075dcdeaa feat: create new markNoShow endpoint (#15738)
* feat: create new markNoShow endpoint

* chore: add schema

* chore: update name

* chore: save progress

* refactor: improve code and types

* fix: unit test

* chore: improvements

* chore: move query to booking repository

* fix: type error
2024-08-07 15:16:31 +05:30
Udit Takkar 3ab735ba9c fix: display skeleton loader (#16114)
* fix: display loader

* fix: updated skelton loader
2024-08-07 10:43:14 +01:00
sean-brydon d429f0ee04 fix: use organization default fixedPrice if self-serve/default price is passed in (#15966)
* Use fixed price if default (selfserve) is used

* fix conditional

* use .env instead of hard coded values

* feedback
2024-08-07 09:36:29 +00:00
Syed Ali Shahbaz 3d0a853c4b fix: Remove missing users from assignment (#16115)
* remove missing users from assignment

* early return
2024-08-07 09:03:54 +00:00
Omar López 3a46375fed chore: v4.3.5 (#16096)
release.yml / release (push) Canceled after 0s
2024-08-06 12:42:12 -07:00
sean-brydon 07de77ddc6 Remove feature for now (#16098) 2024-08-06 18:14:12 +00:00
cf45443726 fix: Set user's timezone as fallback to event's timezone (#16065)
* fix: Set user's timezone as fallback to event's timezone

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

* fix: Use find method instead of filter

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

* chore: Remove user default schedule ID check

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

* revert error message

* fix

---------

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2024-08-06 16:06:54 +00:00
Peer Richelsen 629b9391dd Update README.md (#16049) 2024-08-06 16:02:08 +00:00
25da11c497 fix: issue in the event location field (#15229)
* fix: issues in event location field

* Update EventSetupTab.tsx

* Update EventSetupTab.tsx

* update

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Somay Chauhan <somaychauhan98@gmail.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2024-08-06 15:46:08 +00:00
Imamuzzaki Abu Salamandsean-brydon 003e2630e7 chore: Fix typo in CSS class name for signup page and Calendar (#16059)
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
2024-08-06 15:42:02 +00:00
Omar López 4eef7138c1 fix: Wrong tracking event for bookings (#16094) 2024-08-06 15:36:33 +00:00
Pedro Henrique Braga 253d0174be test: Create 5 tests cases for POST /api/event-types endpoint (#16082)
* test: Add tests for POST /api/event-types endpoint

* revert: undo last unintended change in yarn.lock

* revert: undo last unintended change in package.json
2024-08-06 15:36:21 +00:00
Peer RichelsenandAlex van Andel 5ee5c117c4 replaced discord with github discussions (#16092)
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2024-08-06 15:31:32 +00:00
Alex van Andelandsean-brydon 0f268bbef7 chore: add a timeout of 2s to the licence key service (#16027)
* chore: add a timeout of 2s to the licence key service

* Fix unit test

---------

Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
2024-08-06 15:28:47 +00:00
Anik Dhabal Babu d342e3f9e0 fix: type error that is visible now (#16090)
* fix type error

* fix
2024-08-06 15:09:04 +00:00
Vijay 7f1e20fe19 fix: #16041 team eventtype create from api (#16058)
* update for teameventType creation from api

* update to set userid null for team event
2024-08-06 13:48:01 +02:00
Amit Sharma 3078423848 chore: update null noShowHost in Booking to false (#15891) 2024-08-06 13:47:01 +02:00
MohitandAnik Dhabal Babu 9953d73444 fix: incorrect week-start input value (#16057)
* fix: incorrect week start  input value

* fix the issue

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-08-06 13:46:44 +02:00
100136aae7 fix: #15857 add managed by tooltip (#15861)
* managed by tooltip

* updated for team admin

* updated for typechecks and naming

* for multiple admins

* update to show team link when more than 2 admins

* nit/chore : variable naming

* improvement

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: unknown <adhabal2002@gmail.com>
2024-08-06 13:46:24 +02:00
Imamuzzaki Abu Salam 3c6e9aaa63 chore: Refactor CloseComSetup form submission logic (#16062)
The commit refactors the form submission logic in the CloseComSetup component to use the URLSearchParams API for constructing the URL and removes unnecessary code.
2024-08-06 13:43:17 +02:00
Joe Au-YeungandCarinaWolli 8fa39f2ae5 feat: Round robin reassignment via round robin algorithm [CAL-3138] (#14308)
* add reassign host to edit dropdown

* add dialog to reassign rr host

* Design improvements

* add translations

* only show reassign for admin/owner

* add first version of email template

* Init rr assign endpoint

* Pass booking information to rr assign endpoint

* Only allow attendee of booking to reassign themselves

* Send email to reassigned and cancelled member

* On reassign change calendar events

* Add workflows for new host

* On reassign new host - rr

* Fix icon

* Abstract reassignment logic

* Update calendar invite

* Add tests

* Clean up

* Merge with `main`

* Type fixes

* Add back dialog and handle no available hosts

* Handle if rr host is not organizer

* Fix calendar invites when organizer doesn't change

* Clean up

* Clean up

* Type fixes

* Type fixes

* Type fixes

* Type fixes

* Type fix

* Type fixes

* Type fixes

* Add custom responses to evt object

* Type fixes

* Type fix

* Type fix

* Type fixes

* Type fixes

* Type fix

* Type fix

* Update tests

* Type fixes

* Fix tests

* Fix tests

* Add booking repository

* Fix tests

* Fix tests

* Add doesUserIdHaveAccessToBooking for user

* Add check if user is team admin

* Check user permission tRPC route

* Type fixes

* Correct Promise.all

* UI fixes

* UI fixes

* Remove unused assigned hosts prop

* Type fix

* Remove unused frontend code

* Include user priority

* Fallback to event type users for older event types

* Get booking workflow reminder

* Revert back to eventType.hosts

* Fix lint

* Handle changing workflows

* Type fixes

* Type fixes

* Type fix

* Fix tests

* Update new booking imports

* Fix imports

* Type fix

* Type fix

* Fix adding all members to reassignment emails

* Fix cancelled RR emails to show old host

* Ensure consistent event titles

* Send new event workflows to new host

* Change event name if organizer changed

* Fix query error

* Delete old booking reference when reassigning

* Type fixes

* Type fixes

* Fix test

* Rename func isAdminOfTeamOrParentOrg

* Select specific workflow fields

* Address workflow feedback

* Delete const

* Address feedback

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2024-08-06 13:42:46 +02:00
Alex van AndelandKeith Williams 3aca72fb06 chore: Reapply "fix: bookings made via API don't include the specified locat… (#15461)
This reverts commit d8de919acb.

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-08-06 13:41:45 +02:00
Alex van Andel c46905e9a7 fix: next.js console warning due to output:standalone (#16087) 2024-08-06 11:38:24 +00:00
Hariom Balhara 5ce96c7704 fix: Prefill of Phone type option field (#16086) 2024-08-06 11:05:08 +00:00
Lauris Skraucis 23eb77b8ac fix: re-booked booking metadata (#16074)
* fix: rescheduled booking metadata is overwritten by original booking metadata

* chore: bump v2 platform libraries to 0.0.23

* tests: re-scheduled booking metadata update
2024-08-06 11:15:32 +02:00
Alex van Andel e63edc9d36 fix: testId -> testid (#16081)
* fix: testId -> testid

* fix other errors in caps
2024-08-06 11:34:02 +05:30
sean-brydonandPeer Richelsen d33681eddd re-design (#16048)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2024-08-06 01:35:15 +01:00
Dolev Hadarandzomars e7816cfb0c perf: improve typescript perf by pre-generating the router types (#15929)
Co-authored-by: zomars <zomars@me.com>
2024-08-05 16:49:19 -07:00
Omar López a268d8743d chore: upgrades playwright to 1.45.3 (#16034) 2024-08-05 23:38:45 +01:00
45db5c0d5e refactor: event types infinite scroll (#15949)
* chore

* chore

* feat: paginated event types

* fix: event types page

* fix: k bar

* fix: event type order

* fix: type error

* chore: remove commented code

* chore: type err

* chore: type err and feedback

* feat: add old component

* chore: missing import

* fix: add isInfiniteScrollEnabled prop

* chore: type err

* chore: type error

* feat: auto fetch next page when button is in view

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

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

* chore: feedback

* fix: managed event types

* fix: Review fixes for event-types-infinite-scroll (#16047)

* Review fixes

* chore: missing import

---------

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

* chore: type error

* chore

* fix: delete event type

* fix: create event dialog

* fix: create and duplicate dialog

* chore: simplify return type

* fix: type

* chore: invalidate query

* chore: remove query

* fix duplicated event not showing.

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2024-08-05 16:01:03 +04:00
Anik Dhabal BabuandHariom 583661a15d feat: Default to showing the next month with availability (#15292)
* feat: Default to showing the next month with availability

* update

* Update DatePicker.tsx

* dpdate

* Update DatePicker.tsx

---------

Co-authored-by: Hariom <hariombalhara@gmail.com>
2024-08-05 09:31:45 +00:00
Anik Dhabal BabuandHariom Balhara 461d5e0535 feat: Make event duration a dynamic text variable (#15262)
* feat: Make event duration a dynamic text variable

* Update common.json

* final update

* update

* fix type error

* fix unit test

* refactor and update

* For now log the situation when duration isnt a number

---------

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2024-08-05 09:07:46 +00:00
099d830e7e feat: Add URL-type input in booking question field (#15481)
* feat: Add URL-type input in booking question field

* fix type error

* Update common.json

* fix requested changes

* small update

---------

Co-authored-by: Rajiv Sahal <sahalrajiv6900@gmail.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-08-05 12:53:53 +05:30
Morgan add59896f5 fix: platform orgs shouldn't have org redirect (#16032)
* fix: platform orgs shouldn't have custom domains

* fixup! Merge branch 'main' into no-redirect-for-platform-orgs

* fixup! Merge branch 'main' into no-redirect-for-platform-orgs
2024-08-02 20:34:11 +03:00
03b97fdb51 fix: typo in license service + move to a noop (#16029)
* fix/typoe

* use noop

* Update packages/features/ee/common/server/LicenseKeyService.ts

* wip - try unbork e2e

* update conditional

* use e2e value for NOOP resolve value

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2024-08-02 17:10:58 +00:00
Morgan 4c576fbd57 fix: hide org upgrade banner for platform orgs (#16031) 2024-08-02 14:45:59 +02:00
Hariom Balhara 06ce1988f3 fix: Future Limit Calculations (#15825) 2024-08-02 18:00:50 +05:30
Morgan c059d79a51 feat: api v2 webhooks for users and event-types (#15996)
* feat: webhooks for users

* fixup! feat: webhooks for users

* feat: webhooks for user event types

* feat: webhooks for user event types

* fixup! Merge branch 'main' into feat-webhooks-api-v2

* doc

* chore: split webhook service

* chore: webhook repo only depends on prisma

* chore: split webhook outputs

* fixup! chore: split webhook outputs

* chore: describe payload template

* chore: pipe webhook input and output

* chore: use partialType for update dtos

* chore: improve dto
2024-08-02 12:18:35 +00:00
b91c767d0c feat: SysAdmin API Guard for Org Admins (#15867)
* adds column in schema

* adds org setting to enable/disable admin api

* adds guard to v1 v2

* add update query for isAdminAPIEnabled to be true for existing orgs

* adds contact sales

* fix typo

* fix tests

* fixup v2 guard

* v1 fix

* fix and add integration test

* fixes tests --WIP

* fix test --WIP

* fixupgit add src/modules

* WIP

* fix

* fix: e2e test org teams

* fix: e2e test org users

* review feedback

* requested change

* fix

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Morgan Vernay <morgan@cal.com>
2024-08-02 14:35:54 +04:00
sean-brydonandUdit Takkar bb29edd30f add gap (#16000)
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-08-02 09:05:00 +00:00
Omar López b9a8b613bb fix: don't wait for telemetry for booking critical path (#16039) 2024-08-01 17:30:46 -07:00
Morgan 76377d2d2c chore: disconnect prisma on module destroy apiv2 (#16018) 2024-08-01 09:41:50 +00:00
Anik Dhabal Babu f4f09eaf9e fix: bulk update event type dialog logic (#16014)
* improvise code

* bulk update model issue

* Update BulkEditDefaultForEventsModal.tsx
2024-08-01 08:41:41 +03:00
Omar López 89c08868f6 chore: v4.3.4 (#16010)
release.yml / release (push) Canceled after 0s
2024-07-31 21:01:49 +00:00
Omar López ebab3b701d revert: "fix: meeting link is not updated and broken ui in edit location dialog" (#16011) 2024-07-31 23:16:45 +03:00
Anik Dhabal BabuandKeith Williams 9ffa4b8477 fix: meeting link is not updated and broken ui in edit location dialog (#15503)
* fix: meeting link is not updated

* fix: broken ui in edit location dialog

* Update editLocation.handler.ts

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-07-31 19:05:20 +00:00
Amit Sharma c656063b06 fix: incorrect variable (#16002) 2024-07-31 13:48:32 +00:00
Hariom BalharaandUdit Takkar 5b7f2a6ffc fix: Reschedule URL for an org migrated/moved user's booking. (#15736)
* Fix reschedule URl

* Add e2e for org

* Add team booking reschedule test

* Add unit tests

* Remove unnecessary select

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-07-31 14:25:46 +02:00
Imamuzzaki Abu SalamandAmit Sharma 63c7e4b55a test: add tests for components/editor (#15938)
* test: add VariableNode test suite

* test: add ToolbarPlugin test suite

* test: add AutoLinkPlugin test suite

* test: add AddVariablesDropdown test suite

* test: add Editor test suite

* chore: Remove unnecessary comments in ToolbarPlugin.test.tsx

---------

Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>
2024-07-31 14:25:00 +02:00
Carina WollendorferandCarinaWolli 24e5226e3e fix: issues with verified emails in workflow (#15965)
* fix verified emails

* review feedback

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2024-07-31 11:58:14 +00:00
sean-brydon 9f43c5f1e7 add row on click if user can edit (#15998) 2024-07-31 11:12:48 +00:00
MohitandAnik Dhabal Babu b48e3c7bb4 fix: alert message is not positioned properly (#15995)
* fix: correct button functionality in alert component

* add: bottom margin to alert component

* revert alert comp changes

* small update

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-07-31 00:23:11 +03:00
Morgan a561b805fd chore: add doc + test reschedule booking api v1 (#15907)
* chore: add doc + test reschedule booking api v1

* fixup! Merge branch 'main' into fix-reschedule-booking-api-v1
2024-07-31 00:22:25 +03:00
VijayandAnik Dhabal Babu d21c91c519 fix: #15989 disable country prefill in phone input (#15991)
* default country disabled in phone input

* Update PhoneInput.tsx

* update event-types e2e

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-07-31 00:20:37 +03:00
Lauris SkraucisandMorgan 41e565fcf8 refactor: platform org event types (#15961)
* chore: platform-libraries export updateNewTeamMemberEventTypes

* chore: platform-libraries export updateNewTeamMemberEventTypes

* chore: v2 api bump platform-libraries to 0.0.22

* fix: add created managed user to assignAllMembers: true team event-types

* fix: add created organizations user to assignAllMembers: true team event-types

* refactor: organization teams chose if team creator is auto-accepted

* refactor: managed users have only 2 default event types with video

* refactor: lowercase schedulingType for creating team event-types

* refactor: lowercase schedulingType for creating team event-types

* feat: separate platform teams by OAuth client

* swagger regenerate

* refactor: delete platform team when oauth client deleted

* revert: keep 4 default event-types

* fix: tests && merge of main into this branch

* fix: typecheck

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2024-07-30 22:23:31 +03:00
Lauris SkraucisandMorgan 2f946fc5da feat: platform team event-types (#15928)
* fix: dont check OrganizationSettings.orgAutoAcceptEmail uniqueness for platform teams

* feat: display organizationId in OAuthClientCard

* feat: ApiAuthStrategy handle oauth credentials

* refactor: clean up ApiAuthStrategy test

* refactor: use ApiAuthGuard in OauthClientsUsersController and OAuthFlowController

* fix: copying org id

* refactor: more specific error message when api auth

* refactor: auto accept team creator membership

* feat: useTeamEventType and useTeamEventTypes hooks

* refactor: make team event-types public & searchable by eventSlug

* feat: include host name in team event-types hosts response

* fix: isFixed=true by default for COLLECTIVE event hosts

* fix: useTeamEventType eventSlug access

* feat: BookerPlatformWrapper enable team events by exposting orgId and teamId props

* refactor: provide orgId in atoms context

* refactor: use orgId from context in team event-types hooks

* refactor: return teams in useMe

* chore: examples app teams setup

* Revert "refactor: return teams in useMe"

This reverts commit de992ddc9af6ee9a2111938069f5b9c34cc2d8ea.

* Revert "chore: examples app teams setup"

This reverts commit 0766aa21acc25efa2361d38c3f87ddba773a0245.

* feat: useTeams hook

* chore: setup examples app with team event-type

* fix: small fixes

* swagger

* Revert "refactor: provide orgId in atoms context"

This reverts commit f053a498ee6f8fa8ece5ec8d8630c59eda8873e3.

* feat: orgId in atoms context

* feat: PlatformBilling guard

* chore: delete test of the deleted oauth-client-credentials.guard

* refactor: org event-types collective events isFixed always true and priority medium

* refactor: org event-types COLLECTIVE response ignore isFixed and priority as they are same

* fix: organizations event-types e2e

* fix: billing guard

* refactor: tests cleanup

* fix: platform plan guard spec

* refactor: e2e test cleanup

* seed error if not team

* refactor: rename authenticateApiKey to authenticateBearerToken

* refactor: transforming response hosts

* refactor: rename findUniqueByMatchingAutoAcceptEmail to findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail

* refactor: rename findUniqueByMatchingAutoAcceptEmail to findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2024-07-30 16:58:39 +02:00
06bb7055ac fix: added translations for connect atoms (#15969)
* fix: added translations for connect atoms

* fix: already spelling

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-07-30 14:34:05 +00:00
Peer Richelsen 1a8492b9c2 chore: fixed a few destructive buttons (#15990) 2024-07-30 16:24:11 +03:00
b8ba95dbb0 feat: min max chars for long text (#15691)
* feat: min max chars for long text

* refactor: remove console log

* refactor: revert redundant change

* refactor: review changes

* fix: min max validation

* fix: fix typo

* refactor: update comments

* refactor: review changes

* refactor: review changes

* test: Add test

* Make maxLength more configurable per field type

* Reduce maxLength to 1000

* Update update-event-type.input.ts

* revert changes in main

* Update update-event-type.input.ts

* refactor get-event-type-public.output.ts

* add e2e tests

* add min max char common.json

* Update FormBuilder.tsx

* refactor changes

* Update fieldTypes.ts

* Update schema.ts

* add min max length in api-request

* Update booking-fields.input.ts

* fix e2e tests

* move min-max-char tests to manage-booking-questions.e2e.ts

* remove test

* reorder imports to fix lint

* fix lint

* fix e2e tests

* Abstract out SupportsLengthcheck type Field

* Revert newline change

* Improve the schema validation a bit and fix undefined in the message issue

* revert: v2 API changes

---------

Co-authored-by: Ayush Mhetre <mhetreayush1719@gmail.com>
Co-authored-by: Hariom <hariombalhara@gmail.com>
Co-authored-by: supalarry <laurisskraucis@gmail.com>
2024-07-30 12:21:27 +00:00
40df4cf511 fix: Add strict checks for email to create an organization (#15976)
* Verify email first

* Only allow company emails for org creation

---------

Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2024-07-30 11:52:10 +00:00
58cabe974c fix: Add signup disabled flag check on signup route (#15844)
* fix: Add signup disabled flag check on signup api

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

* feat: Implement a feature flag check trcp middleware

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

* Discard changes to packages/trpc/server/middlewares/featureFlagMiddleware.ts

* Discard changes to packages/trpc/server/routers/viewer/organizations/_router.tsx

---------

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: Omar López <zomars@me.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2024-07-30 11:26:20 +00:00
Udit TakkarandJoe Au-Yeung 199d95b35f feat: cal ai templates (#15391)
* chore: save progress

* feat: add schema and fieds

* chore: update code

* chore: save

* refactor: code and type error

* feat: use facade + command design pattern

* chore: missing param

* feat: add custom template feature

* refactor: code

* chore: move the default prompt

* fix: type err

* fix: code and ux improvements

* chore: change default template

* fix: ui and typo

* fix: add validation check for my phone number

---------

Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
2024-07-30 13:09:12 +02:00
VijayandAnik Dhabal Babu c6febad364 fix: #15968 update to show booking question variables in Available variables (#15981)
* update to show booking question variables in Available variables

* small fix

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-07-30 08:46:44 +00:00
Morgan 0ddcc068ce fix: bullMq redis connection string apiv2 (#15970)
* fix: bullMq redis connection string apiv2

* fixup! fix: bullMq redis connection string apiv2
2024-07-30 08:27:27 +00:00
Anik Dhabal BabuandSomay Chauhan 3ff3a97c78 feat: ability to provide a reason for event rejection (from email) and some changes (#15098)
* feat: ability to provide reason for event rejection from email

* update

---------

Co-authored-by: Somay Chauhan <somaychauhan98@gmail.com>
2024-07-30 09:35:02 +02:00
MorganandKeith Williams baaa1fa71e chore: add global rate limiting to prevent abuse apiv2 (#15971)
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-07-30 06:34:31 +00:00
27d331ad2d fix: #15967 Show better error when wrong variable is used in custom event name (#15974)
* update to show exact err msg with custom event name

* updated tests for validateCustomEventName

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-07-29 20:23:53 +03:00
Joe Au-YeungandAnik Dhabal Babu 9a1a86ed3a test: Delete credential endpoint (#15322)
* Init

* Add mock trpc context

* Intro mockDatabaseClient

* Introduce mockDatabaseClient class

* Add delete video app test

* Add calendar test

* Remove unused func from bookingScenario

* Remove console.log

* Add app repository

* Add createMany method to event type repository

* Remove instance of MockDatabaseClient from video test

* Add destination calendar repository

* Remove instances of MockDatabaseClient

* abstract logic to own handler

* Remove dev dependency

* Clean up

* Type fix

* Pull yarn.lock from main

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-07-29 11:38:58 -04:00
Keith Williams 7902c6157d fix: Broken GitHub workflows (#15979) 2024-07-29 17:35:52 +03:00
sean-brydonandHariom Balhara 92d0fc6eb4 fix: auto accept (#15975)
* fix: auto accept

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

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

---------

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2024-07-29 14:18:55 +00:00
Carina WollendorferandCarinaWolli 4d0b417925 fix: type in webhook payload (#15912)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2024-07-29 13:48:45 +00:00
Keith Williams 9947b5ec85 fix: Next.js bundle analysis (#15903)
* fix: Next.js bundle analysis

* Added the main checkout back
2024-07-29 13:47:50 +00:00
fa06f38d47 fix: refresh connect atoms on token change (#15933)
* added dropdown

* change comment

* fix: added types

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-07-29 16:31:31 +03:00
Lauris SkraucisandKeith Williams 99a6b1c7eb refactor: v1 orgs unlimited teams (#15908)
* refactor: v1 orgs unlimited teams

* fix: response message ownerId

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-07-29 16:31:18 +03:00
e3861ad22a feat: No-show bookings - do not count towards a member's round robin assignment (#15323)
* feat: No-show bookings - do not count towards a member's round robin assignment

* filter no show organizer

* remove logs

* fix: filter bookings where host attendee is no show

* fix: use not condition for noShow

* chore: test

* add default false for noShowHost

* Revert "chore: test"

This reverts commit c914a8fc4a14ca9c7d8c4a08ff8b0f463789171c.

* chore: integration test

* fix: noShowHost not working for null

* Update packages/lib/server/getLuckyUser.ts

* chore: update test description

---------

Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: gh0st91848 <gh0st91848@gmail.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2024-07-29 16:05:03 +03:00
Hariom BalharaandKeith Williams 37caa57798 fix: Add CORP and COEP headers to embedded pages (#15275)
* Add CORP headers

* Improve test

* Add unit tests as well

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-07-29 11:54:31 +02:00
6e66f7f8f8 fix: Assignment reminder modal appears when deleting a team event type (#15544)
* fix: Assignment reminder modal appears when deleting a team event type

* update

* Update EventTypeSingleLayout.tsx

* Update event-types-single-view.tsx

* update

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

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Hariom <hariombalhara@gmail.com>
2024-07-29 09:52:03 +00:00
Hichem FantarandHariom Balhara 62dd5ea95b feat: Add title attribute to iframe in embed.ts (#15944)
* feat: Add title attribute to iframe in embed.ts

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

---------

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2024-07-29 08:14:13 +00:00
Johannes Maendle 365dfe9178 i18n: update translations for "by proceeding, ..." (#15921) 2024-07-29 06:04:33 +00:00
Hichem FantarandKeith Williams 4d89da99a7 feat: pwa ready and add shortcut for quick access (#15957)
* chore: Update site.webmanifest with additional display properties

* chore: Update site.webmanifest with additional display properties

* Update apps/web/public/site.webmanifest

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-07-29 05:37:31 +00:00
Souptik DattaandKeith Williams 6954ad1ea5 fix: Change minimum number of teams required for org self-serve (#15955)
* fix: Change minimum number of teams required for org self-serve

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

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

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

* Update packages/lib/constants.ts

* Update turbo.json

---------

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-07-29 05:17:17 +00:00
Hichem Fantar 825573da8e feat: add transitions to more components (#15916)
* add transitions to more components

* chore: Remove duplicate class
2024-07-29 08:06:44 +03:00
2c517f4878 fix: getting an error when a team member tries to leave (#15925)
a team

Co-authored-by: Somay Chauhan <somaychauhan98@gmail.com>
Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>
2024-07-29 07:21:56 +03:00
VijayandAnik Dhabal Babu 8508a6f1a0 fix: #15697 Assignment warning modal poping up ,even when hosts are added . (#15699)
* removed reduntand check on assignedusers

* update for managed eventtype

* formatted

* comments

* Updated comments

* extracted checkForEmptyAssignment and added tests

* updated comments

* updated to show dialog also when not yet saved

* update translations

* updated comments

* updated translation

* updated types

* chore: update naming and comments

* Delete apps/web/lib/checkForEmptyAssignment.ts

deleted since file was renamed

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

deleted as the file is renamed to be more descriptive

* update to do teamcheck before

* chore: revert name to CheckForEmptyAssignment

* updated check conditions

* updated useEffect dependencies

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-07-29 07:02:59 +03:00
db03e9db20 style: update text area (#15934)
* chore: update text-editor input

* chore: update text-editor input

* final update

* Update Editor.tsx

---------

Co-authored-by: unknown <adhabal2002@gmail.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-07-29 06:51:53 +03:00
Peer Richelsen e6a16f5b5c chore: Update feature_request.md (#15926)
* Update feature_request.md

* Update feature_request.md
2024-07-26 12:49:19 +00:00
b94aa603c5 chore: Add missing comments for self hosted billing (#15923)
* WIP: UI created to see if it matches RFC idea

* Remove schema as we validate on the backend with old+new key schemas

* Add comment about proxy URL request when on older versions

* feat: create custom axios instance to use signature middleware

* fix: update form to respect new schema

* Add cal signature token to trubo env

* use backend mutation for creating license keys

* add loading state to creation button

* update form to have a success state

* fix type error for console.warning :')

* use api version v1

* Inital setup

* Fix verifyApi Key tests

* Implement tests for LicenseKeyService

* Restore old checkLicense code

* check license key first

* fix imports

* fix url in tests. Implement prisma extention to track creation

* Use data.status for licenseCheck

* extract fetch to class

* improve error

* [wip] per user tracking

* Remove new URL as it was breaking search params

* Use client extention in customPrismaClient

* Remove log

* Remove license required from creation form as itll fail

* Fix annual price calculation

* add api route to .env.example and constants

* re-word instructions in .env.example

* chore: feedback

* Update packages/features/ee/common/server/LicenseKeyService.ts

* Add IS_E2E_ENABLED constant + update tests

* Revert "Add IS_E2E_ENABLED constant + update tests"

This reverts commit d059b73f6272bc06fd2ab5ed505b6d52ea030a56.

* chore: tests fixes

* fixes: unit tests

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

* chore: add missing instructions

---------

Signed-off-by: zomars <zomars@me.com>
Co-authored-by: sean-brydon <sean@cal.com>
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
2024-07-26 10:10:46 +00:00
31e6b88951 fix: Fix teams helper image background color mode (#15918)
Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>
Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-07-26 10:25:39 +02:00
483 changed files with 15226 additions and 5441 deletions
+6
View File
@@ -18,6 +18,10 @@
# To enable enterprise-only features please add your envrionment 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
CAL_SIGNATURE_TOKEN=
# The route to the Cal.com License API
CALCOM_PRIVATE_API_ROUTE="https://goblin.cal.com"
# ***********************************************************************************************************
@@ -255,6 +259,8 @@ NEXT_PUBLIC_AVAILABILITY_SCHEDULE_INTERVAL=
# This variable should only be set to 1 or true if you are in a non-prod environment and you want to
# use organizations
ORGANIZATIONS_ENABLED=
NEXT_PUBLIC_ORGANIZATIONS_MIN_SELF_SERVE_SEATS=30
NEXT_PUBLIC_ORGANIZATIONS_SELF_SERVE_PRICE=3700 # $37.00 per seat
# This variable should only be set to 1 or true if you want to autolink external provider sing-ups with
# existing organizations based on email domain address
+53 -38
View File
@@ -1,41 +1,56 @@
---
name: Feature request
description: "Suggest an idea for this project \U0001F680"
about: Suggest a feature or idea
title: ""
labels: ["✨ feature", "🚨 needs approval"],
assignees: []
body:
- type: textarea
id: problem-description
attributes:
label: Is your feature request related to a problem? Please describe.
description: A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
validations:
required: true
- type: textarea
id: solution-description
attributes:
label: Describe the solution you'd like
description: A clear and concise description of what you want to happen.
validations:
required: true
- type: textarea
id: alternate-solution-description
attributes:
label: Describe alternatives you've considered
description: A clear and concise description of any alternative solutions or features you've considered.
validations:
required: false
- type: textarea
id: additional-context
attributes:
label: Additional context
description: Add any other context or screenshots about the feature request here.
validations:
required: false
- type: markdown
id: cal-info
attributes:
value: |
### How we code at Cal.com
labels: ["✨ feature", "🚨 needs approval"]
assignees: ""
---
- Follow Best Practices lined out in our [Contributor Docs](https://github.com/calcom/cal.com/blob/main/CONTRIBUTING.md)
### Is your proposal related to a problem?
<!--
Provide a clear and concise description of what the problem is.
For example, "I'm always frustrated when..."
-->
(Write your answer here.)
### Describe the solution you'd like
<!--
Provide a clear and concise description of what you want to happen.
-->
(Describe your proposed solution here.)
### Describe alternatives you've considered
<!--
Let us know about other solutions you've tried or researched.
-->
(Write your answer here.)
### Additional context
<!--
Is there anything else you can add about the proposal?
You might want to link to related issues here, if you haven't already.
-->
(Write your answer here.)
### Requirement/Document
<!--
Is there any type of document that could support that feature?
-->
(Share it here.)
---
##### House rules
- If this issue has a `🚨 needs approval` label, don't start coding yet. Wait until a core member approves feature request by removing this label, then you can start coding.
- For clarity: Non-core member issues automatically get the `🚨 needs approval` label.
- Your feature ideas are invaluable to us! However, they undergo review to ensure alignment with the product's direction.
- Follow Best Practices lined out in our [Contributor Docs](https://github.com/calcom/cal.com/blob/main/CONTRIBUTING.md)
-6
View File
@@ -69,12 +69,6 @@ jobs:
uses: ./.github/workflows/e2e-embed-react.yml
secrets: inherit
analyze:
name: Analyze Build
needs: [build]
uses: ./.github/workflows/nextjs-bundle-analysis.yml
secrets: inherit
required:
needs: [lint, type-check, unit-test, integration-test, build, build-api-v1, build-api-v2, e2e, e2e-embed, e2e-embed-react, e2e-app-store]
if: always()
@@ -0,0 +1,81 @@
name: "Next.js Bundle Analysis Annotation"
on:
workflow_call:
workflow_dispatch:
permissions:
actions: read
contents: read
pull-requests: write
jobs:
annotate:
if: always()
runs-on: buildjet-2vcpu-ubuntu-2204
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/yarn-install
- uses: ./.github/actions/cache-build
- name: Download base branch bundle stats
uses: dawidd6/action-download-artifact@v2
if: success()
with:
workflow: nextjs-bundle-analysis.yml
branch: ${{ github.event.pull_request.base.ref }}
path: apps/web/.next/analyze/base
# And here's the second place - this runs after we have both the current and
# base branch bundle stats, and will compare them to determine what changed.
# There are two configurable arguments that come from package.json:
#
# - budget: optional, set a budget (bytes) against which size changes are measured
# it's set to 350kb here by default, as informed by the following piece:
# https://infrequently.org/2021/03/the-performance-inequality-gap/
#
# - red-status-percentage: sets the percent size increase where you get a red
# status indicator, defaults to 20%
#
# Either of these arguments can be changed or removed by editing the `nextBundleAnalysis`
# entry in your package.json file.
- name: Compare with base branch bundle
if: success()
run: |
cd apps/web
ls -laR .next/analyze/base && npx -p nextjs-bundle-analysis compare
- name: Get comment body
id: get-comment-body
if: success() && github.event.number
run: |
cd apps/web
body=$(cat .next/analyze/__bundle_analysis_comment.txt)
body="${body//'%'/'%25'}"
body="${body//$'\n'/'%0A'}"
body="${body//$'\r'/'%0D'}"
echo ::set-output name=body::$body
- name: Find Comment
uses: peter-evans/find-comment@v2
if: success() && github.event.number
id: fc
with:
issue-number: ${{ github.event.number }}
body-includes: "<!-- __NEXTJS_BUNDLE_@calcom/web -->"
- name: Create Comment
uses: peter-evans/create-or-update-comment@v3
if: success() && github.event.number && steps.fc.outputs.comment-id == 0
with:
issue-number: ${{ github.event.number }}
body: ${{ steps.get-comment-body.outputs.body }}
- name: Update Comment
uses: peter-evans/create-or-update-comment@v3
if: success() && github.event.number && steps.fc.outputs.comment-id != 0
with:
issue-number: ${{ github.event.number }}
body: ${{ steps.get-comment-body.outputs.body }}
comment-id: ${{ steps.fc.outputs.comment-id }}
edit-mode: replace
@@ -67,65 +67,3 @@ jobs:
with:
name: bundle
path: apps/web/.next/analyze/__bundle_analysis.json
- name: Download base branch bundle stats
uses: dawidd6/action-download-artifact@v2
if: success()
with:
workflow: nextjs-bundle-analysis.yml
branch: ${{ github.event.pull_request.base.ref }}
path: apps/web/.next/analyze/base
# And here's the second place - this runs after we have both the current and
# base branch bundle stats, and will compare them to determine what changed.
# There are two configurable arguments that come from package.json:
#
# - budget: optional, set a budget (bytes) against which size changes are measured
# it's set to 350kb here by default, as informed by the following piece:
# https://infrequently.org/2021/03/the-performance-inequality-gap/
#
# - red-status-percentage: sets the percent size increase where you get a red
# status indicator, defaults to 20%
#
# Either of these arguments can be changed or removed by editing the `nextBundleAnalysis`
# entry in your package.json file.
- name: Compare with base branch bundle
if: success()
run: |
cd apps/web
ls -laR .next/analyze/base && npx -p nextjs-bundle-analysis compare
- name: Get comment body
id: get-comment-body
if: success() && github.event.number
run: |
cd apps/web
body=$(cat .next/analyze/__bundle_analysis_comment.txt)
body="${body//'%'/'%25'}"
body="${body//$'\n'/'%0A'}"
body="${body//$'\r'/'%0D'}"
echo ::set-output name=body::$body
- name: Find Comment
uses: peter-evans/find-comment@v2
if: success() && github.event.number
id: fc
with:
issue-number: ${{ github.event.number }}
body-includes: "<!-- __NEXTJS_BUNDLE_@calcom/web -->"
- name: Create Comment
uses: peter-evans/create-or-update-comment@v3
if: success() && github.event.number && steps.fc.outputs.comment-id == 0
with:
issue-number: ${{ github.event.number }}
body: ${{ steps.get-comment-body.outputs.body }}
- name: Update Comment
uses: peter-evans/create-or-update-comment@v3
if: success() && github.event.number && steps.fc.outputs.comment-id != 0
with:
issue-number: ${{ github.event.number }}
body: ${{ steps.get-comment-body.outputs.body }}
comment-id: ${{ steps.fc.outputs.comment-id }}
edit-mode: replace
+2 -4
View File
File diff suppressed because one or more lines are too long
-21
View File
@@ -1,21 +0,0 @@
BACKEND_URL=http://localhost:3002/api
# BACKEND_URL=https://api.cal.com/v1
FRONTEND_URL=http://localhost:3000
# FRONTEND_URL=https://cal.com
APP_ID=cal-ai
APP_URL=http://localhost:3000/apps/cal-ai
# This is for the onboard route. Which domain should we send emails from?
SENDER_DOMAIN=cal.ai
# Used to verify requests from sendgrid. You can generate a new one with: `openssl rand -hex 32`
PARSE_KEY=
OPENAI_API_KEY=
# Optionally trace completions at https://smith.langchain.com
# LANGCHAIN_TRACING_V2=true
# LANGCHAIN_ENDPOINT=
# LANGCHAIN_API_KEY=
# LANGCHAIN_PROJECT=
-68
View File
@@ -1,68 +0,0 @@
# Cal.ai
Welcome to [Cal.ai](https://cal.ai)!
This app lets you chat with your calendar via email:
- Turn informal emails into bookings eg. forward "wanna meet tmrw at 2pm?"
- List and rearrange your bookings eg. "clear my afternoon"
- Answer basic questions about your busiest times eg. "how does my Tuesday look?"
The core logic is contained in [agent/route.ts](/apps/ai/src/app/api/agent/route.ts). Here, a [LangChain Agent Executor](https://docs.langchain.com/docs/components/agents/agent-executor) is tasked with following your instructions. Given your last-known timezone, working hours, and busy times, it attempts to CRUD your bookings.
_The AI agent can only choose from a set of tools, without ever seeing your API key._
Emails are cleaned and routed in [receive/route.ts](/apps/ai/src/app/api/receive/route.ts) using [MailParser](https://nodemailer.com/extras/mailparser/).
Incoming emails are routed by email address. Addresses are verified by [DKIM record](https://support.google.com/a/answer/174124?hl=en), making them hard to spoof.
## Recognition
<a href="https://www.producthunt.com/posts/cal-ai?utm_source=badge-top-post-badge&utm_medium=badge&utm_souce=badge-cal&#0045;ai" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/top-post-badge.svg?post_id=419860&theme=light&period=daily" alt="Cal&#0046;ai - World&#0039;s&#0032;first&#0032;open&#0032;source&#0032;AI&#0032;scheduling&#0032;assistant | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a> <a href="https://www.producthunt.com/posts/cal-ai?utm_source=badge-featured&utm_medium=badge&utm_souce=badge-cal&#0045;ai" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=419860&theme=light" alt="Cal&#0046;ai - World&#0039;s&#0032;first&#0032;open&#0032;source&#0032;AI&#0032;scheduling&#0032;assistant | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
## Getting Started
### Development
If you haven't yet, please run the [root setup](/README.md) steps.
Before running the app, please see [env.mjs](./src/env.mjs) for all required environment variables. Run `cp .env.example .env` in this folder to get started. You'll need:
- An [OpenAI API key](https://platform.openai.com/account/api-keys) with access to GPT-4
- A [SendGrid API key](https://app.sendgrid.com/settings/api_keys)
- A default sender email (for example, `me@dev.example.com`)
- The Cal.ai app's ID and URL (see [add.ts](/packages/app-store/cal-ai/api/index.ts))
- A unique value for `PARSE_KEY` with `openssl rand -hex 32`
To stand up the API and AI apps simultaneously, simply run `yarn dev:ai`.
### Agent Architecture
The scheduling agent in [agent/route.ts](/apps/ai/src/app/api/agent/route.ts) calls an LLM (in this case, GPT-4) in a loop to accomplish a multi-step task. We use an [OpenAI Functions agent](https://js.langchain.com/docs/modules/agents/agent_types/openai_functions_agent), which is fine-tuned to output text suited for passing to tools.
Tools (eg. [`createBooking`](/apps/ai/src/tools/createBooking.ts)) are simply JavaScript methods wrapped by Zod schemas, telling the agent what format to output.
Here is the full architecture:
![Cal.ai architecture](/apps/ai/src/public/architecture.png)
### Email Router
To expose the AI app, you can use either [Tunnelmole](https://github.com/robbie-cahill/tunnelmole-client), an open source tunnelling tool; or [nGrok](https://ngrok.com/), a popular closed source tunnelling tool.
For Tunnelmole, run `tmole 3005` (or the AI app's port number) in a new terminal. Please replace `3005` with the port number if it is different. In the output, you'll see two URLs, one http and a https (we recommend using the https url for privacy and security). To install Tunnelmole, use `curl -O https://install.tunnelmole.com/8dPBw/install && sudo bash install`. (On Windows, download [tmole.exe](https://tunnelmole.com/downloads/tmole.exe))
For nGrok, run `ngrok http 3005` (or the AI app's port number) in a new terminal. You may need to install nGrok first.
To forward incoming emails to the serverless function at `/agent`, we use [SendGrid's Inbound Parse](https://docs.sendgrid.com/for-developers/parsing-email/setting-up-the-inbound-parse-webhook).
1. Ensure you have a [SendGrid account](https://signup.sendgrid.com/)
2. Ensure you have an authenticated domain. Go to Settings > Sender Authentication > Authenticate. For DNS host, select `I'm not sure`. Click Next and add your domain, eg. `example.com`. Choose Manual Setup. You'll be given three CNAME records to add to your DNS settings, eg. in [Vercel Domains](https://vercel.com/dashboard/domains). After adding those records, click Verify. To troubleshoot, see the [full instructions](https://docs.sendgrid.com/ui/account-and-settings/how-to-set-up-domain-authentication).
3. Authorize your domain for email with MX records: one with name `[your domain].com` and value `mx.sendgrid.net.`, and another with name `bounces.[your domain].com` and value `feedback-smtp.us-east-1.amazonses.com`. Set the priority to `10` if prompted.
4. Go to Settings > [Inbound Parse](https://app.sendgrid.com/settings/parse) > Add Host & URL. Choose your authenticated domain.
5. In the Destination URL field, use the Tunnelmole or ngrok URL from above along with the path, `/api/receive`, and one param, `parseKey`, which lives in [this app's .env](/apps/ai/.env.example) under `PARSE_KEY`. The full URL should look like `https://abc.tunnelmole.net/api/receive?parseKey=ABC-123` or `https://abc.ngrok.io/api/receive?parseKey=ABC-123`.
6. Activate "POST the raw, full MIME message".
7. Send an email to `[anyUsername]@example.com`. You should see a ping on the Tunnelmole or ngrok listener and server.
8. Adjust the logic in [receive/route.ts](/apps/ai/src/app/api/receive/route.ts), save to hot-reload, and send another email to test the behaviour.
Please feel free to improve any part of this architecture!
-5
View File
@@ -1,5 +0,0 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.
-24
View File
@@ -1,24 +0,0 @@
const withBundleAnalyzer = require("@next/bundle-analyzer");
const plugins = [];
plugins.push(withBundleAnalyzer({ enabled: process.env.ANALYZE === "true" }));
/** @type {import("next").NextConfig} */
const nextConfig = {
async redirects() {
return [
{
source: "/",
destination: "https://cal.com/ai",
permanent: true,
},
];
},
i18n: {
defaultLocale: "en",
locales: ["en"],
},
reactStrictMode: true,
};
module.exports = () => plugins.reduce((acc, next) => next(acc), nextConfig);
-28
View File
@@ -1,28 +0,0 @@
{
"name": "@calcom/ai",
"version": "1.2.1",
"private": true,
"author": "Cal.com Inc.",
"dependencies": {
"@calcom/prisma": "*",
"@langchain/core": "^0.1.26",
"@langchain/openai": "^0.0.14",
"@t3-oss/env-nextjs": "^0.6.1",
"langchain": "^0.1.17",
"mailparser": "^3.6.5",
"next": "^13.5.4",
"supports-color": "8.1.1",
"zod": "^3.22.4"
},
"devDependencies": {
"@types/mailparser": "^3.4.0"
},
"scripts": {
"build": "next build",
"dev": "next dev -p 3005",
"format": "npx prettier . --write",
"lint": "eslint . --ext .ts,.js,.tsx,.jsx",
"lint:fix": "eslint . --ext .ts,.js,.tsx,.jsx --fix",
"start": "next start"
}
}
-55
View File
@@ -1,55 +0,0 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import agent from "../../../utils/agent";
import sendEmail from "../../../utils/sendEmail";
import { verifyParseKey } from "../../../utils/verifyParseKey";
// Allow agent loop to run for up to 5 minutes
export const maxDuration = 300;
/**
* Launches a LangChain agent to process an incoming email,
* then sends the response to the user.
*/
export const POST = async (request: NextRequest) => {
const verified = verifyParseKey(request.url);
if (!verified) {
return new NextResponse("Unauthorized", { status: 401 });
}
const json = await request.json();
const { apiKey, userId, message, subject, user, users, replyTo: agentEmail } = json;
if ((!message && !subject) || !user) {
return new NextResponse("Missing fields", { status: 400 });
}
try {
const response = await agent(`${subject}\n\n${message}`, { ...user }, users, apiKey, userId, agentEmail);
// Send response to user
await sendEmail({
subject: `Re: ${subject}`,
text: response.replace(/(?:\r\n|\r|\n)/g, "\n"),
to: user.email,
from: agentEmail,
});
return new NextResponse("ok");
} catch (error) {
await sendEmail({
subject: `Re: ${subject}`,
text: "Thanks for using Cal.ai! We're experiencing high demand and can't currently process your request. Please try again later.",
to: user.email,
from: agentEmail,
});
return new NextResponse(
(error as Error).message || "Something went wrong. Please try again or reach out for help.",
{ status: 500 }
);
}
};
-44
View File
@@ -1,44 +0,0 @@
import type { NextRequest } from "next/server";
import prisma from "@calcom/prisma";
import { env } from "../../../env.mjs";
import sendEmail from "../../../utils/sendEmail";
export const POST = async (request: NextRequest) => {
const { userId } = await request.json();
const user = await prisma.user.findUnique({
select: {
email: true,
name: true,
username: true,
},
where: {
id: userId,
},
});
if (!user) {
return new Response("User not found", { status: 404 });
}
await sendEmail({
subject: "Welcome to Cal AI",
to: user.email,
from: `${user.username}@${env.SENDER_DOMAIN}`,
text: `Hi ${
user.name || `@${user.username}`
},\n\nI'm Cal AI, your personal booking assistant! I'll be here, 24/7 to help manage your busy schedule and find times to meet with the people you care about.\n\nHere are some things you can ask me:\n\n- "Book a meeting with @someone" (The @ symbol lets you tag Cal.com users)\n- "What meetings do I have today?" (I'll show you your schedule)\n- "Find a time for coffee with someone@gmail.com" (I'll intro and send them some good times)\n\nI'm still learning, so if you have any feedback, please tweet it to @calcom!\n\nRemember, you can always reach me here, at ${
user.username
}@${
env.SENDER_DOMAIN
}.\n\nLooking forward to working together (:\n\n- Cal AI, Your personal booking assistant`,
html: `Hi ${
user.name || `@${user.username}`
},<br><br>I'm Cal AI, your personal booking assistant! I'll be here, 24/7 to help manage your busy schedule and find times to meet with the people you care about.<br><br>Here are some things you can ask me:<br><br>- "Book a meeting with @someone" (The @ symbol lets you tag Cal.com users)<br>- "What meetings do I have today?" (I'll show you your schedule)<br>- "Find a time for coffee with someone@gmail.com" (I'll intro and send them some good times)<br><br>I'm still learning, so if you have any feedback, please send it to <a href="https://twitter.com/calcom">@calcom</a> on X!<br><br>Remember, you can always reach me here, at ${
user.username
}@${env.SENDER_DOMAIN}.<br><br>Looking forward to working together (:<br><br>- Cal AI`,
});
return new Response("OK", { status: 200 });
};
-186
View File
@@ -1,186 +0,0 @@
import type { ParsedMail, Source } from "mailparser";
import { simpleParser } from "mailparser";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import prisma from "@calcom/prisma";
import { env } from "../../../env.mjs";
import { fetchAvailability } from "../../../tools/getAvailability";
import { fetchEventTypes } from "../../../tools/getEventTypes";
import { extractUsers } from "../../../utils/extractUsers";
import getHostFromHeaders from "../../../utils/host";
import now from "../../../utils/now";
import sendEmail from "../../../utils/sendEmail";
import { verifyParseKey } from "../../../utils/verifyParseKey";
// Allow receive loop to run for up to 30 seconds
// Why so long? the rate determining API call (getAvailability, getEventTypes) can take up to 15 seconds at peak times so we give it a little extra time to complete.
export const maxDuration = 30;
/**
* Verifies email signature and app authorization,
* then hands off to booking agent.
*/
export const POST = async (request: NextRequest) => {
const verified = verifyParseKey(request.url);
if (!verified) {
return new NextResponse("Unauthorized", { status: 401 });
}
const formData = await request.formData();
const body = Object.fromEntries(formData);
const envelope = JSON.parse(body.envelope as string);
const aiEmail = envelope.to[0];
const subject = body.subject || "";
try {
await checkRateLimitAndThrowError({
identifier: `ai:email:${envelope.from}`,
rateLimitingType: "ai",
});
} catch (error) {
await sendEmail({
subject: `Re: ${subject}`,
text: "Thanks for using Cal.ai! You've reached your daily limit. Please try again tomorrow.",
to: envelope.from,
from: aiEmail,
});
return new NextResponse("Exceeded rate limit", { status: 200 }); // Don't return 429 to avoid triggering retry logic in SendGrid
}
// Parse email from mixed MIME type
const parsed: ParsedMail = await simpleParser(body.email as Source);
if (!parsed.text && !parsed.subject) {
await sendEmail({
subject: `Re: ${subject}`,
text: "Thanks for using Cal.ai! It looks like you forgot to include a message. Please try again.",
to: envelope.from,
from: aiEmail,
});
return new NextResponse("Email missing text and subject", { status: 400 });
}
const user = await prisma.user.findUnique({
select: {
email: true,
id: true,
username: true,
timeZone: true,
credentials: {
select: {
appId: true,
key: true,
},
},
},
where: { email: envelope.from },
});
// body.dkim looks like {@domain-com.22222222.gappssmtp.com : pass}
const signature = (body.dkim as string).includes(" : pass");
// User is not a cal.com user or is using an unverified email.
if (!signature || !user) {
await sendEmail({
html: `Thanks for your interest in Cal.ai! To get started, Make sure you have a <a href="https://cal.com/signup" target="_blank">cal.com</a> account with this email address and then install Cal.ai here: <a href="https://go.cal.com/ai" target="_blank">go.cal.com/ai</a>.`,
subject: `Re: ${subject}`,
text: `Thanks for your interest in Cal.ai! To get started, Make sure you have a cal.com account with this email address. You can sign up for an account at: https://cal.com/signup`,
to: envelope.from,
from: aiEmail,
});
return new NextResponse("ok");
}
const credential = user.credentials.find((c) => c.appId === env.APP_ID)?.key;
// User has not installed the app from the app store. Direct them to install it.
if (!(credential as { apiKey: string })?.apiKey) {
const url = env.APP_URL;
await sendEmail({
html: `Thanks for using Cal.ai! To get started, the app must be installed. <a href=${url} target="_blank">Click this link</a> to install it.`,
subject: `Re: ${subject}`,
text: `Thanks for using Cal.ai! To get started, the app must be installed. Click this link to install the Cal.ai app: ${url}`,
to: envelope.from,
from: aiEmail,
});
return new NextResponse("ok");
}
const { apiKey } = credential as { apiKey: string };
// Pre-fetch data relevant to most bookings.
const [eventTypes, availability, users] = await Promise.all([
fetchEventTypes({
apiKey,
}),
fetchAvailability({
apiKey,
userId: user.id,
dateFrom: now(user.timeZone),
dateTo: now(user.timeZone),
}),
extractUsers(`${parsed.text} ${parsed.subject}`),
]);
if ("error" in availability) {
await sendEmail({
subject: `Re: ${subject}`,
text: "Sorry, there was an error fetching your availability. Please try again.",
to: user.email,
from: aiEmail,
});
console.error(availability.error);
return new NextResponse("Error fetching availability. Please try again.", { status: 400 });
}
if ("error" in eventTypes) {
await sendEmail({
subject: `Re: ${subject}`,
text: "Sorry, there was an error fetching your event types. Please try again.",
to: user.email,
from: aiEmail,
});
console.error(eventTypes.error);
return new NextResponse("Error fetching event types. Please try again.", { status: 400 });
}
const { workingHours } = availability;
const appHost = getHostFromHeaders(request.headers);
// Hand off to long-running agent endpoint to handle the email. (don't await)
fetch(`${appHost}/api/agent?parseKey=${env.PARSE_KEY}`, {
body: JSON.stringify({
apiKey,
userId: user.id,
message: parsed.text || "",
subject: parsed.subject || "",
replyTo: aiEmail,
user: {
email: user.email,
eventTypes,
username: user.username,
timeZone: user.timeZone,
workingHours,
},
users,
}),
headers: {
"Content-Type": "application/json",
},
method: "POST",
});
await new Promise((r) => setTimeout(r, 1000));
return new NextResponse("ok");
};
-47
View File
@@ -1,47 +0,0 @@
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";
export const env = createEnv({
/**
* Specify your client-side environment variables schema here. This way you can ensure the app
* isn't built with invalid env vars. To expose them to the client, prefix them with
* `NEXT_PUBLIC_`.
*/
client: {
// NEXT_PUBLIC_CLIENTVAR: z.string().min(1),
},
/**
* You can't destruct `process.env` as a regular object in the Next.js edge runtimes (e.g.
* middlewares) or client-side so we need to destruct manually.
*/
runtimeEnv: {
BACKEND_URL: process.env.BACKEND_URL,
FRONTEND_URL: process.env.FRONTEND_URL,
APP_ID: process.env.APP_ID,
APP_URL: process.env.APP_URL,
SENDER_DOMAIN: process.env.SENDER_DOMAIN,
PARSE_KEY: process.env.PARSE_KEY,
NODE_ENV: process.env.NODE_ENV,
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
SENDGRID_API_KEY: process.env.SENDGRID_API_KEY,
DATABASE_URL: process.env.DATABASE_URL,
},
/**
* Specify your server-side environment variables schema here. This way you can ensure the app
* isn't built with invalid env vars.
*/
server: {
BACKEND_URL: z.string().url(),
FRONTEND_URL: z.string().url(),
APP_ID: z.string().min(1),
APP_URL: z.string().url(),
SENDER_DOMAIN: z.string().min(1),
PARSE_KEY: z.string().min(1),
NODE_ENV: z.enum(["development", "test", "production"]),
OPENAI_API_KEY: z.string().min(1),
SENDGRID_API_KEY: z.string().min(1),
DATABASE_URL: z.string().url(),
},
});
Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

-121
View File
@@ -1,121 +0,0 @@
import { DynamicStructuredTool } from "@langchain/core/tools";
import { z } from "zod";
import type { UserList } from "~/src/types/user";
import { env } from "../env.mjs";
/**
* Creates a booking for a user by event type, times, and timezone.
*/
const createBooking = async ({
apiKey,
userId,
users,
eventTypeId,
start,
end,
timeZone,
language,
invite,
}: {
apiKey: string;
userId: number;
users: UserList;
eventTypeId: number;
start: string;
end: string;
timeZone: string;
language: string;
invite: number;
title?: string;
status?: string;
}): Promise<string | Error | { error: string }> => {
const params = {
apiKey,
userId: userId.toString(),
};
const urlParams = new URLSearchParams(params);
const url = `${env.BACKEND_URL}/bookings?${urlParams.toString()}`;
const user = users.find((u) => u.id === invite);
if (!user) {
return { error: `User with id ${invite} not found to invite` };
}
const responses = {
id: invite.toString(),
name: user.username,
email: user.email,
};
const response = await fetch(url, {
body: JSON.stringify({
end,
eventTypeId,
language,
metadata: {},
responses,
start,
timeZone,
}),
headers: {
"Content-Type": "application/json",
},
method: "POST",
});
// Let GPT handle this. This will happen when wrong event type id is used.
// if (response.status === 401) throw new Error("Unauthorized");
const data = await response.json();
if (response.status !== 200) {
return {
error: data.message,
};
}
return "Booking created";
};
const createBookingTool = (apiKey: string, userId: number, users: UserList) => {
return new DynamicStructuredTool({
description: "Creates a booking on the primary user's calendar.",
func: async ({ eventTypeId, start, end, timeZone, language, invite, title, status }) => {
return JSON.stringify(
await createBooking({
apiKey,
userId,
users,
end,
eventTypeId,
language,
invite,
start,
status,
timeZone,
title,
})
);
},
name: "createBooking",
schema: z.object({
end: z
.string()
.describe("This should correspond to the event type's length, unless otherwise specified."),
eventTypeId: z.number(),
language: z.string(),
invite: z.number().describe("External user id to invite."),
start: z.string(),
status: z.string().optional().describe("ACCEPTED, PENDING, CANCELLED or REJECTED"),
timeZone: z.string(),
title: z.string().optional(),
}),
});
};
export default createBookingTool;
-66
View File
@@ -1,66 +0,0 @@
import { DynamicStructuredTool } from "@langchain/core/tools";
import { z } from "zod";
import { env } from "../env.mjs";
/**
* Cancels a booking for a user by ID with reason.
*/
const cancelBooking = async ({
apiKey,
id,
reason,
}: {
apiKey: string;
id: string;
reason: string;
}): Promise<string | { error: string }> => {
const params = {
apiKey,
};
const urlParams = new URLSearchParams(params);
const url = `${env.BACKEND_URL}/bookings/${id}/cancel?${urlParams.toString()}`;
const response = await fetch(url, {
body: JSON.stringify({ reason }),
headers: {
"Content-Type": "application/json",
},
method: "DELETE",
});
// Let GPT handle this. This will happen when wrong booking id is used.
// if (response.status === 401) throw new Error("Unauthorized");
const data = await response.json();
if (response.status !== 200) {
return { error: data.message };
}
return "Booking cancelled";
};
const cancelBookingTool = (apiKey: string) => {
return new DynamicStructuredTool({
description: "Cancel a booking",
func: async ({ id, reason }) => {
return JSON.stringify(
await cancelBooking({
apiKey,
id,
reason,
})
);
},
name: "cancelBooking",
schema: z.object({
id: z.string(),
reason: z.string(),
}),
});
};
export default cancelBookingTool;
-77
View File
@@ -1,77 +0,0 @@
import { DynamicStructuredTool } from "@langchain/core/tools";
import { z } from "zod";
import { env } from "../env.mjs";
import type { Availability } from "../types/availability";
/**
* Fetches availability for a user by date range and event type.
*/
export const fetchAvailability = async ({
apiKey,
userId,
dateFrom,
dateTo,
}: {
apiKey: string;
userId: number;
dateFrom: string;
dateTo: string;
}): Promise<Partial<Availability> | { error: string }> => {
const params: { [k: string]: string } = {
apiKey,
userId: userId.toString(),
dateFrom,
dateTo,
};
const urlParams = new URLSearchParams(params);
const url = `${env.BACKEND_URL}/availability?${urlParams.toString()}`;
const response = await fetch(url);
if (response.status === 401) throw new Error("Unauthorized");
const data = await response.json();
if (response.status !== 200) {
return { error: data.message };
}
return {
busy: data.busy,
dateRanges: data.dateRanges,
timeZone: data.timeZone,
workingHours: data.workingHours,
};
};
const getAvailabilityTool = (apiKey: string) => {
return new DynamicStructuredTool({
description: "Get availability of users within range.",
func: async ({ userIds, dateFrom, dateTo }) => {
return JSON.stringify(
await Promise.all(
userIds.map(
async (userId) =>
await fetchAvailability({
userId: userId,
apiKey,
dateFrom,
dateTo,
})
)
)
);
},
name: "getAvailability",
schema: z.object({
userIds: z.array(z.number()).describe("The users to fetch availability for."),
dateFrom: z.string(),
dateTo: z.string(),
}),
});
};
export default getAvailabilityTool;
-75
View File
@@ -1,75 +0,0 @@
import { DynamicStructuredTool } from "@langchain/core/tools";
import { z } from "zod";
import { env } from "../env.mjs";
import type { Booking } from "../types/booking";
import { BOOKING_STATUS } from "../types/booking";
/**
* Fetches bookings for a user by date range.
*/
const fetchBookings = async ({
apiKey,
userId,
from,
to,
}: {
apiKey: string;
userId: number;
from: string;
to: string;
}): Promise<Booking[] | { error: string }> => {
const params = {
apiKey,
userId: userId.toString(),
dateFrom: from,
dateTo: to,
};
const urlParams = new URLSearchParams(params);
const url = `${env.BACKEND_URL}/bookings?${urlParams.toString()}`;
const response = await fetch(url);
if (response.status === 401) throw new Error("Unauthorized");
const data = await response.json();
if (response.status !== 200) {
return { error: data.message };
}
const bookings: Booking[] = data.bookings
.filter((booking: Booking) => {
const notCancelled = booking.status !== BOOKING_STATUS.CANCELLED;
return notCancelled;
})
.map(({ endTime, eventTypeId, id, startTime, status, title }: Booking) => ({
endTime,
eventTypeId,
id,
startTime,
status,
title,
}));
return bookings;
};
const getBookingsTool = (apiKey: string, userId: number) => {
return new DynamicStructuredTool({
description: "Get bookings for the primary user between two dates.",
func: async ({ from, to }) => {
return JSON.stringify(await fetchBookings({ apiKey, userId, from, to }));
},
name: "getBookings",
schema: z.object({
from: z.string().describe("ISO 8601 datetime string"),
to: z.string().describe("ISO 8601 datetime string"),
}),
});
};
export default getBookingsTool;
-59
View File
@@ -1,59 +0,0 @@
import { DynamicStructuredTool } from "@langchain/core/tools";
import { z } from "zod";
import { env } from "../env.mjs";
import type { EventType } from "../types/eventType";
/**
* Fetches event types by user ID.
*/
export const fetchEventTypes = async ({ apiKey, userId }: { apiKey: string; userId?: number }) => {
const params: Record<string, string> = {
apiKey,
};
if (userId) {
params["userId"] = userId.toString();
}
const urlParams = new URLSearchParams(params);
const url = `${env.BACKEND_URL}/event-types?${urlParams.toString()}`;
const response = await fetch(url);
if (response.status === 401) throw new Error("Unauthorized");
const data = await response.json();
if (response.status !== 200) {
return { error: data.message };
}
return data.event_types.map((eventType: EventType) => ({
id: eventType.id,
slug: eventType.slug,
length: eventType.length,
title: eventType.title,
}));
};
const getEventTypesTool = (apiKey: string) => {
return new DynamicStructuredTool({
description: "Get a user's event type IDs. Usually necessary to book a meeting.",
func: async ({ userId }) => {
return JSON.stringify(
await fetchEventTypes({
apiKey,
userId,
})
);
},
name: "getEventTypes",
schema: z.object({
userId: z.number().optional().describe("The user ID. Defaults to the primary user's ID."),
}),
});
};
export default getEventTypesTool;
-124
View File
@@ -1,124 +0,0 @@
import { DynamicStructuredTool } from "@langchain/core/tools";
import { z } from "zod";
import { env } from "~/src/env.mjs";
import type { User, UserList } from "~/src/types/user";
import sendEmail from "~/src/utils/sendEmail";
export const sendBookingEmail = async ({
user,
agentEmail,
subject,
to,
message,
eventTypeSlug,
slots,
date,
}: {
apiKey: string;
user: User;
users: UserList;
agentEmail: string;
subject: string;
to: string;
message: string;
eventTypeSlug: string;
slots?: {
time: string;
text: string;
}[];
date: {
date: string;
text: string;
};
}) => {
// const url = `${env.FRONTEND_URL}/${user.username}/${eventTypeSlug}?date=${date}`;
const timeUrls = slots?.map(({ time, text }) => {
return {
url: `${env.FRONTEND_URL}/${user.username}/${eventTypeSlug}?slot=${time}`,
text,
};
});
const dateUrl = {
url: `${env.FRONTEND_URL}/${user.username}/${eventTypeSlug}?date=${date.date}`,
text: date.text,
};
await sendEmail({
subject,
to,
cc: user.email,
from: agentEmail,
text: message
.split("[[[Slots]]]")
.join(timeUrls?.map(({ url, text }) => `${text}: ${url}`).join("\n"))
.split("[[[Link]]]")
.join(`${dateUrl.text}: ${dateUrl.url}`),
html: message
.split("\n")
.join("<br>")
.split("[[[Slots]]]")
.join(timeUrls?.map(({ url, text }) => `<a href="${url}">${text}</a>`).join("<br>"))
.split("[[[Link]]]")
.join(`<a href="${dateUrl.url}">${dateUrl.text}</a>`),
});
return "Booking link sent";
};
const sendBookingEmailTool = (apiKey: string, user: User, users: UserList, agentEmail: string) => {
return new DynamicStructuredTool({
description:
"Send a booking link via email. Useful for scheduling with non cal users. Be confident, suggesting a good date/time with a fallback to a link to select a date/time.",
func: async ({ message, subject, to, eventTypeSlug, slots, date }) => {
return JSON.stringify(
await sendBookingEmail({
apiKey,
user,
users,
agentEmail,
subject,
to,
message,
eventTypeSlug,
slots,
date,
})
);
},
name: "sendBookingEmail",
schema: z.object({
message: z
.string()
.describe(
"A polite and professional email with an intro and signature at the end. Specify you are the AI booking assistant of the primary user. Use [[[Slots]]] and a fallback [[[Link]]] to inject good times and 'see all times' into messages"
),
subject: z.string(),
to: z
.string()
.describe("email address to send the booking link to. Primary user is automatically CC'd"),
eventTypeSlug: z.string().describe("the slug of the event type to book"),
slots: z
.array(
z.object({
time: z.string().describe("YYYY-MM-DDTHH:mm in UTC"),
text: z.string().describe("minimum readable label. Ex. 4pm."),
})
)
.optional()
.describe("Time slots the external user can click"),
date: z
.object({
date: z.string().describe("YYYY-MM-DD"),
text: z.string().describe('"See all times" or similar'),
})
.describe(
"A booking link that allows the external user to select a date / time. Should be a fallback to time slots"
),
}),
});
};
export default sendBookingEmailTool;
-85
View File
@@ -1,85 +0,0 @@
import { DynamicStructuredTool } from "@langchain/core/tools";
import { z } from "zod";
import { env } from "../env.mjs";
/**
* Edits a booking for a user by booking ID with new times, title, description, or status.
*/
const editBooking = async ({
apiKey,
userId,
id,
startTime, // In the docs it says start, but it's startTime: https://cal.com/docs/enterprise-features/api/api-reference/bookings#edit-an-existing-booking.
endTime, // Same here: it says end but it's endTime.
title,
description,
status,
}: {
apiKey: string;
userId: number;
id: string;
startTime?: string;
endTime?: string;
title?: string;
description?: string;
status?: string;
}): Promise<string | { error: string }> => {
const params = {
apiKey,
userId: userId.toString(),
};
const urlParams = new URLSearchParams(params);
const url = `${env.BACKEND_URL}/bookings/${id}?${urlParams.toString()}`;
const response = await fetch(url, {
body: JSON.stringify({ description, endTime, startTime, status, title }),
headers: {
"Content-Type": "application/json",
},
method: "PATCH",
});
// Let GPT handle this. This will happen when wrong booking id is used.
// if (response.status === 401) throw new Error("Unauthorized");
const data = await response.json();
if (response.status !== 200) {
return { error: data.message };
}
return "Booking edited";
};
const editBookingTool = (apiKey: string, userId: number) => {
return new DynamicStructuredTool({
description: "Edit a booking",
func: async ({ description, endTime, id, startTime, status, title }) => {
return JSON.stringify(
await editBooking({
apiKey,
userId,
description,
endTime,
id,
startTime,
status,
title,
})
);
},
name: "editBooking",
schema: z.object({
description: z.string().optional(),
endTime: z.string().optional(),
id: z.string(),
startTime: z.string().optional(),
status: z.string().optional(),
title: z.string().optional(),
}),
});
};
export default editBookingTool;
-25
View File
@@ -1,25 +0,0 @@
export type Availability = {
busy: {
start: string;
end: string;
title?: string;
}[];
timeZone: string;
dateRanges: {
start: string;
end: string;
}[];
workingHours: {
days: number[];
startTime: number;
endTime: number;
userId: number;
}[];
dateOverrides: {
date: string;
startTime: number;
endTime: number;
userId: number;
};
currentSeats: number;
};
-23
View File
@@ -1,23 +0,0 @@
export enum BOOKING_STATUS {
ACCEPTED = "ACCEPTED",
PENDING = "PENDING",
CANCELLED = "CANCELLED",
REJECTED = "REJECTED",
}
export type Booking = {
id: number;
userId: number;
description: string | null;
eventTypeId: number;
uid: string;
title: string;
startTime: string;
endTime: string;
attendees: { email: string; name: string; timeZone: string; locale: string }[] | null;
user: { email: string; name: string; timeZone: string; locale: string }[] | null;
payment: { id: number; success: boolean; paymentOption: string }[];
metadata: object | null;
status: BOOKING_STATUS;
responses: { email: string; name: string; location: string } | null;
};
-13
View File
@@ -1,13 +0,0 @@
export type EventType = {
id: number;
title: string;
length: number;
metadata: object;
slug: string;
hosts: {
userId: number;
isFixed: boolean;
}[];
hidden: boolean;
// ...
};
-18
View File
@@ -1,18 +0,0 @@
import type { EventType } from "./eventType";
import type { WorkingHours } from "./workingHours";
export type User = {
id: number;
email: string;
username: string;
timeZone: string;
eventTypes: EventType[];
workingHours: WorkingHours[];
};
export type UserList = {
id?: number;
email?: string;
username?: string;
type: "fromUsername" | "fromEmail";
}[];
-5
View File
@@ -1,5 +0,0 @@
export type WorkingHours = {
days: number[];
startTime: number;
endTime: number;
};
-122
View File
@@ -1,122 +0,0 @@
import { ChatPromptTemplate } from "@langchain/core/prompts";
import { ChatOpenAI } from "@langchain/openai";
import { AgentExecutor, createOpenAIFunctionsAgent } from "langchain/agents";
import { env } from "../env.mjs";
import createBookingIfAvailable from "../tools/createBooking";
import deleteBooking from "../tools/deleteBooking";
import getAvailability from "../tools/getAvailability";
import getBookings from "../tools/getBookings";
import sendBookingEmail from "../tools/sendBookingEmail";
import updateBooking from "../tools/updateBooking";
import type { EventType } from "../types/eventType";
import type { User, UserList } from "../types/user";
import type { WorkingHours } from "../types/workingHours";
import now from "./now";
const gptModel = "gpt-4-0125-preview";
/**
* Core of the Cal.ai booking agent: a LangChain Agent Executor.
* Uses a toolchain to book meetings, list available slots, etc.
* Uses OpenAI functions to better enforce JSON-parsable output from the LLM.
*/
const agent = async (
input: string,
user: User,
users: UserList,
apiKey: string,
userId: number,
agentEmail: string
) => {
const tools = [
// getEventTypes(apiKey),
getAvailability(apiKey),
getBookings(apiKey, userId),
createBookingIfAvailable(apiKey, userId, users),
updateBooking(apiKey, userId),
deleteBooking(apiKey),
sendBookingEmail(apiKey, user, users, agentEmail),
];
const model = new ChatOpenAI({
modelName: gptModel,
openAIApiKey: env.OPENAI_API_KEY,
temperature: 0,
});
/**
* Initialize the agent executor with arguments.
*/
const prompt =
ChatPromptTemplate.fromTemplate(`You are Cal.ai - a bleeding edge scheduling assistant that interfaces via email.
Make sure your final answers are definitive, complete and well formatted.
Sometimes, tools return errors. In this case, try to handle the error intelligently or ask the user for more information.
Tools will always handle times in UTC, but times sent to users should be formatted per that user's timezone.
In responses to users, always summarize necessary context and open the door to follow ups. For example "I have booked your chat with @username for 3pm on Wednesday, December 20th, 2023 EST. Please let me know if you need to reschedule."
If you can't find a referenced user, ask the user for their email or @username. Make sure to specify that usernames require the @username format. Users don't know other users' userIds.
The primary user's id is: ${userId}
The primary user's username is: ${user.username}
The current time in the primary user's timezone is: ${now(user.timeZone, {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
hour: "numeric",
minute: "numeric",
})}
The primary user's time zone is: ${user.timeZone}
The primary user's event types are: ${user.eventTypes
.map((e: EventType) => `ID: ${e.id}, Slug: ${e.slug}, Title: ${e.title}, Length: ${e.length};`)
.join("\n")}
The primary user's working hours are: ${user.workingHours
.map(
(w: WorkingHours) =>
`Days: ${w.days.join(", ")}, Start Time (minutes in UTC): ${
w.startTime
}, End Time (minutes in UTC): ${w.endTime};`
)
.join("\n")}
${
users.length
? `The email references the following @usernames and emails: ${users
.map(
(u) =>
`${
(u.id ? `, id: ${u.id}` : "id: (non user)") +
(u.username
? u.type === "fromUsername"
? `, username: @${u.username}`
: ", username: REDACTED"
: ", (no username)") +
(u.email
? u.type === "fromEmail"
? `, email: ${u.email}`
: ", email: REDACTED"
: ", (no email)")
};`
)
.join("\n")}`
: ""
}`);
const agent = await createOpenAIFunctionsAgent({
llm: model,
prompt,
tools,
});
const executor = new AgentExecutor({
agent,
tools,
returnIntermediateSteps: env.NODE_ENV === "development",
verbose: env.NODE_ENV === "development",
});
const result = await executor.invoke({ input });
const { output } = result;
return output;
};
export default agent;
-1
View File
@@ -1 +0,0 @@
export const context = { apiKey: "", userId: "" };
-85
View File
@@ -1,85 +0,0 @@
import prisma from "@calcom/prisma";
import type { UserList } from "../types/user";
/*
* Extracts usernames (@Example) and emails (hi@example.com) from a string
*/
export const extractUsers = async (text: string) => {
const usernames = text
.match(/(?<![a-zA-Z0-9_.])@[a-zA-Z0-9_]+/g)
?.map((username) => username.slice(1).toLowerCase());
const emails = text
.match(/[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+/g)
?.map((email) => email.toLowerCase());
const dbUsersFromUsernames = usernames
? await prisma.user.findMany({
select: {
id: true,
username: true,
email: true,
},
where: {
username: {
in: usernames,
},
},
})
: [];
const usersFromUsernames = usernames
? usernames.map((username) => {
const user = dbUsersFromUsernames.find((u) => u.username === username);
return user
? {
username,
id: user.id,
email: user.email,
type: "fromUsername",
}
: {
username,
id: null,
email: null,
type: "fromUsername",
};
})
: [];
const dbUsersFromEmails = emails
? await prisma.user.findMany({
select: {
id: true,
email: true,
username: true,
},
where: {
email: {
in: emails,
},
},
})
: [];
const usersFromEmails = emails
? emails.map((email) => {
const user = dbUsersFromEmails.find((u) => u.email === email);
return user
? {
email,
id: user.id,
username: user.username,
type: "fromEmail",
}
: {
email,
id: null,
username: null,
type: "fromEmail",
};
})
: [];
return [...usersFromUsernames, ...usersFromEmails] as UserList;
};
-7
View File
@@ -1,7 +0,0 @@
import type { NextRequest } from "next/server";
const getHostFromHeaders = (headers: NextRequest["headers"]): string => {
return `https://${headers.get("host")}`;
};
export default getHostFromHeaders;
-6
View File
@@ -1,6 +0,0 @@
export default function now(timeZone: string, options: Intl.DateTimeFormatOptions = {}) {
return new Date().toLocaleString("en-US", {
timeZone,
...options,
});
}
-43
View File
@@ -1,43 +0,0 @@
import mail from "@sendgrid/mail";
const sendgridAPIKey = process.env.SENDGRID_API_KEY as string;
/**
* Simply send an email by address, subject, and body.
*/
const send = async ({
subject,
to,
cc,
from,
text,
html,
}: {
subject: string;
to: string | string[];
cc?: string | string[];
from: string;
text: string;
html?: string;
}): Promise<boolean> => {
mail.setApiKey(sendgridAPIKey);
const msg = {
to,
cc,
from: {
email: from,
name: "Cal.ai",
},
text,
html,
subject,
};
const res = await mail.send(msg);
const success = !!res;
return success;
};
export default send;
-13
View File
@@ -1,13 +0,0 @@
import type { NextRequest } from "next/server";
import { env } from "../env.mjs";
/**
* Verifies that the request contains the correct parse key.
* env.PARSE_KEY must be configured as a query param in the sendgrid inbound parse settings.
*/
export const verifyParseKey = (url: NextRequest["url"]) => {
const verified = new URL(url).searchParams.get("parseKey") === env.PARSE_KEY;
return verified;
};
-24
View File
@@ -1,24 +0,0 @@
{
"extends": "@calcom/tsconfig/nextjs.json",
"compilerOptions": {
"strict": true,
"jsx": "preserve",
"baseUrl": ".",
"paths": {
"~/*": ["*"]
},
"plugins": [
{
"name": "next"
}
]
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
"../../packages/types/window.d.ts"
],
"exclude": ["node_modules"]
}
+2 -2
View File
@@ -1,6 +1,6 @@
import type { NextMiddleware } from "next-api-middleware";
import LicenseKeyService from "@calcom/ee/common/server/LicenseKeyService";
import { LicenseKeySingleton } from "@calcom/ee/common/server/LicenseKeyService";
import { hashAPIKey } from "@calcom/features/ee/api-keys/lib/apiKeys";
import { IS_PRODUCTION } from "@calcom/lib/constants";
import prisma from "@calcom/prisma";
@@ -18,7 +18,7 @@ export const dateNotInPast = function (date: Date) {
// This verifies the apiKey and sets the user if it is valid.
export const verifyApiKey: NextMiddleware = async (req, res, next) => {
const licenseKeyService = await LicenseKeyService.create();
const licenseKeyService = await LicenseKeySingleton.getInstance();
const hasValidLicense = await licenseKeyService.checkLicense();
if (!hasValidLicense && IS_PRODUCTION) {
+3
View File
@@ -19,6 +19,9 @@ export const isAdminGuard = async (req: NextApiRequest) => {
accepted: true,
team: {
isOrganization: true,
organizationSettings: {
isAdminAPIEnabled: true,
},
},
OR: [{ role: MembershipRole.OWNER }, { role: MembershipRole.ADMIN }],
},
+4 -1
View File
@@ -1,6 +1,6 @@
import { z } from "zod";
import { _BookingModel as Booking, _AttendeeModel, _UserModel, _PaymentModel } from "@calcom/prisma/zod";
import { _AttendeeModel, _BookingModel as Booking, _PaymentModel, _UserModel } from "@calcom/prisma/zod";
import { extendedBookingCreateBody, iso8601 } from "@calcom/prisma/zod-utils";
import { schemaQueryUserId } from "./shared/queryUserId";
@@ -23,8 +23,11 @@ export const schemaBookingGetParams = z.object({
dateTo: iso8601.optional(),
order: z.enum(["asc", "desc"]).default("asc"),
sortBy: z.enum(["createdAt", "updatedAt"]).optional(),
status: z.enum(["upcoming"]).optional(),
});
export type Status = z.infer<typeof schemaBookingGetParams>["status"];
const schemaBookingEditParams = z
.object({
title: z.string().optional(),
+37 -4
View File
@@ -63,6 +63,25 @@ import { schemaQuerySingleOrMultipleUserIds } from "~/lib/validations/shared/que
* schema:
* type: string
* enum: [createdAt, updatedAt]
* - in: query
* name: status
* required: false
* schema:
* type: string
* enum: [upcoming]
* description: Filter bookings by status, it will overwrite dateFrom and dateTo filters
* - in: query
* name: dateFrom
* required: false
* schema:
* type: string
* description: ISO 8601 date string to filter bookings by start time
* - in: query
* name: dateTo
* required: false
* schema:
* type: string
* description: ISO 8601 date string to filter bookings by end time
* operationId: listBookings
* tags:
* - bookings
@@ -130,6 +149,7 @@ type GetAdminArgsType = {
requestedUserIds: number[];
userId: number;
};
/**
* Constructs the WHERE clause for Prisma booking findMany operation.
*
@@ -148,7 +168,9 @@ function buildWhereClause(
) {
const filterByAttendeeEmails = attendeeEmails.length > 0;
const userFilter = userIds.length > 0 ? { userId: { in: userIds } } : !!userId ? { userId } : {};
let whereClause = {};
if (filterByAttendeeEmails) {
whereClause = {
AND: [
@@ -177,9 +199,7 @@ function buildWhereClause(
};
}
return {
...whereClause,
};
return whereClause;
}
export async function handler(req: NextApiRequest) {
@@ -189,7 +209,7 @@ export async function handler(req: NextApiRequest) {
isOrganizationOwnerOrAdmin,
pagination: { take, skip },
} = req;
const { dateFrom, dateTo, order, sortBy } = schemaBookingGetParams.parse(req.query);
const { dateFrom, dateTo, order, sortBy, status } = schemaBookingGetParams.parse(req.query);
const args: Prisma.BookingFindManyArgs = {};
if (req.query.take && req.query.page) {
@@ -275,6 +295,19 @@ export async function handler(req: NextApiRequest) {
};
}
if (status) {
switch (status) {
case "upcoming":
args.where = {
...args.where,
startTime: { gte: new Date().toISOString() },
};
break;
default:
throw new HttpError({ message: "Invalid status", statusCode: 400 });
}
}
const data = await prisma.booking.findMany(args);
return { bookings: data.map((booking) => schemaBookingReadPublic.parse(booking)) };
}
+4
View File
@@ -47,6 +47,10 @@ import { getAccessibleUsers } from "~/lib/utils/retrieveScopedAccessibleUsers";
* type: string
* format: date-time
* description: 'End time of the Event'
* rescheduleUid:
* type: string
* format: UID
* description: 'Uid of the booking to reschedule'
* responses:
* type: object
* required:
+3 -3
View File
@@ -278,8 +278,8 @@ async function postHandler(req: NextApiRequest) {
let data: Prisma.EventTypeCreateArgs["data"] = {
...parsedBody,
userId,
users: { connect: { id: userId } },
userId: !!parsedBody.teamId ? null : userId,
users: !!parsedBody.teamId ? undefined : { connect: { id: userId } },
bookingLimits: bookingLimits === null ? Prisma.DbNull : bookingLimits,
durationLimits: durationLimits === null ? Prisma.DbNull : durationLimits,
};
@@ -291,7 +291,7 @@ async function postHandler(req: NextApiRequest) {
await checkUserMembership(req);
}
if (isSystemWideAdmin && parsedBody.userId) {
if (isSystemWideAdmin && parsedBody.userId && !parsedBody.teamId) {
data = { ...parsedBody, users: { connect: { id: parsedBody.userId } } };
}
+13 -4
View File
@@ -69,6 +69,9 @@ import { schemaTeamCreateBodyParams, schemaTeamReadPublic } from "~/lib/validati
* ownerId:
* type: number
* description: ID of the team owner - only admins can set this.
* parentId:
* type: number
* description: ID of the parent organization.
* tags:
* - teams
* responses:
@@ -99,7 +102,7 @@ async function postHandler(req: NextApiRequest) {
if (alreadyExist) throw new HttpError({ statusCode: 409, message: "Team slug already exists" });
}
// Check if parentId is related to this user
// Check if parentId is related to this user and is an organization
if (data.parentId) {
const parentTeam = await prisma.team.findFirst({
where: { id: data.parentId, members: { some: { userId, role: { in: ["OWNER", "ADMIN"] } } } },
@@ -107,7 +110,13 @@ async function postHandler(req: NextApiRequest) {
if (!parentTeam)
throw new HttpError({
statusCode: 401,
message: "Unauthorized: Invalid parent id. You can only use parent id of your own teams.",
message: "Unauthorized: Invalid parent id. You can only use parent id if you are org owner or admin.",
});
if (parentTeam.parentId)
throw new HttpError({
statusCode: 400,
message: "parentId must be of an organization, not a team.",
});
}
@@ -119,7 +128,7 @@ async function postHandler(req: NextApiRequest) {
metadata: data.metadata === null ? {} : data.metadata || undefined,
};
if (!IS_TEAM_BILLING_ENABLED) {
if (!IS_TEAM_BILLING_ENABLED || data.parentId) {
const team = await prisma.team.create({
data: {
...cloneData,
@@ -135,7 +144,7 @@ async function postHandler(req: NextApiRequest) {
return {
team: schemaTeamReadPublic.parse(team),
owner: schemaMembershipPublic.parse(team.members[0]),
message: `Team created successfully. We also made user with ID=${ownerId} the owner of this team.`,
message: `Team created successfully. We also made user with ID=${effectiveUserId} the owner of this team.`,
};
}
@@ -105,4 +105,87 @@ describe("GET /api/bookings", async () => {
expect(responseData.bookings.find((b) => b.id === proUserBooking.id)).toBeUndefined();
expect(groupedUsers.size).toBeGreaterThanOrEqual(2);
});
describe("Upcoming bookings feature", () => {
it("Returns only upcoming bookings when status=upcoming for regular user", async () => {
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
query: {
status: "upcoming",
},
pagination: DefaultPagination,
});
req.userId = proUser.id;
const responseData = await handler(req);
responseData.bookings.forEach((booking) => {
expect(new Date(booking.startTime).getTime()).toBeGreaterThanOrEqual(new Date().getTime());
});
});
it("Returns all bookings when status not specified for regular user", async () => {
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
pagination: DefaultPagination,
});
req.userId = proUser.id;
const responseData = await handler(req);
expect(responseData.bookings.find((b) => b.id === proUserBooking.id)).toBeDefined();
const { req: req2 } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
pagination: DefaultPagination,
});
req2.userId = proUser.id;
const responseData2 = await handler(req2);
expect(responseData2.bookings.find((b) => b.id === proUserBooking.id)).toBeDefined();
});
it("Returns only upcoming bookings when status=upcoming for system-wide admin", async () => {
const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "owner1-acme@example.com" } });
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
query: {
status: "upcoming",
},
pagination: {
take: 100,
skip: 0,
},
});
req.isSystemWideAdmin = true;
req.userId = adminUser.id;
const responseData = await handler(req);
responseData.bookings.forEach((booking) => {
console.log(booking);
expect(new Date(booking.startTime).getTime()).toBeGreaterThanOrEqual(new Date().getTime());
});
});
it("Returns only upcoming bookings when status=upcoming for org admin", async () => {
const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "owner1-acme@example.com" } });
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
query: {
status: "upcoming",
},
pagination: DefaultPagination,
});
req.userId = adminUser.id;
req.isOrganizationOwnerOrAdmin = true;
const responseData = await handler(req);
responseData.bookings.forEach((booking) => {
expect(new Date(booking.startTime).getTime()).toBeGreaterThanOrEqual(new Date().getTime());
});
});
});
});
+37 -1
View File
@@ -11,6 +11,7 @@ import sendPayload from "@calcom/features/webhooks/lib/sendOrSchedulePayload";
import { ErrorCode } from "@calcom/lib/errorCodes";
import { buildBooking, buildEventType, buildWebhook } from "@calcom/lib/test/builder";
import prisma from "@calcom/prisma";
import type { Booking } from "@calcom/prisma";
import handler from "../../../pages/api/bookings/_post";
@@ -157,6 +158,7 @@ describe.skipIf(true)("POST /api/bookings", () => {
describe("Success", () => {
describe("Regular event-type", () => {
let createdBooking: Booking;
test("Creates one single booking", async () => {
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "POST",
@@ -181,9 +183,43 @@ describe.skipIf(true)("POST /api/bookings", () => {
await handler(req, res);
console.log({ statusCode: res._getStatusCode(), data: JSON.parse(res._getData()) });
createdBooking = JSON.parse(res._getData());
expect(prismaMock.booking.create).toHaveBeenCalledTimes(1);
});
test("Reschedule created booking", async () => {
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "POST",
body: {
name: "testReschedule",
start: dayjs().add(2, "day").format(),
end: dayjs().add(3, "day").format(),
eventTypeId: 2,
email: "test@example.com",
location: "Cal.com Video",
timeZone: "America/Montevideo",
language: "en",
customInputs: [],
metadata: {},
userId: 4,
rescheduleUid: createdBooking.uid,
},
prisma,
});
prismaMock.eventType.findUniqueOrThrow.mockResolvedValue(buildEventType());
prismaMock.booking.findMany.mockResolvedValue([]);
await handler(req, res);
console.log({ statusCode: res._getStatusCode(), data: JSON.parse(res._getData()) });
const rescheduledBooking = JSON.parse(res._getData()) as Booking;
expect(prismaMock.booking.create).toHaveBeenCalledTimes(1);
expect(rescheduledBooking.fromReschedule).toEqual(createdBooking.uid);
const previousBooking = await prisma.booking.findUnique({
where: { uid: createdBooking.uid },
});
expect(previousBooking?.status).toBe("cancelled");
});
});
describe("Recurring event-type", () => {
@@ -0,0 +1,157 @@
import prismaMock from "../../../../../../tests/libs/__mocks__/prismaMock";
import type { Request, Response } from "express";
import type { NextApiRequest, NextApiResponse } from "next";
import { createMocks } from "node-mocks-http";
import { describe, expect, test, vi, afterEach } from "vitest";
import { buildEventType } from "@calcom/lib/test/builder";
import handler from "../../../pages/api/event-types/_post";
import checkParentEventOwnership from "../../../pages/api/event-types/_utils/checkParentEventOwnership";
import checkTeamEventEditPermission from "../../../pages/api/event-types/_utils/checkTeamEventEditPermission";
import checkUserMembership from "../../../pages/api/event-types/_utils/checkUserMembership";
import ensureOnlyMembersAsHosts from "../../../pages/api/event-types/_utils/ensureOnlyMembersAsHosts";
import { canUserAccessTeamWithRole } from "../../../pages/api/teams/[teamId]/_auth-middleware";
type CustomNextApiRequest = NextApiRequest & Request;
type CustomNextApiResponse = NextApiResponse & Response;
const adminUserId = 1;
const memberUserId = 10;
vi.mock("../../../pages/api/teams/[teamId]/_auth-middleware", () => ({
canUserAccessTeamWithRole: vi.fn(),
}));
vi.mock("../../../pages/api/event-types/_utils/checkUserMembership", () => ({
default: vi.fn(),
}));
vi.mock("../../../pages/api/event-types/_utils/checkParentEventOwnership", () => ({
default: vi.fn(),
}));
vi.mock("../../../pages/api/event-types/_utils/checkTeamEventEditPermission", () => ({
default: vi.fn(),
}));
vi.mock("../../../pages/api/event-types/_utils/ensureOnlyMembersAsHosts", () => ({
default: vi.fn(),
}));
afterEach(() => {
vi.resetAllMocks();
});
describe("POST /api/event-types", () => {
describe("Errors", () => {
test("should throw 401 if a non-admin user tries to create an event type with userId", async () => {
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "POST",
body: {
title: "Tennis class",
slug: "tennis-class-{{$guid}}",
length: 60,
hidden: true,
userId: memberUserId,
},
});
await handler(req, res);
expect(res.statusCode).toBe(401);
expect(JSON.parse(res._getData()).message).toBe("ADMIN required for `userId`");
});
test("should throw 401 if not system-wide admin and user cannot access teamId with required roles", async () => {
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "POST",
body: {
title: "Tennis class",
slug: "tennis-class-{{$guid}}",
length: 60,
hidden: true,
teamId: 9999,
},
});
req.userId = memberUserId;
vi.mocked(canUserAccessTeamWithRole).mockImplementationOnce(async () => false);
await handler(req, res);
expect(res.statusCode).toBe(401);
expect(JSON.parse(res._getData()).message).toBe("ADMIN required for `teamId`");
});
test("should throw 400 if system-wide admin but neither userId nor teamId provided", async () => {
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "POST",
body: {
title: "Tennis class",
slug: "tennis-class-{{$guid}}",
length: 60,
hidden: true,
},
});
req.isSystemWideAdmin = true;
await handler(req, res);
expect(res.statusCode).toBe(400);
expect(JSON.parse(res._getData()).message).toBe("`userId` or `teamId` required");
});
});
describe("Success", () => {
test("should call checkParentEventOwnership and checkUserMembership if parentId is present", async () => {
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "POST",
body: {
title: "Tennis class",
slug: "tennis-class-{{$guid}}",
length: 60,
hidden: true,
userId: memberUserId,
parentId: 9999,
teamId: 9999,
},
});
req.isSystemWideAdmin = true;
req.userId = adminUserId;
vi.mocked(canUserAccessTeamWithRole).mockImplementationOnce(async () => false);
await handler(req, res);
expect(checkParentEventOwnership).toHaveBeenCalled();
expect(checkUserMembership).toHaveBeenCalled();
});
test("should create event type successfully if all conditions are met", async () => {
const eventTypeTest = buildEventType();
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "POST",
body: {
title: "test title",
slug: "test-slug",
length: 60,
hidden: true,
userId: memberUserId,
parentId: 9999,
teamId: 9999,
},
});
req.isSystemWideAdmin = true;
req.userId = adminUserId;
vi.mocked(canUserAccessTeamWithRole).mockImplementationOnce(async () => true);
vi.mocked(checkParentEventOwnership).mockImplementationOnce(async () => undefined);
vi.mocked(checkUserMembership).mockImplementationOnce(async () => undefined);
vi.mocked(checkTeamEventEditPermission).mockImplementationOnce(async () => undefined);
vi.mocked(ensureOnlyMembersAsHosts).mockImplementationOnce(async () => undefined);
prismaMock.eventType.create.mockResolvedValue(eventTypeTest);
await handler(req, res);
const data = JSON.parse(res._getData());
expect(prismaMock.eventType.create).toHaveBeenCalledTimes(1);
expect(res.statusCode).toBe(200);
expect(data.message).toBe("Event type created successfully");
});
});
});
@@ -58,7 +58,7 @@ describe("isAdmin guard", () => {
expect(scope).toBe(ScopeOfAdmin.SystemWide);
});
it("Returns org-wide admin when user is set as such", async () => {
it("Returns org-wide admin when user is set as such & admin API access is granted", async () => {
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "POST",
body: {},
@@ -67,10 +67,21 @@ describe("isAdmin guard", () => {
const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "owner1-acme@example.com" } });
req.userId = adminUser.id;
const { isAdmin, scope } = await isAdminGuard(req);
expect(isAdmin).toBe(true);
expect(scope).toBe(ScopeOfAdmin.OrgOwnerOrAdmin);
});
it("Returns no admin when user is set as org admin but admin API access is revoked", async () => {
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "POST",
body: {},
});
const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "owner1-dunder@example.com" } });
req.userId = adminUser.id;
const { isAdmin } = await isAdminGuard(req);
expect(isAdmin).toBe(false);
});
});
+2 -2
View File
@@ -1,5 +1,5 @@
<p align="center">
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="200" alt="Nest Logo" /></a>
<a href="http://nestjs.com/" target="blank"><img src="https://github.com/user-attachments/assets/ce7c2ecf-6097-469a-8512-c846a9fb665d" height="200" alt="Nest Logo" /></a>
</p>
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
@@ -24,7 +24,7 @@
## Description
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
Cal.com is using the [Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
## Installation
+1 -1
View File
@@ -26,7 +26,7 @@
"dependencies": {
"@calcom/platform-constants": "*",
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
"@calcom/platform-libraries-0.0.21": "npm:@calcom/platform-libraries@0.0.21",
"@calcom/platform-libraries-0.0.23": "npm:@calcom/platform-libraries@0.0.23",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
"@calcom/prisma": "*",
+11 -19
View File
@@ -14,8 +14,8 @@ import { RedisService } from "@/modules/redis/redis.service";
import { BullModule } from "@nestjs/bull";
import { MiddlewareConsumer, Module, NestModule, RequestMethod } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { APP_INTERCEPTOR, RouterModule } from "@nestjs/core";
import { seconds, ThrottlerModule } from "@nestjs/throttler";
import { APP_GUARD, APP_INTERCEPTOR } from "@nestjs/core";
import { seconds, ThrottlerGuard, ThrottlerModule } from "@nestjs/throttler";
import { ThrottlerStorageRedisService } from "nestjs-throttler-storage-redis";
import { AppController } from "./app.controller";
@@ -29,15 +29,8 @@ import { AppController } from "./app.controller";
}),
RedisModule,
BullModule.forRootAsync({
imports: [RedisModule],
useFactory: async (redisService: RedisService) => ({
redis: {
host: redisService.redis.options.host,
port: redisService.redis.options.port,
},
}),
inject: [RedisService],
BullModule.forRoot({
redis: `${process.env.REDIS_URL}${process.env.NODE_ENV === "production" ? "?tls=true" : ""}`,
}),
ThrottlerModule.forRootAsync({
imports: [RedisModule],
@@ -45,14 +38,9 @@ import { AppController } from "./app.controller";
useFactory: (redisService: RedisService) => ({
throttlers: [
{
name: "short",
ttl: seconds(10),
limit: 3,
},
{
name: "medium",
ttl: seconds(30),
limit: 10,
name: "long",
ttl: seconds(60), // Time to live for the long period in seconds
limit: 120, // Maximum number of requests within the long ttl
},
],
storage: new ThrottlerStorageRedisService(redisService.redis),
@@ -69,6 +57,10 @@ import { AppController } from "./app.controller";
provide: APP_INTERCEPTOR,
useClass: ResponseInterceptor,
},
{
provide: APP_GUARD,
useClass: ThrottlerGuard,
},
],
})
export class AppModule implements NestModule {
@@ -1,5 +1,6 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { CreateBookingInput } from "@/ee/bookings/inputs/create-booking.input";
import { GetBookingOutput } from "@/ee/bookings/outputs/get-booking.output";
import { GetBookingsOutput } from "@/ee/bookings/outputs/get-bookings.output";
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
@@ -19,7 +20,7 @@ import { UserRepositoryFixture } from "test/fixtures/repository/users.repository
import { withApiAuth } from "test/utils/withApiAuth";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
import { handleNewBooking } from "@calcom/platform-libraries-0.0.21";
import { handleNewBooking } from "@calcom/platform-libraries-0.0.23";
import { ApiSuccessResponse, ApiResponse } from "@calcom/platform-types";
describe("Bookings Endpoints", () => {
@@ -90,7 +91,10 @@ describe("Bookings Endpoints", () => {
const bookingTimeZone = "Europe/London";
const bookingLanguage = "en";
const bookingHashedLink = "";
const bookingMetadata = {};
const bookingMetadata = {
timeFormat: "12",
meetingType: "organizer-phone",
};
const bookingResponses = {
name: "tester",
email: "tester@example.com",
@@ -102,7 +106,7 @@ describe("Bookings Endpoints", () => {
guests: [],
};
const body = {
const body: CreateBookingInput = {
start: bookingStart,
end: bookingEnd,
eventTypeId: bookingEventTypeId,
@@ -135,6 +139,64 @@ describe("Bookings Endpoints", () => {
});
});
describe("should reschedule a booking", () => {
it("should reschedule with updated start time, end time & metadata", async () => {
const newBookingStart = "2040-05-21T12:30:00.000Z";
const newBookingEnd = "2040-05-21T13:30:00.000Z";
const bookingEventTypeId = eventTypeId;
const bookingTimeZone = "Europe/London";
const bookingLanguage = "en";
const bookingHashedLink = "";
const newBookingMetadata = {
timeFormat: "24",
meetingType: "attendee-phone",
};
const bookingResponses = {
name: "tester",
email: "tester@example.com",
location: {
value: "link",
optionValue: "",
},
notes: "test",
guests: [],
};
const body: CreateBookingInput = {
rescheduleUid: createdBooking.uid,
start: newBookingStart,
end: newBookingEnd,
eventTypeId: bookingEventTypeId,
timeZone: bookingTimeZone,
language: bookingLanguage,
metadata: newBookingMetadata,
hashedLink: bookingHashedLink,
responses: bookingResponses,
};
return request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.expect(201)
.then(async (response) => {
const responseBody: ApiSuccessResponse<Awaited<ReturnType<typeof handleNewBooking>>> =
response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.userPrimaryEmail).toBeDefined();
expect(responseBody.data.userPrimaryEmail).toEqual(userEmail);
expect(responseBody.data.id).toBeDefined();
expect(responseBody.data.uid).toBeDefined();
expect(responseBody.data.startTime).toEqual(newBookingStart);
expect(responseBody.data.eventTypeId).toEqual(bookingEventTypeId);
expect(responseBody.data.user.timeZone).toEqual(bookingTimeZone);
expect(responseBody.data.metadata).toEqual(newBookingMetadata);
createdBooking = responseBody.data;
});
});
});
it("should get bookings", async () => {
return request(app.getHttpServer())
.get("/v2/bookings?filters[status]=upcoming")
@@ -46,7 +46,7 @@ import {
getBookingInfo,
handleCancelBooking,
getBookingForReschedule,
} from "@calcom/platform-libraries-0.0.21";
} from "@calcom/platform-libraries-0.0.23";
import { GetBookingsInput, CancelBookingInput, Status } from "@calcom/platform-types";
import { ApiResponse } from "@calcom/platform-types";
import { PrismaClient } from "@calcom/prisma";
@@ -211,6 +211,7 @@ export class BookingsController {
@Permissions([BOOKING_WRITE])
@UseGuards(ApiAuthGuard)
async markNoShow(
@GetUser("id") userId: number,
@Body() body: MarkNoShowInput,
@Param("bookingUid") bookingUid: string
): Promise<MarkNoShowOutput> {
@@ -219,6 +220,7 @@ export class BookingsController {
bookingUid: bookingUid,
attendees: body.attendees,
noShowHost: body.noShowHost,
userId,
});
return { status: SUCCESS_STATUS, data: markNoShowResponse };
@@ -1,7 +1,7 @@
import { CreateBookingInput } from "@/ee/bookings/inputs/create-booking.input";
import { IsBoolean, IsNumber, IsOptional } from "class-validator";
import type { AppsStatus } from "@calcom/platform-libraries-0.0.21";
import type { AppsStatus } from "@calcom/platform-libraries-0.0.23";
export class CreateRecurringBookingInput extends CreateBookingInput {
@IsBoolean()
@@ -20,10 +20,6 @@ class HandleMarkNoShowData {
@IsOptional()
noShowHost?: boolean;
@IsString()
@IsOptional()
messageKey?: string;
@ValidateNested()
@Type(() => Attendee)
@IsArray()
@@ -5,7 +5,7 @@ import { BadRequestException, UnauthorizedException } from "@nestjs/common";
import { Injectable } from "@nestjs/common";
import { SUCCESS_STATUS, APPLE_CALENDAR_TYPE, APPLE_CALENDAR_ID } from "@calcom/platform-constants";
import { symmetricEncrypt, CalendarService } from "@calcom/platform-libraries-0.0.21";
import { symmetricEncrypt, CalendarService } from "@calcom/platform-libraries-0.0.23";
@Injectable()
export class AppleCalendarService implements CredentialSyncCalendarApp {
@@ -18,7 +18,7 @@ import { User } from "@prisma/client";
import { DateTime } from "luxon";
import { z } from "zod";
import { getConnectedDestinationCalendars, getBusyCalendarTimes } from "@calcom/platform-libraries-0.0.21";
import { getConnectedDestinationCalendars, getBusyCalendarTimes } from "@calcom/platform-libraries-0.0.23";
import { Calendar } from "@calcom/platform-types";
import { PrismaClient } from "@calcom/prisma";
@@ -35,7 +35,7 @@ import {
EventTypesPublic,
eventTypeBookingFields,
eventTypeLocations,
} from "@calcom/platform-libraries-0.0.21";
} from "@calcom/platform-libraries-0.0.23";
import { ApiSuccessResponse } from "@calcom/platform-types";
describe("Event types Endpoints", () => {
@@ -40,7 +40,6 @@ import { ApiTags as DocsTags } from "@nestjs/swagger";
import { EVENT_TYPE_READ, EVENT_TYPE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
import { getPublicEvent, getEventTypesByViewer } from "@calcom/platform-libraries-0.0.2";
import { PrismaClient } from "@calcom/prisma";
@Controller({
@@ -4,7 +4,7 @@ import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { UserWithProfile } from "@/modules/users/users.repository";
import { Injectable } from "@nestjs/common";
import { getEventTypeById } from "@calcom/platform-libraries-0.0.21";
import { getEventTypeById } from "@calcom/platform-libraries-0.0.23";
import type { PrismaClient } from "@calcom/prisma";
@Injectable()
@@ -14,7 +14,7 @@ import {
updateEventType,
EventTypesPublic,
getEventTypesPublic,
} from "@calcom/platform-libraries-0.0.21";
} from "@calcom/platform-libraries-0.0.23";
import { EventType } from "@calcom/prisma/client";
@Injectable()
@@ -7,7 +7,7 @@ import {
getEventTypeById,
transformApiEventTypeBookingFields,
transformApiEventTypeLocations,
} from "@calcom/platform-libraries-0.0.21";
} from "@calcom/platform-libraries-0.0.23";
import { CreateEventTypeInput_2024_06_14 } from "@calcom/platform-types";
import type { PrismaClient } from "@calcom/prisma";
@@ -10,9 +10,9 @@ import { UsersService } from "@/modules/users/services/users.service";
import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository";
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
import { createEventType, updateEventType } from "@calcom/platform-libraries-0.0.21";
import { getEventTypesPublic, EventTypesPublic } from "@calcom/platform-libraries-0.0.21";
import { dynamicEvent } from "@calcom/platform-libraries-0.0.21";
import { createEventType, updateEventType } from "@calcom/platform-libraries-0.0.23";
import { getEventTypesPublic, EventTypesPublic } from "@calcom/platform-libraries-0.0.23";
import { dynamicEvent } from "@calcom/platform-libraries-0.0.23";
import {
CreateEventTypeInput_2024_06_14,
UpdateEventTypeInput_2024_06_14,
@@ -3,7 +3,7 @@ import { Injectable } from "@nestjs/common";
import {
transformApiEventTypeBookingFields,
transformApiEventTypeLocations,
} from "@calcom/platform-libraries-0.0.21";
} from "@calcom/platform-libraries-0.0.23";
import { CreateEventTypeInput_2024_06_14, UpdateEventTypeInput_2024_06_14 } from "@calcom/platform-types";
@Injectable()
@@ -9,7 +9,7 @@ import {
parseRecurringEvent,
TransformedLocationsSchema,
BookingFieldsSchema,
} from "@calcom/platform-libraries-0.0.21";
} from "@calcom/platform-libraries-0.0.23";
type EventTypeRelations = { users: User[]; schedule: Schedule | null };
type DatabaseEventType = EventType & EventTypeRelations;
@@ -22,4 +22,7 @@ export class MeOutput {
@IsString()
timeZone!: string;
@IsInt()
organizationId!: number | null;
}
@@ -3,7 +3,7 @@ import { Injectable } from "@nestjs/common";
import {
transformApiScheduleOverrides,
transformApiScheduleAvailability,
} from "@calcom/platform-libraries-0.0.21";
} from "@calcom/platform-libraries-0.0.23";
import { CreateScheduleInput_2024_06_11, ScheduleAvailabilityInput_2024_06_11 } from "@calcom/platform-types";
import { ScheduleOverrideInput_2024_06_11 } from "@calcom/platform-types";
@@ -2,7 +2,7 @@ import { ArgumentsHost, Catch, ExceptionFilter, Logger } from "@nestjs/common";
import { Request } from "express";
import { ERROR_STATUS } from "@calcom/platform-constants";
import { TRPCError } from "@calcom/platform-libraries-0.0.21";
import { TRPCError } from "@calcom/platform-libraries-0.0.23";
import { Response } from "@calcom/platform-types";
@Catch(TRPCError)
@@ -6,6 +6,7 @@ import { NextAuthStrategy } from "@/modules/auth/strategies/next-auth/next-auth.
import { DeploymentsModule } from "@/modules/deployments/deployments.module";
import { MembershipsModule } from "@/modules/memberships/memberships.module";
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
import { ProfilesModule } from "@/modules/profiles/profiles.module";
import { RedisModule } from "@/modules/redis/redis.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
@@ -21,6 +22,7 @@ import { PassportModule } from "@nestjs/passport";
MembershipsModule,
TokensModule,
DeploymentsModule,
ProfilesModule,
],
providers: [NextAuthGuard, NextAuthStrategy, ApiAuthGuard, ApiAuthStrategy, OAuthFlowService],
exports: [NextAuthGuard, ApiAuthGuard],
@@ -0,0 +1,4 @@
import type { PlatformPlanType } from "@/modules/billing/types";
import { Reflector } from "@nestjs/core";
export const PlatformPlan = Reflector.createDecorator<PlatformPlanType>();
@@ -0,0 +1,123 @@
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
import { RedisService } from "@/modules/redis/redis.service";
import { createMock } from "@golevelup/ts-jest";
import { ExecutionContext } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
describe("PlatformPlanGuard", () => {
let guard: PlatformPlanGuard;
let reflector: Reflector;
let organizationsRepository: OrganizationsRepository;
let redisService: RedisService;
const mockContext = createMockExecutionContext({
params: { teamId: "1", orgId: "1" },
user: { id: "1" },
});
beforeEach(async () => {
reflector = new Reflector();
organizationsRepository = createMock<OrganizationsRepository>();
redisService = createMock<RedisService>({
redis: {
get: jest.fn().mockResolvedValue(null),
set: jest.fn().mockResolvedValue(null),
},
});
guard = new PlatformPlanGuard(reflector, organizationsRepository, redisService);
});
it("should be defined", () => {
expect(guard).toBeDefined();
});
it("should return true", async () => {
jest.spyOn(reflector, "get").mockReturnValue("ESSENTIALS");
jest.spyOn(organizationsRepository, "findByIdIncludeBilling").mockResolvedValue({
isPlatform: true,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
platformBilling: {
subscriptionId: "sub_123",
plan: "SCALE",
},
});
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
});
it("should return false if the organization does not exist", async () => {
jest.spyOn(reflector, "get").mockReturnValue("ESSENTIALS");
jest.spyOn(organizationsRepository, "findByIdIncludeBilling").mockResolvedValue(null);
await expect(guard.canActivate(mockContext)).resolves.toBe(false);
});
it("should return true if the organization is not platform", async () => {
jest.spyOn(reflector, "get").mockReturnValue("ESSENTIALS");
jest.spyOn(organizationsRepository, "findByIdIncludeBilling").mockResolvedValue({
isPlatform: false,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
platformBilling: undefined,
});
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
});
it("should return false if the organization has no subscription", async () => {
jest.spyOn(reflector, "get").mockReturnValue("ESSENTIALS");
jest.spyOn(organizationsRepository, "findByIdIncludeBilling").mockResolvedValue({
isPlatform: true,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
platformBilling: {
subscriptionId: null,
plan: "STARTER",
},
});
await expect(guard.canActivate(mockContext)).resolves.toBe(false);
});
it("should return false if the user has a lower plan than required", async () => {
jest.spyOn(reflector, "get").mockReturnValue("ESSENTIALS");
jest.spyOn(organizationsRepository, "findByIdIncludeBilling").mockResolvedValue({
isPlatform: true,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
platformBilling: {
subscriptionId: "sub_123",
plan: "STARTER",
},
});
await expect(guard.canActivate(mockContext)).resolves.toBe(false);
});
it("should return true if the result is cached in Redis", async () => {
jest.spyOn(reflector, "get").mockReturnValue("ESSENTIALS");
jest.spyOn(redisService.redis, "get").mockResolvedValue(JSON.stringify(true));
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
});
it("should return false if the result is cached in Redis", async () => {
jest.spyOn(reflector, "get").mockReturnValue("ESSENTIALS");
jest.spyOn(redisService.redis, "get").mockResolvedValue(JSON.stringify(false));
await expect(guard.canActivate(mockContext)).resolves.toBe(false);
});
function createMockExecutionContext(context: Record<string, string | object>): ExecutionContext {
return createMock<ExecutionContext>({
switchToHttp: () => ({
getRequest: () => ({
params: context.params,
user: context.user,
}),
}),
});
}
});
@@ -0,0 +1,79 @@
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
import { GetUserReturnType } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { PlatformPlanType } from "@/modules/billing/types";
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
import { RedisService } from "@/modules/redis/redis.service";
import { Injectable, CanActivate, ExecutionContext } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { Request } from "express";
@Injectable()
export class PlatformPlanGuard implements CanActivate {
constructor(
private reflector: Reflector,
private readonly organizationsRepository: OrganizationsRepository,
private readonly redisService: RedisService
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request>();
const teamId = request.params.teamId as string;
const orgId = request.params.orgId as string;
const user = request.user as GetUserReturnType;
const minimumPlan = this.reflector.get(PlatformPlan, context.getHandler()) as PlatformPlanType;
const REDIS_CACHE_KEY = `apiv2:user:${user?.id ?? "none"}:org:${orgId ?? "none"}:team:${
teamId ?? "none"
}:guard:platformbilling:${minimumPlan}`;
const cachedAccess = JSON.parse((await this.redisService.redis.get(REDIS_CACHE_KEY)) ?? "false");
if (cachedAccess) {
return cachedAccess;
}
let canAccess = false;
if (user && orgId) {
const team = await this.organizationsRepository.findByIdIncludeBilling(Number(orgId));
const isPlatform = team?.isPlatform;
const hasSubscription = team?.platformBilling?.subscriptionId;
if (!team) {
canAccess = false;
} else if (!isPlatform) {
canAccess = true;
} else if (!hasSubscription) {
canAccess = false;
} else {
canAccess = hasMinimumPlan({
currentPlan: team.platformBilling?.plan as PlatformPlanType,
minimumPlan: minimumPlan,
plans: ["STARTER", "ESSENTIALS", "SCALE", "ENTERPRISE"],
});
}
}
await this.redisService.redis.set(REDIS_CACHE_KEY, String(canAccess), "EX", 300);
return canAccess;
}
}
type HasMinimumPlanProp = {
currentPlan: PlatformPlanType;
minimumPlan: PlatformPlanType;
plans: PlatformPlanType[];
};
export function hasMinimumPlan(props: HasMinimumPlanProp): boolean {
const currentPlanIndex = props.plans.indexOf(props.currentPlan);
const minimumPlanIndex = props.plans.indexOf(props.minimumPlan);
if (currentPlanIndex === -1 || minimumPlanIndex === -1) {
throw new Error(
`Invalid platform billing plan provided. Current plan: ${props.currentPlan}, Minimum plan: ${props.minimumPlan}`
);
}
return currentPlanIndex >= minimumPlanIndex;
}
@@ -0,0 +1,62 @@
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
import { RedisService } from "@/modules/redis/redis.service";
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common";
import { Request } from "express";
import { Team } from "@calcom/prisma/client";
type CachedData = {
org?: Team;
canAccess?: boolean;
};
@Injectable()
export class IsAdminAPIEnabledGuard implements CanActivate {
constructor(
private organizationsRepository: OrganizationsRepository,
private readonly redisService: RedisService
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
let canAccess = false;
const request = context.switchToHttp().getRequest<Request & { organization: Team }>();
const organizationId: string = request.params.orgId;
if (!organizationId) {
throw new ForbiddenException("No organization id found in request params.");
}
const REDIS_CACHE_KEY = `apiv2:org:${organizationId}:guard:isAdminAccess`;
const cachedData = await this.redisService.redis.get(REDIS_CACHE_KEY);
if (cachedData) {
const { org: cachedOrg, canAccess: cachedCanAccess } = JSON.parse(cachedData) as CachedData;
if (cachedOrg?.id === Number(organizationId) && cachedCanAccess !== undefined) {
request.organization = cachedOrg;
return cachedCanAccess;
}
}
const org = await this.organizationsRepository.findById(Number(organizationId));
if (org?.isOrganization && !org?.isPlatform) {
const adminAPIAccessIsEnabledInOrg = await this.organizationsRepository.fetchOrgAdminApiStatus(
Number(organizationId)
);
if (!adminAPIAccessIsEnabledInOrg) {
throw new ForbiddenException(
`Organization does not have Admin API access, please contact https://cal.com/sales to upgrade`
);
}
}
canAccess = true;
org &&
(await this.redisService.redis.set(
REDIS_CACHE_KEY,
JSON.stringify({ org: org, canAccess } satisfies CachedData),
"EX",
300
));
return canAccess;
}
}
@@ -7,6 +7,7 @@ import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repo
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { ProfilesModule } from "@/modules/profiles/profiles.module";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { UsersRepository } from "@/modules/users/users.repository";
import { ExecutionContext, HttpException } from "@nestjs/common";
@@ -17,11 +18,14 @@ import { Test, TestingModule } from "@nestjs/testing";
import { PlatformOAuthClient, Team, User } from "@prisma/client";
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.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 { TokensRepositoryFixture } from "test/fixtures/repository/tokens.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { MockedRedisService } from "test/mocks/mock-redis-service";
import { X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
import { ApiAuthStrategy } from "./api-auth.strategy";
describe("ApiAuthStrategy", () => {
@@ -33,11 +37,17 @@ describe("ApiAuthStrategy", () => {
let oAuthClient: PlatformOAuthClient;
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
let oAuthClientRepositoryFixture: OAuthClientRepositoryFixture;
let profilesRepositoryFixture: ProfileRepositoryFixture;
const validApiKeyEmail = "api-key-user-email@example.com";
const validAccessTokenEmail = "access-token-user-email@example.com";
const validOAuthEmail = "oauth-user@example.com";
let validApiKeyUser: User;
let validAccessTokenUser: User;
let validOAuthUser: User;
let module: TestingModule;
beforeAll(async () => {
module = await Test.createTestingModule({
imports: [
@@ -46,6 +56,7 @@ describe("ApiAuthStrategy", () => {
isGlobal: true,
load: [appConfig],
}),
ProfilesModule,
],
providers: [
MockedRedisService,
@@ -71,6 +82,7 @@ describe("ApiAuthStrategy", () => {
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(module);
teamRepositoryFixture = new TeamRepositoryFixture(module);
oAuthClientRepositoryFixture = new OAuthClientRepositoryFixture(module);
profilesRepositoryFixture = new ProfileRepositoryFixture(module);
organization = await teamRepositoryFixture.create({ name: "organization" });
validApiKeyUser = await userRepositoryFixture.create({
email: validApiKeyEmail,
@@ -78,6 +90,18 @@ describe("ApiAuthStrategy", () => {
validAccessTokenUser = await userRepositoryFixture.create({
email: validAccessTokenEmail,
});
validOAuthUser = await userRepositoryFixture.create({
email: validOAuthEmail,
});
await profilesRepositoryFixture.create({
uid: "asd-asd",
username: validOAuthEmail,
user: { connect: { id: validOAuthUser.id } },
organization: { connect: { id: organization.id } },
});
const data = {
logo: "logo-url",
name: "name",
@@ -94,22 +118,9 @@ describe("ApiAuthStrategy", () => {
oAuthClient.id
);
const context: ExecutionContext = {
switchToHttp: () => ({
getRequest: () => ({
headers: {
authorization: `Bearer ${accessToken}`,
},
get: (key: string) =>
({ Authorization: `Bearer ${accessToken}`, origin: "http://localhost:3000" }[key]),
}),
}),
} as ExecutionContext;
const request = context.switchToHttp().getRequest();
const user = await strategy.accessTokenStrategy(accessToken);
await expect(user).toBeDefined();
if (user) await expect(user.id).toEqual(validAccessTokenUser.id);
expect(user).toBeDefined();
expect(user?.id).toEqual(validAccessTokenUser.id);
});
it("should return user associated with valid api key", async () => {
@@ -117,22 +128,15 @@ describe("ApiAuthStrategy", () => {
now.setDate(now.getDate() + 1);
const { keyString } = await apiKeysRepositoryFixture.createApiKey(validApiKeyUser.id, now);
const context: ExecutionContext = {
switchToHttp: () => ({
getRequest: () => ({
headers: {
authorization: `Bearer cal_test_${keyString}`,
},
get: (key: string) =>
({ Authorization: `Bearer cal_test_${keyString}`, origin: "http://localhost:3000" }[key]),
}),
}),
} as ExecutionContext;
const request = context.switchToHttp().getRequest();
const user = await strategy.apiKeyStrategy(keyString);
await expect(user).toBeDefined();
if (user) expect(user.id).toEqual(validApiKeyUser.id);
expect(user).toBeDefined();
expect(user?.id).toEqual(validApiKeyUser.id);
});
it("should return user associated with valid OAuth client", async () => {
const user = await strategy.oAuthClientStrategy(oAuthClient.id, oAuthClient.secret);
expect(user).toBeDefined();
expect(user.id).toEqual(validOAuthUser.id);
});
it("should throw 401 if api key is invalid", async () => {
@@ -158,7 +162,55 @@ describe("ApiAuthStrategy", () => {
}
});
it("should throw 401 if Authorization header does not contain auth token", async () => {
it("should throw 401 if OAuth ID is invalid", async () => {
const context: ExecutionContext = {
switchToHttp: () => ({
getRequest: () => ({
headers: {
[X_CAL_CLIENT_ID]: `${oAuthClient.id}gibberish`,
[X_CAL_SECRET_KEY]: `secret`,
},
get: (key: string) =>
({ Authorization: `Bearer cal_test_badkey1234`, origin: "http://localhost:3000" }[key]),
}),
}),
} as ExecutionContext;
const request = context.switchToHttp().getRequest();
try {
await strategy.authenticate(request);
} catch (error) {
if (error instanceof HttpException) {
expect(error.getStatus()).toEqual(401);
}
}
});
it("should throw 401 if OAuth secret is invalid", async () => {
const context: ExecutionContext = {
switchToHttp: () => ({
getRequest: () => ({
headers: {
[X_CAL_CLIENT_ID]: `${oAuthClient.id}`,
[X_CAL_SECRET_KEY]: `gibberish`,
},
get: (key: string) =>
({ Authorization: `Bearer cal_test_badkey1234`, origin: "http://localhost:3000" }[key]),
}),
}),
} as ExecutionContext;
const request = context.switchToHttp().getRequest();
try {
await strategy.authenticate(request);
} catch (error) {
if (error instanceof HttpException) {
expect(error.getStatus()).toEqual(401);
}
}
});
it("should throw 401 if request does not contain Bearer token nor OAuth client credentials", async () => {
const context: ExecutionContext = {
switchToHttp: () => ({
getRequest: () => ({
@@ -179,8 +231,11 @@ describe("ApiAuthStrategy", () => {
});
afterAll(async () => {
await userRepositoryFixture.deleteByEmail(validApiKeyEmail);
await userRepositoryFixture.deleteByEmail(validAccessTokenEmail);
await oAuthClientRepositoryFixture.delete(oAuthClient.id);
await userRepositoryFixture.delete(validApiKeyUser.id);
await userRepositoryFixture.delete(validAccessTokenUser.id);
await userRepositoryFixture.delete(validOAuthUser.id);
await teamRepositoryFixture.delete(organization.id);
module.close();
});
});
@@ -2,7 +2,9 @@ import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key";
import { BaseStrategy } from "@/lib/passport/strategies/types";
import { ApiKeyRepository } from "@/modules/api-key/api-key-repository";
import { DeploymentsService } from "@/modules/deployments/deployments.service";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
import { ProfilesRepository } from "@/modules/profiles/profiles.repository";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository";
import { Injectable, InternalServerErrorException, UnauthorizedException } from "@nestjs/common";
@@ -10,7 +12,7 @@ import { ConfigService } from "@nestjs/config";
import { PassportStrategy } from "@nestjs/passport";
import type { Request } from "express";
import { INVALID_ACCESS_TOKEN } from "@calcom/platform-constants";
import { INVALID_ACCESS_TOKEN, X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
@Injectable()
export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth") {
@@ -20,19 +22,74 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
private readonly oauthFlowService: OAuthFlowService,
private readonly tokensRepository: TokensRepository,
private readonly userRepository: UsersRepository,
private readonly apiKeyRepository: ApiKeyRepository
private readonly apiKeyRepository: ApiKeyRepository,
private readonly oauthRepository: OAuthClientRepository,
private readonly profilesRepository: ProfilesRepository
) {
super();
}
async authenticate(request: Request) {
const authString = request.get("Authorization")?.replace("Bearer ", "");
if (!authString) {
return this.error(new UnauthorizedException("No Authorization header provided"));
try {
const { params } = request;
const oAuthClientSecret = request.get(X_CAL_SECRET_KEY);
const oAuthClientId = params.clientId || request.get(X_CAL_CLIENT_ID);
const bearerToken = request.get("Authorization")?.replace("Bearer ", "");
if (oAuthClientId && oAuthClientSecret) {
return await this.authenticateOAuthClient(oAuthClientId, oAuthClientSecret);
}
if (bearerToken) {
const requestOrigin = request.get("Origin");
return await this.authenticateBearerToken(bearerToken, requestOrigin);
}
throw new UnauthorizedException(
"No authentication method provided. Either pass an API key as 'Bearer' header or OAuth client credentials as 'x-cal-secret-key' and 'x-cal-client-id' headers"
);
} catch (err) {
if (err instanceof Error) {
return this.error(err);
}
return this.error(
new InternalServerErrorException("An error occurred while authenticating the request")
);
}
}
async authenticateOAuthClient(oAuthClientId: string, oAuthClientSecret: string) {
const user = await this.oAuthClientStrategy(oAuthClientId, oAuthClientSecret);
return this.success(user);
}
async oAuthClientStrategy(oAuthClientId: string, oAuthClientSecret: string) {
const client = await this.oauthRepository.getOAuthClient(oAuthClientId);
if (!client) {
throw new UnauthorizedException(`Client with ID ${oAuthClientId} not found`);
}
const requestOrigin = request.get("Origin");
if (client.secret !== oAuthClientSecret) {
throw new UnauthorizedException("Invalid client secret");
}
const platformCreatorId = await this.profilesRepository.getPlatformOwnerUserId(client.organizationId);
if (!platformCreatorId) {
throw new UnauthorizedException("No owner ID found for this OAuth client");
}
const user = await this.userRepository.findByIdWithProfile(platformCreatorId);
if (!user) {
throw new UnauthorizedException("No user associated with the provided OAuth client");
}
return user;
}
async authenticateBearerToken(authString: string, requestOrigin: string | undefined) {
try {
const user = isApiKey(authString, this.config.get<string>("api.apiKeyPrefix") ?? "cal_")
? await this.apiKeyStrategy(authString)
+2
View File
@@ -4,3 +4,5 @@ export enum PlatformPlan {
SCALE = "SCALE",
ENTERPRISE = "ENTERPRISE",
}
export type PlatformPlanType = "STARTER" | "ESSENTIALS" | "SCALE" | "ENTERPRISE";
@@ -1,6 +1,6 @@
import { Injectable } from "@nestjs/common";
import { sendSignupToOrganizationEmail, getTranslation } from "@calcom/platform-libraries-0.0.21";
import { sendSignupToOrganizationEmail, getTranslation } from "@calcom/platform-libraries-0.0.23";
@Injectable()
export class EmailService {
+9 -1
View File
@@ -6,9 +6,17 @@ import type { MiddlewareConsumer, NestModule } from "@nestjs/common";
import { Module } from "@nestjs/common";
import { UsersModule } from "./users/users.module";
import { WebhooksModule } from "./webhooks/webhooks.module";
@Module({
imports: [OAuthClientModule, BillingModule, PlatformEndpointsModule, TimezoneModule, UsersModule],
imports: [
OAuthClientModule,
BillingModule,
PlatformEndpointsModule,
TimezoneModule,
UsersModule,
WebhooksModule,
],
})
export class EndpointsModule implements NestModule {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@@ -0,0 +1,295 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
import { UserWithProfile } from "@/modules/users/users.repository";
import { CreateWebhookInputDto, UpdateWebhookInputDto } from "@/modules/webhooks/inputs/webhook.input";
import {
EventTypeWebhookOutputResponseDto,
EventTypeWebhooksOutputResponseDto,
} from "@/modules/webhooks/outputs/event-type-webhook.output";
import { DeleteManyWebhooksOutputResponseDto } from "@/modules/webhooks/outputs/webhook.output";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import * as request from "supertest";
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { WebhookRepositoryFixture } from "test/fixtures/repository/webhooks.repository.fixture";
import { withApiAuth } from "test/utils/withApiAuth";
import { EventType, Webhook } from "@calcom/prisma/client";
describe("EventTypes WebhooksController (e2e)", () => {
let app: INestApplication;
const userEmail = "event-types-webhook-controller-e2e@api.com";
let user: UserWithProfile;
let otherUser: UserWithProfile;
let eventType: EventType;
let eventType2: EventType;
let otherEventType: EventType;
let eventTypeRepositoryFixture: EventTypesRepositoryFixture;
let userRepositoryFixture: UserRepositoryFixture;
let webhookRepositoryFixture: WebhookRepositoryFixture;
let webhook: EventTypeWebhookOutputResponseDto["data"];
let webhook2: Webhook;
let otherWebhook: Webhook;
beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
})
).compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
webhookRepositoryFixture = new WebhookRepositoryFixture(moduleRef);
eventTypeRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
user = await userRepositoryFixture.create({
email: userEmail,
username: userEmail,
});
otherUser = await userRepositoryFixture.create({
email: "other-user-webhook-controller@api.com",
username: "other-user-webhook-controller@api.com",
});
eventType = await eventTypeRepositoryFixture.create(
{
title: "Event Type 1",
slug: "webhook-event-type-1",
length: 60,
},
user.id
);
eventType2 = await eventTypeRepositoryFixture.create(
{
title: "Event Type 2",
slug: "webhook-event-type-2",
length: 60,
},
user.id
);
otherEventType = await eventTypeRepositoryFixture.create(
{
title: "Other Event Type ",
slug: "other-webhook-event-type",
length: 60,
},
otherUser.id
);
otherWebhook = await webhookRepositoryFixture.create({
id: "2mdfnn24",
subscriberUrl: "https://example.com",
eventTriggers: ["BOOKING_CREATED", "BOOKING_RESCHEDULED", "BOOKING_CANCELLED"],
active: true,
payloadTemplate: "string",
});
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
afterAll(async () => {
userRepositoryFixture.deleteByEmail(user.email);
userRepositoryFixture.deleteByEmail(otherUser.email);
webhookRepositoryFixture.delete(otherWebhook.id);
await app.close();
});
it("/webhooks (POST)", () => {
return request(app.getHttpServer())
.post(`/v2/event-types/${eventType.id}/webhooks`)
.send({
subscriberUrl: "https://example.com",
triggers: ["BOOKING_CREATED", "BOOKING_RESCHEDULED", "BOOKING_CANCELLED"],
active: true,
payloadTemplate: "string",
} satisfies CreateWebhookInputDto)
.expect(201)
.then(async (res) => {
expect(res.body).toMatchObject({
status: "success",
data: {
id: expect.any(String),
subscriberUrl: "https://example.com",
triggers: ["BOOKING_CREATED", "BOOKING_RESCHEDULED", "BOOKING_CANCELLED"],
active: true,
payloadTemplate: "string",
eventTypeId: eventType.id,
},
} satisfies EventTypeWebhookOutputResponseDto);
webhook = res.body.data;
});
});
it("/webhooks (POST)", () => {
return request(app.getHttpServer())
.post(`/v2/event-types/${eventType2.id}/webhooks`)
.send({
subscriberUrl: "https://example.com",
triggers: ["BOOKING_CREATED", "BOOKING_RESCHEDULED", "BOOKING_CANCELLED"],
active: true,
payloadTemplate: "string",
} satisfies CreateWebhookInputDto)
.expect(201)
.then(async (res) => {
expect(res.body).toMatchObject({
status: "success",
data: {
id: expect.any(String),
subscriberUrl: "https://example.com",
triggers: ["BOOKING_CREATED", "BOOKING_RESCHEDULED", "BOOKING_CANCELLED"],
active: true,
payloadTemplate: "string",
eventTypeId: eventType2.id,
},
} satisfies EventTypeWebhookOutputResponseDto);
webhook2 = res.body.data;
});
});
it("/webhooks (POST) should fail to create a webhook for an event-type that does not belong to user", () => {
return request(app.getHttpServer())
.post(`/v2/event-types/${otherEventType.id}/webhooks`)
.send({
subscriberUrl: "https://example.com",
triggers: ["BOOKING_CREATED", "BOOKING_RESCHEDULED", "BOOKING_CANCELLED"],
active: true,
payloadTemplate: "string",
} satisfies CreateWebhookInputDto)
.expect(403);
});
it("/event-types/:eventTypeId/webhooks/:webhookId (PATCH)", () => {
return request(app.getHttpServer())
.patch(`/v2/event-types/${eventType.id}/webhooks/${webhook.id}`)
.send({
active: false,
} satisfies UpdateWebhookInputDto)
.expect(200)
.then((res) => {
expect(res.body.data.active).toBe(false);
});
});
it("/event-types/:eventTypeId/webhooks/:webhookId (PATCH) should fail to patch a webhook for an event-type that does not belong to user", () => {
return request(app.getHttpServer())
.patch(`/v2/event-types/${otherEventType.id}/webhooks/${otherWebhook.id}`)
.send({
active: false,
} satisfies UpdateWebhookInputDto)
.expect(403);
});
it("/event-types/:eventTypeId/webhooks/:webhookId (GET)", () => {
return request(app.getHttpServer())
.get(`/v2/event-types/${eventType.id}/webhooks/${webhook.id}`)
.expect(200)
.then((res) => {
expect(res.body).toMatchObject({
status: "success",
data: {
id: expect.any(String),
subscriberUrl: "https://example.com",
triggers: ["BOOKING_CREATED", "BOOKING_RESCHEDULED", "BOOKING_CANCELLED"],
active: false,
payloadTemplate: "string",
eventTypeId: eventType.id,
},
} satisfies EventTypeWebhookOutputResponseDto);
});
});
it("/event-types/:eventTypeId/webhooks/:webhookId (GET) should fail to get a webhook that does not exist", () => {
return request(app.getHttpServer()).get(`/v2/event-types/${eventType.id}/webhooks/90284`).expect(404);
});
it("/event-types/:eventTypeId/webhooks/:webhookId (GET) should fail to get a webhook of an eventType that does not belong to user", () => {
return request(app.getHttpServer())
.get(`/v2/event-types/${otherEventType.id}/webhooks/${otherWebhook.id}`)
.expect(403);
});
it("/event-types/:eventTypeId/webhooks/:webhookId (GET) should fail to get a webhook that does not belong to the eventType", () => {
return request(app.getHttpServer())
.get(`/v2/event-types/${eventType.id}/webhooks/${otherWebhook.id}`)
.expect(400);
});
it("/webhooks (GET)", () => {
return request(app.getHttpServer())
.get(`/v2/event-types/${eventType.id}/webhooks`)
.expect(200)
.then((res) => {
const responseBody = res.body as EventTypeWebhooksOutputResponseDto;
responseBody.data.forEach((webhook) => {
expect(webhook.eventTypeId).toBe(eventType.id);
});
});
});
it("/event-types/:eventTypeId/webhooks (GET)", () => {
return request(app.getHttpServer())
.get(`/v2/event-types/${eventType2.id}/webhooks`)
.expect(200)
.then((res) => {
const responseBody = res.body as EventTypeWebhooksOutputResponseDto;
responseBody.data.forEach((webhook) => {
expect(webhook.eventTypeId).toBe(eventType2.id);
});
});
});
it("/event-types/:eventTypeId/webhooks/:webhookId (DELETE)", () => {
return request(app.getHttpServer())
.delete(`/v2/event-types/${eventType.id}/webhooks/${webhook.id}`)
.expect(200)
.then((res) => {
expect(res.body).toMatchObject({
status: "success",
data: {
id: expect.any(String),
subscriberUrl: "https://example.com",
triggers: ["BOOKING_CREATED", "BOOKING_RESCHEDULED", "BOOKING_CANCELLED"],
active: false,
payloadTemplate: "string",
eventTypeId: eventType.id,
},
} satisfies EventTypeWebhookOutputResponseDto);
});
});
it("/event-types/:eventTypeId/webhooks (DELETE)", () => {
return request(app.getHttpServer())
.delete(`/v2/event-types/${eventType2.id}/webhooks`)
.expect(200)
.then((res) => {
expect(res.body).toMatchObject({
status: "success",
data: "1 webhooks deleted",
} satisfies DeleteManyWebhooksOutputResponseDto);
});
});
it("/event-types/:eventTypeId/webhooks/:webhookId (DELETE) shoud fail to delete a webhook that does not exist", () => {
return request(app.getHttpServer())
.delete(`/v2/event-types/${eventType.id}/webhooks/1234453`)
.expect(404);
});
it("/event-types/:eventTypeId/webhooks/:webhookId (DELETE) shoud fail to delete a webhook that does not belong to user", () => {
return request(app.getHttpServer())
.delete(`/v2/event-types/${otherEventType.id}/webhooks/${otherWebhook.id}`)
.expect(403);
});
});
@@ -0,0 +1,135 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { GetWebhook } from "@/modules/webhooks/decorators/get-webhook-decorator";
import { IsUserEventTypeWebhookGuard } from "@/modules/webhooks/guards/is-user-event-type-webhook-guard";
import { CreateWebhookInputDto, UpdateWebhookInputDto } from "@/modules/webhooks/inputs/webhook.input";
import {
EventTypeWebhookOutputResponseDto,
EventTypeWebhookOutputDto,
EventTypeWebhooksOutputResponseDto,
} from "@/modules/webhooks/outputs/event-type-webhook.output";
import { DeleteManyWebhooksOutputResponseDto } from "@/modules/webhooks/outputs/webhook.output";
import { PartialWebhookInputPipe, WebhookInputPipe } from "@/modules/webhooks/pipes/WebhookInputPipe";
import { WebhookOutputPipe } from "@/modules/webhooks/pipes/WebhookOutputPipe";
import { EventTypeWebhooksService } from "@/modules/webhooks/services/event-type-webhooks.service";
import { WebhooksService } from "@/modules/webhooks/services/webhooks.service";
import {
Controller,
Post,
Body,
UseGuards,
Get,
Param,
Query,
Delete,
Patch,
ParseIntPipe,
} from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { Webhook } from "@prisma/client";
import { plainToClass } from "class-transformer";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { SkipTakePagination } from "@calcom/platform-types";
@Controller({
path: "/v2/event-types/:eventTypeId/webhooks",
version: API_VERSIONS_VALUES,
})
@UseGuards(ApiAuthGuard, IsUserEventTypeWebhookGuard)
@ApiTags("Users' EventTypes Webhooks")
export class EventTypeWebhooksController {
constructor(
private readonly webhooksService: WebhooksService,
private readonly eventTypeWebhooksService: EventTypeWebhooksService
) {}
@Post("/")
@ApiOperation({ summary: "Create a webhook for an event-type" })
async createEventTypeWebhook(
@Body() body: CreateWebhookInputDto,
@Param("eventTypeId", ParseIntPipe) eventTypeId: number
): Promise<EventTypeWebhookOutputResponseDto> {
const webhook = await this.eventTypeWebhooksService.createEventTypeWebhook(
eventTypeId,
new WebhookInputPipe().transform(body)
);
return {
status: SUCCESS_STATUS,
data: plainToClass(EventTypeWebhookOutputDto, new WebhookOutputPipe().transform(webhook), {
strategy: "excludeAll",
}),
};
}
@Patch("/:webhookId")
@ApiOperation({ summary: "Update a webhook of an event-type" })
async updateEventTypeWebhook(
@Body() body: UpdateWebhookInputDto,
@Param("webhookId") webhookId: string
): Promise<EventTypeWebhookOutputResponseDto> {
const webhook = await this.webhooksService.updateWebhook(
webhookId,
new PartialWebhookInputPipe().transform(body)
);
return {
status: SUCCESS_STATUS,
data: plainToClass(EventTypeWebhookOutputDto, new WebhookOutputPipe().transform(webhook), {
strategy: "excludeAll",
}),
};
}
@Get("/:webhookId")
@ApiOperation({ summary: "Get a webhook of an event-type" })
async getEventTypeWebhook(@GetWebhook() webhook: Webhook): Promise<EventTypeWebhookOutputResponseDto> {
return {
status: SUCCESS_STATUS,
data: plainToClass(EventTypeWebhookOutputDto, new WebhookOutputPipe().transform(webhook), {
strategy: "excludeAll",
}),
};
}
@Get("/")
@ApiOperation({ summary: "Get all webhooks of an event-type" })
async getEventTypeWebhooks(
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
@Query() pagination: SkipTakePagination
): Promise<EventTypeWebhooksOutputResponseDto> {
const webhooks = await this.eventTypeWebhooksService.getEventTypeWebhooksPaginated(
eventTypeId,
pagination.skip ?? 0,
pagination.take ?? 250
);
return {
status: SUCCESS_STATUS,
data: webhooks.map((webhook) =>
plainToClass(EventTypeWebhookOutputDto, new WebhookOutputPipe().transform(webhook), {
strategy: "excludeAll",
})
),
};
}
@Delete("/:webhookId")
@ApiOperation({ summary: "Delete a webhook of an event-type" })
async deleteEventTypeWebhook(@GetWebhook() webhook: Webhook): Promise<EventTypeWebhookOutputResponseDto> {
await this.webhooksService.deleteWebhook(webhook.id);
return {
status: SUCCESS_STATUS,
data: plainToClass(EventTypeWebhookOutputDto, new WebhookOutputPipe().transform(webhook), {
strategy: "excludeAll",
}),
};
}
@Delete("/")
@ApiOperation({ summary: "Delete all webhooks of an event-type" })
async deleteAllEventTypeWebhooks(
@Param("eventTypeId", ParseIntPipe) eventTypeId: number
): Promise<DeleteManyWebhooksOutputResponseDto> {
const data = await this.eventTypeWebhooksService.deleteAllEventTypeWebhooks(eventTypeId);
return { status: SUCCESS_STATUS, data: `${data.count} webhooks deleted` };
}
}
@@ -14,10 +14,11 @@ import { UsersModule } from "@/modules/users/users.module";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import { PlatformOAuthClient, Team, User } from "@prisma/client";
import { PlatformOAuthClient, Team, User, EventType } from "@prisma/client";
import * as request from "supertest";
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.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";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
@@ -78,9 +79,13 @@ describe("OAuth Client Users Endpoints", () => {
let teamRepositoryFixture: TeamRepositoryFixture;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let schedulesRepositoryFixture: SchedulesRepositoryFixture;
let profilesRepositoryFixture: ProfileRepositoryFixture;
let postResponseData: CreateUserResponse;
const platformAdminEmail = "platform-sensei@mail.com";
let platformAdmin: User;
const userEmail = "oauth-client-user@gmail.com";
const userTimeZone = "Europe/Rome";
@@ -98,9 +103,27 @@ describe("OAuth Client Users Endpoints", () => {
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
schedulesRepositoryFixture = new SchedulesRepositoryFixture(moduleRef);
profilesRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
platformAdmin = await userRepositoryFixture.create({ email: platformAdminEmail });
organization = await teamRepositoryFixture.create({ name: "organization" });
oAuthClient = await createOAuthClient(organization.id);
await profilesRepositoryFixture.create({
uid: "asd-asd",
username: platformAdminEmail,
organization: { connect: { id: organization.id } },
movedFromUser: {
connect: {
id: platformAdmin.id,
},
},
user: {
connect: { id: platformAdmin.id },
},
});
await app.init();
});
@@ -188,10 +211,16 @@ describe("OAuth Client Users Endpoints", () => {
// note(Lauris): to determine count see default event types created in EventTypesService.createUserDefaultEventTypes
expect(defaultEventTypes?.length).toEqual(4);
expect(
defaultEventTypes?.find((eventType) => eventType.length === DEFAULT_EVENT_TYPES.thirtyMinutes.length)
defaultEventTypes?.find((eventType) => eventType.slug === DEFAULT_EVENT_TYPES.thirtyMinutes.slug)
).toBeTruthy();
expect(
defaultEventTypes?.find((eventType) => eventType.length === DEFAULT_EVENT_TYPES.sixtyMinutes.length)
defaultEventTypes?.find((eventType) => eventType.slug === DEFAULT_EVENT_TYPES.sixtyMinutes.slug)
).toBeTruthy();
expect(
defaultEventTypes?.find((eventType) => eventType.slug === DEFAULT_EVENT_TYPES.thirtyMinutesVideo.slug)
).toBeTruthy();
expect(
defaultEventTypes?.find((eventType) => eventType.slug === DEFAULT_EVENT_TYPES.sixtyMinutesVideo.slug)
).toBeTruthy();
}
@@ -277,6 +306,250 @@ describe("OAuth Client Users Endpoints", () => {
} catch (e) {
// User might have been deleted by the test
}
try {
await userRepositoryFixture.delete(platformAdmin.id);
} catch (e) {
// User might have been deleted by the test
}
await app.close();
});
});
describe("User team even-types", () => {
let app: INestApplication;
let oAuthClient1: PlatformOAuthClient;
let oAuthClient2: PlatformOAuthClient;
let organization: Team;
let team1: Team;
let team2: Team;
let owner: User;
let managedEventType1: EventType;
let userRepositoryFixture: UserRepositoryFixture;
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
let teamRepositoryFixture: TeamRepositoryFixture;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let profileRepositoryFixture: ProfileRepositoryFixture;
let postResponseData: CreateUserResponse;
const userEmail = "oauth-client-users-user@gmail.com";
const userTimeZone = "Europe/Rome";
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
providers: [PrismaExceptionFilter, HttpExceptionFilter],
imports: [AppModule, UsersModule],
}).compile();
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
organization = await teamRepositoryFixture.create({
name: "Testy Organization",
isOrganization: true,
});
owner = await userRepositoryFixture.create({
email: userEmail,
username: userEmail,
organization: { connect: { id: organization.id } },
});
await profileRepositoryFixture.create({
uid: `usr-${owner.id}`,
username: userEmail,
organization: {
connect: {
id: organization.id,
},
},
user: {
connect: {
id: owner.id,
},
},
});
oAuthClient1 = await createOAuthClient(organization.id);
oAuthClient2 = await createOAuthClient(organization.id);
team1 = await teamRepositoryFixture.create({
name: "Testy org team",
isOrganization: false,
parent: { connect: { id: organization.id } },
createdByOAuthClient: { connect: { id: oAuthClient1.id } },
});
team2 = await teamRepositoryFixture.create({
name: "Testy org team 2",
isOrganization: false,
parent: { connect: { id: organization.id } },
createdByOAuthClient: { connect: { id: oAuthClient2.id } },
});
// note(Lauris): team1 team event-types
await eventTypesRepositoryFixture.createTeamEventType({
schedulingType: "COLLECTIVE",
team: {
connect: { id: team1.id },
},
title: "Collective Event Type",
slug: "collective-event-type",
length: 30,
assignAllTeamMembers: true,
bookingFields: [],
locations: [],
});
managedEventType1 = await eventTypesRepositoryFixture.createTeamEventType({
schedulingType: "MANAGED",
team: {
connect: { id: team1.id },
},
title: "Managed Event Type",
slug: "managed-event-type",
length: 60,
assignAllTeamMembers: true,
bookingFields: [],
locations: [],
});
// note(Lauris): team2 team event-types
await eventTypesRepositoryFixture.createTeamEventType({
schedulingType: "COLLECTIVE",
team: {
connect: { id: team2.id },
},
title: "Collective Event Type team 2",
slug: "collective-event-type-team-2",
length: 30,
assignAllTeamMembers: true,
bookingFields: [],
locations: [],
});
await eventTypesRepositoryFixture.createTeamEventType({
schedulingType: "MANAGED",
team: {
connect: { id: team2.id },
},
title: "Managed Event Type team 2",
slug: "managed-event-type-team-2",
length: 60,
assignAllTeamMembers: true,
bookingFields: [],
locations: [],
});
await app.init();
});
async function createOAuthClient(organizationId: number) {
const data = {
logo: "logo-url",
name: "name",
redirectUris: [CLIENT_REDIRECT_URI],
permissions: 32,
};
const secret = "secret";
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
return client;
}
it("should be defined", () => {
expect(oauthClientRepositoryFixture).toBeDefined();
expect(userRepositoryFixture).toBeDefined();
expect(oAuthClient1).toBeDefined();
});
it(`should create managed user and update team event-types of OAuthClient marked as assignAllTeamMembers: true`, async () => {
const requestBody: CreateManagedUserInput = {
email: userEmail,
timeZone: userTimeZone,
weekStart: "Monday",
timeFormat: 24,
locale: Locales.FR,
};
const response = await request(app.getHttpServer())
.post(`/api/v2/oauth-clients/${oAuthClient1.id}/users`)
.set("x-cal-secret-key", oAuthClient1.secret)
.send(requestBody)
.expect(201);
const responseBody: ApiSuccessResponse<{
user: Omit<User, "password">;
accessToken: string;
refreshToken: string;
}> = response.body;
postResponseData = responseBody.data;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
await userHasCorrectEventTypes(responseBody.data.user.id);
await teamHasCorrectEventTypes(team1.id);
});
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 () => {
await oauthClientRepositoryFixture.delete(oAuthClient1.id);
await teamRepositoryFixture.delete(organization.id);
await userRepositoryFixture.delete(owner.id);
try {
await userRepositoryFixture.delete(postResponseData.user.id);
} catch (e) {
// User might have been deleted by the test
}
await app.close();
});
});
@@ -1,11 +1,12 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { Locales } from "@/lib/enums/locales";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.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";
import { ManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/managed-user.output";
import { KeysResponseDto } from "@/modules/oauth-clients/controllers/oauth-flow/responses/KeysResponse.dto";
import { OAuthClientCredentialsGuard } from "@/modules/oauth-clients/guards/oauth-client-credentials/oauth-client-credentials.guard";
import { OAuthClientGuard } from "@/modules/oauth-clients/guards/oauth-client-guard";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
@@ -37,7 +38,7 @@ import { Pagination } from "@calcom/platform-types";
path: "/v2/oauth-clients/:clientId/users",
version: API_VERSIONS_VALUES,
})
@UseGuards(OAuthClientCredentialsGuard)
@UseGuards(ApiAuthGuard, OAuthClientGuard)
@DocsTags("Managed users")
export class OAuthClientUsersController {
private readonly logger = new Logger("UserController");
@@ -78,6 +79,7 @@ export class OAuthClientUsersController {
`Creating user with data: ${JSON.stringify(body, null, 2)} for OAuth Client with ID ${oAuthClientId}`
);
const client = await this.oauthRepository.getOAuthClient(oAuthClientId);
console.log("asap createUser client", JSON.stringify(client, null, 2));
const isPlatformManaged = true;
const { user, tokens } = await this.oAuthClientUsersService.createOauthClientUser(
@@ -9,6 +9,7 @@ import { ManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-cli
import { CreateOAuthClientResponseDto } from "@/modules/oauth-clients/controllers/oauth-clients/responses/CreateOAuthClientResponse.dto";
import { GetOAuthClientResponseDto } from "@/modules/oauth-clients/controllers/oauth-clients/responses/GetOAuthClientResponse.dto";
import { GetOAuthClientsResponseDto } from "@/modules/oauth-clients/controllers/oauth-clients/responses/GetOAuthClientsResponse.dto";
import { OAuthClientGuard } from "@/modules/oauth-clients/guards/oauth-client-guard";
import { UpdateOAuthClientInput } from "@/modules/oauth-clients/inputs/update-oauth-client.input";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
@@ -109,6 +110,7 @@ export class OAuthClientsController {
@HttpCode(HttpStatus.OK)
@MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER, MembershipRole.MEMBER])
@DocsOperation({ description: AUTH_DOCUMENTATION })
@UseGuards(OAuthClientGuard)
async getOAuthClientById(@Param("clientId") clientId: string): Promise<GetOAuthClientResponseDto> {
const client = await this.oauthClientRepository.getOAuthClient(clientId);
if (!client) {
@@ -121,6 +123,7 @@ export class OAuthClientsController {
@HttpCode(HttpStatus.OK)
@MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER, MembershipRole.MEMBER])
@DocsOperation({ description: AUTH_DOCUMENTATION })
@UseGuards(OAuthClientGuard)
async getOAuthClientManagedUsersById(
@Param("clientId") clientId: string,
@Query() queryParams: Pagination
@@ -139,6 +142,7 @@ export class OAuthClientsController {
@HttpCode(HttpStatus.OK)
@MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER])
@DocsOperation({ description: AUTH_DOCUMENTATION })
@UseGuards(OAuthClientGuard)
async updateOAuthClient(
@Param("clientId") clientId: string,
@Body() body: UpdateOAuthClientInput
@@ -152,6 +156,7 @@ export class OAuthClientsController {
@HttpCode(HttpStatus.OK)
@MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER])
@DocsOperation({ description: AUTH_DOCUMENTATION })
@UseGuards(OAuthClientGuard)
async deleteOAuthClient(@Param("clientId") clientId: string): Promise<GetOAuthClientResponseDto> {
this.logger.log(`Deleting OAuth Client with ID: ${clientId}`);
const client = await this.oauthClientRepository.deleteOAuthClient(clientId);
@@ -15,6 +15,8 @@ import { Test, TestingModule } from "@nestjs/testing";
import { PlatformOAuthClient, Team, User } from "@prisma/client";
import * as request from "supertest";
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 { withNextAuth } from "test/utils/withNextAuth";
@@ -56,8 +58,9 @@ describe("OAuthFlow Endpoints", () => {
let app: INestApplication;
let usersRepositoryFixtures: UserRepositoryFixture;
let organizationsRepositoryFixture: TeamRepositoryFixture;
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let oAuthClientsRepositoryFixture: OAuthClientRepositoryFixture;
let profilesRepositoryFixture: ProfileRepositoryFixture;
let user: User;
let organization: Team;
@@ -81,13 +84,22 @@ describe("OAuthFlow Endpoints", () => {
await app.init();
oAuthClientsRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
usersRepositoryFixtures = new UserRepositoryFixture(moduleRef);
profilesRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
user = await usersRepositoryFixtures.create({
email: userEmail,
});
organization = await organizationsRepositoryFixture.create({ name: "organization" });
await profilesRepositoryFixture.create({
uid: "asd-asd",
username: userEmail,
user: { connect: { id: user.id } },
movedFromUser: { connect: { id: user.id } },
organization: { connect: { id: organization.id } },
});
oAuthClient = await createOAuthClient(organization.id);
});
@@ -1,9 +1,9 @@
import { getEnv } from "@/env";
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { NextAuthGuard } from "@/modules/auth/guards/next-auth/next-auth.guard";
import { KeysResponseDto } from "@/modules/oauth-clients/controllers/oauth-flow/responses/KeysResponse.dto";
import { OAuthClientCredentialsGuard } from "@/modules/oauth-clients/guards/oauth-client-credentials/oauth-client-credentials.guard";
import { OAuthAuthorizeInput } from "@/modules/oauth-clients/inputs/authorize.input";
import { ExchangeAuthorizationCodeInput } from "@/modules/oauth-clients/inputs/exchange-code.input";
import { RefreshTokenInput } from "@/modules/oauth-clients/inputs/refresh-token.input";
@@ -137,7 +137,7 @@ export class OAuthFlowController {
@Post("/refresh")
@HttpCode(HttpStatus.OK)
@UseGuards(OAuthClientCredentialsGuard)
@UseGuards(ApiAuthGuard)
async refreshAccessToken(
@Param("clientId") clientId: string,
@Headers(X_CAL_SECRET_KEY) secretKey: string,
@@ -1,94 +0,0 @@
import { AppModule } from "@/app.module";
import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module";
import { createMock } from "@golevelup/ts-jest";
import { ExecutionContext, UnauthorizedException } from "@nestjs/common";
import { Test, TestingModule } from "@nestjs/testing";
import { PlatformOAuthClient, Team } from "@prisma/client";
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { X_CAL_SECRET_KEY } from "@calcom/platform-constants";
import { OAuthClientCredentialsGuard } from "./oauth-client-credentials.guard";
describe("OAuthClientCredentialsGuard", () => {
let guard: OAuthClientCredentialsGuard;
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
let teamRepositoryFixture: TeamRepositoryFixture;
let oauthClient: PlatformOAuthClient;
let organization: Team;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [AppModule, OAuthClientModule],
}).compile();
guard = module.get<OAuthClientCredentialsGuard>(OAuthClientCredentialsGuard);
teamRepositoryFixture = new TeamRepositoryFixture(module);
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(module);
organization = await teamRepositoryFixture.create({ name: "organization" });
const data = {
logo: "logo-url",
name: "name",
redirectUris: ["redirect-uri"],
permissions: 32,
};
const secret = "secret";
oauthClient = await oauthClientRepositoryFixture.create(organization.id, data, secret);
});
it("should be defined", () => {
expect(guard).toBeDefined();
expect(oauthClient).toBeDefined();
});
it("should return true if client ID and secret are valid", async () => {
const mockContext = createMockExecutionContext(
{ [X_CAL_SECRET_KEY]: oauthClient.secret },
{ clientId: oauthClient.id }
);
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
});
it("should return false if client ID is invalid", async () => {
const mockContext = createMockExecutionContext(
{ [X_CAL_SECRET_KEY]: oauthClient.secret },
{ clientId: "invalid id" }
);
await expect(guard.canActivate(mockContext)).rejects.toThrow(UnauthorizedException);
});
it("should return false if secret key is invalid", async () => {
const mockContext = createMockExecutionContext(
{ [X_CAL_SECRET_KEY]: "invalid secret" },
{ clientId: oauthClient.id }
);
await expect(guard.canActivate(mockContext)).rejects.toThrow(UnauthorizedException);
});
afterAll(async () => {
await oauthClientRepositoryFixture.delete(oauthClient.id);
await teamRepositoryFixture.delete(organization.id);
});
function createMockExecutionContext(
headers: Record<string, string>,
params: Record<string, string>
): ExecutionContext {
return createMock<ExecutionContext>({
switchToHttp: () => ({
getRequest: () => ({
headers,
params,
get: (headerName: string) => headers[headerName],
}),
}),
});
}
});
@@ -1,33 +0,0 @@
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from "@nestjs/common";
import { Request } from "express";
import { X_CAL_SECRET_KEY } from "@calcom/platform-constants";
@Injectable()
export class OAuthClientCredentialsGuard implements CanActivate {
constructor(private readonly oauthRepository: OAuthClientRepository) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request>();
const { params } = request;
const oauthClientId = params.clientId;
const oauthClientSecret = request.get(X_CAL_SECRET_KEY);
if (!oauthClientId) {
throw new UnauthorizedException("Missing client ID");
}
if (!oauthClientSecret) {
throw new UnauthorizedException("Missing client secret");
}
const client = await this.oauthRepository.getOAuthClient(oauthClientId);
if (!client || client.secret !== oauthClientSecret) {
throw new UnauthorizedException("Invalid client credentials");
}
return true;
}
}
@@ -0,0 +1,38 @@
import { GetUserReturnType } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import {
Injectable,
CanActivate,
ExecutionContext,
ForbiddenException,
NotFoundException,
} from "@nestjs/common";
import { Request } from "express";
@Injectable()
export class OAuthClientGuard implements CanActivate {
constructor(private oAuthClientRepository: OAuthClientRepository) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request & { user: GetUserReturnType }>();
const user: GetUserReturnType = request.user;
const organizationId = user?.movedToProfile?.organizationId || user?.organizationId;
const oAuthClientId = request.params.clientId;
if (!oAuthClientId) {
throw new ForbiddenException("No OAuth client associated with the request.");
}
if (!user || !organizationId) {
throw new ForbiddenException("No organization associated with the user.");
}
const oAuthClient = await this.oAuthClientRepository.getOAuthClient(oAuthClientId);
if (!oAuthClient) {
throw new NotFoundException("OAuth client not found.");
}
return Boolean(user.isSystemAdmin || oAuthClient.organizationId === organizationId);
}
}
@@ -6,11 +6,11 @@ import { MembershipsModule } from "@/modules/memberships/memberships.module";
import { OAuthClientUsersController } from "@/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller";
import { OAuthClientsController } from "@/modules/oauth-clients/controllers/oauth-clients/oauth-clients.controller";
import { OAuthFlowController } from "@/modules/oauth-clients/controllers/oauth-flow/oauth-flow.controller";
import { OAuthClientCredentialsGuard } from "@/modules/oauth-clients/guards/oauth-client-credentials/oauth-client-credentials.guard";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
import { OrganizationsTeamsService } from "@/modules/organizations/services/organizations-teams.service";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { RedisModule } from "@/modules/redis/redis.module";
import { StripeModule } from "@/modules/stripe/stripe.module";
@@ -36,12 +36,12 @@ import { Global, Module } from "@nestjs/common";
],
providers: [
OAuthClientRepository,
OAuthClientCredentialsGuard,
TokensRepository,
OAuthFlowService,
OAuthClientUsersService,
OrganizationsTeamsService,
],
controllers: [OAuthClientUsersController, OAuthClientsController, OAuthFlowController],
exports: [OAuthClientRepository, OAuthClientCredentialsGuard],
exports: [OAuthClientRepository],
})
export class OAuthClientModule {}
@@ -1,5 +1,6 @@
import { EventTypesService_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/services/event-types.service";
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
import { OrganizationsTeamsService } from "@/modules/organizations/services/organizations-teams.service";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { CreateManagedUserInput } from "@/modules/users/inputs/create-managed-user.input";
import { UpdateManagedUserInput } from "@/modules/users/inputs/update-managed-user.input";
@@ -7,7 +8,7 @@ import { UsersRepository } from "@/modules/users/users.repository";
import { BadRequestException, Injectable } from "@nestjs/common";
import { User } from "@prisma/client";
import { createNewUsersConnectToOrgIfExists, slugify } from "@calcom/platform-libraries-0.0.21";
import { createNewUsersConnectToOrgIfExists, slugify } from "@calcom/platform-libraries-0.0.23";
@Injectable()
export class OAuthClientUsersService {
@@ -15,7 +16,8 @@ export class OAuthClientUsersService {
private readonly userRepository: UsersRepository,
private readonly tokensRepository: TokensRepository,
private readonly eventTypesService: EventTypesService_2024_04_15,
private readonly schedulesService: SchedulesService_2024_04_15
private readonly schedulesService: SchedulesService_2024_04_15,
private readonly organizationsTeamsService: OrganizationsTeamsService
) {}
async createOauthClientUser(
@@ -78,6 +80,8 @@ export class OAuthClientUsersService {
user.defaultScheduleId = defaultSchedule.id;
}
await this.organizationsTeamsService.addUserToPlatformTeamEvents(user.id, organizationId, oAuthClientId);
return {
user,
tokens: {
@@ -1,7 +1,10 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
@@ -32,6 +35,7 @@ import { ApiTags as DocsTags } from "@nestjs/swagger";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import {
CreateTeamEventTypeInput_2024_06_14,
GetTeamEventTypesQuery_2024_06_14,
SkipTakePagination,
UpdateTeamEventTypeInput_2024_06_14,
} from "@calcom/platform-types";
@@ -40,13 +44,13 @@ import {
path: "/v2/organizations/:orgId",
version: API_VERSIONS_VALUES,
})
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard)
@DocsTags("Organizations Event Types")
export class OrganizationsEventTypesController {
constructor(private readonly organizationsEventTypesService: OrganizationsEventTypesService) {}
@Roles("TEAM_ADMIN")
@UseGuards(IsTeamInOrg)
@PlatformPlan("ESSENTIALS")
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
@Post("/teams/:teamId/event-types")
async createTeamEventType(
@GetUser() user: UserWithProfile,
@@ -68,7 +72,8 @@ export class OrganizationsEventTypesController {
}
@Roles("TEAM_ADMIN")
@UseGuards(IsTeamInOrg)
@PlatformPlan("ESSENTIALS")
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
@Get("/teams/:teamId/event-types/:eventTypeId")
async getTeamEventType(
@Param("teamId", ParseIntPipe) teamId: number,
@@ -86,10 +91,22 @@ export class OrganizationsEventTypesController {
};
}
@Roles("TEAM_ADMIN")
@UseGuards(IsTeamInOrg)
@UseGuards(IsOrgGuard, IsTeamInOrg, IsAdminAPIEnabledGuard)
@Get("/teams/:teamId/event-types")
async getTeamEventTypes(@Param("teamId", ParseIntPipe) teamId: number): Promise<GetTeamEventTypesOutput> {
async getTeamEventTypes(
@Param("teamId", ParseIntPipe) teamId: number,
@Query() queryParams: GetTeamEventTypesQuery_2024_06_14
): Promise<GetTeamEventTypesOutput> {
const { eventSlug } = queryParams;
if (eventSlug) {
const eventType = await this.organizationsEventTypesService.getTeamEventTypeBySlug(teamId, eventSlug);
return {
status: SUCCESS_STATUS,
data: eventType ? [eventType] : [],
};
}
const eventTypes = await this.organizationsEventTypesService.getTeamEventTypes(teamId);
return {
@@ -99,6 +116,8 @@ export class OrganizationsEventTypesController {
}
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard)
@Get("/teams/event-types")
async getTeamsEventTypes(
@Param("orgId", ParseIntPipe) orgId: number,
@@ -114,7 +133,8 @@ export class OrganizationsEventTypesController {
}
@Roles("TEAM_ADMIN")
@UseGuards(IsTeamInOrg)
@PlatformPlan("ESSENTIALS")
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
@Patch("/teams/:teamId/event-types/:eventTypeId")
async updateTeamEventType(
@Param("teamId", ParseIntPipe) teamId: number,
@@ -136,7 +156,8 @@ export class OrganizationsEventTypesController {
}
@Roles("TEAM_ADMIN")
@UseGuards(IsTeamInOrg)
@PlatformPlan("ESSENTIALS")
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
@Delete("/teams/:teamId/event-types/:eventTypeId")
@HttpCode(HttpStatus.OK)
async deleteTeamEventType(
@@ -10,6 +10,7 @@ import { 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";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
@@ -30,7 +31,7 @@ describe("Organizations Event Types Endpoints", () => {
let app: INestApplication;
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: TeamRepositoryFixture;
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let teamsRepositoryFixture: TeamRepositoryFixture;
let membershipsRepositoryFixture: MembershipRepositoryFixture;
let profileRepositoryFixture: ProfileRepositoryFixture;
@@ -63,7 +64,7 @@ describe("Organizations Event Types Endpoints", () => {
).compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
@@ -248,17 +249,15 @@ describe("Organizations Event Types Endpoints", () => {
options: ["javascript", "python", "cobol"],
},
],
schedulingType: "COLLECTIVE",
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
schedulingType: "collective",
hosts: [
{
userId: teammate1.id,
mandatory: true,
priority: "high",
},
{
userId: teammate2.id,
mandatory: false,
priority: "low",
},
],
};
@@ -274,6 +273,7 @@ describe("Organizations Event Types Endpoints", () => {
const data = responseBody.data;
expect(data.title).toEqual(body.title);
expect(data.hosts.length).toEqual(2);
expect(data.schedulingType).toEqual("COLLECTIVE");
evaluateHost(body.hosts[0], data.hosts[0]);
evaluateHost(body.hosts[1], data.hosts[1]);
@@ -389,7 +389,6 @@ describe("Organizations Event Types Endpoints", () => {
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const data = responseBody.data;
console.log("asap responseBody.data", JSON.stringify(responseBody.data, null, 2));
expect(data.length).toEqual(2);
const eventTypeCollective = data.find((eventType) => eventType.schedulingType === "COLLECTIVE");
@@ -431,8 +430,6 @@ describe("Organizations Event Types Endpoints", () => {
const newHosts: UpdateTeamEventTypeInput_2024_06_14["hosts"] = [
{
userId: teammate1.id,
mandatory: true,
priority: "medium",
},
];
@@ -603,7 +600,9 @@ describe("Organizations Event Types Endpoints", () => {
await userRepositoryFixture.deleteByEmail(teammate2.email);
await userRepositoryFixture.deleteByEmail(falseTestUser.email);
await teamsRepositoryFixture.delete(team.id);
await teamsRepositoryFixture.delete(falseTestTeam.id);
await organizationsRepositoryFixture.delete(org.id);
await organizationsRepositoryFixture.delete(falseTestOrg.id);
await app.close();
});
});
@@ -11,6 +11,7 @@ import { Test } from "@nestjs/testing";
import { User } from "@prisma/client";
import * as request from "supertest";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.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";
@@ -24,7 +25,7 @@ describe("Organizations Memberships Endpoints", () => {
let app: INestApplication;
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: TeamRepositoryFixture;
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let membershipsRepositoryFixture: MembershipRepositoryFixture;
let org: Team;
@@ -51,7 +52,7 @@ describe("Organizations Memberships Endpoints", () => {
).compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
user = await userRepositoryFixture.create({
@@ -210,7 +211,7 @@ describe("Organizations Memberships Endpoints", () => {
let app: INestApplication;
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: TeamRepositoryFixture;
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let membershipsRepositoryFixture: MembershipRepositoryFixture;
let org: Team;
@@ -228,7 +229,7 @@ describe("Organizations Memberships Endpoints", () => {
).compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
user = await userRepositoryFixture.create({
@@ -1,8 +1,11 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
import { GetMembership } from "@/modules/auth/decorators/get-membership/get-membership.decorator";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
import { IsMembershipInOrg } from "@/modules/auth/guards/memberships/is-membership-in-org.guard";
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { CreateOrgMembershipDto } from "@/modules/organizations/inputs/create-organization-membership.input";
@@ -39,12 +42,13 @@ import { Membership } from "@calcom/prisma/client";
path: "/v2/organizations/:orgId/memberships",
version: API_VERSIONS_VALUES,
})
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard)
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard)
@DocsTags("Organizations Memberships")
export class OrganizationsMembershipsController {
constructor(private organizationsMembershipService: OrganizationsMembershipService) {}
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@Get("/")
@HttpCode(HttpStatus.OK)
async getAllMemberships(
@@ -66,6 +70,7 @@ export class OrganizationsMembershipsController {
}
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@Post("/")
@HttpCode(HttpStatus.CREATED)
async createMembership(
@@ -80,6 +85,7 @@ export class OrganizationsMembershipsController {
}
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@UseGuards(IsMembershipInOrg)
@Get("/:membershipId")
@HttpCode(HttpStatus.OK)
@@ -91,6 +97,7 @@ export class OrganizationsMembershipsController {
}
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@UseGuards(IsMembershipInOrg)
@Delete("/:membershipId")
@HttpCode(HttpStatus.OK)
@@ -107,6 +114,7 @@ export class OrganizationsMembershipsController {
@UseGuards(IsMembershipInOrg)
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@Patch("/:membershipId")
@HttpCode(HttpStatus.OK)
async updateMembership(
@@ -1,7 +1,10 @@
import { SchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/schedules.service";
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { IsUserInOrg } from "@/modules/auth/guards/users/is-user-in-org.guard";
@@ -38,7 +41,7 @@ import { SkipTakePagination } from "@calcom/platform-types";
path: "/v2/organizations/:orgId",
version: API_VERSIONS_VALUES,
})
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard)
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard)
@DocsTags("Organizations Schedules")
export class OrganizationsSchedulesController {
constructor(
@@ -47,6 +50,7 @@ export class OrganizationsSchedulesController {
) {}
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@Get("/schedules")
async getOrganizationSchedules(
@Param("orgId", ParseIntPipe) orgId: number,
@@ -63,6 +67,7 @@ export class OrganizationsSchedulesController {
}
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@UseGuards(IsUserInOrg)
@Post("/users/:userId/schedules")
async createUserSchedule(
@@ -78,6 +83,7 @@ export class OrganizationsSchedulesController {
}
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@UseGuards(IsUserInOrg)
@Get("/users/:userId/schedules/:scheduleId")
async getUserSchedule(
@@ -93,6 +99,7 @@ export class OrganizationsSchedulesController {
}
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@UseGuards(IsUserInOrg)
@Get("/users/:userId/schedules")
async getUserSchedules(
@@ -107,6 +114,7 @@ export class OrganizationsSchedulesController {
}
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@UseGuards(IsUserInOrg)
@Patch("/users/:userId/schedules/:scheduleId")
async updateUserSchedule(
@@ -123,6 +131,7 @@ export class OrganizationsSchedulesController {
}
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@UseGuards(IsUserInOrg)
@Delete("/users/:userId/schedules/:scheduleId")
@HttpCode(HttpStatus.OK)
@@ -8,6 +8,7 @@ import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import * as request from "supertest";
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";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
@@ -31,7 +32,7 @@ describe("Organizations Schedules Endpoints", () => {
let app: INestApplication;
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: TeamRepositoryFixture;
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let membershipFixtures: MembershipRepositoryFixture;
const userEmail = "mr-robot@schedules-api.com";
@@ -48,7 +49,7 @@ describe("Organizations Schedules Endpoints", () => {
).compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
membershipFixtures = new MembershipRepositoryFixture(moduleRef);
org = await organizationsRepositoryFixture.create({
@@ -110,7 +111,7 @@ describe("Organizations Schedules Endpoints", () => {
let app: INestApplication;
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: TeamRepositoryFixture;
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let membershipFixtures: MembershipRepositoryFixture;
let profileRepositoryFixture: ProfileRepositoryFixture;
@@ -146,7 +147,7 @@ describe("Organizations Schedules Endpoints", () => {
).compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
membershipFixtures = new MembershipRepositoryFixture(moduleRef);
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
@@ -11,6 +11,7 @@ import { Test } from "@nestjs/testing";
import { User } from "@prisma/client";
import * as request from "supertest";
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";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
@@ -25,7 +26,7 @@ describe("Organizations Teams Memberships Endpoints", () => {
let app: INestApplication;
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: TeamRepositoryFixture;
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let teamsRepositoryFixture: TeamRepositoryFixture;
let profileRepositoryFixture: ProfileRepositoryFixture;
@@ -59,7 +60,7 @@ describe("Organizations Teams Memberships Endpoints", () => {
).compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
@@ -335,7 +336,7 @@ describe("Organizations Teams Memberships Endpoints", () => {
await userRepositoryFixture.deleteByEmail(nonOrgUser.email);
await userRepositoryFixture.deleteByEmail(user2.email);
await organizationsRepositoryFixture.delete(org.id);
await organizationsRepositoryFixture.delete(nonOrgTeam.id);
await teamsRepositoryFixture.delete(nonOrgTeam.id);
await app.close();
});
});
@@ -1,6 +1,9 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
@@ -38,7 +41,7 @@ import { SkipTakePagination } from "@calcom/platform-types";
path: "/v2/organizations/:orgId/teams/:teamId/memberships",
version: API_VERSIONS_VALUES,
})
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg)
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
@DocsTags("Organizations Teams")
export class OrganizationsTeamsMembershipsController {
constructor(
@@ -50,6 +53,7 @@ export class OrganizationsTeamsMembershipsController {
@ApiOperation({ summary: "Get all the memberships of a team of an organization." })
@UseGuards()
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@HttpCode(HttpStatus.OK)
async getAllOrgTeamMemberships(
@Param("orgId", ParseIntPipe) orgId: number,
@@ -75,6 +79,7 @@ export class OrganizationsTeamsMembershipsController {
@ApiOperation({ summary: "Get the membership of an organization's team by ID" })
@UseGuards()
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@HttpCode(HttpStatus.OK)
async getOrgTeamMembership(
@Param("orgId", ParseIntPipe) orgId: number,
@@ -93,6 +98,7 @@ export class OrganizationsTeamsMembershipsController {
}
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@Delete("/:membershipId")
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Delete the membership of an organization's team by ID" })
@@ -113,6 +119,7 @@ export class OrganizationsTeamsMembershipsController {
}
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@Patch("/:membershipId")
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Update the membership of an organization's team by ID" })
@@ -135,6 +142,7 @@ export class OrganizationsTeamsMembershipsController {
}
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@Post("/")
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: "Create a membership of an organization's team" })
@@ -11,20 +11,22 @@ import { Test } from "@nestjs/testing";
import { User } from "@prisma/client";
import * as request from "supertest";
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 { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { withApiAuth } from "test/utils/withApiAuth";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { SUCCESS_STATUS, X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
import { ApiSuccessResponse } from "@calcom/platform-types";
import { Team } from "@calcom/prisma/client";
import { PlatformOAuthClient, Team } from "@calcom/prisma/client";
describe("Organizations Team Endpoints", () => {
describe("User Authentication - User is Org Admin", () => {
let app: INestApplication;
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: TeamRepositoryFixture;
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let teamsRepositoryFixture: TeamRepositoryFixture;
let membershipsRepositoryFixture: MembershipRepositoryFixture;
@@ -32,6 +34,7 @@ describe("Organizations Team Endpoints", () => {
let team: Team;
let team2: Team;
let teamCreatedViaApi: Team;
let teamCreatedViaApi2: Team;
const userEmail = "org-admin-teams-controller-e2e@api.com";
let user: User;
@@ -45,7 +48,7 @@ describe("Organizations Team Endpoints", () => {
).compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
@@ -85,7 +88,7 @@ describe("Organizations Team Endpoints", () => {
it("should be defined", () => {
expect(userRepositoryFixture).toBeDefined();
expect(organizationsRepositoryFixture).toBeDefined();
expect(teamsRepositoryFixture).toBeDefined();
expect(user).toBeDefined();
expect(org).toBeDefined();
});
@@ -147,6 +150,7 @@ describe("Organizations Team Endpoints", () => {
teamCreatedViaApi.id
);
expect(membership?.role ?? "").toEqual("OWNER");
expect(membership?.accepted).toEqual(true);
});
});
@@ -199,11 +203,35 @@ describe("Organizations Team Endpoints", () => {
return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/teams/123132145`).expect(404);
});
it("should create the team of the org without auto-accepting creator", async () => {
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/teams`)
.send({
name: "Team II created via API",
autoAcceptCreator: false,
} satisfies CreateOrgTeamDto)
.expect(201)
.then(async (response) => {
const responseBody: ApiSuccessResponse<Team> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
teamCreatedViaApi2 = responseBody.data;
expect(teamCreatedViaApi2.name).toEqual("Team II created via API");
expect(teamCreatedViaApi2.parentId).toEqual(org.id);
const membership = await membershipsRepositoryFixture.getUserMembershipByTeamId(
user.id,
teamCreatedViaApi2.id
);
expect(membership?.role ?? "").toEqual("OWNER");
expect(membership?.accepted).toEqual(false);
});
});
afterAll(async () => {
await userRepositoryFixture.deleteByEmail(user.email);
await teamsRepositoryFixture.delete(team.id);
await teamsRepositoryFixture.delete(team2.id);
await organizationsRepositoryFixture.delete(org.id);
await teamsRepositoryFixture.delete(teamCreatedViaApi2.id);
await teamsRepositoryFixture.delete(org.id);
await app.close();
});
});
@@ -214,7 +242,7 @@ describe("Organizations Team Endpoints", () => {
let app: INestApplication;
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: TeamRepositoryFixture;
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let teamsRepositoryFixture: TeamRepositoryFixture;
let membershipsRepositoryFixture: MembershipRepositoryFixture;
@@ -234,7 +262,7 @@ describe("Organizations Team Endpoints", () => {
).compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
@@ -274,7 +302,7 @@ describe("Organizations Team Endpoints", () => {
it("should be defined", () => {
expect(userRepositoryFixture).toBeDefined();
expect(organizationsRepositoryFixture).toBeDefined();
expect(teamsRepositoryFixture).toBeDefined();
expect(user).toBeDefined();
expect(org).toBeDefined();
});
@@ -317,7 +345,7 @@ describe("Organizations Team Endpoints", () => {
await userRepositoryFixture.deleteByEmail(user.email);
await teamsRepositoryFixture.delete(team.id);
await teamsRepositoryFixture.delete(team2.id);
await organizationsRepositoryFixture.delete(org.id);
await teamsRepositoryFixture.delete(org.id);
await app.close();
});
});
@@ -328,7 +356,7 @@ describe("Organizations Team Endpoints", () => {
let app: INestApplication;
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: TeamRepositoryFixture;
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let teamsRepositoryFixture: TeamRepositoryFixture;
let membershipsRepositoryFixture: MembershipRepositoryFixture;
@@ -348,7 +376,7 @@ describe("Organizations Team Endpoints", () => {
).compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
@@ -448,3 +476,160 @@ describe("Organizations Team Endpoints", () => {
});
});
});
describe("Organizations Team Endpoints", () => {
describe("Platform teams", () => {
let app: INestApplication;
let userRepositoryFixture: UserRepositoryFixture;
let teamsRepositoryFixture: TeamRepositoryFixture;
let orgRepositoryFixture: OrganizationRepositoryFixture;
let membershipsRepositoryFixture: MembershipRepositoryFixture;
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
let oAuthClient1: PlatformOAuthClient;
let oAuthClient2: PlatformOAuthClient;
let org: Team;
let team1: Team;
let team2: Team;
const userEmail = "platform-org-member-teams-owner-controller-e2e@api.com";
let user: User;
beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
})
).compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
orgRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
user = await userRepositoryFixture.create({
email: userEmail,
username: userEmail,
});
org = await orgRepositoryFixture.create({
name: "Platform Test Organization",
isOrganization: true,
});
await membershipsRepositoryFixture.create({
role: "ADMIN",
user: { connect: { id: user.id } },
team: { connect: { id: org.id } },
});
oAuthClient1 = await createOAuthClient(org.id);
oAuthClient2 = await createOAuthClient(org.id);
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
async function createOAuthClient(organizationId: number) {
const data = {
logo: "logo-url",
name: "name",
redirectUris: ["redirect-uri"],
permissions: 32,
};
const secret = "secret";
return await oauthClientRepositoryFixture.create(organizationId, data, secret);
}
it("should be defined", () => {
expect(userRepositoryFixture).toBeDefined();
expect(teamsRepositoryFixture).toBeDefined();
expect(user).toBeDefined();
expect(org).toBeDefined();
});
it("should create first oAuth client team", async () => {
const teamName = "Platform team created via API";
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/teams`)
.set(X_CAL_CLIENT_ID, oAuthClient1.id)
.send({
name: teamName,
} satisfies CreateOrgTeamDto)
.expect(201)
.then(async (response) => {
const responseBody: ApiSuccessResponse<Team> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
team1 = responseBody.data;
expect(team1.name).toEqual(teamName);
expect(team1.parentId).toEqual(org.id);
const membership = await membershipsRepositoryFixture.getUserMembershipByTeamId(user.id, team1.id);
expect(membership?.role ?? "").toEqual("OWNER");
expect(membership?.accepted).toEqual(true);
});
});
it("should create second oAuth client team", async () => {
const teamName = "Platform team II created via API";
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/teams`)
.set(X_CAL_CLIENT_ID, oAuthClient2.id)
.set(X_CAL_SECRET_KEY, oAuthClient2.secret)
.send({
name: teamName,
} satisfies CreateOrgTeamDto)
.expect(201)
.then(async (response) => {
const responseBody: ApiSuccessResponse<Team> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
team2 = responseBody.data;
expect(team2.name).toEqual(teamName);
expect(team2.parentId).toEqual(org.id);
const membership = await membershipsRepositoryFixture.getUserMembershipByTeamId(user.id, team2.id);
expect(membership?.role ?? "").toEqual("OWNER");
expect(membership?.accepted).toEqual(true);
});
});
it("should get all the platform teams correctly tied to OAuth clients", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/teams`)
.expect(200)
.then(async (response) => {
const responseBody: ApiSuccessResponse<Team[]> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data[0].id).toEqual(team1.id);
expect(responseBody.data[1].id).toEqual(team2.id);
const oAuthClientTeams = await teamsRepositoryFixture.getPlatformOrgTeams(org.id, oAuthClient1.id);
expect(oAuthClientTeams.length).toEqual(1);
const oAuthClientTeam = oAuthClientTeams[0];
expect(oAuthClientTeam.id).toEqual(team1.id);
expect(oAuthClientTeam.name).toEqual(team1.name);
const oAuthClient2Teams = await teamsRepositoryFixture.getPlatformOrgTeams(org.id, oAuthClient2.id);
expect(oAuthClient2Teams.length).toEqual(1);
const oAuthClientTeam2 = oAuthClient2Teams[0];
expect(oAuthClientTeam2.id).toEqual(team2.id);
expect(oAuthClientTeam2.name).toEqual(team2.name);
});
});
afterAll(async () => {
await userRepositoryFixture.deleteByEmail(user.email);
await teamsRepositoryFixture.delete(team1.id);
await teamsRepositoryFixture.delete(org.id);
await app.close();
});
});
});
@@ -1,16 +1,19 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
import { GetTeam } from "@/modules/auth/decorators/get-team/get-team.decorator";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
import { CreateOrgTeamDto } from "@/modules/organizations/inputs/create-organization-team.input";
import { UpdateOrgTeamDto } from "@/modules/organizations/inputs/update-organization-team.input";
import {
OrgMeTeamOutputDto,
OrgMeTeamsOutputResponseDto,
OrgTeamOutputDto,
OrgTeamOutputResponseDto,
OrgTeamsOutputResponseDto,
} from "@/modules/organizations/outputs/organization-team.output";
@@ -27,11 +30,13 @@ import {
Patch,
Post,
Body,
Headers,
} from "@nestjs/common";
import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
import { plainToClass } from "class-transformer";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { SUCCESS_STATUS, X_CAL_CLIENT_ID } from "@calcom/platform-constants";
import { OrgTeamOutputDto } from "@calcom/platform-types";
import { SkipTakePagination } from "@calcom/platform-types";
import { Team } from "@calcom/prisma/client";
@@ -39,7 +44,7 @@ import { Team } from "@calcom/prisma/client";
path: "/v2/organizations/:orgId/teams",
version: API_VERSIONS_VALUES,
})
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard)
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard)
@DocsTags("Organizations Teams")
export class OrganizationsTeamsController {
constructor(private organizationsTeamsService: OrganizationsTeamsService) {}
@@ -47,6 +52,7 @@ export class OrganizationsTeamsController {
@Get()
@ApiOperation({ summary: "Get all the teams of an organization." })
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
async getAllTeams(
@Param("orgId", ParseIntPipe) orgId: number,
@Query() queryParams: SkipTakePagination
@@ -62,6 +68,7 @@ export class OrganizationsTeamsController {
@Get("/me")
@ApiOperation({ summary: "Get the organization's teams user is a member of" })
@Roles("ORG_MEMBER")
@PlatformPlan("ESSENTIALS")
async getMyTeams(
@Param("orgId", ParseIntPipe) orgId: number,
@Query() queryParams: SkipTakePagination,
@@ -88,6 +95,7 @@ export class OrganizationsTeamsController {
@UseGuards(IsTeamInOrg)
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@Get("/:teamId")
@ApiOperation({ summary: "Get a team of the organization by ID." })
async getTeam(@GetTeam() team: Team): Promise<OrgTeamOutputResponseDto> {
@@ -99,6 +107,7 @@ export class OrganizationsTeamsController {
@UseGuards(IsTeamInOrg)
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@Delete("/:teamId")
@ApiOperation({ summary: "Delete a team of the organization by ID." })
async deleteTeam(
@@ -114,12 +123,13 @@ export class OrganizationsTeamsController {
@UseGuards(IsTeamInOrg)
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@Patch("/:teamId")
@ApiOperation({ summary: "Update a team of the organization by ID." })
async updateTeam(
@Param("orgId", ParseIntPipe) orgId: number,
@Param("teamId", ParseIntPipe) teamId: number,
@Body() body: CreateOrgTeamDto
@Body() body: UpdateOrgTeamDto
): Promise<OrgTeamOutputResponseDto> {
const team = await this.organizationsTeamsService.updateOrgTeam(orgId, teamId, body);
return {
@@ -130,13 +140,18 @@ export class OrganizationsTeamsController {
@Post()
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@ApiOperation({ summary: "Create a team for an organization." })
async createTeam(
@Param("orgId", ParseIntPipe) orgId: number,
@Body() body: CreateOrgTeamDto,
@GetUser() user: UserWithProfile
@GetUser() user: UserWithProfile,
@Headers(X_CAL_CLIENT_ID) oAuthClientId?: string
): Promise<OrgTeamOutputResponseDto> {
const team = await this.organizationsTeamsService.createOrgTeam(orgId, body, user);
const team = oAuthClientId
? await this.organizationsTeamsService.createPlatformOrgTeam(orgId, oAuthClientId, body, user)
: await this.organizationsTeamsService.createOrgTeam(orgId, body, user);
return {
status: SUCCESS_STATUS,
data: plainToClass(OrgTeamOutputDto, team, { strategy: "excludeAll" }),
@@ -1,8 +1,11 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
import { GetOrg } from "@/modules/auth/decorators/get-org/get-org.decorator";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { IsUserInOrg } from "@/modules/auth/guards/users/is-user-in-org.guard";
@@ -39,7 +42,7 @@ import { Team } from "@calcom/prisma/client";
version: API_VERSIONS_VALUES,
})
@UseInterceptors(ClassSerializerInterceptor)
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard)
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard)
@UseGuards(IsOrgGuard)
@DocsTags("Organizations Users")
export class OrganizationsUsersController {
@@ -47,6 +50,7 @@ export class OrganizationsUsersController {
@Get()
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
async getOrganizationsUsers(
@Param("orgId", ParseIntPipe) orgId: number,
@Query() query: GetOrganizationsUsersInput
@@ -66,6 +70,7 @@ export class OrganizationsUsersController {
@Post()
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
async createOrganizationUser(
@Param("orgId", ParseIntPipe) orgId: number,
@GetOrg() org: Team,
@@ -85,6 +90,7 @@ export class OrganizationsUsersController {
@Patch("/:userId")
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@UseGuards(IsUserInOrg)
async updateOrganizationUser(
@Param("orgId", ParseIntPipe) orgId: number,
@@ -101,6 +107,7 @@ export class OrganizationsUsersController {
@Delete("/:userId")
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@UseGuards(IsUserInOrg)
async deleteOrganizationUser(
@Param("orgId", ParseIntPipe) orgId: number,

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