Compare commits

...
70 Commits
Author SHA1 Message Date
Omar López 87d2ec9e75 chore: v4.2.0 (#15482)
release.yml / release (push) Canceled after 0s
* v4.1.13

* Update package.json
2024-06-18 19:42:25 +00:00
Rushikeshandsean-brydon 226686d4dd fix: Large padding on small screens for embedded booking widgets (#15463)
* fix: large padding on small screens for embedded booking widgets

* removed unused

* cssConflict - 'border-booker-width' applies the same CSS properties as 'border'.

---------

Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
2024-06-18 10:35:22 +00:00
Rajiv SahalandRajiv Sahal 518ab82e3b shift pencil icon after input to make it wider (#15460)
Co-authored-by: Rajiv Sahal <rajivsahal@Rajivs-MacBook-Pro.local>
2024-06-18 08:25:18 +00:00
Peer Richelsen 532b78a9b3 added empty screen for missing platform plan (#15467) 2024-06-18 07:37:48 +00:00
1251091300 fix: Offer seats events invites everyone regardless their availability schedule (#15171)
* fix: Offer seats events invites everyone regardless their availibility schedule

* chore: add test

* uncomment

* remove timeout

* only check when first seat

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
2024-06-17 15:30:41 +00:00
Rajdeep Das ce1459011a fix: unexpected scrolling on event-types page (#15452) 2024-06-17 14:25:23 +00:00
Joe Au-YeungandUdit Takkar e1bdac077a fix: Seat Attendee Rescheduling Logic (#14784)
* When rescheduling to new time slot don't trigger reschedule logic

* Add unique iCalUID when rescheduling to new slot

* Generating iCalUID use v4 uuid

* When moving an attendee to a new booking. Only move the attendee

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-06-15 08:57:52 +00:00
Vikas PatilandHariom d666f2926c feat: umami app added (#14783)
Co-authored-by: Hariom <hariombalhara@gmail.com>
2024-06-14 13:42:14 +00:00
Vikas PatilandPeer Richelsen 745f0b0872 feat: twipla app added (#14780)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2024-06-14 12:34:59 +00:00
65f50e9f25 chore: return accessTokenExpiresAt in oauth exchange and refresh (#15425)
* chore: return accessTokenExpiresAt in oauth exchange and refresh

* fixup! chore: return accessTokenExpiresAt in oauth exchange and refresh

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: Morgan Vernay <morgan@cal.com>
2024-06-14 11:25:27 +00:00
Alex van Andel 54162f8568 chore: Release v4.1.12 (#15432)
release.yml / release (push) Canceled after 0s
2024-06-13 22:42:18 +00:00
sean-brydon 0f4b9b2a6e fix (#15431) 2024-06-13 22:18:36 +00:00
Alex van AndelandJack W 70b4e3a9c6 Use "standalone" output to build Next.js as a standalone app when being built e.g. in Docker. (#15405)
Co-authored-by: Jack W <jackweatherilt@outlook.com>
2024-06-13 14:44:49 +00:00
ab44d5b92c add zod.ts for google meet (#15203)
Co-authored-by: Shaik-Sirajuddin <sirajudddinshaik30@gmail.com>
Co-authored-by: Somay Chauhan <somaychauhan98@gmail.com>
2024-06-13 14:00:57 +00:00
Peer Richelsen bed08c1e4f Update semantic-pull-requests.yml (#15421) 2024-06-13 14:00:44 +00:00
Alex van Andel 1e0f7f433f chore: Upgrade Sentry in v1, v2 & web (#15396)
* chore: Upgrade Sentry in v1, v2 & web

* fix: Keep ./instrument to be the first line

* Remove eslint-plugin-import (fixed in prettier)

* Optimistic removal pending some feedback

* Simplified and fixed sentry wrapper

* Create instrumentation.ts also in APIv1

* sentry key in main nextConfig is deprecated
2024-06-13 14:00:27 +00:00
Udit Takkar 0cc7a9fc22 refactor: recording and transcript webhook (#15419)
* refactor: recording and transcript webhook

* refactor: improve code and test
2024-06-13 13:59:57 +00:00
b03b878199 fix: reschedule and cancel unconfirmed event from email (#15013)
Co-authored-by: Somay Chauhan <somaychauhan98@gmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-06-13 09:09:24 +00:00
7a560cf09b chore: added platform admin to seed.ts (#15119)
* initial commit for platform seed

* seed platform team

* fix type checks

* refactors

* fix merge conflicts

* add seed for setting up platform user and team

* fixup

---------

Co-authored-by: Rajiv Sahal <rajivsahal@Rajivs-MacBook-Pro.local>
Co-authored-by: Rajiv Sahal <sahalrajiv-extc@atharvacoe.ac.in>
2024-06-13 06:53:37 +00:00
Hariom Balhara 58ad441223 fix: margin top in booker embed (#15412) 2024-06-12 14:08:04 +00:00
Alex van Andel d8de919acb Revert "fix: bookings made via API don't include the specified location (#15067)" (#15417)
This reverts commit 219b13d053.
2024-06-12 12:34:56 +00:00
Lauris Skraucis d699b33dcb chore: v2 managed user inform when access token expires (#15414) 2024-06-12 09:05:35 +00:00
Alex van Andel c071df4cd6 release: v4.1.11 (#15406)
release.yml / release (push) Canceled after 0s
2024-06-11 18:23:09 +00:00
219b13d053 fix: bookings made via API don't include the specified location (#15067)
* fix booking via api not picking default location when unspecified

* test:add tests for default location of event for booking

* fix:failing tests

---------

Co-authored-by: Shaik-Sirajuddin <sirajudddinshaik30@gmail.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2024-06-11 17:06:43 +00:00
Pranav RajveerandPeer Richelsen 5e09281974 fix: fixed the padding at the footer in /signup (#15394)
* fixed the padding at the footer in /signup

* reverted /auth/login Link style to original

* Update apps/web/pages/signup.tsx

---------

Co-authored-by: Peer Richelsen <peer@cal.com>
2024-06-11 16:45:11 +00:00
Udit TakkarandJoe Au-Yeung b66d36a98e feat: generate transcription from recording and API endpoint (#15358)
* feat: generate transcription from recording

* fix: api

* chore: undo

* tests: add unit tests

* chore: refactor move zod type

* fix: add more security check

* chore: update test

* chore: type err

* chore: test

* chore: fix tyoe

* fix: build err

* chore: add try .. catch

* chore: update desc

---------

Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
2024-06-11 15:41:56 +00:00
Lauris Skraucis 68ce9524eb refactor: v2 schedules (#15403)
* refactor: platform-types schedule types

* refactor: lib add bidirectional schedule transformers api <-> atom

* refactor: trpc handlers use renamed lib transformers

* refactor: platform-libraries export new bi-directional transformers

* chore: v2 update platform-libraries

* chore: enable also old platform-libraries

* fix: platform-types support previous types

* refactor: versioning date

* chore: version old schedules as schedules_2024_04_15 module

* chore: version old schedules as schedules_2024_04_15 module

* feat: new 2024_06_11 schedules module

* feat: atoms use refactored schedule api

* refactor: remove ?for=atom in schedules hooks

* fix: unit test
2024-06-11 14:35:53 +00:00
Morgan 357a42b512 fix: atoms useCheck and bump atoms version (#15390) 2024-06-11 13:38:39 +00:00
33d7da88bf feat: go to Assignment tab after creation and warn if existing without assigning (#14882)
* feat: go to Assignment tab after creation and warn if existing without assigning

* Update AssignmentWarningDialog.tsx

* fix: some changes

* fix: some updates

* Update EventTeamTab.tsx

* update

* update

* update test

* some fixes

* refactor one test and update

* Update managed-event-types.e2e.ts

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Lauris Skraucis <lauris.skraucis@gmail.com>
2024-06-11 09:27:07 +00:00
Martin Sione cd888be61e feat: Add VariableNode and AddVariablesPlugin (#15293) 2024-06-10 13:46:50 +00:00
Joe Au-Yeung 06231ec299 Add better error logging (#15383) 2024-06-10 10:10:19 +00:00
sean-brydonandUdit Takkar e38705c3e8 add missing migration (#15369)
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-06-08 14:20:25 +00:00
Hichem FantarandUdit Takkar 5c95268d1a feat: Datepicker transitions (#15313)
* fix: Kbar component styles

Fix Kbar styles on active state

* refactor: Add transition to event type list item

The commit refactors the event type list item component by adding a transition effect to improve the user experience.

* refactor: Add transition effect to DatePicker component

Add transition effect to improve the user experience in the DatePicker component.

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-06-08 14:05:49 +00:00
Kartik Saini b310eb1a7e feat: Remove minimum of 2 teams for making an Org (#15267)
* feat: Remove min of 2 teams for making an Org

* Set ORG_MINIMUM_PUBLISHED_TEAMS_SELF_SERVE to 0
2024-06-07 14:30:36 +00:00
051510fa36 fix sidebar height not growing (#15223)
Co-authored-by: Rajiv Sahal <rajivsahal@Rajivs-MacBook-Pro.local>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2024-06-07 10:19:07 +00:00
Peer Richelsen 7d1226b823 Delete apps/web/public/.well-known directory (#15362) 2024-06-06 19:59:15 +00:00
Morgan 7732a22c49 fix: event-types controller params validation (#15353) 2024-06-06 19:58:46 +00:00
sean-brydonandPeer Richelsen 2a41cd0770 feat: license creation admin PR (#15024)
* WIP: UI created to see if it matches RFC idea

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

* Add comment about proxy URL request when on older versions

* feat: create custom axios instance to use signature middleware

* fix: update form to respect new schema

* Add cal signature token to trubo env

* use backend mutation for creating license keys

* add loading state to creation button

* update form to have a success state

* fix type error for console.warning :')

* use api version v1

* restore checkLicense from main

* Add env vars to example

* nit: rename page export

* use US spelling

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2024-06-06 18:34:32 +00:00
a574e5e60f fix: trigger workflow only for first recurring event (#15128)
* fix isFirstRecurringEvent param

* code clean up

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
2024-06-06 18:34:17 +00:00
Keith Williams 0b6508379b v4.1.10 (#15359)
release.yml / release (push) Canceled after 0s
2024-06-06 18:25:42 +00:00
Peer Richelsen f6930df87d chore: removed next app dir banner (#15356)
* removed next app dir banner

* yarn lock
2024-06-06 16:55:32 +00:00
7d4e8e2210 feat: outlook 365 connect atom (#15318)
* custom for office 365 connect atom

* office 365 connect atom

* add office 365 atom to package exports

* fixup

* fixup

* fixup fixup

* refactors

* chore: refactor connect atoms

* fixup

* chore: refactor connect, enable custom redir

* fixup! Merge branch 'outlook-365-calendar-frontend' of github.com:calcom/cal.com into outlook-365-calendar-frontend

* fixup! fixup! Merge branch 'outlook-365-calendar-frontend' of github.com:calcom/cal.com into outlook-365-calendar-frontend

---------

Co-authored-by: Rajiv Sahal <rajivsahal@Rajivs-MacBook-Pro.local>
Co-authored-by: Morgan Vernay <morgan@cal.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2024-06-06 15:08:38 +00:00
50eeb712e3 fix: Exclude location options for seated event (#15174)
Co-authored-by: Somay Chauhan <somaychauhan98@gmail.com>
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
2024-06-06 13:24:00 +00:00
75edc4539c fix: [CAL-3578] [CAL-2733] Zoho calendar issues (#14905)
* fix zohocalender installation

* fix func name

* fix: [CAL-3578] [CAL-2733] check events in the calendar when checking availability

* fix: server location on credentials instead app keys

* refactor: remove console log

Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>

---------

Co-authored-by: pritam <pritam.pk36129@gmail.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
2024-06-06 11:49:57 +00:00
Hariom Balhara def87ea84a fix: Team and User Links (#15342)
* fix: Team and User Links

* Add tests
2024-06-06 11:40:29 +00:00
Peer Richelsen 4153e5b2d5 added apple verification (#15346) 2024-06-06 11:04:26 +00:00
Omar López 8c2f6ae145 Revert "fix: 404 collisions (#15249)" (#15339)
This reverts commit 5d01eb2f38.

Signed-off-by: zomars <zomars@me.com>
2024-06-05 20:33:19 +00:00
78ea4d2f68 chore: Remove deprecated logo/avatar/away fields (#14705)
* chore: Remove deprecated logo/avatar/away fields

* Use right variables for accounts-step-card

* Remove more deprecated logo/avatar usage

* Dont drop the fields until all usages are gone

* Remove the avatar/logo drops for staggered release

* fix: Typescript blindspot (unvalidated include fields)

* fix: Further updates (mostly to API) typescript missed

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Bailey Pumfleet <bailey@pumfleet.co.uk>
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-06-05 16:44:31 +00:00
Hariom Balhara e7c638c1e4 fix: Wrong error message when inviting (#15332)
* fix: Wrong message when inviting

* Fix num users invited
2024-06-05 13:23:47 +00:00
Amit Sharma c28fab4fe2 fix: install flow: wrong org team avatars (#15334) 2024-06-05 13:10:31 +00:00
Alex van Andel e8a02554b6 chore: Remove DeploySentinel (#15328) 2024-06-05 00:18:11 +00:00
Kartik Saini 4017c0e28f feat: org-wide webhooks (#15144)
* feat: include org in teams list

* feat: org-wide webhooks

* Move getOrgIdFromMemberOrTeamId to packages/lib
2024-06-04 20:16:46 +00:00
Keith Williams d72f7f8a42 v4.1.9 (#15321)
release.yml / release (push) Canceled after 0s
2024-06-04 16:32:54 +00:00
3a4ebadbf3 feat: track no-shows in bookings (#15307)
* added dropdown for no-shows in past bookings

* added layout

* nit

* todo comment: figure out how to close dropdown

* more todo comments

* Adds TRPC route among other WIP

* functional update

* fixes type err

* fix type err

* fix: styles

* strikethrough in email

* NITs

* removed stale comment

* add tests

* err comment

---------

Co-authored-by: Peer Richelsen <peer@cal.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: sean-brydon <sean@cal.com>
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
2024-06-04 15:18:11 +00:00
Hariom BalharaandJoe Au-Yeung 765d601ff9 fix: Booking Error - cannot be booked at this time (#15288)
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
2024-06-04 15:09:57 +00:00
3a22fdb428 feat: API v2 subversioning (#15135)
* feat: API v2 subversioning

* Update app.ts

* Added multiple versions to existing controllers

* Updated the slots controller

* chore: version platform library package with npm package alias

* chore: use consts to version

* chore: use consts to version

* chore: version api for cal provider

* chore: remove timezone controller subversion poc

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

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: Morgan Vernay <morgan@cal.com>
2024-06-04 15:05:39 +00:00
Rajiv SahalandRajiv Sahal 93580ac4bc fix: typo in Gcal atom prop and outlook service (#15319)
* fix typo in onCheckError prop

* pass in correct access token

---------

Co-authored-by: Rajiv Sahal <rajivsahal@Rajivs-MacBook-Pro.local>
2024-06-04 11:17:33 +00:00
Somay Chauhanandsean-brydon b15ad6f233 fix: inviting a team member dosent work when he login in via Google (#11312)
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
2024-06-04 07:54:26 +00:00
56e59ab3e1 fix: no rating submitted text fixed (#15007)
* no rating submitted text fixed

* Update packages/ui/components/empty-screen/EmptyScreen.tsx

* Update packages/ui/components/empty-screen/EmptyScreen.tsx

---------

Co-authored-by: Peer Richelsen <peer@cal.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2024-06-03 15:19:38 +00:00
Abhishek Gurav ee796933ee Updated CONTRIBUTING.md (#15102)
For AES256 CALENDSO_ENCRYPTION_KEY
2024-06-03 14:57:36 +00:00
Joe Au-Yeung d2f76edfd6 fix: Double encoding in addAppMutation (#15303)
* Remove double encoding

* Add test for OAuth page

* Add tests

* Remove test from integrations.e2e
2024-06-03 14:53:03 +00:00
Peer RichelsenandJoe Au-Yeung 822f0204a6 added admin API docs (#15297)
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
2024-06-03 14:41:13 +00:00
9770aff153 feat: outlook 365 calendar endpoints (#15077)
* init endpoints for office 365 syncing

* get web app url for oauth

* update typings

* nethods for office 365 calendar

* update office 365 connect endpoint

* update packages

* add calendar type and if office 365 calendar

* add SelectedCalendarsRepository and TokensModule

* add functions for getting oauth credentials, getting default calendars and redirect url for office 365 calendar

* update controllers

* e2e tests for office 365 calendar endpoints

* fixup! Merge branch 'main' into rajiv/cal-3544-outlookoffice365-integration-connect-atom

* fixup! fixup! Merge branch 'main' into rajiv/cal-3544-outlookoffice365-integration-connect-atom

* fix merge conflict

* update typings

* abstract microsoft outlook into its own module

* add one common interface for calendar apps

* make calendars dynamic

* abstract every calendar app into its own service

* cleanup

* make all calendar endpoints dynamic

* calendar app constants

* cleanup

* remove unused repositories

* rename microsoft calendar type

* fix typings

* add google and outlook calendar service to calendars module

* fixup

* some more e2e tests including  google calendar

* chore: small type change and apple calendar not implemented yet

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: Morgan Vernay <morgan@cal.com>
Co-authored-by: Rajiv Sahal <rajivsahal@Rajivs-MacBook-Pro.local>
2024-06-03 11:47:57 +00:00
Hichem Fantar 723d0ddec2 fix: Kbar component styles and improve transitions (#15283)
* fix: Kbar component styles

Fix Kbar styles on active state

* refactor: Add transition to event type list item

The commit refactors the event type list item component by adding a transition effect to improve the user experience.
2024-06-01 21:34:25 +00:00
Amit SharmaandSomay Chauhan 60b61eb625 feat: ability to assign people to round robin from org members (#14199)
* feat: ability to assign people to round robin from org members

* fix: include schedulingtype in filter

* fix: type check

* rename to EventTypesList

* rename to teams, change icon to link, early return if no events or teamId

* filter scheduling type array, change filter name

* rename to bulkAddMembersToTeams and bulkAddMembersToEventTypes

* filter refactor

* remove bulk

* filter remove null return

* Revert "filter remove null return"

This reverts commit 8ae6296dac24e5081b5896bb9dfe0c000c0f7faf.

* show checked if user already host in event type

* check boxes if user is part of the team

* remove hosts on unchecking boxes

* fix isSelected condition

* remove selected members from teams

---------

Co-authored-by: Somay Chauhan <somaychauhan98@gmail.com>
2024-06-01 21:30:52 +00:00
Omar López 5d2d99006b v4.1.8 (#15287)
release.yml / release (push) Canceled after 0s
2024-06-01 00:32:45 +00:00
Keith WilliamsandOmar López 5d01eb2f38 fix: 404 collisions (#15249)
* fix: 404 collisions

* Added E2E test

* Removed await

* Attempting to fix E2E

* fingers crossed

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

* fix: e2e tests

* fix: missing navigation to 404 page

* fix: don't interrupt post login navigation

* fix: Expect owner as possible host for RR events

---------

Signed-off-by: zomars <zomars@me.com>
Co-authored-by: Omar López <zomars@me.com>
2024-05-31 22:28:43 +00:00
Joe Au-Yeung ac3fb0a1cd fix: Typo in deleteCredential handler (#15281)
* Fix typo

* Added optional chaining
2024-05-31 16:57:20 +00:00
Hariom Balhara 1730ef7d5d feat: Add framework to send all events fired for embed to Analytics Apps (#15173)
* Send all events to analytics apps

* feat: Start sending all events fired for embed to Analytics Apps(GTM Support added)

* Add tests
2024-05-31 16:07:09 +00:00
Somay Chauhan 9e371ded90 add redirectUrl to /eventTypes page as well (#15274)
* During signup, if the account already exists, we redirect the user to /event-types instead of onboarding. Adding this redirection logic to /event-types page as well to ensure the user is redirected to the correct redirectUrl.
2024-05-31 15:06:11 +00:00
330 changed files with 10715 additions and 2109 deletions
+5
View File
@@ -19,6 +19,11 @@
# instructions. This environment variable is deprecated although still supported for backward compatibility.
# @see https://console.cal.com
CALCOM_LICENSE_KEY=
# Signature token for the Cal.com License API (used for self-hosted integrations)
# We will give you a token when we provide you with a license key this ensure you and only you can communicate with the Cal.com License API for your license key
CAL_SIGNATURE_TOKEN=
# The route to the Cal.com License API
CALCOM_PRIVATE_API_ROUTE=
# ***********************************************************************************************************
# - DATABASE ************************************************************************************************
@@ -37,12 +37,3 @@ jobs:
```
${{ steps.lint_pr_title.outputs.error_message }}
```
# Delete a previous comment when the issue has been resolved
- if: ${{ steps.lint_pr_title.outputs.error_message == null }}
uses: marocchino/sticky-pull-request-comment@v2
with:
header: pr-title-lint-error
message: |
Thank you for following the naming conventions! 🙏 Feel free to join our [discord](https://go.cal.com/discord) and post your PR link.
+1 -1
View File
@@ -99,7 +99,7 @@ To develop locally:
- Duplicate `.env.example` to `.env`.
- Use `openssl rand -base64 32` to generate a key and add it under `NEXTAUTH_SECRET` in the `.env` file.
- Use `openssl rand -base64 24` to generate a key and add it under `CALENDSO_ENCRYPTION_KEY` in the `.env` file.
- Use `openssl rand -base64 32` to generate a key and add it under `CALENDSO_ENCRYPTION_KEY` in the `.env` file.
6. Setup Node
If your Node version does not meet the project's requirements as instructed by the docs, "nvm" (Node Version Manager) allows using Node at the version required by the project:
+17
View File
@@ -0,0 +1,17 @@
import * as Sentry from "@sentry/nextjs";
export function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
// reduce sample rate to 10% on production
tracesSampleRate: process.env.NODE_ENV !== "production" ? 1.0 : 0.1,
});
}
if (process.env.NEXT_RUNTIME === "edge") {
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
});
}
}
@@ -14,3 +14,7 @@ export const withValidQueryIdTransformParseInt = withValidation({
type: "Zod",
mode: "query",
});
export const getTranscriptFromRecordingId = schemaQueryIdParseInt.extend({
recordingId: z.string(),
});
+11 -6
View File
@@ -2,7 +2,12 @@ const { withAxiom } = require("next-axiom");
const { withSentryConfig } = require("@sentry/nextjs");
const plugins = [withAxiom];
/** @type {import("next").NextConfig} */
const nextConfig = {
experimental: {
instrumentationHook: true,
},
transpilePackages: [
"@calcom/app-store",
"@calcom/core",
@@ -87,12 +92,12 @@ const nextConfig = {
};
if (!!process.env.NEXT_PUBLIC_SENTRY_DSN) {
nextConfig["sentry"] = {
autoInstrumentServerFunctions: true,
hideSourceMaps: true,
};
plugins.push(withSentryConfig);
plugins.push((nextConfig) =>
withSentryConfig(nextConfig, {
autoInstrumentServerFunctions: true,
hideSourceMaps: true,
})
);
}
module.exports = () => plugins.reduce((acc, next) => next(acc), nextConfig);
+1 -1
View File
@@ -30,7 +30,7 @@
"@calcom/lib": "*",
"@calcom/prisma": "*",
"@calcom/trpc": "*",
"@sentry/nextjs": "^7.73.0",
"@sentry/nextjs": "^8.8.0",
"bcryptjs": "^2.4.3",
"memory-cache": "^0.2.0",
"next": "^13.5.4",
@@ -0,0 +1,94 @@
import type { NextApiRequest } from "next";
import {
getTranscriptsAccessLinkFromRecordingId,
checkIfRoomNameMatchesInRecording,
} from "@calcom/core/videoClient";
import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server";
import prisma from "@calcom/prisma";
import type { PartialReference } from "@calcom/types/EventManager";
import { getTranscriptFromRecordingId } from "~/lib/validations/shared/queryIdTransformParseInt";
/**
* @swagger
* /bookings/{id}/transcripts/{recordingId}:
* get:
* summary: Find all Cal video transcripts of that recording
* operationId: getTranscriptsByRecordingId
* parameters:
* - in: path
* name: id
* schema:
* type: integer
* required: true
* description: ID of the booking for which transcripts need to be fetched.
* - in: path
* name: recordingId
* schema:
* type: string
* required: true
* description: ID of the recording(daily.co recording id) for which transcripts need to be fetched.
* - in: query
* name: apiKey
* required: true
* schema:
* type: string
* description: Your API key
* tags:
* - bookings
* responses:
* 200:
* description: OK
* content:
* application/json:
* 401:
* description: Authorization information is missing or invalid.
* 404:
* description: Booking was not found
*/
export async function getHandler(req: NextApiRequest) {
const { query } = req;
const { id, recordingId } = getTranscriptFromRecordingId.parse(query);
await checkIfRecordingBelongsToBooking(id, recordingId);
const transcriptsAccessLinks = await getTranscriptsAccessLinkFromRecordingId(recordingId);
return transcriptsAccessLinks;
}
const checkIfRecordingBelongsToBooking = async (bookingId: number, recordingId: string) => {
const booking = await prisma.booking.findUnique({
where: { id: bookingId },
include: { references: true },
});
if (!booking)
throw new HttpError({
statusCode: 404,
message: `No Booking found with booking id ${bookingId}`,
});
const roomName =
booking?.references?.find((reference: PartialReference) => reference.type === "daily_video")?.meetingId ??
undefined;
if (!roomName)
throw new HttpError({
statusCode: 404,
message: `No Booking Reference with Daily Video found with booking id ${bookingId}`,
});
const canUserAccessRecordingId = await checkIfRoomNameMatchesInRecording(roomName, recordingId);
if (!canUserAccessRecordingId) {
throw new HttpError({
statusCode: 403,
message: `This Recording Id ${recordingId} does not belong to booking ${bookingId}`,
});
}
};
export default defaultResponder(getHandler);
@@ -0,0 +1,16 @@
import type { NextApiRequest, NextApiResponse } from "next";
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"),
})(req, res);
})
);
+6 -1
View File
@@ -7,7 +7,12 @@ import { schemaUserReadPublic } from "~/lib/validations/user";
async function handler({ userId }: NextApiRequest) {
const data = await prisma.user.findUniqueOrThrow({ where: { id: userId } });
return { user: schemaUserReadPublic.parse(data) };
return {
user: schemaUserReadPublic.parse({
...data,
avatar: data.avatarUrl,
}),
};
}
export default defaultResponder(handler);
+4 -1
View File
@@ -45,7 +45,10 @@ export async function getHandler(req: NextApiRequest) {
if (!isSystemWideAdmin && query.userId !== req.userId)
throw new HttpError({ statusCode: 403, message: "Forbidden" });
const data = await prisma.user.findUnique({ where: { id: query.userId } });
const user = schemaUserReadPublic.parse(data);
const user = schemaUserReadPublic.parse({
...data,
avatar: data?.avatarUrl,
});
return { user };
}
+12 -1
View File
@@ -2,7 +2,9 @@ import type { NextApiRequest } from "next";
import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server";
import { uploadAvatar } from "@calcom/lib/server/avatar";
import prisma from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
import { schemaQueryUserId } from "~/lib/validations/shared/queryUserId";
import { schemaUserEditBodyParams, schemaUserReadPublic } from "~/lib/validations/user";
@@ -101,7 +103,8 @@ export async function patchHandler(req: NextApiRequest) {
if (!isSystemWideAdmin && query.userId !== req.userId)
throw new HttpError({ statusCode: 403, message: "Forbidden" });
const body = await schemaUserEditBodyParams.parseAsync(req.body);
const { avatar, ...body }: { avatar?: string | undefined } & Prisma.UserUpdateInput =
await schemaUserEditBodyParams.parseAsync(req.body);
// disable role or branding changes unless admin.
if (!isSystemWideAdmin) {
if (body.role) body.role = undefined;
@@ -119,6 +122,14 @@ export async function patchHandler(req: NextApiRequest) {
message: "Bad request: Invalid default schedule id",
});
}
if (avatar) {
body.avatarUrl = await uploadAvatar({
userId: query.userId,
avatar: await (await import("@calcom/lib/server/resizeBase64Image")).resizeBase64Image(avatar),
});
}
const data = await prisma.user.update({
where: { id: query.userId },
data: body,
-5
View File
@@ -1,5 +0,0 @@
import { init as SentryInit } from "@sentry/nextjs";
SentryInit({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
});
-6
View File
@@ -1,6 +0,0 @@
import { init as SentryInit } from "@sentry/nextjs";
SentryInit({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
tracesSampleRate: 1.0,
});
@@ -0,0 +1,129 @@
import prismaMock from "../../../../../../../../../tests/libs/__mocks__/prismaMock";
import type { Request, Response } from "express";
import type { NextApiRequest, NextApiResponse } from "next";
import { createMocks } from "node-mocks-http";
import { describe, expect, test, vi, afterEach } from "vitest";
import {
getTranscriptsAccessLinkFromRecordingId,
checkIfRoomNameMatchesInRecording,
} from "@calcom/core/videoClient";
import { buildBooking } from "@calcom/lib/test/builder";
import { getAccessibleUsers } from "~/lib/utils/retrieveScopedAccessibleUsers";
import authMiddleware from "../../../../../../pages/api/bookings/[id]/_auth-middleware";
import handler from "../../../../../../pages/api/bookings/[id]/transcripts/[recordingId]/_get";
type CustomNextApiRequest = NextApiRequest & Request;
type CustomNextApiResponse = NextApiResponse & Response;
vi.mock("@calcom/core/videoClient", () => {
return {
getTranscriptsAccessLinkFromRecordingId: vi.fn(),
checkIfRoomNameMatchesInRecording: vi.fn(),
};
});
vi.mock("~/lib/utils/retrieveScopedAccessibleUsers", () => {
return {
getAccessibleUsers: vi.fn(),
};
});
afterEach(() => {
vi.resetAllMocks();
});
const mockGetTranscripts = () => {
const downloadLinks = [{ format: "json", link: "https://URL1" }];
vi.mocked(getTranscriptsAccessLinkFromRecordingId).mockResolvedValue(downloadLinks);
vi.mocked(checkIfRoomNameMatchesInRecording).mockResolvedValue(true);
return downloadLinks;
};
const recordingId = "abc-xyz";
describe("GET /api/bookings/[id]/transcripts/[recordingId]", () => {
test("Returns transcripts if user is system-wide admin", async () => {
const adminUserId = 1;
const userId = 2;
const bookingId = 1111;
prismaMock.booking.findUnique.mockResolvedValue(
buildBooking({
id: bookingId,
userId,
references: [
{
id: 1,
type: "daily_video",
uid: "17OHkCH53pBa03FhxMbw",
meetingId: "17OHkCH53pBa03FhxMbw",
meetingPassword: "password",
meetingUrl: "https://URL",
},
],
})
);
const mockedTranscripts = mockGetTranscripts();
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
body: {},
query: {
id: bookingId,
recordingId,
},
});
req.isSystemWideAdmin = true;
req.userId = adminUserId;
await authMiddleware(req);
await handler(req, res);
expect(res.statusCode).toBe(200);
expect(JSON.parse(res._getData())).toEqual(mockedTranscripts);
});
test("Allows GET transcripts when user is org-wide admin", async () => {
const adminUserId = 1;
const memberUserId = 10;
const bookingId = 3333;
prismaMock.booking.findUnique.mockResolvedValue(
buildBooking({
id: bookingId,
userId: memberUserId,
references: [
{ id: 1, type: "daily_video", uid: "17OHkCH53pBa03FhxMbw", meetingId: "17OHkCH53pBa03FhxMbw" },
],
})
);
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
body: {},
query: {
id: bookingId,
recordingId,
},
});
req.userId = adminUserId;
req.isOrganizationOwnerOrAdmin = true;
mockGetTranscripts();
vi.mocked(getAccessibleUsers).mockResolvedValue([memberUserId]);
await authMiddleware(req);
await handler(req, res);
expect(res.statusCode).toBe(200);
});
});
+1
View File
@@ -2,5 +2,6 @@ const rootConfig = require("../../../packages/config/prettier-preset");
module.exports = {
...rootConfig,
importOrder: ["^./instrument", ...rootConfig.importOrder],
importOrderParserPlugins: ["typescript", "decorators-legacy"],
};
+5 -3
View File
@@ -25,11 +25,13 @@
},
"dependencies": {
"@calcom/platform-constants": "*",
"@calcom/platform-libraries": "0.0.2",
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
"@calcom/platform-libraries-0.0.4": "npm:@calcom/platform-libraries@0.0.4",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
"@calcom/prisma": "*",
"@golevelup/ts-jest": "^0.4.0",
"@microsoft/microsoft-graph-types-beta": "^0.42.0-preview",
"@nestjs/common": "^10.0.0",
"@nestjs/config": "^3.1.1",
"@nestjs/core": "^10.0.0",
@@ -38,8 +40,7 @@
"@nestjs/platform-express": "^10.0.0",
"@nestjs/swagger": "^7.3.0",
"@nestjs/throttler": "^5.1.2",
"@sentry/node": "^7.86.0",
"@sentry/tracing": "^7.86.0",
"@sentry/node": "^8.8.0",
"body-parser": "^1.20.2",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
@@ -55,6 +56,7 @@
"next-auth": "^4.22.1",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"querystring": "^0.2.1",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.8.1",
"stripe": "^15.3.0",
-2
View File
@@ -50,8 +50,6 @@ import { AppController } from "./app.controller";
EndpointsModule,
AuthModule,
JwtModule,
//register prefix for all routes in EndpointsModule
RouterModule.register([{ path: "/v2", module: EndpointsModule }]),
],
controllers: [AppController],
providers: [
+36 -15
View File
@@ -1,26 +1,43 @@
import { getEnv } from "@/env";
import "./instrument";
import { HttpExceptionFilter } from "@/filters/http-exception.filter";
import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter";
import { SentryFilter } from "@/filters/sentry-exception.filter";
import { ZodExceptionFilter } from "@/filters/zod-exception.filter";
import type { ValidationError } from "@nestjs/common";
import { BadRequestException, ValidationPipe, VersioningType } from "@nestjs/common";
import { HttpAdapterHost } from "@nestjs/core";
import { BaseExceptionFilter, HttpAdapterHost } from "@nestjs/core";
import type { NestExpressApplication } from "@nestjs/platform-express";
import * as Sentry from "@sentry/node";
import * as cookieParser from "cookie-parser";
import { Request } from "express";
import helmet from "helmet";
import { X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
import {
API_VERSIONS,
VERSION_2024_04_15,
API_VERSIONS_ENUM,
CAL_API_VERSION_HEADER,
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 => {
app.enableShutdownHooks();
app.enableVersioning({
type: VersioningType.URI,
prefix: "v",
defaultVersion: "1",
type: VersioningType.CUSTOM,
extractor: (request: unknown) => {
const headerVersion = (request as Request)?.headers[CAL_API_VERSION_HEADER] as string | undefined;
console.log("asap header headerVersion", headerVersion);
if (headerVersion && API_VERSIONS.includes(headerVersion as API_VERSIONS_ENUM)) {
console.log("asap return header headerVersion", headerVersion);
return headerVersion;
}
return VERSION_2024_04_15;
},
defaultVersion: VERSION_2024_04_15,
});
app.use(helmet());
@@ -28,7 +45,15 @@ export const bootstrap = (app: NestExpressApplication): NestExpressApplication =
app.enableCors({
origin: "*",
methods: ["GET", "PATCH", "DELETE", "HEAD", "POST", "PUT", "OPTIONS"],
allowedHeaders: [X_CAL_CLIENT_ID, X_CAL_SECRET_KEY, "Accept", "Authorization", "Content-Type", "Origin"],
allowedHeaders: [
X_CAL_CLIENT_ID,
X_CAL_SECRET_KEY,
CAL_API_VERSION_HEADER,
"Accept",
"Authorization",
"Content-Type",
"Origin",
],
maxAge: 86_400,
});
@@ -46,15 +71,11 @@ export const bootstrap = (app: NestExpressApplication): NestExpressApplication =
})
);
if (process.env.SENTRY_DSN) {
Sentry.init({
dsn: getEnv("SENTRY_DSN"),
});
}
// Exception filters, new filters go at the bottom, keep the order
const { httpAdapter } = app.get(HttpAdapterHost);
app.useGlobalFilters(new SentryFilter(httpAdapter));
if (process.env.SENTRY_DSN) {
Sentry.setupNestErrorHandler(app, new BaseExceptionFilter(httpAdapter));
}
app.useGlobalFilters(new PrismaExceptionFilter());
app.useGlobalFilters(new ZodExceptionFilter());
app.useGlobalFilters(new HttpExceptionFilter());
@@ -2,11 +2,10 @@ import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
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 { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { AvailabilitiesModule } from "@/modules/availabilities/availabilities.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { UsersModule } from "@/modules/users/users.module";
import { INestApplication } from "@nestjs/common";
@@ -20,7 +19,7 @@ import { UserRepositoryFixture } from "test/fixtures/repository/users.repository
import { withAccessTokenAuth } from "test/utils/withAccessTokenAuth";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
import { handleNewBooking } from "@calcom/platform-libraries";
import { handleNewBooking } from "@calcom/platform-libraries-0.0.2";
import { ApiSuccessResponse, ApiResponse } from "@calcom/platform-types";
describe("Bookings Endpoints", () => {
@@ -29,7 +28,7 @@ describe("Bookings Endpoints", () => {
let userRepositoryFixture: UserRepositoryFixture;
let bookingsRepositoryFixture: BookingsRepositoryFixture;
let schedulesService: SchedulesService;
let schedulesService: SchedulesService_2024_04_15;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
const userEmail = "bookings-controller-e2e@api.com";
@@ -43,7 +42,7 @@ describe("Bookings Endpoints", () => {
const moduleRef = await withAccessTokenAuth(
userEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, AvailabilitiesModule, UsersModule, SchedulesModule],
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
})
)
.overrideGuard(PermissionsGuard)
@@ -55,13 +54,13 @@ describe("Bookings Endpoints", () => {
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
schedulesService = moduleRef.get<SchedulesService>(SchedulesService);
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
user = await userRepositoryFixture.create({
email: userEmail,
});
const userSchedule: CreateScheduleInput = {
const userSchedule: CreateScheduleInput_2024_04_15 = {
name: "working time",
timeZone: "Europe/Rome",
isDefault: true,
@@ -2,6 +2,7 @@ import { CreateBookingInput } from "@/ee/bookings/inputs/create-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 { API_VERSIONS_VALUES } from "@/lib/api-versions";
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";
@@ -37,14 +38,14 @@ import {
getBookingInfo,
handleCancelBooking,
getBookingForReschedule,
} from "@calcom/platform-libraries";
} from "@calcom/platform-libraries-0.0.2";
import {
handleNewBooking,
BookingResponse,
HttpError,
handleNewRecurringBooking,
handleInstantMeeting,
} from "@calcom/platform-libraries";
} from "@calcom/platform-libraries-0.0.2";
import { GetBookingsInput, CancelBookingInput, Status } from "@calcom/platform-types";
import { ApiResponse } from "@calcom/platform-types";
import { PrismaClient } from "@calcom/prisma";
@@ -72,8 +73,8 @@ const DEFAULT_PLATFORM_PARAMS = {
};
@Controller({
path: "/bookings",
version: "2",
path: "/v2/bookings",
version: API_VERSIONS_VALUES,
})
@UseGuards(PermissionsGuard)
@DocsTags("Bookings")
@@ -1,7 +1,7 @@
import { CreateBookingInput } from "@/ee/bookings/inputs/create-booking.input";
import { IsBoolean, IsNumber, IsOptional } from "class-validator";
import type { AppsStatus } from "@calcom/platform-libraries";
import type { AppsStatus } from "@calcom/platform-libraries-0.0.2";
export class CreateRecurringBookingInput extends CreateBookingInput {
@IsBoolean()
@@ -0,0 +1,211 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
import { HttpExceptionFilter } from "@/filters/http-exception.filter";
import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import { PlatformOAuthClient, Team, User, Credential } from "@prisma/client";
import * as request from "supertest";
import { CredentialsRepositoryFixture } from "test/fixtures/repository/credentials.repository.fixture";
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { TokensRepositoryFixture } from "test/fixtures/repository/tokens.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import {
GOOGLE_CALENDAR,
OFFICE_365_CALENDAR,
GOOGLE_CALENDAR_TYPE,
GOOGLE_CALENDAR_ID,
} from "@calcom/platform-constants";
import { OFFICE_365_CALENDAR_ID, OFFICE_365_CALENDAR_TYPE } from "@calcom/platform-constants";
const CLIENT_REDIRECT_URI = "http://localhost:5555";
class CalendarsServiceMock {
async getCalendars() {
return {
connectedCalendars: [
{
integration: {
type: "google_calendar",
},
},
],
};
}
}
describe("Platform Calendars Endpoints", () => {
let app: INestApplication;
let oAuthClient: PlatformOAuthClient;
let organization: Team;
let userRepositoryFixture: UserRepositoryFixture;
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
let teamRepositoryFixture: TeamRepositoryFixture;
let tokensRepositoryFixture: TokensRepositoryFixture;
let credentialsRepositoryFixture: CredentialsRepositoryFixture;
let user: User;
let office365Credentials: Credential;
let googleCalendarCredentials: Credential;
let accessTokenSecret: string;
let refreshTokenSecret: string;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
providers: [PrismaExceptionFilter, HttpExceptionFilter],
imports: [AppModule, UsersModule, TokensModule],
})
.overrideGuard(PermissionsGuard)
.useValue({
canActivate: () => true,
})
.overrideProvider(CalendarsService)
.useClass(CalendarsServiceMock)
.compile();
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
tokensRepositoryFixture = new TokensRepositoryFixture(moduleRef);
credentialsRepositoryFixture = new CredentialsRepositoryFixture(moduleRef);
organization = await teamRepositoryFixture.create({ name: "organization" });
oAuthClient = await createOAuthClient(organization.id);
user = await userRepositoryFixture.createOAuthManagedUser("office365-connect@gmail.com", oAuthClient.id);
const tokens = await tokensRepositoryFixture.createTokens(user.id, oAuthClient.id);
accessTokenSecret = tokens.accessToken;
refreshTokenSecret = tokens.refreshToken;
await app.init();
});
async function createOAuthClient(organizationId: number) {
const data = {
logo: "logo-url",
name: "name",
redirectUris: [CLIENT_REDIRECT_URI],
permissions: 32,
};
const secret = "secret";
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
return client;
}
it("should be defined", () => {
expect(oauthClientRepositoryFixture).toBeDefined();
expect(userRepositoryFixture).toBeDefined();
expect(oAuthClient).toBeDefined();
expect(accessTokenSecret).toBeDefined();
expect(refreshTokenSecret).toBeDefined();
expect(user).toBeDefined();
});
it(`/GET/v2/calendars/${OFFICE_365_CALENDAR}/connect: it should respond 401 with invalid access token`, async () => {
await request(app.getHttpServer())
.get(`/v2/calendars/${OFFICE_365_CALENDAR}/connect`)
.set("Authorization", `Bearer invalid_access_token`)
.expect(401);
});
it(`/GET/v2/calendars/${OFFICE_365_CALENDAR}/connect: it should redirect to auth-url for office 365 calendar oauth with valid access token `, async () => {
const response = await request(app.getHttpServer())
.get(`/v2/calendars/${OFFICE_365_CALENDAR}/connect`)
.set("Authorization", `Bearer ${accessTokenSecret}`)
.set("Origin", CLIENT_REDIRECT_URI)
.expect(200);
const data = response.body.data;
expect(data.authUrl).toBeDefined();
});
it(`/GET/v2/calendars/${GOOGLE_CALENDAR}/connect: it should redirect to auth-url for google calendar oauth with valid access token `, async () => {
const response = await request(app.getHttpServer())
.get(`/v2/calendars/${GOOGLE_CALENDAR}/connect`)
.set("Authorization", `Bearer ${accessTokenSecret}`)
.set("Origin", CLIENT_REDIRECT_URI)
.expect(200);
const data = response.body.data;
expect(data.authUrl).toBeDefined();
});
it(`/GET/v2/calendars/random-calendar/connect: it should respond 400 with a message saying the calendar type is invalid`, async () => {
await request(app.getHttpServer())
.get(`/v2/calendars/random-calendar/connect`)
.set("Authorization", `Bearer ${accessTokenSecret}`)
.set("Origin", CLIENT_REDIRECT_URI)
.expect(400);
});
it(`/GET/v2/calendars/${OFFICE_365_CALENDAR}/save: without access token`, async () => {
await request(app.getHttpServer())
.get(
`/v2/calendars/${OFFICE_365_CALENDAR}/save?state=accessToken=${accessTokenSecret}&code=4/0AfJohXmBuT7QVrEPlAJLBu4ZcSnyj5jtDoJqSW_riPUhPXQ70RPGkOEbVO3xs-OzQwpPQw&scope=User.Read%20Calendars.Read%20Calendars.ReadWrite%20offline_access`
)
.expect(400);
});
it(`/GET/v2/calendars/${OFFICE_365_CALENDAR}/save: without origin`, async () => {
await request(app.getHttpServer())
.get(
`/v2/calendars/${OFFICE_365_CALENDAR}/save?state=accessToken=${accessTokenSecret}&code=4/0AfJohXmBuT7QVrEPlAJLBu4ZcSnyj5jtDoJqSW_riPUhPXQ70RPGkOEbVO3xs-OzQwpPQw&scope=User.Read%20Calendars.Read%20Calendars.ReadWrite%20offline_access`
)
.expect(400);
});
it(`/GET/v2/calendars/${OFFICE_365_CALENDAR}/check without access token`, async () => {
await request(app.getHttpServer()).get(`/v2/calendars/${OFFICE_365_CALENDAR}/check`).expect(401);
});
it(`/GET/v2/calendars/${OFFICE_365_CALENDAR}/check with no credentials`, async () => {
await request(app.getHttpServer())
.get(`/v2/calendars/${OFFICE_365_CALENDAR}/check`)
.set("Authorization", `Bearer ${accessTokenSecret}`)
.set("Origin", CLIENT_REDIRECT_URI)
.expect(400);
});
it(`/GET/v2/calendars/${OFFICE_365_CALENDAR}/check with access token, origin and office365 credentials`, async () => {
office365Credentials = await credentialsRepositoryFixture.create(
OFFICE_365_CALENDAR_TYPE,
{},
user.id,
OFFICE_365_CALENDAR_ID
);
await request(app.getHttpServer())
.get(`/v2/calendars/${OFFICE_365_CALENDAR}/check`)
.set("Authorization", `Bearer ${accessTokenSecret}`)
.set("Origin", CLIENT_REDIRECT_URI)
.expect(200);
});
it(`/GET/v2/calendars/${GOOGLE_CALENDAR}/check with access token, origin and google calendar credentials`, async () => {
googleCalendarCredentials = await credentialsRepositoryFixture.create(
GOOGLE_CALENDAR_TYPE,
{},
user.id,
GOOGLE_CALENDAR_ID
);
await request(app.getHttpServer())
.get(`/v2/calendars/${GOOGLE_CALENDAR}/check`)
.set("Authorization", `Bearer ${accessTokenSecret}`)
.set("Origin", CLIENT_REDIRECT_URI)
.expect(200);
});
afterAll(async () => {
await oauthClientRepositoryFixture.delete(oAuthClient.id);
await teamRepositoryFixture.delete(organization.id);
await credentialsRepositoryFixture.delete(office365Credentials.id);
await credentialsRepositoryFixture.delete(googleCalendarCredentials.id);
await userRepositoryFixture.deleteByEmail(user.email);
await app.close();
});
});
@@ -0,0 +1,12 @@
import { Request } from "express";
import { ApiResponse } from "@calcom/platform-types";
export interface CalendarApp {
save(state: string, code: string, origin: string): Promise<{ url: string }>;
check(userId: number): Promise<ApiResponse>;
}
export interface OAuthCalendarApp extends CalendarApp {
connect(authorization: string, req: Request): Promise<ApiResponse<{ authUrl: string }>>;
}
@@ -1,13 +1,25 @@
import { CalendarsController } from "@/ee/calendars/controllers/calendars.controller";
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
import { GoogleCalendarService } from "@/ee/calendars/services/gcal.service";
import { OutlookService } from "@/ee/calendars/services/outlook.service";
import { AppsRepository } from "@/modules/apps/apps.repository";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule, UsersModule],
providers: [CredentialsRepository, CalendarsService],
imports: [PrismaModule, UsersModule, TokensModule],
providers: [
CredentialsRepository,
CalendarsService,
OutlookService,
GoogleCalendarService,
SelectedCalendarsRepository,
AppsRepository,
],
controllers: [CalendarsController],
exports: [CalendarsService],
})
@@ -1,24 +1,48 @@
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 { GoogleCalendarService } from "@/ee/calendars/services/gcal.service";
import { OutlookService } from "@/ee/calendars/services/outlook.service";
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
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 { UserWithProfile } from "@/modules/users/users.repository";
import { Controller, Get, UseGuards, Query } from "@nestjs/common";
import {
Controller,
Get,
UseGuards,
Query,
HttpStatus,
HttpCode,
Req,
Param,
Headers,
Redirect,
BadRequestException,
} from "@nestjs/common";
import { ApiTags as DocsTags } from "@nestjs/swagger";
import { Request } from "express";
import { z } from "zod";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { CalendarBusyTimesInput } from "@calcom/platform-types";
import { APPS_READ } from "@calcom/platform-constants";
import { SUCCESS_STATUS, CALENDARS, GOOGLE_CALENDAR, OFFICE_365_CALENDAR } from "@calcom/platform-constants";
import { ApiResponse, CalendarBusyTimesInput } from "@calcom/platform-types";
@Controller({
path: "/calendars",
version: "2",
path: "/v2/calendars",
version: API_VERSIONS_VALUES,
})
@UseGuards(AccessTokenGuard)
@DocsTags("Calendars")
export class CalendarsController {
constructor(private readonly calendarsService: CalendarsService) {}
constructor(
private readonly calendarsService: CalendarsService,
private readonly outlookService: OutlookService,
private readonly googleCalendarService: GoogleCalendarService
) {}
@UseGuards(AccessTokenGuard)
@Get("/busy-times")
async getBusyTimes(
@Query() queryParams: CalendarBusyTimesInput,
@@ -47,6 +71,7 @@ export class CalendarsController {
}
@Get("/")
@UseGuards(AccessTokenGuard)
async getCalendars(@GetUser("id") userId: number): Promise<ConnectedCalendarsOutput> {
const calendars = await this.calendarsService.getCalendars(userId);
@@ -55,4 +80,74 @@ export class CalendarsController {
data: calendars,
};
}
@UseGuards(AccessTokenGuard)
@Get("/:calendar/connect")
@HttpCode(HttpStatus.OK)
async redirect(
@Req() req: Request,
@Headers("Authorization") authorization: string,
@Param("calendar") calendar: string,
@Query("redir") redir?: string | null
): Promise<ApiResponse<{ authUrl: string }>> {
switch (calendar) {
case OFFICE_365_CALENDAR:
return await this.outlookService.connect(authorization, req, redir ?? "");
case GOOGLE_CALENDAR:
return await this.googleCalendarService.connect(authorization, req, redir ?? "");
default:
throw new BadRequestException(
"Invalid calendar type, available calendars are: ",
CALENDARS.join(", ")
);
}
}
@Get("/:calendar/save")
@HttpCode(HttpStatus.OK)
@Redirect(undefined, 301)
async save(
@Query("state") state: string,
@Query("code") code: string,
@Param("calendar") calendar: string
): Promise<{ url: string }> {
// state params contains our user access token
const stateParams = new URLSearchParams(state);
const { accessToken, origin, redir } = z
.object({ accessToken: z.string(), origin: z.string(), redir: z.string().nullish().optional() })
.parse({
accessToken: stateParams.get("accessToken"),
origin: stateParams.get("origin"),
redir: stateParams.get("redir"),
});
switch (calendar) {
case OFFICE_365_CALENDAR:
return await this.outlookService.save(code, accessToken, origin, redir ?? "");
case GOOGLE_CALENDAR:
return await this.googleCalendarService.save(code, accessToken, origin, redir ?? "");
default:
throw new BadRequestException(
"Invalid calendar type, available calendars are: ",
CALENDARS.join(", ")
);
}
}
@Get("/:calendar/check")
@HttpCode(HttpStatus.OK)
@UseGuards(AccessTokenGuard, PermissionsGuard)
@Permissions([APPS_READ])
async check(@GetUser("id") userId: number, @Param("calendar") calendar: string): Promise<ApiResponse> {
switch (calendar) {
case OFFICE_365_CALENDAR:
return await this.outlookService.check(userId);
case GOOGLE_CALENDAR:
return await this.googleCalendarService.check(userId);
default:
throw new BadRequestException(
"Invalid calendar type, available calendars are: ",
CALENDARS.join(", ")
);
}
}
}
@@ -1,3 +1,4 @@
import { AppsRepository } from "@/modules/apps/apps.repository";
import {
CredentialsRepository,
CredentialsWithUserEmail,
@@ -11,21 +12,27 @@ import {
UnauthorizedException,
NotFoundException,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { User } from "@prisma/client";
import { DateTime } from "luxon";
import { z } from "zod";
import { getConnectedDestinationCalendars } from "@calcom/platform-libraries";
import { getBusyCalendarTimes } from "@calcom/platform-libraries";
import { getConnectedDestinationCalendars } from "@calcom/platform-libraries-0.0.2";
import { getBusyCalendarTimes } from "@calcom/platform-libraries-0.0.2";
import { Calendar } from "@calcom/platform-types";
import { PrismaClient } from "@calcom/prisma";
@Injectable()
export class CalendarsService {
private oAuthCalendarResponseSchema = z.object({ client_id: z.string(), client_secret: z.string() });
constructor(
private readonly usersRepository: UsersRepository,
private readonly credentialsRepository: CredentialsRepository,
private readonly appsRepository: AppsRepository,
private readonly dbRead: PrismaReadService,
private readonly dbWrite: PrismaWriteService
private readonly dbWrite: PrismaWriteService,
private readonly config: ConfigService
) {}
async getCalendars(userId: number) {
@@ -110,4 +117,24 @@ export class CalendarsService {
});
return composedSelectedCalendars;
}
async getAppKeys(appName: string) {
const app = await this.appsRepository.getAppBySlug(appName);
if (!app) {
throw new NotFoundException();
}
const { client_id, client_secret } = this.oAuthCalendarResponseSchema.parse(app.keys);
if (!client_id) {
throw new NotFoundException();
}
if (!client_secret) {
throw new NotFoundException();
}
return { client_id, client_secret };
}
}
@@ -0,0 +1,162 @@
import { OAuthCalendarApp } from "@/ee/calendars/calendars.interface";
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
import { AppsRepository } from "@/modules/apps/apps.repository";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { Logger, NotFoundException } from "@nestjs/common";
import { BadRequestException, UnauthorizedException } from "@nestjs/common";
import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Prisma } from "@prisma/client";
import { Request } from "express";
import { google } from "googleapis";
import { z } from "zod";
import { SUCCESS_STATUS, GOOGLE_CALENDAR_TYPE } from "@calcom/platform-constants";
const CALENDAR_SCOPES = [
"https://www.googleapis.com/auth/calendar.readonly",
"https://www.googleapis.com/auth/calendar.events",
];
@Injectable()
export class GoogleCalendarService implements OAuthCalendarApp {
private redirectUri = `${this.config.get("api.url")}/gcal/oauth/save`;
private gcalResponseSchema = z.object({ client_id: z.string(), client_secret: z.string() });
private logger = new Logger("GcalService");
constructor(
private readonly config: ConfigService,
private readonly appsRepository: AppsRepository,
private readonly credentialRepository: CredentialsRepository,
private readonly calendarsService: CalendarsService,
private readonly tokensRepository: TokensRepository,
private readonly selectedCalendarsRepository: SelectedCalendarsRepository
) {}
async connect(
authorization: string,
req: Request,
redir?: string
): Promise<{ status: typeof SUCCESS_STATUS; data: { authUrl: string } }> {
const accessToken = authorization.replace("Bearer ", "");
const origin = req.get("origin") ?? req.get("host");
const redirectUrl = await await this.getCalendarRedirectUrl(accessToken, origin ?? "", redir);
return { status: SUCCESS_STATUS, data: { authUrl: redirectUrl } };
}
async save(code: string, accessToken: string, origin: string, redir?: string): Promise<{ url: string }> {
return await this.saveCalendarCredentialsAndRedirect(code, accessToken, origin, redir);
}
async check(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
return await this.checkIfCalendarConnected(userId);
}
async getCalendarRedirectUrl(accessToken: string, origin: string, redir?: string) {
const oAuth2Client = await this.getOAuthClient(this.redirectUri);
const authUrl = oAuth2Client.generateAuthUrl({
access_type: "offline",
scope: CALENDAR_SCOPES,
prompt: "consent",
state: `accessToken=${accessToken}&origin=${origin}&redir=${redir ?? ""}`,
});
return authUrl;
}
async getOAuthClient(redirectUri: string) {
this.logger.log("Getting Google Calendar OAuth Client");
const app = await this.appsRepository.getAppBySlug("google-calendar");
if (!app) {
throw new NotFoundException();
}
const { client_id, client_secret } = this.gcalResponseSchema.parse(app.keys);
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirectUri);
return oAuth2Client;
}
async checkIfCalendarConnected(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
const gcalCredentials = await this.credentialRepository.getByTypeAndUserId("google_calendar", userId);
if (!gcalCredentials) {
throw new BadRequestException("Credentials for google_calendar not found.");
}
if (gcalCredentials.invalid) {
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 };
}
async saveCalendarCredentialsAndRedirect(
code: string,
accessToken: string,
origin: string,
redir?: string
) {
// User chose not to authorize your app or didn't authorize your app
// redirect directly without oauth code
if (!code) {
return { url: redir || origin };
}
const parsedCode = z.string().parse(code);
const ownerId = await this.tokensRepository.getAccessTokenOwnerId(accessToken);
if (!ownerId) {
throw new UnauthorizedException("Invalid Access token.");
}
const oAuth2Client = await this.getOAuthClient(this.redirectUri);
const token = await oAuth2Client.getToken(parsedCode);
// Google oAuth Credentials are stored in token.tokens
const key = token.tokens;
const credential = await this.credentialRepository.createAppCredential(
GOOGLE_CALENDAR_TYPE,
key as Prisma.InputJsonValue,
ownerId
);
oAuth2Client.setCredentials(key);
const calendar = google.calendar({
version: "v3",
auth: oAuth2Client,
});
const cals = await calendar.calendarList.list({ fields: "items(id,summary,primary,accessRole)" });
const primaryCal = cals.data.items?.find((cal) => cal.primary);
if (primaryCal?.id) {
await this.selectedCalendarsRepository.createSelectedCalendar(
primaryCal.id,
credential.id,
ownerId,
GOOGLE_CALENDAR_TYPE
);
}
return { url: redir || origin };
}
}
@@ -0,0 +1,189 @@
import { OAuthCalendarApp } from "@/ee/calendars/calendars.interface";
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import type { Calendar as OfficeCalendar } from "@microsoft/microsoft-graph-types-beta";
import { BadRequestException, UnauthorizedException } from "@nestjs/common";
import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Request } from "express";
import { stringify } from "querystring";
import { z } from "zod";
import {
SUCCESS_STATUS,
OFFICE_365_CALENDAR,
OFFICE_365_CALENDAR_ID,
OFFICE_365_CALENDAR_TYPE,
} from "@calcom/platform-constants";
@Injectable()
export class OutlookService implements OAuthCalendarApp {
private redirectUri = `${this.config.get("api.url")}/calendars/${OFFICE_365_CALENDAR}/save`;
constructor(
private readonly config: ConfigService,
private readonly calendarsService: CalendarsService,
private readonly credentialRepository: CredentialsRepository,
private readonly tokensRepository: TokensRepository,
private readonly selectedCalendarsRepository: SelectedCalendarsRepository
) {}
async connect(
authorization: string,
req: Request,
redir?: string
): Promise<{ status: typeof SUCCESS_STATUS; data: { authUrl: string } }> {
const accessToken = authorization.replace("Bearer ", "");
const origin = req.get("origin") ?? req.get("host");
const redirectUrl = await await this.getCalendarRedirectUrl(accessToken, origin ?? "", redir);
return { status: SUCCESS_STATUS, data: { authUrl: redirectUrl } };
}
async save(code: string, accessToken: string, origin: string, redir?: string): Promise<{ url: string }> {
return await this.saveCalendarCredentialsAndRedirect(code, accessToken, origin, redir);
}
async check(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
return await this.checkIfCalendarConnected(userId);
}
async getCalendarRedirectUrl(accessToken: string, origin: string, redir?: string) {
const { client_id } = await this.calendarsService.getAppKeys(OFFICE_365_CALENDAR_ID);
const scopes = ["User.Read", "Calendars.Read", "Calendars.ReadWrite", "offline_access"];
const params = {
response_type: "code",
scope: scopes.join(" "),
client_id,
prompt: "select_account",
redirect_uri: this.redirectUri,
state: `accessToken=${accessToken}&origin=${origin}&redir=${redir ?? ""}`,
};
const query = stringify(params);
const url = `https://login.microsoftonline.com/common/oauth2/v2.0/authorize?${query}`;
return url;
}
async checkIfCalendarConnected(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
const office365CalendarCredentials = await this.credentialRepository.getByTypeAndUserId(
"office365_calendar",
userId
);
if (!office365CalendarCredentials) {
throw new BadRequestException("Credentials for office_365_calendar not found.");
}
if (office365CalendarCredentials.invalid) {
throw new BadRequestException("Invalid office 365 calendar credentials.");
}
const { connectedCalendars } = await this.calendarsService.getCalendars(userId);
const office365Calendar = connectedCalendars.find(
(cal: { integration: { type: string } }) => cal.integration.type === OFFICE_365_CALENDAR_TYPE
);
if (!office365Calendar) {
throw new UnauthorizedException("Office 365 calendar not connected.");
}
if (office365Calendar.error?.message) {
throw new UnauthorizedException(office365Calendar.error?.message);
}
return {
status: SUCCESS_STATUS,
};
}
async getOAuthCredentials(code: string) {
const scopes = ["offline_access", "Calendars.Read", "Calendars.ReadWrite"];
const { client_id, client_secret } = await this.calendarsService.getAppKeys(OFFICE_365_CALENDAR_ID);
const toUrlEncoded = (payload: Record<string, string>) =>
Object.keys(payload)
.map((key) => `${key}=${encodeURIComponent(payload[key])}`)
.join("&");
const body = toUrlEncoded({
client_id,
grant_type: "authorization_code",
code,
scope: scopes.join(" "),
redirect_uri: this.redirectUri,
client_secret,
});
const response = await fetch("https://login.microsoftonline.com/common/oauth2/v2.0/token", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
},
body,
});
const responseBody = await response.json();
return responseBody;
}
async getDefaultCalendar(accessToken: string): Promise<OfficeCalendar> {
const response = await fetch("https://graph.microsoft.com/v1.0/me/calendar", {
method: "GET",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
});
const responseBody = await response.json();
return responseBody as OfficeCalendar;
}
async saveCalendarCredentialsAndRedirect(
code: string,
accessToken: string,
origin: string,
redir?: string
) {
// if code is not defined, user denied to authorize office 365 app, just redirect straight away
if (!code) {
return { url: redir || origin };
}
const parsedCode = z.string().parse(code);
const ownerId = await this.tokensRepository.getAccessTokenOwnerId(accessToken);
if (!ownerId) {
throw new UnauthorizedException("Invalid Access token.");
}
const office365OAuthCredentials = await this.getOAuthCredentials(parsedCode);
const defaultCalendar = await this.getDefaultCalendar(office365OAuthCredentials.access_token);
if (defaultCalendar?.id) {
const credential = await this.credentialRepository.createAppCredential(
OFFICE_365_CALENDAR_TYPE,
office365OAuthCredentials,
ownerId
);
await this.selectedCalendarsRepository.createSelectedCalendar(
defaultCalendar.id,
credential.id,
ownerId,
OFFICE_365_CALENDAR_TYPE
);
}
return {
url: redir || origin,
};
}
}
@@ -30,7 +30,7 @@ import {
EventTypesPublic,
eventTypeBookingFields,
eventTypeLocations,
} from "@calcom/platform-libraries";
} from "@calcom/platform-libraries-0.0.2";
import { ApiSuccessResponse } from "@calcom/platform-types";
describe("Event types Endpoints", () => {
@@ -204,6 +204,16 @@ describe("Event types Endpoints", () => {
});
});
it("should return 400 if param event type id is null", async () => {
const locations = [{ type: "inPerson", address: "123 Main St" }];
const body: UpdateEventTypeInput = {
locations,
};
return request(app.getHttpServer()).patch(`/api/v2/event-types/null`).send(body).expect(400);
});
it("should update event type locations", async () => {
const locations = [{ type: "inPerson", address: "123 Main St" }];
@@ -1,4 +1,5 @@
import { CreateEventTypeInput } from "@/ee/event-types/inputs/create-event-type.input";
import { EventTypeIdParams } from "@/ee/event-types/inputs/event-type-id.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";
@@ -9,6 +10,7 @@ import { GetEventTypesPublicOutput } from "@/ee/event-types/outputs/get-event-ty
import { GetEventTypesData, 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 { API_VERSIONS_VALUES } from "@/lib/api-versions";
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";
@@ -29,17 +31,18 @@ import {
Delete,
Query,
InternalServerErrorException,
ParseIntPipe,
} from "@nestjs/common";
import { ApiTags as DocsTags } from "@nestjs/swagger";
import { EVENT_TYPE_READ, EVENT_TYPE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
import { getPublicEvent } from "@calcom/platform-libraries";
import { getEventTypesByViewer } from "@calcom/platform-libraries";
import { getPublicEvent } from "@calcom/platform-libraries-0.0.2";
import { getEventTypesByViewer } from "@calcom/platform-libraries-0.0.2";
import { PrismaClient } from "@calcom/prisma";
@Controller({
path: "event-types",
version: "2",
path: "/v2/event-types",
version: API_VERSIONS_VALUES,
})
@UseGuards(PermissionsGuard)
@DocsTags("Event types")
@@ -68,7 +71,9 @@ export class EventTypesController {
@Permissions([EVENT_TYPE_READ])
@UseGuards(AccessTokenGuard)
async getEventType(
@Param("eventTypeId") eventTypeId: string,
@Param() params: EventTypeIdParams,
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
@GetUser() user: UserWithProfile
): Promise<GetEventTypeOutput> {
const eventType = await this.eventTypesService.getUserEventTypeForAtom(user, Number(eventTypeId));
@@ -144,7 +149,8 @@ export class EventTypesController {
@UseGuards(AccessTokenGuard)
@HttpCode(HttpStatus.OK)
async updateEventType(
@Param("eventTypeId") eventTypeId: number,
@Param() params: EventTypeIdParams,
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
@Body() body: UpdateEventTypeInput,
@GetUser() user: UserWithProfile
): Promise<UpdateEventTypeOutput> {
@@ -160,7 +166,8 @@ export class EventTypesController {
@Permissions([EVENT_TYPE_WRITE])
@UseGuards(AccessTokenGuard)
async deleteEventType(
@Param("eventTypeId") eventTypeId: number,
@Param() params: EventTypeIdParams,
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
@GetUser("id") userId: number
): Promise<DeleteEventTypeOutput> {
const eventType = await this.eventTypesService.deleteEventType(eventTypeId, userId);
@@ -4,7 +4,7 @@ import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { UserWithProfile } from "@/modules/users/users.repository";
import { Injectable } from "@nestjs/common";
import { getEventTypeById } from "@calcom/platform-libraries";
import { getEventTypeById } from "@calcom/platform-libraries-0.0.2";
import type { PrismaClient } from "@calcom/prisma";
@Injectable()
@@ -0,0 +1,6 @@
import { IsNumberString } from "class-validator";
export class EventTypeIdParams {
@IsNumberString()
eventTypeId!: number;
}
@@ -9,8 +9,8 @@ import { SelectedCalendarsRepository } from "@/modules/selected-calendars/select
import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository";
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
import { createEventType, updateEventType } from "@calcom/platform-libraries";
import { getEventTypesPublic, EventTypesPublic } from "@calcom/platform-libraries";
import { createEventType, updateEventType } from "@calcom/platform-libraries-0.0.2";
import { getEventTypesPublic, EventTypesPublic } from "@calcom/platform-libraries-0.0.2";
import { EventType } from "@calcom/prisma/client";
@Injectable()
+7 -52
View File
@@ -2,6 +2,7 @@ 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 { API_VERSIONS_VALUES } from "@/lib/api-versions";
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";
@@ -40,8 +41,8 @@ const CALENDAR_SCOPES = [
// Controller for the GCalConnect Atom
@Controller({
path: "/gcal",
version: "2",
path: "/v2/gcal",
version: API_VERSIONS_VALUES,
})
@DocsTags("Google Calendar")
export class GcalController {
@@ -81,56 +82,10 @@ export class GcalController {
@Redirect(undefined, 301)
@HttpCode(HttpStatus.OK)
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() })
.parse({ accessToken: stateParams.get("accessToken"), origin: stateParams.get("origin") });
// User chose not to authorize your app or didn't authorize your app
// redirect directly without oauth code
if (!code) {
return { url: origin };
}
const parsedCode = z.string().parse(code);
const ownerId = await this.tokensRepository.getAccessTokenOwnerId(accessToken);
if (!ownerId) {
throw new UnauthorizedException("Invalid Access token.");
}
const oAuth2Client = await this.gcalService.getOAuthClient(this.redirectUri);
const token = await oAuth2Client.getToken(parsedCode);
// Google oAuth Credentials are stored in token.tokens
const key = token.tokens;
const credential = await this.credentialRepository.createAppCredential(
GOOGLE_CALENDAR_TYPE,
key as Prisma.InputJsonValue,
ownerId
);
oAuth2Client.setCredentials(key);
const calendar = google.calendar({
version: "v3",
auth: oAuth2Client,
});
const cals = await calendar.calendarList.list({ fields: "items(id,summary,primary,accessRole)" });
const primaryCal = cals.data.items?.find((cal) => cal.primary);
if (primaryCal?.id) {
await this.selectedCalendarsRepository.createSelectedCalendar(
primaryCal.id,
credential.id,
ownerId,
GOOGLE_CALENDAR_TYPE
);
}
return { url: origin };
const url = new URL(this.config.get("api.url") + "/calendars/google/save");
url.searchParams.append("code", code);
url.searchParams.append("state", state);
return { url: url.href };
}
@Get("/check")
@@ -1,8 +1,7 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { SchedulesModule } from "@/ee/schedules/schedules.module";
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/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 { UpdateManagedUserInput } from "@/modules/users/inputs/update-managed-user.input";
@@ -34,14 +33,7 @@ describe("Me Endpoints", () => {
const moduleRef = await withAccessTokenAuth(
userEmail,
Test.createTestingModule({
imports: [
AppModule,
PrismaModule,
AvailabilitiesModule,
UsersModule,
TokensModule,
SchedulesModule,
],
imports: [AppModule, PrismaModule, UsersModule, TokensModule, SchedulesModule_2024_04_15],
})
)
.overrideGuard(PermissionsGuard)
@@ -118,13 +110,13 @@ describe("Me Endpoints", () => {
});
it("should not update user associated with access token given invalid time format", async () => {
const bodyWithIncorrectTimeFormat: UpdateManagedUserInput = { timeFormat: 100 };
const bodyWithIncorrectTimeFormat: UpdateManagedUserInput = { timeFormat: 100 as any };
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: UpdateManagedUserInput = { weekStart: "waba luba dub dub" };
const bodyWithIncorrectWeekStart: UpdateManagedUserInput = { weekStart: "waba luba dub dub" as any };
return request(app.getHttpServer()).patch("/v2/me").send(bodyWithIncorrectWeekStart).expect(400);
});
+5 -4
View File
@@ -1,6 +1,7 @@
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 { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
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";
@@ -14,15 +15,15 @@ import { PROFILE_READ, PROFILE_WRITE, SUCCESS_STATUS } from "@calcom/platform-co
import { userSchemaResponse } from "@calcom/platform-types";
@Controller({
path: "/me",
version: "2",
path: "/v2/me",
version: API_VERSIONS_VALUES,
})
@UseGuards(AccessTokenGuard, PermissionsGuard)
@DocsTags("Me")
export class MeController {
constructor(
private readonly usersRepository: UsersRepository,
private readonly schedulesRepository: SchedulesService
private readonly schedulesRepository: SchedulesService_2024_04_15
) {}
@Get("/")
+2 -2
View File
@@ -1,11 +1,11 @@
import { MeController } from "@/ee/me/me.controller";
import { SchedulesModule } from "@/ee/schedules/schedules.module";
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
import { Module } from "@nestjs/common";
@Module({
imports: [UsersModule, SchedulesModule, TokensModule],
imports: [UsersModule, SchedulesModule_2024_04_15, TokensModule],
controllers: [MeController],
})
export class MeModule {}
@@ -4,7 +4,8 @@ import { EventTypesModule } from "@/ee/event-types/event-types.module";
import { GcalModule } from "@/ee/gcal/gcal.module";
import { MeModule } from "@/ee/me/me.module";
import { ProviderModule } from "@/ee/provider/provider.module";
import { SchedulesModule } from "@/ee/schedules/schedules.module";
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
import { SchedulesModule_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.module";
import { SlotsModule } from "@/modules/slots/slots.module";
import type { MiddlewareConsumer, NestModule } from "@nestjs/common";
import { Module } from "@nestjs/common";
@@ -13,7 +14,8 @@ import { Module } from "@nestjs/common";
imports: [
GcalModule,
ProviderModule,
SchedulesModule,
SchedulesModule_2024_04_15,
SchedulesModule_2024_06_11,
MeModule,
EventTypesModule,
CalendarsModule,
@@ -1,5 +1,6 @@
import { ProviderVerifyAccessTokenOutput } from "@/ee/provider/outputs/verify-access-token.output";
import { ProviderVerifyClientOutput } from "@/ee/provider/outputs/verify-client.output";
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
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";
@@ -20,8 +21,8 @@ import { ApiTags as DocsTags } from "@nestjs/swagger";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
@Controller({
path: "/provider",
version: "2",
path: "/v2/provider",
version: API_VERSIONS_VALUES,
})
@DocsTags("Cal provider")
export class CalProviderController {
@@ -1,16 +0,0 @@
import { SchedulesController } from "@/ee/schedules/controllers/schedules.controller";
import { SchedulesRepository } from "@/ee/schedules/schedules.repository";
import { SchedulesService } from "@/ee/schedules/services/schedules.service";
import { AvailabilitiesModule } from "@/modules/availabilities/availabilities.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule, AvailabilitiesModule, UsersModule, TokensModule],
providers: [SchedulesRepository, SchedulesService],
controllers: [SchedulesController],
exports: [SchedulesService, SchedulesRepository],
})
export class SchedulesModule {}
@@ -1,12 +1,11 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
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 { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
import { CreateScheduleOutput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/outputs/create-schedule.output";
import { GetSchedulesOutput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/outputs/get-schedules.output";
import { UpdateScheduleOutput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/outputs/update-schedule.output";
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/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 { UsersModule } from "@/modules/users/users.module";
@@ -19,8 +18,8 @@ import { SchedulesRepositoryFixture } from "test/fixtures/repository/schedules.r
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { withAccessTokenAuth } from "test/utils/withAccessTokenAuth";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { UpdateScheduleInput } from "@calcom/platform-types";
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_04_15 } from "@calcom/platform-constants";
import { UpdateScheduleInput_2024_04_15 } from "@calcom/platform-types";
describe("Schedules Endpoints", () => {
describe("User Authentication", () => {
@@ -32,7 +31,7 @@ describe("Schedules Endpoints", () => {
const userEmail = "schedules-controller-e2e@api.com";
let user: User;
let createdSchedule: CreateScheduleOutput["data"];
let createdSchedule: CreateScheduleOutput_2024_04_15["data"];
const defaultAvailabilityDays = [1, 2, 3, 4, 5];
const defaultAvailabilityStartTime = "1970-01-01T09:00:00.000Z";
const defaultAvailabilityEndTime = "1970-01-01T17:00:00.000Z";
@@ -41,14 +40,7 @@ describe("Schedules Endpoints", () => {
const moduleRef = await withAccessTokenAuth(
userEmail,
Test.createTestingModule({
imports: [
AppModule,
PrismaModule,
AvailabilitiesModule,
UsersModule,
TokensModule,
SchedulesModule,
],
imports: [AppModule, PrismaModule, UsersModule, TokensModule, SchedulesModule_2024_04_15],
})
)
.overrideGuard(PermissionsGuard)
@@ -79,7 +71,7 @@ describe("Schedules Endpoints", () => {
const scheduleTimeZone = "Europe/Rome";
const isDefault = true;
const body: CreateScheduleInput = {
const body: CreateScheduleInput_2024_04_15 = {
name: scheduleName,
timeZone: scheduleTimeZone,
isDefault,
@@ -87,10 +79,11 @@ describe("Schedules Endpoints", () => {
return request(app.getHttpServer())
.post("/api/v2/schedules")
.set(CAL_API_VERSION_HEADER, VERSION_2024_04_15)
.send(body)
.expect(201)
.then(async (response) => {
const responseData: CreateScheduleOutput = response.body;
const responseData: CreateScheduleOutput_2024_04_15 = response.body;
expect(responseData.status).toEqual(SUCCESS_STATUS);
expect(responseData.data).toBeDefined();
expect(responseData.data.isDefault).toEqual(isDefault);
@@ -117,7 +110,7 @@ describe("Schedules Endpoints", () => {
.get("/api/v2/schedules/default")
.expect(200)
.then(async (response) => {
const responseData: CreateScheduleOutput = response.body;
const responseData: CreateScheduleOutput_2024_04_15 = response.body;
expect(responseData.status).toEqual(SUCCESS_STATUS);
expect(responseData.data).toBeDefined();
expect(responseData.data.id).toEqual(createdSchedule.id);
@@ -137,7 +130,7 @@ describe("Schedules Endpoints", () => {
.get(`/api/v2/schedules`)
.expect(200)
.then((response) => {
const responseData: GetSchedulesOutput = response.body;
const responseData: GetSchedulesOutput_2024_04_15 = response.body;
expect(responseData.status).toEqual(SUCCESS_STATUS);
expect(responseData.data).toBeDefined();
expect(responseData.data?.[0].id).toEqual(createdSchedule.id);
@@ -155,16 +148,17 @@ describe("Schedules Endpoints", () => {
it("should update schedule name", async () => {
const newScheduleName = "new-schedule-name";
const body: UpdateScheduleInput = {
const body: UpdateScheduleInput_2024_04_15 = {
name: newScheduleName,
};
return request(app.getHttpServer())
.patch(`/api/v2/schedules/${createdSchedule.id}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_04_15)
.send(body)
.expect(200)
.then((response: any) => {
const responseData: UpdateScheduleOutput = response.body;
const responseData: UpdateScheduleOutput_2024_04_15 = response.body;
expect(responseData.status).toEqual(SUCCESS_STATUS);
expect(responseData.data).toBeDefined();
expect(responseData.data.schedule.name).toEqual(newScheduleName);
@@ -1,10 +1,11 @@
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 { CreateScheduleOutput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/outputs/create-schedule.output";
import { DeleteScheduleOutput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/outputs/delete-schedule.output";
import { GetDefaultScheduleOutput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/outputs/get-default-schedule.output";
import { GetScheduleOutput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/outputs/get-schedule.output";
import { GetSchedulesOutput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/outputs/get-schedules.output";
import { UpdateScheduleOutput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/outputs/update-schedule.output";
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
import { VERSION_2024_04_15_VALUE } from "@/lib/api-versions";
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";
@@ -26,25 +27,25 @@ import { ApiResponse, ApiTags as DocsTags } from "@nestjs/swagger";
import { Throttle } from "@nestjs/throttler";
import { SCHEDULE_READ, SCHEDULE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
import { UpdateScheduleInput } from "@calcom/platform-types";
import { UpdateScheduleInput_2024_04_15 } from "@calcom/platform-types";
import { CreateScheduleInput } from "../inputs/create-schedule.input";
import { CreateScheduleInput_2024_04_15 } from "../inputs/create-schedule.input";
@Controller({
path: "schedules",
version: "2",
path: "/v2/schedules",
version: VERSION_2024_04_15_VALUE,
})
@UseGuards(AccessTokenGuard, PermissionsGuard)
@DocsTags("Schedules")
export class SchedulesController {
constructor(private readonly schedulesService: SchedulesService) {}
export class SchedulesController_2024_04_15 {
constructor(private readonly schedulesService: SchedulesService_2024_04_15) {}
@Post("/")
@Permissions([SCHEDULE_WRITE])
async createSchedule(
@GetUser() user: UserWithProfile,
@Body() bodySchedule: CreateScheduleInput
): Promise<CreateScheduleOutput> {
@Body() bodySchedule: CreateScheduleInput_2024_04_15
): Promise<CreateScheduleOutput_2024_04_15> {
const schedule = await this.schedulesService.createUserSchedule(user.id, bodySchedule);
const scheduleFormatted = await this.schedulesService.formatScheduleForAtom(user, schedule);
@@ -56,8 +57,14 @@ export class SchedulesController {
@Get("/default")
@Permissions([SCHEDULE_READ])
@ApiResponse({ status: 200, description: "Returns the default schedule", type: GetDefaultScheduleOutput })
async getDefaultSchedule(@GetUser() user: UserWithProfile): Promise<GetDefaultScheduleOutput | null> {
@ApiResponse({
status: 200,
description: "Returns the default schedule",
type: GetDefaultScheduleOutput_2024_04_15,
})
async getDefaultSchedule(
@GetUser() user: UserWithProfile
): Promise<GetDefaultScheduleOutput_2024_04_15 | null> {
const schedule = await this.schedulesService.getUserScheduleDefault(user.id);
const scheduleFormatted = schedule
? await this.schedulesService.formatScheduleForAtom(user, schedule)
@@ -75,7 +82,7 @@ export class SchedulesController {
async getSchedule(
@GetUser() user: UserWithProfile,
@Param("scheduleId") scheduleId: number
): Promise<GetScheduleOutput> {
): Promise<GetScheduleOutput_2024_04_15> {
const schedule = await this.schedulesService.getUserSchedule(user.id, scheduleId);
const scheduleFormatted = await this.schedulesService.formatScheduleForAtom(user, schedule);
@@ -87,7 +94,7 @@ export class SchedulesController {
@Get("/")
@Permissions([SCHEDULE_READ])
async getSchedules(@GetUser() user: UserWithProfile): Promise<GetSchedulesOutput> {
async getSchedules(@GetUser() user: UserWithProfile): Promise<GetSchedulesOutput_2024_04_15> {
const schedules = await this.schedulesService.getUserSchedules(user.id);
const schedulesFormatted = await this.schedulesService.formatSchedulesForAtom(user, schedules);
@@ -102,9 +109,9 @@ export class SchedulesController {
@Permissions([SCHEDULE_WRITE])
async updateSchedule(
@GetUser() user: UserWithProfile,
@Body() bodySchedule: UpdateScheduleInput,
@Body() bodySchedule: UpdateScheduleInput_2024_04_15,
@Param("scheduleId") scheduleId: string
): Promise<UpdateScheduleOutput> {
): Promise<UpdateScheduleOutput_2024_04_15> {
const updatedSchedule = await this.schedulesService.updateUserSchedule(
user,
Number(scheduleId),
@@ -123,7 +130,7 @@ export class SchedulesController {
async deleteSchedule(
@GetUser("id") userId: number,
@Param("scheduleId") scheduleId: number
): Promise<DeleteScheduleOutput> {
): Promise<DeleteScheduleOutput_2024_04_15> {
await this.schedulesService.deleteUserSchedule(userId, scheduleId);
return {
@@ -3,7 +3,7 @@ import { ApiProperty } from "@nestjs/swagger";
import { Transform, TransformFnParams } from "class-transformer";
import { IsArray, IsDate, IsNumber } from "class-validator";
export class CreateAvailabilityInput {
export class CreateAvailabilityInput_2024_04_15 {
@IsArray()
@IsNumber({}, { each: true })
@ApiProperty({ example: [1, 2] })
@@ -1,8 +1,8 @@
import { CreateAvailabilityInput } from "@/modules/availabilities/inputs/create-availability.input";
import { CreateAvailabilityInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-availability.input";
import { Type } from "class-transformer";
import { IsArray, IsBoolean, IsTimeZone, IsOptional, IsString, ValidateNested } from "class-validator";
export class CreateScheduleInput {
export class CreateScheduleInput_2024_04_15 {
@IsString()
name!: string;
@@ -11,9 +11,9 @@ export class CreateScheduleInput {
@IsArray()
@ValidateNested({ each: true })
@Type(() => CreateAvailabilityInput)
@Type(() => CreateAvailabilityInput_2024_04_15)
@IsOptional()
availabilities?: CreateAvailabilityInput[];
availabilities?: CreateAvailabilityInput_2024_04_15[];
@IsBoolean()
isDefault!: boolean;
@@ -1,11 +1,11 @@
import { ScheduleOutput } from "@/ee/schedules/outputs/schedule.output";
import { ScheduleOutput } from "@/ee/schedules/schedules_2024_04_15/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 {
export class CreateScheduleOutput_2024_04_15 {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@@ -3,7 +3,7 @@ import { IsEnum } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
export class DeleteScheduleOutput {
export class DeleteScheduleOutput_2024_04_15 {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@@ -1,11 +1,11 @@
import { ScheduleOutput } from "@/ee/schedules/outputs/schedule.output";
import { ScheduleOutput } from "@/ee/schedules/schedules_2024_04_15/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 {
export class GetDefaultScheduleOutput_2024_04_15 {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@@ -1,11 +1,11 @@
import { ScheduleOutput } from "@/ee/schedules/outputs/schedule.output";
import { ScheduleOutput } from "@/ee/schedules/schedules_2024_04_15/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 {
export class GetScheduleOutput_2024_04_15 {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@@ -1,11 +1,11 @@
import { ScheduleOutput } from "@/ee/schedules/outputs/schedule.output";
import { ScheduleOutput } from "@/ee/schedules/schedules_2024_04_15/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 {
export class GetSchedulesOutput_2024_04_15 {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@@ -1,7 +1,7 @@
import { Type } from "class-transformer";
import { IsBoolean, IsInt, IsOptional, IsString, ValidateNested, IsArray } from "class-validator";
class EventTypeModel {
class EventTypeModel_2024_04_15 {
@IsInt()
id!: number;
@@ -10,7 +10,7 @@ class EventTypeModel {
eventName?: string | null;
}
class AvailabilityModel {
class AvailabilityModel_2024_04_15 {
@IsInt()
id!: number;
@@ -46,7 +46,7 @@ class AvailabilityModel {
date?: Date | null;
}
class ScheduleModel {
class ScheduleModel_2024_04_15 {
@IsInt()
id!: number;
@@ -62,21 +62,21 @@ class ScheduleModel {
@IsOptional()
@ValidateNested({ each: true })
@Type(() => EventTypeModel)
@Type(() => EventTypeModel_2024_04_15)
@IsArray()
eventType?: EventTypeModel[];
eventType?: EventTypeModel_2024_04_15[];
@IsOptional()
@ValidateNested({ each: true })
@Type(() => AvailabilityModel)
@Type(() => AvailabilityModel_2024_04_15)
@IsArray()
availability?: AvailabilityModel[];
availability?: AvailabilityModel_2024_04_15[];
}
export class UpdatedScheduleOutput {
export class UpdatedScheduleOutput_2024_04_15 {
@ValidateNested()
@Type(() => ScheduleModel)
schedule!: ScheduleModel;
@Type(() => ScheduleModel_2024_04_15)
schedule!: ScheduleModel_2024_04_15;
@IsBoolean()
isDefault!: boolean;
@@ -0,0 +1,20 @@
import { UpdatedScheduleOutput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/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_2024_04_15 {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
type: UpdatedScheduleOutput_2024_04_15,
})
@IsNotEmptyObject()
@ValidateNested()
@Type(() => UpdatedScheduleOutput_2024_04_15)
data!: UpdatedScheduleOutput_2024_04_15;
}
@@ -0,0 +1,15 @@
import { SchedulesController_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/controllers/schedules.controller";
import { SchedulesRepository_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.repository";
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule, UsersModule, TokensModule],
providers: [SchedulesRepository_2024_04_15, SchedulesService_2024_04_15],
controllers: [SchedulesController_2024_04_15],
exports: [SchedulesService_2024_04_15, SchedulesRepository_2024_04_15],
})
export class SchedulesModule_2024_04_15 {}
@@ -1,18 +1,18 @@
import { CreateScheduleInput } from "@/ee/schedules/inputs/create-schedule.input";
import { CreateAvailabilityInput } from "@/modules/availabilities/inputs/create-availability.input";
import { CreateAvailabilityInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-availability.input";
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
import { Prisma } from "@prisma/client";
@Injectable()
export class SchedulesRepository {
export class SchedulesRepository_2024_04_15 {
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
async createScheduleWithAvailabilities(
userId: number,
schedule: CreateScheduleInput,
availabilities: CreateAvailabilityInput[]
schedule: CreateScheduleInput_2024_04_15,
availabilities: CreateAvailabilityInput_2024_04_15[]
) {
const createScheduleData: Prisma.ScheduleCreateInput = {
user: {
@@ -1,26 +1,25 @@
import { CreateScheduleInput } from "@/ee/schedules/inputs/create-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 { CreateAvailabilityInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-availability.input";
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
import { ScheduleOutput } from "@/ee/schedules/schedules_2024_04_15/outputs/schedule.output";
import { SchedulesRepository_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.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 type { ScheduleWithAvailabilities } from "@calcom/platform-libraries-0.0.2";
import { updateScheduleHandler } from "@calcom/platform-libraries-0.0.2";
import {
transformWorkingHoursForClient,
transformAvailabilityForClient,
transformDateOverridesForClient,
} from "@calcom/platform-libraries";
import { UpdateScheduleInput } from "@calcom/platform-types";
} from "@calcom/platform-libraries-0.0.2";
import { UpdateScheduleInput_2024_04_15 } from "@calcom/platform-types";
@Injectable()
export class SchedulesService {
export class SchedulesService_2024_04_15 {
constructor(
private readonly schedulesRepository: SchedulesRepository,
private readonly availabilitiesService: AvailabilitiesService,
private readonly schedulesRepository: SchedulesRepository_2024_04_15,
private readonly usersRepository: UsersRepository
) {}
@@ -34,10 +33,10 @@ export class SchedulesService {
return this.createUserSchedule(userId, schedule);
}
async createUserSchedule(userId: number, schedule: CreateScheduleInput) {
async createUserSchedule(userId: number, schedule: CreateScheduleInput_2024_04_15) {
const availabilities = schedule.availabilities?.length
? schedule.availabilities
: [this.availabilitiesService.getDefaultAvailabilityInput()];
: [this.getDefaultAvailabilityInput()];
const createdSchedule = await this.schedulesRepository.createScheduleWithAvailabilities(
userId,
@@ -76,7 +75,11 @@ export class SchedulesService {
return this.schedulesRepository.getSchedulesByUserId(userId);
}
async updateUserSchedule(user: UserWithProfile, scheduleId: number, bodySchedule: UpdateScheduleInput) {
async updateUserSchedule(
user: UserWithProfile,
scheduleId: number,
bodySchedule: UpdateScheduleInput_2024_04_15
) {
const existingSchedule = await this.schedulesRepository.getScheduleById(scheduleId);
if (!existingSchedule) {
@@ -158,4 +161,15 @@ export class SchedulesService {
throw new ForbiddenException(`User with ID=${userId} does not own schedule with ID=${schedule.id}`);
}
}
getDefaultAvailabilityInput(): CreateAvailabilityInput_2024_04_15 {
const startTime = new Date(new Date().setUTCHours(9, 0, 0, 0));
const endTime = new Date(new Date().setUTCHours(17, 0, 0, 0));
return {
days: [1, 2, 3, 4, 5],
startTime,
endTime,
};
}
}
@@ -0,0 +1,238 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { SchedulesModule_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.module";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import { User } from "@prisma/client";
import * as request from "supertest";
import { SchedulesRepositoryFixture } from "test/fixtures/repository/schedules.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { withAccessTokenAuth } from "test/utils/withAccessTokenAuth";
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_06_11 } from "@calcom/platform-constants";
import {
CreateScheduleInput_2024_06_11,
CreateScheduleOutput_2024_06_11,
GetScheduleOutput_2024_06_11,
GetSchedulesOutput_2024_06_11,
ScheduleOutput_2024_06_11,
UpdateScheduleOutput_2024_06_11,
} from "@calcom/platform-types";
import { UpdateScheduleInput_2024_06_11 } from "@calcom/platform-types";
describe("Schedules Endpoints", () => {
describe("User Authentication", () => {
let app: INestApplication;
let userRepositoryFixture: UserRepositoryFixture;
let scheduleRepositoryFixture: SchedulesRepositoryFixture;
const userEmail = "schedules-controller-e2e@api.com";
let user: User;
const createScheduleInput: CreateScheduleInput_2024_06_11 = {
name: "work",
timeZone: "Europe/Rome",
isDefault: true,
};
const defaultAvailability: CreateScheduleInput_2024_06_11["availability"] = [
{
days: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"],
startTime: "09:00",
endTime: "17:00",
},
];
let createdSchedule: CreateScheduleOutput_2024_06_11["data"];
beforeAll(async () => {
const moduleRef = await withAccessTokenAuth(
userEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, TokensModule, SchedulesModule_2024_06_11],
})
)
.overrideGuard(PermissionsGuard)
.useValue({
canActivate: () => true,
})
.compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
scheduleRepositoryFixture = new SchedulesRepositoryFixture(moduleRef);
user = await userRepositoryFixture.create({
email: userEmail,
});
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
it("should be defined", () => {
expect(userRepositoryFixture).toBeDefined();
expect(user).toBeDefined();
});
it("should create a default schedule", async () => {
return request(app.getHttpServer())
.post("/api/v2/schedules")
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_11)
.send(createScheduleInput)
.expect(201)
.then(async (response) => {
const responseBody: CreateScheduleOutput_2024_06_11 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
createdSchedule = response.body.data;
const expectedSchedule = {
...createScheduleInput,
availability: defaultAvailability,
overrides: [],
};
outputScheduleMatchesExpected(createdSchedule, expectedSchedule, 1);
const scheduleOwner = createdSchedule.ownerId
? await userRepositoryFixture.get(createdSchedule.ownerId)
: null;
expect(scheduleOwner?.defaultScheduleId).toEqual(createdSchedule.id);
});
});
function outputScheduleMatchesExpected(
outputSchedule: ScheduleOutput_2024_06_11 | null,
expected: CreateScheduleInput_2024_06_11 & {
availability: CreateScheduleInput_2024_06_11["availability"];
} & {
overrides: CreateScheduleInput_2024_06_11["overrides"];
},
expectedAvailabilityLength: number
) {
expect(outputSchedule).toBeTruthy();
expect(outputSchedule?.name).toEqual(expected.name);
expect(outputSchedule?.timeZone).toEqual(expected.timeZone);
expect(outputSchedule?.isDefault).toEqual(expected.isDefault);
expect(outputSchedule?.availability.length).toEqual(expectedAvailabilityLength);
const outputScheduleAvailability = outputSchedule?.availability[0];
expect(outputScheduleAvailability).toBeDefined();
expect(outputScheduleAvailability?.days).toEqual(expected.availability?.[0].days);
expect(outputScheduleAvailability?.startTime).toEqual(expected.availability?.[0].startTime);
expect(outputScheduleAvailability?.endTime).toEqual(expected.availability?.[0].endTime);
expect(JSON.stringify(outputSchedule?.overrides)).toEqual(JSON.stringify(expected.overrides));
}
it("should get default schedule", async () => {
return request(app.getHttpServer())
.get("/api/v2/schedules/default")
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_11)
.expect(200)
.then(async (response) => {
const responseBody: GetScheduleOutput_2024_06_11 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const outputSchedule = responseBody.data;
const expectedSchedule = {
...createScheduleInput,
availability: defaultAvailability,
overrides: [],
};
outputScheduleMatchesExpected(outputSchedule, expectedSchedule, 1);
});
});
it("should get schedules", async () => {
return request(app.getHttpServer())
.get(`/api/v2/schedules`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_11)
.expect(200)
.then((response) => {
const responseBody: GetSchedulesOutput_2024_06_11 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const outputSchedule = responseBody.data[0];
const expectedSchedule = {
...createScheduleInput,
availability: defaultAvailability,
overrides: [],
};
outputScheduleMatchesExpected(outputSchedule, expectedSchedule, 1);
});
});
it("should update schedule name", async () => {
const newScheduleName = "updated-schedule-name";
const body: UpdateScheduleInput_2024_06_11 = {
name: newScheduleName,
};
return request(app.getHttpServer())
.patch(`/api/v2/schedules/${createdSchedule.id}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_11)
.send(body)
.expect(200)
.then((response: any) => {
const responseData: UpdateScheduleOutput_2024_06_11 = response.body;
expect(responseData.status).toEqual(SUCCESS_STATUS);
const responseSchedule = responseData.data;
const expectedSchedule = { ...createdSchedule, name: newScheduleName };
outputScheduleMatchesExpected(responseSchedule, expectedSchedule, 1);
createdSchedule = responseSchedule;
});
});
it("should add overrides", async () => {
const overrides = [
{
date: "2026-05-05",
startTime: "10:00",
endTime: "12:00",
},
];
const body: UpdateScheduleInput_2024_06_11 = {
overrides,
};
return request(app.getHttpServer())
.patch(`/api/v2/schedules/${createdSchedule.id}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_11)
.send(body)
.expect(200)
.then((response: any) => {
const responseData: UpdateScheduleOutput_2024_06_11 = response.body;
expect(responseData.status).toEqual(SUCCESS_STATUS);
const responseSchedule = responseData.data;
const expectedSchedule = { ...createdSchedule, overrides };
outputScheduleMatchesExpected(responseSchedule, expectedSchedule, 1);
createdSchedule = responseSchedule;
});
});
it("should delete schedule", async () => {
return request(app.getHttpServer()).delete(`/api/v2/schedules/${createdSchedule.id}`).expect(200);
});
afterAll(async () => {
await userRepositoryFixture.deleteByEmail(user.email);
try {
await scheduleRepositoryFixture.deleteById(createdSchedule.id);
} catch (e) {}
await app.close();
});
});
});
@@ -0,0 +1,132 @@
import { SchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/schedules.service";
import { VERSION_2024_06_11_VALUE } from "@/lib/api-versions";
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 { UserWithProfile } from "@/modules/users/users.repository";
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
Post,
Patch,
UseGuards,
} from "@nestjs/common";
import { ApiResponse, ApiTags as DocsTags } from "@nestjs/swagger";
import { Throttle } from "@nestjs/throttler";
import { SCHEDULE_READ, SCHEDULE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
import {
CreateScheduleOutput_2024_06_11,
CreateScheduleInput_2024_06_11,
UpdateScheduleInput_2024_06_11,
GetScheduleOutput_2024_06_11,
UpdateScheduleOutput_2024_06_11,
GetDefaultScheduleOutput_2024_06_11,
DeleteScheduleOutput_2024_06_11,
GetSchedulesOutput_2024_06_11,
} from "@calcom/platform-types";
@Controller({
path: "/v2/schedules",
version: VERSION_2024_06_11_VALUE,
})
@UseGuards(AccessTokenGuard, PermissionsGuard)
@DocsTags("Schedules")
export class SchedulesController_2024_06_11 {
constructor(private readonly schedulesService: SchedulesService_2024_06_11) {}
@Post("/")
@Permissions([SCHEDULE_WRITE])
async createSchedule(
@GetUser() user: UserWithProfile,
@Body() bodySchedule: CreateScheduleInput_2024_06_11
): Promise<CreateScheduleOutput_2024_06_11> {
const schedule = await this.schedulesService.createUserSchedule(user.id, bodySchedule);
return {
status: SUCCESS_STATUS,
data: schedule,
};
}
@Get("/default")
@Permissions([SCHEDULE_READ])
@ApiResponse({
status: 200,
description: "Returns the default schedule",
type: GetDefaultScheduleOutput_2024_06_11,
})
async getDefaultSchedule(@GetUser() user: UserWithProfile): Promise<GetScheduleOutput_2024_06_11> {
const schedule = await this.schedulesService.getUserScheduleDefault(user.id);
return {
status: SUCCESS_STATUS,
data: schedule,
};
}
@Get("/:scheduleId")
@Permissions([SCHEDULE_READ])
@Throttle({ default: { limit: 10, ttl: 60000 } }) // allow 10 requests per minute (for :scheduleId)
async getSchedule(
@GetUser() user: UserWithProfile,
@Param("scheduleId") scheduleId: number
): Promise<GetScheduleOutput_2024_06_11> {
const schedule = await this.schedulesService.getUserSchedule(user.id, scheduleId);
return {
status: SUCCESS_STATUS,
data: schedule,
};
}
@Get("/")
@Permissions([SCHEDULE_READ])
async getSchedules(@GetUser() user: UserWithProfile): Promise<GetSchedulesOutput_2024_06_11> {
const schedules = await this.schedulesService.getUserSchedules(user.id);
return {
status: SUCCESS_STATUS,
data: schedules,
};
}
@Patch("/:scheduleId")
@Permissions([SCHEDULE_WRITE])
async updateSchedule(
@GetUser() user: UserWithProfile,
@Body() bodySchedule: UpdateScheduleInput_2024_06_11,
@Param("scheduleId") scheduleId: string
): Promise<UpdateScheduleOutput_2024_06_11> {
const updatedSchedule = await this.schedulesService.updateUserSchedule(
user.id,
Number(scheduleId),
bodySchedule
);
return {
status: SUCCESS_STATUS,
data: updatedSchedule,
};
}
@Delete("/:scheduleId")
@HttpCode(HttpStatus.OK)
@Permissions([SCHEDULE_WRITE])
async deleteSchedule(
@GetUser("id") userId: number,
@Param("scheduleId") scheduleId: number
): Promise<DeleteScheduleOutput_2024_06_11> {
await this.schedulesService.deleteUserSchedule(userId, scheduleId);
return {
status: SUCCESS_STATUS,
};
}
}
@@ -0,0 +1,22 @@
import { SchedulesController_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/controllers/schedules.controller";
import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository";
import { InputSchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/input-schedules.service";
import { OutputSchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/output-schedules.service";
import { SchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/schedules.service";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule, UsersModule, TokensModule],
providers: [
SchedulesRepository_2024_06_11,
SchedulesService_2024_06_11,
InputSchedulesService_2024_06_11,
OutputSchedulesService_2024_06_11,
],
controllers: [SchedulesController_2024_06_11],
exports: [SchedulesService_2024_06_11, SchedulesRepository_2024_06_11],
})
export class SchedulesModule_2024_06_11 {}
@@ -0,0 +1,208 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
import { Prisma } from "@prisma/client";
import type { CreateScheduleInput_2024_06_11 } from "@calcom/platform-types";
type InputScheduleAvailabilityTransformed = {
days: number[];
startTime: Date;
endTime: Date;
};
type InputScheduleOverrideTransformed = {
date: Date;
startTime: Date;
endTime: Date;
};
type InputScheduleTransformed = Omit<CreateScheduleInput_2024_06_11, "availability" | "overrides"> & {
availability: InputScheduleAvailabilityTransformed[];
overrides: InputScheduleOverrideTransformed[];
};
@Injectable()
export class SchedulesRepository_2024_06_11 {
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
async createSchedule(userId: number, schedule: Omit<InputScheduleTransformed, "isDefault">) {
const { availability, overrides } = schedule;
const createScheduleData: Prisma.ScheduleCreateInput = {
user: {
connect: {
id: userId,
},
},
name: schedule.name,
timeZone: schedule.timeZone,
};
const availabilitiesAndOverrides: Prisma.AvailabilityCreateManyInput[] = [];
if (availability && availability.length > 0) {
availability.forEach((availability) => {
availabilitiesAndOverrides.push({
days: availability.days,
startTime: availability.startTime,
endTime: availability.endTime,
userId,
});
});
}
if (overrides && overrides.length > 0) {
overrides.forEach((override) => {
availabilitiesAndOverrides.push({
date: override.date,
startTime: override.startTime,
endTime: override.endTime,
userId,
});
});
}
if (availabilitiesAndOverrides.length > 0) {
createScheduleData.availability = {
createMany: {
data: availabilitiesAndOverrides,
},
};
}
const createdSchedule = await this.dbWrite.prisma.schedule.create({
data: {
...createScheduleData,
},
include: {
availability: true,
},
});
return createdSchedule;
}
async getScheduleById(scheduleId: number) {
const schedule = await this.dbRead.prisma.schedule.findUnique({
where: {
id: scheduleId,
},
include: {
availability: true,
},
});
return schedule;
}
async updateSchedule(
userId: number,
scheduleId: number,
schedule: Partial<Omit<InputScheduleTransformed, "isDefault">>
) {
const { availability, overrides } = schedule;
const updateScheduleData: Prisma.ScheduleUpdateInput = {
name: schedule.name,
timeZone: schedule.timeZone,
};
const availabilitiesAndOverrides: Prisma.AvailabilityCreateManyInput[] = [];
const deleteConditions = [];
if (availability) {
// note(Lauris): availabilities and overrides are stored in the same "Availability" table,
// but availabilities have "date" field as null, while overrides have it as not null, so delete
// condition below results in deleting only rows from Availability table that are availabilities.
deleteConditions.push({
scheduleId: { equals: scheduleId },
date: null,
});
}
if (overrides) {
// note(Lauris): availabilities and overrides are stored in the same "Availability" table,
// but overrides have "date" field as not-null, while availabilities have it as null, so delete
// condition below results in deleting only rows from Availability table that are overrides.
deleteConditions.push({
scheduleId: { equals: scheduleId },
NOT: { date: null },
});
}
if (availability && availability.length > 0) {
availability.forEach((availability) => {
availabilitiesAndOverrides.push({
days: availability.days,
startTime: availability.startTime,
endTime: availability.endTime,
userId,
});
});
}
if (overrides && overrides.length > 0) {
overrides.forEach((override) => {
availabilitiesAndOverrides.push({
date: override.date,
startTime: override.startTime,
endTime: override.endTime,
userId,
});
});
}
if (availabilitiesAndOverrides.length > 0) {
updateScheduleData.availability = {
deleteMany: deleteConditions,
createMany: {
data: availabilitiesAndOverrides,
},
};
}
const updatedSchedule = await this.dbWrite.prisma.schedule.update({
where: {
id: scheduleId,
},
data: {
...updateScheduleData,
},
include: {
availability: true,
},
});
return updatedSchedule;
}
async getSchedulesByUserId(userId: number) {
const schedules = await this.dbRead.prisma.schedule.findMany({
where: {
userId,
},
include: {
availability: true,
},
});
return schedules;
}
async deleteScheduleById(scheduleId: number) {
return this.dbWrite.prisma.schedule.delete({
where: {
id: scheduleId,
},
});
}
async getUserSchedulesCount(userId: number) {
return this.dbRead.prisma.schedule.count({
where: {
userId,
},
});
}
}
@@ -0,0 +1,43 @@
import { Injectable } from "@nestjs/common";
import {
transformApiScheduleOverrides,
transformApiScheduleAvailability,
} from "@calcom/platform-libraries-0.0.4";
import { CreateScheduleInput_2024_06_11, ScheduleAvailabilityInput_2024_06_11 } from "@calcom/platform-types";
import { ScheduleOverrideInput_2024_06_11 } from "@calcom/platform-types";
@Injectable()
export class InputSchedulesService_2024_06_11 {
transformInputCreateSchedule(inputSchedule: CreateScheduleInput_2024_06_11) {
const defaultAvailability: ScheduleAvailabilityInput_2024_06_11[] = [
{
days: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"],
startTime: "09:00",
endTime: "17:00",
},
];
const defaultOverrides: ScheduleOverrideInput_2024_06_11[] = [];
const availability = this.transformInputScheduleAvailability(
inputSchedule.availability || defaultAvailability
);
const overrides = this.transformInputOverrides(inputSchedule.overrides || defaultOverrides);
const internalCreateSchedule = {
...inputSchedule,
availability,
overrides,
};
return internalCreateSchedule;
}
transformInputScheduleAvailability(inputAvailability: ScheduleAvailabilityInput_2024_06_11[]) {
return transformApiScheduleAvailability(inputAvailability);
}
transformInputOverrides(inputOverrides: ScheduleOverrideInput_2024_06_11[]) {
return transformApiScheduleOverrides(inputOverrides);
}
}
@@ -0,0 +1,78 @@
import { UsersRepository } from "@/modules/users/users.repository";
import { Injectable } from "@nestjs/common";
import type { Availability, Schedule } from "@prisma/client";
import { WeekDay } from "@calcom/platform-types";
@Injectable()
export class OutputSchedulesService_2024_06_11 {
constructor(private readonly usersRepository: UsersRepository) {}
async getResponseSchedule(databaseSchedule: Schedule & { availability: Availability[] }) {
if (!databaseSchedule.timeZone) {
throw new Error("Failed to create schedule because its timezone is not set.");
}
const ownerDefaultScheduleId = await this.usersRepository.getUserScheduleDefaultId(
databaseSchedule.userId
);
const createdScheduleAvailabilities = databaseSchedule.availability.filter(
(availability) => !!availability.days.length
);
const createdScheduleOverrides = databaseSchedule.availability.filter((override) => !!override.date);
return {
id: databaseSchedule.id,
ownerId: databaseSchedule.userId,
name: databaseSchedule.name,
timeZone: databaseSchedule.timeZone,
availability: createdScheduleAvailabilities.map((availability) => ({
days: availability.days.map(transformNumberToDay),
startTime: this.padHoursMinutesWithZeros(
availability.startTime.getUTCHours() + ":" + availability.startTime.getUTCMinutes()
),
endTime: this.padHoursMinutesWithZeros(
availability.endTime.getUTCHours() + ":" + availability.endTime.getUTCMinutes()
),
})),
isDefault: databaseSchedule.id === ownerDefaultScheduleId,
overrides: createdScheduleOverrides.map((override) => ({
date:
override.date?.getUTCFullYear() +
"-" +
(override.date ? override.date.getUTCMonth() + 1 : "").toString().padStart(2, "0") +
"-" +
override.date?.getUTCDate().toString().padStart(2, "0"),
startTime: this.padHoursMinutesWithZeros(
override.startTime.getUTCHours() + ":" + override.startTime.getUTCMinutes()
),
endTime: this.padHoursMinutesWithZeros(
override.endTime.getUTCHours() + ":" + override.endTime.getUTCMinutes()
),
})),
};
}
padHoursMinutesWithZeros(hhMM: string) {
const [hours, minutes] = hhMM.split(":");
const formattedHours = hours.padStart(2, "0");
const formattedMinutes = minutes.padStart(2, "0");
return `${formattedHours}:${formattedMinutes}`;
}
}
function transformNumberToDay(day: number): WeekDay {
const weekMap: { [key: number]: WeekDay } = {
0: "Sunday",
1: "Monday",
2: "Tuesday",
3: "Wednesday",
4: "Thursday",
5: "Friday",
6: "Saturday",
};
return weekMap[day];
}
@@ -0,0 +1,123 @@
import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository";
import { InputSchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/input-schedules.service";
import { OutputSchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/output-schedules.service";
import { UsersRepository } from "@/modules/users/users.repository";
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
import { Schedule } from "@prisma/client";
import { CreateScheduleInput_2024_06_11, ScheduleOutput_2024_06_11 } from "@calcom/platform-types";
import { UpdateScheduleInput_2024_06_11 } from "@calcom/platform-types";
@Injectable()
export class SchedulesService_2024_06_11 {
constructor(
private readonly schedulesRepository: SchedulesRepository_2024_06_11,
private readonly inputSchedulesService: InputSchedulesService_2024_06_11,
private readonly outputSchedulesService: OutputSchedulesService_2024_06_11,
private readonly usersRepository: UsersRepository
) {}
async createUserDefaultSchedule(userId: number, timeZone: string) {
const defaultSchedule = {
isDefault: true,
name: "Default schedule",
timeZone,
};
return this.createUserSchedule(userId, defaultSchedule);
}
async createUserSchedule(
userId: number,
scheduleInput: CreateScheduleInput_2024_06_11
): Promise<ScheduleOutput_2024_06_11> {
const schedule = this.inputSchedulesService.transformInputCreateSchedule(scheduleInput);
const createdSchedule = await this.schedulesRepository.createSchedule(userId, schedule);
if (schedule.isDefault) {
await this.usersRepository.setDefaultSchedule(userId, createdSchedule.id);
}
return this.outputSchedulesService.getResponseSchedule(createdSchedule);
}
async getUserScheduleDefault(userId: number) {
const user = await this.usersRepository.findById(userId);
if (!user?.defaultScheduleId) return null;
const defaultSchedule = await this.schedulesRepository.getScheduleById(user.defaultScheduleId);
if (!defaultSchedule) return null;
return this.outputSchedulesService.getResponseSchedule(defaultSchedule);
}
async getUserSchedule(userId: number, scheduleId: number) {
const existingSchedule = await this.schedulesRepository.getScheduleById(scheduleId);
if (!existingSchedule) {
throw new NotFoundException(`Schedule with ID=${scheduleId} does not exist.`);
}
this.checkUserOwnsSchedule(userId, existingSchedule);
return this.outputSchedulesService.getResponseSchedule(existingSchedule);
}
async getUserSchedules(userId: number) {
const schedules = await this.schedulesRepository.getSchedulesByUserId(userId);
return Promise.all(
schedules.map(async (schedule) => {
return this.outputSchedulesService.getResponseSchedule(schedule);
})
);
}
async updateUserSchedule(userId: number, scheduleId: number, bodySchedule: UpdateScheduleInput_2024_06_11) {
const existingSchedule = await this.schedulesRepository.getScheduleById(scheduleId);
if (!existingSchedule) {
throw new NotFoundException(`Schedule with ID=${scheduleId} does not exist.`);
}
this.checkUserOwnsSchedule(userId, existingSchedule);
const availability = bodySchedule.availability
? this.inputSchedulesService.transformInputScheduleAvailability(bodySchedule.availability)
: undefined;
const overrides = bodySchedule.overrides
? this.inputSchedulesService.transformInputOverrides(bodySchedule.overrides)
: undefined;
if (bodySchedule.isDefault) {
await this.usersRepository.setDefaultSchedule(userId, scheduleId);
}
const updatedSchedule = await this.schedulesRepository.updateSchedule(userId, scheduleId, {
...bodySchedule,
availability,
overrides,
});
return this.outputSchedulesService.getResponseSchedule(updatedSchedule);
}
async deleteUserSchedule(userId: number, scheduleId: number) {
const existingSchedule = await this.schedulesRepository.getScheduleById(scheduleId);
if (!existingSchedule) {
throw new BadRequestException(`Schedule with ID=${scheduleId} does not exist.`);
}
this.checkUserOwnsSchedule(userId, existingSchedule);
return this.schedulesRepository.deleteScheduleById(scheduleId);
}
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,39 +0,0 @@
import { ArgumentsHost, Catch, Logger, HttpStatus } from "@nestjs/common";
import { BaseExceptionFilter } from "@nestjs/core";
import * as Sentry from "@sentry/node";
import { Request } from "express";
import { ERROR_STATUS, INTERNAL_SERVER_ERROR } from "@calcom/platform-constants";
import { Response } from "@calcom/platform-types";
@Catch()
export class SentryFilter extends BaseExceptionFilter {
private readonly logger = new Logger("SentryExceptionFilter");
handleUnknownError(exception: any, host: ArgumentsHost): void {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const requestId = request.headers["X-Request-Id"];
this.logger.error(`Sentry Exception Filter: ${exception?.message}`, {
exception,
body: request.body,
headers: request.headers,
url: request.url,
method: request.method,
requestId,
});
// capture if client has been init
if (Boolean(Sentry.getCurrentHub().getClient())) {
Sentry.captureException(exception);
}
response.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
status: ERROR_STATUS,
timestamp: new Date().toISOString(),
path: request.url,
error: { code: INTERNAL_SERVER_ERROR, message: "Internal server error." },
});
}
}
@@ -2,7 +2,7 @@ import { ArgumentsHost, Catch, ExceptionFilter, Logger } from "@nestjs/common";
import { Request } from "express";
import { ERROR_STATUS } from "@calcom/platform-constants";
import { TRPCError } from "@calcom/platform-libraries";
import { TRPCError } from "@calcom/platform-libraries-0.0.2";
import { Response } from "@calcom/platform-types";
@Catch(TRPCError)
+12
View File
@@ -0,0 +1,12 @@
import { getEnv } from "@/env";
import * as Sentry from "@sentry/node";
if (process.env.SENTRY_DSN) {
// Ensure to call this before requiring any other modules!
Sentry.init({
dsn: getEnv("SENTRY_DSN"),
// Add Performance Monitoring by setting tracesSampleRate
// We recommend adjusting this value in production
// todo: Evaluate? tracesSampleRate: 1.0
});
}
+13
View File
@@ -0,0 +1,13 @@
import { VersionValue } from "@nestjs/common/interfaces";
import {
API_VERSIONS,
VERSION_2024_04_15,
VERSION_2024_05_21,
VERSION_2024_06_11,
} from "@calcom/platform-constants";
export const API_VERSIONS_VALUES: VersionValue = API_VERSIONS as unknown as VersionValue;
export const VERSION_2024_06_11_VALUE: VersionValue = VERSION_2024_06_11 as unknown as VersionValue;
export const VERSION_2024_05_21_VALUE: VersionValue = VERSION_2024_05_21 as unknown as VersionValue;
export const VERSION_2024_04_15_VALUE: VersionValue = VERSION_2024_04_15 as unknown as VersionValue;
@@ -1,10 +0,0 @@
import { AvailabilitiesService } from "@/modules/availabilities/availabilities.service";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule],
providers: [AvailabilitiesService],
exports: [AvailabilitiesService],
})
export class AvailabilitiesModule {}
@@ -1,16 +0,0 @@
import { CreateAvailabilityInput } from "@/modules/availabilities/inputs/create-availability.input";
import { Injectable } from "@nestjs/common";
@Injectable()
export class AvailabilitiesService {
getDefaultAvailabilityInput(): CreateAvailabilityInput {
const startTime = new Date(new Date().setUTCHours(9, 0, 0, 0));
const endTime = new Date(new Date().setUTCHours(17, 0, 0, 0));
return {
days: [1, 2, 3, 4, 5],
startTime,
endTime,
};
}
}
@@ -1,4 +1,5 @@
import { AppConfig } from "@/config/type";
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { NextAuthGuard } from "@/modules/auth/guards/next-auth/next-auth.guard";
import { OrganizationRolesGuard } from "@/modules/auth/guards/organization-roles/organization-roles.guard";
@@ -29,8 +30,8 @@ import { Stripe } from "stripe";
import { ApiResponse } from "@calcom/platform-types";
@Controller({
path: "/billing",
version: "2",
path: "/v2/billing",
version: API_VERSIONS_VALUES,
})
@ApiExcludeController(true)
export class BillingController {
@@ -1,3 +1,4 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { CreateManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/create-managed-user.output";
import { GetManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/get-managed-user.output";
import { GetManagedUsersOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/get-managed-users.output";
@@ -21,7 +22,6 @@ import {
HttpStatus,
Param,
Patch,
BadRequestException,
Delete,
Query,
NotFoundException,
@@ -33,8 +33,8 @@ import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { Pagination } from "@calcom/platform-types";
@Controller({
path: "oauth-clients/:clientId/users",
version: "2",
path: "/v2/oauth-clients/:clientId/users",
version: API_VERSIONS_VALUES,
})
@UseGuards(OAuthClientCredentialsGuard)
@DocsTags("Managed users")
@@ -91,6 +91,7 @@ export class OAuthClientUsersController {
data: {
user: this.getResponseUser(user),
accessToken: tokens.accessToken,
accessTokenExpiresAt: tokens.accessTokenExpiresAt.valueOf(),
refreshToken: tokens.refreshToken,
},
};
@@ -155,7 +156,7 @@ export class OAuthClientUsersController {
const { id } = await this.validateManagedUserOwnership(oAuthClientId, userId);
const { accessToken, refreshToken } = await this.tokensRepository.createOAuthTokens(
const { accessToken, refreshToken, accessTokenExpiresAt } = await this.tokensRepository.createOAuthTokens(
oAuthClientId,
id,
true
@@ -166,6 +167,7 @@ export class OAuthClientUsersController {
data: {
accessToken,
refreshToken,
accessTokenExpiresAt: accessTokenExpiresAt.valueOf(),
},
};
}
@@ -1,7 +1,7 @@
import { ManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/managed-user.output";
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsEnum, IsString, ValidateNested } from "class-validator";
import { IsEnum, IsNumber, IsString, ValidateNested } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
@@ -18,6 +18,9 @@ class CreateManagedUserData {
@IsString()
refreshToken!: string;
@IsNumber()
accessTokenExpiresAt!: number;
}
export class CreateManagedUserOutput {
@@ -1,4 +1,5 @@
import { getEnv } from "@/env";
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { NextAuthGuard } from "@/modules/auth/guards/next-auth/next-auth.guard";
@@ -46,8 +47,8 @@ const AUTH_DOCUMENTATION = `⚠️ First, this endpoint requires \`Cookie: next-
Second, make sure that the logged in user has organizationId set to pass the OrganizationRolesGuard guard.`;
@Controller({
path: "oauth-clients",
version: "2",
path: "/v2/oauth-clients",
version: API_VERSIONS_VALUES,
})
@UseGuards(NextAuthGuard, OrganizationRolesGuard)
@DocsExcludeController(getEnv("NODE_ENV") === "production")
@@ -1,4 +1,5 @@
import { getEnv } from "@/env";
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { NextAuthGuard } from "@/modules/auth/guards/next-auth/next-auth.guard";
import { KeysResponseDto } from "@/modules/oauth-clients/controllers/oauth-flow/responses/KeysResponse.dto";
@@ -33,8 +34,8 @@ import { Response as ExpressResponse } from "express";
import { SUCCESS_STATUS, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
@Controller({
path: "oauth/:clientId",
version: "2",
path: "/v2/oauth/:clientId",
version: API_VERSIONS_VALUES,
})
@DocsExcludeController(getEnv("NODE_ENV") === "production")
@DocsTags("OAuth - development only")
@@ -117,16 +118,18 @@ export class OAuthFlowController {
throw new BadRequestException("Missing 'Bearer' Authorization header.");
}
const { accessToken, refreshToken } = await this.oAuthFlowService.exchangeAuthorizationToken(
authorizeEndpointCode,
clientId,
body.clientSecret
);
const { accessToken, refreshToken, accessTokenExpiresAt } =
await this.oAuthFlowService.exchangeAuthorizationToken(
authorizeEndpointCode,
clientId,
body.clientSecret
);
return {
status: SUCCESS_STATUS,
data: {
accessToken,
accessTokenExpiresAt: accessTokenExpiresAt.valueOf(),
refreshToken,
},
};
@@ -140,7 +143,7 @@ export class OAuthFlowController {
@Headers(X_CAL_SECRET_KEY) secretKey: string,
@Body() body: RefreshTokenInput
): Promise<KeysResponseDto> {
const { accessToken, refreshToken } = await this.oAuthFlowService.refreshToken(
const { accessToken, refreshToken, accessTokenExpiresAt } = await this.oAuthFlowService.refreshToken(
clientId,
secretKey,
body.refreshToken
@@ -150,6 +153,7 @@ export class OAuthFlowController {
status: SUCCESS_STATUS,
data: {
accessToken: accessToken,
accessTokenExpiresAt: accessTokenExpiresAt.valueOf(),
refreshToken: refreshToken,
},
};
@@ -1,6 +1,6 @@
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { ValidateNested, IsEnum, IsString, IsNotEmptyObject } from "class-validator";
import { ValidateNested, IsEnum, IsString, IsNotEmptyObject, IsNumber } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
@@ -16,6 +16,9 @@ class KeysDto {
})
@IsString()
refreshToken!: string;
@IsNumber()
accessTokenExpiresAt!: number;
}
export class KeysResponseDto {
@@ -1,5 +1,5 @@
import { EventTypesModule } from "@/ee/event-types/event-types.module";
import { SchedulesModule } from "@/ee/schedules/schedules.module";
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
import { AuthModule } from "@/modules/auth/auth.module";
import { BillingModule } from "@/modules/billing/billing.module";
import { MembershipsModule } from "@/modules/memberships/memberships.module";
@@ -32,7 +32,7 @@ import { Global, Module } from "@nestjs/common";
OrganizationsModule,
StripeModule,
BillingModule,
SchedulesModule,
SchedulesModule_2024_04_15,
],
providers: [
OAuthClientRepository,
@@ -1,5 +1,5 @@
import { EventTypesService } from "@/ee/event-types/services/event-types.service";
import { SchedulesService } from "@/ee/schedules/services/schedules.service";
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { CreateManagedUserInput } from "@/modules/users/inputs/create-managed-user.input";
import { UpdateManagedUserInput } from "@/modules/users/inputs/update-managed-user.input";
@@ -7,7 +7,7 @@ import { UsersRepository } from "@/modules/users/users.repository";
import { BadRequestException, Injectable } from "@nestjs/common";
import { User } from "@prisma/client";
import { createNewUsersConnectToOrgIfExists, slugify } from "@calcom/platform-libraries";
import { createNewUsersConnectToOrgIfExists, slugify } from "@calcom/platform-libraries-0.0.2";
@Injectable()
export class OAuthClientUsersService {
@@ -15,7 +15,7 @@ export class OAuthClientUsersService {
private readonly userRepository: UsersRepository,
private readonly tokensRepository: TokensRepository,
private readonly eventTypesService: EventTypesService,
private readonly schedulesService: SchedulesService
private readonly schedulesService: SchedulesService_2024_04_15
) {}
async createOauthClientUser(
@@ -62,7 +62,7 @@ export class OAuthClientUsersService {
await this.userRepository.update(user.id, { name: body.name ?? user.username ?? undefined });
}
const { accessToken, refreshToken } = await this.tokensRepository.createOAuthTokens(
const { accessToken, refreshToken, accessTokenExpiresAt } = await this.tokensRepository.createOAuthTokens(
oAuthClientId,
user.id
);
@@ -78,6 +78,7 @@ export class OAuthClientUsersService {
user,
tokens: {
accessToken,
accessTokenExpiresAt,
refreshToken,
},
};
@@ -103,7 +103,7 @@ export class OAuthFlowService {
tokenId: string,
clientId: string,
clientSecret: string
): Promise<{ accessToken: string; refreshToken: string }> {
): Promise<{ accessToken: string; refreshToken: string; accessTokenExpiresAt: Date }> {
const oauthClient = await this.oAuthClientRepository.getOAuthClientWithAuthTokens(
tokenId,
clientId,
@@ -120,7 +120,7 @@ export class OAuthFlowService {
throw new BadRequestException("Invalid Authorization Token.");
}
const { accessToken, refreshToken } = await this.tokensRepository.createOAuthTokens(
const { accessToken, refreshToken, accessTokenExpiresAt } = await this.tokensRepository.createOAuthTokens(
clientId,
authorizationToken.owner.id
);
@@ -129,6 +129,7 @@ export class OAuthFlowService {
return {
accessToken,
accessTokenExpiresAt,
refreshToken,
};
}
@@ -158,6 +159,7 @@ export class OAuthFlowService {
return {
accessToken: accessToken.secret,
accessTokenExpiresAt: accessToken.expiresAt,
refreshToken: refreshToken.secret,
};
}
@@ -1,3 +1,4 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard";
import { SlotsService } from "@/modules/slots/services/slots.service";
import { Query, Body, Controller, Get, Delete, Post, Req, Res, UseGuards } from "@nestjs/common";
@@ -5,14 +6,14 @@ import { ApiTags as DocsTags } from "@nestjs/swagger";
import { Response as ExpressResponse, Request as ExpressRequest } from "express";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { getAvailableSlots } from "@calcom/platform-libraries";
import type { AvailableSlotsType } from "@calcom/platform-libraries";
import { getAvailableSlots } from "@calcom/platform-libraries-0.0.2";
import type { AvailableSlotsType } from "@calcom/platform-libraries-0.0.2";
import { RemoveSelectedSlotInput, ReserveSlotInput } from "@calcom/platform-types";
import { ApiResponse, GetAvailableSlotsInput } from "@calcom/platform-types";
@Controller({
path: "slots",
version: "2",
path: "/v2/slots",
version: API_VERSIONS_VALUES,
})
@DocsTags("Slots")
export class SlotsController {
@@ -3,7 +3,7 @@ import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
import { DateTime } from "luxon";
import { MINUTES_TO_BOOK } from "@calcom/platform-libraries";
import { MINUTES_TO_BOOK } from "@calcom/platform-libraries-0.0.2";
import { ReserveSlotInput } from "@calcom/platform-types";
@Injectable()
@@ -1,14 +1,15 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { TimezonesService } from "@/modules/timezones/services/timezones.service";
import { Controller, Get } from "@nestjs/common";
import { ApiTags as DocsTags } from "@nestjs/swagger";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import type { CityTimezones } from "@calcom/platform-libraries";
import type { CityTimezones } from "@calcom/platform-libraries-0.0.2";
import { ApiResponse } from "@calcom/platform-types";
@Controller({
path: "timezones",
version: "2",
path: "/v2/timezones",
version: API_VERSIONS_VALUES,
})
@DocsTags("Timezones")
export class TimezonesController {
@@ -1,8 +1,8 @@
import { RedisService } from "@/modules/redis/redis.service";
import { Injectable } from "@nestjs/common";
import { cityTimezonesHandler } from "@calcom/platform-libraries";
import type { CityTimezones } from "@calcom/platform-libraries";
import { cityTimezonesHandler } from "@calcom/platform-libraries-0.0.2";
import type { CityTimezones } from "@calcom/platform-libraries-0.0.2";
@Injectable()
export class TimezonesService {
@@ -89,6 +89,7 @@ export class TokensRepository {
return {
accessToken: accessToken.secret,
accessTokenExpiresAt: accessToken.expiresAt,
refreshToken: refreshToken.secret,
};
}
@@ -173,6 +173,14 @@ export class UsersRepository {
},
});
}
async getUserScheduleDefaultId(userId: number) {
const user = await this.findById(userId);
if (!user?.defaultScheduleId) return null;
return user?.defaultScheduleId;
}
}
function capitalizeTimezone(timezone: string) {
+530 -33
View File
@@ -634,7 +634,7 @@
"required": true,
"in": "path",
"schema": {
"type": "string"
"type": "number"
}
}
],
@@ -808,14 +808,14 @@
},
"/v2/schedules": {
"post": {
"operationId": "SchedulesController_createSchedule",
"operationId": "SchedulesController_2024_06_11_createSchedule",
"parameters": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateScheduleInput"
"$ref": "#/components/schemas/CreateScheduleInput_2024_06_11"
}
}
}
@@ -826,7 +826,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateScheduleOutput"
"$ref": "#/components/schemas/CreateScheduleOutput_2024_06_11"
}
}
}
@@ -837,7 +837,7 @@
]
},
"get": {
"operationId": "SchedulesController_getSchedules",
"operationId": "SchedulesController_2024_06_11_getSchedules",
"parameters": [],
"responses": {
"200": {
@@ -845,7 +845,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/GetSchedulesOutput"
"$ref": "#/components/schemas/GetSchedulesOutput_2024_06_11"
}
}
}
@@ -858,7 +858,7 @@
},
"/v2/schedules/default": {
"get": {
"operationId": "SchedulesController_getDefaultSchedule",
"operationId": "SchedulesController_2024_06_11_getDefaultSchedule",
"parameters": [],
"responses": {
"200": {
@@ -866,7 +866,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/GetDefaultScheduleOutput"
"$ref": "#/components/schemas/GetDefaultScheduleOutput_2024_06_11"
}
}
}
@@ -879,7 +879,7 @@
},
"/v2/schedules/{scheduleId}": {
"get": {
"operationId": "SchedulesController_getSchedule",
"operationId": "SchedulesController_2024_06_11_getSchedule",
"parameters": [
{
"name": "scheduleId",
@@ -896,7 +896,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/GetScheduleOutput"
"$ref": "#/components/schemas/GetScheduleOutput_2024_06_11"
}
}
}
@@ -907,7 +907,7 @@
]
},
"patch": {
"operationId": "SchedulesController_updateSchedule",
"operationId": "SchedulesController_2024_06_11_updateSchedule",
"parameters": [
{
"name": "scheduleId",
@@ -923,7 +923,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateScheduleInput"
"$ref": "#/components/schemas/UpdateScheduleInput_2024_06_11"
}
}
}
@@ -934,7 +934,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateScheduleOutput"
"$ref": "#/components/schemas/UpdateScheduleOutput_2024_06_11"
}
}
}
@@ -945,7 +945,7 @@
]
},
"delete": {
"operationId": "SchedulesController_deleteSchedule",
"operationId": "SchedulesController_2024_06_11_deleteSchedule",
"parameters": [
{
"name": "scheduleId",
@@ -962,7 +962,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DeleteScheduleOutput"
"$ref": "#/components/schemas/DeleteScheduleOutput_2024_06_11"
}
}
}
@@ -1214,6 +1214,113 @@
]
}
},
"/v2/calendars/{calendar}/connect": {
"get": {
"operationId": "CalendarsController_redirect",
"parameters": [
{
"name": "Authorization",
"required": true,
"in": "header",
"schema": {
"type": "string"
}
},
{
"name": "calendar",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
}
},
"tags": [
"Calendars"
]
}
},
"/v2/calendars/{calendar}/save": {
"get": {
"operationId": "CalendarsController_save",
"parameters": [
{
"name": "state",
"required": true,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "code",
"required": true,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "calendar",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": ""
}
},
"tags": [
"Calendars"
]
}
},
"/v2/calendars/{calendar}/check": {
"get": {
"operationId": "CalendarsController_check",
"parameters": [
{
"name": "calendar",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
}
},
"tags": [
"Calendars"
]
}
},
"/v2/bookings": {
"get": {
"operationId": "BookingsController_getBookings",
@@ -1726,12 +1833,16 @@
},
"refreshToken": {
"type": "string"
},
"accessTokenExpiresAt": {
"type": "number"
}
},
"required": [
"user",
"accessToken",
"refreshToken"
"refreshToken",
"accessTokenExpiresAt"
]
},
"CreateManagedUserOutput": {
@@ -2895,7 +3006,7 @@
"data"
]
},
"CreateAvailabilityInput": {
"CreateAvailabilityInput_2024_04_15": {
"type": "object",
"properties": {
"days": {
@@ -2923,7 +3034,7 @@
"endTime"
]
},
"CreateScheduleInput": {
"CreateScheduleInput_2024_04_15": {
"type": "object",
"properties": {
"name": {
@@ -2935,7 +3046,7 @@
"availabilities": {
"type": "array",
"items": {
"$ref": "#/components/schemas/CreateAvailabilityInput"
"$ref": "#/components/schemas/CreateAvailabilityInput_2024_04_15"
}
},
"isDefault": {
@@ -3107,7 +3218,7 @@
"readOnly"
]
},
"CreateScheduleOutput": {
"CreateScheduleOutput_2024_04_15": {
"type": "object",
"properties": {
"status": {
@@ -3127,7 +3238,7 @@
"data"
]
},
"GetDefaultScheduleOutput": {
"GetDefaultScheduleOutput_2024_04_15": {
"type": "object",
"properties": {
"status": {
@@ -3152,7 +3263,7 @@
"data"
]
},
"GetScheduleOutput": {
"GetScheduleOutput_2024_04_15": {
"type": "object",
"properties": {
"status": {
@@ -3172,7 +3283,7 @@
"data"
]
},
"GetSchedulesOutput": {
"GetSchedulesOutput_2024_04_15": {
"type": "object",
"properties": {
"status": {
@@ -3192,7 +3303,7 @@
"data"
]
},
"UpdateScheduleInput": {
"UpdateScheduleInput_2024_04_15": {
"type": "object",
"properties": {
"timeZone": {
@@ -3252,7 +3363,7 @@
"schedule"
]
},
"EventTypeModel": {
"EventTypeModel_2024_04_15": {
"type": "object",
"properties": {
"id": {
@@ -3267,7 +3378,50 @@
"id"
]
},
"ScheduleModel": {
"AvailabilityModel_2024_04_15": {
"type": "object",
"properties": {
"id": {
"type": "number"
},
"userId": {
"type": "number",
"nullable": true
},
"scheduleId": {
"type": "number",
"nullable": true
},
"eventTypeId": {
"type": "number",
"nullable": true
},
"days": {
"type": "array",
"items": {
"type": "number"
}
},
"startTime": {
"format": "date-time",
"type": "string"
},
"endTime": {
"format": "date-time",
"type": "string"
},
"date": {
"format": "date-time",
"type": "string",
"nullable": true
}
},
"required": [
"id",
"days"
]
},
"ScheduleModel_2024_04_15": {
"type": "object",
"properties": {
"id": {
@@ -3286,13 +3440,13 @@
"eventType": {
"type": "array",
"items": {
"$ref": "#/components/schemas/EventTypeModel"
"$ref": "#/components/schemas/EventTypeModel_2024_04_15"
}
},
"availability": {
"type": "array",
"items": {
"$ref": "#/components/schemas/AvailabilityModel"
"$ref": "#/components/schemas/AvailabilityModel_2024_04_15"
}
}
},
@@ -3302,11 +3456,11 @@
"name"
]
},
"UpdatedScheduleOutput": {
"UpdatedScheduleOutput_2024_04_15": {
"type": "object",
"properties": {
"schedule": {
"$ref": "#/components/schemas/ScheduleModel"
"$ref": "#/components/schemas/ScheduleModel_2024_04_15"
},
"isDefault": {
"type": "boolean"
@@ -3328,7 +3482,7 @@
"isDefault"
]
},
"UpdateScheduleOutput": {
"UpdateScheduleOutput_2024_04_15": {
"type": "object",
"properties": {
"status": {
@@ -3340,7 +3494,7 @@
]
},
"data": {
"$ref": "#/components/schemas/UpdatedScheduleOutput"
"$ref": "#/components/schemas/UpdatedScheduleOutput_2024_04_15"
}
},
"required": [
@@ -3348,7 +3502,7 @@
"data"
]
},
"DeleteScheduleOutput": {
"DeleteScheduleOutput_2024_04_15": {
"type": "object",
"properties": {
"status": {
@@ -3454,6 +3608,349 @@
"status"
]
},
"ScheduleAvailabilityInput_2024_06_11": {
"type": "object",
"properties": {
"days": {
"example": [
"Monday",
"Tuesday"
],
"type": "array",
"items": {
"type": "string"
}
},
"startTime": {
"type": "string",
"example": "09:00"
},
"endTime": {
"type": "string",
"example": "10:00"
}
},
"required": [
"days",
"startTime",
"endTime"
]
},
"ScheduleOverrideInput_2024_06_11": {
"type": "object",
"properties": {
"date": {
"type": "string",
"example": "2024-05-20"
},
"startTime": {
"type": "string",
"example": "12:00"
},
"endTime": {
"type": "string",
"example": "13:00"
}
},
"required": [
"date",
"startTime",
"endTime"
]
},
"CreateScheduleInput_2024_06_11": {
"type": "object",
"properties": {
"name": {
"type": "string",
"example": "One-on-one coaching"
},
"timeZone": {
"type": "string",
"example": "Europe/Rome"
},
"availability": {
"example": [
{
"days": [
"Monday",
"Tuesday"
],
"startTime": "09:00",
"endTime": "10:00"
}
],
"type": "array",
"items": {
"$ref": "#/components/schemas/ScheduleAvailabilityInput_2024_06_11"
}
},
"isDefault": {
"type": "boolean",
"example": true
},
"overrides": {
"example": [
{
"date": "2024-05-20",
"startTime": "12:00",
"endTime": "14:00"
}
],
"type": "array",
"items": {
"$ref": "#/components/schemas/ScheduleOverrideInput_2024_06_11"
}
}
},
"required": [
"name",
"timeZone",
"isDefault"
]
},
"ScheduleOutput_2024_06_11": {
"type": "object",
"properties": {
"id": {
"type": "number",
"example": 254
},
"ownerId": {
"type": "number",
"example": 478
},
"name": {
"type": "string",
"example": "One-on-one coaching"
},
"timeZone": {
"type": "string",
"example": "Europe/Rome"
},
"availability": {
"example": [
{
"days": [
"Monday",
"Tuesday"
],
"startTime": "09:00",
"endTime": "10:00"
}
],
"type": "array",
"items": {
"$ref": "#/components/schemas/ScheduleAvailabilityInput_2024_06_11"
}
},
"isDefault": {
"type": "boolean",
"example": true
},
"overrides": {
"example": [
{
"date": "2024-05-20",
"startTime": "12:00",
"endTime": "13:00"
}
],
"type": "array",
"items": {
"$ref": "#/components/schemas/ScheduleOverrideInput_2024_06_11"
}
}
},
"required": [
"id",
"ownerId",
"name",
"timeZone",
"availability",
"isDefault",
"overrides"
]
},
"CreateScheduleOutput_2024_06_11": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "success",
"enum": [
"success",
"error"
]
},
"data": {
"$ref": "#/components/schemas/ScheduleOutput_2024_06_11"
}
},
"required": [
"status",
"data"
]
},
"GetDefaultScheduleOutput_2024_06_11": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "success",
"enum": [
"success",
"error"
]
},
"data": {
"$ref": "#/components/schemas/ScheduleOutput_2024_06_11"
}
},
"required": [
"status",
"data"
]
},
"GetScheduleOutput_2024_06_11": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "success",
"enum": [
"success",
"error"
]
},
"data": {
"nullable": true,
"allOf": [
{
"$ref": "#/components/schemas/ScheduleOutput_2024_06_11"
}
]
},
"error": {
"type": "object"
}
},
"required": [
"status",
"data"
]
},
"GetSchedulesOutput_2024_06_11": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "success",
"enum": [
"success",
"error"
]
},
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ScheduleOutput_2024_06_11"
}
},
"error": {
"type": "object"
}
},
"required": [
"status",
"data"
]
},
"UpdateScheduleInput_2024_06_11": {
"type": "object",
"properties": {
"name": {
"type": "string",
"example": "One-on-one coaching"
},
"timeZone": {
"type": "string",
"example": "Europe/Rome"
},
"availability": {
"example": [
{
"days": [
"Monday",
"Tuesday"
],
"startTime": "09:00",
"endTime": "10:00"
}
],
"type": "array",
"items": {
"$ref": "#/components/schemas/ScheduleAvailabilityInput_2024_06_11"
}
},
"isDefault": {
"type": "boolean",
"example": true
},
"overrides": {
"example": [
{
"date": "2024-05-20",
"startTime": "12:00",
"endTime": "14:00"
}
],
"type": "array",
"items": {
"$ref": "#/components/schemas/ScheduleOverrideInput_2024_06_11"
}
}
}
},
"UpdateScheduleOutput_2024_06_11": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "success",
"enum": [
"success",
"error"
]
},
"data": {
"$ref": "#/components/schemas/ScheduleOutput_2024_06_11"
},
"error": {
"type": "object"
}
},
"required": [
"status",
"data"
]
},
"DeleteScheduleOutput_2024_06_11": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "success",
"enum": [
"success",
"error"
]
}
},
"required": [
"status"
]
},
"MeOutput": {
"type": "object",
"properties": {
@@ -53,9 +53,9 @@ const AccountSelector: FC<AccountSelectorProps> = ({
);
};
export type PersonalAccountProps = Pick<User, "id" | "avatar" | "name"> & { alreadyInstalled: boolean };
export type PersonalAccountProps = Pick<User, "id" | "avatarUrl" | "name"> & { alreadyInstalled: boolean };
export type TeamsProp = (Pick<Team, "id" | "name" | "logo"> & {
export type TeamsProp = (Pick<Team, "id" | "name" | "logoUrl"> & {
alreadyInstalled: boolean;
})[];
@@ -74,7 +74,7 @@ export const AccountsStepCard: FC<AccountStepCardProps> = ({ teams, personalAcco
<div className={classNames("mt-2 flex flex-col gap-2 ")}>
<AccountSelector
testId="install-app-button-personal"
avatar={personalAccount.avatar ?? ""}
avatar={personalAccount.avatarUrl ?? ""}
name={personalAccount.name ?? ""}
alreadyInstalled={personalAccount.alreadyInstalled}
onClick={() => onSelect()}
@@ -85,7 +85,7 @@ export const AccountsStepCard: FC<AccountStepCardProps> = ({ teams, personalAcco
key={team.id}
testId={`install-app-button-team${team.id}`}
alreadyInstalled={team.alreadyInstalled}
avatar={team.logo ?? ""}
avatar={team.logoUrl ?? ""}
name={team.name}
onClick={() => onSelect(team.id)}
loading={loading}
+243 -16
View File
@@ -1,5 +1,6 @@
import Link from "next/link";
import { useState } from "react";
import { useForm, Controller, useFieldArray } from "react-hook-form";
import type { EventLocationType, getEventLocationValue } from "@calcom/app-store/locations";
import {
@@ -15,6 +16,7 @@ import classNames from "@calcom/lib/classNames";
import { formatTime } from "@calcom/lib/date-fns";
import getPaymentAppData from "@calcom/lib/getPaymentAppData";
import { useBookerUrl } from "@calcom/lib/hooks/useBookerUrl";
import { useCopy } from "@calcom/lib/hooks/useCopy";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { getEveryFreqFor } from "@calcom/lib/recurringStrings";
import { BookingStatus } from "@calcom/prisma/enums";
@@ -35,6 +37,14 @@ import {
TableActions,
TextAreaField,
Tooltip,
Dropdown,
DropdownMenuContent,
DropdownMenuTrigger,
DropdownMenuItem,
DropdownItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuCheckboxItem,
} from "@calcom/ui";
import { ChargeCardDialog } from "@components/dialog/ChargeCardDialog";
@@ -87,7 +97,7 @@ function BookingListItem(booking: BookingItemProps) {
});
const isUpcoming = new Date(booking.endTime) >= new Date();
const isPast = new Date(booking.endTime) < new Date();
const isBookingInPast = new Date(booking.endTime) < new Date();
const isCancelled = booking.status === BookingStatus.CANCELLED;
const isConfirmed = booking.status === BookingStatus.ACCEPTED;
const isRejected = booking.status === BookingStatus.REJECTED;
@@ -220,7 +230,7 @@ function BookingListItem(booking: BookingItemProps) {
bookedActions = bookedActions.filter((action) => action.id !== "edit_booking");
}
if (isPast && isPending && !isConfirmed) {
if (isBookingInPast && isPending && !isConfirmed) {
bookedActions = bookedActions.filter((action) => action.id !== "cancel");
}
@@ -278,9 +288,9 @@ function BookingListItem(booking: BookingItemProps) {
const title = booking.title;
const showViewRecordingsButton = !!(booking.isRecorded && isPast && isConfirmed);
const showViewRecordingsButton = !!(booking.isRecorded && isBookingInPast && isConfirmed);
const showCheckRecordingButton =
isPast &&
isBookingInPast &&
isConfirmed &&
!booking.isRecorded &&
(!booking.location || booking.location === "integrations:daily" || booking?.location?.trim() === "");
@@ -298,7 +308,14 @@ function BookingListItem(booking: BookingItemProps) {
];
const showPendingPayment = paymentAppData.enabled && booking.payment.length && !booking.paid;
const attendeeList = booking.attendees.map((attendee) => {
return {
name: attendee.name,
email: attendee.email,
id: attendee.id,
noShow: attendee.noShow || false,
};
});
return (
<>
<RescheduleDialog
@@ -436,7 +453,7 @@ function BookingListItem(booking: BookingItemProps) {
</div>
</Link>
</td>
<td className={`w-full px-4${isRejected ? " line-through" : ""}`}>
<td data-testid="title-and-attendees" className={`w-full px-4${isRejected ? " line-through" : ""}`}>
<Link href={bookingLink}>
{/* Time and Badges for mobile */}
<div className="w-full pb-2 pt-4 sm:hidden">
@@ -507,9 +524,11 @@ function BookingListItem(booking: BookingItemProps) {
)}
{booking.attendees.length !== 0 && (
<DisplayAttendees
attendees={booking.attendees}
attendees={attendeeList}
user={booking.user}
currentEmail={userEmail}
bookingUid={booking.uid}
isBookingInPast={isBookingInPast}
/>
)}
{isCancelled && booking.rescheduled && (
@@ -528,7 +547,7 @@ function BookingListItem(booking: BookingItemProps) {
{isRejected && <div className="text-subtle text-sm">{t("rejected")}</div>}
</>
) : null}
{isPast && isPending && !isConfirmed ? <TableActions actions={bookedActions} /> : null}
{isBookingInPast && isPending && !isConfirmed ? <TableActions actions={bookedActions} /> : null}
{(showViewRecordingsButton || showCheckRecordingButton) && (
<TableActions actions={showRecordingActions} />
)}
@@ -654,13 +673,215 @@ const FirstAttendee = ({
type AttendeeProps = {
name?: string;
email: string;
id: number;
noShow: boolean;
};
const Attendee = ({ email, name }: AttendeeProps) => {
type NoShowProps = {
bookingUid: string;
isBookingInPast: boolean;
};
const Attendee = (attendeeProps: AttendeeProps & NoShowProps) => {
const { email, name, bookingUid, isBookingInPast, noShow: noShowAttendee } = attendeeProps;
const { t } = useLocale();
const [noShow, setNoShow] = useState(noShowAttendee);
const [openDropdown, setOpenDropdown] = useState(false);
const { copyToClipboard, isCopied } = useCopy();
const noShowMutation = trpc.viewer.public.noShow.useMutation({
onSuccess: async () => {
showToast(
t(noShow ? "x_marked_as_no_show" : "x_unmarked_as_no_show", {
x: name || email,
}),
"success"
);
},
onError: (err) => {
showToast(err.message, "error");
},
});
function toggleNoShow({
attendee,
bookingUid,
}: {
attendee: { email: string; noShow: boolean };
bookingUid: string;
}) {
noShowMutation.mutate({ bookingUid, attendees: [attendee] });
setNoShow(!noShow);
}
return (
<a className="hover:text-blue-500" href={`mailto:${email}`} onClick={(e) => e.stopPropagation()}>
{name || email}
</a>
<Dropdown open={openDropdown} onOpenChange={setOpenDropdown}>
<DropdownMenuTrigger asChild>
<button
data-testid="guest"
onClick={(e) => e.stopPropagation()}
className="radix-state-open:text-blue-500 hover:text-blue-500">
{noShow ? (
<s>
{name || email} <Icon name="eye-off" className="inline h-4" />
</s>
) : (
<>{name || email}</>
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem className="focus:outline-none">
<DropdownItem
StartIcon="mail"
href={`mailto:${email}`}
onClick={(e) => {
setOpenDropdown(false);
e.stopPropagation();
}}>
<a href={`mailto:${email}`}>{t("email")}</a>
</DropdownItem>
</DropdownMenuItem>
<DropdownMenuItem className="focus:outline-none">
<DropdownItem
StartIcon={isCopied ? "clipboard-check" : "clipboard"}
onClick={(e) => {
e.preventDefault();
copyToClipboard(email);
setOpenDropdown(false);
showToast(t("email_copied"), "success");
}}>
{!isCopied ? t("copy") : t("copied")}
</DropdownItem>
</DropdownMenuItem>
{isBookingInPast && (
<DropdownMenuItem className="focus:outline-none">
{noShow ? (
<DropdownItem
data-testid="unmark-no-show"
onClick={(e) => {
setOpenDropdown(false);
toggleNoShow({ attendee: { noShow: false, email }, bookingUid });
e.preventDefault();
}}
StartIcon="eye">
{t("unmark_as_no_show")}
</DropdownItem>
) : (
<DropdownItem
data-testid="mark-no-show"
onClick={(e) => {
setOpenDropdown(false);
toggleNoShow({ attendee: { noShow: true, email }, bookingUid });
e.preventDefault();
}}
StartIcon="eye-off">
{t("mark_as_no_show")}
</DropdownItem>
)}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</Dropdown>
);
};
type GroupedAttendeeProps = {
attendees: AttendeeProps[];
bookingUid: string;
};
const GroupedAttendees = (groupedAttendeeProps: GroupedAttendeeProps) => {
const { bookingUid } = groupedAttendeeProps;
const attendees = groupedAttendeeProps.attendees.map((attendee) => {
return {
id: attendee.id,
email: attendee.email,
name: attendee.name,
noShow: attendee.noShow || false,
};
});
const { t } = useLocale();
const noShowMutation = trpc.viewer.public.noShow.useMutation({
onSuccess: async () => {
showToast(t("no_show_updated"), "success");
},
onError: (err) => {
showToast(err.message, "error");
},
});
const { control, handleSubmit } = useForm<{
attendees: AttendeeProps[];
}>({
defaultValues: {
attendees,
},
mode: "onBlur",
});
const { fields } = useFieldArray({
control,
name: "attendees",
});
const onSubmit = (data: { attendees: AttendeeProps[] }) => {
const filteredData = data.attendees.slice(1);
noShowMutation.mutate({ bookingUid, attendees: filteredData });
setOpenDropdown(false);
};
const [openDropdown, setOpenDropdown] = useState(false);
return (
<Dropdown open={openDropdown} onOpenChange={setOpenDropdown}>
<DropdownMenuTrigger asChild>
<button
data-testid="more-guests"
onClick={(e) => e.stopPropagation()}
className="radix-state-open:text-blue-500 hover:text-blue-500 focus:outline-none">
{t("plus_more", { count: attendees.length - 1 })}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel className="text-xs font-medium uppercase">
{t("mark_as_no_show_title")}
</DropdownMenuLabel>
<form onSubmit={handleSubmit(onSubmit)}>
{fields.slice(1).map((field, index) => (
<Controller
key={field.id}
name={`attendees.${index + 1}.noShow`}
control={control}
render={({ field: { onChange, value } }) => (
<DropdownMenuCheckboxItem
checked={value || false}
onCheckedChange={onChange}
className="pr-8 focus:outline-none"
onClick={(e) => {
e.preventDefault();
onChange(!value);
}}>
<span className={value ? "line-through" : ""}>{field.email}</span>
</DropdownMenuCheckboxItem>
)}
/>
))}
<DropdownMenuSeparator />
<div className=" flex justify-end p-2">
<Button
data-testid="update-no-show"
color="secondary"
onClick={(e) => {
e.preventDefault();
handleSubmit(onSubmit)();
}}>
{t("mark_as_no_show_title")}
</Button>
</div>
</form>
</DropdownMenuContent>
</Dropdown>
);
};
@@ -668,17 +889,23 @@ const DisplayAttendees = ({
attendees,
user,
currentEmail,
bookingUid,
isBookingInPast,
}: {
attendees: AttendeeProps[];
user: UserProps | null;
currentEmail?: string | null;
bookingUid: string;
isBookingInPast: boolean;
}) => {
const { t } = useLocale();
attendees.sort((a, b) => a.id - b.id);
return (
<div className="text-emphasis text-sm">
{user && <FirstAttendee user={user} currentEmail={currentEmail} />}
{attendees.length > 1 ? <span>,&nbsp;</span> : <span>&nbsp;{t("and")}&nbsp;</span>}
<Attendee {...attendees[0]} />
<Attendee {...attendees[0]} bookingUid={bookingUid} isBookingInPast={isBookingInPast} />
{attendees.length > 1 && (
<>
<div className="text-emphasis inline-block text-sm">&nbsp;{t("and")}&nbsp;</div>
@@ -686,13 +913,13 @@ const DisplayAttendees = ({
<Tooltip
content={attendees.slice(1).map((attendee) => (
<p key={attendee.email}>
<Attendee {...attendee} />
<Attendee {...attendee} bookingUid={bookingUid} isBookingInPast={isBookingInPast} />
</p>
))}>
<div className="inline-block">{t("plus_more", { count: attendees.length - 1 })}</div>
{isBookingInPast && <GroupedAttendees attendees={attendees} bookingUid={bookingUid} />}
</Tooltip>
) : (
<Attendee {...attendees[1]} />
<Attendee {...attendees[1]} bookingUid={bookingUid} isBookingInPast={isBookingInPast} />
)}
</>
)}
@@ -0,0 +1,56 @@
import { useRouter } from "next/navigation";
import type { Dispatch, SetStateAction } from "react";
import type { MutableRefObject } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Dialog, DialogContent, Button, DialogFooter } from "@calcom/ui";
interface AssignmentWarningDialogProps {
isOpenAssignmentWarnDialog: boolean;
setIsOpenAssignmentWarnDialog: Dispatch<SetStateAction<boolean>>;
pendingRoute: string;
leaveWithoutAssigningHosts: MutableRefObject<boolean>;
id: number;
}
const AssignmentWarningDialog = (props: AssignmentWarningDialogProps) => {
const { t } = useLocale();
const {
isOpenAssignmentWarnDialog,
setIsOpenAssignmentWarnDialog,
pendingRoute,
leaveWithoutAssigningHosts,
id,
} = props;
const router = useRouter();
return (
<Dialog open={isOpenAssignmentWarnDialog} onOpenChange={setIsOpenAssignmentWarnDialog}>
<DialogContent title={t("leave_without_assigning_anyone")} Icon="circle-alert" type="confirmation">
<div className="text-subtle text-sm">
<p className="mt-3">{t("leave_without_adding_attendees")}</p>
<p className="mt-4">{t("no_availability_shown_to_bookers")}</p>
</div>
<DialogFooter className="mt-6">
<Button
onClick={(e) => {
e.preventDefault();
setIsOpenAssignmentWarnDialog(false);
router.replace(`/event-types/${id}?tabName=team`);
}}
color="minimal">
{t("go_back_and_assign")}
</Button>
<Button
onClick={(e) => {
e.preventDefault();
setIsOpenAssignmentWarnDialog(false);
leaveWithoutAssigningHosts.current = true;
router.replace(pendingRoute);
}}>
{t("leave_without_assigning")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default AssignmentWarningDialog;
@@ -82,6 +82,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
);
const placeholderHashedLink = `${WEBSITE_URL}/d/${hashedUrl}/${formMethods.getValues("slug")}`;
const seatsEnabled = formMethods.watch("seatsPerTimeSlotEnabled");
const multiLocation = (formMethods.getValues("locations") || []).length > 1;
const noShowFeeEnabled =
formMethods.getValues("metadata")?.apps?.stripe?.enabled === true &&
formMethods.getValues("metadata")?.apps?.stripe?.paymentOption === "HOLD";
@@ -434,7 +435,14 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
{...seatsLocked}
description={t("offer_seats_description")}
checked={value}
disabled={noShowFeeEnabled}
disabled={noShowFeeEnabled || multiLocation}
tooltip={
multiLocation
? t("multilocation_doesnt_support_seats")
: noShowFeeEnabled
? t("no_show_fee_doesnt_support_seats")
: undefined
}
onCheckedChange={(e) => {
// Enabling seats will disable guests and requiring confirmation until fully supported
if (e) {
@@ -158,6 +158,7 @@ export const EventSetupTab = (
const [animationRef] = useAutoAnimate<HTMLUListElement>();
const seatsEnabled = formMethods.getValues("seatsPerTimeSlotEnabled");
const validLocations = formMethods.getValues("locations").filter((location) => {
const eventLocation = getEventLocationType(location.type);
if (!eventLocation) {
@@ -444,6 +445,8 @@ export const EventSetupTab = (
data-testid="add-location"
StartIcon="plus"
color="minimal"
disabled={seatsEnabled}
tooltip={seatsEnabled ? t("seats_option_doesnt_support_multi_location") : undefined}
onClick={() => setShowEmptyLocationSelect(true)}>
{t("add_location")}
</Button>
@@ -33,6 +33,7 @@ export const mapMemberToChildrenOption = (
membership: member.membership,
eventTypeSlugs: member.eventTypes ?? [],
avatar: member.avatar,
profile: member.profile,
},
value: `${member.id ?? ""}`,
label: `${member.name || member.email || ""}${!member.username ? ` (${pendingString})` : ""}`,
@@ -0,0 +1,19 @@
import { EmptyScreen, Button } from "@calcom/ui";
export default function NoPlatformPlan() {
return (
<EmptyScreen
Icon="credit-card"
headline="Subscription needed"
description="You are not subscribed to a Platform plan."
buttonRaw={
<div className="flex gap-2">
<Button href="https://cal.com/platform/pricing">Go to Pricing</Button>
<Button color="secondary" href="https://cal.com/pricing">
Contact Sales
</Button>
</div>
}
/>
);
}
+17
View File
@@ -0,0 +1,17 @@
import * as Sentry from "@sentry/nextjs";
export function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
// reduce sample rate to 10% on production
tracesSampleRate: process.env.NODE_ENV !== "production" ? 1.0 : 0.1,
});
}
if (process.env.NEXT_RUNTIME === "edge") {
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
});
}
}
@@ -0,0 +1,22 @@
import type { GetServerSidePropsContext } from "next";
import { getServerSession } from "@calcom/feature-auth/lib/getServerSession";
import { AUTH_OPTIONS } from "@calcom/feature-auth/lib/next-auth-options";
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const session = await getServerSession({
req: context.req,
res: context.res,
authOptions: AUTH_OPTIONS,
});
// Disable this check if we ever make this self serve.
if (session?.user.role !== "ADMIN") {
return {
notFound: true,
} as const;
}
return {
props: {},
};
};
@@ -955,6 +955,7 @@ const EventTypesPage: React.FC & {
const orgBranding = useOrgBranding();
const routerQuery = useRouterQuery();
const filters = getTeamsFiltersFromQuery(routerQuery);
const router = useRouter();
// TODO: Maybe useSuspenseQuery to focus on success case only? Remember that it would crash the page when there is an error in query. Also, it won't support skeleton
const { data, status, error } = trpc.viewer.eventTypes.getByViewer.useQuery(filters && { filters }, {
@@ -967,6 +968,13 @@ const EventTypesPage: React.FC & {
if (searchParams?.get("openIntercom") === "true") {
open();
}
/**
* During signup, if the account already exists, we redirect the user to /event-types instead of onboarding.
* Adding this redirection logic here as well to ensure the user is redirected to the correct redirectUrl.
*/
const redirectUrl = localStorage.getItem("onBoardingRedirect");
localStorage.removeItem("onBoardingRedirect");
redirectUrl && router.push(redirectUrl);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -5,7 +5,9 @@ import { useAutoAnimate } from "@formkit/auto-animate/react";
import { zodResolver } from "@hookform/resolvers/zod";
import { isValidPhoneNumber } from "libphonenumber-js";
import dynamic from "next/dynamic";
import { useEffect, useMemo, useState } from "react";
// eslint-disable-next-line @calcom/eslint/deprecated-imports-next-router
import { useRouter } from "next/router";
import { useEffect, useMemo, useState, useRef } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
@@ -113,6 +115,8 @@ const EventAITab = dynamic(() => import("@components/eventtype/EventAITab").then
const ManagedEventTypeDialog = dynamic(() => import("@components/eventtype/ManagedEventDialog"));
const AssignmentWarningDialog = dynamic(() => import("@components/eventtype/AssignmentWarningDialog"));
export type Host = { isFixed: boolean; userId: number; priority: number };
export type CustomInputParsed = typeof customInputSchema._output;
@@ -154,6 +158,9 @@ const EventTypePage = (props: EventTypeSetupProps) => {
});
const { eventType, locationOptions, team, teamMembers, currentUserMembership, destinationCalendar } = props;
const [isOpenAssignmentWarnDialog, setIsOpenAssignmentWarnDialog] = useState<boolean>(false);
const [pendingRoute, setPendingRoute] = useState("");
const leaveWithoutAssigningHosts = useRef(false);
const [animationParentRef] = useAutoAnimate<HTMLDivElement>();
const updateMutation = trpc.viewer.eventTypes.update.useMutation({
onSuccess: async () => {
@@ -196,6 +203,32 @@ const EventTypePage = (props: EventTypeSetupProps) => {
},
});
const router = useRouter();
useEffect(() => {
const handleRouteChange = (url: string) => {
const paths = url.split("/");
if (
!!team &&
eventType.hosts.length === 0 &&
!leaveWithoutAssigningHosts.current &&
(url === "/event-types" || paths[1] !== "event-types")
) {
setIsOpenAssignmentWarnDialog(true);
setPendingRoute(url);
router.events.emit(
"routeChangeError",
new Error(`Aborted route change to ${url} because none was assigned to team event`)
);
throw "Aborted";
}
};
router.events.on("routeChangeStart", handleRouteChange);
return () => {
router.events.off("routeChangeStart", handleRouteChange);
};
}, [router]);
const [periodDates] = useState<{ startDate: Date; endDate: Date }>({
startDate: new Date(eventType.periodStartDate || Date.now()),
endDate: new Date(eventType.periodEndDate || Date.now()),
@@ -774,7 +807,6 @@ const EventTypePage = (props: EventTypeSetupProps) => {
<div ref={animationParentRef}>{tabMap[tabName]}</div>
</Form>
</EventTypeSingleLayout>
{slugExistsChildrenDialogOpen.length ? (
<ManagedEventTypeDialog
slugExistsChildrenDialogOpen={slugExistsChildrenDialogOpen}
@@ -791,10 +823,16 @@ const EventTypePage = (props: EventTypeSetupProps) => {
}}
/>
) : null}
<AssignmentWarningDialog
isOpenAssignmentWarnDialog={isOpenAssignmentWarnDialog}
setIsOpenAssignmentWarnDialog={setIsOpenAssignmentWarnDialog}
pendingRoute={pendingRoute}
leaveWithoutAssigningHosts={leaveWithoutAssigningHosts}
id={eventType.id}
/>
</>
);
};
const EventTypePageWrapper: React.FC<PageProps> & {
PageWrapper?: AppProps["Component"]["PageWrapper"];
getLayout?: AppProps["Component"]["getLayout"];
@@ -14,7 +14,6 @@ import {
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
import { EventTypeDescriptionLazy as EventTypeDescription } from "@calcom/features/eventtypes/components";
import EmptyPage from "@calcom/features/eventtypes/components/EmptyPage";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
import useTheme from "@calcom/lib/hooks/useTheme";
import { HeadSeo, Icon, UnpublishedEntity, UserAvatar } from "@calcom/ui";
@@ -26,7 +25,6 @@ export function UserPage(props: InferGetServerSidePropsType<typeof getServerSide
const [user] = users; //To be used when we only have a single user, not dynamic group
useTheme(profile.theme);
const { t } = useLocale();
const isBioEmpty = !user.bio || !user.bio.replace("<p><br></p>", "").length;
@@ -52,7 +50,7 @@ export function UserPage(props: InferGetServerSidePropsType<typeof getServerSide
}, [telemetry, router.asPath]); */
if (entity.considerUnpublished) {
return (
<div className="flex h-full min-h-[100dvh] items-center justify-center">
<div className="flex h-full min-h-[calc(100dvh)] items-center justify-center">
<UnpublishedEntity {...entity} />
</div>
);
@@ -81,7 +79,7 @@ export function UserPage(props: InferGetServerSidePropsType<typeof getServerSide
<main
className={classNames(
shouldAlignCentrally ? "mx-auto" : "",
isEmbed ? "border-booker border-booker-width bg-default rounded-md border" : "",
isEmbed ? "border-booker border-booker-width bg-default rounded-md" : "",
"max-w-3xl px-4 py-24"
)}>
<div className="mb-8 text-center">
@@ -126,7 +124,7 @@ export function UserPage(props: InferGetServerSidePropsType<typeof getServerSide
<div
key={type.id}
style={{ display: "flex", ...eventTypeListItemEmbedStyles }}
className="bg-default border-subtle dark:bg-muted dark:hover:bg-emphasis hover:bg-muted group relative border-b first:rounded-t-md last:rounded-b-md last:border-b-0">
className="bg-default border-subtle dark:bg-muted dark:hover:bg-emphasis hover:bg-muted group relative border-b transition first:rounded-t-md last:rounded-b-md last:border-b-0">
<Icon
name="arrow-right"
className="text-emphasis absolute right-4 top-4 h-4 w-4 opacity-0 transition-opacity group-hover:opacity-100"
+10 -8
View File
@@ -158,10 +158,12 @@ const matcherConfigUserTypeEmbedRoute = {
/** @type {import("next").NextConfig} */
const nextConfig = {
output: "standalone",
experimental: {
// externalize server-side node_modules with size > 1mb, to improve dev mode performance/RAM usage
serverComponentsExternalPackages: ["next-i18next"],
optimizePackageImports: ["@calcom/ui"],
instrumentationHook: true,
},
i18n: {
...i18n,
@@ -590,14 +592,14 @@ const nextConfig = {
};
if (!!process.env.NEXT_PUBLIC_SENTRY_DSN) {
nextConfig["sentry"] = {
autoInstrumentServerFunctions: true,
hideSourceMaps: true,
// disable source map generation for the server code
disableServerWebpackPlugin: !!process.env.SENTRY_DISABLE_SERVER_WEBPACK_PLUGIN,
};
plugins.push(withSentryConfig);
plugins.push((nextConfig) =>
withSentryConfig(nextConfig, {
autoInstrumentServerFunctions: true,
hideSourceMaps: true,
// disable source map generation for the server code
disableServerWebpackPlugin: !!process.env.SENTRY_DISABLE_SERVER_WEBPACK_PLUGIN,
})
);
}
module.exports = () => plugins.reduce((acc, next) => next(acc), nextConfig);
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "4.1.7",
"version": "4.2.0",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
@@ -60,7 +60,7 @@
"@radix-ui/react-switch": "^1.0.0",
"@radix-ui/react-toggle-group": "^1.0.0",
"@radix-ui/react-tooltip": "^1.0.0",
"@sentry/nextjs": "^7.73.0",
"@sentry/nextjs": "^8.8.0",
"@stripe/react-stripe-js": "^1.10.0",
"@stripe/stripe-js": "^1.35.0",
"@tanstack/react-query": "^5.17.15",
+229 -144
View File
@@ -3,10 +3,16 @@ import { createHmac } from "crypto";
import type { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod";
import { getDownloadLinkOfCalVideoByRecordingId } from "@calcom/core/videoClient";
import {
getDownloadLinkOfCalVideoByRecordingId,
submitBatchProcessorTranscriptionJob,
} from "@calcom/core/videoClient";
import { getAllTranscriptsAccessLinkFromRoomName } from "@calcom/core/videoClient";
import { sendDailyVideoRecordingEmails } from "@calcom/emails";
import { sendDailyVideoTranscriptEmails } from "@calcom/emails";
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
import sendPayload from "@calcom/features/webhooks/lib/sendOrSchedulePayload";
import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId";
import { getTeamIdFromEventType } from "@calcom/lib/getTeamIdFromEventType";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
@@ -17,26 +23,40 @@ import type { CalendarEvent } from "@calcom/types/Calendar";
const log = logger.getSubLogger({ prefix: ["daily-video-webhook-handler"] });
const schema = z
const commonSchema = z
.object({
version: z.string(),
type: z.string(),
id: z.string(),
payload: z.object({
recording_id: z.string(),
end_ts: z.number().optional(),
room_name: z.string(),
start_ts: z.number().optional(),
status: z.string(),
max_participants: z.number().optional(),
duration: z.number().optional(),
s3_key: z.string().optional(),
}),
event_ts: z.number().optional(),
})
.passthrough();
const meetingEndedSchema = commonSchema.extend({
payload: z
.object({
meeting_id: z.string(),
end_ts: z.number().optional(),
room: z.string(),
start_ts: z.number().optional(),
})
.passthrough(),
});
const recordingReadySchema = commonSchema.extend({
payload: z.object({
recording_id: z.string(),
end_ts: z.number().optional(),
room_name: z.string(),
start_ts: z.number().optional(),
status: z.string(),
max_participants: z.number().optional(),
duration: z.number().optional(),
s3_key: z.string().optional(),
}),
});
const downloadLinkSchema = z.object({
download_link: z.string(),
});
@@ -60,11 +80,16 @@ const triggerWebhook = async ({
// Send Webhook call if hooked to BOOKING.RECORDING_READY
const triggerForUser = !booking.teamId || (booking.teamId && booking.eventTypeParentId);
const organizerUserId = triggerForUser ? booking.userId : null;
const orgId = await getOrgIdFromMemberOrTeamId({ memberId: organizerUserId, teamId: booking.teamId });
const subscriberOptions = {
userId: triggerForUser ? booking.userId : null,
userId: organizerUserId,
eventTypeId: booking.eventTypeId,
triggerEvent: eventTrigger,
teamId: booking.teamId,
orgId,
};
const webhooks = await getWebhooks(subscriberOptions);
@@ -89,6 +114,102 @@ const triggerWebhook = async ({
await Promise.all(promises);
};
const getBooking = async (bookingId: number) => {
const booking = await prisma.booking.findUniqueOrThrow({
where: {
id: bookingId,
},
select: {
...bookingMinimalSelect,
uid: true,
location: true,
isRecorded: true,
eventTypeId: true,
eventType: {
select: {
teamId: true,
parentId: true,
},
},
user: {
select: {
id: true,
timeZone: true,
email: true,
name: true,
locale: true,
destinationCalendar: true,
},
},
},
});
if (!booking) {
log.error(
"Couldn't find Booking Id:",
safeStringify({
bookingId,
})
);
}
return booking;
};
const getBookingReference = async (roomName: string) => {
const bookingReference = await prisma.bookingReference.findFirst({
where: { type: "daily_video", uid: roomName, meetingId: roomName },
select: { bookingId: true },
});
if (!bookingReference) {
log.error(
"bookingReference not found error:",
safeStringify({
bookingReference,
roomName,
})
);
}
return bookingReference;
};
const getCalendarEvent = async (booking: Awaited<ReturnType<typeof getBooking>>) => {
const t = await getTranslation(booking?.user?.locale ?? "en", "common");
const attendeesListPromises = booking.attendees.map(async (attendee) => {
return {
id: attendee.id,
name: attendee.name,
email: attendee.email,
timeZone: attendee.timeZone,
language: {
translate: await getTranslation(attendee.locale ?? "en", "common"),
locale: attendee.locale ?? "en",
},
};
});
const attendeesList = await Promise.all(attendeesListPromises);
const evt: CalendarEvent = {
type: booking.title,
title: booking.title,
description: booking.description || undefined,
startTime: booking.startTime.toISOString(),
endTime: booking.endTime.toISOString(),
organizer: {
email: booking?.userPrimaryEmail || booking.user?.email || "Email-less",
name: booking.user?.name || "Nameless",
timeZone: booking.user?.timeZone || "Europe/London",
language: { translate: t, locale: booking?.user?.locale ?? "en" },
},
attendees: attendeesList,
uid: booking.uid,
};
return Promise.resolve(evt);
};
const testRequestSchema = z.object({
test: z.enum(["test"]),
});
@@ -116,157 +237,121 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
return res.status(403).json({ message: "Signature does not match" });
}
const response = schema.safeParse(req.body);
log.debug(
"Daily video recording webhook Request Body:",
"Daily video webhook Request Body:",
safeStringify({
response,
body: req.body,
})
);
if (!response.success || response.data.type !== "recording.ready-to-download") {
return res.status(400).send({
message: "Invalid Payload",
});
}
const { room_name, recording_id, status } = response.data.payload;
if (status !== "finished") {
return res.status(400).send({
message: "Recording not finished",
});
}
try {
const bookingReference = await prisma.bookingReference.findFirst({
where: { type: "daily_video", uid: room_name, meetingId: room_name },
select: { bookingId: true },
});
if (req.body?.type === "recording.ready-to-download") {
const recordingReadyResponse = recordingReadySchema.safeParse(req.body);
if (!bookingReference || !bookingReference.bookingId) {
log.error(
"bookingReference not found error:",
safeStringify({
bookingReference,
room_name,
recording_id,
})
);
return res.status(200).send({ message: "Booking reference not found" });
}
if (!recordingReadyResponse.success) {
return res.status(400).send({
message: "Invalid Payload",
});
}
const booking = await prisma.booking.findUniqueOrThrow({
where: {
id: bookingReference.bookingId,
},
select: {
...bookingMinimalSelect,
uid: true,
location: true,
isRecorded: true,
eventTypeId: true,
eventType: {
select: {
teamId: true,
parentId: true,
},
const { room_name, recording_id, status } = recordingReadyResponse.data.payload;
if (status !== "finished") {
return res.status(400).send({
message: "Recording not finished",
});
}
const bookingReference = await getBookingReference(room_name);
if (!bookingReference || !bookingReference.bookingId) {
return res.status(200).send({ message: "Booking reference not found" });
}
const booking = await getBooking(bookingReference.bookingId);
if (!booking) {
return res.status(404).send({
message: `Booking of room_name ${room_name} does not exist or does not contain daily video as location`,
});
}
const evt = await getCalendarEvent(booking);
await prisma.booking.update({
where: {
uid: booking.uid,
},
user: {
select: {
id: true,
timeZone: true,
email: true,
name: true,
locale: true,
destinationCalendar: true,
},
data: {
isRecorded: true,
},
},
});
if (!booking) {
log.error(
"Booking:",
safeStringify({
booking,
})
);
return res.status(404).send({
message: `Booking of room_name ${room_name} does not exist or does not contain daily video as location`,
});
}
const t = await getTranslation(booking?.user?.locale ?? "en", "common");
const attendeesListPromises = booking.attendees.map(async (attendee) => {
return {
id: attendee.id,
name: attendee.name,
email: attendee.email,
timeZone: attendee.timeZone,
language: {
translate: await getTranslation(attendee.locale ?? "en", "common"),
locale: attendee.locale ?? "en",
const response = await getDownloadLinkOfCalVideoByRecordingId(recording_id);
const downloadLinkResponse = downloadLinkSchema.parse(response);
const downloadLink = downloadLinkResponse.download_link;
const teamId = await getTeamIdFromEventType({
eventType: {
team: { id: booking?.eventType?.teamId ?? null },
parentId: booking?.eventType?.parentId ?? null,
},
};
});
});
const attendeesList = await Promise.all(attendeesListPromises);
await triggerWebhook({
evt,
downloadLink,
booking: {
userId: booking?.user?.id,
eventTypeId: booking.eventTypeId,
eventTypeParentId: booking.eventType?.parentId,
teamId,
},
});
await prisma.booking.update({
where: {
uid: booking.uid,
},
data: {
isRecorded: true,
},
});
try {
// Submit Transcription Batch Processor Job
await submitBatchProcessorTranscriptionJob(recording_id);
} catch (err) {
log.error("Failed to Submit Transcription Batch Processor Job:", safeStringify(err));
}
const response = await getDownloadLinkOfCalVideoByRecordingId(recording_id);
const downloadLinkResponse = downloadLinkSchema.parse(response);
const downloadLink = downloadLinkResponse.download_link;
// send emails to all attendees only when user has team plan
await sendDailyVideoRecordingEmails(evt, downloadLink);
return res.status(200).json({ message: "Success" });
} else if (req.body.type === "meeting.ended") {
const meetingEndedResponse = meetingEndedSchema.safeParse(req.body);
if (!meetingEndedResponse.success) {
return res.status(400).send({
message: "Invalid Payload",
});
}
const { room } = meetingEndedResponse.data.payload;
const evt: CalendarEvent = {
type: booking.title,
title: booking.title,
description: booking.description || undefined,
startTime: booking.startTime.toISOString(),
endTime: booking.endTime.toISOString(),
organizer: {
email: booking?.userPrimaryEmail || booking.user?.email || "Email-less",
name: booking.user?.name || "Nameless",
timeZone: booking.user?.timeZone || "Europe/London",
language: { translate: t, locale: booking?.user?.locale ?? "en" },
},
attendees: attendeesList,
uid: booking.uid,
};
const bookingReference = await getBookingReference(room);
if (!bookingReference || !bookingReference.bookingId) {
return res.status(200).send({ message: "Booking reference not found" });
}
const teamId = await getTeamIdFromEventType({
eventType: {
team: { id: booking?.eventType?.teamId ?? null },
parentId: booking?.eventType?.parentId ?? null,
},
});
const booking = await getBooking(bookingReference.bookingId);
await triggerWebhook({
evt,
downloadLink,
booking: {
userId: booking?.user?.id,
eventTypeId: booking.eventTypeId,
eventTypeParentId: booking.eventType?.parentId,
teamId,
},
});
if (!booking) {
return res.status(404).send({
message: `Booking of room_name ${room} does not exist or does not contain daily video as location`,
});
}
// send emails to all attendees only when user has team plan
await sendDailyVideoRecordingEmails(evt, downloadLink);
return res.status(200).json({ message: "Success" });
const transcripts = await getAllTranscriptsAccessLinkFromRoomName(room);
if (!transcripts || !transcripts.length)
return res.status(200).json({ message: `No Transcripts found for room name ${room}` });
const evt = await getCalendarEvent(booking);
await sendDailyVideoTranscriptEmails(evt, transcripts);
return res.status(200).json({ message: "Success" });
}
} catch (err) {
console.error("Error in /recorded-daily-video", err);
log.error("Error in /recorded-daily-video", err);
return res.status(500).json({ message: "something went wrong" });
}
}

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