Compare commits

...
93 Commits
Author SHA1 Message Date
Keith Williams 0b719ac070 chore: v4.4.2 (#16278)
release.yml / release (push) Canceled after 0s
2024-08-20 22:54:22 +09:00
Anik Dhabal BabuandSomay Chauhan baa9045af7 feat: ability to add guests via app.cal.com/bookings (#14740)
* feat: ability to add guests via app.cal.com/bookings

* fix: some update

* fix: minor issue

* fix: final update

* update

* update

* add requested changes

* fix type error

* small update

* final update

* fix type error

* fix location

* update calender event

---------

Co-authored-by: Somay Chauhan <somaychauhan98@gmail.com>
2024-08-20 09:26:19 +00:00
b004587262 fix: textarea ux in Editor.tsx #16225 (#16231)
* fix: textarea ux in Editor.tsx #16225

* fix: textarea ux in Editor.tsx #16225

* fix: textarea ux in Editor.tsx #16225

* fix: textarea ux in Editor.tsx #16225

* fix: textarea ux in Editor.tsx #16225

* fix and update

* remove space

---------

Co-authored-by: unknown <adhabal2002@gmail.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-08-20 08:39:33 +00:00
Sy Sagar 8a7beff339 style(Booker): overflow on timezone Select (#16242) 2024-08-20 08:26:53 +00:00
Shreya AgarwalandAmit Sharma 9001eb7795 fix: fix app config same key values (#16215)
Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>
2024-08-20 07:33:24 +00:00
Hichem Fantar 50c4578c2e feat: Add transition to hover background in multiple components (#16216)
* chore: Add transition to hover background in multiple components

* chore: Add transition to hover background in multiple components
2024-08-20 07:23:09 +00:00
4ff2ce90f9 fix: whatsapp message body sometimes editable (Issue #16067) (#16185)
* Fixing issue #16067

* Creating reusable Editor component for Editor

* fix editor issue

* remove log

* fix: unit test

* fix: unit test

* add unit test for the plugin and changes

---------

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-20 16:19:24 +09:00
86f8c6c5fa fix: Close.com can't be installed (#16196)
* Fix for #16183 , [CAL-3969]

* Update index.tsx

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
2024-08-20 16:18:20 +09:00
Vijay 0611bbd50e fix: #16079 hide settings/Admin API if not org and update link to sales (#16198)
* hide admin_api if not org and update link to sales

* update for org members

* updated back to orgAdmin check
2024-08-20 16:17:24 +09:00
Omar López b946abab02 fix: only keep latest e2e test results (#16273) 2024-08-19 23:23:06 +00:00
Omar López a091c60618 fix: uncaught types errors in invite members team handler (#16109) 2024-08-19 20:27:37 +00:00
Omar López bba7fc1a30 hotfix: wrong branch name for reports (#16271) 2024-08-19 20:18:15 +00:00
zomars 864c46736b hotfix: wrong branch name for reports
Signed-off-by: zomars <zomars@me.com>
2024-08-19 12:59:44 -07:00
4416709ff6 chore(availabilty-page): Bulk update modal (#16267)
* chore(Availability): Bulk update modal

* fix type error

---------

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-19 21:34:16 +02:00
Vijay 591d42fc8f fix: #16150 update to fix seated event reschedule (#16163)
* update to fix seated event reschedule

* updated handleseats tests

* updated handleSeats timeslot

* updated test to expect error

* update to remove uid in where clause (isFirstSeat)
2024-08-19 22:03:35 +05:30
Syed Ali Shahbaz d73b84157a fix: v1 api payments endpoint fix (#16264)
* Update payment validation v1

* fix for [id]
2024-08-19 16:29:36 +00:00
680e748b7a feat: make the meeting duration time more visually toggleable (#15422)
* feat: make the meeting duration time more visually toggleable

* final_update

* Update dynamic-booking-pages.e2e.ts

* fix e2e

* fix: e2e test

* fixed

* update

* show selcted item at the time of mount

* fix type error

* Update Duration.tsx

* fix

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Udit Takkar <udit222001@gmail.com>
2024-08-19 14:25:02 +00:00
zerone0xandunknown 99b7aaa62d fix: Chinese traditional shows multiple durations of the event as a string (#16187)
* fix: Chinese traditional shows multiple durations of the event as a string

* revert

* make changes only in necessary files

---------

Co-authored-by: unknown <adhabal2002@gmail.com>
2024-08-19 19:34:30 +09:00
Omar López 3a01e6390f fix: type issues in attributes-edit-view (#16243) 2024-08-19 10:33:20 +00:00
dillonstreatorandunknown a6c25ab7d3 docs: update documentation references to include /docs base path (#16257)
* docs: update /doc references

* gix avilibilty docs link

---------

Co-authored-by: unknown <adhabal2002@gmail.com>
2024-08-19 10:21:50 +00:00
Rajiv Sahal df98274a8f update icons list (#16263) 2024-08-19 10:21:36 +00:00
Kaiwalya Koparkar b540a2ec45 update: Deploy to Elestio button updated (#16238) 2024-08-19 19:16:35 +09:00
sean-brydon 8e4459f2fe chore: missing ff attributes migration (#16229)
* add migration file

* add miration file
2024-08-19 10:09:25 +00:00
Carina WollendorferandCarinaWolli bc5a9f231f fix: POST v1/teams request body (#16262)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2024-08-19 08:28:56 +00:00
Carina WollendorferandCarinaWolli 88984f6175 fix: event name in attendee cancellation email (#16240)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2024-08-19 08:24:52 +00:00
fc592a17ac feat: Add 'Disable on Prefill' option for fields (#15715)
* feat:add prefilcheck check box in the field creation

* chore:formatted the li8 translations

* feat:add e2e test case for this feature

* refactor:reanmed the key from prefilledCheck to disableOnPrefill and moved the readOnly logic to formbuilderField

* chore:fixed the type check

* fix:if prefilled has error. do not disabble it

* chore:removed the e2e tests for prefill in e2e

* chore:revert back to old changes

* chore:remove unwanted code frome2e

* Support name field as well

* refactor:handled the feedback changes. still unit test cases pending

* feat: add unit tests for  disable field on prefill logic

* chore: resolved the type check and typos

* chore:renamed the files and type clean up

* Apply suggestions from code review

* chore:renaming test files and moving the disableonprefill hook to formbuilderFeild.

* refactor: add unit test for serach params and form values check and minor code formatting and file name changes

* fix: validating the input value partially at the intialising phase. so that form response values and  from ui values are same and making sure invalid data not sent to backend on submit.

* chore:fix the type check issue

* fix: field value valdiation at initilisation and add the  unit test cases for that change

* chore:add comments to the tests

* chore:resolve type check

* chore: moved the tests to the formBuilder

* Simplify codebase by ensuring select and multiselect changes the value when no option is found

* Relocate and refactor tests

* handle special cases

* Dont disable for dirty fields

* Update schema.prisma

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: Hariom <hariombalhara@gmail.com>
2024-08-18 17:34:59 +00:00
Udit Takkar a51524e673 fix: cal video bug fix and improvements (#16234) 2024-08-18 09:09:27 +00:00
Omar López cb6553c731 chore: v4.4.1 (#16246)
release.yml / release (push) Canceled after 0s
2024-08-16 10:34:36 -07:00
Omar López 101fea2c1c fix: cross domain SVG sprites (#16244) 2024-08-16 16:33:10 +00:00
Morgan aab9193e7b fix: rewrite to avoid cross origin issues with sprite.svg icons (#16239) 2024-08-16 15:24:43 +03:00
Morgan a6d2e46b3d fix: check profiles from user.profiles apiv2 (#16235) 2024-08-16 11:06:53 +00:00
25ae1493c3 fix: Duplicating org team event types not working #16199 (#16204)
* fix: Duplicating org team event types not working #16199

* fix type error

* Update eventtype.ts

---------

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-16 12:24:26 +02:00
Carina WollendorferandCarinaWolli 56dcab2f6d fix padding (#16227)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2024-08-16 09:53:28 +00:00
Somay ChauhanandMorgan 8fbc76d59a feat: extend api/v2/event-types (#16128)
* feat: extended api/v2/event-types to add more attributes

- additional attributes added
* bookingLimits
* onlyShowFirstAvailableSlot
* durationLimits
* offsetStart
* bookingWindow

* add swagger docs

* type fix

* extend api/v2/teams/[teamId]/event-types

* added tests

* extend api/v2/orgs/[orgId]/teams/[teamid]/event-types

* update: added tests to get and update event-type as well

* rename variables

- bookingLimits -> bookingLimitsCount
- durationLimits -> bookingLimitsDuration

* refactor: rename bookingLimits and durationLimits, add descriptions

- Renamed `bookingLimits` to `bookingLimitsCount`
- Renamed `durationLimits` to `bookingLimitsDuration`
- Added descriptions with examples to `bookingLimitsCount` and `bookingLimitsDuration`

* feat: add unit tests for transformation logic in api-request and api-response

- Added tests for `transformApiEventTypeIntervalLimits`
- Added tests for `transformApiEventTypeFutureBookingLimits`

* fix: type errors

* feat: added custom validators for BookingLimitsCount and BookingLimitsDuration

* fix: unit-tests

* Update CHANGELOG.md

* refactor: update imports to use platform-libraries from @calcom/platform-libraries-1.2.3

* fix: prevent double transformation of bookingWindow and bookingFields

- resolving e2e test failures

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2024-08-16 09:25:34 +00:00
Joe Au-Yeung 894c0fc8a7 Change body from HTML to text (#16217) 2024-08-16 00:40:56 +01:00
Alex van AndelandOmar López 076550b384 chore: Release v4.4.0 (#16219)
release.yml / release (push) Canceled after 0s
Co-authored-by: Omar López <zomars@me.com>
2024-08-15 14:51:18 -07:00
08e1b0a9c8 feat: attributes (#15964)
Co-authored-by: Joe Au-Yeung <j.auyeung419@gmail.com>
Co-authored-by: Omar López <zomars@me.com>
2024-08-15 14:49:05 -07:00
Omar López ef4deb1657 fix: pipeline permissions for label action (#16220) 2024-08-15 14:37:23 -07:00
f81074c4f5 feat: destination calendar settings for CalendarSettings atom (#16133)
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: Morgan Vernay <morgan@cal.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2024-08-15 14:31:53 -07:00
Omar López 5df14c344e refactor: use WebhookService in payments router (#16108) 2024-08-15 13:03:07 -07:00
cd311f076c feat: Round Robin Weights (#15558)
* WIP

* add frontend

* backend to update weight

* UI improvements

* WIP weight algorithm

* enable weights switch + algorithm improvements

* fix weightDescription

* clean up code

* remove OOOEntryHost from schema.prisma

* implement logic (not yet tested)

* add tests for weight algorithm

* add test with weight adjustment

* finish unit tests

* fix type error

* fix type error

* add migration

* fix type error

* fix event type update handler

* fix failing test

* UI fixes for saving hosts

* make sure weightAdjustment is not lost on host changes

* fix weightadjustment for new hosts

* add weightAdjustment to availableUsers

* fix type errors

* fix default value for weight

* make weight and weightAdjustment optional

* fix type errors from schema changes

* type fix

* clean up code

* improve comments

* remove comment

* clean up code

* add tests & weight adjustment improvments

* better variable naming

* fixes for weight adjustments

* make weightAdjustments proportional to weights

* fix previous host weight adjustments

* improved tests for weight adjustments

* save weight and priority + sort hosts correctly

* fix type error

* code clean up

* remove console.log

* use BookingRepository to fetch bookings of users

* use BookingRepository to fetch bookings in getLuckyUser

* fix type errors

* fix weightAdjustment if changed from fixed to rr host

* disable weights when 'assign all' is enabled

* typo

* allow 0 weight

* set min (and max) for weight and priority

* use useWatch

* code clean up

* fix type error

* only count accepted bookings for RR

* fix type error

* improve data fetching of bookings

* only filter bookings of availableUsers

* code clean up form feedback

* fix tests

* don't count no show bookings

* code clean up

* choose user with highest weight

* use one reduce instead of two

* use reduce instead of filter and map

* don't show weights toggle when 'assign all' is enabled

* design fixes

* fix type errors

* fix: type check

* Update packages/features/eventtypes/components/AddMembersWithSwitch.tsx

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2024-08-15 15:01:15 +01:00
Morgan d60f165223 fix: use platform me typing (#16214) 2024-08-15 11:31:26 +00:00
Morgan 8477128605 fix: platform me data for dashboard (#16210) 2024-08-15 10:12:31 +00:00
Morgan 84606543ae chore: preserve non-org dashboard for platform users (#16181) 2024-08-14 15:11:46 -07:00
Omar López a11242e93d fix: missing pipeline dependecies (#16205) 2024-08-14 21:41:46 +00:00
Omar López 5939a99aca test: merge all e2e reports (#16202) 2024-08-14 19:44:10 +00:00
Alex van AndelandOmar López 050abd9fab Update pr-review.yml (#16100)
Co-authored-by: Omar López <zomars@me.com>
2024-08-14 11:24:23 -07:00
Peer RichelsenandCarina Wollendorfer 1adf2ad5a8 fix: transcribe UI subtitle were blocking chat (#16097)
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
2024-08-14 13:33:17 +00:00
Kartik Saini 3f2d83d89f fix: doesn't fall back to calvideo if wrong destination is used for google meet (#16195) 2024-08-14 15:11:22 +02:00
528a4fbb97 feat: Assign colors to events (#15298)
* feat: Assign colors to events

* update

* final update

* update

* fix requested changes error

* Update EventAdvancedTab.tsx

* add contrast check

* update

* fix contrsterror message visibility

* fix type error

* fix

* update test

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
2024-08-14 15:10:40 +02:00
Joe Au-Yeung 95e2ad3007 fix: Disable all emails per event type w/o a workflow [CAL-4066] (#15902)
* Create confirmation dialog for attendees

* Add disabling host emails option

* Fix DOM issues

* Allow updating disabling emails on backend

* Pass metadata to sendEmails functions

* Clean up

* Type fixes

* Fix test

* Allow for enterprise only

* Type fix

* Test fix

* Check for parentId

* Disable emails for sendScheduledEmails and sendScheduledSeatsEmails

* Remove unused variable

* Only update disable all emails if event type belongs to an org

* Disable scheduling mandatory reminder workflow if attendee emails are disabled

* Refactor disable email UI

* Update copy

* Type fixes

* Fix missing params
2024-08-14 13:17:52 +02:00
c41e5ddc2c feat: add gtm to load in us only (#16146)
* add gtm to load in us only

* fix: Tried to give typescript some help

* fix: Alternative fix

* fix: Add yarn.lock

* fix: Oops

* fix: type err

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Udit Takkar <udit222001@gmail.com>
2024-08-14 09:31:01 +00:00
Omar López f206b3d135 feat: e2e reports in gh-pages (#16179) 2024-08-13 21:50:25 +00:00
0b03bcb90e feat: CalendarSettings atom (#16120)
* dumb components for calendar settings

* shadcn switch

* update exports

* update packages

* add calendar settings atom to examples app

* init calendar settings atom

* refactors

* fix import path

* export type for calendar switch props

* invalidate queries on deleting calendar credentials

* replace calendars list with calendar settings wrapper

* cleanup

* update styling

* refactors

* cleanup

* fix: missing key prop CalendarSettingsPlatformWrapper

* Label as client components

* Address client component build errors

* Move QueryCell out of packages/lib

* PR feedback

* more feedback

---------

Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: Morgan Vernay <morgan@cal.com>
Co-authored-by: Joe Au-Yeung <j.auyeung419@gmail.com>
2024-08-13 21:51:29 +05:30
Alex van Andelandzomars b09b32b121 fix: scheduleId not unset properly bug (#15993)
* fix: scheduleId not unset properly bug

* Refactor almost complete, couple of tests needed

* More testid fixes

* Add migration to fixup schedule id bug

* Remove migration

* fix: Refactor useLockedFieldManager for correct useState use

---------

Co-authored-by: zomars <zomars@me.com>
2024-08-13 16:44:16 +01:00
Shreya Agarwal d5f7065fdb fix: Replace empty user name with user email (#16176) 2024-08-13 16:29:09 +02:00
dc40cc664d test: Bring back broken out-of-office tests, prevent modal button clickable before flicker, and fix timezone issue (#16001)
* test: prevent modal button clickable before flicker

* chore: add note to migrate to appDir to solve full page flicker issue

* chore: Improve OutOfOfficePage loading experience

Refactor the OutOfOfficePage component to improve the loading experience by using the `isPending` property from the `trpc.viewer.outOfOfficeReasonList.useQuery()` hook. This workaorund eliminates the full page flicker issue and provides a better user experience. The "Add" button now shows a loading state when the query is pending.

* Update index.tsx

* chore: Update outOfOffice.handler.ts to use input.offset instead of calculating offset

* fix e2e

* Update

* revert

* chore: Fix offset calculation for out of office bookings

* chore: Update date range calculation for out-of-office modal

* chore: Remove console.log statement in out-of-office modal

* chore: Update TimeZoneEnum with additional time zones

This commit updates the TimeZoneEnum in the types.ts file to include additional time zones. The new time zones added are:
- ID: Asia/Jakarta (UTC+7)
- SAMOA: Etc/GMT+12 (UTC-12)
- AMERICAN_SAMOA: Pacific/Pago_Pago (UTC-11)
- HAWAII: Pacific/Honolulu (UTC-10)
- ALASKA: America/Anchorage (UTC-9)
- PACIFIC_USA: America/Los_Angeles (UTC-8)
- CENTRAL_USA: America/Chicago (UTC-6)
- EASTERN_USA: America/New_York (UTC-5)
- VENEZUELA: America/Caracas (UTC-4)
- ARGENTINA: America/Buenos_Aires (UTC-3)
- BRAZIL: America/Noronha (UTC-2)
- AZORES: Atlantic/Azores (UTC-1)
- FRANCE: Europe/Paris (UTC+1)
- GREECE: Europe/Athens (UTC+2)
- RUSSIA: Europe/Moscow (UTC+3)
- UAE: Asia/Dubai (UTC+4)
- PAKISTAN: Asia/Karachi (UTC+5)
- BANGLADESH: Asia/Dhaka (UTC+6)
- SINGAPORE: Asia/Singapore (UTC+8)
- JAPAN: Asia/Tokyo (UTC+9)
- AUSTRALIA: Australia/Sydney (UTC+10)
- SOLOMON_ISLANDS: Pacific/Guadalcanal (UTC+11)
- NEW_ZEALAND: Pacific/Auckland (UTC+12)

The purpose of this update is to provide a comprehensive list of time zones for users to choose from when setting their time zone in the out-of-office feature.

* update

* fix

* remove comment

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: unknown <adhabal2002@gmail.com>
2024-08-13 16:21:09 +02:00
Imamuzzaki Abu Salam 3eaf84e961 test: Add E2E tests for orgRedirection=true param (#16066) 2024-08-13 12:32:39 +00:00
Hariom Balhara c36249b5a7 fix: Add booking related events for recurring bookings as well (#16180)
* Add bookingSuccessful event for recurring booking

* Remove unused variable
2024-08-13 10:25:52 +00:00
sean-brydon 3666c260ee fix: remove layout as its already in /future/settings/teams/layout.tsx (#16172) 2024-08-12 16:08:15 +00:00
Peer Richelsen 77f3fd3b38 fixed todesktop nav (#16170) 2024-08-12 14:11:03 +00:00
Anik Dhabal Babu 848bcbfb8f feat: provide option to allow rescheduling with the same round-robin host (#15132)
* feat: provide option to allow rescheduling with the same round-robin
host

* update

* fix type error

* fix and update

* fix type error

* update

* remove

* Update getEventTypesFromDB.ts

* add test

* small fix

* fix requested changes
2024-08-12 15:03:50 +02:00
Hichem Fantarandsean-brydon 24936d8b7b refactor: improve Card component hover effect (#16161)
Simplify the hover effect on the Card component by adding a transition to the transform property. This improves the user experience and makes the hover effect smoother.

Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
2024-08-12 11:01:48 +00:00
Natnael YilmaandAmit Sharma ad62f9d2bd fix: reviewed-user-sms-lock (#16016)
* throw trpc error on re-review for sms

* update logic

* test

* update logic

---------

Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>
2024-08-12 08:12:48 +02:00
Omar López 829dd937d6 fix: add sensible default size for svg icons (#16159) 2024-08-10 02:59:25 +00:00
MorganandPeer Richelsen dda4b17a7c feat: Platform OAuthClient Webhooks (#16134)
* wip

* fixup! Merge branch 'main' into platform-oauth-client-webhooks

* wip

* fixup! Merge branch 'main' into platform-oauth-client-webhooks

* fixup! fixup! Merge branch 'main' into platform-oauth-client-webhooks

* fixup! fixup! fixup! Merge branch 'main' into platform-oauth-client-webhooks

* fixup! fixup! fixup! fixup! Merge branch 'main' into platform-oauth-client-webhooks

* fixup! fixup! fixup! fixup! fixup! Merge branch 'main' into platform-oauth-client-webhooks

* fixup! fixup! fixup! fixup! fixup! fixup! Merge branch 'main' into platform-oauth-client-webhooks

* fixup! fixup! fixup! fixup! fixup! fixup! fixup! Merge branch 'main' into platform-oauth-client-webhooks

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

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

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2024-08-09 17:02:39 +02:00
Peer Richelsen 4def31e98f added horizon workrooms (#16152) 2024-08-09 11:02:14 -04:00
Udit TakkarandAlex van Andel 6d3f5495cb revert: fix: #15857 add managed by tooltip (#16139)
* revert: fix: #15857 add managed by tooltip

* chore: remove parent

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2024-08-09 14:56:06 +01:00
sean-brydon 008af08148 remove requirement for signaturetoken (#16151) 2024-08-09 14:38:05 +01:00
Lauris Skraucis ac0930a11f test: v2 new event-types endpoints handle legacy event-types (#16144)
* test: v2 new event-types endpoints return legacy event-types

* Update event-types.repository.fixture.ts
2024-08-09 13:14:28 +00:00
Udit TakkarandJoe Au-Yeung 0e62800af6 refactor: simplify workflow page and improve load time (#16095)
* fix: simplify workflow page and improve load time

* chore: use new endpoint

* chore: save progress

* refactor: code

* refactor: remove not requried code

* chore: remove schema

* chore: fix typ

* chore: improve

* chore: change name

* chore: remove unused

* chore: remove page

---------

Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
2024-08-09 14:48:48 +02:00
sean-brydon b6ab1ef80a fix: broken password inputs (#16148) 2024-08-09 11:24:32 +00:00
Lauris Skraucis 13347edced feat: atoms date overrides modal custom classes (#16147)
* feat: atoms date override modal custom classes

* refactor: rename overridesModalClassName to overridesModalClassNames
2024-08-09 11:05:57 +00:00
d734841184 fix: #15726 increase max recurring events limit input (#15728)
* increased recurring limit input

* updated input max recurring limit to 24

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
2024-08-09 08:24:53 +00:00
a2998b6c84 fix: #15474 retrieve zoom usersettings and update zoom integration (#15489)
* retrieve zoom usersettings and update

* update to filter response

* updated zoomUserSettingsSchema

* abstract to new function, log error

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
2024-08-09 08:19:31 +00:00
Nafees Nazikandzomars da5df7164a perf: use svg sprite for icons (#16135)
Co-authored-by: zomars <zomars@me.com>
2024-08-08 15:12:08 -07:00
3e28160fa7 fix: uncaught PaymentService typings in payment utils (#16107)
* fix: uncaught errors in PaymentService

* fix: uncaught errors in WebhookService

* fix: uncaught errors in sendPayload

* fix: uncaught PaymentService typings in payment utils

* fix: Remove missing users from assignment (#16115)

* remove missing users from assignment

* early return

* 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

* fix: display skeleton loader (#16114)

* fix: display loader

* fix: updated skelton loader

* 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

* 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>

* 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>

* fix: Hide personal booking theme when in an organization (#16117)

Co-authored-by: Peer Richelsen <peeroke@gmail.com>

* fix: uncaught errors in WebhookService

* fix: uncaught errors in sendPayload

---------

Co-authored-by: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com>
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
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>
Co-authored-by: Imamuzzaki Abu Salam <imamuzzaki@gmail.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
2024-08-08 20:15:44 +02:00
Lauris Skraucis 2b9bdb9c80 fix: v2 event-types (#16132)
* refactor: have system and user booking fields schemas and united schema

* fix: event-types output service filter out system booking fields for response

* chore: v2 update platform-libraries to 0.0.25

* chore: platform-libraries export types and update changelog

* chore: add updated lock file
2024-08-08 20:21:56 +03:00
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
567 changed files with 15067 additions and 12540 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
+1 -1
View File
@@ -12,7 +12,7 @@
## Mandatory Tasks (DO NOT REMOVE)
- [ ] I have self-reviewed the code (A decent size PR without self-review might be rejected).
- [ ] I have added a Docs issue [here](https://github.com/calcom/docs/issues/new) if this PR makes changes that would require a [documentation change](https://docs.cal.com). If N/A, write N/A here and check the checkbox.
- [ ] I have added a Docs issue [here](https://github.com/calcom/docs/issues/new) if this PR makes changes that would require a [documentation change](https://docs.cal.com/docs). If N/A, write N/A here and check the checkbox.
- [ ] I confirm automated tests are in place that prove my fix is effective or that my feature works.
## How should this be tested?
-2
View File
@@ -37,8 +37,6 @@ runs:
with:
path: ${{ steps.yarn-config.outputs.CACHE_FOLDER }}
key: yarn-download-cache-${{ hashFiles('yarn.lock') }}
restore-keys: |
yarn-download-cache-
# Invalidated on yarn.lock changes
- name: Restore node_modules
@@ -3,6 +3,10 @@ description: "Install playwright, cache and restore if necessary"
runs:
using: "composite"
steps:
- name: Get installed Playwright version
shell: bash
id: playwright-version
run: echo "PLAYWRIGHT_VERSION=$(node -e "console.log(require('./package.json').devDependencies['@playwright/test'])")" >> $GITHUB_ENV
- name: Cache playwright binaries
id: playwright-cache
uses: buildjet/cache@v4
@@ -11,9 +15,7 @@ runs:
~/Library/Caches/ms-playwright
~/.cache/ms-playwright
${{ github.workspace }}/node_modules/playwright
key: cache-playwright-${{ hashFiles('**/yarn.lock') }}
restore-keys: cache-playwright-
key: ${{ runner.os }}-playwright-${{ env.PLAYWRIGHT_VERSION }}
- name: Yarn playwright install
shell: bash
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: yarn playwright install
run: yarn playwright install --with-deps
+58
View File
@@ -0,0 +1,58 @@
name: Delete
on:
delete:
branches-ignore: [main, gh-pages]
# ensures that currently running Playwright workflow of deleted branch gets cancelled
concurrency:
group: ${{ github.event.ref }}
cancel-in-progress: true
jobs:
delete_reports:
name: Delete Reports
runs-on: ubuntu-latest
env:
# Contains all reports for deleted branch
BRANCH_REPORTS_DIR: reports/${{ github.event.ref }}
steps:
- name: Checkout GitHub Pages Branch
uses: actions/checkout@v2
with:
ref: gh-pages
- name: Set Git User
# see: https://github.com/actions/checkout/issues/13#issuecomment-724415212
run: |
git config --global user.name "github-actions[bot]"
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
- name: Check for workflow reports
run: |
if [ -z "$(ls -A $BRANCH_REPORTS_DIR)" ]; then
echo "BRANCH_REPORTS_EXIST="false"" >> $GITHUB_ENV
else
echo "BRANCH_REPORTS_EXIST="true"" >> $GITHUB_ENV
fi
- name: Delete reports from repo for branch
if: ${{ env.BRANCH_REPORTS_EXIST == 'true' }}
timeout-minutes: 3
run: |
cd $BRANCH_REPORTS_DIR/..
rm -rf ${{ github.event.ref }}
git add .
git commit -m "workflow: remove all reports for branch ${{ github.event.ref }}"
while true; do
git pull --rebase
if [ $? -ne 0 ]; then
echo "Failed to rebase. Please review manually."
exit 1
fi
git push
if [ $? -eq 0 ]; then
echo "Successfully pushed HTML reports to repo."
exit 0
fi
done
+2 -2
View File
@@ -98,5 +98,5 @@ jobs:
if: ${{ always() }}
uses: actions/upload-artifact@v4
with:
name: app-store-results
path: test-results
name: blob-report-app-store
path: blob-report
+2 -2
View File
@@ -85,5 +85,5 @@ jobs:
if: ${{ always() }}
uses: actions/upload-artifact@v4
with:
name: embed-react-results
path: test-results
name: blob-report-embed-react
path: blob-report
+2 -2
View File
@@ -91,5 +91,5 @@ jobs:
if: ${{ always() }}
uses: actions/upload-artifact@v4
with:
name: embed-core-results
path: test-results
name: blob-report-embed-core
path: blob-report
+4 -3
View File
@@ -89,8 +89,9 @@ jobs:
- name: Run Tests
run: yarn e2e --shard=${{ matrix.shard }}/${{ strategy.job-total }}
- name: Upload Test Results
if: ${{ always() }}
uses: actions/upload-artifact@v4
if: always()
with:
name: test-results-${{ matrix.shard }}_${{ strategy.job-total }}
path: test-results
name: blob-report-${{ matrix.shard }}
path: blob-report
retention-days: 30
+25
View File
@@ -0,0 +1,25 @@
# https://playwright.dev/docs/test-sharding#merging-reports-from-multiple-shards
on:
workflow_call:
jobs:
merge-reports:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/dangerous-git-checkout
- uses: ./.github/actions/yarn-install
- uses: ./.github/actions/yarn-playwright-install
- name: Download blob reports from GitHub Actions Artifacts
uses: actions/download-artifact@v4
with:
path: all-blob-reports
pattern: blob-report-*
merge-multiple: true
- name: Merge into HTML Report
run: yarn playwright merge-reports --reporter html ./all-blob-reports
- name: Upload HTML report
uses: actions/upload-artifact@v4
with:
name: html-report--attempt-${{ github.run_number }}-${{ github.run_attempt }}
path: playwright-report
retention-days: 14
+5 -2
View File
@@ -7,11 +7,14 @@ on:
jobs:
label-pr:
runs-on: ubuntu-latest
permissions:
actions: read
issues: write
pull-requests: write
steps:
- name: Label PR as ready for E2E
if: github.event.review.state == 'approved'
uses: actions-ecosystem/action-add-labels@v1
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
github_token: ${{ secrets.READY_FOR_E2E_PAT }}
labels: 'ready-for-e2e'
+47 -7
View File
@@ -91,44 +91,50 @@ jobs:
console.log('Found the label?', labelFound);
core.setOutput('run-e2e', labelFound);
deps:
name: Install dependencies
needs: [changes, check-label]
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
uses: ./.github/workflows/yarn-install.yml
type-check:
name: Type check
needs: [changes, check-label]
needs: [changes, check-label, deps]
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
uses: ./.github/workflows/check-types.yml
secrets: inherit
lint:
name: Linters
needs: [changes, check-label]
needs: [changes, check-label, deps]
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
uses: ./.github/workflows/lint.yml
secrets: inherit
unit-test:
name: Tests
needs: [changes, check-label]
needs: [changes, check-label, deps]
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
uses: ./.github/workflows/unit-tests.yml
secrets: inherit
build-api-v1:
name: Production builds
needs: [changes, check-label]
needs: [changes, check-label, deps]
if: ${{ needs.check-label.outputs.run-e2e == 'true' && needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
uses: ./.github/workflows/api-v1-production-build.yml
secrets: inherit
build-api-v2:
name: Production builds
needs: [changes, check-label]
needs: [changes, check-label, deps]
if: ${{ needs.check-label.outputs.run-e2e == 'true' && needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
uses: ./.github/workflows/api-v2-production-build.yml
secrets: inherit
build:
name: Production builds
needs: [changes, check-label]
needs: [changes, check-label, deps]
if: ${{ needs.check-label.outputs.run-e2e == 'true' && needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
uses: ./.github/workflows/production-build-without-database.yml
secrets: inherit
@@ -181,8 +187,42 @@ jobs:
uses: ./.github/workflows/nextjs-bundle-analysis.yml
secrets: inherit
merge-reports:
name: Merge reports
if: ${{ !cancelled() && needs.check-label.outputs.run-e2e == 'true' && needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
needs: [changes, check-label, e2e, e2e-embed, e2e-embed-react, e2e-app-store]
uses: ./.github/workflows/merge-reports.yml
secrets: inherit
publish-report:
name: Publish HTML report
if: ${{ !cancelled() && needs.check-label.outputs.run-e2e == 'true' && needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
permissions:
contents: write
issues: write
pull-requests: write
needs: [changes, check-label, merge-reports]
uses: ./.github/workflows/publish-report.yml
secrets: inherit
required:
needs: [changes, lint, type-check, unit-test, integration-test, check-label, build, build-api-v1, build-api-v2, e2e, e2e-api-v2, e2e-embed, e2e-embed-react, e2e-app-store]
needs:
[
changes,
lint,
type-check,
unit-test,
integration-test,
check-label,
build,
build-api-v1,
build-api-v2,
e2e,
e2e-api-v2,
e2e-embed,
e2e-embed-react,
e2e-app-store,
]
if: always()
runs-on: buildjet-2vcpu-ubuntu-2204
steps:
+96
View File
@@ -0,0 +1,96 @@
on:
workflow_call:
permissions:
contents: write
issues: write
pull-requests: write
jobs:
publish-report:
runs-on: ubuntu-latest
continue-on-error: true
env:
# Unique URL path for each workflow run attempt
HTML_REPORT_URL_PATH: reports/${{ github.head_ref }}/${{ github.run_id }}/${{ github.run_attempt }}
BRANCH_REPORTS_DIR: reports/${{ github.head_ref }}
steps:
- name: Checkout GitHub Pages Branch
uses: actions/checkout@v4
with:
ref: gh-pages
- name: Set Git User
# see: https://github.com/actions/checkout/issues/13#issuecomment-724415212
run: |
git config --global user.name "github-actions[bot]"
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
- name: Check for workflow reports
run: |
if [ -z "$(ls -A $BRANCH_REPORTS_DIR)" ]; then
echo "BRANCH_REPORTS_EXIST="false"" >> $GITHUB_ENV
else
echo "BRANCH_REPORTS_EXIST="true"" >> $GITHUB_ENV
fi
- name: Cleanup old HTML reports
if: ${{ env.BRANCH_REPORTS_EXIST == 'true' }}
timeout-minutes: 3
run: |
rm -rf $BRANCH_REPORTS_DIR
git add .
git commit -m "workflow: remove all reports for branch ${{ github.head_ref }}"
- name: Download zipped HTML report
uses: actions/download-artifact@v4
with:
name: html-report--attempt-${{ github.run_number }}-${{ github.run_attempt }}
path: ${{ env.HTML_REPORT_URL_PATH }}
- name: Push HTML Report
timeout-minutes: 3
# commit report, then try push-rebase-loop until it's able to merge the HTML report to the gh-pages branch
# this is necessary when this job running at least twice at the same time (e.g. through two pushes at the same time)
run: |
git add .
git commit -m "workflow: add HTML report for run-id ${{ github.run_id }} (attempt: ${{ github.run_attempt }})"
while true; do
git pull --rebase
if [ $? -ne 0 ]; then
echo "Failed to rebase. Please review manually."
exit 1
fi
git push
if [ $? -eq 0 ]; then
echo "Successfully pushed HTML report to repo."
exit 0
fi
done
- name: Output Report URL as Workflow Annotation
id: url
run: |
FULL_HTML_REPORT_URL=https://calcom.github.io/cal.com/$HTML_REPORT_URL_PATH
echo "::notice title=📋 Published Playwright Test Report::$FULL_HTML_REPORT_URL"
echo "link=$FULL_HTML_REPORT_URL" >> $GITHUB_OUTPUT
- name: Find Comment
uses: peter-evans/find-comment@v2
id: fc
with:
issue-number: ${{ github.event.pull_request.number }}
comment-author: "github-actions[bot]"
body-includes: <!-- GENERATED-E2E-RESULTS -->
- name: Create comment
if: steps.fc.outputs.comment-id == ''
uses: peter-evans/create-or-update-comment@v3
with:
issue-number: ${{ github.event.pull_request.number }}
body: |
<!-- GENERATED-E2E-RESULTS -->
## E2E results are ready!
- [Workflow #${{ github.run_number }}.${{ github.run_attempt }} latest results](${{ steps.url.outputs.link }})
- name: Update comment
if: steps.fc.outputs.comment-id != ''
uses: peter-evans/create-or-update-comment@v3
with:
comment-id: ${{ steps.fc.outputs.comment-id }}
edit-mode: replace
body: |
<!-- GENERATED-E2E-RESULTS -->
## E2E results are ready!
- [Workflow #${{ github.run_number }}.${{ github.run_attempt }} latest results](${{ steps.url.outputs.link }})
+1
View File
@@ -15,3 +15,4 @@ jobs:
- uses: actions/checkout@v4
- uses: ./.github/actions/dangerous-git-checkout
- uses: ./.github/actions/yarn-install
- uses: ./.github/actions/yarn-playwright-install
+1 -1
View File
@@ -380,7 +380,7 @@ Currently Vercel Pro Plan is required to be able to Deploy this application with
### Elestio
[![Deploy on Elestio](https://pub-da36157c854648669813f3f76c526c2b.r2.dev/deploy-on-elestio-black.png)](https://elest.io/open-source/cal.com)
[![Deploy on Elestio](https://elest.io/images/logos/deploy-to-elestio-btn.png)](https://elest.io/open-source/cal.com)
<!-- ROADMAP -->
-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) {
+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(),
+10 -3
View File
@@ -1,5 +1,12 @@
import { _PaymentModel as Payment } from "@calcom/prisma/zod";
// FIXME: Payment seems a delicate endpoint, do we need to remove anything here?
export const schemaPaymentBodyParams = Payment.omit({ id: true });
export const schemaPaymentPublic = Payment.omit({ externalId: true });
export const schemaPaymentPublic = Payment.pick({
id: true,
amount: true,
success: true,
refunded: true,
fee: true,
paymentOption: true,
currency: true,
bookingId: true,
});
+1
View File
@@ -9,6 +9,7 @@ export const schemaTeamBaseBodyParams = Team.omit({ id: true, createdAt: true })
isOrganization: true,
isPlatform: true,
smsLockState: true,
smsLockReviewedByAdmin: true,
});
const schemaTeamRequiredParams = z.object({
@@ -27,7 +27,7 @@ import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransform
* tags:
* - availabilities
* externalDocs:
* url: https://docs.cal.com/availability
* url: https://docs.cal.com/docs/core-features/availability
* responses:
* 201:
* description: OK, availability removed successfully
@@ -28,7 +28,7 @@ import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransform
* tags:
* - availabilities
* externalDocs:
* url: https://docs.cal.com/availability
* url: https://docs.cal.com/docs/core-features/availability
* responses:
* 200:
* description: OK
@@ -63,7 +63,7 @@ import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransform
* tags:
* - availabilities
* externalDocs:
* url: https://docs.cal.com/availability
* url: https://docs.cal.com/docs/core-features/availability
* responses:
* 201:
* description: OK, availability edited successfully
@@ -62,7 +62,7 @@ import {
* tags:
* - availabilities
* externalDocs:
* url: https://docs.cal.com/availability
* url: https://docs.cal.com/docs/core-features/availability
* responses:
* 201:
* description: OK, availability created
+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)) };
}
+2 -2
View File
@@ -11,8 +11,8 @@ const swaggerHandler = withSwagger({
{ url: "https://api.cal.com/v1" },
],
externalDocs: {
url: "https://docs.cal.com",
description: "Find more info at our main docs: https://docs.cal.com/",
url: "https://docs.cal.com/docs",
description: "Find more info at our main docs: https://docs.cal.com/docs/",
},
info: {
title: `${pjson.name}: ${pjson.description}`,
@@ -28,7 +28,7 @@ import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransform
* tags:
* - event-types
* externalDocs:
* url: https://docs.cal.com/core-features/event-types
* url: https://docs.cal.com/docs/core-features/event-types
* responses:
* 201:
* description: OK, eventType removed successfully
@@ -34,7 +34,7 @@ import getCalLink from "../_utils/getCalLink";
* tags:
* - event-types
* externalDocs:
* url: https://docs.cal.com/core-features/event-types
* url: https://docs.cal.com/docs/core-features/event-types
* responses:
* 200:
* description: OK
@@ -194,7 +194,7 @@ import checkTeamEventEditPermission from "../_utils/checkTeamEventEditPermission
* tags:
* - event-types
* externalDocs:
* url: https://docs.cal.com/core-features/event-types
* url: https://docs.cal.com/docs/core-features/event-types
* responses:
* 201:
* description: OK, eventType edited successfully
+1 -1
View File
@@ -33,7 +33,7 @@ import getCalLink from "./_utils/getCalLink";
* tags:
* - event-types
* externalDocs:
* url: https://docs.cal.com/core-features/event-types
* url: https://docs.cal.com/docs/core-features/event-types
* responses:
* 200:
* description: OK
+1 -1
View File
@@ -255,7 +255,7 @@ import ensureOnlyMembersAsHosts from "./_utils/ensureOnlyMembersAsHosts";
* tags:
* - event-types
* externalDocs:
* url: https://docs.cal.com/core-features/event-types
* url: https://docs.cal.com/docs/core-features/event-types
* responses:
* 201:
* description: OK, event type created
@@ -125,6 +125,7 @@ export async function patchHandler(req: NextApiRequest) {
metadata: NonNullable<typeof data.metadata> | undefined;
} = {
...data,
smsLockReviewedByAdmin: false,
metadata: data.metadata === null ? {} : data.metadata || undefined,
};
const team = await prisma.team.update({ where: { id: teamId }, data: cloneData });
@@ -32,7 +32,7 @@ const querySchema = z.object({
* tags:
* - event-types
* externalDocs:
* url: https://docs.cal.com/core-features/event-types
* url: https://docs.cal.com/docs/core-features/event-types
* responses:
* 200:
* description: OK
+1
View File
@@ -125,6 +125,7 @@ async function postHandler(req: NextApiRequest) {
metadata: NonNullable<typeof data.metadata> | undefined;
} = {
...data,
smsLockReviewedByAdmin: false,
metadata: data.metadata === null ? {} : data.metadata || undefined,
};
@@ -27,7 +27,7 @@ import { schemaQueryIdAsString } from "~/lib/validations/shared/queryIdString";
* tags:
* - webhooks
* externalDocs:
* url: https://docs.cal.com/core-features/webhooks
* url: https://docs.cal.com/docs/core-features/webhooks
* responses:
* 201:
* description: OK, hook removed successfully
+1 -1
View File
@@ -28,7 +28,7 @@ import { schemaWebhookReadPublic } from "~/lib/validations/webhook";
* tags:
* - webhooks
* externalDocs:
* url: https://docs.cal.com/core-features/webhooks
* url: https://docs.cal.com/docs/core-features/webhooks
* responses:
* 200:
* description: OK
@@ -58,7 +58,7 @@ import { schemaWebhookEditBodyParams, schemaWebhookReadPublic } from "~/lib/vali
* tags:
* - webhooks
* externalDocs:
* url: https://docs.cal.com/core-features/webhooks
* url: https://docs.cal.com/docs/core-features/webhooks
* responses:
* 201:
* description: OK, webhook edited successfully
+1 -1
View File
@@ -24,7 +24,7 @@ import { schemaWebhookReadPublic } from "~/lib/validations/webhook";
* tags:
* - webhooks
* externalDocs:
* url: https://docs.cal.com/core-features/webhooks
* url: https://docs.cal.com/docs/core-features/webhooks
* responses:
* 200:
* description: OK
+1 -1
View File
@@ -56,7 +56,7 @@ import { schemaWebhookCreateBodyParams, schemaWebhookReadPublic } from "~/lib/va
* tags:
* - webhooks
* externalDocs:
* url: https://docs.cal.com/core-features/webhooks
* url: https://docs.cal.com/docs/core-features/webhooks
* responses:
* 201:
* description: OK, webhook created
@@ -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());
});
});
});
});
+1 -1
View File
@@ -25,8 +25,8 @@
},
"dependencies": {
"@calcom/platform-constants": "*",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.28",
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
"@calcom/platform-libraries-0.0.23": "npm:@calcom/platform-libraries@0.0.23",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
"@calcom/prisma": "*",
@@ -20,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.23";
import { handleNewBooking } from "@calcom/platform-libraries";
import { ApiSuccessResponse, ApiResponse } from "@calcom/platform-types";
describe("Bookings Endpoints", () => {
@@ -46,7 +46,7 @@ import {
getBookingInfo,
handleCancelBooking,
getBookingForReschedule,
} from "@calcom/platform-libraries-0.0.23";
} from "@calcom/platform-libraries";
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.23";
import type { AppsStatus } from "@calcom/platform-libraries";
export class CreateRecurringBookingInput extends CreateBookingInput {
@IsBoolean()
@@ -20,10 +20,6 @@ class HandleMarkNoShowData {
@IsOptional()
noShowHost?: boolean;
@IsString()
@IsOptional()
messageKey?: string;
@ValidateNested()
@Type(() => Attendee)
@IsArray()
@@ -105,7 +105,7 @@ class Primary {
credentialId!: number;
}
class Calendar {
export class Calendar {
@IsEmail()
externalId!: string;
@@ -135,7 +135,7 @@ class Calendar {
credentialId!: number;
}
class ConnectedCalendar {
export class ConnectedCalendar {
@ValidateNested()
@IsObject()
integration!: Integration;
@@ -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.23";
import { symmetricEncrypt, CalendarService } from "@calcom/platform-libraries";
@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.23";
import { getConnectedDestinationCalendars, getBusyCalendarTimes } from "@calcom/platform-libraries";
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.23";
} from "@calcom/platform-libraries";
import { ApiSuccessResponse } from "@calcom/platform-types";
describe("Event types Endpoints", () => {
@@ -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.23";
import { getEventTypeById } from "@calcom/platform-libraries";
import type { PrismaClient } from "@calcom/prisma";
@Injectable()
@@ -177,6 +177,10 @@ export class BookingField_2024_04_15 {
@ValidateNested({ each: true })
@Type(() => Source)
sources?: Source[];
@IsOptional()
@IsBoolean()
disableOnPrefill?: boolean;
}
export class RecurringEvent_2024_04_15 {
@@ -87,6 +87,10 @@ class BookingField {
@Type(() => Source)
@IsOptional()
sources?: Source[];
@IsOptional()
@IsBoolean()
disableOnPrefill?: boolean;
}
class Organization {
@@ -14,7 +14,7 @@ import {
updateEventType,
EventTypesPublic,
getEventTypesPublic,
} from "@calcom/platform-libraries-0.0.23";
} from "@calcom/platform-libraries";
import { EventType } from "@calcom/prisma/client";
@Injectable()
@@ -1,5 +1,6 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { CreateEventTypeInput_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/inputs/create-event-type.input";
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
import { HttpExceptionFilter } from "@/filters/http-exception.filter";
import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter";
@@ -25,6 +26,8 @@ import {
EventTypeOutput_2024_06_14,
UpdateEventTypeInput_2024_06_14,
} from "@calcom/platform-types";
import { BookingWindowPeriodInputTypeEnum_2024_06_14 } from "@calcom/platform-types/dist/event-types/event-types_2024_06_14/inputs/enums/booking-window.enum";
import { SchedulingType } from "@calcom/prisma/enums";
describe("Event types Endpoints", () => {
describe("Not authenticated", () => {
@@ -212,6 +215,21 @@ describe("Event types Endpoints", () => {
},
],
scheduleId: firstSchedule.id,
bookingLimitsCount: {
day: 2,
week: 5,
},
onlyShowFirstAvailableSlot: true,
bookingLimitsDuration: {
day: 60,
week: 100,
},
offsetStart: 30,
bookingWindow: {
type: BookingWindowPeriodInputTypeEnum_2024_06_14.calendarDays,
value: 30,
rolling: true,
},
};
return request(app.getHttpServer())
@@ -230,7 +248,11 @@ describe("Event types Endpoints", () => {
expect(createdEventType.bookingFields).toEqual(body.bookingFields);
expect(createdEventType.ownerId).toEqual(user.id);
expect(createdEventType.scheduleId).toEqual(firstSchedule.id);
expect(createdEventType.bookingLimitsCount).toEqual(body.bookingLimitsCount);
expect(createdEventType.onlyShowFirstAvailableSlot).toEqual(body.onlyShowFirstAvailableSlot);
expect(createdEventType.bookingLimitsDuration).toEqual(body.bookingLimitsDuration);
expect(createdEventType.offsetStart).toEqual(body.offsetStart);
expect(createdEventType.bookingWindow).toEqual(body.bookingWindow);
eventType = responseBody.data;
});
});
@@ -241,6 +263,21 @@ describe("Event types Endpoints", () => {
const body: UpdateEventTypeInput_2024_06_14 = {
title: newTitle,
scheduleId: secondSchedule.id,
bookingLimitsCount: {
day: 4,
week: 10,
},
onlyShowFirstAvailableSlot: true,
bookingLimitsDuration: {
day: 100,
week: 200,
},
offsetStart: 50,
bookingWindow: {
type: BookingWindowPeriodInputTypeEnum_2024_06_14.businessDays,
value: 40,
rolling: false,
},
};
return request(app.getHttpServer())
@@ -261,9 +298,19 @@ describe("Event types Endpoints", () => {
expect(updatedEventType.bookingFields).toEqual(eventType.bookingFields);
expect(updatedEventType.ownerId).toEqual(user.id);
expect(updatedEventType.scheduleId).toEqual(secondSchedule.id);
expect(updatedEventType.bookingLimitsCount).toEqual(body.bookingLimitsCount);
expect(updatedEventType.onlyShowFirstAvailableSlot).toEqual(body.onlyShowFirstAvailableSlot);
expect(updatedEventType.bookingLimitsDuration).toEqual(body.bookingLimitsDuration);
expect(updatedEventType.offsetStart).toEqual(body.offsetStart);
expect(updatedEventType.bookingWindow).toEqual(body.bookingWindow);
eventType.title = newTitle;
eventType.scheduleId = secondSchedule.id;
eventType.bookingLimitsCount = updatedEventType.bookingLimitsCount;
eventType.onlyShowFirstAvailableSlot = updatedEventType.onlyShowFirstAvailableSlot;
eventType.bookingLimitsDuration = updatedEventType.bookingLimitsDuration;
eventType.offsetStart = updatedEventType.offsetStart;
eventType.bookingWindow = updatedEventType.bookingWindow;
});
});
@@ -299,6 +346,11 @@ describe("Event types Endpoints", () => {
expect(fetchedEventType.locations).toEqual(eventType.locations);
expect(fetchedEventType.bookingFields).toEqual(eventType.bookingFields);
expect(fetchedEventType.ownerId).toEqual(user.id);
expect(fetchedEventType.bookingLimitsCount).toEqual(eventType.bookingLimitsCount);
expect(fetchedEventType.onlyShowFirstAvailableSlot).toEqual(eventType.onlyShowFirstAvailableSlot);
expect(fetchedEventType.bookingLimitsDuration).toEqual(eventType.bookingLimitsDuration);
expect(fetchedEventType.offsetStart).toEqual(eventType.offsetStart);
expect(fetchedEventType.bookingWindow).toEqual(eventType.bookingWindow);
});
it(`/GET/even-types by username`, async () => {
@@ -324,6 +376,11 @@ describe("Event types Endpoints", () => {
expect(fetchedEventType?.locations).toEqual(eventType.locations);
expect(fetchedEventType?.bookingFields).toEqual(eventType.bookingFields);
expect(fetchedEventType?.ownerId).toEqual(user.id);
expect(fetchedEventType.bookingLimitsCount).toEqual(eventType.bookingLimitsCount);
expect(fetchedEventType.onlyShowFirstAvailableSlot).toEqual(eventType.onlyShowFirstAvailableSlot);
expect(fetchedEventType.bookingLimitsDuration).toEqual(eventType.bookingLimitsDuration);
expect(fetchedEventType.offsetStart).toEqual(eventType.offsetStart);
expect(fetchedEventType.bookingWindow).toEqual(eventType.bookingWindow);
});
it(`/GET/event-types by username and eventSlug`, async () => {
@@ -344,6 +401,11 @@ describe("Event types Endpoints", () => {
expect(fetchedEventType?.locations).toEqual(eventType.locations);
expect(fetchedEventType?.bookingFields).toEqual(eventType.bookingFields);
expect(fetchedEventType?.ownerId).toEqual(user.id);
expect(fetchedEventType.bookingLimitsCount).toEqual(eventType.bookingLimitsCount);
expect(fetchedEventType.onlyShowFirstAvailableSlot).toEqual(eventType.onlyShowFirstAvailableSlot);
expect(fetchedEventType.bookingLimitsDuration).toEqual(eventType.bookingLimitsDuration);
expect(fetchedEventType.offsetStart).toEqual(eventType.offsetStart);
expect(fetchedEventType.bookingWindow).toEqual(eventType.bookingWindow);
});
it(`/GET/:id not existing`, async () => {
@@ -380,4 +442,329 @@ describe("Event types Endpoints", () => {
await app.close();
});
});
describe("Handle legacy event-types booking fields", () => {
let app: INestApplication;
let oAuthClient: PlatformOAuthClient;
let organization: Team;
let userRepositoryFixture: UserRepositoryFixture;
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
let teamRepositoryFixture: TeamRepositoryFixture;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
const userEmail = "legacy-event-types-test-e2e@api.com";
const name = "bob-the-builder";
const username = name;
let user: User;
let legacyEventTypeId1: number;
let legacyEventTypeId2: number;
beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
providers: [PrismaExceptionFilter, HttpExceptionFilter],
imports: [AppModule, UsersModule, EventTypesModule_2024_06_14, TokensModule],
})
)
.overrideGuard(PermissionsGuard)
.useValue({
canActivate: () => true,
})
.compile();
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
organization = await teamRepositoryFixture.create({ name: "organization" });
oAuthClient = await createOAuthClient(organization.id);
user = await userRepositoryFixture.create({
email: userEmail,
name,
username,
});
await app.init();
});
async function createOAuthClient(organizationId: number) {
const data = {
logo: "logo-url",
name: "name",
redirectUris: ["redirect-uri"],
permissions: 32,
};
const secret = "secret";
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
return client;
}
it("should be defined", () => {
expect(oauthClientRepositoryFixture).toBeDefined();
expect(userRepositoryFixture).toBeDefined();
expect(oAuthClient).toBeDefined();
expect(user).toBeDefined();
});
it("should not allow creating an event type with input of event-types version 2024_04_15", async () => {
const body: CreateEventTypeInput_2024_04_15 = {
title: "Coding class",
slug: "coding-class",
description: "Let's learn how to code like a pro.",
length: 60,
locations: [{ type: "integrations:daily" }],
};
return request(app.getHttpServer())
.post("/api/v2/event-types")
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.send(body)
.expect(400);
});
it("should return empty bookingFields if system fields are the only one in database", async () => {
const legacyEventTypeInput = {
title: "legacy event type",
description: "legacy event type description",
length: 40,
hidden: false,
slug: "legacy-event-type",
locations: [],
schedulingType: SchedulingType.ROUND_ROBIN,
bookingFields: [
{
name: "name",
type: "name",
sources: [{ id: "default", type: "default", label: "Default" }],
editable: "system",
required: true,
defaultLabel: "your_name",
},
{
name: "email",
type: "email",
sources: [{ id: "default", type: "default", label: "Default" }],
editable: "system",
required: true,
defaultLabel: "email_address",
},
{
name: "location",
type: "radioInput",
label: "",
sources: [{ id: "default", type: "default", label: "Default" }],
editable: "system",
required: false,
placeholder: "",
defaultLabel: "location",
getOptionsAt: "locations",
optionsInputs: {
phone: { type: "phone", required: true, placeholder: "" },
attendeeInPerson: { type: "address", required: true, placeholder: "" },
},
hideWhenJustOneOption: true,
},
{
name: "title",
type: "text",
hidden: true,
sources: [{ id: "default", type: "default", label: "Default" }],
editable: "system-but-optional",
required: true,
defaultLabel: "what_is_this_meeting_about",
defaultPlaceholder: "",
},
{
name: "notes",
type: "textarea",
sources: [{ id: "default", type: "default", label: "Default" }],
editable: "system-but-optional",
required: false,
defaultLabel: "additional_notes",
defaultPlaceholder: "share_additional_notes",
},
{
name: "guests",
type: "multiemail",
hidden: false,
sources: [{ id: "default", type: "default", label: "Default" }],
editable: "system-but-optional",
required: false,
defaultLabel: "additional_guests",
defaultPlaceholder: "email",
},
{
name: "rescheduleReason",
type: "textarea",
views: [{ id: "reschedule", label: "Reschedule View" }],
sources: [{ id: "default", type: "default", label: "Default" }],
editable: "system-but-optional",
required: false,
defaultLabel: "reason_for_reschedule",
defaultPlaceholder: "reschedule_placeholder",
},
],
};
const legacyEventType = await eventTypesRepositoryFixture.create(legacyEventTypeInput, user.id);
legacyEventTypeId1 = legacyEventType.id;
return request(app.getHttpServer())
.get(`/api/v2/event-types/${legacyEventTypeId1}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.expect(200)
.then(async (response) => {
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
const fetchedEventType = responseBody.data;
expect(fetchedEventType.bookingFields).toEqual([]);
});
});
it("should return user created bookingFields among system fields in the database", async () => {
const userDefinedBookingField = {
name: "team",
type: "textarea",
label: "your team",
sources: [
{
id: "user",
type: "user",
label: "User",
fieldRequired: true,
},
],
editable: "user",
required: true,
placeholder: "FC Barcelona",
};
const legacyEventTypeInput = {
title: "legacy event type two",
description: "legacy event type description two",
length: 40,
hidden: false,
slug: "legacy-event-type-two",
locations: [],
schedulingType: SchedulingType.ROUND_ROBIN,
bookingFields: [
userDefinedBookingField,
{
name: "name",
type: "name",
sources: [{ id: "default", type: "default", label: "Default" }],
editable: "system",
required: true,
defaultLabel: "your_name",
},
{
name: "email",
type: "email",
sources: [{ id: "default", type: "default", label: "Default" }],
editable: "system",
required: true,
defaultLabel: "email_address",
},
{
name: "location",
type: "radioInput",
label: "",
sources: [{ id: "default", type: "default", label: "Default" }],
editable: "system",
required: false,
placeholder: "",
defaultLabel: "location",
getOptionsAt: "locations",
optionsInputs: {
phone: { type: "phone", required: true, placeholder: "" },
attendeeInPerson: { type: "address", required: true, placeholder: "" },
},
hideWhenJustOneOption: true,
},
{
name: "title",
type: "text",
hidden: true,
sources: [{ id: "default", type: "default", label: "Default" }],
editable: "system-but-optional",
required: true,
defaultLabel: "what_is_this_meeting_about",
defaultPlaceholder: "",
},
{
name: "notes",
type: "textarea",
sources: [{ id: "default", type: "default", label: "Default" }],
editable: "system-but-optional",
required: false,
defaultLabel: "additional_notes",
defaultPlaceholder: "share_additional_notes",
},
{
name: "guests",
type: "multiemail",
hidden: false,
sources: [{ id: "default", type: "default", label: "Default" }],
editable: "system-but-optional",
required: false,
defaultLabel: "additional_guests",
defaultPlaceholder: "email",
},
{
name: "rescheduleReason",
type: "textarea",
views: [{ id: "reschedule", label: "Reschedule View" }],
sources: [{ id: "default", type: "default", label: "Default" }],
editable: "system-but-optional",
required: false,
defaultLabel: "reason_for_reschedule",
defaultPlaceholder: "reschedule_placeholder",
},
],
};
const legacyEventType = await eventTypesRepositoryFixture.create(legacyEventTypeInput, user.id);
legacyEventTypeId2 = legacyEventType.id;
return request(app.getHttpServer())
.get(`/api/v2/event-types/${legacyEventTypeId2}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.expect(200)
.then(async (response) => {
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
const fetchedEventType = responseBody.data;
expect(fetchedEventType.bookingFields).toEqual([
{
type: userDefinedBookingField.type,
slug: userDefinedBookingField.name,
label: userDefinedBookingField.label,
required: userDefinedBookingField.required,
placeholder: userDefinedBookingField.placeholder,
},
]);
});
});
afterAll(async () => {
await oauthClientRepositoryFixture.delete(oAuthClient.id);
await teamRepositoryFixture.delete(organization.id);
try {
await eventTypesRepositoryFixture.delete(legacyEventTypeId1);
await eventTypesRepositoryFixture.delete(legacyEventTypeId2);
} catch (e) {
// Event type might have been deleted by the test
}
try {
await userRepositoryFixture.delete(user.id);
} catch (e) {
// User might have been deleted by the test
}
await app.close();
});
});
});
@@ -7,13 +7,24 @@ import {
getEventTypeById,
transformApiEventTypeBookingFields,
transformApiEventTypeLocations,
} from "@calcom/platform-libraries-0.0.23";
} from "@calcom/platform-libraries";
import { CreateEventTypeInput_2024_06_14 } from "@calcom/platform-types";
import type { PrismaClient } from "@calcom/prisma";
type InputEventTransformed = Omit<
CreateEventTypeInput_2024_06_14,
"lengthInMinutes" | "locations" | "bookingFields"
| "lengthInMinutes"
| "locations"
| "bookingFields"
| "bookingLimitsCount"
| "onlyShowFirstAvailableSlot"
| "bookingLimitsDuration"
| "offsetStart"
| "periodType"
| "periodDays"
| "periodCountCalendarDays"
| "periodStartDate"
| "periodEndDate"
> & {
length: number;
slug: string;
@@ -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.23";
import { getEventTypesPublic, EventTypesPublic } from "@calcom/platform-libraries-0.0.23";
import { dynamicEvent } from "@calcom/platform-libraries-0.0.23";
import { createEventType, updateEventType } from "@calcom/platform-libraries";
import { getEventTypesPublic, EventTypesPublic } from "@calcom/platform-libraries";
import { dynamicEvent } from "@calcom/platform-libraries";
import {
CreateEventTypeInput_2024_06_14,
UpdateEventTypeInput_2024_06_14,
@@ -38,7 +38,16 @@ export class EventTypesService_2024_06_14 {
async createUserEventType(user: UserWithProfile, body: CreateEventTypeInput_2024_06_14) {
await this.checkCanCreateEventType(user.id, body);
const eventTypeUser = await this.getUserToCreateEvent(user);
const bodyTransformed = this.inputEventTypesService.transformInputCreateEventType(body);
const {
bookingLimits,
durationLimits,
periodType = undefined,
periodDays = undefined,
periodCountCalendarDays = undefined,
periodStartDate = undefined,
periodEndDate = undefined,
...bodyTransformed
} = this.inputEventTypesService.transformInputCreateEventType(body);
const { eventType: eventTypeCreated } = await createEventType({
input: bodyTransformed,
ctx: {
@@ -49,6 +58,26 @@ export class EventTypesService_2024_06_14 {
},
});
await updateEventType({
input: {
id: eventTypeCreated.id,
bookingLimits,
durationLimits,
periodType,
periodDays,
periodCountCalendarDays,
periodStartDate,
periodEndDate,
...bodyTransformed,
},
ctx: {
user: eventTypeUser,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
prisma: this.dbWrite.prisma,
},
});
const eventType = await this.eventTypesRepository.getEventTypeById(eventTypeCreated.id);
if (!eventType) {
@@ -95,13 +124,16 @@ export class EventTypesService_2024_06_14 {
? await this.membershipsRepository.isUserOrganizationAdmin(user.id, organizationId)
: false;
const profileId = user.movedToProfile?.id || null;
const selectedCalendars = await this.selectedCalendarsRepository.getUserSelectedCalendars(user.id);
return {
id: user.id,
role: user.role,
username: user.username,
organizationId: user.organizationId,
organization: { isOrgAdmin },
profile: { id: profileId },
metadata: user.metadata,
selectedCalendars,
};
}
@@ -3,7 +3,9 @@ import { Injectable } from "@nestjs/common";
import {
transformApiEventTypeBookingFields,
transformApiEventTypeLocations,
} from "@calcom/platform-libraries-0.0.23";
transformApiEventTypeIntervalLimits,
transformApiEventTypeFutureBookingLimits,
} from "@calcom/platform-libraries";
import { CreateEventTypeInput_2024_06_14, UpdateEventTypeInput_2024_06_14 } from "@calcom/platform-types";
@Injectable()
@@ -16,20 +18,42 @@ export class InputEventTypesService_2024_06_14 {
},
];
const { lengthInMinutes, locations, bookingFields, ...rest } = inputEventType;
const {
lengthInMinutes,
locations,
bookingFields,
bookingLimitsCount,
bookingLimitsDuration,
bookingWindow,
...rest
} = inputEventType;
const eventType = {
...rest,
length: lengthInMinutes,
locations: this.transformInputLocations(locations || defaultLocations),
bookingFields: this.transformInputBookingFields(bookingFields),
bookingLimits: bookingLimitsCount ? this.transformInputIntervalLimits(bookingLimitsCount) : undefined,
durationLimits: bookingLimitsDuration
? this.transformInputIntervalLimits(bookingLimitsDuration)
: undefined,
...this.transformInputBookingWindow(bookingWindow),
};
return eventType;
}
transformInputUpdateEventType(inputEventType: UpdateEventTypeInput_2024_06_14) {
const { lengthInMinutes, locations, bookingFields, scheduleId, ...rest } = inputEventType;
const {
lengthInMinutes,
locations,
bookingFields,
scheduleId,
bookingLimitsCount,
bookingLimitsDuration,
bookingWindow,
...rest
} = inputEventType;
const eventType = {
...rest,
@@ -37,6 +61,11 @@ export class InputEventTypesService_2024_06_14 {
locations: locations ? this.transformInputLocations(locations) : undefined,
bookingFields: bookingFields ? this.transformInputBookingFields(bookingFields) : undefined,
schedule: scheduleId,
bookingLimits: bookingLimitsCount ? this.transformInputIntervalLimits(bookingLimitsCount) : undefined,
durationLimits: bookingLimitsDuration
? this.transformInputIntervalLimits(bookingLimitsDuration)
: undefined,
...this.transformInputBookingWindow(bookingWindow),
};
return eventType;
@@ -49,4 +78,13 @@ export class InputEventTypesService_2024_06_14 {
transformInputBookingFields(inputBookingFields: CreateEventTypeInput_2024_06_14["bookingFields"]) {
return transformApiEventTypeBookingFields(inputBookingFields);
}
transformInputIntervalLimits(inputBookingFields: CreateEventTypeInput_2024_06_14["bookingLimitsCount"]) {
return transformApiEventTypeIntervalLimits(inputBookingFields);
}
transformInputBookingWindow(inputBookingWindow: CreateEventTypeInput_2024_06_14["bookingWindow"]) {
const res = transformApiEventTypeFutureBookingLimits(inputBookingWindow);
return !!res ? res : {};
}
}
@@ -9,7 +9,13 @@ import {
parseRecurringEvent,
TransformedLocationsSchema,
BookingFieldsSchema,
} from "@calcom/platform-libraries-0.0.23";
SystemField,
UserField,
parseBookingLimit,
getResponseEventTypeIntervalLimits,
getResponseEventTypeFutureBookingLimits,
} from "@calcom/platform-libraries";
import { TransformFutureBookingsLimitSchema_2024_06_14 } from "@calcom/platform-types";
type EventTypeRelations = { users: User[]; schedule: Schedule | null };
type DatabaseEventType = EventType & EventTypeRelations;
@@ -42,6 +48,15 @@ type Input = Pick<
| "metadata"
| "users"
| "scheduleId"
| "bookingLimits"
| "durationLimits"
| "onlyShowFirstAvailableSlot"
| "offsetStart"
| "periodType"
| "periodDays"
| "periodCountCalendarDays"
| "periodStartDate"
| "periodEndDate"
>;
@Injectable()
@@ -69,13 +84,26 @@ export class OutputEventTypesService_2024_06_14 {
seatsShowAvailabilityCount,
isInstantEvent,
scheduleId,
onlyShowFirstAvailableSlot,
offsetStart,
} = databaseEventType;
const locations = this.transformLocations(databaseEventType.locations);
const bookingFields = this.transformBookingFields(databaseEventType.bookingFields);
const bookingFields = databaseEventType.bookingFields
? this.transformBookingFields(BookingFieldsSchema.parse(databaseEventType.bookingFields))
: [];
const recurringEvent = this.transformRecurringEvent(databaseEventType.recurringEvent);
const metadata = this.transformMetadata(databaseEventType.metadata) || {};
const users = this.transformUsers(databaseEventType.users);
const bookingLimitsCount = this.transformIntervalLimits(databaseEventType.bookingLimits);
const bookingLimitsDuration = this.transformIntervalLimits(databaseEventType.durationLimits);
const bookingWindow = this.transformBookingWindow({
periodType: databaseEventType.periodType,
periodDays: databaseEventType.periodDays,
periodCountCalendarDays: databaseEventType.periodCountCalendarDays,
periodStartDate: databaseEventType.periodStartDate,
periodEndDate: databaseEventType.periodEndDate,
} as TransformFutureBookingsLimitSchema_2024_06_14);
return {
id,
@@ -105,6 +133,11 @@ export class OutputEventTypesService_2024_06_14 {
isInstantEvent,
users,
scheduleId,
bookingLimitsCount,
bookingLimitsDuration,
onlyShowFirstAvailableSlot,
offsetStart,
bookingWindow,
};
}
@@ -113,9 +146,10 @@ export class OutputEventTypesService_2024_06_14 {
return getResponseEventTypeLocations(TransformedLocationsSchema.parse(locations));
}
transformBookingFields(inputBookingFields: any) {
transformBookingFields(inputBookingFields: (SystemField | UserField)[] | null) {
if (!inputBookingFields) return [];
return getResponseEventTypeBookingFields(BookingFieldsSchema.parse(inputBookingFields));
const userFields = inputBookingFields.filter((field) => field.editable === "user") as UserField[];
return getResponseEventTypeBookingFields(userFields);
}
transformRecurringEvent(recurringEvent: any) {
@@ -143,4 +177,13 @@ export class OutputEventTypesService_2024_06_14 {
};
});
}
transformIntervalLimits(bookingLimits: any) {
const bookingLimitsParsed = parseBookingLimit(bookingLimits);
return getResponseEventTypeIntervalLimits(bookingLimitsParsed);
}
transformBookingWindow(bookingLimits: TransformFutureBookingsLimitSchema_2024_06_14) {
return getResponseEventTypeFutureBookingLimits(bookingLimits);
}
}
@@ -9,8 +9,10 @@ import { UsersModule } from "@/modules/users/users.module";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import { User } from "@prisma/client";
import { User, Team } from "@prisma/client";
import * as request from "supertest";
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
import { SchedulesRepositoryFixture } from "test/fixtures/repository/schedules.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { withApiAuth } from "test/utils/withApiAuth";
@@ -25,9 +27,11 @@ describe("Me Endpoints", () => {
let userRepositoryFixture: UserRepositoryFixture;
let schedulesRepositoryFixture: SchedulesRepositoryFixture;
let profilesRepositoryFixture: ProfileRepositoryFixture;
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
const userEmail = "me-controller-e2e@api.com";
let user: User;
let org: Team;
beforeAll(async () => {
const moduleRef = await withApiAuth(
@@ -43,6 +47,9 @@ describe("Me Endpoints", () => {
.compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
profilesRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
schedulesRepositoryFixture = new SchedulesRepositoryFixture(moduleRef);
user = await userRepositoryFixture.create({
@@ -50,6 +57,20 @@ describe("Me Endpoints", () => {
username: userEmail,
});
org = await organizationsRepositoryFixture.create({
name: "Test org team",
isOrganization: true,
isPlatform: true,
});
await profilesRepositoryFixture.create({
uid: "asd-asd",
username: userEmail,
user: { connect: { id: user.id } },
organization: { connect: { id: org.id } },
movedFromUser: { connect: { id: user.id } },
});
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
@@ -75,6 +96,8 @@ describe("Me Endpoints", () => {
expect(responseBody.data.defaultScheduleId).toEqual(user.defaultScheduleId);
expect(responseBody.data.weekStart).toEqual(user.weekStart);
expect(responseBody.data.timeZone).toEqual(user.timeZone);
expect(responseBody.data.organization?.isPlatform).toEqual(true);
expect(responseBody.data.organization?.id).toEqual(org.id);
});
});
@@ -138,6 +161,7 @@ describe("Me Endpoints", () => {
afterAll(async () => {
await userRepositoryFixture.deleteByEmail(user.email);
await organizationsRepositoryFixture.delete(org.id);
await app.close();
});
});
+16 -3
View File
@@ -7,6 +7,7 @@ import { Permissions } from "@/modules/auth/decorators/permissions/permissions.d
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { UpdateManagedUserInput } from "@/modules/users/inputs/update-managed-user.input";
import { UsersService } from "@/modules/users/services/users.service";
import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository";
import { Controller, UseGuards, Get, Patch, Body } from "@nestjs/common";
import { ApiTags as DocsTags } from "@nestjs/swagger";
@@ -23,14 +24,26 @@ import { userSchemaResponse } from "@calcom/platform-types";
export class MeController {
constructor(
private readonly usersRepository: UsersRepository,
private readonly schedulesService: SchedulesService_2024_04_15
private readonly schedulesService: SchedulesService_2024_04_15,
private readonly usersService: UsersService
) {}
@Get("/")
@Permissions([PROFILE_READ])
async getMe(@GetUser() user: UserWithProfile): Promise<GetMeOutput> {
const me = userSchemaResponse.parse(user);
const organization = this.usersService.getUserMainProfile(user)?.organization;
const me = userSchemaResponse.parse(
organization
? {
...user,
organizationId: organization.id,
organization: {
id: organization.id,
isPlatform: organization.isPlatform,
},
}
: user
);
return {
status: SUCCESS_STATUS,
data: me,
+12 -1
View File
@@ -1,5 +1,11 @@
import { IsInt, IsEmail, IsOptional, IsString } from "class-validator";
import { Type } from "class-transformer";
import { IsInt, IsEmail, IsOptional, IsString, ValidateNested } from "class-validator";
export class MeOrgOutput {
isPlatform!: boolean;
id!: number;
}
export class MeOutput {
@IsInt()
id!: number;
@@ -25,4 +31,9 @@ export class MeOutput {
@IsInt()
organizationId!: number | null;
@IsOptional()
@ValidateNested()
@Type(() => MeOrgOutput)
organization?: MeOrgOutput;
}
@@ -1,9 +1,6 @@
import { Injectable } from "@nestjs/common";
import {
transformApiScheduleOverrides,
transformApiScheduleAvailability,
} from "@calcom/platform-libraries-0.0.23";
import { transformApiScheduleOverrides, transformApiScheduleAvailability } from "@calcom/platform-libraries";
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.23";
import { TRPCError } from "@calcom/platform-libraries";
import { Response } from "@calcom/platform-types";
@Catch(TRPCError)
@@ -4,6 +4,7 @@ import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { Injectable, CanActivate, ExecutionContext } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Reflector } from "@nestjs/core";
import { getToken } from "next-auth/jwt";
import { hasPermissions } from "@calcom/platform-utils";
@@ -24,6 +25,12 @@ export class PermissionsGuard implements CanActivate {
const request = context.switchToHttp().getRequest();
const authString = request.get("Authorization")?.replace("Bearer ", "");
const nextAuthSecret = this.config.get("next.authSecret", { infer: true });
const nextAuthToken = await getToken({ req: request, secret: nextAuthSecret });
if (nextAuthToken) {
return true;
}
if (!authString) {
return false;
@@ -11,6 +11,7 @@ import { Injectable, InternalServerErrorException, UnauthorizedException } from
import { ConfigService } from "@nestjs/config";
import { PassportStrategy } from "@nestjs/passport";
import type { Request } from "express";
import { getToken } from "next-auth/jwt";
import { INVALID_ACCESS_TOKEN, X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
@@ -45,6 +46,13 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
return await this.authenticateBearerToken(bearerToken, requestOrigin);
}
const nextAuthSecret = this.config.get("next.authSecret", { infer: true });
const nextAuthToken = await getToken({ req: request, secret: nextAuthSecret });
if (nextAuthToken) {
return await this.authenticateNextAuth(nextAuthToken);
}
throw new UnauthorizedException(
"No authentication method provided. Either pass an API key as 'Bearer' header or OAuth client credentials as 'x-cal-secret-key' and 'x-cal-client-id' headers"
);
@@ -58,6 +66,11 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
}
}
async authenticateNextAuth(token: { email?: string | null }) {
const user = await this.nextAuthStrategy(token);
return this.success(user);
}
async authenticateOAuthClient(oAuthClientId: string, oAuthClientSecret: string) {
const user = await this.oAuthClientStrategy(oAuthClientId, oAuthClientSecret);
return this.success(user);
@@ -163,4 +176,17 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
const user: UserWithProfile | null = await this.userRepository.findByIdWithProfile(ownerId);
return user;
}
async nextAuthStrategy(token: { email?: string | null }) {
if (!token.email) {
throw new UnauthorizedException("Email not found in the authentication token.");
}
const user = await this.userRepository.findByEmailWithProfile(token.email);
if (!user) {
throw new UnauthorizedException("User associated with the authentication token email not found.");
}
return user;
}
}
@@ -0,0 +1,171 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
import { HttpExceptionFilter } from "@/filters/http-exception.filter";
import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { DestinationCalendarsOutputResponseDto } from "@/modules/destination-calendars/outputs/destination-calendars.output";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import { PlatformOAuthClient, Team, User, Credential } from "@prisma/client";
import * as request from "supertest";
import { CredentialsRepositoryFixture } from "test/fixtures/repository/credentials.repository.fixture";
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { TokensRepositoryFixture } from "test/fixtures/repository/tokens.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { APPLE_CALENDAR_TYPE, APPLE_CALENDAR_ID } from "@calcom/platform-constants";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
const CLIENT_REDIRECT_URI = "http://localhost:5555";
describe("Platform Destination Calendar Endpoints", () => {
let app: INestApplication;
let oAuthClient: PlatformOAuthClient;
let organization: Team;
let userRepositoryFixture: UserRepositoryFixture;
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
let teamRepositoryFixture: TeamRepositoryFixture;
let tokensRepositoryFixture: TokensRepositoryFixture;
let credentialsRepositoryFixture: CredentialsRepositoryFixture;
let appleCalendarCredentials: Credential;
let user: User;
let accessTokenSecret: string;
let refreshTokenSecret: string;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
providers: [PrismaExceptionFilter, HttpExceptionFilter],
imports: [AppModule, UsersModule, TokensModule],
})
.overrideGuard(PermissionsGuard)
.useValue({
canActivate: () => true,
})
.compile();
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
tokensRepositoryFixture = new TokensRepositoryFixture(moduleRef);
credentialsRepositoryFixture = new CredentialsRepositoryFixture(moduleRef);
organization = await teamRepositoryFixture.create({ name: "organization" });
oAuthClient = await createOAuthClient(organization.id);
user = await userRepositoryFixture.createOAuthManagedUser("office365-connect@gmail.com", oAuthClient.id);
const tokens = await tokensRepositoryFixture.createTokens(user.id, oAuthClient.id);
accessTokenSecret = tokens.accessToken;
refreshTokenSecret = tokens.refreshToken;
appleCalendarCredentials = await credentialsRepositoryFixture.create(
APPLE_CALENDAR_TYPE,
{},
user.id,
APPLE_CALENDAR_ID
);
jest.spyOn(CalendarsService.prototype, "getCalendars").mockReturnValue(
Promise.resolve({
connectedCalendars: [
{
integration: {
installed: false,
type: "apple_calendar",
title: "",
name: "",
description: "",
variant: "calendar",
slug: "",
locationOption: null,
categories: ["calendar"],
logo: "",
publisher: "",
url: "",
email: "",
},
calendars: {
externalId:
"https://caldav.icloud.com/20961146906/calendars/83C4F9A1-F1D0-41C7-8FC3-0B$9AE22E813/",
readOnly: false,
integration: "apple_calendar",
credentialId: appleCalendarCredentials.id,
primary: true,
email: user.email,
},
error: { message: "" },
},
],
})
);
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
async function createOAuthClient(organizationId: number) {
const data = {
logo: "logo-url",
name: "name",
redirectUris: [CLIENT_REDIRECT_URI],
permissions: 32,
};
const secret = "secret";
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
return client;
}
it("should be defined", () => {
expect(oauthClientRepositoryFixture).toBeDefined();
expect(userRepositoryFixture).toBeDefined();
expect(oAuthClient).toBeDefined();
expect(accessTokenSecret).toBeDefined();
expect(refreshTokenSecret).toBeDefined();
expect(user).toBeDefined();
});
it(`POST /v2/destination-calendars: it should respond with a 200 returning back the user updated destination calendar`, async () => {
const body = {
integration: appleCalendarCredentials.type,
externalId: "https://caldav.icloud.com/20961146906/calendars/83C4F9A1-F1D0-41C7-8FC3-0B$9AE22E813/",
};
return request(app.getHttpServer())
.put("/v2/destination-calendars")
.set("Authorization", `Bearer ${accessTokenSecret}`)
.send(body)
.expect(200)
.then(async (response) => {
const responseBody: DestinationCalendarsOutputResponseDto = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.credentialId).toEqual(appleCalendarCredentials.id);
expect(responseBody.data.integration).toEqual(body.integration);
expect(responseBody.data.externalId).toEqual(body.externalId);
expect(responseBody.data.userId).toEqual(user.id);
});
});
it(`POST /v2/destination-calendars: should fail 400 if calendar type is invalid`, async () => {
const body = {
integration: "not-supported-calendar",
externalId: "https://caldav.icloud.com/20961146906/calendars/83C4F9A1-F1D0-41C7-8FC3-0B$9AE22E813/",
};
return request(app.getHttpServer())
.put("/v2/destination-calendars")
.set("Authorization", `Bearer ${accessTokenSecret}`)
.send(body)
.expect(400);
});
afterAll(async () => {
await oauthClientRepositoryFixture.delete(oAuthClient.id);
await teamRepositoryFixture.delete(organization.id);
await userRepositoryFixture.deleteByEmail(user.email);
await app.close();
});
});
@@ -0,0 +1,46 @@
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 { DestinationCalendarsInputBodyDto } from "@/modules/destination-calendars/inputs/destination-calendars.input";
import {
DestinationCalendarsOutputDto,
DestinationCalendarsOutputResponseDto,
} from "@/modules/destination-calendars/outputs/destination-calendars.output";
import { DestinationCalendarsService } from "@/modules/destination-calendars/services/destination-calendars.service";
import { UserWithProfile } from "@/modules/users/users.repository";
import { Body, Controller, Put, UseGuards } from "@nestjs/common";
import { ApiTags as DocsTags } from "@nestjs/swagger";
import { plainToClass } from "class-transformer";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
@Controller({
path: "/v2/destination-calendars",
version: API_VERSIONS_VALUES,
})
@DocsTags("Destination-Calendars")
export class DestinationCalendarsController {
constructor(private readonly destinationCalendarsService: DestinationCalendarsService) {}
@Put("/")
@UseGuards(ApiAuthGuard)
@DocsTags("Select a third party destination calendar where events will be created")
async updateDestinationCalendars(
@Body() input: DestinationCalendarsInputBodyDto,
@GetUser() user: UserWithProfile
): Promise<DestinationCalendarsOutputResponseDto> {
const { integration, externalId } = input;
const updatedDestinationCalendar = await this.destinationCalendarsService.updateDestinationCalendars(
integration,
externalId,
user.id
);
return {
status: SUCCESS_STATUS,
data: plainToClass(DestinationCalendarsOutputDto, updatedDestinationCalendar, {
strategy: "excludeAll",
}),
};
}
}
@@ -0,0 +1,26 @@
import { CalendarsRepository } from "@/ee/calendars/calendars.repository";
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
import { AppsRepository } from "@/modules/apps/apps.repository";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { DestinationCalendarsController } from "@/modules/destination-calendars/controllers/destination-calendars.controller";
import { DestinationCalendarsRepository } from "@/modules/destination-calendars/destination-calendars.repository";
import { DestinationCalendarsService } from "@/modules/destination-calendars/services/destination-calendars.service";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { UsersRepository } from "@/modules/users/users.repository";
import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule],
providers: [
CalendarsRepository,
CalendarsService,
DestinationCalendarsService,
DestinationCalendarsRepository,
UsersRepository,
CredentialsRepository,
AppsRepository,
],
controllers: [DestinationCalendarsController],
exports: [DestinationCalendarsRepository],
})
export class DestinationCalendarsModule {}
@@ -0,0 +1,34 @@
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
@Injectable()
export class DestinationCalendarsRepository {
constructor(private readonly dbWrite: PrismaWriteService) {}
async updateCalendar(
integration: string,
externalId: string,
credentialId: number,
userId: number,
primaryEmail: string | null
) {
return await this.dbWrite.prisma.destinationCalendar.upsert({
update: {
integration,
externalId,
credentialId,
primaryEmail,
},
create: {
integration,
externalId,
credentialId,
primaryEmail,
userId,
},
where: {
userId: userId,
},
});
}
}
@@ -0,0 +1,36 @@
import { ApiProperty } from "@nestjs/swagger";
import { Expose } from "class-transformer";
import { IsString, IsEnum } from "class-validator";
import {
APPLE_CALENDAR_TYPE,
GOOGLE_CALENDAR_TYPE,
OFFICE_365_CALENDAR_TYPE,
} from "@calcom/platform-constants";
export class DestinationCalendarsInputBodyDto {
@IsString()
@Expose()
@ApiProperty({
example: APPLE_CALENDAR_TYPE,
description: "The calendar service you want to integrate, as returned by the /calendars endpoint",
enum: [APPLE_CALENDAR_TYPE, GOOGLE_CALENDAR_TYPE, OFFICE_365_CALENDAR_TYPE],
required: true,
})
@IsEnum([APPLE_CALENDAR_TYPE, GOOGLE_CALENDAR_TYPE, OFFICE_365_CALENDAR_TYPE])
readonly integration!:
| typeof APPLE_CALENDAR_TYPE
| typeof GOOGLE_CALENDAR_TYPE
| typeof OFFICE_365_CALENDAR_TYPE;
@IsString()
@Expose()
@ApiProperty({
example: "https://caldav.icloud.com/26962146906/calendars/1644422A-1945-4438-BBC0-4F0Q23A57R7S/",
description:
"Unique identifier used to represent the specfic calendar, as returned by the /calendars endpoint",
type: "string",
required: true,
})
readonly externalId!: string;
}
@@ -0,0 +1,34 @@
import { ApiProperty } from "@nestjs/swagger";
import { Expose, Type } from "class-transformer";
import { IsInt, IsString, ValidateNested, IsEnum } from "class-validator";
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
export class DestinationCalendarsOutputDto {
@IsInt()
@Expose()
readonly userId!: number;
@IsString()
@Expose()
readonly integration!: string;
@IsString()
@Expose()
readonly externalId!: string;
@IsInt()
@Expose()
readonly credentialId!: number | null;
}
export class DestinationCalendarsOutputResponseDto {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@Expose()
@ValidateNested()
@Type(() => DestinationCalendarsOutputDto)
data!: DestinationCalendarsOutputDto;
}

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