Compare commits

...
54 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
326 changed files with 8390 additions and 3748 deletions
+2
View File
@@ -259,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
+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)) };
}
+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 } } };
}
@@ -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());
});
});
});
});
@@ -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.22": "npm:@calcom/platform-libraries@0.0.22",
"@calcom/platform-libraries-0.0.23": "npm:@calcom/platform-libraries@0.0.23",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
"@calcom/prisma": "*",
@@ -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.22";
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.22";
} 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.22";
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.22";
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.22";
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.22";
} 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.22";
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.22";
} 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.22";
} 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.22";
import { getEventTypesPublic, EventTypesPublic } from "@calcom/platform-libraries-0.0.22";
import { dynamicEvent } from "@calcom/platform-libraries-0.0.22";
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.22";
} 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.22";
} from "@calcom/platform-libraries-0.0.23";
type EventTypeRelations = { users: User[]; schedule: Schedule | null };
type DatabaseEventType = EventType & EventTypeRelations;
@@ -3,7 +3,7 @@ import { Injectable } from "@nestjs/common";
import {
transformApiScheduleOverrides,
transformApiScheduleAvailability,
} from "@calcom/platform-libraries-0.0.22";
} 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.22";
import { TRPCError } from "@calcom/platform-libraries-0.0.23";
import { Response } from "@calcom/platform-types";
@Catch(TRPCError)
@@ -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;
}
}
@@ -1,6 +1,6 @@
import { Injectable } from "@nestjs/common";
import { sendSignupToOrganizationEmail, getTranslation } from "@calcom/platform-libraries-0.0.22";
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` };
}
}
@@ -112,9 +112,16 @@ describe("OAuth Client Users Endpoints", () => {
await profilesRepositoryFixture.create({
uid: "asd-asd",
username: userEmail,
user: { connect: { id: platformAdmin.id } },
username: platformAdminEmail,
organization: { connect: { id: organization.id } },
movedFromUser: {
connect: {
id: platformAdmin.id,
},
},
user: {
connect: { id: platformAdmin.id },
},
});
await app.init();
@@ -6,6 +6,7 @@ import { GetManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-
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 { 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(ApiAuthGuard)
@UseGuards(ApiAuthGuard, OAuthClientGuard)
@DocsTags("Managed users")
export class OAuthClientUsersController {
private readonly logger = new Logger("UserController");
@@ -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,7 @@ 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";
@@ -57,7 +58,7 @@ describe("OAuthFlow Endpoints", () => {
let app: INestApplication;
let usersRepositoryFixtures: UserRepositoryFixture;
let organizationsRepositoryFixture: TeamRepositoryFixture;
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let oAuthClientsRepositoryFixture: OAuthClientRepositoryFixture;
let profilesRepositoryFixture: ProfileRepositoryFixture;
@@ -83,7 +84,7 @@ 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);
@@ -96,6 +97,7 @@ describe("OAuthFlow Endpoints", () => {
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);
@@ -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);
}
}
@@ -8,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.22";
import { createNewUsersConnectToOrgIfExists, slugify } from "@calcom/platform-libraries-0.0.23";
@Injectable()
export class OAuthClientUsersService {
@@ -4,6 +4,7 @@ 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";
@@ -49,7 +50,7 @@ export class OrganizationsEventTypesController {
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard)
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
@Post("/teams/:teamId/event-types")
async createTeamEventType(
@GetUser() user: UserWithProfile,
@@ -72,7 +73,7 @@ export class OrganizationsEventTypesController {
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard)
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
@Get("/teams/:teamId/event-types/:eventTypeId")
async getTeamEventType(
@Param("teamId", ParseIntPipe) teamId: number,
@@ -90,7 +91,7 @@ export class OrganizationsEventTypesController {
};
}
@UseGuards(IsOrgGuard, IsTeamInOrg)
@UseGuards(IsOrgGuard, IsTeamInOrg, IsAdminAPIEnabledGuard)
@Get("/teams/:teamId/event-types")
async getTeamEventTypes(
@Param("teamId", ParseIntPipe) teamId: number,
@@ -116,7 +117,7 @@ export class OrganizationsEventTypesController {
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard)
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard)
@Get("/teams/event-types")
async getTeamsEventTypes(
@Param("orgId", ParseIntPipe) orgId: number,
@@ -133,7 +134,7 @@ export class OrganizationsEventTypesController {
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard)
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
@Patch("/teams/:teamId/event-types/:eventTypeId")
async updateTeamEventType(
@Param("teamId", ParseIntPipe) teamId: number,
@@ -156,7 +157,7 @@ export class OrganizationsEventTypesController {
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard)
@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);
@@ -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({
@@ -5,6 +5,7 @@ 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";
@@ -41,7 +42,7 @@ import { Membership } from "@calcom/prisma/client";
path: "/v2/organizations/:orgId/memberships",
version: API_VERSIONS_VALUES,
})
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard)
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard)
@DocsTags("Organizations Memberships")
export class OrganizationsMembershipsController {
constructor(private organizationsMembershipService: OrganizationsMembershipService) {}
@@ -4,6 +4,7 @@ import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.de
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";
@@ -40,7 +41,7 @@ import { SkipTakePagination } from "@calcom/platform-types";
path: "/v2/organizations/:orgId",
version: API_VERSIONS_VALUES,
})
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard)
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard)
@DocsTags("Organizations Schedules")
export class OrganizationsSchedulesController {
constructor(
@@ -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();
});
});
@@ -3,6 +3,7 @@ import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.de
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";
@@ -40,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, PlatformPlanGuard)
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
@DocsTags("Organizations Teams")
export class OrganizationsTeamsMembershipsController {
constructor(
@@ -12,6 +12,7 @@ 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";
@@ -25,6 +26,7 @@ describe("Organizations Team Endpoints", () => {
let app: INestApplication;
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let teamsRepositoryFixture: TeamRepositoryFixture;
let membershipsRepositoryFixture: MembershipRepositoryFixture;
@@ -46,6 +48,7 @@ describe("Organizations Team Endpoints", () => {
).compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
@@ -54,7 +57,7 @@ describe("Organizations Team Endpoints", () => {
username: userEmail,
});
org = await teamsRepositoryFixture.create({
org = await organizationsRepositoryFixture.create({
name: "Test Organization",
isOrganization: true,
});
@@ -239,6 +242,7 @@ describe("Organizations Team Endpoints", () => {
let app: INestApplication;
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let teamsRepositoryFixture: TeamRepositoryFixture;
let membershipsRepositoryFixture: MembershipRepositoryFixture;
@@ -258,6 +262,7 @@ describe("Organizations Team Endpoints", () => {
).compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
@@ -266,7 +271,7 @@ describe("Organizations Team Endpoints", () => {
username: userEmail,
});
org = await teamsRepositoryFixture.create({
org = await organizationsRepositoryFixture.create({
name: "Test Organization",
isOrganization: true,
});
@@ -351,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;
@@ -371,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);
@@ -478,6 +483,8 @@ describe("Organizations Team Endpoints", () => {
let userRepositoryFixture: UserRepositoryFixture;
let teamsRepositoryFixture: TeamRepositoryFixture;
let orgRepositoryFixture: OrganizationRepositoryFixture;
let membershipsRepositoryFixture: MembershipRepositoryFixture;
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
@@ -502,13 +509,13 @@ describe("Organizations Team Endpoints", () => {
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 teamsRepositoryFixture.create({
org = await orgRepositoryFixture.create({
name: "Platform Test Organization",
isOrganization: true,
});
@@ -5,6 +5,7 @@ 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";
@@ -43,7 +44,7 @@ import { Team } from "@calcom/prisma/client";
path: "/v2/organizations/:orgId/teams",
version: API_VERSIONS_VALUES,
})
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard)
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard)
@DocsTags("Organizations Teams")
export class OrganizationsTeamsController {
constructor(private organizationsTeamsService: OrganizationsTeamsService) {}
@@ -5,6 +5,7 @@ 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";
@@ -41,7 +42,7 @@ import { Team } from "@calcom/prisma/client";
version: API_VERSIONS_VALUES,
})
@UseInterceptors(ClassSerializerInterceptor)
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard)
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard)
@UseGuards(IsOrgGuard)
@DocsTags("Organizations Users")
export class OrganizationsUsersController {
@@ -10,6 +10,7 @@ import { Test } from "@nestjs/testing";
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";
@@ -23,7 +24,7 @@ describe("Organizations Users Endpoints", () => {
let app: INestApplication;
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: TeamRepositoryFixture;
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let membershipFixtures: MembershipRepositoryFixture;
let profileRepositoryFixture: ProfileRepositoryFixture;
@@ -40,7 +41,7 @@ describe("Organizations Users Endpoints", () => {
).compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
membershipFixtures = new MembershipRepositoryFixture(moduleRef);
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
@@ -113,7 +114,7 @@ describe("Organizations Users Endpoints", () => {
let app: INestApplication;
let profileRepositoryFixture: ProfileRepositoryFixture;
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: TeamRepositoryFixture;
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let membershipFixtures: MembershipRepositoryFixture;
const userEmail = "admin1@org.com";
@@ -148,7 +149,7 @@ describe("Organizations Users Endpoints", () => {
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
membershipFixtures = new MembershipRepositoryFixture(moduleRef);
org = await organizationsRepositoryFixture.create({
@@ -349,7 +350,8 @@ describe("Organizations Users Endpoints", () => {
let app: INestApplication;
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: TeamRepositoryFixture;
let teamsRepositoryFixture: TeamRepositoryFixture;
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let membershipFixtures: MembershipRepositoryFixture;
let profileRepositoryFixture: ProfileRepositoryFixture;
@@ -370,7 +372,9 @@ describe("Organizations Users Endpoints", () => {
).compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
membershipFixtures = new MembershipRepositoryFixture(moduleRef);
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
@@ -380,7 +384,7 @@ describe("Organizations Users Endpoints", () => {
isOrganization: true,
});
team = await organizationsRepositoryFixture.create({
team = await teamsRepositoryFixture.create({
name: "Test org 4 team",
isOrganization: false,
parent: { connect: { id: org.id } },
@@ -102,4 +102,15 @@ export class OrganizationsRepository {
},
});
}
async fetchOrgAdminApiStatus(organizationId: number) {
return this.dbRead.prisma.organizationSettings.findUnique({
where: {
organizationId,
},
select: {
isAdminAPIEnabled: true,
},
});
}
}
@@ -9,7 +9,7 @@ import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { UserWithProfile } from "@/modules/users/users.repository";
import { Injectable, NotFoundException } from "@nestjs/common";
import { createEventType, updateEventType } from "@calcom/platform-libraries-0.0.22";
import { createEventType, updateEventType } from "@calcom/platform-libraries-0.0.23";
import {
CreateTeamEventTypeInput_2024_06_14,
UpdateTeamEventTypeInput_2024_06_14,
@@ -5,7 +5,7 @@ import { OrganizationsTeamsRepository } from "@/modules/organizations/repositori
import { UserWithProfile } from "@/modules/users/users.repository";
import { Injectable } from "@nestjs/common";
import { updateNewTeamMemberEventTypes } from "@calcom/platform-libraries-0.0.22";
import { updateNewTeamMemberEventTypes } from "@calcom/platform-libraries-0.0.23";
@Injectable()
export class OrganizationsTeamsService {
@@ -7,7 +7,7 @@ import { CreateUserInput } from "@/modules/users/inputs/create-user.input";
import { Injectable, ConflictException } from "@nestjs/common";
import { plainToInstance } from "class-transformer";
import { createNewUsersConnectToOrgIfExists } from "@calcom/platform-libraries-0.0.22";
import { createNewUsersConnectToOrgIfExists } from "@calcom/platform-libraries-0.0.23";
import { Team } from "@calcom/prisma/client";
@Injectable()
@@ -1,10 +1,12 @@
import type { OnModuleInit } from "@nestjs/common";
import { Injectable } from "@nestjs/common";
import type { OnModuleDestroy, OnModuleInit } from "@nestjs/common";
import { Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { PrismaClient } from "@prisma/client";
@Injectable()
export class PrismaReadService implements OnModuleInit {
export class PrismaReadService implements OnModuleInit, OnModuleDestroy {
private logger = new Logger("PrismaReadService");
public prisma: PrismaClient;
constructor(readonly configService: ConfigService) {
@@ -22,4 +24,12 @@ export class PrismaReadService implements OnModuleInit {
async onModuleInit() {
this.prisma.$connect();
}
async onModuleDestroy() {
try {
await this.prisma.$disconnect();
} catch (error) {
this.logger.error(error);
}
}
}
@@ -1,10 +1,12 @@
import { OnModuleInit } from "@nestjs/common";
import { Injectable } from "@nestjs/common";
import { OnModuleDestroy, OnModuleInit } from "@nestjs/common";
import { Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { PrismaClient } from "@prisma/client";
@Injectable()
export class PrismaWriteService implements OnModuleInit {
export class PrismaWriteService implements OnModuleInit, OnModuleDestroy {
private logger = new Logger("PrismaWriteService");
public prisma: PrismaClient;
constructor(readonly configService: ConfigService) {
@@ -22,4 +24,12 @@ export class PrismaWriteService implements OnModuleInit {
async onModuleInit() {
this.prisma.$connect();
}
async onModuleDestroy() {
try {
await this.prisma.$disconnect();
} catch (error) {
this.logger.error(error);
}
}
}
@@ -5,8 +5,8 @@ import { ApiTags as DocsTags } from "@nestjs/swagger";
import { Response as ExpressResponse, Request as ExpressRequest } from "express";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { getAvailableSlots } from "@calcom/platform-libraries-0.0.22";
import type { AvailableSlotsType } from "@calcom/platform-libraries-0.0.22";
import { getAvailableSlots } from "@calcom/platform-libraries-0.0.23";
import type { AvailableSlotsType } from "@calcom/platform-libraries-0.0.23";
import { RemoveSelectedSlotInput, ReserveSlotInput } from "@calcom/platform-types";
import { ApiResponse, GetAvailableSlotsInput } from "@calcom/platform-types";
@@ -3,7 +3,7 @@ import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
import { DateTime } from "luxon";
import { MINUTES_TO_BOOK } from "@calcom/platform-libraries-0.0.22";
import { MINUTES_TO_BOOK } from "@calcom/platform-libraries-0.0.23";
import { ReserveSlotInput } from "@calcom/platform-types";
@Injectable()
@@ -4,7 +4,7 @@ import { Controller, Get } from "@nestjs/common";
import { ApiTags as DocsTags } from "@nestjs/swagger";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import type { CityTimezones } from "@calcom/platform-libraries-0.0.22";
import type { CityTimezones } from "@calcom/platform-libraries-0.0.23";
import { ApiResponse } from "@calcom/platform-types";
@Controller({
@@ -1,8 +1,8 @@
import { RedisService } from "@/modules/redis/redis.service";
import { Injectable } from "@nestjs/common";
import { cityTimezonesHandler } from "@calcom/platform-libraries-0.0.22";
import type { CityTimezones } from "@calcom/platform-libraries-0.0.22";
import { cityTimezonesHandler } from "@calcom/platform-libraries-0.0.23";
import type { CityTimezones } from "@calcom/platform-libraries-0.0.23";
@Injectable()
export class TimezonesService {
@@ -0,0 +1,189 @@
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 {
UserWebhookOutputResponseDto,
UserWebhooksOutputResponseDto,
} from "@/modules/webhooks/outputs/user-webhook.output";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import * as request from "supertest";
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 { Webhook } from "@calcom/prisma/client";
describe("WebhooksController (e2e)", () => {
let app: INestApplication;
const userEmail = "webhook-controller-e2e@api.com";
let user: UserWithProfile;
let otherUser: UserWithProfile;
let userRepositoryFixture: UserRepositoryFixture;
let webhookRepositoryFixture: WebhookRepositoryFixture;
let webhook: UserWebhookOutputResponseDto["data"];
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);
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",
});
otherWebhook = await webhookRepositoryFixture.create({
id: "2mdfnn2",
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/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",
userId: user.id,
},
} satisfies UserWebhookOutputResponseDto);
webhook = res.body.data;
});
});
it("/webhooks (POST) should fail to create a webhook that already has same userId / subcriberUrl combo", () => {
return request(app.getHttpServer())
.post("/v2/webhooks")
.send({
subscriberUrl: "https://example.com",
triggers: ["BOOKING_CREATED", "BOOKING_RESCHEDULED", "BOOKING_CANCELLED"],
active: true,
payloadTemplate: "string",
} satisfies CreateWebhookInputDto)
.expect(409);
});
it("/webhooks/:webhookId (PATCH)", () => {
return request(app.getHttpServer())
.patch(`/v2/webhooks/${webhook.id}`)
.send({
active: false,
} satisfies UpdateWebhookInputDto)
.expect(200)
.then((res) => {
expect(res.body.data.active).toBe(false);
});
});
it("/webhooks/:webhookId (GET)", () => {
return request(app.getHttpServer())
.get(`/v2/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",
userId: user.id,
},
} satisfies UserWebhookOutputResponseDto);
});
});
it("/webhooks/:webhookId (GET) should fail to get a webhook that does not exist", () => {
return request(app.getHttpServer()).get(`/v2/webhooks/90284`).expect(404);
});
it("/webhooks/:webhookId (GET) should fail to get a webhook that does not belong to user", () => {
return request(app.getHttpServer()).get(`/v2/webhooks/${otherWebhook.id}`).expect(403);
});
it("/webhooks (GET)", () => {
return request(app.getHttpServer())
.get("/v2/webhooks")
.expect(200)
.then((res) => {
const responseBody = res.body as UserWebhooksOutputResponseDto;
responseBody.data.forEach((webhook) => {
expect(webhook.userId).toBe(user.id);
});
});
});
it("/webhooks/:webhookId (DELETE)", () => {
return request(app.getHttpServer())
.delete(`/v2/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",
userId: user.id,
},
} satisfies UserWebhookOutputResponseDto);
});
});
it("/webhooks/:webhookId (DELETE) shoud fail to delete a webhook that does not exist", () => {
return request(app.getHttpServer()).delete(`/v2/webhooks/12993`).expect(404);
});
it("/webhooks/:webhookId (DELETE) shoud fail to delete a webhook that does not belong to user", () => {
return request(app.getHttpServer()).delete(`/v2/webhooks/${otherWebhook.id}`).expect(403);
});
});
@@ -0,0 +1,117 @@
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 { UserWithProfile } from "@/modules/users/users.repository";
import { GetWebhook } from "@/modules/webhooks/decorators/get-webhook-decorator";
import { IsUserWebhookGuard } from "@/modules/webhooks/guards/is-user-webhook-guard";
import { CreateWebhookInputDto, UpdateWebhookInputDto } from "@/modules/webhooks/inputs/webhook.input";
import {
UserWebhookOutputDto,
UserWebhookOutputResponseDto,
UserWebhooksOutputResponseDto,
} from "@/modules/webhooks/outputs/user-webhook.output";
import { PartialWebhookInputPipe, WebhookInputPipe } from "@/modules/webhooks/pipes/WebhookInputPipe";
import { WebhookOutputPipe } from "@/modules/webhooks/pipes/WebhookOutputPipe";
import { UserWebhooksService } from "@/modules/webhooks/services/user-webhooks.service";
import { WebhooksService } from "@/modules/webhooks/services/webhooks.service";
import { Controller, Post, Body, UseGuards, Get, Param, Query, Delete, Patch } 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/webhooks",
version: API_VERSIONS_VALUES,
})
@UseGuards(ApiAuthGuard)
@ApiTags("Users' Webhooks")
export class WebhooksController {
constructor(
private readonly webhooksService: WebhooksService,
private readonly userWebhooksService: UserWebhooksService
) {}
@Post("/")
@ApiOperation({ summary: "Create a webhook" })
async createWebhook(
@Body() body: CreateWebhookInputDto,
@GetUser() user: UserWithProfile
): Promise<UserWebhookOutputResponseDto> {
const webhook = await this.userWebhooksService.createUserWebhook(
user.id,
new WebhookInputPipe().transform(body)
);
return {
status: SUCCESS_STATUS,
data: plainToClass(UserWebhookOutputDto, new WebhookOutputPipe().transform(webhook), {
strategy: "excludeAll",
}),
};
}
@Patch("/:webhookId")
@ApiOperation({ summary: "Update a webhook" })
@UseGuards(IsUserWebhookGuard)
async updateWebhook(
@Param("webhookId") webhookId: string,
@Body() body: UpdateWebhookInputDto
): Promise<UserWebhookOutputResponseDto> {
const webhook = await this.webhooksService.updateWebhook(
webhookId,
new PartialWebhookInputPipe().transform(body)
);
return {
status: SUCCESS_STATUS,
data: plainToClass(UserWebhookOutputDto, new WebhookOutputPipe().transform(webhook), {
strategy: "excludeAll",
}),
};
}
@Get("/:webhookId")
@ApiOperation({ summary: "Get a webhook" })
@UseGuards(IsUserWebhookGuard)
async getWebhook(@GetWebhook() webhook: Webhook): Promise<UserWebhookOutputResponseDto> {
return {
status: SUCCESS_STATUS,
data: plainToClass(UserWebhookOutputDto, new WebhookOutputPipe().transform(webhook)),
};
}
@Get("/")
@ApiOperation({ summary: "Get all user webhooks paginated" })
async getWebhooks(
@GetUser() user: UserWithProfile,
@Query() query: SkipTakePagination
): Promise<UserWebhooksOutputResponseDto> {
const webhooks = await this.userWebhooksService.getUserWebhooksPaginated(
user.id,
query.skip ?? 0,
query.take ?? 250
);
return {
status: SUCCESS_STATUS,
data: webhooks.map((webhook) =>
plainToClass(UserWebhookOutputDto, new WebhookOutputPipe().transform(webhook), {
strategy: "excludeAll",
})
),
};
}
@Delete("/:webhookId")
@ApiOperation({ summary: "Delete a webhook" })
@UseGuards(IsUserWebhookGuard)
async deleteWebhook(@Param("webhookId") webhookId: string): Promise<UserWebhookOutputResponseDto> {
const webhook = await this.webhooksService.deleteWebhook(webhookId);
return {
status: SUCCESS_STATUS,
data: plainToClass(UserWebhookOutputDto, new WebhookOutputPipe().transform(webhook), {
strategy: "excludeAll",
}),
};
}
}
@@ -0,0 +1,33 @@
import { ExecutionContext } from "@nestjs/common";
import { createParamDecorator } from "@nestjs/common";
import { Webhook } from "@calcom/prisma/client";
export type GetWebhookReturnType = Webhook;
export const GetWebhook = createParamDecorator<
keyof GetWebhookReturnType | (keyof GetWebhookReturnType)[],
ExecutionContext
>((data, ctx) => {
const request = ctx.switchToHttp().getRequest();
const webhook = request.webhook as GetWebhookReturnType;
if (!webhook) {
throw new Error("GetWebhook decorator : Webhook not found");
}
if (Array.isArray(data)) {
return data.reduce((prev, curr) => {
return {
...prev,
[curr]: webhook[curr],
};
}, {});
}
if (data) {
return webhook[data];
}
return webhook;
});
@@ -0,0 +1,60 @@
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
import { GetUserReturnType } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { WebhooksService } from "@/modules/webhooks/services/webhooks.service";
import {
BadRequestException,
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { EventType, Webhook } from "@prisma/client";
import { Request } from "express";
@Injectable()
export class IsUserEventTypeWebhookGuard implements CanActivate {
constructor(
private readonly webhooksService: WebhooksService,
private readonly eventtypesRepository: EventTypesRepository_2024_06_14
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context
.switchToHttp()
.getRequest<Request & { webhook: Webhook } & { eventType: EventType }>();
const user = request.user as GetUserReturnType;
const webhookId = request.params.webhookId;
const eventTypeId = request.params.eventTypeId;
if (!user) {
return false;
}
if (eventTypeId) {
const eventType = await this.eventtypesRepository.getEventTypeById(parseInt(eventTypeId));
if (!eventType) {
throw new NotFoundException(`Event type (${eventTypeId}) not found`);
}
if (eventType.userId !== user.id) {
throw new ForbiddenException(`User (${user.id}) is not the owner of event type (${eventTypeId})`);
}
request.eventType = eventType;
}
if (webhookId) {
const webhook = await this.webhooksService.getWebhookById(webhookId);
if (!webhook.eventTypeId) {
throw new BadRequestException(`Webhook (${webhookId}) is not associated with an event type`);
}
if (webhook.eventTypeId !== parseInt(eventTypeId)) {
throw new ForbiddenException(
`Webhook (${webhookId}) is not associated with event type (${eventTypeId})`
);
}
request.webhook = webhook;
}
return true;
}
}
@@ -0,0 +1,31 @@
import { GetUserReturnType } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { WebhooksService } from "@/modules/webhooks/services/webhooks.service";
import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common";
import { Request } from "express";
import { Webhook } from "@calcom/prisma/client";
@Injectable()
export class IsUserWebhookGuard implements CanActivate {
constructor(private readonly webhooksService: WebhooksService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request & { webhook: Webhook }>();
const user = request.user as GetUserReturnType;
const webhookId = request.params.webhookId;
if (!user || !webhookId) {
return false;
}
const webhook = await this.webhooksService.getWebhookById(webhookId);
if (webhook.userId !== user.id) {
return user.isSystemAdmin;
}
request.webhook = webhook;
return true;
}
}
@@ -0,0 +1,49 @@
import { ApiProperty, PartialType } from "@nestjs/swagger";
import { WebhookTriggerEvents } from "@prisma/client";
import { IsArray, IsBoolean, IsEnum, IsOptional, IsString } from "class-validator";
export class CreateWebhookInputDto {
@IsString()
@IsOptional()
@ApiProperty({
description:
"The template of the payload that will be sent to the subscriberUrl, check cal.com/docs/core-features/webhooks for more information",
example: JSON.stringify({
content: "A new event has been scheduled",
type: "{{type}}",
name: "{{title}}",
organizer: "{{organizer.name}}",
booker: "{{attendees.0.name}}",
}),
})
payloadTemplate?: string;
@IsBoolean()
active!: boolean;
@IsString()
subscriberUrl!: string;
@IsArray()
@ApiProperty({
example: [
"BOOKING_CREATED",
"BOOKING_RESCHEDULED",
"BOOKING_CANCELLED",
"BOOKING_CONFIRMED",
"BOOKING_REJECTED",
"BOOKING_COMPLETED",
"BOOKING_NO_SHOW",
"BOOKING_REOPENED",
],
enum: WebhookTriggerEvents,
})
@IsEnum(WebhookTriggerEvents, { each: true })
triggers!: WebhookTriggerEvents[];
@IsString()
@IsOptional()
secret?: string;
}
export class UpdateWebhookInputDto extends PartialType(CreateWebhookInputDto) {}
@@ -0,0 +1,37 @@
import { ApiProperty } from "@nestjs/swagger";
import { Expose, Type } from "class-transformer";
import { IsInt, IsEnum, ValidateNested } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
import { WebhookOutputDto } from "./webhook.output";
export class EventTypeWebhookOutputDto extends WebhookOutputDto {
@IsInt()
@Expose()
readonly eventTypeId!: number;
}
export class EventTypeWebhookOutputResponseDto {
@Expose()
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@Expose()
@ValidateNested()
@Type(() => WebhookOutputDto)
data!: EventTypeWebhookOutputDto;
}
export class EventTypeWebhooksOutputResponseDto {
@Expose()
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@Expose()
@ValidateNested()
@Type(() => WebhookOutputDto)
data!: EventTypeWebhookOutputDto[];
}
@@ -0,0 +1,37 @@
import { ApiProperty } from "@nestjs/swagger";
import { Expose, Type } from "class-transformer";
import { IsInt, IsEnum, ValidateNested } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
import { WebhookOutputDto } from "./webhook.output";
export class UserWebhookOutputDto extends WebhookOutputDto {
@IsInt()
@Expose()
readonly userId!: number;
}
export class UserWebhookOutputResponseDto {
@Expose()
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@Expose()
@ValidateNested()
@Type(() => WebhookOutputDto)
data!: UserWebhookOutputDto;
}
export class UserWebhooksOutputResponseDto {
@Expose()
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@Expose()
@ValidateNested()
@Type(() => WebhookOutputDto)
data!: UserWebhookOutputDto[];
}
@@ -0,0 +1,51 @@
import { ApiProperty } from "@nestjs/swagger";
import { WebhookTriggerEvents } from "@prisma/client";
import { Expose, Type } from "class-transformer";
import { IsBoolean, IsEnum, IsInt, IsString, ValidateNested } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
export class WebhookOutputDto {
@IsInt()
@Expose()
readonly id!: number;
@IsString()
@Expose()
@ApiProperty({
description:
"The template of the payload that will be sent to the subscriberUrl, check cal.com/docs/core-features/webhooks for more information",
example: JSON.stringify({
content: "A new event has been scheduled",
type: "{{type}}",
name: "{{title}}",
organizer: "{{organizer.name}}",
booker: "{{attendees.0.name}}",
}),
})
readonly payloadTemplate!: string;
@IsEnum(WebhookTriggerEvents)
@Expose()
readonly triggers!: WebhookTriggerEvents[];
@IsString()
@Expose()
readonly subscriberUrl!: string;
@IsBoolean()
@Expose()
readonly active!: boolean;
}
export class DeleteManyWebhooksOutputResponseDto {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
@Expose()
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@Expose()
@ValidateNested()
@Type(() => WebhookOutputDto)
data!: string;
}

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