Compare commits

...
196 Commits
Author SHA1 Message Date
Alex van Andel 00131668aa fix: Avatar API isn't ran at signup handler (#14675)
release.yml / release (push) Canceled after 0s
2024-04-22 12:13:15 -03:00
Alex van Andel 0306ad737f fix: invalid Cancelled / Rescheduled downstream calendar block (#14700)
* fix: invalid Cancelled / Rescheduled downstream calendar block

* Adjust test so no calendar is connected

* Remove the googleCalendar from multiple tests
2024-04-22 16:10:39 +01:00
20dfc34e70 fix: Managed event isn’t showing in the upcoming bookings when filtered (#14552)
Co-authored-by: Somay Chauhan <somaychauhan98@gmail.com>
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-04-22 10:55:26 -04:00
Somay Chauhan b7e8b252d2 fix: send completed_onboarding to intercom (#14703) 2024-04-22 14:12:02 +00:00
74f09c332b feat: updated the useage of listCurrent in the app and use org role from session (#14653)
* feat: include org role in org session data

* update contents

* fix: type err

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Udit Takkar <udit222001@gmail.com>
2024-04-22 14:07:43 +00:00
sean-brydonandHariom 3ce6c23603 fix: Update visibility of team members based on user role (#14629)
* fix: team memebrs being hidden for admin when org is private and they are not org admin

* chore: remove console.loog

* chore: make permissionCheck more readable

* test: push e2e test for normal privacy banner in orgs

* test: add a few more edge cases

* chore: negate names

* feat: update func to make more sense

* Fix

---------

Co-authored-by: Hariom <hariombalhara@gmail.com>
2024-04-22 13:45:28 +00:00
Soumik PatraandUdit Takkar ad3ee14501 fix: Date Override Modal Trims Date Picker (#14680)
* [CAL-3500] Date Override Modal Trims Date Picker #14658

* file

* type fix

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-04-22 18:53:16 +05:30
Somay ChauhanandUdit Takkar 1931758fc7 fix: Private settings of the event disabled after making changes to your event (#14628)
* fix: Private settings of the event disabled after making changes to your event

* add test

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-04-22 11:53:50 +00:00
Kiss Benedek MátéandUdit Takkar fe551af145 Add missing dollar sign (#14681)
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-04-22 11:33:07 +00:00
9fdaaed100 fix: show toast in center of screen (#14690)
* fix: show toast in center of screen

* fix: show toast in center of screen

---------

Co-authored-by: Nur Ovezova <nur.ovezova@techguilds.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-04-22 10:34:29 +00:00
Udit Takkar c0f8a355e3 fix: managed type url in orgs (#14698) 2024-04-22 09:38:58 +01:00
Peer Richelsen df95ec4ce8 fixed credit card icon (#14678) 2024-04-20 14:46:23 +01:00
Alex van Andel a15cad4d03 chore: Removing away (DB related, not slot related) from code (#14673)
* chore: Removing away (DB related, not slot related) from code

* Remove more isAway/away, eg. references
2024-04-20 02:23:34 +01:00
Udit Takkar 8904f20eb8 fix: get llm response schema (#14670) 2024-04-19 18:09:16 +00:00
Carina WollendorferandCarinaWolli 0ee81d0739 fix: remove away view (#14672)
* remove away view

* remove yellow away dot

* remove Away from Booker

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2024-04-19 17:40:35 +00:00
Peer Richelsen c0bc6f8ccb Update common.json (#14647) 2024-04-19 16:50:38 +00:00
Hariom Balhara 1f1cd1e236 Add self serve variable in tests (#14665) 2024-04-19 12:32:22 +01:00
sean-brydon dbbd78fb26 feat: include org role in org session data (#14652)
* feat: include org role in org session data

* update contents

* remove: files
2024-04-19 12:32:04 +01:00
Somay Chauhan f6e45842fd fix: only boot intercom if .env variable is set (#14656) 2024-04-19 09:04:02 +01:00
Alex van Andel d25c043046 chore: Remove all avatar/logo references (#14532)
* chore: Remove all avatar/logo references

* Updated user profile to only use avatarUrl

* fix: minor style issue

* chore: Remove redundant includeTeamLogo

* fix: Use right avatar default in event type list

* fix: placeholder avatar team profile, target

* chore: Add logoUrl/avatarUrl to JWT

* fix: Bunch of org avatar issues, fix members list

* Fix logoUrl on org pages

* More type fixes

* Hopefully final type fixes

* Another round of type fixes

* fix: UserForm ts

* fix: Handle as return types, not input types

* fix: Remove profile and add avatarUrl

* fix: notFound as const

* fix: Seeder avatarUrl params

* revert: Migration changes for easier recovery

* fix: Add explicit type to builder as avatar is now set

* Add logoUrl to unpublished entity

* Avatar test out of scope here

* fix: Use the new avatarUrl after save

* chore: Removed getOrgAvatarUrl/getTeamAvatarUrl

* fix: Update sidebar image immediately after change

* Unable to safe unnamed user, so default

* fix: Unpublished page, add organization test

* Add more tests
2024-04-18 21:54:16 -07:00
5695ba7c13 feat: Tasker (#14180)
Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
2024-04-18 11:56:25 -07:00
Morgan eeb0322ce1 fix: managed users check if exist (#14649) 2024-04-18 18:09:56 +00:00
MorganandLauris Skraucis 480977c2b5 fix: api v2 - platform users can disable emails and display their links (#14604)
* fix: disable emails with oauthCLient settings and hide empty links

* fixup! fix: disable emails with oauthCLient settings and hide empty links

* feat: enable to display booking success page link in emails

* fixup! feat: enable to display booking success page link in emails

* fixup! Merge branch 'fix-no-email-booking-hide-empty-links' of github.com:calcom/cal.com into fix-no-email-booking-hide-empty-links

* fixup! fixup! Merge branch 'fix-no-email-booking-hide-empty-links' of github.com:calcom/cal.com into fix-no-email-booking-hide-empty-links

* fixup! Merge branch 'main' into fix-no-email-booking-hide-empty-links

* fixup! Merge branch 'main' into fix-no-email-booking-hide-empty-links

* fixup! Merge branch 'fix-no-email-booking-hide-empty-links' of github.com:calcom/cal.com into fix-no-email-booking-hide-empty-links

---------

Co-authored-by: Lauris Skraucis <lauris.skraucis@gmail.com>
2024-04-18 12:43:17 +00:00
Lauris Skraucis 8b2e46a6fa fix: v2 e2e tests (#14655)
* fix: oauth-clients-users e2e -> auth was changed from access token to client secret header

* fix: Gcal e2e

* remove gcal module

* simplify gcal override
2024-04-18 12:13:31 +00:00
Morgan 842be660b0 chore: atoms 1.0.30 (#14650) 2024-04-18 10:33:39 +00:00
Hariom BalharaandJoe Au-Yeung 4f69c0e502 fix: Namespaced react embeds not working with Floating and Element Click Popups (#13386)
* Fix embed queue

* Fix getCalApi namespace use

* Fixes

* Add more e2e

* Fix test

* Fix syntax error in generated code

* Make getCalApi argument optional

* Fix types

* Add more namespacing tests

---------

Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
2024-04-18 14:41:39 +05:30
be128181e2 feat: booker atom custom location (#14635)
* frontend: handle booking locationUrl

* backend: pass booking locationUrl to handleNewBooking

* examples app: display booking location if provided

* fix: v2 not starting: contains -> includes

* backend: pass booking locationUrl to handleNewBooking

* swagger docs update

* examples app: dont display where of booking if does not start with http

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2024-04-18 08:26:36 +00:00
Morgan 0aa1628925 chore: atoms 1.0.29 + fix typing issues (#14633) 2024-04-18 07:30:34 +00:00
Omar López fd58f669d0 hotfix: add linting exception for website (#14645) 2024-04-17 19:51:42 +00:00
Lauris Skraucis 51927f82c0 fix: v2 app.rewrites.middleware.ts (#14626) 2024-04-17 16:48:52 +00:00
Alex van Andel 0f8a0eaf4a fix: 500 on forbidden showing up in logs (#14636) 2024-04-17 16:00:21 +00:00
b9ac22d4ed feat: AI Transcribe (#14140)
* chore: transcribe

* chore: provider

* chore: use daily provider

* chore: rebase

* chore: remove types file

* chore: remove recoil

* chore: progress

* chore: add recoil

* chore: save progress

* chore: save progress

* fix: type err

* fix: err

* fix: save progress

* feat: finish emails

* chore: add new env variable

* chore: fix type err

* chore: turbo

* chore: improvements

---------

Co-authored-by: Udit Takkar <udit222001@gmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
2024-04-17 11:09:11 -04:00
ebca5c6409 fix: use 'success' value for better accuracy (#14624)
* fix: use 'success' value for better accuracy

* add success to test

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2024-04-17 14:01:13 +00:00
sean-brydonandUdit Takkar 6ef35e4dba chore: settings design match (#14630)
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-04-17 09:35:18 -04:00
Carina Wollendorfer 8bb5200dd8 fix: add new styling for emoji links (#14481) 2024-04-17 12:30:14 +00:00
sean-brydon 4aeb6b7c9b fix: Hide org tab when you are team admin (#14627)
* fix: hide-tab-on-fe

* fix: backend pulling back info if the user dont have perms
2024-04-17 10:38:12 +01:00
Keith Williams ae64d7ba82 fix: API v2 recurring spelling (#14620) 2024-04-16 18:58:36 -03:00
Keith Williams 9dd2dbff19 v4.0.1 (#14618)
release.yml / release (push) Canceled after 0s
2024-04-16 21:05:18 +00:00
Joe Au-Yeung cebbffe230 fix: Minimum fix for N Calendar Invites (#14617)
* Remove pushing team member destination calendars

* Only create the event on the first destination calendar

* Only create event on the first destination calendar
2024-04-16 20:05:40 +00:00
Morgan e3e464abfa fix: booker atoms using next/router (#14612)
* fix: booker OOO using next/router

* fixup! fix: booker OOO using next/router
2024-04-16 18:37:24 +00:00
268438b0ff fix: rescheduling daily (#13736)
* fix: rescheduling daily

* fix: e2e test

* chore: test

* add location to default event type

* revert fix

* chore: add env variable

* fix: sort alphabetically

* test: checking

* chore: use another user

* chore: try adding location

* chore: undo

* chore: add variable

* chore: commented

* F

* chore: comment

* test: add unit test

* fix: type err

---------

Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Omar López <zomars@me.com>
2024-04-16 12:57:34 -04:00
Hariom Balhara 355c8de5e7 Set exclusive tax behaviour (#14574) 2024-04-16 13:18:08 +01:00
e6056bc4a4 fix: change max width to avoid overflow in dialog when creating new event type (#14198)
* fix: change max width to avoid overflow when creating new event dialog

* style: make max with responsive at md breakpoint

* chore: add tooltip

---------

Co-authored-by: xylonx <x956200361@gmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Udit Takkar <udit222001@gmail.com>
2024-04-16 10:48:10 +00:00
Omar López f2eebe95bd chore: Adds deprecated-imports-next-router (#14486) 2024-04-16 11:46:45 +01:00
sean-brydonandJoe Au-Yeung 3084013269 fix: always load via SSR (#14476)
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
2024-04-16 11:37:03 +01:00
68568e5a25 feat: round robin: show avatar and name of person on success page (#14245)
* feat: round robin: show avatar and name of person on success page

* fix: add background border

* fix: e2e tests

* fix: remove avatar on select, use proper border variables

* fix: bookings-list e2e round robin title

* chore: change name

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Udit Takkar <udit222001@gmail.com>
2024-04-16 11:36:11 +01:00
Lauris Skraucis 0eae0bb8fe refactor: remove /ee/ url segment from atoms hooks (#14602) 2024-04-16 07:29:12 -03:00
Somay Chauhan b61820d354 fix: custom attributes not being synced in intercom (#14600)
* fix: custom attributes not being synced in intercom

* fix: ssrInit
2024-04-16 11:03:45 +01:00
MorganandBailey Pumfleet 1606272850 chore: bump atoms (#14585)
Co-authored-by: Bailey Pumfleet <bailey@pumfleet.co.uk>
2024-04-16 00:17:04 -03:00
Kaleem ea04fe1c9b fix: remove @ts-ignore (#14582) 2024-04-16 07:44:00 +05:30
Peer RichelsenandAlex van Andel 4c50b6a8af fix: hide "upgrade org banner" for isPlatform (#14522)
* not great but does the job

* comments

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2024-04-16 02:41:07 +01:00
sean-brydon 39aa848e9e fix: update sub-team slug - update tempOrgRedirect (#14523) 2024-04-16 01:14:41 +00:00
Peer Richelsen 64c56c1b17 Delete apps/platform directory (#14520) 2024-04-16 01:12:54 +00:00
MorganandRyukemeister 8e883a6b10 feat: update platform oauth client form (#14542)
* feat: update platform oauth client form

* fixup! feat: update platform oauth client form

* add tooltip to mention permissions cannot be modified

---------

Co-authored-by: Ryukemeister <sahalrajiv-extc@atharvacoe.ac.in>
2024-04-16 01:55:00 +01:00
Peer Richelsen 9684aa3ad5 for admins min 1 org user. for self-serve 30 (#14567) 2024-04-16 01:53:22 +01:00
sean-brydon 5fede84bf6 feat: use experimental optimizePackageImports (#14587) 2024-04-16 01:52:08 +01:00
Alex van Andel a788e07be1 fix: Re-instate hasEmailBeenChanged before sending email (#14596) 2024-04-15 22:09:06 +00:00
Somay Chauhan 68ffd2248b fix: intercom visible on mobile (#14563) 2024-04-15 21:18:42 +00:00
Carina WollendorferandCarinaWolli 4b12d021e4 remove : from email from name (#14594)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2024-04-15 20:27:10 +01:00
Alex van Andel 4daf9db9db fix: acme.cal.local crashes due to missing requestedSlug in seed (#14592) 2024-04-15 18:53:41 +01:00
sean-brydon a660b1eb05 fix: conditionally remove fixed prop if we're in embed (#14586)
* fix: conditionally remove fixed prop if we're in embed

* fix: conditionally remove fixed prop if we're in embed
2024-04-15 14:19:25 +01:00
Somay Chauhan 6f2f098e12 fix: removing stripe results in the event-types page loading indefintely (#14583)
* fix: removing stripe results in the event-types page loading indefinitely

* undo yarn.lock
2024-04-15 10:44:47 +00:00
Keith Williams 6a75ef86b0 v4.0.0
release.yml / release (push) Canceled after 0s
2024-04-14 20:02:28 -03:00
Alex van Andel 3d2397879c fix: Remove PageWrapper (#14579)
* fix: Remove PageWrapper

* Remove useLocale
2024-04-14 20:46:08 +00:00
Alex van Andel de474089a7 Revert "fix: Production deploys (#14578)"
This reverts commit 8b31b9aa05.
2024-04-14 16:41:25 +01:00
Keith Williams 8b31b9aa05 fix: Production deploys (#14578) 2024-04-14 15:04:30 +00:00
Crowdin Bot ad41672958 New Crowdin translations by Github Action 2024-04-14 12:51:43 +00:00
Morgan 86f2984b30 fix: only call useMe if access token is set (#14576)
* fix: only call useMe if access token is set

* chore: export use me hook
2024-04-14 12:48:30 +00:00
Morgan 38e62786e4 chore: remove /ee from apiv2 urls (#14568)
* chore: remove /ee from apiv2 urls

* fixup! chore: remove /ee from apiv2 urls

* chore: remove /ee from apiv2 urls

* fixup! chore: remove /ee from apiv2 urls

* fixup! fixup! chore: remove /ee from apiv2 urls

* fixup! fixup! fixup! chore: remove /ee from apiv2 urls
2024-04-13 22:16:37 +00:00
Keith Williams ac3d7c9fae fix: Integrate maintenance mode with current middleware (#14571)
release.yml / release (push) Canceled after 0s
* fix: API v1 maintenance mode

* Removed old middleware

* Refactor to follow same pattern as other middleware

* Added missing import
2024-04-13 20:27:22 +01:00
Rajiv Sahal 98f94ea694 return isPlaatform boolean to detect if user is a platfor user (#14570) 2024-04-13 18:42:25 +05:30
Lauris Skraucis 8ad80f7ac3 refactor: v2 event types endpoint paths (#14545)
* refactor: rename event-types/:username/public to event-types/public/:username

* refactor: move /events/public to /event-types/public/:username/:eventSlug

* fix: remove EVENT_TYPE_READ permission from public GET event-types/public/:username

* docs: add response DTO to generate docs for /public/:username/:slug aka ex. events/public

* tests: public event types endpoints

* refactor: have /public at the end of event-types routes

* correct swagger doc
2024-04-13 12:47:35 +03:00
Morgan 296678046c chore: fix class type and bump atoms version (#14562)
* chore: fix class type and bump version

* fixup! chore: fix class type and bump version

* fixup! fixup! chore: fix class type and bump version

* fixup! Merge branch 'main' into atoms-package-version-1.0.18

* fixup! fixup! Merge branch 'main' into atoms-package-version-1.0.18
2024-04-13 01:21:03 +00:00
Keith Williams fd193e651d revert: "fix: view team bookings (#14079)" (#14561)
This reverts commit f86c448359.
2024-04-13 00:53:05 +00:00
Omar LópezandKeith Williams becad5ee67 chore: adds maintenance mode for API v1 (#14555)
* Create middleware.ts

* Update middleware.ts

* Update middleware.ts

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-04-13 00:16:14 +00:00
Morgan 161dcbdd4c fix: remove prefix /api from apiv2 (#14559) 2024-04-12 23:00:12 +00:00
Crowdin Bot 7ad6652cb0 New Crowdin translations by Github Action 2024-04-12 21:52:02 +00:00
c467167e91 feat: add classNames prop to Booker atom for passing external styles (#14270)
* add classnames prop to pass external styles

* fix import

* add comments

* ignore this commit: tinkering with styling

* update typings

* mostly creating props and passing it down to child components

* remove unnecessary comment

* merge conflicts

* fixup

* fixup

* fix conflicts

* fix merge conflicts

* fix merge conflicts

* fix failing type checks

* type checks fix

* add custom classnames to booker props

* update typings

* pass in props down wherever necessary

* fixup

* still passing down around some more props

* addressing pr feedback

* final fixes

* address feedback

* fixup! address feedback

* fixup! fixup! address feedback

* fixup! fixup! fixup! address feedback

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: Morgan Vernay <morgan@cal.com>
2024-04-12 21:49:01 +00:00
sean-brydon 18093824d8 fix: update playwright config to allow permissions (#14526) 2024-04-12 14:13:53 -07:00
Crowdin Bot 8102f3c33c New Crowdin translations by Github Action 2024-04-12 21:09:12 +00:00
Joe Au-Yeung 902c441248 fix: Cal Video Links Expire After 14 Days (#14557)
* Set expiry date to 14 days after end time

* Add comment
2024-04-12 17:05:26 -04:00
Morgan e641bb3d57 chore: enable platform emails (#14471)
* feat: enable platform emails

* fixup! feat: enable platform emails

* fixup! fixup! feat: enable platform emails

* fixup! Merge branch 'main' into chore-platform-emails

* fixup! fixup! Merge branch 'main' into chore-platform-emails

* fixup! fixup! fixup! Merge branch 'main' into chore-platform-emails

* chore: add oauth client repo

* chore: enable platform emails using oauth client

* fix: call getConnectedCalendar with dbWrite since it creates destinationCalendar for new credentials

* chore: update doc

* fixup! chore: update doc

* fixup! fixup! chore: update doc
2024-04-12 22:39:01 +03:00
19d938ed60 feat: travel schedules to schedule timezone changes (#14512)
* add Travel Schedule modal

* UI to list schedules

* set shouldDirty

* add backend  to add new travelSchedule

* implement deleting a schedule

* check if schedule is overlapping

* WIP

* fix finding overlapping travel schedule

* only use travelSchedule when default availability is used

* adjust date overrides to timezone schedule

* first version of changeTimeZone cron job

* fix tests by adding travelSchedules

* fixes for cron job api call

* improve unit tests

* add migration

* fix type error

* fix collective-scheduling test

* clean up cron job

* code clean up

* code clean up

* show timezone from travel schedule for date override

* add date override tests

* show tz on date override only in default schedule

* fix deleting old schedules

* minor fixes in cron api handler

* code clean up

* code clean up from feedback

* fix asia/kalkota comment

* fix dark mode

* fix start and end date conversion to utc

* add first unit test for travel schedules

* Fix modal render issue

* show timezone city wtihout _

* fix dark more for datepicker

* reset values after closing dialog

* remove session from middleware

* exit loop early once schedule is found

* fix type error

* add getTravelSchedules handler

* clean up DatePicker

* fix type error

* code clean up

* code clean up

* add indexes

* use deleteMany

* fix icons

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
2024-04-12 17:57:28 +00:00
5561949f9b feat: Delete/Create WebhookScheduledTriggers for existing bookings (#14121)
* feat: Delete/Create WebhookScheduledTriggers for existing bookings

* fix: type check

* fix: early return, query status in prisma, unnecessary trigger type check

* feat: add/delete scheduled webhook triggers for team and user

---------

Co-authored-by: v0ltZzie <samit091848@gmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-04-12 16:57:59 +00:00
Lauris Skraucis 5e98cca062 fix: v2 create schedule with availabilities (#14551)
* fix: v2 schedule with availabilities creation

* driveby: remove permissions from public event-types endpoint
2024-04-12 15:49:47 +00:00
ac858c9bee fix: [CAL-3374] Says Number is not Verified, but it is Verified (#14531)
* fix: [CAL-3374] Says Number is not Verified, but it is Verified

* clearing error only when verified

* type-checks fixed

* chore: improvement

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Udit Takkar <udit222001@gmail.com>
2024-04-12 11:33:24 -04:00
Erik f58f5e4e02 chore: SDK version 1.0.1 (#14524) 2024-04-12 15:31:04 +00:00
Alex van Andel ad844f5e51 fix: Remove hardcoded stripe.. (#14549) 2024-04-12 15:50:02 +01:00
Morgan 53c1bc45d6 fix: atoms package types were not exported (#14546) 2024-04-12 13:16:07 +00:00
Hariom Balhara 0cc93aa87e chore: Publish new versions of embeds (#14543) 2024-04-12 09:44:37 +01:00
Amit Sharma b41b830086 fix: State Inconsistency issue on webhook edit modal (#14544) 2024-04-12 14:08:58 +05:30
Erik c79d0890c0 v3.9.8
release.yml / release (push) Canceled after 0s
2024-04-11 17:11:42 -03:00
Erik 5071e6566f Revert "feat: new App install flow (#11975)" (#14540) 2024-04-11 17:06:17 -03:00
Keith Williams ac77fe360d fix: Invalid 403 forbidden for event-types GET (#14528) 2024-04-11 19:15:21 +00:00
Erik 711aceba0e Revert "Fix: Redirect only those apps that extend 'EventType' to the new app …" (#14539)
This reverts commit 0c67f4940a.
2024-04-11 19:09:13 +00:00
Somay ChauhanandPeer Richelsen f010279303 feat: always show intercom on desktop by default (hidden on mobile) (#14423)
* feat: always show intercom on desktop by default (hidden on mobile)

* Revert "feat: always show intercom on desktop by default (hidden on mobile)"

This reverts commit d14a7b0808a5c3b7f15de75d3a422c4ec5e14f2b.

* feat: always show intercom on desktop by default (hidden on mobile)

* fix: type error

* refactor

* saved showIntercom in localstorage instead of DB

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2024-04-11 18:58:37 +00:00
Somay Chauhan 0c67f4940a Fix: Redirect only those apps that extend 'EventType' to the new app install flow (#14527) 2024-04-11 18:37:01 +00:00
7836aeacc6 feat: add redirect uris for booking success, cancel and reschedule for a platform user (#14518)
* update typings

* update prisma to include redirect uris for booking success, cancel and reschedule

* update migrations

* add input for uris and send them back in custom hook

* display new uris

* add type for enabling emails

* update prisma schema and add migrations

* chores

* fixup

* chore: fix style, remove console log, rewrite migration

---------

Co-authored-by: Morgan Vernay <morgan@cal.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2024-04-11 17:46:57 +00:00
Hariom Balhara af0cc5c899 fix: test (#14535)
* Consider requestedSlug as well for instances that require payment

* fix

* Though we should add isTeamEvent to avoid duplicate /event request. But right now that is not breaking functionality
2024-04-11 20:24:16 +03:00
Hariom Balhara aa2808d621 fix: Broken SSR for event booking pages (#14497)
* fix: Broken SSR for event type pages

* Add test

* Consider requestedSlug as well for instances that require payment

* Add a todo test
2024-04-11 10:57:15 +01:00
Morgan 621067acd8 fix: platform gcal connect reconnect credentials (#14513)
* fix: enable gcal connect to reconnect revoked google credentials

* feat: callback to react when check fails
2024-04-11 09:36:35 +00:00
Morgan 3fcba33b1b fix: platform booking endpoints should be public (#14509) 2024-04-11 08:52:31 +00:00
Hariom Balhara 9a66dae0ba feat: In Embed, Fire 'routed' event when Routing Form routes (#14470) 2024-04-11 03:39:28 +00:00
e2ef8d2dc8 fix: correct language is used when booking with a modal + location icon visible in booker when using light mode (#14280)
* fix small frontend issues with Booker

This commit fixes the following:
- The "location" icon is now visible in light move when using the "in person (Organizer Address)" option

- The language in which the date and length is shown in modals now corresponds to the language of the creator of the calendar (before this is was always in english)

- If the text is too large for the modal, they will adapt into a collumn

-

* correct small mistakes

* chore: getDurationFormatted

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Udit Takkar <udit222001@gmail.com>
2024-04-10 21:45:55 +00:00
Crowdin Bot eb089bb207 New Crowdin translations by Github Action 2024-04-10 21:42:51 +00:00
Anwar Sadathandzomars 4565c3311c feat: Implement the ability to login via multiple means when signed up via google/non-cal providers (#14082)
Co-authored-by: zomars <zomars@me.com>
2024-04-10 14:38:39 -07:00
John ForteandRajiv Sahal e30b41a85a fix(platform): missing string literal opening (#14463)
Co-authored-by: Rajiv Sahal <sahalrajiv-extc@atharvacoe.ac.in>
2024-04-10 17:26:08 -04:00
Carina WollendorferandCarinaWolli 7b0321043d fix: OOO for team events (#14503)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2024-04-10 13:47:26 -07:00
Morgan c4b7062d65 chore: bump atoms to 1.0.13 (#14506) 2024-04-10 20:44:14 +00:00
Meeran E Mandhini eb1c1b9475 Fix locale title typo (#14501) 2024-04-10 17:04:56 -03:00
Erik 67725f0cba feat: SDK managed users + PLA fixes (#14495)
* feat: SDK managed users + PLA fixes

* chore: include lock
2024-04-10 19:53:44 +00:00
Amit Sharma 68c4ad2bf9 fix: private link for a team event gives 404 (#14473)
## What does this PR do?

Fixes #14456 

https://github.com/calcom/cal.com/blob/65df5ac0e4086422bc0b8416f16ba991a10e785d/apps/web/lib/d/%5Blink%5D/%5Bslug%5D/getServerSideProps.tsx#L52-L60

For team events there is no username, so it was returning 404 irrespective of whether anyone was assigned or not.

https://github.com/calcom/cal.com/assets/74371312/b4c8c592-4132-4040-acb2-db37f8f152d6




## Type of change

<!-- Please delete bullets that are not relevant. -->

- Bug fix (non-breaking change which fixes an issue)


## Mandatory Tasks

- [x] Make sure you have self-reviewed the code. A decent size PR without self-review might be rejected.
2024-04-10 12:29:45 -07:00
sean-brydon dbafda9822 fix: org-redirect-url-admin-update (#14493) 2024-04-10 11:48:22 -07:00
6d5d2a1c4d feat: new App install flow (#11975)
* feat(appStore): add isOAuth config

* refactor(appStore): EventTypeAppSettngsInterface

* refactor(qr_code): split EventTypeAppCardInterface and EventTypeAppSettingsInterface

* feat(appStore): redirect to onboarding for stripe and basecam

* fix(ui/ScrollableArea): overflow indicator not working

* feat(apps): new install app flow

* fix(configureStep): get disabled props for settings

* fix: getAppInstallsBySlug now use teamIds

* chore: Alby to AppSettingsInterface

* chore: Giphy to appsettings interface

* chore: GTM migration

* chore: GT4 - migration

* chore: fathom migration

* chore: paypal

* feat: basecamp migration

* feat: metapixel migration

* feat:plausable

* feat:stripe

* fix merge issues

* fix merge issues in new app install flow steps

* wip: callback to onboarding

* fix: imports

* fix: more imports

* feat: use redirect to onboarding on install flow

* feat: New app install flow 2 (#14077)

* fix: redirect to event-type after oauth flow

* When clicking on install new app button, only redirect to the new app install flow if it is an oAuth app or an app that extends the EventType feature in it's config

* code refactor, skip event-type step

* removed extra consoles

* so that no one can change the eventTypeId in url

* theme support enabled

* only pass eventTypeId instead of whole evetType obj to OmniInstallAppButton

* fix: clicking on event-type gives error and redirects to /apps

* saved appOnboardingRedirectUrl to OAuth State

* fix: a user having no team breaks the onboarding flow

* add optional chaining to appMetadata.extendsFeature and appMetadata.isOAuth;

* encode appOnboardingRedirectUrl

* fix: dark theme ui issues

* skip configure step if no-event types

if user/team does not have event-type then skip configure step and goto apps/installed/[category]

* exclude calendars from apps- onboarding flow

* Update getAppOnboardingRedirectUrl.ts

* fix: On configure step, inputs aren't showing errors

* change route from `apps/onboarding` to `apps/installation`

* fix: flow not correctly working for teams

* should only allow team ADMIN or OWER to install an app to team

* Prevent two payment apps to be enabled

* removed loading state

* Fix type error

* undo new-app-flow from OmniInstallAppButton

* remove OAuth Step, handle step numbering

* fix: redirect url

* getAppOnboardingRedirectUrl refactor

* for debugging

* Revert "for debugging"

This reverts commit b9362c1a8f47f2280e684c213d7ec08d04a8ac4c.

* fix: added toaster

* feat: added support to select multiple events (almost)

* hide button

* Move inner form outside outer form using portal

Due to limitations with nested forms in React Hook Form, moved the inner form outside the outer form using a portal to ensure proper form handling.

* submit buttons refs madness

* removed configure step

we had to use query params to manage selected event-types, between routes which was causing 2 issues:
* each time we selected or unselected an event the whole page was reloaded including getserversideprops being called again and again
* the form was reloaded causing more issues

* removed extra unwanted code

* small fix

* fix: type error

* fix: types

* implemented suggested changes

* removed step footer
* fixed error.message
* removed commented styles

* removed step footer

* fix: Display team event types instead of user's on event types step when team selected

* fix: typo

* removed shouldLockDisableProps

* refactor setUpdatedEventTypesStatus, fix type

* removed extra code

* can't delete while saving

* added translations

* fix: loading in select account step, fix handle onSelect params type

* Fix typo

---------

Co-authored-by: Joe Au-Yeung <j.auyeung419@gmail.com>

* fix: type errors

* fix: failing tests

---------

Co-authored-by: sean-brydon <sean@cal.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Joe Au-Yeung <j.auyeung419@gmail.com>
Co-authored-by: Somay Chauhan <somaychauhan98@gmail.com>
2024-04-10 16:24:31 +00:00
Morgan b748b8d077 chore: AtomsWrapper to scope css (#14490) 2024-04-10 16:21:20 +00:00
f86c448359 fix: view team bookings (#14079)
* fix: view team bookings

* fix: edge case- common user across 2 different teams

* @SomayChauhan fix: edge case- common user across 2 different teams v2.0

* feat: added booking list test for collective event-types

* feat: added booking list test for round-robin event-types

* feat: added test for managed event types bookings

* feat: added test for individual events bookings

* refactor code

* added comments

* added isFixed to collective event hosts

* better function naming

* code refactor

* fix: assertBookingVisibleFor ignoring today's meetings

* fix: handling case for organizations

* fix: non org bookings-list test

* feat: added tests for organisations booking list

* added comments for better clarity

* booking-list in org tests

* uncommented doOnOrgDomain

* removed extra selectors

* fix: failing tests

* fix: db-load on bookings.get

* fix: only select the required fields

* followUp: Improve getUniqueBookings perf

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: Omar López <zomars@me.com>
2024-04-10 16:57:04 +01:00
Vachmara d0af4b8ddd fix: error when adding through URL (#14496) 2024-04-10 14:32:32 +00:00
Morgan 39103c07c9 fix: unnecessary access token guard on public endpoint for slots (#14492) 2024-04-10 12:31:26 +00:00
Lauris Skraucis 8382ebc057 refactor: v2 docs (#14446)
* refactor: group endpoints by tags

* refactor: remove swagger header

* feat: managed users docs

* feat: gcal docs

* feat: provider docs

* feat: managed users docs

* fix: event-types locations docs

* refactor: schedules remove forAtom logic - return just for atoms

* feat: schedules docs

* feat: schedules docs

* fix: me tests

* fix: schedules tests

* feat: me docs

* feat: calendar busy times docs

* feat: calendar connected and destination calendars docs

* feat: some docs for bookings endpoints

* fix: add OAuth id when updating user email + disallow updating username

* fix: creating user with same e-mail results in 500

* fix: me e2e tests

* fix: event-types e2e tests

* fix: oauth-client-users e2e tests

* fix: some bookings e2e

* revert: re-add name for create update user

* fix: schedule create isDefault required

* lock file from main

* lock file from main

* comment broken bookings tests

* fix: connected calendars output type

* fix: connected calendars output type
2024-04-10 12:08:41 +03:00
880ba8f1dd feat: ooo-v2 (#13621)
Co-authored-by: zomars <zomars@me.com>
Co-authored-by: Peer Richelsen <peer@cal.com>
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2024-04-09 18:29:39 -07:00
Alex van Andel f00849b8d1 fix: Remove logo from getPublicEvent response (#14488) 2024-04-09 23:23:54 +00:00
Alex van Andel c090f78898 fix: isTeamEvent would eval to null when undefined (#14480) 2024-04-09 21:01:39 +00:00
Hariom Balhara 46a1713122 fix: Remove unnecessary export in barrel file which is causing prisma import on client side (#14475) 2024-04-09 15:37:18 -03:00
4a88b33782 refactor: Add crm type to app credentials (#14147)
* fix timezone display on booking page to reflect event availability timezone

* migrate fetching event owner's schedule to server side

* migrate fetching event owner's schedule to server side

* fix e2e test errors

* Add WEBAPP_URL_FOR_OAUTH to salesforce auth

* In event manager constructor include "_crm" credentials as calendar creds

* Change crm apps to type to end with `_crm`

* Move sendgrid out of CRM

* Add zoho bigin to CRM apps

* When getting apps, use slug

* Add `crm` variants

* Hubspot Oauth use `WEBAPP_URL_FOR_OAUTH`

* Refactor creating credentials

* Fix empty CRM page

* Use credentials with `_crm`

* Abstract getAppCategoryTitle

* Add integration.handler changes

* Fix tests

* Type fix

---------

Co-authored-by: Shaik-Sirajuddin <sirajuddinshaik30gmail.com>
Co-authored-by: Shaik-Sirajuddin <sirajudddinshaik30@gmail.com>
Co-authored-by: Shaik-Sirajuddin <89742297+Shaik-Sirajuddin@users.noreply.github.com>
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
2024-04-09 16:46:06 +00:00
Udit Takkar a1a2a141e4 chore: add instruction in recording email (#14424) 2024-04-09 09:32:10 -04:00
Crowdin Bot 97404455d4 New Crowdin translations by Github Action 2024-04-09 13:10:36 +00:00
Alex van Andel be31100bd7 fix: Ensure the session is invalidated during booking-seats test (#14478) 2024-04-09 13:05:40 +00:00
Keith Williams 4335c9ab92 v3.9.7
release.yml / release (push) Canceled after 0s
2024-04-09 09:37:32 -03:00
Peer Richelsen 20df7156cf Update common.json (#14472) 2024-04-09 12:23:54 +00:00
ErikandPeer Richelsen 9c35425e57 chore: Release SDK 1.0.0 (#14462)
* chore: dont require version

* fixup: type

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2024-04-09 11:56:02 +00:00
Hariom Balhara 65df5ac0e4 fix: Allow unpublished org to serve redirected links plus other org onboarding fixes (#14337)
* fix: Allow unpublished org to serve redirected links

* fix: Fix team event link for unpublished org

* fixes

* Rename variable

* Add minimum teams requirement for upgrade

* Fixes

* PR Feedback addressed
2024-04-09 10:33:39 +01:00
Keith Williams 84fdd03603 fix: Return team event type (#14464) 2024-04-08 18:35:41 -03:00
Crowdin Bot e6ec8bdf4d New Crowdin translations by Github Action 2024-04-08 19:17:45 +00:00
Yamini KandKeith Williams 86c8197104 docs: Update README.md regarding POSTGRES db port (#14431)
* Update README.md

Updating the description about POSTGRES DB port

* Update README.md

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-04-08 19:12:11 +00:00
Pat bb13badcf4 Update README.md (#14432)
Add minimum version number to PostgreSQL in 'Getting Started,' to help potential contributors successfully set up environment with local database.

I tried to set up local dev environment on Ubuntu 20.04 with PostgreSQL 12.18 which failed when running 'yarn workspace @calcom/prisma db-migrate'

PostgreSQL version 14.11 worked for me. I saw another dev say v. 13+ would work. Could save folks some troubleshooting time.

This is the error on PostgreSQL 12.18:

Error: P3006 Migration `20240321143215_move_avatars_cols_to_avatar_table` failed to apply cleanly to the shadow database. Error: ERROR: function gen_random_uuid() does not exist HINT: No function matches the given name and argument types. You might need to add explicit type casts. 0: sql_schema_connector::validate_migrations with namespaces=None at schema-engine/connectors/sql-schema-connector/src/lib.rs:324 1: schema_core::state::DevDiagnostic at schema-engine/core/src/state.rs:270
2024-04-08 16:09:43 -03:00
Keith Williams 64b2397fa2 v3.9.6
release.yml / release (push) Canceled after 0s
2024-04-08 12:42:15 -03:00
Kartik SainiandUdit Takkar 90130457e2 fix: Add missing import for Icon component in MultiDropdownSelect.tsx (#14440)
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-04-08 13:44:47 +00:00
Amit Sharma 260521fb28 fix: Empty "Add guests" email input is blocking form submission (#14442) 2024-04-08 13:24:15 +00:00
Hariom Balhara 6390d595f5 fix: Team Invite Email Text formatting (#14434) 2024-04-08 10:15:26 +00:00
Udit Takkar 74dc46f8b2 chore: change timezone format and update webhook (#14433)
* chore: change timezone format

* chore: set guest email and guest company to null

* feat: also check states to get id

* chore: improve code

* chore: fix type erro

* chore: update zod schema
2024-04-08 15:26:31 +05:30
Lauris Skraucis 67262910cd fix: v1 tsconfig decorators (#14454) 2024-04-08 11:51:56 +03:00
Omar López d59e68de6d ci: adds api v1 builds to required checks (#14345) 2024-04-05 23:00:01 +00:00
zomars b50318fbca chore: Adds devops script for getting table sizes 2024-04-05 14:29:50 -07:00
Udit Takkar 1ce7df324d feat: retell AI webhook url (#14430)
* feat: retell AI webhook url

* fix: type error

* chore

* chore: make company and email optional

* chore: remove logs

* chore: type error

* chore: type error

* fix: type err

* fix: pass guest email

* fix: type err

* chore: change date format

* chore: move advanced format
2024-04-05 22:44:57 +05:30
sean-brydonandKeith Williams e3b7264208 fix: filter and listMember handler (#14429)
* fix: filter and listMember handler

* Update packages/trpc/server/routers/viewer/teams/listMembers.handler.ts

Co-authored-by: Keith Williams <keithwillcode@gmail.com>

* Update packages/features/bookings/components/PeopleFilter.tsx

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-04-05 11:51:22 -03:00
Morgan 5e3f6e6c2c chore: bump atoms package version (#14428) 2024-04-05 14:00:22 +00:00
Udit Takkar 5b6c43118e fix: change identity provider on password reset (#14326)
* fix: change identity provider on password reset

* chore: remove check
2024-04-05 10:13:43 +00:00
Amit SharmaandHariom beee545568 fix: "Checkbox" custom field throws error (#14339)
* fix: "Checkbox" custom field throws error

* Add e2e

* Self review feedback

* Fix typo

---------

Co-authored-by: Hariom <hariombalhara@gmail.com>
2024-04-05 15:05:48 +05:30
Lauris SkraucisandKeith Williams a597defd03 fix: create platform handler (#14288)
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-04-05 07:46:29 +00:00
Morgan e3562cdfb5 fix: scope managed user to client id (#14347) 2024-04-05 09:14:24 +02:00
Morgan 86a7824ec9 fix: remove port in prod url apiv2 (#14343) 2024-04-04 20:25:35 +00:00
Morgan 7001515a44 chore: bump atoms package to 1.0.11 (#14344) 2024-04-04 19:31:45 +00:00
zomars 36044c4ab3 ci: enable settings for app-router canary 2024-04-04 11:53:40 -07:00
Omar López 4933295810 v3.9.5
release.yml / release (push) Canceled after 0s
2024-04-04 11:34:47 -07:00
Carina WollendorferandCarinaWolli ed0cd30092 fix: improve api/cron/webhookTriggers endpoint (#14188)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2024-04-04 10:01:57 -07:00
Alex van Andel 1132d9d0dc chore: teams filter improvements (#14315)
* Changed translations, many languages were wrong

* Clear localStorage from the useTypedQuery

* Missed one
2024-04-04 09:59:58 -07:00
9bd3a362a2 feat: added Rating Template to Workflows (#14215)
Co-authored-by: alishaz-polymath <alishahbaz7@gmail.com>
Co-authored-by: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com>
2024-04-04 09:58:52 -07:00
Lauris SkraucisandMorgan 844076badb docs: v2 event types swagger (#14296)
* docs: GET /event-types/:id response

* docs: GET /event-types response

* docs: UPDATE /event-types/:id response

* docs: DELETE /event-types/:id response

* docs: GET /event-types/:username/public response

* docs: prod docs show only event-types, bookings cancel and reschedule, managed users

* fix merge main

* Revert "docs: prod docs show only event-types, bookings cancel and reschedule, managed users"

This reverts commit 96cdc4b4840e3841c6ed27bd3a030d0574aad19e.

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2024-04-04 16:20:03 +00:00
Lauris Skraucis c0523a31a7 Revert "refactor: OAuth creation form hide apps permissions (#14264)" (#14336)
This reverts commit fe62f9c6de.
2024-04-04 15:24:05 +00:00
Morgan 1fff642860 fix: urls, gcal check, oauth client controller (#14335)
* fix: urls, gcal check, oauth client controller

* fixup! fix: urls, gcal check, oauth client controller
2024-04-04 14:58:15 +00:00
89fe383c9f feat: implement turnstile on signup (#13913)
* wip

* feat: turnstile + removal of trpcState

* fix: use react-turnstile

* fix: i18n hydration errors

* wip: server side validation for cf token

* fix: hide in e2e tests

* feat:throw on error of token

* fix: set correct form values onVerify

* fix: use getIp for remoteIp and use correct header for token input

* chore: update .env.example

* fix: Update .env.example

* Update apps/web/pages/signup.tsx

* chore: fix typo

* fix: typo

* feat: hide until error

* feat: appearance interaction only

* remove cloudflare from e2e

* fix: legacyBehavior

* fix links + wait for load

* fix: signup tests

* test: wait for load state

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-04-04 10:21:06 -03:00
sean-brydon 4a173c48b8 fix: correct limit amount in api middleware (#14333) 2024-04-04 12:20:26 +00:00
Morgan a72a3e45e9 fix: refresh access token expiry 60m (#14331)
* fix: refresh access token expiry 60m

* fix: exception filters logging request

* fix: never auto accept domain for managed users
2024-04-04 09:07:43 -03:00
a8d8c992a4 chore: added email and company form to cal.ai form (#14306)
* added email and company form

* i18n

* feat: add guest email and company

* chore: update and format prompt

* chore: hint var

* chore: fix err message

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Udit Takkar <udit222001@gmail.com>
2024-04-04 11:57:43 +00:00
Hariom Balhara 99f9a121cb fix: Show proper error when API_V2_URL variable is missing (#14269) 2024-04-04 11:51:39 +00:00
Crowdin Bot 6a0e2392fe New Crowdin translations by Github Action 2024-04-04 11:49:00 +00:00
Joe Au-Yeung addec46feb chore: Remove payment keys from appKeysSchema (#14298)
* Remove payment keys from appKeysSchema

* Update .env.example
2024-04-04 11:44:16 +00:00
Crowdin Bot a4fe9a51b5 New Crowdin translations by Github Action 2024-04-04 11:41:42 +00:00
Richard Poelderl 78b8bbb5aa update docs for yarn.lock management (#14230) 2024-04-04 08:37:15 -03:00
Lauris Skraucis d7ece4f583 Revert "chore: downgrade react query atoms (#14328)" (#14332)
This reverts commit 8fc9b9ae9d.
2024-04-04 10:55:15 +00:00
Lauris Skraucis 8fc9b9ae9d chore: downgrade react query atoms (#14328)
* refactor: downgrade atoms react-query to v4

* driveby: prettier format file

* refactor: react-query isPending to isLoading

* chore: bump atoms version

* refactor: isPending -> isLoading

* chore: bump version
2024-04-04 11:28:35 +01:00
Udit Takkar d1175bf7de fix: pass begin_message for updating LLM (#14327) 2024-04-04 11:14:19 +01:00
Crowdin Bot 3900453482 New Crowdin translations by Github Action 2024-04-04 09:46:04 +00:00
Morganandsupalarry 68e7e76b31 feat: list managed users of an oauth client (#14330)
* feat: list managed users with apiv2 api

* refactor: isPending -> isLoading

* Revert "refactor: isPending -> isLoading"

This reverts commit dc2108e05508070185d57633346f3c532458e2a8.

---------

Co-authored-by: supalarry <laurisskraucis@gmail.com>
2024-04-04 09:41:47 +00:00
zomars 986d196a37 hotfix: lazy icon dev performance 2024-04-03 22:14:30 -07:00
d30fb213d2 fix: Add adminReview option to allow impersonating org members (#14275)
* added layouts for moving teams step

* added admin section for org creation step

* extended org creation to also move existing teams

* wip

* wip

* wip

* further changes

* Add checkout for org in onboarding

* Fix ts errors

* Self review feedback

* Self review addressed

* Fix unit tests

* Fix ts error

* Seans feedback addressed

* feat: fix correct accounts pending

* fix: unit tests for new invite member permissions

* tests: org admin onboarding tests for existing user

* tests: Inital user self serve flow

* chore: update teamAndUserFixture to create X amount of teams

* chore: add testId to card actionButton

* test: add test-Id to continue or checkout button

* tests: add tests for migrating existing teams

* feat: match new designs

* fix: isAdminCheck

* chore: fix pricing copy

* fix: flacky tests

* Fix tests?

* More test fixes

* Check all checkboxes

* Fix type error

* Fix failing test and typescript issues

* Fix unpaid org allowing auto-add users

* Add self-serve flag

* Skip tests

* Add adminReview option

* Fixes

* Add server checks for impersonation

* Improved error

* feat: extract - and disalow updating from org uset list if not reviewed

---------

Co-authored-by: Peer Richelsen <peer@cal.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: sean-brydon <sean@cal.com>
Co-authored-by: Joe Au-Yeung <j.auyeung419@gmail.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
2024-04-04 04:48:41 +00:00
3f02de8214 chore: icon cleanup (#13770)
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
2024-04-03 23:23:57 +00:00
Omar López 7421cea0da fixes: API builds after rename (#14319) 2024-04-04 00:12:09 +01:00
Lauris Skraucis 507daa6a0c chore: publish atoms on npm and bump version (#14309) 2024-04-03 18:37:00 +03:00
Crowdin Bot 61f68af435 New Crowdin translations by Github Action 2024-04-03 13:32:23 +00:00
b3f4cfab32 feat: new org onboarding wizard (#13139)
* added layouts for moving teams step

* added admin section for org creation step

* extended org creation to also move existing teams

* wip

* wip

* wip

* further changes

* Add checkout for org in onboarding

* Fix ts errors

* Self review feedback

* Self review addressed

* Fix unit tests

* Fix ts error

* Seans feedback addressed

* feat: fix correct accounts pending

* fix: unit tests for new invite member permissions

* tests: org admin onboarding tests for existing user

* tests: Inital user self serve flow

* chore: update teamAndUserFixture to create X amount of teams

* chore: add testId to card actionButton

* test: add test-Id to continue or checkout button

* tests: add tests for migrating existing teams

* feat: match new designs

* fix: isAdminCheck

* chore: fix pricing copy

* fix: flacky tests

* Fix tests?

* More test fixes

* Check all checkboxes

* Fix type error

* Fix failing test and typescript issues

* Fix unpaid org allowing auto-add users

* Add self-serve flag

* Skip tests

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Hariom <hariombalhara@gmail.com>
Co-authored-by: sean-brydon <sean@cal.com>
Co-authored-by: Joe Au-Yeung <j.auyeung419@gmail.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: Omar López <zomars@me.com>
2024-04-03 18:53:02 +05:30
Morgan c3b1de498d fix: org guard did not have user org profile + missing db read in selectedCalendarRepo (#14303)
* fix: user did not contain profile with next-auth-guard

* fix: missing dbread instance
2024-04-03 10:49:19 +03:00
Erik cdf059361e chore: Tidy up unused variables, etc (#14289) 2024-04-02 22:06:42 +00:00
Alex van AndelandUdit Takkar 2aada9a7da chore: Move avatars using cursor (#14257)
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-04-02 12:59:39 -07:00
Erik b96786125c v3.9.4
release.yml / release (push) Canceled after 0s
2024-04-02 15:32:21 -03:00
Erik 9451f38cec chore: SDK Fixes and add swagger docs (#14218)
* chore: fixes and add swagger docs

* chore: add script
2024-04-02 18:31:01 +00:00
Keith Williams 306f53dec7 fix: Typo in OAuth clients (#14292)
* fix: Typo in OAuth clients

* Update index.tsx
2024-04-02 15:02:26 -03:00
Erik db865a680f chore: hotfixes (#14297) 2024-04-02 17:48:48 +00:00
Rajiv Sahal aca552c9f7 fix: Booker atom modal not rendering for mobile devices (#14278)
* make sure modal always appears

* separate event form wrappers into two components

* platform hook to fetch event type by id

* testing useGetEventTypeById hook

* add useGetEventTypeById to monorepo exports

* use platform hook for event type by id
2024-04-02 20:41:28 +03:00
Erik b202c4e79f chore: sentry dsn (#14295) 2024-04-02 17:01:00 +00:00
Peer Richelsen 972ce6911a Update Shell.tsx (#14293) 2024-04-02 16:23:03 +01:00
Peer Richelsen 751530b460 Create README.md 2024-04-02 15:25:02 +01:00
3b263e9282 feat: v2 event-types POST, PATCH, DELETE endpoints (#14237)
* create event type: use createEventType from trpc handler

* create event type: simplify handler input and create e2e test

* update event type + test

* delete end point

* check if owner owns event type

* fix: starting v2

* fixes

* remove unused enum

* fix TS issue for update input

* docs refresh

* docs

* docs

* simplify event types docs

* docs refactor

* fix: check if user already has event type with existing slug

* refactor update into a service

* fix tests

* remove unused import

* update create event link

* refactor: create event handler process passed locations

* move input out of folder

* reuse eventtype location for inputs

* docs: example POST event-type return

---------

Co-authored-by: Rajiv Sahal <sahalrajiv-extc@atharvacoe.ac.in>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2024-04-02 15:50:12 +02:00
Hariom BalharaandUdit Takkar 2cbf9e1ac8 fix: Show proper error in case the team can't be published because requestedSlug isn't set (#14283)
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-04-02 12:44:39 +00:00
Udit TakkarandCarina Wollendorfer 6283535d6a chore: add more duration options for multiple duration events (#14162)
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
2024-04-02 12:43:24 +00:00
Amit SharmaandUdit Takkar a03ae81509 fix: cancellation seems to be using slug instead of title (#14195)
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-04-02 12:42:48 +00:00
949 changed files with 22895 additions and 7153 deletions
+1
View File
@@ -38,6 +38,7 @@ BASECAMP3_USER_AGENT=
DAILY_API_KEY=
DAILY_SCALE_PLAN=''
DAILY_WEBHOOK_SECRET=''
DAILY_MEETING_ENDED_WEBHOOK_SECRET=''
# - GOOGLE CALENDAR/MEET/LOGIN
# Needed to enable Google Calendar integration and Login with Google
+10 -3
View File
@@ -169,8 +169,7 @@ STRIPE_WEBHOOK_SECRET=
STRIPE_WEBHOOK_SECRET_APPS=
STRIPE_PRIVATE_KEY=
STRIPE_CLIENT_ID=
PAYMENT_FEE_FIXED=
PAYMENT_FEE_PERCENTAGE=
# Use for internal Public API Keys and optional
API_KEY_PREFIX=cal_
@@ -213,6 +212,10 @@ E2E_TEST_MAILHOG_ENABLED=
# **********************************************************************************************************
# Cloudflare Turnstile
NEXT_PUBLIC_CLOUDFLARE_SITEKEY=
CLOUDFLARE_TURNSTILE_SECRET=
# Set the following value to true if you wish to enable Team Impersonation
NEXT_PUBLIC_TEAM_IMPERSONATION=false
@@ -237,7 +240,7 @@ CSP_POLICY=
# Vercel Edge Config
EDGE_CONFIG=
NEXT_PUBLIC_MINUTES_TO_BOOK=5 # Minutes
NEXT_PUBLIC_MINUTES_TO_BOOK=5 # Minutes
NEXT_PUBLIC_BOOKER_NUMBER_OF_DAYS_TO_LOAD=0 # Override the booker to only load X number of days worth of data
# Control time intervals on a user's Schedule availability
@@ -344,6 +347,10 @@ SENTRY_DISABLE_SERVER_WEBPACK_PLUGIN=1
# api v2
NEXT_PUBLIC_API_V2_URL="http://localhost:5555/api/v2"
# Tasker features
TASKER_ENABLE_WEBHOOKS=0
TASKER_ENABLE_EMAILS=0
# Ratelimiting via unkey
UNKEY_ROOT_KEY=
+1
View File
@@ -4,3 +4,4 @@ node_modules
**/**/public
packages/prisma/zod
apps/web/public/embed
packages/ui/components/icon/dynamicIconImports.tsx
@@ -0,0 +1,70 @@
name: Production Build
on:
workflow_call:
env:
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }}
E2E_TEST_APPLE_CALENDAR_EMAIL: ${{ secrets.E2E_TEST_APPLE_CALENDAR_EMAIL }}
E2E_TEST_APPLE_CALENDAR_PASSWORD: ${{ secrets.E2E_TEST_APPLE_CALENDAR_PASSWORD }}
E2E_TEST_CALCOM_QA_EMAIL: ${{ secrets.E2E_TEST_CALCOM_QA_EMAIL }}
E2E_TEST_CALCOM_QA_PASSWORD: ${{ secrets.E2E_TEST_CALCOM_QA_PASSWORD }}
E2E_TEST_CALCOM_QA_GCAL_CREDENTIALS: ${{ secrets.E2E_TEST_CALCOM_QA_GCAL_CREDENTIALS }}
E2E_TEST_CALCOM_GCAL_KEYS: ${{ secrets.E2E_TEST_CALCOM_GCAL_KEYS }}
GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
GOOGLE_LOGIN_ENABLED: ${{ vars.CI_GOOGLE_LOGIN_ENABLED }}
NEXTAUTH_SECRET: ${{ secrets.CI_NEXTAUTH_SECRET }}
NEXTAUTH_URL: ${{ secrets.CI_NEXTAUTH_URL }}
NEXT_PUBLIC_IS_E2E: ${{ vars.CI_NEXT_PUBLIC_IS_E2E }}
NEXT_PUBLIC_STRIPE_PUBLIC_KEY: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PUBLIC_KEY }}
NEXT_PUBLIC_WEBAPP_URL: ${{ vars.CI_NEXT_PUBLIC_WEBAPP_URL }}
NEXT_PUBLIC_WEBSITE_URL: ${{ vars.CI_NEXT_PUBLIC_WEBSITE_URL }}
PAYMENT_FEE_FIXED: ${{ vars.CI_PAYMENT_FEE_FIXED }}
PAYMENT_FEE_PERCENTAGE: ${{ vars.CI_PAYMENT_FEE_PERCENTAGE }}
SAML_ADMINS: ${{ secrets.CI_SAML_ADMINS }}
SAML_DATABASE_URL: ${{ secrets.CI_SAML_DATABASE_URL }}
STRIPE_PRIVATE_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }}
STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }}
SENDGRID_API_KEY: ${{ secrets.CI_SENDGRID_API_KEY }}
SENDGRID_EMAIL: ${{ secrets.CI_SENDGRID_EMAIL }}
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
NEXT_PUBLIC_API_V2_URL: ${{ secrets.CI_NEXT_PUBLIC_API_V2_URL }}
NEXT_PUBLIC_API_V2_ROOT_URL: ${{ secrets.CI_NEXT_PUBLIC_API_V2_ROOT_URL }}
NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED: ${{ vars.CI_NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED }}
jobs:
build:
name: Build API V1
runs-on: buildjet-4vcpu-ubuntu-2204
timeout-minutes: 30
services:
postgres:
image: postgres:13
credentials:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: calendso
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- uses: actions/checkout@v3
- uses: ./.github/actions/dangerous-git-checkout
- uses: ./.github/actions/yarn-install
- uses: ./.github/actions/cache-db
- run: |
export NODE_OPTIONS="--max_old_space_size=8192"
yarn turbo run build --filter=@calcom/api...
shell: bash
+24
View File
@@ -0,0 +1,24 @@
name: Cron - changeTimeZone
on:
# "Scheduled workflows run on the latest commit on the default or base branch."
# — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule
schedule:
# Runs "At every full hour." (see https://crontab.guru)
- cron: "0 * * * *"
jobs:
cron-scheduleEmailReminders:
env:
APP_URL: ${{ secrets.APP_URL }}
CRON_API_KEY: ${{ secrets.CRON_API_KEY }}
runs-on: ubuntu-latest
steps:
- name: cURL request
if: ${{ env.APP_URL && env.CRON_API_KEY }}
run: |
curl ${{ secrets.APP_URL }}/api/cron/changeTimeZone \
-X POST \
-H 'content-type: application/json' \
-H 'authorization: ${{ secrets.CRON_API_KEY }}' \
-sSf
+2
View File
@@ -62,6 +62,7 @@ jobs:
env:
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
DAILY_API_KEY: ${{ secrets.CI_DAILY_API_KEY }}
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }}
DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }}
@@ -91,6 +92,7 @@ jobs:
SENDGRID_EMAIL: ${{ secrets.CI_SENDGRID_EMAIL }}
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED: ${{ vars.CI_NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED }}
- name: Upload Test Results
if: ${{ always() }}
uses: actions/upload-artifact@v4
+2
View File
@@ -49,6 +49,7 @@ jobs:
env:
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
DAILY_API_KEY: ${{ secrets.CI_DAILY_API_KEY }}
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }}
DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }}
@@ -73,6 +74,7 @@ jobs:
SENDGRID_EMAIL: ${{ secrets.CI_SENDGRID_EMAIL }}
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED: ${{ vars.CI_NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED }}
- name: Upload Test Results
if: ${{ always() }}
uses: actions/upload-artifact@v4
+2
View File
@@ -55,6 +55,7 @@ jobs:
env:
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
DAILY_API_KEY: ${{ secrets.CI_DAILY_API_KEY }}
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }}
DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }}
@@ -80,6 +81,7 @@ jobs:
SENDGRID_EMAIL: ${{ secrets.CI_SENDGRID_EMAIL }}
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED: ${{ vars.CI_NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED }}
- name: Upload Test Results
if: ${{ always() }}
uses: actions/upload-artifact@v4
+2
View File
@@ -53,6 +53,7 @@ jobs:
env:
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
DAILY_API_KEY: ${{ secrets.CI_DAILY_API_KEY }}
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }}
DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }}
@@ -82,6 +83,7 @@ jobs:
SENDGRID_EMAIL: ${{ secrets.CI_SENDGRID_EMAIL }}
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED: ${{ vars.CI_NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED }}
- name: Upload Test Results
if: ${{ always() }}
uses: actions/upload-artifact@v4
+8 -1
View File
@@ -56,6 +56,13 @@ jobs:
uses: ./.github/workflows/production-build.yml
secrets: inherit
build-api-v1:
name: Production build
needs: [changes]
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
uses: ./.github/workflows/api-v1-production-build.yml
secrets: inherit
build-without-database:
name: Production build (without database)
needs: [changes]
@@ -99,7 +106,7 @@ jobs:
secrets: inherit
required:
needs: [changes, lint, type-check, test, build, e2e, e2e-embed, e2e-embed-react, e2e-app-store]
needs: [changes, lint, type-check, test, build, build-api-v1, e2e, e2e-embed, e2e-embed-react, e2e-app-store]
if: always()
runs-on: buildjet-4vcpu-ubuntu-2204
steps:
@@ -6,6 +6,7 @@ on:
env:
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
DAILY_API_KEY: ${{ secrets.CI_DAILY_API_KEY }}
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }}
E2E_TEST_APPLE_CALENDAR_EMAIL: ${{ secrets.E2E_TEST_APPLE_CALENDAR_EMAIL }}
@@ -31,6 +32,7 @@ env:
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
NEXT_PUBLIC_API_V2_URL: ${{ secrets.CI_NEXT_PUBLIC_API_V2_URL }}
NEXT_PUBLIC_API_V2_ROOT_URL: ${{ secrets.CI_NEXT_PUBLIC_API_V2_ROOT_URL }}
NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED: ${{ vars.CI_NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED }}
jobs:
+2
View File
@@ -6,6 +6,7 @@ on:
env:
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
DAILY_API_KEY: ${{ secrets.CI_DAILY_API_KEY }}
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }}
E2E_TEST_APPLE_CALENDAR_EMAIL: ${{ secrets.E2E_TEST_APPLE_CALENDAR_EMAIL }}
@@ -35,6 +36,7 @@ env:
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
NEXT_PUBLIC_API_V2_URL: ${{ secrets.CI_NEXT_PUBLIC_API_V2_URL }}
NEXT_PUBLIC_API_V2_ROOT_URL: ${{ secrets.CI_NEXT_PUBLIC_API_V2_ROOT_URL }}
NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED: ${{ vars.CI_NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED }}
jobs:
+1
View File
@@ -17,3 +17,4 @@ packages/prisma/zod
packages/prisma/enums
apps/web/public/embed
apps/api/v2/swagger/documentation.json
packages/ui/components/icon/dynamicIconImports.tsx
+13 -2
View File
@@ -13,8 +13,6 @@ Contributions are what makes the open source community such an amazing place to
- Issues from non-core members automatically receive the `🚨 needs approval` label.
- We greatly value new feature ideas. To ensure consistency in the product's direction, they undergo review and approval.
## Priorities
<table>
@@ -178,11 +176,15 @@ If you get errors, be sure to fix them before committing.
Do not commit your `yarn.lock` unless you've made changes to the `package.json`. If you've already committed `yarn.lock` unintentionally, follow these steps to undo:
If your last commit has the `yarn.lock` file alongside other files and you only wish to uncommit the `yarn.lock`:
```bash
git checkout HEAD~1 yarn.lock
git commit -m "Revert yarn.lock changes"
```
_NB_: You may have to bypass the pre-commit hook with by appending `--no-verify` to the git commit
If you've pushed the commit with the `yarn.lock`:
1. Correct the commit locally using the above method.
2. Carefully force push:
@@ -194,26 +196,35 @@ If `yarn.lock` was committed a while ago and there have been several commits sin
1. **Checkout a Previous Version**:
- Find the commit hash before the `yarn.lock` was unintentionally committed. You can do this by viewing the Git log:
```bash
git log yarn.lock
```
- Once you have identified the commit hash, use it to checkout the previous version of `yarn.lock`:
```bash
git checkout <commit_hash> yarn.lock
```
2. **Commit the Reverted Version**:
- After checking out the previous version of the `yarn.lock`, commit this change:
```bash
git commit -m "Revert yarn.lock to its state before unintended changes"
```
3. **Proceed with Caution**:
- If you need to push this change, first pull the latest changes from your remote branch to ensure you're not overwriting other recent changes:
```bash
git pull origin <your-branch-name>
```
- Then push the updated branch:
```bash
git push origin <your-branch-name>
```
Lastly, make sure to keep the branches updated (e.g. click the `Update branch` button on GitHub PR).
+2 -2
View File
@@ -113,7 +113,7 @@ To get a local copy up and running, please follow these simple steps.
Here is what you need to be able to run Cal.com.
- Node.js (Version: >=18.x)
- PostgreSQL
- PostgreSQL (Version: >=13.x)
- Yarn _(recommended)_
> If you want to enable any of the available integrations, you may want to obtain additional credentials for each one. More details on this can be found below under the [integrations section](#integrations).
@@ -210,7 +210,7 @@ echo 'NEXT_PUBLIC_DEBUG=1' >> .env
![image](https://user-images.githubusercontent.com/39329182/236612291-51d87f69-6dc1-4a23-bf4d-1ca1754e0a35.png)
5. Now extract all the info and add it to your DATABASE_URL. The url would look something like this
`postgresql://postgres:postgres@localhost:5432/Your-DB-Name`.
`postgresql://postgres:postgres@localhost:5432/Your-DB-Name`. The port is configurable and does not have to be 5432.
</details>
@@ -0,0 +1,23 @@
import { get } from "@vercel/edge-config";
import type { NextMiddleware } from "next-api-middleware";
const safeGet = async <T = unknown>(key: string): Promise<T | undefined> => {
try {
return get<T>(key);
} catch (error) {
// Don't crash if EDGE_CONFIG env var is missing
}
};
export const config = { matcher: "/:path*" };
export const checkIsInMaintenanceMode: NextMiddleware = async (req, res, next) => {
const isInMaintenanceMode = await safeGet<boolean>("isInMaintenanceMode");
if (isInMaintenanceMode) {
return res
.status(503)
.json({ message: "API is currently under maintenance. Please try again at a later time." });
}
await next();
};
+1 -2
View File
@@ -1,7 +1,6 @@
import type { NextMiddleware } from "next-api-middleware";
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import { API_KEY_RATE_LIMIT } from "@calcom/lib/rateLimit";
export const rateLimitApiKey: NextMiddleware = async (req, res, next) => {
if (!req.query.apiKey) return res.status(401).json({ message: "No apiKey provided" });
@@ -11,7 +10,7 @@ export const rateLimitApiKey: NextMiddleware = async (req, res, next) => {
identifier: req.query.apiKey as string,
rateLimitingType: "api",
onRateLimiterResponse: (response) => {
res.setHeader("X-RateLimit-Limit", API_KEY_RATE_LIMIT);
res.setHeader("X-RateLimit-Limit", response.limit);
res.setHeader("X-RateLimit-Remaining", response.remaining);
res.setHeader("X-RateLimit-Reset", response.reset);
},
@@ -2,6 +2,7 @@ import { label } from "next-api-middleware";
import { addRequestId } from "./addRequestid";
import { captureErrors } from "./captureErrors";
import { checkIsInMaintenanceMode } from "./checkIsInMaintenanceMode";
import { extendRequest } from "./extendRequest";
import {
HTTP_POST,
@@ -24,6 +25,7 @@ const middleware = {
HTTP_POST,
HTTP_DELETE,
addRequestId,
checkIsInMaintenanceMode,
verifyApiKey,
rateLimitApiKey,
extendRequest,
@@ -36,6 +38,7 @@ type Middleware = keyof typeof middleware;
const middlewareOrder = [
// The order here, determines the order of execution
"checkIsInMaintenanceMode",
"extendRequest",
"captureErrors",
"verifyApiKey",
-2
View File
@@ -82,7 +82,6 @@ export const schemaUserBaseBodyParams = User.pick({
brandColor: true,
darkBrandColor: true,
allowDynamicBooking: true,
away: true,
role: true,
// @note: disallowing avatar changes via API for now. We can add it later if needed. User should upload image via UI.
// avatar: true,
@@ -171,7 +170,6 @@ export const schemaUserReadPublic = User.pick({
brandColor: true,
darkBrandColor: true,
allowDynamicBooking: true,
away: true,
createdDate: true,
verified: true,
invitedTo: true,
@@ -1,18 +0,0 @@
import type { NextApiRequest } from "next";
import { HttpError } from "@calcom/lib/http-error";
import prisma from "@calcom/prisma";
import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt";
async function authMiddleware(req: NextApiRequest) {
const { userId, isAdmin } = req;
const { id } = schemaQueryIdParseInt.parse(req.query);
if (isAdmin) return;
const eventType = await prisma.eventType.findFirst({
where: { id, users: { some: { id: userId } } },
});
if (!eventType) throw new HttpError({ statusCode: 403, message: "Forbidden" });
}
export default authMiddleware;
@@ -93,6 +93,7 @@ async function checkPermissions<T extends BaseEventTypeCheckPermissions>(
await canAccessTeamEventOrThrow(req, {
in: [MembershipRole.OWNER, MembershipRole.ADMIN, MembershipRole.MEMBER],
});
return true;
}
if (eventType?.userId === req.userId) return true; // is owner.
throw new HttpError({ statusCode: 403, message: "Forbidden" });
@@ -4,11 +4,8 @@ import { defaultHandler, defaultResponder } from "@calcom/lib/server";
import { withMiddleware } from "~/lib/helpers/withMiddleware";
import authMiddleware from "./_auth-middleware";
export default withMiddleware()(
defaultResponder(async (req: NextApiRequest, res: NextApiResponse) => {
await authMiddleware(req);
return defaultHandler({
GET: import("./_get"),
PATCH: import("./_patch"),
@@ -26,7 +26,7 @@ export default async function checkTeamEventEditPermission(
if (!membership?.role || !["ADMIN", "OWNER"].includes(membership.role)) {
throw new HttpError({
statusCode: 401,
statusCode: 403,
message: "No permission to operate on event-type for this team",
});
}
@@ -1,7 +1,7 @@
import type { Prisma } from "@prisma/client";
import type { NextApiRequest } from "next";
import { purchaseTeamSubscription } from "@calcom/features/ee/teams/lib/payments";
import { purchaseTeamOrOrgSubscription } from "@calcom/features/ee/teams/lib/payments";
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server";
@@ -105,10 +105,11 @@ export async function patchHandler(req: NextApiRequest) {
};
delete data.slug;
if (IS_TEAM_BILLING_ENABLED) {
const checkoutSession = await purchaseTeamSubscription({
const checkoutSession = await purchaseTeamOrOrgSubscription({
teamId: _team.id,
seats: _team.members.length,
userId,
pricePerSeat: null,
});
if (!checkoutSession.url)
throw new TRPCError({
+3 -1
View File
@@ -2,6 +2,7 @@ import type { NextApiRequest } from "next";
import { getStripeCustomerIdFromUserId } from "@calcom/app-store/stripepayment/lib/customer";
import stripe from "@calcom/app-store/stripepayment/lib/server";
import { IS_PRODUCTION } from "@calcom/lib/constants";
import { IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants";
import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server";
@@ -197,8 +198,9 @@ const generateTeamCheckoutSession = async ({
customer_update: {
address: "auto",
},
// Disabled when testing locally as usually developer doesn't setup Tax in Stripe Test mode
automatic_tax: {
enabled: true,
enabled: IS_PRODUCTION,
},
metadata: {
pendingPaymentTeamId,
+1 -1
View File
@@ -7,7 +7,7 @@ import { createMocks } from "node-mocks-http";
import { describe, expect, test, vi } from "vitest";
import dayjs from "@calcom/dayjs";
import sendPayload from "@calcom/features/webhooks/lib/sendPayload";
import sendPayload from "@calcom/features/webhooks/lib/sendOrSchedulePayload";
import { ErrorCode } from "@calcom/lib/errorCodes";
import { buildBooking, buildEventType, buildWebhook } from "@calcom/lib/test/builder";
import prisma from "@calcom/prisma";
@@ -0,0 +1,143 @@
import prismaMock from "../../../../../../../tests/libs/__mocks__/prismaMock";
import type { Request, Response } from "express";
import type { NextApiRequest, NextApiResponse } from "next";
import { createMocks } from "node-mocks-http";
import { describe, expect, test } from "vitest";
import { buildEventType } from "@calcom/lib/test/builder";
import { MembershipRole } from "@calcom/prisma/enums";
import handler from "../../../../pages/api/event-types/[id]/_get";
type CustomNextApiRequest = NextApiRequest & Request;
type CustomNextApiResponse = NextApiResponse & Response;
describe("GET /api/event-types/[id]", () => {
describe("Errors", () => {
test("Returns 403 if user not admin/team member/event owner", async () => {
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
body: {},
query: {
id: 123456,
},
});
prismaMock.eventType.findUnique.mockResolvedValue(
buildEventType({
id: 123456,
userId: 444444,
})
);
req.userId = 333333;
await handler(req, res);
expect(res.statusCode).toBe(403);
});
});
describe("Success", async () => {
test("Returns event type if user is admin", async () => {
const eventTypeId = 123456;
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
body: {},
query: {
id: eventTypeId,
},
});
prismaMock.eventType.findUnique.mockResolvedValue(
buildEventType({
id: eventTypeId,
})
);
req.isAdmin = true;
req.userId = 333333;
await handler(req, res);
expect(res.statusCode).toBe(200);
expect(JSON.parse(res._getData()).event_type.id).toEqual(eventTypeId);
});
test("Returns event type if user is in team associated with event type", async () => {
const eventTypeId = 123456;
const teamId = 9999;
const userId = 333333;
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
body: {},
query: {
id: eventTypeId,
},
});
prismaMock.eventType.findUnique.mockResolvedValue(
buildEventType({
id: eventTypeId,
teamId,
})
);
prismaMock.team.findFirst.mockResolvedValue({
id: teamId,
members: [
{
userId,
},
],
});
req.isAdmin = false;
req.userId = userId;
await handler(req, res);
expect(res.statusCode).toBe(200);
expect(JSON.parse(res._getData()).event_type.id).toEqual(eventTypeId);
expect(prismaMock.team.findFirst).toHaveBeenCalledWith({
where: {
id: teamId,
members: {
some: {
userId: req.userId,
role: {
in: [MembershipRole.OWNER, MembershipRole.ADMIN, MembershipRole.MEMBER],
},
},
},
},
});
});
test("Returns event type if user is the event type owner", async () => {
const eventTypeId = 123456;
const userId = 333333;
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
body: {},
query: {
id: eventTypeId,
},
});
prismaMock.eventType.findUnique.mockResolvedValue(
buildEventType({
id: eventTypeId,
userId,
scheduleId: 1111,
})
);
req.isAdmin = false;
req.userId = userId;
await handler(req, res);
expect(res.statusCode).toBe(200);
expect(JSON.parse(res._getData()).event_type.id).toEqual(eventTypeId);
expect(prismaMock.team.findFirst).not.toHaveBeenCalled();
});
});
});
+2 -1
View File
@@ -7,7 +7,8 @@
"paths": {
"~/*": ["*"],
"@prisma/client/*": ["@calcom/prisma/client/*"]
}
},
"experimentalDecorators": true
},
"include": [
"next-env.d.ts",
+1 -1
View File
@@ -7,7 +7,7 @@ LOG_LEVEL=
NEXTAUTH_SECRET=
DATABASE_URL=
JWT_SECRET=
SENTRY_DNS=
SENTRY_DSN=
# KEEP THIS EMPTY, DISABLE SENTRY CLIENT INSIDE OF LIBRARIES USED BY APIv2
NEXT_PUBLIC_SENTRY_DSN=
+1 -1
View File
@@ -7,7 +7,7 @@
"plugins": [
{
"name": "@nestjs/swagger",
"options": { "dtoFileNameSuffix": ".input.ts", "classValidatorShim": true }
"options": { "dtoFileNameSuffix": [".input.ts", ".output.ts", ".dto.ts"], "classValidatorShim": true }
}
]
}
+4
View File
@@ -1,6 +1,10 @@
import { getEnv } from "@/env";
import { Controller, Get, Version, VERSION_NEUTRAL } from "@nestjs/common";
import { ApiTags as DocsTags, ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
@Controller()
@DocsTags("Health - development only")
@DocsExcludeController(getEnv("NODE_ENV") === "production")
export class AppController {
@Get("health")
@Version(VERSION_NEUTRAL)
+1 -1
View File
@@ -8,7 +8,7 @@ export class AppLoggerMiddleware implements NestMiddleware {
private logger = new Logger("HTTP");
use(request: Request, response: Response, next: NextFunction): void {
const { ip, method, protocol, originalUrl, path: url } = request;
const { ip, method, originalUrl } = request;
const userAgent = request.get("user-agent") || "";
response.on("close", () => {
+5
View File
@@ -1,4 +1,5 @@
import { AppLoggerMiddleware } from "@/app.logger.middleware";
import { RewriterMiddleware } from "@/app.rewrites.middleware";
import appConfig from "@/config/app";
import { AuthModule } from "@/modules/auth/auth.module";
import { EndpointsModule } from "@/modules/endpoints.module";
@@ -6,6 +7,7 @@ import { JwtModule } from "@/modules/jwt/jwt.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { MiddlewareConsumer, Module, NestModule } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { RouterModule } from "@nestjs/core";
import { AppController } from "./app.controller";
@@ -34,11 +36,14 @@ import { AppController } from "./app.controller";
EndpointsModule,
AuthModule,
JwtModule,
//register prefix for all routes in EndpointsModule
RouterModule.register([{ path: "/v2", module: EndpointsModule }]),
],
controllers: [AppController],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer): void {
consumer.apply(AppLoggerMiddleware).forRoutes("*");
consumer.apply(RewriterMiddleware).forRoutes("/");
}
}
@@ -0,0 +1,18 @@
import { Injectable, NestMiddleware } from "@nestjs/common";
import { Request, Response } from "express";
@Injectable()
export class RewriterMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: () => void) {
if (req.url.startsWith("/api/v2")) {
req.url = req.url.replace("/api/v2", "/v2");
}
if (req.url.startsWith("/v2/ee")) {
req.url = req.url.replace("/v2/ee", "/v2");
}
if (req.url.includes("reccuring")) {
req.url = req.url.replace("reccuring", "recurring");
}
next();
}
}
+5 -7
View File
@@ -11,6 +11,8 @@ import * as Sentry from "@sentry/node";
import * as cookieParser from "cookie-parser";
import helmet from "helmet";
import { X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
import { TRPCExceptionFilter } from "./filters/trpc-exception.filter";
export const bootstrap = (app: NestExpressApplication): NestExpressApplication => {
@@ -26,7 +28,7 @@ export const bootstrap = (app: NestExpressApplication): NestExpressApplication =
app.enableCors({
origin: "*",
methods: ["GET", "PATCH", "DELETE", "HEAD", "POST", "PUT", "OPTIONS"],
allowedHeaders: ["Accept", "Authorization", "Content-Type", "Origin"],
allowedHeaders: [X_CAL_CLIENT_ID, X_CAL_SECRET_KEY, "Accept", "Authorization", "Content-Type", "Origin"],
maxAge: 86_400,
});
@@ -44,9 +46,9 @@ export const bootstrap = (app: NestExpressApplication): NestExpressApplication =
})
);
if (process.env.SENTRY_DNS) {
if (process.env.SENTRY_DSN) {
Sentry.init({
dsn: getEnv("SENTRY_DNS"),
dsn: getEnv("SENTRY_DSN"),
});
}
@@ -58,10 +60,6 @@ export const bootstrap = (app: NestExpressApplication): NestExpressApplication =
app.useGlobalFilters(new HttpExceptionFilter());
app.useGlobalFilters(new TRPCExceptionFilter());
app.setGlobalPrefix("api", {
exclude: [{ path: "health", method: RequestMethod.GET }],
});
app.use(cookieParser());
return app;
+4 -2
View File
@@ -11,8 +11,10 @@ const loadConfig = (): AppConfig => {
port: Number(getEnv("API_PORT", "5555")),
path: getEnv("API_URL", "http://localhost"),
url: `${getEnv("API_URL", "http://localhost")}${
process.env.API_PORT ? `:${Number(getEnv("API_PORT", "5555"))}` : ""
}/api/v2`,
process.env.API_PORT && getEnv("NODE_ENV", "development") === "development"
? `:${Number(getEnv("API_PORT", "5555"))}`
: ""
}/v2`,
},
db: {
readUrl: getEnv("DATABASE_READ_URL"),
@@ -1,4 +1,5 @@
import { BookingsController } from "@/ee/bookings/controllers/bookings.controller";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
@@ -7,7 +8,7 @@ import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule, TokensModule],
providers: [TokensRepository, OAuthFlowService],
providers: [TokensRepository, OAuthFlowService, OAuthClientRepository],
controllers: [BookingsController],
})
export class BookingsModule {}
@@ -1,7 +1,11 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { SchedulesRepository } from "@/ee/schedules/schedules.repository";
import { GetBookingOutput } from "@/ee/bookings/outputs/get-booking.output";
import { GetBookingsOutput } from "@/ee/bookings/outputs/get-bookings.output";
import { CreateScheduleInput } from "@/ee/schedules/inputs/create-schedule.input";
import { SchedulesModule } from "@/ee/schedules/schedules.module";
import { SchedulesService } from "@/ee/schedules/services/schedules.service";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { AvailabilitiesModule } from "@/modules/availabilities/availabilities.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { UsersModule } from "@/modules/users/users.module";
@@ -11,17 +15,12 @@ import { Test } from "@nestjs/testing";
import { User } from "@prisma/client";
import * as request from "supertest";
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { withAccessTokenAuth } from "test/utils/withAccessTokenAuth";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
import {
getAllUserBookings,
handleNewBooking,
getBookingInfo,
handleNewRecurringBooking,
handleInstantMeeting,
} from "@calcom/platform-libraries";
import { handleNewBooking } from "@calcom/platform-libraries";
import { ApiSuccessResponse, ApiResponse } from "@calcom/platform-types";
describe("Bookings Endpoints", () => {
@@ -30,28 +29,50 @@ describe("Bookings Endpoints", () => {
let userRepositoryFixture: UserRepositoryFixture;
let bookingsRepositoryFixture: BookingsRepositoryFixture;
let schedulesService: SchedulesService;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
const userEmail = "bookings-controller-e2e@api.com";
let user: User;
let eventTypeId: number;
let createdBooking: Awaited<ReturnType<typeof handleNewBooking>>;
beforeAll(async () => {
const moduleRef = await withAccessTokenAuth(
userEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, AvailabilitiesModule, UsersModule],
providers: [SchedulesRepository, SchedulesService],
imports: [AppModule, PrismaModule, AvailabilitiesModule, UsersModule, SchedulesModule],
})
).compile();
)
.overrideGuard(PermissionsGuard)
.useValue({
canActivate: () => true,
})
.compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
schedulesService = moduleRef.get<SchedulesService>(SchedulesService);
user = await userRepositoryFixture.create({
email: userEmail,
});
const userSchedule: CreateScheduleInput = {
name: "working time",
timeZone: "Europe/Rome",
isDefault: true,
};
await schedulesService.createUserSchedule(user.id, userSchedule);
const event = await eventTypesRepositoryFixture.create(
{ title: "peer coding", slug: "peer-coding", length: 60 },
user.id
);
eventTypeId = event.id;
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
@@ -64,13 +85,23 @@ describe("Bookings Endpoints", () => {
});
it("should create a booking", async () => {
const bookingStart = "2023-05-25T09:30:00.000Z";
const bookingEnd = "2023-05-25T10:30:00.000Z";
const bookingEventTypeId = 7;
const bookingTimeZone = "Europe/Londom";
const bookingStart = "2040-05-21T09:30:00.000Z";
const bookingEnd = "2040-05-21T10:30:00.000Z";
const bookingEventTypeId = eventTypeId;
const bookingTimeZone = "Europe/London";
const bookingLanguage = "en";
const bookingHashedLink = "";
const bookingMetadata = {};
const bookingResponses = {
name: "tester",
email: "tester@example.com",
location: {
value: "link",
optionValue: "",
},
notes: "test",
guests: [],
};
const body = {
start: bookingStart,
@@ -80,10 +111,11 @@ describe("Bookings Endpoints", () => {
language: bookingLanguage,
metadata: bookingMetadata,
hashedLink: bookingHashedLink,
responses: bookingResponses,
};
return request(app.getHttpServer())
.post("/api/v2/ee/bookings")
.post("/v2/bookings")
.send(body)
.expect(201)
.then(async (response) => {
@@ -91,8 +123,8 @@ describe("Bookings Endpoints", () => {
response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.user.email).toBeDefined();
expect(responseBody.data.user.email).toEqual(userEmail);
expect(responseBody.data.userPrimaryEmail).toBeDefined();
expect(responseBody.data.userPrimaryEmail).toEqual(userEmail);
expect(responseBody.data.id).toBeDefined();
expect(responseBody.data.uid).toBeDefined();
expect(responseBody.data.startTime).toEqual(bookingStart);
@@ -106,10 +138,10 @@ describe("Bookings Endpoints", () => {
it("should get bookings", async () => {
return request(app.getHttpServer())
.get("/api/v2/ee/bookings")
.get("/v2/bookings?filters[status]=upcoming")
.then((response) => {
const responseBody: ApiSuccessResponse<Awaited<ReturnType<typeof getAllUserBookings>>> =
response.body;
console.log("asap responseBody", JSON.stringify(response.body, null, 2));
const responseBody: GetBookingsOutput = response.body;
const fetchedBooking = responseBody.data.bookings[0];
expect(responseBody.data.bookings.length).toEqual(1);
@@ -121,16 +153,16 @@ describe("Bookings Endpoints", () => {
expect(fetchedBooking.uid).toEqual(createdBooking.uid);
expect(fetchedBooking.startTime).toEqual(createdBooking.startTime);
expect(fetchedBooking.endTime).toEqual(createdBooking.endTime);
expect(fetchedBooking.user?.email).toEqual(createdBooking.user.email);
expect(fetchedBooking.user?.email).toEqual(userEmail);
});
});
it("should get booking", async () => {
return request(app.getHttpServer())
.get(`/api/v2/ee/bookings/${createdBooking.uid}`)
.get(`/v2/bookings/${createdBooking.uid}`)
.then((response) => {
const responseBody: ApiSuccessResponse<Awaited<ReturnType<typeof getBookingInfo>>> = response.body;
const { bookingInfo } = responseBody.data;
const responseBody: GetBookingOutput = response.body;
const bookingInfo = responseBody.data;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
@@ -143,68 +175,71 @@ describe("Bookings Endpoints", () => {
});
});
it("should create a recurring booking", async () => {
const bookingStart = "2023-05-25T09:30:00.000Z";
const bookingEnd = "2023-05-25T10:30:00.000Z";
const bookingEventTypeId = 7;
const bookingTimeZone = "Europe/Londom";
const bookingLanguage = "en";
const bookingHashedLink = "";
const bookingRecurringCount = 5;
const currentBookingRecurringIndex = 0;
// note(Lauris) : found this test broken here - first thing to fix is that recurring endpoint accepts an array not 1 object.
// it("should create a recurring booking", async () => {
// const bookingStart = "2040-05-25T09:30:00.000Z";
// const bookingEnd = "2040-05-25T10:30:00.000Z";
// const bookingEventTypeId = 7;
// const bookingTimeZone = "Europe/London";
// const bookingLanguage = "en";
// const bookingHashedLink = "";
// const bookingRecurringCount = 5;
// const currentBookingRecurringIndex = 0;
const body = {
start: bookingStart,
end: bookingEnd,
eventTypeId: bookingEventTypeId,
timeZone: bookingTimeZone,
language: bookingLanguage,
metadata: {},
hashedLink: bookingHashedLink,
recurringCount: bookingRecurringCount,
currentRecurringIndex: currentBookingRecurringIndex,
};
// const body = {
// start: bookingStart,
// end: bookingEnd,
// eventTypeId: bookingEventTypeId,
// timeZone: bookingTimeZone,
// language: bookingLanguage,
// metadata: {},
// hashedLink: bookingHashedLink,
// recurringCount: bookingRecurringCount,
// currentRecurringIndex: currentBookingRecurringIndex,
// };
return request(app.getHttpServer())
.post("/api/v2/ee/bookings/reccuring")
.send(body)
.expect(201)
.then((response) => {
const responseBody: ApiResponse<Awaited<ReturnType<typeof handleNewRecurringBooking>>> =
response.body;
// return request(app.getHttpServer())
// .post("/v2/bookings/recurring")
// .send(body)
// .expect(201)
// .then((response) => {
// const responseBody: ApiResponse<Awaited<ReturnType<typeof handleNewRecurringBooking>>> =
// response.body;
expect(responseBody.status).toEqual("recurring");
});
});
// expect(responseBody.status).toEqual("recurring");
// });
// });
it("should create an instant booking", async () => {
const bookingStart = "2023-05-25T09:30:00.000Z";
const bookingEnd = "2023-05-25T10:30:00.000Z";
const bookingEventTypeId = 7;
const bookingTimeZone = "Europe/Londom";
const bookingLanguage = "en";
const bookingHashedLink = "";
// note(Lauris) : found this test broken here - first thing to fix is that the eventTypeId must be team event type, because
// instant bookings only work for teams.
// it("should create an instant booking", async () => {
// const bookingStart = "2040-05-25T09:30:00.000Z";
// const bookingEnd = "2040-25T10:30:00.000Z";
// const bookingEventTypeId = 7;
// const bookingTimeZone = "Europe/London";
// const bookingLanguage = "en";
// const bookingHashedLink = "";
const body = {
start: bookingStart,
end: bookingEnd,
eventTypeId: bookingEventTypeId,
timeZone: bookingTimeZone,
language: bookingLanguage,
metadata: {},
hashedLink: bookingHashedLink,
};
// const body = {
// start: bookingStart,
// end: bookingEnd,
// eventTypeId: bookingEventTypeId,
// timeZone: bookingTimeZone,
// language: bookingLanguage,
// metadata: {},
// hashedLink: bookingHashedLink,
// };
return request(app.getHttpServer())
.post("/api/v2/ee/bookings/instant")
.send(body)
.expect(201)
.then((response) => {
const responseBody: ApiResponse<Awaited<ReturnType<typeof handleInstantMeeting>>> = response.body;
// return request(app.getHttpServer())
// .post("/v2/bookings/instant")
// .send(body)
// .expect(201)
// .then((response) => {
// const responseBody: ApiResponse<Awaited<ReturnType<typeof handleInstantMeeting>>> = response.body;
expect(responseBody.status).toEqual("instant");
});
});
// expect(responseBody.status).toEqual("instant");
// });
// });
it("should cancel a booking", async () => {
const bookingId = createdBooking.id;
@@ -215,7 +250,7 @@ describe("Bookings Endpoints", () => {
};
return request(app.getHttpServer())
.post(`/api/v2/ee/bookings/${bookingId}/cancel`)
.post(`/v2/bookings/${bookingId}/cancel`)
.send(body)
.expect(201)
.then((response) => {
@@ -1,9 +1,12 @@
import { CreateBookingInput } from "@/ee/bookings/inputs/create-booking.input";
import { CreateReccuringBookingInput } from "@/ee/bookings/inputs/create-reccuring-booking.input";
import { CreateRecurringBookingInput } from "@/ee/bookings/inputs/create-recurring-booking.input";
import { GetBookingOutput } from "@/ee/bookings/outputs/get-booking.output";
import { GetBookingsOutput } from "@/ee/bookings/outputs/get-bookings.output";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import {
@@ -13,6 +16,7 @@ import {
Req,
InternalServerErrorException,
Body,
Headers,
HttpException,
Param,
Get,
@@ -20,11 +24,13 @@ import {
NotFoundException,
UseGuards,
} from "@nestjs/common";
import { ApiQuery, ApiTags as DocsTags } from "@nestjs/swagger";
import { User } from "@prisma/client";
import { Request } from "express";
import { NextApiRequest } from "next/types";
import { BOOKING_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
import { X_CAL_CLIENT_ID } from "@calcom/platform-constants";
import { BOOKING_READ, SUCCESS_STATUS } from "@calcom/platform-constants";
import {
getAllUserBookings,
getBookingInfo,
@@ -38,30 +44,57 @@ import {
handleNewRecurringBooking,
handleInstantMeeting,
} from "@calcom/platform-libraries";
import { GetBookingsInput, CancelBookingInput } from "@calcom/platform-types";
import { GetBookingsInput, CancelBookingInput, Status } from "@calcom/platform-types";
import { ApiResponse } from "@calcom/platform-types";
import { PrismaClient } from "@calcom/prisma";
type BookingRequest = Request & {
userId?: number;
};
type OAuthRequestParams = {
platformClientId: string;
platformRescheduleUrl: string;
platformCancelUrl: string;
platformBookingUrl: string;
platformBookingLocation?: string;
arePlatformEmailsEnabled: boolean;
};
const DEFAULT_PLATFORM_PARAMS = {
platformClientId: "",
platformCancelUrl: "",
platformRescheduleUrl: "",
platformBookingUrl: "",
arePlatformEmailsEnabled: false,
platformBookingLocation: undefined,
};
@Controller({
path: "ee/bookings",
path: "/bookings",
version: "2",
})
@UseGuards(PermissionsGuard)
@DocsTags("Bookings")
export class BookingsController {
private readonly logger = new Logger("ee bookings controller");
constructor(
private readonly oAuthFlowService: OAuthFlowService,
private readonly prismaReadService: PrismaReadService
private readonly prismaReadService: PrismaReadService,
private readonly oAuthClientRepository: OAuthClientRepository
) {}
// note(Rajiv): currently this endpoint is atoms only
@Get("/")
@UseGuards(AccessTokenGuard)
@Permissions([BOOKING_READ])
@ApiQuery({ name: "filters[status]", enum: Status, required: true })
@ApiQuery({ name: "limit", type: "number", required: false })
@ApiQuery({ name: "cursor", type: "number", required: false })
async getBookings(
@GetUser() user: User,
@Query() queryParams: GetBookingsInput
): Promise<ApiResponse<unknown>> {
): Promise<GetBookingsOutput> {
const { filters, cursor, limit } = queryParams;
const bookings = await getAllUserBookings({
bookingListingByStatus: filters.status,
@@ -80,9 +113,8 @@ export class BookingsController {
};
}
// note(Rajiv): currently this endpoint is atoms only
@Get("/:bookingUid")
async getBooking(@Param("bookingUid") bookingUid: string): Promise<ApiResponse<unknown>> {
async getBooking(@Param("bookingUid") bookingUid: string): Promise<GetBookingOutput> {
const { bookingInfo } = await getBookingInfo(bookingUid);
if (!bookingInfo) {
@@ -95,7 +127,6 @@ export class BookingsController {
};
}
// note(Rajiv): currently this endpoint is atoms only
@Get("/:bookingUid/reschedule")
async getBookingForReschedule(@Param("bookingUid") bookingUid: string): Promise<ApiResponse<unknown>> {
const booking = await getBookingForReschedule(bookingUid);
@@ -111,15 +142,17 @@ export class BookingsController {
}
@Post("/")
@Permissions([BOOKING_WRITE])
async createBooking(
@Req() req: Request & { userId?: number },
@Body() _: CreateBookingInput
@Req() req: BookingRequest,
@Body() body: CreateBookingInput,
@Headers(X_CAL_CLIENT_ID) clientId?: string
): Promise<ApiResponse<unknown>> {
req.userId = await this.getOwnerId(req);
req.body = { ...req.body, noEmail: true };
const oAuthClientId = clientId?.toString();
const locationUrl = body.locationUrl;
try {
const booking = await handleNewBooking(req as unknown as NextApiRequest & { userId?: number });
const booking = await handleNewBooking(
await this.createNextApiBookingRequest(req, oAuthClientId, locationUrl)
);
return {
status: SUCCESS_STATUS,
data: booking,
@@ -131,17 +164,17 @@ export class BookingsController {
}
@Post("/:bookingId/cancel")
@Permissions([BOOKING_WRITE])
async cancelBooking(
@Req() req: Request & { userId?: number },
@Req() req: BookingRequest,
@Param("bookingId") bookingId: string,
@Body() body: CancelBookingInput
@Body() _: CancelBookingInput,
@Headers(X_CAL_CLIENT_ID) clientId?: string
): Promise<ApiResponse> {
const oAuthClientId = clientId?.toString();
if (bookingId) {
req.userId = await this.getOwnerId(req);
req.body = { ...body, id: parseInt(bookingId) };
try {
await handleCancelBooking(req as unknown as NextApiRequest & { userId?: number });
req.body.id = parseInt(bookingId);
await handleCancelBooking(await this.createNextApiBookingRequest(req, oAuthClientId));
return {
status: SUCCESS_STATUS,
};
@@ -154,17 +187,16 @@ export class BookingsController {
throw new InternalServerErrorException("Could not cancel booking.");
}
@Post("/reccuring")
@Permissions([BOOKING_WRITE])
async createReccuringBooking(
@Req() req: Request & { userId?: number },
@Body() _: CreateReccuringBookingInput[]
@Post("/recurring")
async createRecurringBooking(
@Req() req: BookingRequest,
@Body() _: CreateRecurringBookingInput[],
@Headers(X_CAL_CLIENT_ID) clientId?: string
): Promise<ApiResponse<BookingResponse[]>> {
req.userId = await this.getOwnerId(req);
req.body = { ...req.body, noEmail: true };
const oAuthClientId = clientId?.toString();
try {
const createdBookings: BookingResponse[] = await handleNewRecurringBooking(
req as unknown as NextApiRequest & { userId?: number }
await this.createNextApiBookingRequest(req, oAuthClientId)
);
return {
status: SUCCESS_STATUS,
@@ -177,16 +209,16 @@ export class BookingsController {
}
@Post("/instant")
@Permissions([BOOKING_WRITE])
async createInstantBooking(
@Req() req: Request & { userId?: number },
@Body() _: CreateBookingInput
@Req() req: BookingRequest,
@Body() _: CreateBookingInput,
@Headers(X_CAL_CLIENT_ID) clientId?: string
): Promise<ApiResponse<Awaited<ReturnType<typeof handleInstantMeeting>>>> {
req.userId = await this.getOwnerId(req);
req.body = { ...req.body, noEmail: true };
const oAuthClientId = clientId?.toString();
req.userId = (await this.getOwnerId(req)) ?? -1;
try {
const instantMeeting = await handleInstantMeeting(
req as unknown as NextApiRequest & { userId?: number }
await this.createNextApiBookingRequest(req, oAuthClientId)
);
return {
status: SUCCESS_STATUS,
@@ -208,6 +240,39 @@ export class BookingsController {
this.logger.error(err);
}
}
async getOAuthClientsParams(req: BookingRequest, clientId: string): Promise<OAuthRequestParams> {
const res = DEFAULT_PLATFORM_PARAMS;
try {
const client = await this.oAuthClientRepository.getOAuthClient(clientId);
// fetch oAuthClient from db and use data stored in db to set these values
if (client) {
res.platformClientId = clientId;
res.platformCancelUrl = client.bookingCancelRedirectUri ?? "";
res.platformRescheduleUrl = client.bookingRescheduleRedirectUri ?? "";
res.platformBookingUrl = client.bookingRedirectUri ?? "";
res.arePlatformEmailsEnabled = client.areEmailsEnabled ?? false;
}
return res;
} catch (err) {
this.logger.error(err);
return res;
}
}
async createNextApiBookingRequest(
req: BookingRequest,
oAuthClientId?: string,
platformBookingLocation?: string
): Promise<NextApiRequest & { userId?: number } & OAuthRequestParams> {
const userId = (await this.getOwnerId(req)) ?? -1;
const oAuthParams = oAuthClientId
? await this.getOAuthClientsParams(req, oAuthClientId)
: DEFAULT_PLATFORM_PARAMS;
Object.assign(req, { userId, ...oAuthParams, platformBookingLocation });
req.body = { ...req.body, noEmail: !oAuthParams.arePlatformEmailsEnabled };
return req as unknown as NextApiRequest & { userId?: number } & OAuthRequestParams;
}
}
function handleBookingErrors(err: Error | HttpError | unknown, type?: "recurring" | `instant`): void {
@@ -1,5 +1,44 @@
import { Transform } from "class-transformer";
import { IsBoolean, IsTimeZone, IsNumber, IsString, IsOptional, IsArray } from "class-validator";
import { Transform, Type } from "class-transformer";
import {
IsBoolean,
IsTimeZone,
IsNumber,
IsString,
IsOptional,
IsArray,
IsObject,
IsEmail,
ValidateNested,
} from "class-validator";
class Location {
@IsString()
optionValue!: string;
@IsString()
value!: string;
}
class Response {
@IsString()
name!: string;
@IsEmail()
email!: string;
@IsArray()
@IsString({ each: true })
guests!: string[];
@IsOptional()
@ValidateNested()
@Type(() => Location)
location?: Location;
@IsOptional()
@IsString()
notes?: string;
}
export class CreateBookingInput {
@IsString()
@@ -42,6 +81,7 @@ export class CreateBookingInput {
@IsOptional()
bookingUid?: string;
@IsObject()
metadata!: Record<string, string>;
@IsBoolean()
@@ -55,4 +95,11 @@ export class CreateBookingInput {
@IsString()
@IsOptional()
seatReferenceUid?: string;
@Type(() => Response)
responses!: Response;
@IsString()
@IsOptional()
locationUrl?: string;
}
@@ -1,9 +1,9 @@
import { CreateBookingInput } from "@/ee/bookings/inputs/create-booking.input";
import { IsBoolean, IsNumber, IsString, IsOptional, IsArray } from "class-validator";
import { IsBoolean, IsNumber, IsOptional } from "class-validator";
import type { AppsStatus } from "@calcom/platform-libraries";
export class CreateReccuringBookingInput extends CreateBookingInput {
export class CreateRecurringBookingInput extends CreateBookingInput {
@IsBoolean()
@IsOptional()
noEmail?: boolean;
@@ -0,0 +1,173 @@
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import {
IsString,
IsEnum,
IsInt,
IsOptional,
IsObject,
ValidateNested,
IsArray,
IsUrl,
IsDateString,
IsEmail,
} from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
class Metadata {
@IsUrl()
videoCallUrl!: string;
}
class Location {
@IsString()
optionValue!: string;
@IsString()
value!: string;
}
class Response {
@IsString()
name!: string;
@IsEmail()
email!: string;
@IsString()
notes!: string;
@IsArray()
@IsString({ each: true })
guests!: string[];
@ValidateNested()
@Type(() => Location)
location!: Location;
}
class User {
@IsInt()
id!: number;
@IsString()
name!: string | null;
@IsEmail()
email!: string;
@IsString()
username!: string | null;
@IsString()
timeZone!: string;
}
class Attendee {
@IsString()
name!: string;
@IsEmail()
email!: string;
@IsString()
timeZone!: string;
}
class EventType {
@IsOptional()
@IsString()
eventName!: string | null;
@IsString()
slug!: string;
@IsOptional()
@IsString()
timeZone!: string | null;
}
class GetBookingData {
@IsString()
title!: string;
@IsInt()
id!: number;
@IsString()
uid!: string;
@IsString()
description!: string | null;
@IsObject()
customInputs!: any;
@IsOptional()
@IsString()
smsReminderNumber!: string | null;
@IsOptional()
@IsString()
recurringEventId!: string | null;
@IsDateString()
startTime!: Date;
@IsDateString()
endTime!: Date;
@IsUrl()
location!: string | null;
@IsString()
status!: string;
metadata!: Metadata | any;
@IsOptional()
@IsString()
cancellationReason!: string | null;
@ValidateNested()
@Type(() => Response)
responses!: Response | any;
@IsOptional()
@IsString()
rejectionReason!: string | null;
@IsString()
@IsEmail()
userPrimaryEmail!: string | null;
@ValidateNested()
@Type(() => User)
user!: User | null;
@ValidateNested()
@Type(() => Attendee)
@IsArray()
attendees!: Attendee[];
@IsInt()
eventTypeId!: number | null;
@ValidateNested()
@Type(() => EventType)
eventType!: EventType | null;
}
export class GetBookingOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
type: GetBookingData,
})
@ValidateNested()
@Type(() => GetBookingData)
data!: GetBookingData;
}
@@ -0,0 +1,229 @@
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import {
IsString,
IsEnum,
IsInt,
IsBoolean,
IsUrl,
IsOptional,
IsObject,
ValidateNested,
IsArray,
IsDateString,
IsEmail,
} from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
enum Status {
CANCELLED = "CANCELLED",
REJECTED = "REJECTED",
ACCEPTED = "ACCEPTED",
PENDING = "PENDING",
AWAITING_HOST = "AWAITING_HOST",
}
class Attendee {
@IsInt()
id!: number;
@IsEmail()
email!: string;
@IsString()
name!: string;
@IsString()
timeZone!: string;
@IsString()
locale!: string | null;
@IsInt()
bookingId!: number | null;
}
class EventType {
@IsString()
@IsOptional()
slug?: string;
@IsOptional()
@IsInt()
id?: number;
@IsOptional()
@IsString()
eventName?: string | null;
@IsInt()
price!: number;
@IsOptional()
recurringEvent?: any;
@IsString()
currency!: string;
@IsObject()
metadata!: any;
@IsBoolean()
@IsOptional()
seatsShowAttendees?: boolean | undefined | null;
@IsBoolean()
@IsOptional()
seatsShowAvailabilityCount?: boolean | undefined | null;
@IsOptional()
team?: any | null;
}
class Reference {
@IsInt()
id!: number;
@IsString()
type!: string;
@IsString()
uid!: string;
@IsOptional()
@IsString()
meetingId?: string | null;
@IsOptional()
@IsString()
thirdPartyRecurringEventId?: string | null;
@IsString()
meetingPassword!: string | null;
@IsOptional()
@IsString()
meetingUrl?: string | null;
@IsInt()
bookingId!: number | null;
@IsEmail()
externalCalendarId!: string | null;
@IsOptional()
deleted?: any;
@IsInt()
credentialId!: number | null;
}
class User {
@IsInt()
id!: number;
@IsString()
name!: string | null;
@IsEmail()
email!: string;
}
class GetBookingsDataEntry {
@IsInt()
id!: number;
@IsString()
title!: string;
@IsOptional()
@IsEmail()
userPrimaryEmail?: string | null;
@IsString()
description!: string | null;
@IsObject()
customInputs!: object | any;
@IsDateString()
startTime!: string;
@IsDateString()
endTime!: string;
@ValidateNested({ each: true })
@Type(() => Attendee)
@IsArray()
attendees!: Attendee[];
metadata!: any;
@IsString()
uid!: string;
@IsOptional()
@IsString()
recurringEventId!: string | null;
@IsUrl()
location!: string | null;
@ValidateNested()
@Type(() => EventType)
eventType!: EventType;
@IsEnum(Status)
status!: "CANCELLED" | "REJECTED" | "ACCEPTED" | "PENDING" | "AWAITING_HOST";
@IsBoolean()
paid!: boolean;
@IsArray()
payment!: any[];
@ValidateNested()
@Type(() => Reference)
@IsArray()
references!: Reference[];
@IsBoolean()
isRecorded!: boolean;
@IsArray()
seatsReferences!: any[];
@ValidateNested()
@Type(() => User)
user!: User | null;
@IsOptional()
rescheduled?: any;
}
class GetBookingsData {
@ValidateNested()
@Type(() => GetBookingsDataEntry)
@IsArray()
bookings!: GetBookingsDataEntry[];
@IsArray()
recurringInfo!: any[];
@IsInt()
nextCursor!: number | null;
}
export class GetBookingsOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
type: GetBookingsData,
})
@ValidateNested()
@Type(() => GetBookingsData)
data!: GetBookingsData;
}
@@ -1,30 +1,29 @@
import { GetBusyTimesOutput } from "@/ee/calendars/outputs/busy-times.output";
import { ConnectedCalendarsOutput } from "@/ee/calendars/outputs/connected-calendars.output";
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard";
import { UserWithProfile } from "@/modules/users/users.repository";
import { Controller, Get, Logger, UseGuards, Query } from "@nestjs/common";
import { Controller, Get, UseGuards, Query } from "@nestjs/common";
import { ApiTags as DocsTags } from "@nestjs/swagger";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { ConnectedDestinationCalendars } from "@calcom/platform-libraries";
import { CalendarBusyTimesInput } from "@calcom/platform-types";
import { ApiResponse } from "@calcom/platform-types";
import { EventBusyDate } from "@calcom/types/Calendar";
@Controller({
path: "ee/calendars",
path: "/calendars",
version: "2",
})
@UseGuards(AccessTokenGuard)
@DocsTags("Calendars")
export class CalendarsController {
private readonly logger = new Logger("ee overlay calendars controller");
constructor(private readonly calendarsService: CalendarsService) {}
@Get("/busy-times")
async getBusyTimes(
@Query() queryParams: CalendarBusyTimesInput,
@GetUser() user: UserWithProfile
): Promise<ApiResponse<EventBusyDate[]>> {
): Promise<GetBusyTimesOutput> {
const { loggedInUsersTz, dateFrom, dateTo, calendarsToLoad } = queryParams;
if (!dateFrom || !dateTo) {
return {
@@ -48,7 +47,7 @@ export class CalendarsController {
}
@Get("/")
async getCalendars(@GetUser("id") userId: number): Promise<ApiResponse<ConnectedDestinationCalendars>> {
async getCalendars(@GetUser("id") userId: number): Promise<ConnectedCalendarsOutput> {
const calendars = await this.calendarsService.getCalendars(userId);
return {
@@ -0,0 +1,28 @@
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsArray, IsDate, IsEnum, IsOptional, IsString, ValidateNested } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
export class BusyTimesOutput {
@IsDate()
start!: Date;
@IsDate()
end!: Date;
@IsOptional()
@IsString()
source?: string | null;
}
export class GetBusyTimesOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ValidateNested()
@Type(() => BusyTimesOutput)
@IsArray()
data!: BusyTimesOutput[];
}
@@ -0,0 +1,218 @@
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import {
IsArray,
IsBoolean,
IsEmail,
IsEnum,
IsInt,
IsObject,
IsOptional,
IsString,
IsUrl,
ValidateNested,
} from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
class Integration {
@IsOptional()
@IsObject()
appData?: object | null;
@IsOptional()
@IsString()
dirName?: string;
@IsOptional()
@IsString()
__template?: string;
@IsString()
name!: string;
@IsString()
description!: string;
@IsOptional()
@IsBoolean()
installed?: boolean;
@IsString()
type!: string;
@IsOptional()
@IsString()
title?: string;
@IsString()
variant!: string;
@IsOptional()
@IsString()
category?: string;
@IsArray()
@IsString({ each: true })
categories!: string[];
@IsString()
logo!: string;
@IsString()
publisher!: string;
@IsString()
slug!: string;
@IsUrl()
url!: string;
@IsEmail()
email!: string;
@IsOptional()
@IsObject()
locationOption!: object | null;
}
class Primary {
@IsEmail()
externalId!: string;
@IsString()
@IsOptional()
integration?: string;
@IsOptional()
@IsEmail()
name?: string;
@IsBoolean()
primary!: boolean | null;
@IsBoolean()
readOnly!: boolean;
@IsEmail()
@IsOptional()
email?: string;
@IsBoolean()
isSelected!: boolean;
@IsInt()
credentialId!: number;
}
class Calendar {
@IsEmail()
externalId!: string;
@IsString()
@IsOptional()
integration?: string;
@IsEmail()
@IsOptional()
name?: string;
@IsOptional()
@IsBoolean()
primary?: boolean | null;
@IsBoolean()
readOnly!: boolean;
@IsEmail()
@IsOptional()
email?: string;
@IsBoolean()
isSelected!: boolean;
@IsInt()
credentialId!: number;
}
class ConnectedCalendar {
@ValidateNested()
@IsObject()
integration!: Integration;
@IsInt()
credentialId!: number;
@ValidateNested()
@IsObject()
@IsOptional()
primary?: Primary;
@ValidateNested({ each: true })
@IsArray()
@IsOptional()
calendars?: Calendar[];
}
class DestinationCalendar {
@IsInt()
id!: number;
@IsString()
integration!: string;
@IsEmail()
externalId!: string;
@IsEmail()
primaryEmail!: string | null;
@IsInt()
userId!: number | null;
@IsOptional()
@IsInt()
eventTypeId!: number | null;
@IsInt()
credentialId!: number | null;
@IsString()
@IsOptional()
name?: string | null;
@IsBoolean()
@IsOptional()
primary?: boolean;
@IsBoolean()
@IsOptional()
readOnly?: boolean;
@IsEmail()
@IsOptional()
email?: string;
@IsString()
@IsOptional()
integrationTitle?: string;
}
class ConnectedCalendarsData {
@ValidateNested({ each: true })
@IsArray()
connectedCalendars!: ConnectedCalendar[];
@ValidateNested()
@IsObject()
destinationCalendar!: DestinationCalendar;
}
export class ConnectedCalendarsOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ValidateNested()
@Type(() => ConnectedCalendarsData)
data!: ConnectedCalendarsData;
}
@@ -3,6 +3,7 @@ import {
CredentialsWithUserEmail,
} from "@/modules/credentials/credentials.repository";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { UsersRepository } from "@/modules/users/users.repository";
import {
Injectable,
@@ -23,7 +24,8 @@ export class CalendarsService {
constructor(
private readonly usersRepository: UsersRepository,
private readonly credentialsRepository: CredentialsRepository,
private readonly dbRead: PrismaReadService
private readonly dbRead: PrismaReadService,
private readonly dbWrite: PrismaWriteService
) {}
async getCalendars(userId: number) {
@@ -35,7 +37,7 @@ export class CalendarsService {
return getConnectedDestinationCalendars(
userWithCalendars,
false,
this.dbRead.prisma as unknown as PrismaClient
this.dbWrite.prisma as unknown as PrismaClient
);
}
@@ -1,6 +1,11 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { EventTypesModule } from "@/ee/event-types/event-types.module";
import { CreateEventTypeInput } from "@/ee/event-types/inputs/create-event-type.input";
import { UpdateEventTypeInput } from "@/ee/event-types/inputs/update-event-type.input";
import { GetEventTypePublicOutput } from "@/ee/event-types/outputs/get-event-type-public.output";
import { GetEventTypeOutput } from "@/ee/event-types/outputs/get-event-type.output";
import { GetEventTypesPublicOutput } from "@/ee/event-types/outputs/get-event-types-public.output";
import { HttpExceptionFilter } from "@/filters/http-exception.filter";
import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
@@ -61,7 +66,7 @@ describe("Event types Endpoints", () => {
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
const userEmail = "event-types-test-e2e@api.com";
const name = "bob";
const name = "bob-the-builder";
const username = name;
let eventType: EventType;
let user: User;
@@ -86,6 +91,7 @@ describe("Event types Endpoints", () => {
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);
@@ -95,17 +101,6 @@ describe("Event types Endpoints", () => {
username,
});
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
eventType = await eventTypesRepositoryFixture.create(
{
length: 60,
title: "peer coding session",
slug: "peer-coding",
},
user.id
);
await app.init();
});
@@ -129,6 +124,50 @@ describe("Event types Endpoints", () => {
expect(user).toBeDefined();
});
it("should create an event type", async () => {
const body: CreateEventTypeInput = {
title: "Test Event Type",
slug: "test-event-type",
description: "A description of the test event type.",
length: 60,
hidden: false,
locations: [
{
type: "Online",
link: "https://example.com/meet",
displayLocationPublicly: true,
},
],
};
return request(app.getHttpServer())
.post("/api/v2/event-types")
.send(body)
.expect(201)
.then(async (response) => {
const responseBody: ApiSuccessResponse<EventType> = response.body;
expect(responseBody.data).toHaveProperty("id");
expect(responseBody.data.title).toEqual(body.title);
eventType = responseBody.data;
});
});
it("should update event type", async () => {
const newTitle = "Updated title";
const body: UpdateEventTypeInput = {
title: newTitle,
};
return request(app.getHttpServer())
.patch(`/api/v2/event-types/${eventType.id}`)
.send(body)
.expect(200)
.then(async () => {
eventType.title = newTitle;
});
});
it(`/GET/:id`, async () => {
const response = await request(app.getHttpServer())
.get(`/api/v2/event-types/${eventType.id}`)
@@ -136,14 +175,49 @@ describe("Event types Endpoints", () => {
.set("Authorization", `Bearer whatever`)
.expect(200);
const responseBody: ApiSuccessResponse<EventType> = response.body;
const responseBody: GetEventTypeOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.id).toEqual(eventType.id);
expect(responseBody.data.title).toEqual(eventType.title);
expect(responseBody.data.slug).toEqual(eventType.slug);
expect(responseBody.data.userId).toEqual(user.id);
expect(responseBody.data.eventType.id).toEqual(eventType.id);
expect(responseBody.data.eventType.title).toEqual(eventType.title);
expect(responseBody.data.eventType.slug).toEqual(eventType.slug);
expect(responseBody.data.eventType.userId).toEqual(user.id);
});
it(`/GET/:username/public`, async () => {
const response = await request(app.getHttpServer())
.get(`/api/v2/event-types/${username}/public`)
// note: bearer token value mocked using "withAccessTokenAuth" for user which id is used when creating event type above
.set("Authorization", `Bearer whatever`)
.expect(200);
const responseBody: GetEventTypesPublicOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data?.length).toEqual(1);
expect(responseBody.data?.[0]?.id).toEqual(eventType.id);
expect(responseBody.data?.[0]?.title).toEqual(eventType.title);
expect(responseBody.data?.[0]?.slug).toEqual(eventType.slug);
expect(responseBody.data?.[0]?.length).toEqual(eventType.length);
});
it(`/GET/:username/:eventSlug/public`, async () => {
const response = await request(app.getHttpServer())
.get(`/api/v2/event-types/${username}/${eventType.slug}/public`)
// note: bearer token value mocked using "withAccessTokenAuth" for user which id is used when creating event type above
.set("Authorization", `Bearer whatever`)
.expect(200);
const responseBody: GetEventTypePublicOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data?.id).toEqual(eventType.id);
expect(responseBody.data?.title).toEqual(eventType.title);
expect(responseBody.data?.slug).toEqual(eventType.slug);
expect(responseBody.data?.length).toEqual(eventType.length);
});
it(`/GET/`, async () => {
@@ -154,10 +228,8 @@ describe("Event types Endpoints", () => {
.expect(200);
const responseBody: ApiSuccessResponse<EventTypesByViewer> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
console.log("asap responseBody.data", responseBody.data);
expect(responseBody.data.eventTypeGroups).toBeDefined();
expect(responseBody.data.eventTypeGroups).toBeDefined();
expect(responseBody.data.eventTypeGroups[0]).toBeDefined();
@@ -167,7 +239,7 @@ describe("Event types Endpoints", () => {
expect(responseBody.data.profiles?.[0]?.name).toEqual(name);
});
it(`/GET/:username/public`, async () => {
it(`/GET/public/:username/`, async () => {
const response = await request(app.getHttpServer())
.get(`/api/v2/event-types/${username}/public`)
// note: bearer token value mocked using "withAccessTokenAuth" for user which id is used when creating event type above
@@ -178,7 +250,6 @@ describe("Event types Endpoints", () => {
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
console.log("asap responseBody.data", responseBody.data);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.length).toEqual(1);
expect(responseBody.data[0].id).toEqual(eventType.id);
@@ -192,10 +263,18 @@ describe("Event types Endpoints", () => {
.expect(404);
});
it("should delete schedule", async () => {
return request(app.getHttpServer()).delete(`/api/v2/event-types/${eventType.id}`).expect(200);
});
afterAll(async () => {
await oauthClientRepositoryFixture.delete(oAuthClient.id);
await teamRepositoryFixture.delete(organization.id);
await eventTypesRepositoryFixture.delete(eventType.id);
try {
await eventTypesRepositoryFixture.delete(eventType.id);
} catch (e) {
// Event type might have been deleted by the test
}
try {
await userRepositoryFixture.delete(user.id);
} catch (e) {
@@ -1,27 +1,53 @@
import { CreateEventTypeInput } from "@/ee/event-types/inputs/create-event-type.input";
import { GetPublicEventTypeQueryParams } from "@/ee/event-types/inputs/get-public-event-type-query-params.input";
import { UpdateEventTypeInput } from "@/ee/event-types/inputs/update-event-type.input";
import { CreateEventTypeOutput } from "@/ee/event-types/outputs/create-event-type.output";
import { DeleteEventTypeOutput } from "@/ee/event-types/outputs/delete-event-type.output";
import { GetEventTypePublicOutput } from "@/ee/event-types/outputs/get-event-type-public.output";
import { GetEventTypeOutput } from "@/ee/event-types/outputs/get-event-type.output";
import { GetEventTypesPublicOutput } from "@/ee/event-types/outputs/get-event-types-public.output";
import { GetEventTypesOutput } from "@/ee/event-types/outputs/get-event-types.output";
import { UpdateEventTypeOutput } from "@/ee/event-types/outputs/update-event-type.output";
import { EventTypesService } from "@/ee/event-types/services/event-types.service";
import { ForAtom } from "@/lib/atoms/decorators/for-atom.decorator";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { UserWithProfile } from "@/modules/users/users.repository";
import { Controller, UseGuards, Get, Param, Post, Body, NotFoundException } from "@nestjs/common";
import { EventType } from "@prisma/client";
import {
Controller,
UseGuards,
Get,
Param,
Post,
Body,
NotFoundException,
Patch,
HttpCode,
HttpStatus,
Delete,
Query,
InternalServerErrorException,
} from "@nestjs/common";
import { ApiTags as DocsTags } from "@nestjs/swagger";
import { EventTypesByViewer } from "@calcom/lib";
import { EVENT_TYPE_READ, EVENT_TYPE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
import type { EventType as AtomEventType, EventTypesPublic } from "@calcom/platform-libraries";
import { getPublicEvent } from "@calcom/platform-libraries";
import { getEventTypesByViewer } from "@calcom/platform-libraries";
import { ApiResponse, ApiSuccessResponse } from "@calcom/platform-types";
import { PrismaClient } from "@calcom/prisma";
@Controller({
path: "event-types",
version: "2",
})
@UseGuards(PermissionsGuard)
@DocsTags("Event types")
export class EventTypesController {
constructor(private readonly eventTypesService: EventTypesService) {}
constructor(
private readonly eventTypesService: EventTypesService,
private readonly prismaReadService: PrismaReadService
) {}
@Post("/")
@Permissions([EVENT_TYPE_WRITE])
@@ -29,8 +55,8 @@ export class EventTypesController {
async createEventType(
@Body() body: CreateEventTypeInput,
@GetUser() user: UserWithProfile
): Promise<ApiResponse<EventType>> {
const eventType = await this.eventTypesService.createUserEventType(user.id, body);
): Promise<CreateEventTypeOutput> {
const eventType = await this.eventTypesService.createUserEventType(user, body);
return {
status: SUCCESS_STATUS,
@@ -43,12 +69,9 @@ export class EventTypesController {
@UseGuards(AccessTokenGuard)
async getEventType(
@Param("eventTypeId") eventTypeId: string,
@ForAtom() forAtom: boolean,
@GetUser() user: UserWithProfile
): Promise<ApiSuccessResponse<EventType | AtomEventType>> {
const eventType = forAtom
? await this.eventTypesService.getUserEventTypeForAtom(user, Number(eventTypeId))
: await this.eventTypesService.getUserEventType(user.id, Number(eventTypeId));
): Promise<GetEventTypeOutput> {
const eventType = await this.eventTypesService.getUserEventTypeForAtom(user, Number(eventTypeId));
if (!eventType) {
throw new NotFoundException(`Event type with id ${eventTypeId} not found`);
@@ -63,7 +86,7 @@ export class EventTypesController {
@Get("/")
@Permissions([EVENT_TYPE_READ])
@UseGuards(AccessTokenGuard)
async getEventTypes(@GetUser() user: UserWithProfile): Promise<ApiSuccessResponse<EventTypesByViewer>> {
async getEventTypes(@GetUser() user: UserWithProfile): Promise<GetEventTypesOutput> {
const eventTypes = await getEventTypesByViewer({
id: user.id,
profile: {
@@ -77,11 +100,37 @@ export class EventTypesController {
};
}
@Get("/:username/:eventSlug/public")
async getPublicEventType(
@Param("username") username: string,
@Param("eventSlug") eventSlug: string,
@Query() queryParams: GetPublicEventTypeQueryParams
): Promise<GetEventTypePublicOutput> {
try {
const event = await getPublicEvent(
username.toLowerCase(),
eventSlug,
queryParams.isTeamEvent,
queryParams.org || null,
this.prismaReadService.prisma as unknown as PrismaClient,
// We should be fine allowing unpublished orgs events to be servable through platform because Platform access is behind license
// If there is ever a need to restrict this, we can introduce a new query param `fromRedirectOfNonOrgLink`
true
);
return {
data: event,
status: SUCCESS_STATUS,
};
} catch (err) {
if (err instanceof Error) {
throw new NotFoundException(err.message);
}
}
throw new InternalServerErrorException("Could not find public event.");
}
@Get("/:username/public")
@Permissions([EVENT_TYPE_READ])
async getPublicEventTypes(
@Param("username") username: string
): Promise<ApiSuccessResponse<EventTypesPublic>> {
async getPublicEventTypes(@Param("username") username: string): Promise<GetEventTypesPublicOutput> {
const eventTypes = await this.eventTypesService.getEventTypesPublicByUsername(username);
return {
@@ -89,4 +138,41 @@ export class EventTypesController {
data: eventTypes,
};
}
@Patch("/:eventTypeId")
@Permissions([EVENT_TYPE_WRITE])
@UseGuards(AccessTokenGuard)
@HttpCode(HttpStatus.OK)
async updateEventType(
@Param("eventTypeId") eventTypeId: number,
@Body() body: UpdateEventTypeInput,
@GetUser() user: UserWithProfile
): Promise<UpdateEventTypeOutput> {
const eventType = await this.eventTypesService.updateEventType(eventTypeId, body, user);
return {
status: SUCCESS_STATUS,
data: eventType,
};
}
@Delete("/:eventTypeId")
@Permissions([EVENT_TYPE_WRITE])
@UseGuards(AccessTokenGuard)
async deleteEventType(
@Param("eventTypeId") eventTypeId: number,
@GetUser("id") userId: number
): Promise<DeleteEventTypeOutput> {
const eventType = await this.eventTypesService.deleteEventType(eventTypeId, userId);
return {
status: SUCCESS_STATUS,
data: {
id: eventType.id,
length: eventType.length,
slug: eventType.slug,
title: eventType.title,
},
};
}
}
@@ -3,12 +3,13 @@ import { EventTypesRepository } from "@/ee/event-types/event-types.repository";
import { EventTypesService } from "@/ee/event-types/services/event-types.service";
import { MembershipsModule } from "@/modules/memberships/memberships.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { SelectedCalendarsModule } from "@/modules/selected-calendars/selected-calendars.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule, MembershipsModule, TokensModule, UsersModule],
imports: [PrismaModule, MembershipsModule, TokensModule, UsersModule, SelectedCalendarsModule],
providers: [EventTypesRepository, EventTypesService],
controllers: [EventTypesController],
exports: [EventTypesService, EventTypesRepository],
@@ -1,8 +1,9 @@
import { CreateEventTypeInput } from "@/ee/event-types/inputs/create-event-type.input";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import type { PrismaClient } from "@calcom/prisma";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { UserWithProfile } from "@/modules/users/users.repository";
import { Injectable, NotFoundException } from "@nestjs/common";
import { Injectable } from "@nestjs/common";
import { getEventTypeById } from "@calcom/platform-libraries";
@@ -10,7 +11,10 @@ import { getEventTypeById } from "@calcom/platform-libraries";
export class EventTypesRepository {
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
async createUserEventType(userId: number, body: CreateEventTypeInput) {
async createUserEventType(
userId: number,
body: Pick<CreateEventTypeInput, "title" | "slug" | "length" | "hidden">
) {
return this.dbWrite.prisma.eventType.create({
data: {
...body,
@@ -41,22 +45,32 @@ export class EventTypesRepository {
isUserOrganizationAdmin: boolean,
eventTypeId: number
) {
try {
return getEventTypeById({
currentOrganizationId: user.movedToProfile?.organizationId || user.organizationId,
eventTypeId,
userId: user.id,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
prisma: this.dbRead.prisma,
isUserOrganizationAdmin,
});
} catch (error) {
throw new NotFoundException(`User with id ${user.id} has no event type with id ${eventTypeId}`);
}
return await getEventTypeById({
currentOrganizationId: user.movedToProfile?.organizationId || user.organizationId,
eventTypeId,
userId: user.id,
prisma: this.dbRead.prisma as unknown as PrismaClient,
isUserOrganizationAdmin,
isTrpcCall: true,
});
}
async getEventTypeById(eventTypeId: number) {
return this.dbRead.prisma.eventType.findUnique({ where: { id: eventTypeId } });
}
async getUserEventTypeBySlug(userId: number, slug: string) {
return this.dbRead.prisma.eventType.findUnique({
where: {
userId_slug: {
userId: userId,
slug: slug,
},
},
});
}
async deleteEventType(eventTypeId: number) {
return this.dbWrite.prisma.eventType.delete({ where: { id: eventTypeId } });
}
}
@@ -1,13 +1,53 @@
import { IsNumber, IsString, Min } from "class-validator";
import { EventTypeLocation } from "@/ee/event-types/inputs/event-type-location.input";
import { ApiProperty as DocsProperty, ApiHideProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsString, IsNumber, IsBoolean, IsOptional, ValidateNested, Min, IsArray } from "class-validator";
export const CREATE_EVENT_LENGTH_EXAMPLE = 60;
export const CREATE_EVENT_SLUG_EXAMPLE = "cooking-class";
export const CREATE_EVENT_TITLE_EXAMPLE = "Learn the secrets of masterchief!";
export const CREATE_EVENT_DESCRIPTION_EXAMPLE =
"Discover the culinary wonders of the Argentina by making the best flan ever!";
// note(Lauris): We will gradually expose more properties if any customer needs them.
// Just uncomment any below when requested.
export class CreateEventTypeInput {
@IsNumber()
@Min(1)
@DocsProperty({ example: CREATE_EVENT_LENGTH_EXAMPLE })
length!: number;
@IsString()
@DocsProperty({ example: CREATE_EVENT_SLUG_EXAMPLE })
slug!: string;
@IsString()
@DocsProperty({ example: CREATE_EVENT_TITLE_EXAMPLE })
title!: string;
@IsOptional()
@IsString()
@DocsProperty({ example: CREATE_EVENT_DESCRIPTION_EXAMPLE })
description?: string;
@IsOptional()
@IsBoolean()
@ApiHideProperty()
hidden?: boolean;
@IsOptional()
@ValidateNested({ each: true })
@Type(() => EventTypeLocation)
@IsArray()
locations?: EventTypeLocation[];
// @ApiHideProperty()
// @IsOptional()
// @IsNumber()
// teamId?: number;
// @ApiHideProperty()
// @IsOptional()
// @IsEnum(SchedulingType)
// schedulingType?: SchedulingType; -> import { SchedulingType } from "@/ee/event-types/inputs/enums/scheduling-type";
}
@@ -0,0 +1,7 @@
export enum Editable {
system = "system",
systemButOptional = "system-but-optional",
systemButHidden = "system-but-hidden",
user = "user",
userReadonly = "user-readonly",
}
@@ -0,0 +1,16 @@
export enum BaseField {
number = "number",
boolean = "boolean",
address = "address",
name = "name",
text = "text",
textarea = "textarea",
email = "email",
phone = "phone",
multiemail = "multiemail",
select = "select",
multiselect = "multiselect",
checkbox = "checkbox",
radio = "radio",
radioInput = "radioInput",
}
@@ -0,0 +1,9 @@
export enum Frequency {
YEARLY = 0,
MONTHLY = 1,
WEEKLY = 2,
DAILY = 3,
HOURLY = 4,
MINUTELY = 5,
SECONDLY = 6,
}
@@ -0,0 +1,5 @@
export enum PeriodType {
UNLIMITED = "UNLIMITED",
ROLLING = "ROLLING",
RANGE = "RANGE",
}
@@ -0,0 +1,5 @@
export enum SchedulingType {
ROUND_ROBIN = "ROUND_ROBIN",
COLLECTIVE = "COLLECTIVE",
MANAGED = "MANAGED",
}
@@ -0,0 +1,41 @@
import { ApiProperty as DocsProperty, ApiHideProperty } from "@nestjs/swagger";
import { IsString, IsNumber, IsBoolean, IsOptional, IsUrl } from "class-validator";
// note(Lauris): We will gradually expose more properties if any customer needs them.
// Just uncomment any below when requested.
export class EventTypeLocation {
@IsString()
@DocsProperty({ example: "link" })
type!: string;
@IsOptional()
@IsString()
@ApiHideProperty()
address?: string;
@IsOptional()
@IsUrl()
@DocsProperty({ example: "https://masterchief.com/argentina/flan/video/9129412" })
link?: string;
@IsOptional()
@IsBoolean()
@ApiHideProperty()
displayLocationPublicly?: boolean;
@IsOptional()
@IsString()
@ApiHideProperty()
hostPhoneNumber?: string;
@IsOptional()
@IsNumber()
@ApiHideProperty()
credentialId?: number;
@IsOptional()
@IsString()
@ApiHideProperty()
teamName?: string;
}
@@ -2,16 +2,7 @@ import { ApiProperty } from "@nestjs/swagger";
import { Transform } from "class-transformer";
import { IsBoolean, IsOptional, IsString } from "class-validator";
export class GetPublicEventInput {
@IsString()
@Transform(({ value }: { value: string }) => value.toLowerCase())
@ApiProperty({ required: true })
username!: string;
@IsString()
@ApiProperty({ required: true })
eventSlug!: string;
export class GetPublicEventTypeQueryParams {
@Transform(({ value }: { value: string }) => value === "true")
@IsBoolean()
@IsOptional()
@@ -0,0 +1,412 @@
import { Editable } from "@/ee/event-types/inputs/enums/editable";
import { BaseField } from "@/ee/event-types/inputs/enums/field-type";
import { Frequency } from "@/ee/event-types/inputs/enums/frequency";
import { EventTypeLocation } from "@/ee/event-types/inputs/event-type-location.input";
import { Type } from "class-transformer";
import {
IsString,
IsBoolean,
IsOptional,
ValidateNested,
Min,
IsInt,
IsEnum,
IsArray,
IsDate,
IsNumber,
} from "class-validator";
// note(Lauris): We will gradually expose more properties if any customer needs them.
// Just uncomment any below when requested. Go to bottom of file to see UpdateEventTypeInput.
class Option {
@IsString()
value!: string;
@IsString()
label!: string;
}
class Source {
@IsString()
id!: string;
@IsString()
type!: string;
@IsString()
label!: string;
@IsOptional()
@IsString()
editUrl?: string;
@IsOptional()
@IsBoolean()
fieldRequired?: boolean;
}
class View {
@IsString()
id!: string;
@IsString()
label!: string;
@IsOptional()
@IsString()
description?: string;
}
class OptionsInput {
@IsString()
type!: "address" | "text" | "phone";
@IsOptional()
@IsBoolean()
required?: boolean;
@IsOptional()
@IsString()
placeholder?: string;
}
class VariantField {
@IsString()
type!: BaseField;
@IsString()
name!: string;
@IsOptional()
@IsString()
label?: string;
@IsOptional()
@IsString()
labelAsSafeHtml?: string;
@IsOptional()
@IsString()
placeholder?: string;
@IsOptional()
@IsBoolean()
required?: boolean;
}
class Variant {
@ValidateNested({ each: true })
@Type(() => VariantField)
fields!: VariantField[];
}
class VariantsConfig {
variants!: Record<string, Variant>;
}
export class BookingField {
@IsEnum(BaseField)
type!: BaseField;
@IsString()
name!: string;
@IsOptional()
@ValidateNested({ each: true })
@Type(() => Option)
options?: Option[];
@IsOptional()
@IsString()
label?: string;
@IsOptional()
@IsString()
labelAsSafeHtml?: string;
@IsOptional()
@IsString()
defaultLabel?: string;
@IsOptional()
@IsString()
placeholder?: string;
@IsOptional()
@IsBoolean()
required?: boolean;
@IsOptional()
@IsString()
getOptionsAt?: string;
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => OptionsInput)
optionsInputs?: Record<string, OptionsInput>;
@IsOptional()
@IsString()
variant?: string;
@IsOptional()
@ValidateNested()
@Type(() => VariantsConfig)
variantsConfig?: VariantsConfig;
@IsOptional()
@ValidateNested({ each: true })
@Type(() => View)
views?: View[];
@IsOptional()
@IsBoolean()
hideWhenJustOneOption?: boolean;
@IsOptional()
@IsBoolean()
hidden?: boolean;
@IsOptional()
@IsEnum(Editable)
editable?: Editable;
@IsOptional()
@ValidateNested({ each: true })
@Type(() => Source)
sources?: Source[];
}
export class RecurringEvent {
@IsDate()
@IsOptional()
dtstart?: Date;
@IsInt()
interval!: number;
@IsInt()
count!: number;
@IsEnum(Frequency)
freq!: Frequency;
@IsDate()
@IsOptional()
until?: Date;
@IsString()
@IsOptional()
tzid?: string;
}
export class IntervalLimits {
@IsNumber()
@IsOptional()
PER_DAY?: number;
@IsNumber()
@IsOptional()
PER_WEEK?: number;
@IsNumber()
@IsOptional()
PER_MONTH?: number;
@IsNumber()
@IsOptional()
PER_YEAR?: number;
}
export class UpdateEventTypeInput {
@IsInt()
@Min(1)
@IsOptional()
length?: number;
@IsString()
@IsOptional()
slug?: string;
@IsString()
@IsOptional()
title?: string;
@IsString()
@IsOptional()
description?: string;
@IsBoolean()
@IsOptional()
hidden?: boolean;
@ValidateNested({ each: true })
@Type(() => EventTypeLocation)
@IsOptional()
locations?: EventTypeLocation[];
// @IsInt()
// @IsOptional()
// position?: number;
// @IsInt()
// @IsOptional()
// offsetStart?: number;
// @IsInt()
// @IsOptional()
// userId?: number;
// @IsInt()
// @IsOptional()
// profileId?: number;
// @IsInt()
// @IsOptional()
// teamId?: number;
// @IsString()
// @IsOptional()
// eventName?: string;
// @IsInt()
// @IsOptional()
// parentId?: number;
// @IsOptional()
// @IsArray()
// @ValidateNested({ each: true })
// @Type(() => BookingField)
// bookingFields?: BookingField[];
// @IsString()
// @IsOptional()
// timeZone?: string;
// @IsEnum(PeriodType)
// @IsOptional()
// periodType?: PeriodType; -> import { PeriodType } from "@/ee/event-types/inputs/enums/period-type";
// @IsDate()
// @IsOptional()
// periodStartDate?: Date;
// @IsDate()
// @IsOptional()
// periodEndDate?: Date;
// @IsInt()
// @IsOptional()
// periodDays?: number;
// @IsBoolean()
// @IsOptional()
// periodCountCalendarDays?: boolean;
// @IsBoolean()
// @IsOptional()
// lockTimeZoneToggleOnBookingPage?: boolean;
// @IsBoolean()
// @IsOptional()
// requiresConfirmation?: boolean;
// @IsBoolean()
// @IsOptional()
// requiresBookerEmailVerification?: boolean;
// @ValidateNested()
// @Type(() => RecurringEvent)
// @IsOptional()
// recurringEvent?: RecurringEvent;
// @IsBoolean()
// @IsOptional()
// disableGuests?: boolean;
// @IsBoolean()
// @IsOptional()
// hideCalendarNotes?: boolean;
// @IsInt()
// @Min(0)
// @IsOptional()
// minimumBookingNotice?: number;
// @IsInt()
// @IsOptional()
// beforeEventBuffer?: number;
// @IsInt()
// @IsOptional()
// afterEventBuffer?: number;
// @IsInt()
// @IsOptional()
// seatsPerTimeSlot?: number;
// @IsBoolean()
// @IsOptional()
// onlyShowFirstAvailableSlot?: boolean;
// @IsBoolean()
// @IsOptional()
// seatsShowAttendees?: boolean;
// @IsBoolean()
// @IsOptional()
// seatsShowAvailabilityCount?: boolean;
// @IsEnum(SchedulingType)
// @IsOptional()
// schedulingType?: SchedulingType; -> import { SchedulingType } from "@/ee/event-types/inputs/enums/scheduling-type";
// @IsInt()
// @IsOptional()
// scheduleId?: number;
// @IsInt()
// @IsOptional()
// price?: number;
// @IsString()
// @IsOptional()
// currency?: string;
// @IsInt()
// @IsOptional()
// slotInterval?: number;
// @IsString()
// @IsOptional()
// @IsUrl()
// successRedirectUrl?: string;
// @ValidateNested()
// @Type(() => IntervalLimits)
// @IsOptional()
// bookingLimits?: IntervalLimits;
// @ValidateNested()
// @Type(() => IntervalLimits)
// @IsOptional()
// durationLimits?: IntervalLimits;
// @IsBoolean()
// @IsOptional()
// isInstantEvent?: boolean;
// @IsBoolean()
// @IsOptional()
// assignAllTeamMembers?: boolean;
// @IsBoolean()
// @IsOptional()
// useEventTypeDestinationCalendarEmail?: boolean;
// @IsInt()
// @IsOptional()
// secondaryEmailId?: number;
}
@@ -0,0 +1,20 @@
import { EventTypeOutput } from "@/ee/event-types/outputs/event-type.output";
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
export class CreateEventTypeOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
type: EventTypeOutput,
})
@IsNotEmptyObject()
@ValidateNested()
@Type(() => EventTypeOutput)
data!: EventTypeOutput;
}
@@ -0,0 +1,38 @@
import {
CREATE_EVENT_LENGTH_EXAMPLE,
CREATE_EVENT_SLUG_EXAMPLE,
CREATE_EVENT_TITLE_EXAMPLE,
} from "@/ee/event-types/inputs/create-event-type.input";
import { ApiProperty } from "@nestjs/swagger";
import { ApiProperty as DocsProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsEnum, IsInt, IsString } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
class DeleteData {
@IsInt()
@DocsProperty({ example: 1 })
id!: number;
@IsInt()
@DocsProperty({ example: CREATE_EVENT_LENGTH_EXAMPLE })
length!: number;
@IsString()
@DocsProperty({ example: CREATE_EVENT_SLUG_EXAMPLE })
slug!: string;
@IsString()
@DocsProperty({ example: CREATE_EVENT_TITLE_EXAMPLE })
title!: string;
}
export class DeleteEventTypeOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@Type(() => DeleteData)
data!: DeleteData;
}
@@ -0,0 +1,227 @@
import {
CREATE_EVENT_DESCRIPTION_EXAMPLE,
CREATE_EVENT_LENGTH_EXAMPLE,
CREATE_EVENT_SLUG_EXAMPLE,
CREATE_EVENT_TITLE_EXAMPLE,
} from "@/ee/event-types/inputs/create-event-type.input";
import { PeriodType } from "@/ee/event-types/inputs/enums/period-type";
import { SchedulingType } from "@/ee/event-types/inputs/enums/scheduling-type";
import { EventTypeLocation } from "@/ee/event-types/inputs/event-type-location.input";
import {
BookingField,
IntervalLimits,
RecurringEvent,
} from "@/ee/event-types/inputs/update-event-type.input";
import { ApiProperty as DocsProperty, ApiHideProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import {
IsArray,
IsBoolean,
IsDate,
IsEnum,
IsInt,
IsJSON,
IsNumber,
IsOptional,
IsString,
ValidateNested,
} from "class-validator";
export class EventTypeOutput {
@IsInt()
@DocsProperty({ example: 1 })
id!: number;
@IsInt()
@DocsProperty({ example: CREATE_EVENT_LENGTH_EXAMPLE })
length!: number;
@IsString()
@DocsProperty({ example: CREATE_EVENT_SLUG_EXAMPLE })
slug!: string;
@IsString()
@DocsProperty({ example: CREATE_EVENT_TITLE_EXAMPLE })
title!: string;
@IsString()
@DocsProperty({ example: CREATE_EVENT_DESCRIPTION_EXAMPLE })
description!: string | null;
@IsBoolean()
@ApiHideProperty()
hidden!: boolean;
@ValidateNested({ each: true })
@Type(() => EventTypeLocation)
@IsArray()
locations!: EventTypeLocation[] | null;
@IsInt()
@ApiHideProperty()
position!: number;
@IsInt()
@ApiHideProperty()
offsetStart!: number;
@IsInt()
@ApiHideProperty()
userId!: number | null;
@IsInt()
@ApiHideProperty()
profileId!: number | null;
@IsInt()
@ApiHideProperty()
teamId!: number | null;
@IsString()
@ApiHideProperty()
eventName!: string | null;
@IsInt()
@ApiHideProperty()
parentId!: number | null;
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => BookingField)
@ApiHideProperty()
bookingFields!: BookingField[] | null;
@IsString()
@ApiHideProperty()
timeZone!: string | null;
@IsEnum(PeriodType)
@ApiHideProperty()
periodType!: PeriodType | null;
@IsDate()
@ApiHideProperty()
periodStartDate!: Date | null;
@IsDate()
@ApiHideProperty()
periodEndDate!: Date | null;
@IsInt()
@ApiHideProperty()
periodDays!: number | null;
@IsBoolean()
@ApiHideProperty()
periodCountCalendarDays!: boolean | null;
@IsBoolean()
@ApiHideProperty()
lockTimeZoneToggleOnBookingPage!: boolean;
@IsBoolean()
@ApiHideProperty()
requiresConfirmation!: boolean;
@IsBoolean()
@ApiHideProperty()
requiresBookerEmailVerification!: boolean;
@ValidateNested()
@Type(() => RecurringEvent)
@IsOptional()
@ApiHideProperty()
recurringEvent!: RecurringEvent | null;
@IsBoolean()
@ApiHideProperty()
disableGuests!: boolean;
@IsBoolean()
@ApiHideProperty()
hideCalendarNotes!: boolean;
@IsInt()
@ApiHideProperty()
minimumBookingNotice!: number;
@IsInt()
@ApiHideProperty()
beforeEventBuffer!: number;
@IsInt()
@ApiHideProperty()
afterEventBuffer!: number;
@IsInt()
@ApiHideProperty()
seatsPerTimeSlot!: number | null;
@IsBoolean()
@ApiHideProperty()
onlyShowFirstAvailableSlot!: boolean;
@IsBoolean()
@ApiHideProperty()
seatsShowAttendees!: boolean;
@IsBoolean()
@ApiHideProperty()
seatsShowAvailabilityCount!: boolean;
@IsEnum(SchedulingType)
@ApiHideProperty()
schedulingType!: SchedulingType | null;
@IsInt()
@ApiHideProperty()
scheduleId!: number | null;
@IsNumber()
@ApiHideProperty()
price!: number;
@IsString()
@ApiHideProperty()
currency!: string;
@IsInt()
@ApiHideProperty()
slotInterval!: number | null;
@IsJSON()
@ApiHideProperty()
metadata!: Record<string, any> | null;
@IsString()
@ApiHideProperty()
successRedirectUrl!: string | null;
@ValidateNested()
@Type(() => IntervalLimits)
@IsOptional()
@ApiHideProperty()
bookingLimits!: IntervalLimits;
@ValidateNested()
@Type(() => IntervalLimits)
@ApiHideProperty()
durationLimits!: IntervalLimits;
@IsBoolean()
@ApiHideProperty()
isInstantEvent!: boolean;
@IsBoolean()
@ApiHideProperty()
assignAllTeamMembers!: boolean;
@IsBoolean()
@ApiHideProperty()
useEventTypeDestinationCalendarEmail!: boolean;
@IsInt()
@ApiHideProperty()
secondaryEmailId!: number | null;
}
@@ -0,0 +1,355 @@
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import {
IsBoolean,
IsInt,
IsOptional,
IsString,
IsUrl,
ValidateNested,
IsArray,
IsObject,
IsNumber,
IsEnum,
} from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
class Location {
@IsString()
type!: string;
}
class Source {
@IsString()
id!: string;
@IsString()
type!: string;
@IsString()
label!: string;
}
class OptionInput {
@IsString()
type!: string;
@IsBoolean()
@IsOptional()
required?: boolean;
@IsString()
@IsOptional()
placeholder?: string;
}
class BookingField {
@IsString()
name!: string;
@IsString()
type!: string;
@IsOptional()
@IsString()
defaultLabel?: string;
@IsString()
@IsOptional()
label?: string;
@IsString()
@IsOptional()
placeholder?: string;
@IsBoolean()
@IsOptional()
required?: boolean;
@IsOptional()
getOptionsAt?: string;
@IsObject()
@IsOptional()
optionsInputs?: { [key: string]: OptionInput };
@IsBoolean()
@IsOptional()
hideWhenJustOneOption?: boolean;
@IsString()
@IsOptional()
editable?: string;
@IsArray()
@ValidateNested({ each: true })
@Type(() => Source)
@IsOptional()
sources?: Source[];
}
class Organization {
@IsInt()
id!: number;
@IsString()
@IsOptional()
slug?: string | null;
@IsString()
name!: string;
@IsOptional()
metadata!: Record<string, any>;
}
class Profile {
@IsString()
username!: string | null;
@IsInt()
id!: number | null;
@IsInt()
@IsOptional()
userId?: number;
@IsString()
@IsOptional()
uid?: string;
@IsOptional()
@IsString()
name?: string;
@IsInt()
organizationId!: number | null;
@ValidateNested()
@Type(() => Organization)
organization?: Organization | null;
@IsString()
upId!: string;
@IsString()
@IsOptional()
image?: string;
@IsString()
@IsOptional()
brandColor?: string;
@IsString()
@IsOptional()
darkBrandColor?: string;
@IsString()
@IsOptional()
theme?: string;
@IsOptional()
bookerLayouts?: any;
}
class Owner {
@IsInt()
id!: number;
@IsString()
@IsOptional()
avatarUrl?: string | null;
@IsString()
username!: string | null;
@IsString()
name!: string | null;
@IsString()
weekStart!: string;
@IsString()
@IsOptional()
brandColor?: string | null;
@IsString()
@IsOptional()
darkBrandColor?: string | null;
@IsString()
@IsOptional()
theme?: string | null;
@IsOptional()
metadata!: any;
@IsInt()
@IsOptional()
defaultScheduleId?: number | null;
@IsString()
nonProfileUsername!: string | null;
@ValidateNested()
@Type(() => Profile)
profile!: Profile;
}
class User {
@IsString()
username!: string | null;
@IsString()
name!: string | null;
@IsString()
weekStart!: string;
@IsInt()
organizationId?: number;
@IsString()
@IsOptional()
avatarUrl?: string | null;
@ValidateNested()
profile!: Profile;
@IsString()
bookerUrl!: string;
}
class Schedule {
@IsInt()
id!: number;
@IsString()
timeZone!: string | null;
}
class PublicEventTypeOutput {
@IsInt()
id!: number;
@IsString()
title!: string;
@IsString()
description!: string;
@IsString()
@IsOptional()
eventName?: string | null;
@IsString()
slug!: string;
@IsBoolean()
isInstantEvent!: boolean;
@IsOptional()
aiPhoneCallConfig?: any;
@IsOptional()
schedulingType?: any;
@IsInt()
length!: number;
@IsArray()
@ValidateNested({ each: true })
@Type(() => Location)
locations!: Location[];
@IsArray()
customInputs!: any[];
@IsBoolean()
disableGuests!: boolean;
@IsObject()
metadata!: object | null;
@IsBoolean()
lockTimeZoneToggleOnBookingPage!: boolean;
@IsBoolean()
requiresConfirmation!: boolean;
@IsBoolean()
requiresBookerEmailVerification!: boolean;
@IsOptional()
recurringEvent?: any;
@IsNumber()
price!: number;
@IsString()
currency!: string;
@IsOptional()
seatsPerTimeSlot?: number | null;
@IsBoolean()
seatsShowAvailabilityCount!: boolean | null;
@IsArray()
@ValidateNested({ each: true })
@Type(() => BookingField)
bookingFields!: BookingField[];
@IsOptional()
team?: any;
@IsOptional()
@IsUrl()
successRedirectUrl?: string | null;
@IsArray()
workflows!: any[];
@IsArray()
hosts!: any[];
@ValidateNested()
@Type(() => Owner)
owner!: Owner | null;
@ValidateNested()
@Type(() => Schedule)
schedule!: Schedule | null;
@IsBoolean()
hidden!: boolean;
@IsBoolean()
assignAllTeamMembers!: boolean;
@IsOptional()
bookerLayouts?: any;
@IsArray()
@ValidateNested({ each: true })
@Type(() => User)
users!: User[];
@IsObject()
entity!: object;
@IsBoolean()
isDynamic!: boolean;
}
export class GetEventTypePublicOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ValidateNested({ each: true })
@Type(() => PublicEventTypeOutput)
@IsArray()
data!: PublicEventTypeOutput | null;
}
@@ -0,0 +1,28 @@
import { EventTypeOutput } from "@/ee/event-types/outputs/event-type.output";
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsEnum, ValidateNested } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
class Data {
@ApiProperty({
type: EventTypeOutput,
})
@ValidateNested()
@Type(() => EventTypeOutput)
eventType!: EventTypeOutput;
}
export class GetEventTypeOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
type: Data,
})
@ValidateNested()
@Type(() => Data)
data!: Data;
}
@@ -0,0 +1,43 @@
import {
CREATE_EVENT_LENGTH_EXAMPLE,
CREATE_EVENT_SLUG_EXAMPLE,
CREATE_EVENT_TITLE_EXAMPLE,
} from "@/ee/event-types/inputs/create-event-type.input";
import { ApiProperty } from "@nestjs/swagger";
import { ApiProperty as DocsProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsArray, IsEnum, IsInt, IsString, ValidateNested } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
class PublicEventType {
@IsInt()
@DocsProperty({ example: 1 })
id!: number;
@IsInt()
@DocsProperty({ example: CREATE_EVENT_LENGTH_EXAMPLE })
length!: number;
@IsString()
@DocsProperty({ example: CREATE_EVENT_SLUG_EXAMPLE })
slug!: string;
@IsString()
@DocsProperty({ example: CREATE_EVENT_TITLE_EXAMPLE })
title!: string;
@IsString()
description?: string | null;
}
export class GetEventTypesPublicOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ValidateNested({ each: true })
@Type(() => PublicEventType)
@IsArray()
data!: PublicEventType[];
}
@@ -0,0 +1,31 @@
import { EventTypeOutput } from "@/ee/event-types/outputs/event-type.output";
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsArray, IsEnum, ValidateNested } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
class EventTypeGroup {
@ValidateNested({ each: true })
@Type(() => EventTypeOutput)
@IsArray()
eventTypes!: EventTypeOutput[];
}
class GetEventTypesData {
@ValidateNested({ each: true })
@Type(() => EventTypeGroup)
@IsArray()
eventTypeGroups!: EventTypeGroup[];
}
export class GetEventTypesOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ValidateNested({ each: true })
@Type(() => GetEventTypesData)
@IsArray()
data!: GetEventTypesData;
}
@@ -0,0 +1,20 @@
import { EventTypeOutput } from "@/ee/event-types/outputs/event-type.output";
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
export class UpdateEventTypeOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
type: EventTypeOutput,
})
@IsNotEmptyObject()
@ValidateNested()
@Type(() => EventTypeOutput)
data!: EventTypeOutput;
}
@@ -1,26 +1,73 @@
import { DEFAULT_EVENT_TYPES } from "@/ee/event-types/constants/constants";
import { EventTypesRepository } from "@/ee/event-types/event-types.repository";
import { CreateEventTypeInput } from "@/ee/event-types/inputs/create-event-type.input";
import { UpdateEventTypeInput } from "@/ee/event-types/inputs/update-event-type.input";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository";
import { Injectable, NotFoundException } from "@nestjs/common";
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
import { createEventType, updateEventType } from "@calcom/platform-libraries";
import { getEventTypesPublic, EventTypesPublic } from "@calcom/platform-libraries";
import { EventType } from "@calcom/prisma/client";
@Injectable()
export class EventTypesService {
constructor(
private readonly eventTypesRepository: EventTypesRepository,
private readonly membershipsRepository: MembershipsRepository,
private readonly usersRepository: UsersRepository
private readonly usersRepository: UsersRepository,
private readonly selectedCalendarsRepository: SelectedCalendarsRepository,
private readonly dbWrite: PrismaWriteService
) {}
async createUserEventType(userId: number, body: CreateEventTypeInput) {
return this.eventTypesRepository.createUserEventType(userId, body);
async createUserEventType(user: UserWithProfile, body: CreateEventTypeInput) {
await this.checkCanCreateEventType(user.id, body);
const eventTypeUser = await this.getUserToCreateEvent(user);
const { eventType } = await createEventType({
input: body,
ctx: {
user: eventTypeUser,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
prisma: this.dbWrite.prisma,
},
});
return eventType;
}
async checkCanCreateEventType(userId: number, body: CreateEventTypeInput) {
const existsWithSlug = await this.eventTypesRepository.getUserEventTypeBySlug(userId, body.slug);
if (existsWithSlug) {
throw new BadRequestException("User already has an event type with this slug.");
}
}
async getUserToCreateEvent(user: UserWithProfile) {
const organizationId = user.movedToProfile?.organizationId || user.organizationId;
const isOrgAdmin = organizationId
? await this.membershipsRepository.isUserOrganizationAdmin(user.id, organizationId)
: false;
const profileId = user.movedToProfile?.id || null;
return {
id: user.id,
organizationId: user.organizationId,
organization: { isOrgAdmin },
profile: { id: profileId },
metadata: user.metadata,
};
}
async getUserEventType(userId: number, eventTypeId: number) {
return this.eventTypesRepository.getUserEventType(userId, eventTypeId);
const eventType = await this.eventTypesRepository.getUserEventType(userId, eventTypeId);
if (!eventType) {
return null;
}
this.checkUserOwnsEventType(userId, eventType);
return eventType;
}
async getUserEventTypeForAtom(user: UserWithProfile, eventTypeId: number) {
@@ -30,7 +77,18 @@ export class EventTypesService {
? await this.membershipsRepository.isUserOrganizationAdmin(user.id, organizationId)
: false;
return this.eventTypesRepository.getUserEventTypeForAtom(user, isUserOrganizationAdmin, eventTypeId);
const eventType = await this.eventTypesRepository.getUserEventTypeForAtom(
user,
isUserOrganizationAdmin,
eventTypeId
);
if (!eventType) {
return null;
}
this.checkUserOwnsEventType(user.id, eventType.eventType);
return eventType;
}
async getEventTypesPublicByUsername(username: string): Promise<EventTypesPublic> {
@@ -43,11 +101,7 @@ export class EventTypesService {
}
async createUserDefaultEventTypes(userId: number) {
const thirtyMinutes = DEFAULT_EVENT_TYPES.thirtyMinutes;
const thirtyMinutesVideo = DEFAULT_EVENT_TYPES.thirtyMinutesVideo;
const sixtyMinutes = DEFAULT_EVENT_TYPES.sixtyMinutes;
const sixtyMinutesVideo = DEFAULT_EVENT_TYPES.sixtyMinutesVideo;
const { sixtyMinutes, sixtyMinutesVideo, thirtyMinutes, thirtyMinutesVideo } = DEFAULT_EVENT_TYPES;
const defaultEventTypes = await Promise.all([
this.eventTypesRepository.createUserEventType(userId, thirtyMinutes),
@@ -58,4 +112,53 @@ export class EventTypesService {
return defaultEventTypes;
}
async updateEventType(eventTypeId: number, body: UpdateEventTypeInput, user: UserWithProfile) {
this.checkCanUpdateEventType(user.id, eventTypeId);
const eventTypeUser = await this.getUserToUpdateEvent(user);
await updateEventType({
input: { id: eventTypeId, ...body },
ctx: {
user: eventTypeUser,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
prisma: this.dbWrite.prisma,
},
});
const { eventType } = await this.getUserEventTypeForAtom(user, eventTypeId);
return eventType;
}
async checkCanUpdateEventType(userId: number, eventTypeId: number) {
const existingEventType = await this.getUserEventType(userId, eventTypeId);
if (!existingEventType) {
throw new NotFoundException(`Event type with id ${eventTypeId} not found`);
}
this.checkUserOwnsEventType(userId, existingEventType);
}
async getUserToUpdateEvent(user: UserWithProfile) {
const profileId = user.movedToProfile?.id || null;
const selectedCalendars = await this.selectedCalendarsRepository.getUserSelectedCalendars(user.id);
return { ...user, profile: { id: profileId }, selectedCalendars };
}
async deleteEventType(eventTypeId: number, userId: number) {
const existingEventType = await this.eventTypesRepository.getEventTypeById(eventTypeId);
if (!existingEventType) {
throw new NotFoundException(`Event type with ID=${eventTypeId} does not exist.`);
}
this.checkUserOwnsEventType(userId, existingEventType);
return this.eventTypesRepository.deleteEventType(eventTypeId);
}
checkUserOwnsEventType(userId: number, eventType: Pick<EventType, "id" | "userId">) {
if (userId !== eventType.userId) {
throw new ForbiddenException(`User with ID=${userId} does not own event type with ID=${eventType.id}`);
}
}
}
@@ -1,5 +1,6 @@
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";
@@ -18,6 +19,20 @@ import { UserRepositoryFixture } from "test/fixtures/repository/users.repository
const CLIENT_REDIRECT_URI = "http://localhost:5555";
class CalendarsServiceMock {
async getCalendars() {
return {
connectedCalendars: [
{
integration: {
type: "google_calendar",
},
},
],
};
}
}
describe("Platform Gcal Endpoints", () => {
let app: INestApplication;
@@ -32,6 +47,7 @@ describe("Platform Gcal Endpoints", () => {
let gcalCredentials: Credential;
let accessTokenSecret: string;
let refreshTokenSecret: string;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
providers: [PrismaExceptionFilter, HttpExceptionFilter],
@@ -41,6 +57,8 @@ describe("Platform Gcal Endpoints", () => {
.useValue({
canActivate: () => true,
})
.overrideProvider(CalendarsService)
.useClass(CalendarsServiceMock)
.compile();
app = moduleRef.createNestApplication();
@@ -84,14 +102,14 @@ describe("Platform Gcal Endpoints", () => {
it(`/GET/ee/gcal/oauth/auth-url: it should respond 401 with invalid access token`, async () => {
await request(app.getHttpServer())
.get(`/api/v2/ee/gcal/oauth/auth-url`)
.get(`/v2/gcal/oauth/auth-url`)
.set("Authorization", `Bearer invalid_access_token`)
.expect(401);
});
it(`/GET/ee/gcal/oauth/auth-url: it should auth-url to google oauth with valid access token `, async () => {
const response = await request(app.getHttpServer())
.get(`/api/v2/ee/gcal/oauth/auth-url`)
.get(`/v2/gcal/oauth/auth-url`)
.set("Authorization", `Bearer ${accessTokenSecret}`)
.set("Origin", CLIENT_REDIRECT_URI)
.expect(200);
@@ -102,7 +120,7 @@ describe("Platform Gcal Endpoints", () => {
it(`/GET/ee/gcal/oauth/save: without oauth code`, async () => {
await request(app.getHttpServer())
.get(
`/api/v2/ee/gcal/oauth/save?state=accessToken=${accessTokenSecret}&origin%3D${CLIENT_REDIRECT_URI}&scope=https://www.googleapis.com/auth/calendar.readonly%20https://www.googleapis.com/auth/calendar.events`
`/v2/gcal/oauth/save?state=accessToken=${accessTokenSecret}&origin%3D${CLIENT_REDIRECT_URI}&scope=https://www.googleapis.com/auth/calendar.readonly%20https://www.googleapis.com/auth/calendar.events`
)
.expect(400);
});
@@ -110,7 +128,7 @@ describe("Platform Gcal Endpoints", () => {
it(`/GET/ee/gcal/oauth/save: without access token`, async () => {
await request(app.getHttpServer())
.get(
`/api/v2/ee/gcal/oauth/save?state=origin%3D${CLIENT_REDIRECT_URI}&code=4/0AfJohXmBuT7QVrEPlAJLBu4ZcSnyj5jtDoJqSW_riPUhPXQ70RPGkOEbVO3xs-OzQwpPQw&scope=https://www.googleapis.com/auth/calendar.readonly%20https://www.googleapis.com/auth/calendar.events`
`/v2/gcal/oauth/save?state=origin%3D${CLIENT_REDIRECT_URI}&code=4/0AfJohXmBuT7QVrEPlAJLBu4ZcSnyj5jtDoJqSW_riPUhPXQ70RPGkOEbVO3xs-OzQwpPQw&scope=https://www.googleapis.com/auth/calendar.readonly%20https://www.googleapis.com/auth/calendar.events`
)
.expect(400);
});
@@ -118,25 +136,25 @@ describe("Platform Gcal Endpoints", () => {
it(`/GET/ee/gcal/oauth/save: without origin`, async () => {
await request(app.getHttpServer())
.get(
`/api/v2/ee/gcal/oauth/save?state=accessToken=${accessTokenSecret}&code=4/0AfJohXmBuT7QVrEPlAJLBu4ZcSnyj5jtDoJqSW_riPUhPXQ70RPGkOEbVO3xs-OzQwpPQw&scope=https://www.googleapis.com/auth/calendar.readonly%20https://www.googleapis.com/auth/calendar.events`
`/v2/gcal/oauth/save?state=accessToken=${accessTokenSecret}&code=4/0AfJohXmBuT7QVrEPlAJLBu4ZcSnyj5jtDoJqSW_riPUhPXQ70RPGkOEbVO3xs-OzQwpPQw&scope=https://www.googleapis.com/auth/calendar.readonly%20https://www.googleapis.com/auth/calendar.events`
)
.expect(400);
});
it(`/GET/ee/gcal/check with access token but without origin`, async () => {
await request(app.getHttpServer())
.get(`/api/v2/ee/gcal/check`)
.get(`/v2/gcal/check`)
.set("Authorization", `Bearer ${accessTokenSecret}`)
.expect(400);
});
it(`/GET/ee/gcal/check without access token`, async () => {
await request(app.getHttpServer()).get(`/api/v2/ee/gcal/check`).expect(401);
await request(app.getHttpServer()).get(`/v2/gcal/check`).expect(401);
});
it(`/GET/ee/gcal/check with access token and origin but no credentials`, async () => {
await request(app.getHttpServer())
.get(`/api/v2/ee/gcal/check`)
.get(`/v2/gcal/check`)
.set("Authorization", `Bearer ${accessTokenSecret}`)
.set("Origin", CLIENT_REDIRECT_URI)
.expect(400);
@@ -150,7 +168,7 @@ describe("Platform Gcal Endpoints", () => {
"google-calendar"
);
await request(app.getHttpServer())
.get(`/api/v2/ee/gcal/check`)
.get(`/v2/gcal/check`)
.set("Authorization", `Bearer ${accessTokenSecret}`)
.set("Origin", CLIENT_REDIRECT_URI)
.expect(200);
+26 -10
View File
@@ -1,5 +1,8 @@
import { AppsRepository } from "@/modules/apps/apps.repository";
import { GcalService } from "@/modules/apps/services/gcal.service";
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
import { GcalAuthUrlOutput } from "@/ee/gcal/outputs/auth-url.output";
import { GcalCheckOutput } from "@/ee/gcal/outputs/check.output";
import { GcalSaveRedirectOutput } from "@/ee/gcal/outputs/save-redirect.output";
import { GCalService } from "@/modules/apps/services/gcal.service";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard";
@@ -22,6 +25,7 @@ import {
Headers,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { ApiTags as DocsTags } from "@nestjs/swagger";
import { Request } from "express";
import { google } from "googleapis";
import { z } from "zod";
@@ -32,30 +36,31 @@ import {
GOOGLE_CALENDAR_TYPE,
SUCCESS_STATUS,
} from "@calcom/platform-constants";
import { ApiRedirectResponseType, ApiResponse } from "@calcom/platform-types";
const CALENDAR_SCOPES = [
"https://www.googleapis.com/auth/calendar.readonly",
"https://www.googleapis.com/auth/calendar.events",
];
// Controller for the GCalConnect Atom
@Controller({
path: "ee/gcal",
path: "/gcal",
version: "2",
})
@DocsTags("Google Calendar")
export class GcalController {
private readonly logger = new Logger("Platform Gcal Provider");
constructor(
private readonly appRepository: AppsRepository,
private readonly credentialRepository: CredentialsRepository,
private readonly tokensRepository: TokensRepository,
private readonly selectedCalendarsRepository: SelectedCalendarsRepository,
private readonly config: ConfigService,
private readonly gcalService: GcalService
private readonly gcalService: GCalService,
private readonly calendarsService: CalendarsService
) {}
private redirectUri = `${this.config.get("api.url")}/ee/gcal/oauth/save`;
private redirectUri = `${this.config.get("api.url")}/gcal/oauth/save`;
@Get("/oauth/auth-url")
@HttpCode(HttpStatus.OK)
@@ -63,7 +68,7 @@ export class GcalController {
async redirect(
@Headers("Authorization") authorization: string,
@Req() req: Request
): Promise<ApiResponse<{ authUrl: string }>> {
): Promise<GcalAuthUrlOutput> {
const oAuth2Client = await this.gcalService.getOAuthClient(this.redirectUri);
const accessToken = authorization.replace("Bearer ", "");
const origin = req.get("origin") ?? req.get("host");
@@ -79,7 +84,7 @@ export class GcalController {
@Get("/oauth/save")
@Redirect(undefined, 301)
@HttpCode(HttpStatus.OK)
async save(@Query("state") state: string, @Query("code") code: string): Promise<ApiRedirectResponseType> {
async save(@Query("state") state: string, @Query("code") code: string): Promise<GcalSaveRedirectOutput> {
const stateParams = new URLSearchParams(state);
const { accessToken, origin } = z
.object({ accessToken: z.string(), origin: z.string() })
@@ -135,7 +140,7 @@ export class GcalController {
@HttpCode(HttpStatus.OK)
@UseGuards(AccessTokenGuard, PermissionsGuard)
@Permissions([APPS_READ])
async check(@GetUser("id") userId: number): Promise<ApiResponse> {
async check(@GetUser("id") userId: number): Promise<GcalCheckOutput> {
const gcalCredentials = await this.credentialRepository.getByTypeAndUserId("google_calendar", userId);
if (!gcalCredentials) {
@@ -146,6 +151,17 @@ export class GcalController {
throw new BadRequestException("Invalid google oauth credentials.");
}
const { connectedCalendars } = await this.calendarsService.getCalendars(userId);
const googleCalendar = connectedCalendars.find(
(cal: { integration: { type: string } }) => cal.integration.type === GOOGLE_CALENDAR_TYPE
);
if (!googleCalendar) {
throw new UnauthorizedException("Google Calendar not connected.");
}
if (googleCalendar.error?.message) {
throw new UnauthorizedException(googleCalendar.error?.message);
}
return { status: SUCCESS_STATUS };
}
}
+12 -2
View File
@@ -1,17 +1,27 @@
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
import { GcalController } from "@/ee/gcal/gcal.controller";
import { AppsRepository } from "@/modules/apps/apps.repository";
import { GcalService } from "@/modules/apps/services/gcal.service";
import { GCalService } from "@/modules/apps/services/gcal.service";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersRepository } from "@/modules/users/users.repository";
import { Module } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
@Module({
imports: [PrismaModule, TokensModule, OAuthClientModule],
providers: [AppsRepository, ConfigService, CredentialsRepository, SelectedCalendarsRepository, GcalService],
providers: [
AppsRepository,
ConfigService,
CredentialsRepository,
SelectedCalendarsRepository,
GCalService,
CalendarsService,
UsersRepository,
],
controllers: [GcalController],
})
export class GcalModule {}
@@ -0,0 +1,20 @@
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsEnum, IsString, ValidateNested } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
class AuthUrlData {
@IsString()
authUrl!: string;
}
export class GcalAuthUrlOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@Type(() => AuthUrlData)
@ValidateNested()
data!: AuthUrlData;
}
@@ -0,0 +1,10 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsEnum } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
export class GcalCheckOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
}
@@ -0,0 +1,6 @@
import { IsString } from "class-validator";
export class GcalSaveRedirectOutput {
@IsString()
url!: string;
}
+20 -17
View File
@@ -1,12 +1,11 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { SchedulesRepository } from "@/ee/schedules/schedules.repository";
import { SchedulesService } from "@/ee/schedules/services/schedules.service";
import { SchedulesModule } from "@/ee/schedules/schedules.module";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { AvailabilitiesModule } from "@/modules/availabilities/availabilities.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UpdateManagedPlatformUserInput } from "@/modules/users/inputs/update-managed-platform-user.input";
import { UpdateManagedUserInput } from "@/modules/users/inputs/update-managed-user.input";
import { UsersModule } from "@/modules/users/users.module";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
@@ -35,8 +34,14 @@ describe("Me Endpoints", () => {
const moduleRef = await withAccessTokenAuth(
userEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, AvailabilitiesModule, UsersModule, TokensModule],
providers: [SchedulesRepository, SchedulesService],
imports: [
AppModule,
PrismaModule,
AvailabilitiesModule,
UsersModule,
TokensModule,
SchedulesModule,
],
})
)
.overrideGuard(PermissionsGuard)
@@ -50,6 +55,7 @@ describe("Me Endpoints", () => {
user = await userRepositoryFixture.create({
email: userEmail,
username: userEmail,
});
app = moduleRef.createNestApplication();
@@ -65,7 +71,7 @@ describe("Me Endpoints", () => {
it("should get user associated with access token", async () => {
return request(app.getHttpServer())
.get("/api/v2/ee/me")
.get("/v2/me")
.expect(200)
.then((response) => {
const responseBody: ApiSuccessResponse<UserResponse> = response.body;
@@ -81,10 +87,10 @@ describe("Me Endpoints", () => {
});
it("should update user associated with access token", async () => {
const body: UpdateManagedPlatformUserInput = { timeZone: "Europe/Rome" };
const body: UpdateManagedUserInput = { timeZone: "Europe/Rome" };
return request(app.getHttpServer())
.patch("/api/v2/ee/me")
.patch("/v2/me")
.send(body)
.expect(200)
.then(async (response) => {
@@ -106,24 +112,21 @@ describe("Me Endpoints", () => {
});
it("should not update user associated with access token given invalid timezone", async () => {
const bodyWithIncorrectTimeZone: UpdateManagedPlatformUserInput = { timeZone: "Narnia/Woods" };
const bodyWithIncorrectTimeZone: UpdateManagedUserInput = { timeZone: "Narnia/Woods" };
return request(app.getHttpServer()).patch("/api/v2/ee/me").send(bodyWithIncorrectTimeZone).expect(400);
return request(app.getHttpServer()).patch("/v2/me").send(bodyWithIncorrectTimeZone).expect(400);
});
it("should not update user associated with access token given invalid time format", async () => {
const bodyWithIncorrectTimeFormat: UpdateManagedPlatformUserInput = { timeFormat: 100 };
const bodyWithIncorrectTimeFormat: UpdateManagedUserInput = { timeFormat: 100 };
return request(app.getHttpServer())
.patch("/api/v2/ee/me")
.send(bodyWithIncorrectTimeFormat)
.expect(400);
return request(app.getHttpServer()).patch("/v2/me").send(bodyWithIncorrectTimeFormat).expect(400);
});
it("should not update user associated with access token given invalid week start", async () => {
const bodyWithIncorrectWeekStart: UpdateManagedPlatformUserInput = { weekStart: "waba luba dub dub" };
const bodyWithIncorrectWeekStart: UpdateManagedUserInput = { weekStart: "waba luba dub dub" };
return request(app.getHttpServer()).patch("/api/v2/ee/me").send(bodyWithIncorrectWeekStart).expect(400);
return request(app.getHttpServer()).patch("/v2/me").send(bodyWithIncorrectWeekStart).expect(400);
});
afterAll(async () => {
+11 -8
View File
@@ -1,21 +1,24 @@
import { GetMeOutput } from "@/ee/me/outputs/get-me.output";
import { UpdateMeOutput } from "@/ee/me/outputs/update-me.output";
import { SchedulesService } from "@/ee/schedules/services/schedules.service";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { UpdateManagedPlatformUserInput } from "@/modules/users/inputs/update-managed-platform-user.input";
import { UpdateManagedUserInput } from "@/modules/users/inputs/update-managed-user.input";
import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository";
import { Controller, UseGuards, Get, Patch, Body } from "@nestjs/common";
import { ApiTags as DocsTags } from "@nestjs/swagger";
import { PROFILE_READ, PROFILE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
import { UserResponse, userSchemaResponse } from "@calcom/platform-types";
import { ApiResponse } from "@calcom/platform-types";
import { userSchemaResponse } from "@calcom/platform-types";
@Controller({
path: "ee/me",
path: "/me",
version: "2",
})
@UseGuards(AccessTokenGuard, PermissionsGuard)
@DocsTags("Me")
export class MeController {
constructor(
private readonly usersRepository: UsersRepository,
@@ -24,7 +27,7 @@ export class MeController {
@Get("/")
@Permissions([PROFILE_READ])
async getMe(@GetUser() user: UserWithProfile): Promise<ApiResponse<UserResponse>> {
async getMe(@GetUser() user: UserWithProfile): Promise<GetMeOutput> {
const me = userSchemaResponse.parse(user);
return {
@@ -37,11 +40,11 @@ export class MeController {
@Permissions([PROFILE_WRITE])
async updateMe(
@GetUser() user: UserWithProfile,
@Body() bodySchedule: UpdateManagedPlatformUserInput
): Promise<ApiResponse<UserResponse>> {
@Body() bodySchedule: UpdateManagedUserInput
): Promise<UpdateMeOutput> {
const updatedUser = await this.usersRepository.update(user.id, bodySchedule);
if (bodySchedule.timeZone && user.defaultScheduleId) {
await this.schedulesRepository.updateUserSchedule(user.id, user.defaultScheduleId, {
await this.schedulesRepository.updateUserSchedule(user, user.defaultScheduleId, {
timeZone: bodySchedule.timeZone,
});
}
@@ -0,0 +1,20 @@
import { MeOutput } from "@/ee/me/outputs/me.output";
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
export class GetMeOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
type: MeOutput,
})
@IsNotEmptyObject()
@ValidateNested()
@Type(() => MeOutput)
data!: MeOutput;
}
@@ -0,0 +1,25 @@
import { IsInt, IsEmail, IsOptional, IsString } from "class-validator";
export class MeOutput {
@IsInt()
id!: number;
@IsString()
username!: string;
@IsEmail()
email!: string;
@IsInt()
timeFormat!: number;
@IsInt()
@IsOptional()
defaultScheduleId!: number | null;
@IsString()
weekStart!: string;
@IsString()
timeZone!: string;
}
@@ -0,0 +1,20 @@
import { MeOutput } from "@/ee/me/outputs/me.output";
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
export class UpdateMeOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
type: MeOutput,
})
@IsNotEmptyObject()
@ValidateNested()
@Type(() => MeOutput)
data!: MeOutput;
}
@@ -0,0 +1,10 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsEnum } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
export class ProviderVerifyAccessTokenOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
}
@@ -0,0 +1,10 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsEnum } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
export class ProviderVerifyClientOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
}
@@ -1,7 +1,8 @@
import { ProviderVerifyAccessTokenOutput } from "@/ee/provider/outputs/verify-access-token.output";
import { ProviderVerifyClientOutput } from "@/ee/provider/outputs/verify-client.output";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { UserWithProfile } from "@/modules/users/users.repository";
import {
BadRequestException,
@@ -9,31 +10,26 @@ import {
Get,
HttpCode,
HttpStatus,
Logger,
NotFoundException,
Param,
UnauthorizedException,
UseGuards,
} from "@nestjs/common";
import { ApiTags as DocsTags } from "@nestjs/swagger";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { ApiResponse } from "@calcom/platform-types";
@Controller({
path: "ee/provider",
path: "/provider",
version: "2",
})
@DocsTags("Cal provider")
export class CalProviderController {
private readonly logger = new Logger("Platform Provider Controller");
constructor(
private readonly tokensRepository: TokensRepository,
private readonly oauthClientRepository: OAuthClientRepository
) {}
constructor(private readonly oauthClientRepository: OAuthClientRepository) {}
@Get("/:clientId")
@HttpCode(HttpStatus.OK)
async verifyClientId(@Param("clientId") clientId: string): Promise<ApiResponse> {
async verifyClientId(@Param("clientId") clientId: string): Promise<ProviderVerifyClientOutput> {
if (!clientId) {
throw new NotFoundException();
}
@@ -52,7 +48,7 @@ export class CalProviderController {
async verifyAccessToken(
@Param("clientId") clientId: string,
@GetUser() user: UserWithProfile
): Promise<ApiResponse> {
): Promise<ProviderVerifyAccessTokenOutput> {
if (!clientId) {
throw new BadRequestException();
}
@@ -1,7 +1,10 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { SchedulesRepository } from "@/ee/schedules/schedules.repository";
import { SchedulesService } from "@/ee/schedules/services/schedules.service";
import { CreateScheduleInput } from "@/ee/schedules/inputs/create-schedule.input";
import { CreateScheduleOutput } from "@/ee/schedules/outputs/create-schedule.output";
import { GetSchedulesOutput } from "@/ee/schedules/outputs/get-schedules.output";
import { UpdateScheduleOutput } from "@/ee/schedules/outputs/update-schedule.output";
import { SchedulesModule } from "@/ee/schedules/schedules.module";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { AvailabilitiesModule } from "@/modules/availabilities/availabilities.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
@@ -17,13 +20,7 @@ import { UserRepositoryFixture } from "test/fixtures/repository/users.repository
import { withAccessTokenAuth } from "test/utils/withAccessTokenAuth";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import {
ScheduleWithAvailabilities,
ScheduleWithAvailabilitiesForWeb,
UpdateScheduleOutputType,
} from "@calcom/platform-libraries";
import { ScheduleResponse, UpdateScheduleInput } from "@calcom/platform-types";
import { ApiSuccessResponse } from "@calcom/platform-types";
import { UpdateScheduleInput } from "@calcom/platform-types";
describe("Schedules Endpoints", () => {
describe("User Authentication", () => {
@@ -35,14 +32,23 @@ describe("Schedules Endpoints", () => {
const userEmail = "schedules-controller-e2e@api.com";
let user: User;
let createdSchedule: ScheduleResponse;
let createdSchedule: CreateScheduleOutput["data"];
const defaultAvailabilityDays = [1, 2, 3, 4, 5];
const defaultAvailabilityStartTime = "1970-01-01T09:00:00.000Z";
const defaultAvailabilityEndTime = "1970-01-01T17:00:00.000Z";
beforeAll(async () => {
const moduleRef = await withAccessTokenAuth(
userEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, AvailabilitiesModule, UsersModule, TokensModule],
providers: [SchedulesRepository, SchedulesService],
imports: [
AppModule,
PrismaModule,
AvailabilitiesModule,
UsersModule,
TokensModule,
SchedulesModule,
],
})
)
.overrideGuard(PermissionsGuard)
@@ -71,10 +77,12 @@ describe("Schedules Endpoints", () => {
it("should create a default schedule", async () => {
const scheduleName = "schedule-name";
const scheduleTimeZone = "Europe/Rome";
const isDefault = true;
const body = {
const body: CreateScheduleInput = {
name: scheduleName,
timeZone: scheduleTimeZone,
isDefault,
};
return request(app.getHttpServer())
@@ -82,73 +90,25 @@ describe("Schedules Endpoints", () => {
.send(body)
.expect(201)
.then(async (response) => {
const responseBody: ApiSuccessResponse<ScheduleResponse> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.id).toBeDefined();
expect(responseBody.data.userId).toEqual(user.id);
expect(responseBody.data.name).toEqual(scheduleName);
expect(responseBody.data.timeZone).toEqual(scheduleTimeZone);
const responseData: CreateScheduleOutput = response.body;
expect(responseData.status).toEqual(SUCCESS_STATUS);
expect(responseData.data).toBeDefined();
expect(responseData.data.isDefault).toEqual(isDefault);
expect(responseData.data.timeZone).toEqual(scheduleTimeZone);
expect(responseData.data.name).toEqual(scheduleName);
expect(responseBody.data.availability).toBeDefined();
expect(responseBody.data.availability?.length).toEqual(1);
const defaultAvailabilityDays = [1, 2, 3, 4, 5];
const defaultAvailabilityStartTime = "09:00:00";
const defaultAvailabilityEndTime = "17:00:00";
const schedule = responseData.data.schedule;
expect(schedule).toBeDefined();
expect(schedule.length).toEqual(1);
expect(schedule?.[0]?.days).toEqual(defaultAvailabilityDays);
expect(schedule?.[0]?.startTime).toEqual(defaultAvailabilityStartTime);
expect(schedule?.[0]?.endTime).toEqual(defaultAvailabilityEndTime);
expect(responseBody.data.availability?.[0]?.days).toEqual(defaultAvailabilityDays);
expect(responseBody.data.availability?.[0]?.startTime).toEqual(defaultAvailabilityStartTime);
expect(responseBody.data.availability?.[0]?.endTime).toEqual(defaultAvailabilityEndTime);
const scheduleUser = await userRepositoryFixture.get(responseBody.data.userId);
expect(scheduleUser?.defaultScheduleId).toEqual(responseBody.data.id);
await scheduleRepositoryFixture.deleteById(responseBody.data.id);
await scheduleRepositoryFixture.deleteAvailabilities(responseBody.data.id);
});
});
it("should create a schedule", async () => {
const scheduleName = "schedule-name";
const scheduleTimeZone = "Europe/Rome";
const availabilityDays = [1, 2, 3, 4, 5, 6];
const availabilityStartTime = "11:00:00";
const availabilityEndTime = "14:00:00";
const body = {
name: scheduleName,
timeZone: scheduleTimeZone,
availabilities: [
{
days: availabilityDays,
startTime: availabilityStartTime,
endTime: availabilityEndTime,
},
],
};
return request(app.getHttpServer())
.post("/api/v2/schedules")
.send(body)
.expect(201)
.then(async (response) => {
const responseBody: ApiSuccessResponse<ScheduleResponse> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.id).toBeDefined();
expect(responseBody.data.userId).toEqual(user.id);
expect(responseBody.data.name).toEqual(scheduleName);
expect(responseBody.data.timeZone).toEqual(scheduleTimeZone);
expect(responseBody.data.availability).toBeDefined();
expect(responseBody.data.availability?.length).toEqual(1);
expect(responseBody.data.availability?.[0]?.days).toEqual(availabilityDays);
expect(responseBody.data.availability?.[0]?.startTime).toEqual(availabilityStartTime);
expect(responseBody.data.availability?.[0]?.endTime).toEqual(availabilityEndTime);
createdSchedule = responseBody.data;
const scheduleUser = await userRepositoryFixture.get(responseBody.data.userId);
expect(scheduleUser?.defaultScheduleId).toEqual(responseBody.data.id);
const scheduleUser = schedule?.[0].userId
? await userRepositoryFixture.get(schedule?.[0].userId)
: null;
expect(scheduleUser?.defaultScheduleId).toEqual(responseData.data.id);
createdSchedule = responseData.data;
});
});
@@ -156,90 +116,19 @@ describe("Schedules Endpoints", () => {
return request(app.getHttpServer())
.get("/api/v2/schedules/default")
.expect(200)
.then((response) => {
const responseBody: ApiSuccessResponse<ScheduleResponse> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.id).toBeDefined();
expect(responseBody.data.userId).toEqual(createdSchedule.userId);
expect(responseBody.data.name).toEqual(createdSchedule.name);
expect(responseBody.data.timeZone).toEqual(createdSchedule.timeZone);
.then(async (response) => {
const responseData: CreateScheduleOutput = response.body;
expect(responseData.status).toEqual(SUCCESS_STATUS);
expect(responseData.data).toBeDefined();
expect(responseData.data.id).toEqual(createdSchedule.id);
expect(responseData.data.schedule?.[0].userId).toEqual(createdSchedule.schedule[0].userId);
expect(responseBody.data.availability).toBeDefined();
expect(responseBody.data.availability?.length).toEqual(1);
expect(responseBody.data.availability?.[0]?.days).toEqual(createdSchedule.availability?.[0]?.days);
expect(responseBody.data.availability?.[0]?.startTime).toEqual(
createdSchedule.availability?.[0]?.startTime
);
expect(responseBody.data.availability?.[0]?.endTime).toEqual(
createdSchedule.availability?.[0]?.endTime
);
});
});
it("should get schedule", async () => {
return request(app.getHttpServer())
.get(`/api/v2/schedules/${createdSchedule.id}`)
.expect(200)
.then((response) => {
const responseBody: ApiSuccessResponse<ScheduleResponse> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.id).toBeDefined();
expect(
(responseBody.data as unknown as ScheduleWithAvailabilitiesForWeb).dateOverrides
).toBeFalsy();
expect(responseBody.data.userId).toEqual(createdSchedule.userId);
expect(responseBody.data.name).toEqual(createdSchedule.name);
expect(responseBody.data.timeZone).toEqual(createdSchedule.timeZone);
expect(responseBody.data.availability).toBeDefined();
expect(responseBody.data.availability?.length).toEqual(1);
expect(responseBody.data.availability?.[0]?.days).toEqual(createdSchedule.availability?.[0]?.days);
expect(responseBody.data.availability?.[0]?.startTime).toEqual(
createdSchedule.availability?.[0]?.startTime
);
expect(responseBody.data.availability?.[0]?.endTime).toEqual(
createdSchedule.availability?.[0]?.endTime
);
});
});
it("should get schedule for atom", async () => {
const forAtomQueryParam = "?for=atom";
return request(app.getHttpServer())
.get(`/api/v2/schedules/${createdSchedule.id}${forAtomQueryParam}`)
.expect(200)
.then((response) => {
const responseBody: ApiSuccessResponse<ScheduleWithAvailabilitiesForWeb> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.id).toBeDefined();
expect(responseBody.data.dateOverrides).toBeDefined();
expect((responseBody.data as unknown as ScheduleWithAvailabilities).userId).toBeFalsy();
expect(responseBody.data.name).toEqual(createdSchedule.name);
expect(responseBody.data.timeZone).toEqual(createdSchedule.timeZone);
expect(responseBody.data.availability).toBeDefined();
expect(responseBody.data.availability?.length).toEqual(7);
const dateStart = new Date(responseBody.data.availability?.[1]?.[0]?.start);
const dateEnd = new Date(responseBody.data.availability?.[1]?.[0]?.end);
const dateStartHours = dateStart.getUTCHours().toString().padStart(2, "0");
const dateStartMinutes = dateStart.getUTCMinutes().toString().padStart(2, "0");
const dateStartSeconds = dateStart.getUTCSeconds().toString().padStart(2, "0");
const dateEndHours = dateEnd.getUTCHours().toString().padStart(2, "0");
const dateEndMinutes = dateEnd.getUTCMinutes().toString().padStart(2, "0");
const dateEndSeconds = dateEnd.getUTCSeconds().toString().padStart(2, "0");
const expectedStart = `${dateStartHours}:${dateStartMinutes}:${dateStartSeconds}`;
const expectedEnd = `${dateEndHours}:${dateEndMinutes}:${dateEndSeconds}`;
expect(expectedStart).toEqual(createdSchedule.availability?.[0]?.startTime);
expect(expectedEnd).toEqual(createdSchedule.availability?.[0]?.endTime);
const schedule = responseData.data.schedule;
expect(schedule).toBeDefined();
expect(schedule.length).toEqual(1);
expect(schedule?.[0]?.days).toEqual(defaultAvailabilityDays);
expect(schedule?.[0]?.startTime).toEqual(defaultAvailabilityStartTime);
expect(schedule?.[0]?.endTime).toEqual(defaultAvailabilityEndTime);
});
});
@@ -248,77 +137,26 @@ describe("Schedules Endpoints", () => {
.get(`/api/v2/schedules`)
.expect(200)
.then((response) => {
const responseBody: ApiSuccessResponse<ScheduleResponse[]> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.length).toEqual(1);
const responseData: GetSchedulesOutput = response.body;
expect(responseData.status).toEqual(SUCCESS_STATUS);
expect(responseData.data).toBeDefined();
expect(responseData.data?.[0].id).toEqual(createdSchedule.id);
expect(responseData.data?.[0].schedule?.[0].userId).toEqual(createdSchedule.schedule[0].userId);
const fetchedSchedule = responseBody.data[0];
expect(fetchedSchedule).toBeDefined();
expect(fetchedSchedule.userId).toEqual(createdSchedule.userId);
expect(fetchedSchedule.name).toEqual(createdSchedule.name);
expect(fetchedSchedule.timeZone).toEqual(createdSchedule.timeZone);
expect(fetchedSchedule.availability).toBeDefined();
expect(fetchedSchedule.availability?.length).toEqual(1);
expect(fetchedSchedule.availability?.[0]?.days).toEqual(createdSchedule.availability?.[0]?.days);
expect(fetchedSchedule.availability?.[0]?.startTime).toEqual(
createdSchedule.availability?.[0]?.startTime
);
expect(fetchedSchedule.availability?.[0]?.endTime).toEqual(
createdSchedule.availability?.[0]?.endTime
);
const schedule = responseData.data?.[0].schedule;
expect(schedule).toBeDefined();
expect(schedule.length).toEqual(1);
expect(schedule?.[0]?.days).toEqual(defaultAvailabilityDays);
expect(schedule?.[0]?.startTime).toEqual(defaultAvailabilityStartTime);
expect(schedule?.[0]?.endTime).toEqual(defaultAvailabilityEndTime);
});
});
it("should update schedule name", async () => {
const newScheduleName = "new-schedule-name";
const schedule = [
[],
[
{
start: new Date("2024-03-05T11:00:00.000Z"),
end: new Date("2024-03-05T14:00:00.000Z"),
},
],
[
{
start: new Date("2024-03-05T11:00:00.000Z"),
end: new Date("2024-03-05T14:00:00.000Z"),
},
],
[
{
start: new Date("2024-03-05T11:00:00.000Z"),
end: new Date("2024-03-05T14:00:00.000Z"),
},
],
[
{
start: new Date("2024-03-05T11:00:00.000Z"),
end: new Date("2024-03-05T14:00:00.000Z"),
},
],
[
{
start: new Date("2024-03-05T11:00:00.000Z"),
end: new Date("2024-03-05T14:00:00.000Z"),
},
],
[
{
start: new Date("2024-03-05T11:00:00.000Z"),
end: new Date("2024-03-05T14:00:00.000Z"),
},
],
];
const body: UpdateScheduleInput = {
scheduleId: createdSchedule.id,
name: newScheduleName,
schedule,
};
return request(app.getHttpServer())
@@ -326,29 +164,19 @@ describe("Schedules Endpoints", () => {
.send(body)
.expect(200)
.then((response: any) => {
const responseBody: ApiSuccessResponse<UpdateScheduleOutputType> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.schedule.id).toBeDefined();
expect(responseBody.data.schedule.userId).toEqual(createdSchedule.userId);
expect(responseBody.data.schedule.name).toEqual(newScheduleName);
expect(responseBody.data.timeZone).toEqual(createdSchedule.timeZone);
const responseData: UpdateScheduleOutput = response.body;
expect(responseData.status).toEqual(SUCCESS_STATUS);
expect(responseData.data).toBeDefined();
expect(responseData.data.schedule.name).toEqual(newScheduleName);
expect(responseData.data.schedule.id).toEqual(createdSchedule.id);
expect(responseData.data.schedule.userId).toEqual(createdSchedule.schedule[0].userId);
expect(responseBody.data.availability).toBeDefined();
const availabilities = responseBody.data?.availability?.filter(
(availability: any[]) => availability.length
);
expect(availabilities?.length).toEqual(6);
expect(responseBody.data.schedule.availability?.[0]?.days).toEqual(
createdSchedule.availability?.[0]?.days
);
expect(responseBody.data.schedule.availability?.[0]?.startTime).toEqual(
`1970-01-01T${createdSchedule.availability?.[0]?.startTime}.000Z`
);
expect(responseBody.data.schedule.availability?.[0]?.endTime).toEqual(
`1970-01-01T${createdSchedule.availability?.[0]?.endTime}.000Z`
);
const availability = responseData.data.schedule.availability;
expect(availability).toBeDefined();
expect(availability?.length).toEqual(1);
expect(availability?.[0]?.days).toEqual(defaultAvailabilityDays);
expect(availability?.[0]?.startTime).toEqual(defaultAvailabilityStartTime);
expect(availability?.[0]?.endTime).toEqual(defaultAvailabilityEndTime);
createdSchedule.name = newScheduleName;
});
@@ -360,6 +188,10 @@ describe("Schedules Endpoints", () => {
afterAll(async () => {
await userRepositoryFixture.deleteByEmail(user.email);
try {
await scheduleRepositoryFixture.deleteById(createdSchedule.id);
} catch (e) {}
await app.close();
});
});
@@ -1,6 +1,10 @@
import { ResponseService } from "@/ee/schedules/services/response/response.service";
import { CreateScheduleOutput } from "@/ee/schedules/outputs/create-schedule.output";
import { DeleteScheduleOutput } from "@/ee/schedules/outputs/delete-schedule.output";
import { GetDefaultScheduleOutput } from "@/ee/schedules/outputs/get-default-schedule.output";
import { GetScheduleOutput } from "@/ee/schedules/outputs/get-schedule.output";
import { GetSchedulesOutput } from "@/ee/schedules/outputs/get-schedules.output";
import { UpdateScheduleOutput } from "@/ee/schedules/outputs/update-schedule.output";
import { SchedulesService } from "@/ee/schedules/services/schedules.service";
import { ForAtom } from "@/lib/atoms/decorators/for-atom.decorator";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard";
@@ -18,13 +22,10 @@ import {
Patch,
UseGuards,
} from "@nestjs/common";
import { ApiResponse, ApiTags as DocsTags } from "@nestjs/swagger";
import { SCHEDULE_READ, SCHEDULE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
import type { ScheduleWithAvailabilitiesForWeb } from "@calcom/platform-libraries";
import { updateScheduleHandler } from "@calcom/platform-libraries";
import type { UpdateScheduleOutputType } from "@calcom/platform-libraries";
import { ApiSuccessResponse, ScheduleResponse, UpdateScheduleInput } from "@calcom/platform-types";
import { ApiResponse } from "@calcom/platform-types";
import { UpdateScheduleInput } from "@calcom/platform-types";
import { CreateScheduleInput } from "../inputs/create-schedule.input";
@@ -33,21 +34,18 @@ import { CreateScheduleInput } from "../inputs/create-schedule.input";
version: "2",
})
@UseGuards(AccessTokenGuard, PermissionsGuard)
@DocsTags("Schedules")
export class SchedulesController {
constructor(
private readonly schedulesService: SchedulesService,
private readonly schedulesResponseService: ResponseService
) {}
constructor(private readonly schedulesService: SchedulesService) {}
@Post("/")
@Permissions([SCHEDULE_WRITE])
async createSchedule(
@GetUser() user: UserWithProfile,
@Body() bodySchedule: CreateScheduleInput,
@ForAtom() forAtom: boolean
): Promise<ApiSuccessResponse<ScheduleResponse | ScheduleWithAvailabilitiesForWeb>> {
@Body() bodySchedule: CreateScheduleInput
): Promise<CreateScheduleOutput> {
const schedule = await this.schedulesService.createUserSchedule(user.id, bodySchedule);
const scheduleFormatted = await this.schedulesResponseService.formatSchedule(forAtom, user, schedule);
const scheduleFormatted = await this.schedulesService.formatScheduleForAtom(user, schedule);
return {
status: SUCCESS_STATUS,
@@ -57,13 +55,11 @@ export class SchedulesController {
@Get("/default")
@Permissions([SCHEDULE_READ])
async getDefaultSchedule(
@GetUser() user: UserWithProfile,
@ForAtom() forAtom: boolean
): Promise<ApiResponse<ScheduleResponse | ScheduleWithAvailabilitiesForWeb | null>> {
@ApiResponse({ status: 200, description: "Returns the default schedule", type: GetDefaultScheduleOutput })
async getDefaultSchedule(@GetUser() user: UserWithProfile): Promise<GetDefaultScheduleOutput | null> {
const schedule = await this.schedulesService.getUserScheduleDefault(user.id);
const scheduleFormatted = schedule
? await this.schedulesResponseService.formatSchedule(forAtom, user, schedule)
? await this.schedulesService.formatScheduleForAtom(user, schedule)
: null;
return {
@@ -76,11 +72,10 @@ export class SchedulesController {
@Permissions([SCHEDULE_READ])
async getSchedule(
@GetUser() user: UserWithProfile,
@Param("scheduleId") scheduleId: number,
@ForAtom() forAtom: boolean
): Promise<ApiSuccessResponse<ScheduleResponse | ScheduleWithAvailabilitiesForWeb>> {
@Param("scheduleId") scheduleId: number
): Promise<GetScheduleOutput> {
const schedule = await this.schedulesService.getUserSchedule(user.id, scheduleId);
const scheduleFormatted = await this.schedulesResponseService.formatSchedule(forAtom, user, schedule);
const scheduleFormatted = await this.schedulesService.formatScheduleForAtom(user, schedule);
return {
status: SUCCESS_STATUS,
@@ -90,12 +85,9 @@ export class SchedulesController {
@Get("/")
@Permissions([SCHEDULE_READ])
async getSchedules(
@GetUser() user: UserWithProfile,
@ForAtom() forAtom: boolean
): Promise<ApiSuccessResponse<ScheduleResponse[] | ScheduleWithAvailabilitiesForWeb[]>> {
async getSchedules(@GetUser() user: UserWithProfile): Promise<GetSchedulesOutput> {
const schedules = await this.schedulesService.getUserSchedules(user.id);
const schedulesFormatted = await this.schedulesResponseService.formatSchedules(forAtom, user, schedules);
const schedulesFormatted = await this.schedulesService.formatSchedulesForAtom(user, schedules);
return {
status: SUCCESS_STATUS,
@@ -108,12 +100,14 @@ export class SchedulesController {
@Permissions([SCHEDULE_WRITE])
async updateSchedule(
@GetUser() user: UserWithProfile,
@Body() bodySchedule: UpdateScheduleInput
): Promise<ApiSuccessResponse<UpdateScheduleOutputType>> {
const updatedSchedule: UpdateScheduleOutputType = await updateScheduleHandler({
input: bodySchedule,
ctx: { user },
});
@Body() bodySchedule: UpdateScheduleInput,
@Param("scheduleId") scheduleId: string
): Promise<UpdateScheduleOutput> {
const updatedSchedule = await this.schedulesService.updateUserSchedule(
user,
Number(scheduleId),
bodySchedule
);
return {
status: SUCCESS_STATUS,
@@ -127,7 +121,7 @@ export class SchedulesController {
async deleteSchedule(
@GetUser("id") userId: number,
@Param("scheduleId") scheduleId: number
): Promise<ApiResponse> {
): Promise<DeleteScheduleOutput> {
await this.schedulesService.deleteUserSchedule(userId, scheduleId);
return {
@@ -16,6 +16,5 @@ export class CreateScheduleInput {
availabilities?: CreateAvailabilityInput[];
@IsBoolean()
@IsOptional()
isDefault = true;
isDefault!: boolean;
}
@@ -1,22 +0,0 @@
import { CreateAvailabilityInput } from "@/modules/availabilities/inputs/create-availability.input";
import { Type } from "class-transformer";
import { IsBoolean, IsOptional, IsString, IsTimeZone, ValidateNested } from "class-validator";
export class UpdateScheduleInput {
@IsString()
@IsOptional()
name?: string;
@IsTimeZone()
@IsOptional()
timeZone?: string;
@ValidateNested({ each: true })
@Type(() => CreateAvailabilityInput)
@IsOptional()
availabilities?: CreateAvailabilityInput[];
@IsBoolean()
@IsOptional()
isDefault?: boolean;
}
@@ -0,0 +1,20 @@
import { ScheduleOutput } from "@/ee/schedules/outputs/schedule.output";
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
export class CreateScheduleOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
type: ScheduleOutput,
})
@IsNotEmptyObject()
@ValidateNested()
@Type(() => ScheduleOutput)
data!: ScheduleOutput;
}
@@ -0,0 +1,10 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsEnum } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
export class DeleteScheduleOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
}
@@ -0,0 +1,20 @@
import { ScheduleOutput } from "@/ee/schedules/outputs/schedule.output";
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
export class GetDefaultScheduleOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
type: ScheduleOutput,
})
@IsNotEmptyObject()
@ValidateNested()
@Type(() => ScheduleOutput)
data!: ScheduleOutput | null;
}
@@ -0,0 +1,20 @@
import { ScheduleOutput } from "@/ee/schedules/outputs/schedule.output";
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
export class GetScheduleOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
type: ScheduleOutput,
})
@IsNotEmptyObject()
@ValidateNested()
@Type(() => ScheduleOutput)
data!: ScheduleOutput;
}
@@ -0,0 +1,21 @@
import { ScheduleOutput } from "@/ee/schedules/outputs/schedule.output";
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsArray, IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
export class GetSchedulesOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
type: ScheduleOutput,
})
@IsNotEmptyObject()
@ValidateNested({ each: true })
@Type(() => ScheduleOutput)
@IsArray()
data!: ScheduleOutput[];
}
@@ -0,0 +1,95 @@
import { Type } from "class-transformer";
import { IsBoolean, IsInt, IsOptional, IsString, ValidateNested, IsArray } from "class-validator";
class EventTypeModel {
@IsInt()
id!: number;
@IsOptional()
@IsString()
eventName?: string | null;
}
class AvailabilityModel {
@IsInt()
id!: number;
@IsOptional()
@IsInt()
userId?: number | null;
@IsOptional()
@IsInt()
scheduleId?: number | null;
@IsOptional()
@IsInt()
eventTypeId?: number | null;
@IsArray()
@IsInt({ each: true })
days!: number[];
@IsOptional()
@Type(() => Date)
@IsString() // Assuming date is serialized/deserialized appropriately
startTime?: Date;
@IsOptional()
@Type(() => Date)
@IsString() // Assuming date is serialized/deserialized appropriately
endTime?: Date;
@IsOptional()
@Type(() => Date)
@IsString() // Assuming date is serialized/deserialized appropriately
date?: Date | null;
}
class ScheduleModel {
@IsInt()
id!: number;
@IsInt()
userId!: number;
@IsString()
name!: string;
@IsOptional()
@IsString()
timeZone?: string | null;
@IsOptional()
@ValidateNested({ each: true })
@Type(() => EventTypeModel)
@IsArray()
eventType?: EventTypeModel[];
@IsOptional()
@ValidateNested({ each: true })
@Type(() => AvailabilityModel)
@IsArray()
availability?: AvailabilityModel[];
}
export class UpdatedScheduleOutput {
@ValidateNested()
@Type(() => ScheduleModel)
schedule!: ScheduleModel;
@IsBoolean()
isDefault!: boolean;
@IsOptional()
@IsString()
timeZone?: string;
@IsOptional()
@IsInt()
prevDefaultId?: number | null;
@IsOptional()
@IsInt()
currentDefaultId?: number | null;
}
@@ -0,0 +1,105 @@
import { Type } from "class-transformer";
import { IsDate, IsOptional, IsArray, IsBoolean, IsInt, IsString, ValidateNested } from "class-validator";
class AvailabilityModel {
@IsInt()
id!: number;
@IsOptional()
@IsInt()
userId?: number | null;
@IsOptional()
@IsInt()
eventTypeId?: number | null;
@IsArray()
@IsInt({ each: true })
days!: number[];
@IsDate()
@Type(() => Date)
startTime!: Date;
@IsDate()
@Type(() => Date)
endTime!: Date;
@IsOptional()
@IsDate()
@Type(() => Date)
date?: Date | null;
@IsOptional()
@IsInt()
scheduleId?: number | null;
}
class WorkingHours {
@IsArray()
@IsInt({ each: true })
days!: number[];
@IsInt()
startTime!: number;
@IsInt()
endTime!: number;
@IsOptional()
@IsInt()
userId?: number | null;
}
class TimeRange {
@IsOptional()
@IsInt()
userId?: number | null;
@IsDate()
start!: Date;
@IsDate()
end!: Date;
}
export class ScheduleOutput {
@IsInt()
id!: number;
@IsString()
name!: string;
@IsBoolean()
isManaged!: boolean;
@ValidateNested({ each: true })
@Type(() => WorkingHours)
workingHours!: WorkingHours[];
@ValidateNested({ each: true })
@Type(() => AvailabilityModel)
@IsArray()
schedule!: AvailabilityModel[];
availability!: TimeRange[][];
@IsString()
timeZone!: string;
@ValidateNested({ each: true })
@IsArray()
// note(Lauris) it should be
// dateOverrides!: { ranges: TimeRange[] }[];
// but docs aren't generating correctly it results in array of strings
dateOverrides!: unknown[];
@IsBoolean()
isDefault!: boolean;
@IsBoolean()
isLastSchedule!: boolean;
@IsBoolean()
readOnly!: boolean;
}
@@ -0,0 +1,20 @@
import { UpdatedScheduleOutput } from "@/ee/schedules/outputs/schedule-updated.output";
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
export class UpdateScheduleOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
type: UpdatedScheduleOutput,
})
@IsNotEmptyObject()
@ValidateNested()
@Type(() => UpdatedScheduleOutput)
data!: UpdatedScheduleOutput;
}
@@ -1,6 +1,5 @@
import { SchedulesController } from "@/ee/schedules/controllers/schedules.controller";
import { SchedulesRepository } from "@/ee/schedules/schedules.repository";
import { ResponseService } from "@/ee/schedules/services/response/response.service";
import { SchedulesService } from "@/ee/schedules/services/schedules.service";
import { AvailabilitiesModule } from "@/modules/availabilities/availabilities.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
@@ -10,8 +9,8 @@ import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule, AvailabilitiesModule, UsersModule, TokensModule],
providers: [SchedulesRepository, SchedulesService, ResponseService],
providers: [SchedulesRepository, SchedulesService],
controllers: [SchedulesController],
exports: [SchedulesService],
exports: [SchedulesService, SchedulesRepository],
})
export class SchedulesModule {}
@@ -1,9 +1,8 @@
import { CreateScheduleInput } from "@/ee/schedules/inputs/create-schedule.input";
import { UpdateScheduleInput } from "@/ee/schedules/inputs/update-schedule.input";
import { CreateAvailabilityInput } from "@/modules/availabilities/inputs/create-availability.input";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { BadRequestException, Injectable } from "@nestjs/common";
import { Injectable } from "@nestjs/common";
import { Prisma } from "@prisma/client";
@Injectable()
@@ -78,51 +77,6 @@ export class SchedulesRepository {
return schedules;
}
async updateScheduleWithAvailabilities(scheduleId: number, schedule: UpdateScheduleInput) {
const existingSchedule = await this.dbRead.prisma.schedule.findUnique({
where: { id: scheduleId },
});
if (!existingSchedule) {
throw new BadRequestException(`Schedule with ID=${scheduleId} not found`);
}
const updatedScheduleData: Prisma.ScheduleUpdateInput = {
user: {
connect: {
id: existingSchedule.userId,
},
},
};
if (schedule.name) updatedScheduleData.name = schedule.name;
if (schedule.timeZone) updatedScheduleData.timeZone = schedule.timeZone;
if (schedule.availabilities && schedule.availabilities.length > 0) {
await this.dbWrite.prisma.availability.deleteMany({
where: { scheduleId },
});
updatedScheduleData.availability = {
createMany: {
data: schedule.availabilities.map((availability) => ({
...availability,
userId: existingSchedule.userId,
})),
},
};
}
const updatedSchedule = await this.dbWrite.prisma.schedule.update({
where: { id: scheduleId },
data: updatedScheduleData,
include: {
availability: true,
},
});
return updatedSchedule;
}
async deleteScheduleById(scheduleId: number) {
return this.dbWrite.prisma.schedule.delete({
where: {
@@ -1,68 +0,0 @@
import { SchedulesRepository } from "@/ee/schedules/schedules.repository";
import { Injectable } from "@nestjs/common";
import { User } from "@prisma/client";
import {
transformWorkingHoursForClient,
transformAvailabilityForClient,
transformDateOverridesForClient,
} from "@calcom/platform-libraries";
import type {
ScheduleWithAvailabilities,
ScheduleWithAvailabilitiesForWeb,
} from "@calcom/platform-libraries";
import { ScheduleResponse, schemaScheduleResponse } from "@calcom/platform-types";
@Injectable()
export class ResponseService {
constructor(private readonly schedulesRepository: SchedulesRepository) {}
async formatSchedule(
forAtom: boolean,
user: User,
schedule: ScheduleWithAvailabilities
): Promise<ScheduleResponse | ScheduleWithAvailabilitiesForWeb> {
if (forAtom) {
const usersSchedulesCount = await this.schedulesRepository.getUserSchedulesCount(user.id);
return this.transformScheduleForAtom(schedule, usersSchedulesCount, user);
}
return schemaScheduleResponse.parse(schedule);
}
async formatSchedules(
forAtom: boolean,
user: User,
schedules: ScheduleWithAvailabilities[]
): Promise<ScheduleResponse[] | ScheduleWithAvailabilitiesForWeb[]> {
if (forAtom) {
const usersSchedulesCount = await this.schedulesRepository.getUserSchedulesCount(user.id);
return Promise.all(
schedules.map((schedule) => this.transformScheduleForAtom(schedule, usersSchedulesCount, user))
);
}
return schedules.map((schedule) => schemaScheduleResponse.parse(schedule));
}
async transformScheduleForAtom(
schedule: ScheduleWithAvailabilities,
userSchedulesCount: number,
user: Pick<User, "id" | "defaultScheduleId" | "timeZone">
): Promise<ScheduleWithAvailabilitiesForWeb> {
const timeZone = schedule.timeZone || user.timeZone;
return {
id: schedule.id,
name: schedule.name,
isManaged: schedule.userId !== user.id,
workingHours: transformWorkingHoursForClient(schedule),
schedule: schedule.availability,
availability: transformAvailabilityForClient(schedule),
timeZone,
dateOverrides: transformDateOverridesForClient(schedule, timeZone),
isDefault: user.defaultScheduleId === schedule.id,
isLastSchedule: userSchedulesCount <= 1,
readOnly: schedule.userId !== user.id,
};
}
}
@@ -1,10 +1,20 @@
import { CreateScheduleInput } from "@/ee/schedules/inputs/create-schedule.input";
import { UpdateScheduleInput } from "@/ee/schedules/inputs/update-schedule.input";
import { ScheduleOutput } from "@/ee/schedules/outputs/schedule.output";
import { SchedulesRepository } from "@/ee/schedules/schedules.repository";
import { AvailabilitiesService } from "@/modules/availabilities/availabilities.service";
import { UsersRepository } from "@/modules/users/users.repository";
import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository";
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
import { Schedule } from "@prisma/client";
import { User } from "@prisma/client";
import type { ScheduleWithAvailabilities } from "@calcom/platform-libraries";
import { updateScheduleHandler } from "@calcom/platform-libraries";
import {
transformWorkingHoursForClient,
transformAvailabilityForClient,
transformDateOverridesForClient,
} from "@calcom/platform-libraries";
import { UpdateScheduleInput } from "@calcom/platform-types";
@Injectable()
export class SchedulesService {
@@ -56,25 +66,31 @@ export class SchedulesService {
return this.schedulesRepository.getSchedulesByUserId(userId);
}
async updateUserSchedule(userId: number, scheduleId: number, schedule: UpdateScheduleInput) {
async updateUserSchedule(user: UserWithProfile, scheduleId: number, bodySchedule: UpdateScheduleInput) {
const existingSchedule = await this.schedulesRepository.getScheduleById(scheduleId);
if (!existingSchedule) {
throw new NotFoundException(`Schedule with ID=${scheduleId} does not exist.`);
}
this.checkUserOwnsSchedule(userId, existingSchedule);
this.checkUserOwnsSchedule(user.id, existingSchedule);
const updatedSchedule = await this.schedulesRepository.updateScheduleWithAvailabilities(
scheduleId,
schedule
);
const schedule = await this.getUserSchedule(user.id, Number(scheduleId));
const scheduleFormatted = await this.formatScheduleForAtom(user, schedule);
if (schedule.isDefault) {
await this.usersRepository.setDefaultSchedule(userId, updatedSchedule.id);
if (!bodySchedule.schedule) {
// note(Lauris): When updating an availability in cal web app, lets say only its name, also
// the schedule is sent and then passed to the update handler. Notably, availability is passed too
// and they have same shape, so to match shapes I attach "scheduleFormatted.availability" to reflect
// schedule that would be passed by the web app. If we don't, then updating schedule name will erase
// schedule.
bodySchedule.schedule = scheduleFormatted.availability;
}
return updatedSchedule;
return updateScheduleHandler({
input: { scheduleId: Number(scheduleId), ...bodySchedule },
ctx: { user },
});
}
async deleteUserSchedule(userId: number, scheduleId: number) {
@@ -89,6 +105,44 @@ export class SchedulesService {
return this.schedulesRepository.deleteScheduleById(scheduleId);
}
async formatScheduleForAtom(user: User, schedule: ScheduleWithAvailabilities): Promise<ScheduleOutput> {
const usersSchedulesCount = await this.schedulesRepository.getUserSchedulesCount(user.id);
return this.transformScheduleForAtom(schedule, usersSchedulesCount, user);
}
async formatSchedulesForAtom(
user: User,
schedules: ScheduleWithAvailabilities[]
): Promise<ScheduleOutput[]> {
const usersSchedulesCount = await this.schedulesRepository.getUserSchedulesCount(user.id);
return Promise.all(
schedules.map((schedule) => this.transformScheduleForAtom(schedule, usersSchedulesCount, user))
);
}
async transformScheduleForAtom(
schedule: ScheduleWithAvailabilities,
userSchedulesCount: number,
user: Pick<User, "id" | "defaultScheduleId" | "timeZone">
): Promise<ScheduleOutput> {
const timeZone = schedule.timeZone || user.timeZone;
const defaultSchedule = await this.getUserScheduleDefault(user.id);
return {
id: schedule.id,
name: schedule.name,
isManaged: schedule.userId !== user.id,
workingHours: transformWorkingHoursForClient(schedule),
schedule: schedule.availability,
availability: transformAvailabilityForClient(schedule),
timeZone,
dateOverrides: transformDateOverridesForClient(schedule, timeZone),
isDefault: defaultSchedule?.id === schedule.id,
isLastSchedule: userSchedulesCount <= 1,
readOnly: schedule.userId !== user.id,
};
}
checkUserOwnsSchedule(userId: number, schedule: Pick<Schedule, "id" | "userId">) {
if (userId !== schedule.userId) {
throw new ForbiddenException(`User with ID=${userId} does not own schedule with ID=${schedule.id}`);
+1 -1
View File
@@ -9,7 +9,7 @@ export type Environment = {
NEXTAUTH_SECRET: string;
DATABASE_URL: string;
JWT_SECRET: string;
SENTRY_DNS: string;
SENTRY_DSN: string;
LOG_LEVEL: keyof typeof logLevels;
REDIS_URL: string;
};

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