Compare commits

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

* fix: broken ui in edit location dialog

* Update editLocation.handler.ts

---------

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

* Add e2e for org

* Add team booking reschedule test

* Add unit tests

* Remove unnecessary select

---------

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

* test: add ToolbarPlugin test suite

* test: add AutoLinkPlugin test suite

* test: add AddVariablesDropdown test suite

* test: add Editor test suite

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

---------

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

* review feedback

---------

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

* add: bottom margin to alert component

* revert alert comp changes

* small update

---------

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

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

* Update PhoneInput.tsx

* update event-types e2e

---------

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

* chore: platform-libraries export updateNewTeamMemberEventTypes

* chore: v2 api bump platform-libraries to 0.0.22

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

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

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

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

* refactor: lowercase schedulingType for creating team event-types

* refactor: lowercase schedulingType for creating team event-types

* feat: separate platform teams by OAuth client

* swagger regenerate

* refactor: delete platform team when oauth client deleted

* revert: keep 4 default event-types

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

* fix: typecheck

---------

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

* feat: display organizationId in OAuthClientCard

* feat: ApiAuthStrategy handle oauth credentials

* refactor: clean up ApiAuthStrategy test

* refactor: use ApiAuthGuard in OauthClientsUsersController and OAuthFlowController

* fix: copying org id

* refactor: more specific error message when api auth

* refactor: auto accept team creator membership

* feat: useTeamEventType and useTeamEventTypes hooks

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

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

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

* fix: useTeamEventType eventSlug access

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

* refactor: provide orgId in atoms context

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

* refactor: return teams in useMe

* chore: examples app teams setup

* Revert "refactor: return teams in useMe"

This reverts commit de992ddc9af6ee9a2111938069f5b9c34cc2d8ea.

* Revert "chore: examples app teams setup"

This reverts commit 0766aa21acc25efa2361d38c3f87ddba773a0245.

* feat: useTeams hook

* chore: setup examples app with team event-type

* fix: small fixes

* swagger

* Revert "refactor: provide orgId in atoms context"

This reverts commit f053a498ee6f8fa8ece5ec8d8630c59eda8873e3.

* feat: orgId in atoms context

* feat: PlatformBilling guard

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

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

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

* fix: organizations event-types e2e

* fix: billing guard

* refactor: tests cleanup

* fix: platform plan guard spec

* refactor: e2e test cleanup

* seed error if not team

* refactor: rename authenticateApiKey to authenticateBearerToken

* refactor: transforming response hosts

* refactor: rename findUniqueByMatchingAutoAcceptEmail to findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail

* refactor: rename findUniqueByMatchingAutoAcceptEmail to findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail

---------

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

* fix: already spelling

---------

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

* refactor: remove console log

* refactor: revert redundant change

* refactor: review changes

* fix: min max validation

* fix: fix typo

* refactor: update comments

* refactor: review changes

* refactor: review changes

* test: Add test

* Make maxLength more configurable per field type

* Reduce maxLength to 1000

* Update update-event-type.input.ts

* revert changes in main

* Update update-event-type.input.ts

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

* add e2e tests

* add min max char common.json

* Update FormBuilder.tsx

* refactor changes

* Update fieldTypes.ts

* Update schema.ts

* add min max length in api-request

* Update booking-fields.input.ts

* fix e2e tests

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

* remove test

* reorder imports to fix lint

* fix lint

* fix e2e tests

* Abstract out SupportsLengthcheck type Field

* Revert newline change

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

* revert: v2 API changes

---------

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

* Only allow company emails for org creation

---------

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

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

* feat: Implement a feature flag check trcp middleware

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

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

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

---------

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

* feat: add schema and fieds

* chore: update code

* chore: save

* refactor: code and type error

* feat: use facade + command design pattern

* chore: missing param

* feat: add custom template feature

* refactor: code

* chore: move the default prompt

* fix: type err

* fix: code and ux improvements

* chore: change default template

* fix: ui and typo

* fix: add validation check for my phone number

---------

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

* small fix

---------

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

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

* update

---------

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

* updated tests for validateCustomEventName

---------

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

* Add mock trpc context

* Intro mockDatabaseClient

* Introduce mockDatabaseClient class

* Add delete video app test

* Add calendar test

* Remove unused func from bookingScenario

* Remove console.log

* Add app repository

* Add createMany method to event type repository

* Remove instance of MockDatabaseClient from video test

* Add destination calendar repository

* Remove instances of MockDatabaseClient

* abstract logic to own handler

* Remove dev dependency

* Clean up

* Type fix

* Pull yarn.lock from main

---------

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

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

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

---------

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

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

* change comment

* fix: added types

---------

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

* fix: response message ownerId

---------

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

* filter no show organizer

* remove logs

* fix: filter bookings where host attendee is no show

* fix: use not condition for noShow

* chore: test

* add default false for noShowHost

* Revert "chore: test"

This reverts commit c914a8fc4a14ca9c7d8c4a08ff8b0f463789171c.

* chore: integration test

* fix: noShowHost not working for null

* Update packages/lib/server/getLuckyUser.ts

* chore: update test description

---------

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

* Improve test

* Add unit tests as well

---------

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

* update

* Update EventTypeSingleLayout.tsx

* Update event-types-single-view.tsx

* update

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

---------

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

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

---------

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

* chore: Update site.webmanifest with additional display properties

* Update apps/web/public/site.webmanifest

---------

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

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

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

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

* Update packages/lib/constants.ts

* Update turbo.json

---------

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

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

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

* update for managed eventtype

* formatted

* comments

* Updated comments

* extracted checkForEmptyAssignment and added tests

* updated comments

* updated to show dialog also when not yet saved

* update translations

* updated comments

* updated translation

* updated types

* chore: update naming and comments

* Delete apps/web/lib/checkForEmptyAssignment.ts

deleted since file was renamed

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

deleted as the file is renamed to be more descriptive

* update to do teamcheck before

* chore: revert name to CheckForEmptyAssignment

* updated check conditions

* updated useEffect dependencies

---------

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

* chore: update text-editor input

* final update

* Update Editor.tsx

---------

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

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

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

* Add comment about proxy URL request when on older versions

* feat: create custom axios instance to use signature middleware

* fix: update form to respect new schema

* Add cal signature token to trubo env

* use backend mutation for creating license keys

* add loading state to creation button

* update form to have a success state

* fix type error for console.warning :')

* use api version v1

* Inital setup

* Fix verifyApi Key tests

* Implement tests for LicenseKeyService

* Restore old checkLicense code

* check license key first

* fix imports

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

* Use data.status for licenseCheck

* extract fetch to class

* improve error

* [wip] per user tracking

* Remove new URL as it was breaking search params

* Use client extention in customPrismaClient

* Remove log

* Remove license required from creation form as itll fail

* Fix annual price calculation

* add api route to .env.example and constants

* re-word instructions in .env.example

* chore: feedback

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

* Add IS_E2E_ENABLED constant + update tests

* Revert "Add IS_E2E_ENABLED constant + update tests"

This reverts commit d059b73f6272bc06fd2ab5ed505b6d52ea030a56.

* chore: tests fixes

* fixes: unit tests

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

* chore: add missing instructions

---------

Signed-off-by: zomars <zomars@me.com>
Co-authored-by: sean-brydon <sean@cal.com>
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
2024-07-26 10:10:46 +00:00
31e6b88951 fix: Fix teams helper image background color mode (#15918)
Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>
Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-07-26 10:25:39 +02:00
Alex van Andel fbff4db2a6 chore: Release v4.3.3 (#15924)
release.yml / release (push) Canceled after 0s
2024-07-26 09:00:00 +01:00
Udit Takkar ba61731689 fix: make max_participants optional (#15920) 2024-07-26 07:08:05 +00:00
sean-brydonandOmar López e16662e655 feat: usage base billed - license key check - move to private api (#15222)
Co-authored-by: Omar López <zomars@me.com>
2024-07-25 13:20:24 -07:00
sean-brydon 53c37a9ddf feat: refactor drawer component (#15892)
* Add sheet component

* refactor user edit sheet

* refactor availbility sheet edit

* Remove tremor raw sheet text

* remove accidently file commit

* fix overflow issue
2024-07-25 12:41:23 +01:00
Morgan 64ef2703a5 feat: org members can fetch their teams apiv2 (#15910)
* feat: org members can fetch their teams apiv2

* fixup! feat: org members can fetch their teams apiv2

* feat: org members can fetch their teams apiv2

* fixup! feat: org members can fetch their teams apiv2

* fixup! fixup! feat: org members can fetch their teams apiv2

* fixup! fixup! fixup! feat: org members can fetch their teams apiv2

* fixup! fixup! fixup! fixup! feat: org members can fetch their teams apiv2
2024-07-25 10:39:43 +00:00
Rajiv SahalandMorgan Vernay a997edd8db chore: update dtos for v2 calendars endpoint (#15904)
* init calendars input

* update request body input type

* fix incorrect naming

* update imprts

* fixup! update imprts

* fixup! fixup! update imprts

---------

Co-authored-by: Morgan Vernay <morgan@cal.com>
2024-07-25 08:49:18 +00:00
Peer Richelsen bd73eba528 chore: Update config.yml (#15900) 2024-07-25 09:35:33 +02:00
0d53c2069e feat: team owner/admin, be able to confirm/reject "requires confirmation" team events (#15326)
* feat: eam owner/admin, be able to confirm/reject requir
es confirmation team events

* update

* addressed requested changes

* remove console.log

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Somay Chauhan <somaychauhan98@gmail.com>
Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>
2024-07-25 09:35:03 +02:00
Souptik Datta 3b559525a8 fix: Unable to invite a user to a team via username (#15868)
* fix: Fix invalid existing username DB query

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

* fix: Support adding org team member using username

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

* fix: Remove invite by username logic and fix labe to display only email

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

* refactor: Remove username support completely from invitation-modal component

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

---------

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>
2024-07-25 07:04:44 +00:00
Peer Richelsen 33e1abf4a7 chore: fixed bug_report.yml (#15901) 2024-07-24 22:55:48 +00:00
059c6c37ba fix google calendar event not deleted on default calendar of cal event change (#14994)
Co-authored-by: Shaik-Sirajuddin <sirajudddinshaik30@gmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2024-07-24 15:34:41 +00:00
Peer Richelsen 14bc489d70 chore: Update bug_report.md (#15894)
* Update bug_report.md

* Apply suggestions from code review
2024-07-24 15:22:45 +00:00
Morgan e8b36d5b0b chore: platform api usage based billing using queue (#15085)
* chore: platform api usage based billing using queue

* fixup! chore: platform api usage based billing using queue

* fixup! fixup! chore: platform api usage based billing using queue

* fixup! Merge branch 'chore-platformapi-billing-queue' of github.com:calcom/cal.com into chore-platformapi-billing-queue

* fixup! fixup! Merge branch 'chore-platformapi-billing-queue' of github.com:calcom/cal.com into chore-platformapi-billing-queue

* fixup! Merge branch 'main' into chore-platformapi-billing-queue

* chore: platform billing by userId

* fix: fix tests requirement team subscirption

* fixup! Merge branch 'main' into chore-platformapi-billing-queue

* fixup! fixup! Merge branch 'main' into chore-platformapi-billing-queue

* fixup! Merge branch 'main' into chore-platformapi-billing-queue

* improve tests logs

* fix: silently close redis and queue

* fixup! Merge branch 'main' into chore-platformapi-billing-queue

* fixup! Merge branch 'main' into chore-platformapi-billing-queue

* fixup! fixup! Merge branch 'main' into chore-platformapi-billing-queue

* fix: upgrade libraries version
2024-07-24 17:10:22 +02:00
Saket kumar singh 5983c960a6 Fix: #15888 (#15890) 2024-07-24 14:03:31 +00:00
Souptik Dattaandunknown 40305b9c5a fix: Change conferencing app-store URL on event types and bookings pages (#15878)
* fix: Change conferencing app-store URL on event types and bookings pages

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

* chore: Change conference app store help text

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

* chore: Revert app install helper text key for all locales except en

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

* delete key and text

---------

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>
Co-authored-by: unknown <adhabal2002@gmail.com>
2024-07-24 14:02:13 +00:00
Lauris Skraucis dcc62c6db0 fix: CreateAvailabilityInput_2024_04_15 Cannot read properties of undefined (#15885) 2024-07-24 12:52:42 +00:00
Lucas Víctor Ferreira de AraújoandAnik Dhabal Babu 07f14e4e14 test: test cases for POST selected-calendars (#15873)
* test-selected_calendars

* Update _post.test.ts

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-07-24 14:29:09 +02:00
Keith Williams f940f6bd87 chore: Remove unit tests from running after crowd in (#15880)
* chore: Remove unit tests from running after crowd in

* Update permissions on all workflows

* Updated pr permissions

* Added actions:write to top-level pr

* Update .github/workflows/unit-tests.yml

* Removed the linting annotation
2024-07-24 15:18:11 +03:00
Keith Williams ca5dcfd1e8 chore: Add v2 API test suite (#15865)
* chore: Add v2 API test suite

* Changed oauth credentials guard to e2e spec

* Uploading e2e artifacts

* Removed commented line
2024-07-24 11:02:03 +02:00
Lauris Skraucis 324728d4ec refactor: v2 organizations event-types e2e tests (#15886) 2024-07-24 07:15:26 +00:00
Trillium S 2f7cc05cad fix: Update component order, props for Link in user-public-view (#15882) 2024-07-24 12:28:52 +05:30
Keith Williams 84be4dba8d chore: v4.3.2 (#15879)
release.yml / release (push) Canceled after 0s
2024-07-23 16:52:12 +02:00
Peer Richelsen 17655cd1c8 chore: updated feature_request.md template (#15877)
* Update feature_request.md

* Update .github/ISSUE_TEMPLATE/feature_request.md
2024-07-23 15:10:25 +02:00
Amit SharmaandSomay Chauhan 010f5349f6 feat: Link to rescheduled booking from the old one (#15211)
* feat: Link to rescheduled booking from the old one

* add original comment

* use fromReschedule

* fix: type check

* review updates

* Update common.json

* change booking page text

* Delete packages/features/bookings/lib/getRescheduledToBooking.tsx

---------

Co-authored-by: Somay Chauhan <somaychauhan98@gmail.com>
2024-07-23 12:39:50 +00:00
Lauris Skraucis 01dead9a1d refactor: auto-accept membership for v2 org team creator (#15866) 2024-07-23 12:34:33 +00:00
Peer Richelsen 900ea37b43 chore: updated bug_report.md template (#15876)
* Update bug_report.md

* Update .github/ISSUE_TEMPLATE/bug_report.md

* Update .github/ISSUE_TEMPLATE/bug_report.md

* Update .github/ISSUE_TEMPLATE/bug_report.md
2024-07-23 12:24:42 +00:00
Morgan c209c22b98 chore: add cleanup to api auth strategy e2e test apiv2 (#15875) 2024-07-23 12:22:46 +00:00
b8610dc2d1 test: Create 4 test cases to POST Attendees (#15856)
* Create 4 test cases to POST Attendees

* Adjusted commented tips on PR

* Adjusted isAdmin to isSystemWideAdmin

* Removed req.isAdmin

* Delete test-insights.csv

---------

Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-07-23 14:19:36 +02:00
Anik Dhabal BabuandAmit Sharma 28f84d3570 feat: skype app added (#14751)
Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>
2024-07-23 09:39:34 +00:00
b46a902a3d feat: api endpoints for calendar settings atom (#15761)
* calendars repository

* add disconnect endpoint

* update platform libraries to include getCalendar fn

* cleanup

* fix typings

* update calendars service to include delete credentials handler

* update typings

* add missing return

* refactor delete calendar credentials handler

* update disconnect endpoint

* fix typing

* fixup

* add handlers to insert and remove selected calendar

* init selected calendars controller

* fix naming

* output response dto for deleting calendar credentials

* fix typing

* fix merge conflicts

* resolve merge conflicts

* fixup

* cleanup

* capitalize controller name

* include selected calendars controller in selected calendars module

* fix type error

* cleanup

* fixup

* add calendars repository

* custom hook for calendar credentials

* fix typing

* cleanup

* take input from query params instead of body in delete request

* custom hook to add selected calendar

* custom hook to remove selected calendar

* better naming

* fix typo

* fixup

* update input for delete calendar query params

* address PR feedback

* add method to check calendar credentials

* abstract logic to check calendar credentials in calendars service

* resolve module errors

* export custom hooks for calendar settings

* e2e for deleting calendar credentials endpoint

* fix typo

* set authorization header to calendars post

* e2e tests for selected calendars controller

* fix output typing

* better error messages

* restructuring

* fix imports

* remove unused not found exception

* fixup! remove unused not found exception

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: Morgan Vernay <morgan@cal.com>
2024-07-22 17:26:39 +00:00
Lauris Skraucis de2f51e104 feat: v2 API enable updating event type schedule (#15803)
* refactor: event-types CREATE, UPDATE accept scheduleId and return scheduleId

* chore: enable passing scheduleId to event-types create handler

* chore: use platform-libraries at version 0.0.20

* fix: remove team output schedule

* finish merge main into this branch

* fix: dont allow adding scheduleId to event-type if user does not own schedule
2024-07-22 20:09:54 +03:00
Hariom Balhara 35110892c1 fix: Fix React cal api namespaced usage (#15834)
* fix: Fix React cal api namespaced usage

* Update e2e
2024-07-22 12:01:05 +02:00
Hichem FantarandAnik Dhabal Babu c870df7027 chore: Add transition effects in UI components (#15841)
* chore: Add transition to hover effects in UI components

* chore: Add transition to hover effects in UI components

* Update ToggleGroup component transition

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-07-22 11:50:44 +02:00
Krishnendu SukumarandAnik Dhabal Babu a4b0fdba7b fix: Text in React Embed Code Becomes Invisible on Selection (#15835)
* fix: #15832

* fix: #15832

* Revert yarn.lock to its state before unintended changes

* Update EmbedTabs.tsx

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-07-22 09:26:13 +02:00
Punit Kumar Ojha b530d31b35 fix: added fix for email title for issue# 15848 (#15855) 2024-07-22 09:22:08 +02:00
Anik Dhabal Babu a69f9c5fa3 fix: broken ui of availability setup in onboarding (#15854) 2024-07-22 09:20:48 +02:00
73b8569a16 fix: added translations for et (#15762)
* added translations for et

* estonian support

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: unknown <adhabal2002@gmail.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-07-22 08:42:07 +02:00
Hariom BalharaandUdit Takkar 5becc95fc4 feat: Add a fresh signup to matching organization after email verification (#15705)
* Support automatically adding user to organization

* Use Repository

* Fix unit test

* Code Improvements

* Fix type error

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-07-20 12:22:42 +05:30
SuPrabhuandsumantha a74522af3d fix: TimeZone select should show the selected timezone (#15838)
* bugfix - CAL4052 TimeZone select should show the selected timezone

* Pusheding the code for type - check failure

* Updating MenuPosition as absolute

---------

Co-authored-by: sumantha <sumantha-ullasa-prabhu@hpe.com>
2024-07-19 23:49:12 +05:30
Lauris Skraucis 8b23e41954 feat: v2 organizations event types endpoints (#15775)
* fix: event-types input service transform length property

* feat: create organizations event-types controller, service and repository

* feat: enable organizations event types

* fix: create team request user isOrgAdmin check

* refactor: hosts array required in team input and output

* chore: use platform-libraries at version 0.0.19

* tests: test organizations event-types

* feat: enable managed event-types

* chore: fix main merge into this branch

* feat: assign all team members for managed event-types

* refactor: gathering managed event-type users ids

* chore: include assignAllTeamMembers in team event response

* test: assign all team members db event

* chore: delete unused files

* docs: swagger create and update request body objects

* revert: event-types platform-libraries update

* refactor: organisations event-types endpoints accessible by TEAM_ADMIN and up

* fix: managed event types output hosts array empty

* fix: dont allow creating event-type for non-team member

* chore: delete unused variables

* tests: negative tests

* chore: finish main merge
2024-07-19 15:12:05 +00:00
Morgan 94873ea3cb fix: api-auth-strategy tests are e2e apiv2 (#15837) 2024-07-19 14:51:50 +00:00
0c0cac28fd fix: multiple getSchedule requests on email change in booker (#15616)
* fix: multiple getSchedule requests on email change in booker

* fix: use debounce for formEmail

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-07-19 12:30:41 +00:00
Richard Poelderl 769ca9002e fix(website): fix sitemap by adding prisma env var (#15827)
* update docs for `yarn.lock` management

* fix(website): Add `DATABASE_URL` as required environment variable to `website`
2024-07-19 11:56:37 +00:00
Morgan 03366d2337 feat: api v2 org team memberships endpoints (#15820)
* feat: api v2 org team memberships endpoints

* fixup! feat: api v2 org team memberships endpoints
2024-07-19 11:44:16 +00:00
159fe3a87d fix: system default theme for dashboard and booking page (#15291)
* fix: system default theme

* update

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: unknown <adhabal2002@gmail.com>
2024-07-19 13:42:21 +02:00
Morgan 5b97d3bf52 fix: revert libraries version event-types controller apiv2 (#15831) 2024-07-19 08:50:15 +00:00
Morgan 7cdecf57e0 fix: PATCH /me formatted timezone incorrectly (#15830) 2024-07-19 08:49:59 +00:00
Udit Takkar 1c8d0eda45 chore: update translation (#15818)
* chore: change title and description

* chore: update
2024-07-19 07:40:59 +00:00
7ee3f9409c feat: [CAL-3939] fix org banner (#15557)
* update documentation for proper hostile command

* add OrgBanner component and replace it in ee organization profile settings

* add width and height defaults, with option to override

* updated sizing with image loaded

* fix: add alt to OrgBanner in test

* fix: failing OrgBanner test, added neg test

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2024-07-19 05:30:50 +00:00
Keith Williams 0d1dd036eb revert: "chore: removed cal.ai email assistant (#15793)" (#15823) 2024-07-18 11:57:40 +00:00
Omar López 2af27170cd chore: v4.3.1 (#15817)
release.yml / release (push) Canceled after 0s
2024-07-18 13:09:30 +02:00
Joe Au-Yeungandzomars 218f6d6eaf feat: Salesforce - Disable creating new contacts (#15780)
Co-authored-by: zomars <zomars@me.com>
2024-07-18 10:50:42 +00:00
Hariom Balhara f0464e72c7 fix: Check URL (#15815) 2024-07-18 10:20:03 +00:00
Anik Dhabal BabuandPeer Richelsen 2ad3592eb3 chore: Introduce a new class WebAppURL that extends URL (#14982)
* chore: Introduce a new class WebAppURL that extends URL

* update

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2024-07-18 07:27:34 +00:00
Syed Ali ShahbazandMorgan Vernay 22fc47f69f feat: V2 Org Memberships (#15578)
* init

* --WIP

* WIP

* ...

* more wip

* doc

* address feedback --WIP

* further requested changes

* fix

* add missing parseIntPipe

* chore: code review comments + e2e tests

* fixup! chore: code review comments + e2e tests

* fixup! fixup! chore: code review comments + e2e tests

* fix: don't allow passing teamId for org membership create/update

* fix: improve docs and DTOs

---------

Co-authored-by: Morgan Vernay <morgan@cal.com>
2024-07-18 03:43:20 +00:00
Morgan af3d2e6879 chore: set managed user locale via api v2 (#15794) 2024-07-17 12:56:37 +00:00
Morgan 58e61cb18e fix: findOrgUser use profiles instead of organizationId apiv2 (#15808) 2024-07-17 12:51:42 +00:00
NitinPSingh 9e3f8a2dc0 fix: Add signout button on onboarding screen (#15807)
* fix #15787 created signoutbtn on onboarding screen

* fix: Add signout button on onboarding screen and changes done as per feedback

* small error removed
2024-07-17 14:32:13 +02:00
Udit Takkar e960b8ad10 fix: fetch workflows on the backend (#15796)
* fix: fetch workflows on the router

* chore: remove workflows

* chore: import zod

* chore: move to repository
2024-07-17 07:02:22 +00:00
Peer Richelsen b7bfd77f95 readded border (#15798) 2024-07-16 15:46:40 +00:00
Peer Richelsen 2707adbb7b chore: removed cal.ai email assistant (#15793)
* removed cal-ai

* removed apps/ai
2024-07-16 14:21:00 +00:00
Morgan 5ef1b22c87 fix: remove permission blocking public apiv2 event-types endpoint (#15801) 2024-07-16 14:05:51 +00:00
Mohit YadavandKeith Williams 7e29db7a1b docs(contributing): change fork link (#15792)
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-07-16 14:25:06 +02:00
41cde8e384 feat: API org user scope (#15739)
* Init organizations users GET endpoint

* Fix get users endpoint

* Add create user input

* Add POST to organization user endpoint

* Creating a user make email mandatory

* Move DTOs out of platform types

* Use plainToInstance for data filtering

* POST create user and org membership

* Add email check

* Filter update user call

* Add getOrg decorator to endpoints

* Add update endpoint

* Add delete endpoint

* Fix merge changes to create new org user

* Init tests

* Send org signup email

* Abstract username check

* Implement email service

* WIP E2E test

* Rename methods

* Update update org user DTO

* Remove unused inputs

* chore: add ApiProperty decorator to users class validators

* Type fix

* Update tests

* chore: code review comments

* chore: code review comments

* chore: code review comments

---------

Co-authored-by: Morgan Vernay <morgan@cal.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2024-07-16 10:50:46 +00:00
MorganandLauris Skraucis 9fec77e345 fix: creating organization team should add owner (#15779)
* chore: platform libraries versions cleanup api v2

* fix: create membership to created org team apiv2

* chore: add test check owner membership

---------

Co-authored-by: Lauris Skraucis <lauris.skraucis@gmail.com>
2024-07-16 10:31:29 +00:00
7046f6f80a feat: Fix Proton Calendar with ICS (#15454)
* feat: fix proton calendar with ics

* ics-feed: add note & clarify checkbox

* fix review

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>
2024-07-16 12:08:54 +02:00
Hariom Balhara 3f4a0eff7f Enable embed namespacing again (#15783) 2024-07-16 12:07:37 +02:00
Peer Richelsen d2768c4dc5 chore: Update /ee README.md (#15765)
* Update README.md

* Update README.md
2024-07-16 09:52:12 +00:00
470e0f53d8 feat: allow passing initial data props to calendar connectors (#15760)
* feat: allow passing init data to AvailabilitySettings, OAuthConnect wrappers

* chore: gitignore idea

* chore: minor fixes

* chore: use yarn.lock from main branch

---------

Co-authored-by: supalarry <laurisskraucis@gmail.com>
Co-authored-by: Lauris Skraucis <lauris.skraucis@gmail.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2024-07-16 09:47:50 +00:00
Lauris Skraucis 003af9a69d chore: specify platform-libraries version for event-types (#15785) 2024-07-16 09:47:37 +00:00
VijayandAmit Sharma 283ca9fc74 fix: #15749 Display availability page as per weekstart setting (#15753)
* update to display availability page as per weekstart setting

* update to accomodate any day

* update for AvailabilityTab

* reusable and tests added

* update test description

---------

Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>
2024-07-16 11:31:02 +02:00
Pranav RajveerandAmit Sharma 13ec930a61 fix: filter admin_api if org is present (#15386)
* fix: filter admin_api if org is present

* remove admin_api from developers tab when org is present

---------

Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>
2024-07-16 11:30:39 +02:00
Lauris Skraucis cada4a58ac chore: add platform-libraries change log (#15786) 2024-07-16 10:53:26 +02:00
Lauris Skraucis 77519a077c refactor: allow system admins to create event-type without membership (#15774) 2024-07-16 06:08:28 +00:00
Nick Holden 1a5a539106 fix: higher resolution Campsite icon (#15777) 2024-07-16 04:21:34 +00:00
Morgan 539a2ea301 chore: platform libraries versions cleanup api v2 (#15768)
* chore: platform libraries versions cleanup api v2

* fixup! chore: platform libraries versions cleanup api v2
2024-07-15 17:03:15 +00:00
Alex van Andel c8b577fb7a chore: Release v4.3.0 (#15771)
release.yml / release (push) Canceled after 0s
2024-07-15 13:55:09 +00:00
Peer Richelsen 7f97d27c20 chore: Update README.md (#15766)
* Update README.md

* Update README.md

* Update README.md
2024-07-15 13:40:28 +02:00
Morgan 9ff10877e7 chore: fix apiv2 org teams doc (#15746) 2024-07-15 10:27:33 +00:00
Morgan eb0768917c chore: readme platform libraries (#15737)
* chore: readme platform libraries

* fixup! chore: readme platform libraries

* fixup! fixup! chore: readme platform libraries

* Update README.md

* Update README.md
2024-07-15 10:13:41 +00:00
ec65cd6333 fix: icon visibility and add up and down icons (#15543)
* fixed the icons visibility and added up and down icon

* wip: TimeUnitAddonSuffix

* chore: Quick pass for rerender-optimisation

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-07-15 09:35:48 +00:00
Udit Takkar 563141b295 chore: add check for booking (#15740)
* chore: add check

* chore: missting no shoe
2024-07-15 08:59:29 +00:00
Hariom Balhara 7b24972900 fix: Ensure unique username in an organization (#15730) 2024-07-15 06:11:24 +00:00
Somay Chauhan 6d2ec71df3 add missing getServerSideProps to /event-types (#15731) 2024-07-13 09:51:00 +00:00
492304fe1d feat: BookerEmbed atom (#15689)
* init: booker embed atom

* update booker platform wrapper

* export booker embed for atoms

* update booker embed wrapper

* fixup: refresh url not needed

* rename booker embed

* add global styles

* export not needed

* add booker embed atoms in examples app

* fixup

* fixup! fixup

* fixup! fixup! fixup

---------

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-07-12 11:10:32 +00:00
c7c5cdf133 feat: Campsite conferencing app (#15713)
* feat: Campsite conferencing app

* Improve redirect after OAuth flow

---------

Co-authored-by: Somay Chauhan <somaychauhan98@gmail.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2024-07-11 14:20:31 +00:00
Keith Williams 0dd3b25624 chore: v4.2.5 (#15735)
release.yml / release (push) Canceled after 0s
2024-07-11 15:03:37 +02:00
Morgan a633e35c66 fix: api v2 unit tests (#15733)
* fix: permissions guard spec

* fix: api auth strategy spec
2024-07-11 11:27:40 +00:00
sean-brydon c618e69d8e fix: event type back button (#15722) 2024-07-11 10:44:37 +00:00
Morgan 844dd7e3af fix: remove console.log and tsignore apiv2 (#15732) 2024-07-11 10:40:11 +00:00
Morgan b6c512e5d7 fix: api-v2 controllers e2e tests (#15724)
* fix: bookings controllers e2e tests

* fixup! fix: bookings controllers e2e tests

* fix: calendars  controllers e2e tests

* chore: move org teams controller files into folder

* fix: gcal controller e2e tests

* fix: oauth-clients.controller.e2e-spec

* fix: calendars  controllers e2e tests

* fix: gcal controller e2e tests

* fix: oauth-flow.controller.e2e-spec.ts
2024-07-11 13:14:45 +03:00
Ankit SinghandKeith Williams 6e0035689a fix: Text in Embed Code Visibility Fixed (#15711)
* Fix:#15707

* bg-subtle fixed

* Update packages/features/embed/lib/EmbedTabs.tsx

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-07-10 16:18:31 +00:00
58540af9ce chore: sort calendar crendentials in event manager (#15448)
* chore_sort_calendar_credentials_in_desc_for_reliability

* chore: create reusable function

* refactor: rename functions

* Update EventManager.ts

Variable name change

---------

Co-authored-by: Shaik-Sirajuddin <sirajudddinshaik30@gmail.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Udit Takkar <udit222001@gmail.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2024-07-10 16:16:26 +00:00
Morgan 21f30fc47a chore: cache org guard and fix roles guard apiv2 (#15719) 2024-07-10 15:46:04 +00:00
Amit Sharma 92845d3adc Revert "fix: Autodetection of time zone only updated default time zone (#15392)" (#15720)
This reverts commit 285d1ec4c9.
2024-07-10 15:28:41 +00:00
8ee27d3997 feat: add POST end point to mark calls as no-shows (#15690)
* feat: add POST end point to mark calls as no-shows

* fix: type

* feat: add v2 endpoint

* wip

* chore: type err

* chore: add permission

* chore: remove packae

* chore: remove v1 API endpoint

* fix: endpoint

* feat: add new version

* chore: change route

* chore: error msg

* fix: message

* fix: type err

* chore: output

* fix: type err

* chore: bump libraries to 0.0.17

* chore: add noShowHost

---------

Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: Morgan Vernay <morgan@cal.com>
2024-07-10 13:01:10 +00:00
Morgan 1263519973 chore: add i18n to atoms (#15698)
* chore: add i18n to atoms

* fixup! chore: add i18n to atoms

* fixup! fixup! chore: add i18n to atoms

* fixup! Merge branch 'platform-atoms-i18n' of github.com:calcom/cal.com into platform-atoms-i18n

* fixup! Merge branch 'main' into platform-atoms-i18n
2024-07-10 12:34:41 +00:00
caf7943c91 fix: set conferencing apps as default (#15376)
* fix: set conferencing apps as default

* fix in other oauth conferencing apps

* creating default event-types should set the user's default location

* Update getDefaultLocations.ts

* fix: unit test

* default install only for first oauth app

* fix: type-check

* update default on success

* fix: pass callback prop

* chore: code cleanup

* Fix condition

* no longer needed

- as we reverted https://github.com/calcom/cal.com/commit/20fdb50081ae684723a5af78436068c0b09509a4

---------

Co-authored-by: Somay Chauhan <somaychauhan98@gmail.com>
Co-authored-by: Hariom <hariombalhara@gmail.com>
2024-07-10 11:00:59 +00:00
Somay ChauhanandHariom Balhara fed3987028 fix: disable google-meet in user onboarding if google-calendar is not installed (#15702)
* fix: disable google-meet in user onboarding if google-calendar is not installed

* add tooltip

* fix: type check

* fix: type check for tooltip

- allowed `ReactNode` for tooltip content prop

---------

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2024-07-10 10:34:14 +00:00
Hariom Balhara ad61d48706 fix: Rescheduling email when there is broken calendar integration (#15669) 2024-07-10 10:32:56 +01:00
533 changed files with 22342 additions and 3640 deletions
+7 -3
View File
@@ -15,10 +15,14 @@
# - You can not repackage or sell the codebase
# - Acquire a commercial license to remove these terms by visiting: cal.com/sales
#
# To enable enterprise-only features, as an admin, go to /auth/setup to select your license and follow
# instructions. This environment variable is deprecated although still supported for backward compatibility.
# @see https://console.cal.com
# To enable enterprise-only features please add your envrionment variable to the .env file then make your way to /auth/setup to select your license and follow instructions.
CALCOM_LICENSE_KEY=
# Signature token for the Cal.com License API (used for self-hosted integrations)
# We will give you a token when we provide you with a license key this ensure you and only you can communicate with the Cal.com License API for your license key
CAL_SIGNATURE_TOKEN=
# The route to the Cal.com License API
CALCOM_PRIVATE_API_ROUTE="https://goblin.cal.com"
# ***********************************************************************************************************
# - DATABASE ************************************************************************************************
-39
View File
@@ -1,39 +0,0 @@
---
name: Bug report
about: Report any issues with the platform
title: ""
labels: bug
assignees: ""
---
Found a bug? Please fill out the sections below. 👍
### Issue Summary
A summary of the issue. This needs to be a clear detailed-rich summary.
### Steps to Reproduce
1. (for example) Went to ...
2. Clicked on...
3. ...
Any other relevant information. For example, why do you consider this a bug and what did you expect to happen instead?
### Actual Results
- What's happening right now that is different from what is expected
### Expected Results
- This is an ideal result that the system should get after the tests are performed
### Technical details
- Browser version, screen recording, console logs, network requests: You can make a recording with [Bird Eats Bug](https://birdeatsbug.com/).
- Node.js version
- Anything else that you think could be an issue.
### Evidence
- How was this tested? This is quite mandatory in terms of bugs. Providing evidence of your testing with screenshots or/and videos is an amazing way to prove the bug and a troubleshooting chance to find the solution.
+81
View File
@@ -0,0 +1,81 @@
name: Bug report
description: "Please make sure you have checked for any duplicate issues."
title: "(bug)"
labels: ["🐛 bug"]
assignees: []
body:
- type: textarea
id: issue-summary
attributes:
label: Issue Summary
description: A summary of the issue. This needs to be a clear detailed-rich summary.
validations:
required: true
- type: textarea
id: steps-to-reproduce
attributes:
label: Steps to Reproduce
value: |
1. (for example) Went to ...
2. Clicked on...
3. ...
validations:
required: true
- type: textarea
id: expected-behavior
attributes:
label: Expected behavior
description: A clear and concise description of what you expected to happen.
validations:
required: true
- type: textarea
id: other-information
attributes:
label: Other information
validations:
required: false
- type: textarea
id: screenshots
attributes:
label: Screenshots
description: If applicable, add screenshots to help explain your problem.
validations:
required: false
- type: checkboxes
id: environment
attributes:
label: Environment
options:
- label: app.cal.com
- label: Self-hosted Cal
- type: textarea
id: desktop-version
attributes:
label: Desktop (please complete the following information)
description: |
examples:
- **OS**: [e.g. iOS]
- **Browser**: [e.g. chrome, safari]
- **Version**: [e.g. 22]
value: |
- OS:
- Node:
- npm:
render: markdown
validations:
required: true
- type: markdown
id: nodejs-version
attributes:
value: |
#### Node.JS version
[e.g. v18.15.0]
- type: markdown
id: anything-else
attributes:
value: |
#### Anything else?
- Screen recording, console logs, network requests: You can make a recording with [Loom](https://www.loom.com).
- Anything else that you think could be an issue?
+2 -2
View File
@@ -1,5 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: Questions
url: https://go.cal.com/discord
about: Ask a general question about the project on our discord community
url: https://github.com/calcom/cal.com/discussions
about: Need help selfhosting or ask a general question about the project? Open a discussion
+2 -1
View File
@@ -2,7 +2,7 @@
name: Feature request
about: Suggest a feature or idea
title: ""
labels: feature
labels: ["✨ feature", "🚨 needs approval"]
assignees: ""
---
@@ -53,3 +53,4 @@ assignees: ""
- If this issue has a `🚨 needs approval` label, don't start coding yet. Wait until a core member approves feature request by removing this label, then you can start coding.
- For clarity: Non-core member issues automatically get the `🚨 needs approval` label.
- Your feature ideas are invaluable to us! However, they undergo review to ensure alignment with the product's direction.
- Follow Best Practices lined out in our [Contributor Docs](https://github.com/calcom/cal.com/blob/main/CONTRIBUTING.md)
-6
View File
@@ -69,12 +69,6 @@ jobs:
uses: ./.github/workflows/e2e-embed-react.yml
secrets: inherit
analyze:
name: Analyze Build
needs: [build]
uses: ./.github/workflows/nextjs-bundle-analysis.yml
secrets: inherit
required:
needs: [lint, type-check, unit-test, integration-test, build, build-api-v1, build-api-v2, e2e, e2e-embed, e2e-embed-react, e2e-app-store]
if: always()
@@ -3,6 +3,9 @@ name: Production Builds
on:
workflow_call:
permissions:
contents: read
env:
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
@@ -10,6 +10,8 @@ env:
jobs:
build:
name: Build API v2
permissions:
contents: read
runs-on: buildjet-4vcpu-ubuntu-2204
timeout-minutes: 30
services:
+2
View File
@@ -3,6 +3,8 @@ on:
workflow_call:
env:
NODE_OPTIONS: --max-old-space-size=4096
permissions:
contents: read
jobs:
check-types:
runs-on: buildjet-4vcpu-ubuntu-2204
+79
View File
@@ -0,0 +1,79 @@
name: E2E
on:
workflow_call:
env:
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
API_KEY_PREFIX: ${{ secrets.CI_API_KEY_PREFIX }}
API_PORT: ${{ vars.CI_API_V2_PORT }}
CALCOM_LICENSE_KEY: ${{ secrets.CI_CALCOM_LICENSE_KEY }}
DAILY_API_KEY: ${{ secrets.CI_DAILY_API_KEY }}
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
DATABASE_READ_URL: ${{ secrets.CI_DATABASE_URL }}
DATABASE_WRITE_URL: ${{ secrets.CI_DATABASE_URL }}
DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }}
GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
IS_E2E: true
NEXTAUTH_SECRET: ${{ secrets.CI_NEXTAUTH_SECRET }}
NEXTAUTH_URL: ${{ secrets.CI_NEXTAUTH_URL }}
NODE_OPTIONS: --max-old-space-size=4096
REDIS_URL: "redis://localhost:6379"
STRIPE_PRIVATE_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
STRIPE_API_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }}
STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }}
jobs:
e2e:
timeout-minutes: 20
name: E2E API v2
runs-on: buildjet-8vcpu-ubuntu-2204
services:
postgres:
image: postgres:13
credentials:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: calendso
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
redis:
image: redis:latest
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
strategy:
fail-fast: false
steps:
- uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- uses: actions/checkout@v4
- uses: ./.github/actions/dangerous-git-checkout
- uses: ./.github/actions/yarn-install
- uses: ./.github/actions/yarn-playwright-install
- uses: ./.github/actions/cache-db
- name: Run Tests
working-directory: apps/api/v2
run: |
yarn test:e2e
EXIT_CODE=$?
echo "yarn test:e2e command exit code: $EXIT_CODE"
exit $EXIT_CODE
- name: Upload Test Results
if: ${{ always() }}
uses: actions/upload-artifact@v4
with:
name: test-results-api-v2
path: test-results
+3
View File
@@ -1,6 +1,9 @@
name: E2E App Store Tests
on:
workflow_call:
permissions:
actions: write
contents: read
env:
NODE_OPTIONS: --max-old-space-size=4096
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
+3
View File
@@ -1,6 +1,9 @@
name: E2E Embed React tests and booking flow (for non-embed as well)
on:
workflow_call:
permissions:
actions: write
contents: read
env:
NODE_OPTIONS: --max-old-space-size=4096
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
+3
View File
@@ -1,6 +1,9 @@
name: E2E Embed Core tests and booking flow (for non-embed as well)
on:
workflow_call:
permissions:
actions: write
contents: read
env:
NODE_OPTIONS: --max-old-space-size=4096
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
+3
View File
@@ -1,6 +1,9 @@
name: E2E
on:
workflow_call:
permissions:
actions: write
contents: read
env:
NODE_OPTIONS: --max-old-space-size=4096
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
+2
View File
@@ -1,6 +1,8 @@
name: Integration
on:
workflow_call:
permissions:
contents: read
env:
NODE_OPTIONS: --max-old-space-size=4096
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
+3 -7
View File
@@ -1,6 +1,9 @@
name: Lint
on:
workflow_call:
permissions:
actions: write
contents: read
jobs:
lint:
runs-on: buildjet-4vcpu-ubuntu-2204
@@ -16,13 +19,6 @@ jobs:
- name: Merge lint reports
run: jq -s '[.[]]|flatten' lint-results/*.json &> lint-results/eslint_report.json
- name: Annotate Code Linting Results
uses: ataylorme/eslint-annotate-action@v2
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
report-json: "lint-results/eslint_report.json"
only-pr-files: false
- name: Upload ESLint report
if: ${{ always() }}
uses: actions/upload-artifact@v4
@@ -0,0 +1,81 @@
name: "Next.js Bundle Analysis Annotation"
on:
workflow_call:
workflow_dispatch:
permissions:
actions: read
contents: read
pull-requests: write
jobs:
annotate:
if: always()
runs-on: buildjet-2vcpu-ubuntu-2204
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/yarn-install
- uses: ./.github/actions/cache-build
- name: Download base branch bundle stats
uses: dawidd6/action-download-artifact@v2
if: success()
with:
workflow: nextjs-bundle-analysis.yml
branch: ${{ github.event.pull_request.base.ref }}
path: apps/web/.next/analyze/base
# And here's the second place - this runs after we have both the current and
# base branch bundle stats, and will compare them to determine what changed.
# There are two configurable arguments that come from package.json:
#
# - budget: optional, set a budget (bytes) against which size changes are measured
# it's set to 350kb here by default, as informed by the following piece:
# https://infrequently.org/2021/03/the-performance-inequality-gap/
#
# - red-status-percentage: sets the percent size increase where you get a red
# status indicator, defaults to 20%
#
# Either of these arguments can be changed or removed by editing the `nextBundleAnalysis`
# entry in your package.json file.
- name: Compare with base branch bundle
if: success()
run: |
cd apps/web
ls -laR .next/analyze/base && npx -p nextjs-bundle-analysis compare
- name: Get comment body
id: get-comment-body
if: success() && github.event.number
run: |
cd apps/web
body=$(cat .next/analyze/__bundle_analysis_comment.txt)
body="${body//'%'/'%25'}"
body="${body//$'\n'/'%0A'}"
body="${body//$'\r'/'%0D'}"
echo ::set-output name=body::$body
- name: Find Comment
uses: peter-evans/find-comment@v2
if: success() && github.event.number
id: fc
with:
issue-number: ${{ github.event.number }}
body-includes: "<!-- __NEXTJS_BUNDLE_@calcom/web -->"
- name: Create Comment
uses: peter-evans/create-or-update-comment@v3
if: success() && github.event.number && steps.fc.outputs.comment-id == 0
with:
issue-number: ${{ github.event.number }}
body: ${{ steps.get-comment-body.outputs.body }}
- name: Update Comment
uses: peter-evans/create-or-update-comment@v3
if: success() && github.event.number && steps.fc.outputs.comment-id != 0
with:
issue-number: ${{ github.event.number }}
body: ${{ steps.get-comment-body.outputs.body }}
comment-id: ${{ steps.fc.outputs.comment-id }}
edit-mode: replace
+4 -62
View File
@@ -7,6 +7,10 @@ on:
branches:
- main
permissions:
actions: write
contents: read
env:
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
@@ -63,65 +67,3 @@ jobs:
with:
name: bundle
path: apps/web/.next/analyze/__bundle_analysis.json
- name: Download base branch bundle stats
uses: dawidd6/action-download-artifact@v2
if: success()
with:
workflow: nextjs-bundle-analysis.yml
branch: ${{ github.event.pull_request.base.ref }}
path: apps/web/.next/analyze/base
# And here's the second place - this runs after we have both the current and
# base branch bundle stats, and will compare them to determine what changed.
# There are two configurable arguments that come from package.json:
#
# - budget: optional, set a budget (bytes) against which size changes are measured
# it's set to 350kb here by default, as informed by the following piece:
# https://infrequently.org/2021/03/the-performance-inequality-gap/
#
# - red-status-percentage: sets the percent size increase where you get a red
# status indicator, defaults to 20%
#
# Either of these arguments can be changed or removed by editing the `nextBundleAnalysis`
# entry in your package.json file.
- name: Compare with base branch bundle
if: success()
run: |
cd apps/web
ls -laR .next/analyze/base && npx -p nextjs-bundle-analysis compare
- name: Get comment body
id: get-comment-body
if: success() && github.event.number
run: |
cd apps/web
body=$(cat .next/analyze/__bundle_analysis_comment.txt)
body="${body//'%'/'%25'}"
body="${body//$'\n'/'%0A'}"
body="${body//$'\r'/'%0D'}"
echo ::set-output name=body::$body
- name: Find Comment
uses: peter-evans/find-comment@v2
if: success() && github.event.number
id: fc
with:
issue-number: ${{ github.event.number }}
body-includes: "<!-- __NEXTJS_BUNDLE_@calcom/web -->"
- name: Create Comment
uses: peter-evans/create-or-update-comment@v3
if: success() && github.event.number && steps.fc.outputs.comment-id == 0
with:
issue-number: ${{ github.event.number }}
body: ${{ steps.get-comment-body.outputs.body }}
- name: Update Comment
uses: peter-evans/create-or-update-comment@v3
if: success() && github.event.number && steps.fc.outputs.comment-id != 0
with:
issue-number: ${{ github.event.number }}
body: ${{ steps.get-comment-body.outputs.body }}
comment-id: ${{ steps.fc.outputs.comment-id }}
edit-mode: replace
+14 -1
View File
@@ -8,6 +8,10 @@ on:
- gh-actions-test-branch
workflow_dispatch:
permissions:
actions: write
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
@@ -39,6 +43,8 @@ jobs:
needs: [changes]
runs-on: buildjet-2vcpu-ubuntu-2204
name: Check for E2E label
permissions:
pull-requests: read
outputs:
run-e2e: ${{ steps.check-if-pr-has-label.outputs.run-e2e == 'true' && (github.event.action != 'labeled' || (github.event.action == 'labeled' && github.event.label.name == 'ready-for-e2e')) }}
steps:
@@ -141,6 +147,13 @@ jobs:
uses: ./.github/workflows/e2e.yml
secrets: inherit
e2e-api-v2:
name: Tests
needs: [changes, check-label, build, build-api-v1, build-api-v2]
if: ${{ needs.check-label.outputs.run-e2e == 'true' && needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
uses: ./.github/workflows/e2e-api-v2.yml
secrets: inherit
e2e-app-store:
name: Tests
needs: [changes, check-label, build, build-api-v1, build-api-v2]
@@ -169,7 +182,7 @@ jobs:
secrets: inherit
required:
needs: [changes, lint, type-check, unit-test, integration-test, check-label, build, build-api-v1, build-api-v2, e2e, e2e-embed, e2e-embed-react, e2e-app-store]
needs: [changes, lint, type-check, unit-test, integration-test, check-label, build, build-api-v1, build-api-v2, e2e, e2e-api-v2, e2e-embed, e2e-embed-react, e2e-app-store]
if: always()
runs-on: buildjet-2vcpu-ubuntu-2204
steps:
-77
View File
@@ -1,77 +0,0 @@
name: Pre-release checks
on:
workflow_dispatch:
jobs:
changes:
name: Detect changes
runs-on: buildjet-4vcpu-ubuntu-2204
permissions:
pull-requests: read
outputs:
app-store: ${{ steps.filter.outputs.app-store }}
embed: ${{ steps.filter.outputs.embed }}
embed-react: ${{ steps.filter.outputs.embed-react }}
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/dangerous-git-checkout
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
app-store:
- 'apps/web/**'
- 'packages/app-store/**'
- 'playwright.config.ts'
embed:
- 'apps/web/**'
- 'packages/embeds/**'
- 'playwright.config.ts'
embed-react:
- 'apps/web/**'
- 'packages/embeds/**'
- 'playwright.config.ts'
lint:
name: Linters
uses: ./.github/workflows/lint.yml
secrets: inherit
build:
name: Production builds
uses: ./.github/workflows/production-build-without-database.yml
secrets: inherit
e2e:
name: E2E tests
needs: [changes, lint, build]
uses: ./.github/workflows/e2e.yml
secrets: inherit
e2e-app-store:
name: E2E App Store tests
needs: [changes, lint, build]
uses: ./.github/workflows/e2e-app-store.yml
secrets: inherit
e2e-embed:
name: E2E embeds tests
needs: [changes, lint, build]
uses: ./.github/workflows/e2e-embed.yml
secrets: inherit
e2e-embed-react:
name: E2E React embeds tests
needs: [changes, lint, build]
uses: ./.github/workflows/e2e-embed-react.yml
secrets: inherit
required:
needs: [changes, lint, build, e2e, e2e-app-store, e2e-embed, e2e-embed-react]
if: always()
runs-on: buildjet-2vcpu-ubuntu-2204
steps:
- name: fail if conditional jobs failed
if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'skipped') || contains(needs.*.result, 'cancelled')
run: exit 1
@@ -2,6 +2,9 @@ name: Production Builds
on:
workflow_call:
permissions:
contents: read
env:
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
+7 -4
View File
@@ -1,9 +1,8 @@
name: Unit
on:
workflow_call:
workflow_run:
workflows: [Crowdin Action]
types: [completed]
permissions:
contents: read
jobs:
test:
name: Unit
@@ -13,7 +12,11 @@ jobs:
- uses: actions/checkout@v4
- uses: ./.github/actions/dangerous-git-checkout
- uses: ./.github/actions/yarn-install
# Should be an 8GB machine as per https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners
- run: yarn test
# We could add different timezones here that we need to run our tests in
- run: TZ=America/Los_Angeles yarn test -- --timeZoneDependentTestsOnly
- name: Run API v2 tests
working-directory: apps/api/v2
run: |
export NODE_OPTIONS="--max_old_space_size=8192"
yarn test
+3
View File
@@ -3,6 +3,9 @@ name: Yarn install
on:
workflow_call:
permissions:
contents: read
jobs:
setup:
name: Yarn install & cache
+1 -1
View File
@@ -74,7 +74,7 @@ branch are tagged into a release monthly.
To develop locally:
1. [Fork](https://help.github.com/articles/fork-a-repo/) this repository to your
1. [Fork](https://github.com/calcom/cal.com/fork/) this repository to your
own GitHub account and then
[clone](https://help.github.com/articles/cloning-a-repository/) it to your local device.
2. Create a new branch:
+31 -1
View File
@@ -392,7 +392,37 @@ Currently Vercel Pro Plan is required to be able to Deploy this application with
See the [roadmap project](https://cal.com/roadmap) for a list of proposed features (and known issues). You can change the view to see planned tagged releases.
<!-- RORADMAP -->
<!-- LICENSE -->
## License
Cal.com, Inc. is a commercial open source company, which means some parts of this open source repository require a commercial license. The concept is called "Open Core" where the core technology (99%) is fully open source, licensed under [AGPLv3](https://opensource.org/license/agpl-v3) and the last 1% is covered under a commercial license (["/ee" Enterprise Edition](https://github.com/calcom/cal.com/tree/main/packages/features/ee)) which we believe is entirely relevant for larger organisations that require enterprise features. Enterprise features are built by the core engineering team of Cal.com, Inc. which is hired in full-time. Find their compensation on https://cal.com/open.
> [!NOTE]
> Our philosophy is simple, all "Singleplayer APIs" are open-source under AGPLv3. All commercial "Multiplayer APIs" are under a commercial license.
| | AGPLv3 | EE |
|---|---|---|
| Self-host for commercial purposes | ✅ | ✅ |
| Clone privately | ✅ | ✅ |
| Fork publicly | ✅ | ✅ |
| Requires CLA | ✅ | ✅ |
| Official Support| ❌ | ✅ |
| Derivative work privately | ❌ | ✅ |
| SSO | ❌ | ✅ |
| Admin Panel | ❌ | ✅ |
| Impersonation | ❌ | ✅ |
| Managed Event Types | ❌ | ✅ |
| Organizations | ❌ | ✅ |
| Payments | ❌ | ✅ |
| Platform | ❌ | ✅ |
| Teams | ❌ | ✅ |
| Users | ❌ | ✅ |
| Video | ❌ | ✅ |
| Workflows | ❌ | ✅ |
> [!TIP]
> We work closely with the community and always invite feedback about what should be open and what is fine to be commercial. This list is not set and stone and we have moved things from commercial to open in the past. Please open a [discussion](https://github.com/calcom/cal.com/discussions) if you feel like something is wrong.
## Repo Activity
+6 -3
View File
@@ -1,7 +1,7 @@
import type { NextMiddleware } from "next-api-middleware";
import LicenseKeyService from "@calcom/ee/common/server/LicenseKeyService";
import { hashAPIKey } from "@calcom/features/ee/api-keys/lib/apiKeys";
import checkLicense from "@calcom/features/ee/common/server/checkLicense";
import { IS_PRODUCTION } from "@calcom/lib/constants";
import prisma from "@calcom/prisma";
@@ -18,9 +18,12 @@ export const dateNotInPast = function (date: Date) {
// This verifies the apiKey and sets the user if it is valid.
export const verifyApiKey: NextMiddleware = async (req, res, next) => {
const hasValidLicense = await checkLicense(prisma);
if (!hasValidLicense && IS_PRODUCTION)
const licenseKeyService = await LicenseKeyService.create();
const hasValidLicense = await licenseKeyService.checkLicense();
if (!hasValidLicense && IS_PRODUCTION) {
return res.status(401).json({ error: "Invalid or missing CALCOM_LICENSE_KEY environment variable" });
}
// Check if the apiKey query param is provided.
if (!req.query.apiKey) return res.status(401).json({ message: "No apiKey provided" });
// remove the prefix from the user provided api_key. If no env set default to "cal_"
+4
View File
@@ -47,6 +47,10 @@ import { getAccessibleUsers } from "~/lib/utils/retrieveScopedAccessibleUsers";
* type: string
* format: date-time
* description: 'End time of the Event'
* rescheduleUid:
* type: string
* format: UID
* description: 'Uid of the booking to reschedule'
* responses:
* type: object
* required:
-1
View File
@@ -43,7 +43,6 @@ async function postHandler(req: NextApiRequest, res: NextApiResponse) {
language: data.language,
teamId: data.teamId,
usernameOrEmail: data.usernameOrEmail,
isOrg: data.isOrg,
});
return { success: true, message: `${data.usernameOrEmail} has been invited.` };
+13 -4
View File
@@ -69,6 +69,9 @@ import { schemaTeamCreateBodyParams, schemaTeamReadPublic } from "~/lib/validati
* ownerId:
* type: number
* description: ID of the team owner - only admins can set this.
* parentId:
* type: number
* description: ID of the parent organization.
* tags:
* - teams
* responses:
@@ -99,7 +102,7 @@ async function postHandler(req: NextApiRequest) {
if (alreadyExist) throw new HttpError({ statusCode: 409, message: "Team slug already exists" });
}
// Check if parentId is related to this user
// Check if parentId is related to this user and is an organization
if (data.parentId) {
const parentTeam = await prisma.team.findFirst({
where: { id: data.parentId, members: { some: { userId, role: { in: ["OWNER", "ADMIN"] } } } },
@@ -107,7 +110,13 @@ async function postHandler(req: NextApiRequest) {
if (!parentTeam)
throw new HttpError({
statusCode: 401,
message: "Unauthorized: Invalid parent id. You can only use parent id of your own teams.",
message: "Unauthorized: Invalid parent id. You can only use parent id if you are org owner or admin.",
});
if (parentTeam.parentId)
throw new HttpError({
statusCode: 400,
message: "parentId must be of an organization, not a team.",
});
}
@@ -119,7 +128,7 @@ async function postHandler(req: NextApiRequest) {
metadata: data.metadata === null ? {} : data.metadata || undefined,
};
if (!IS_TEAM_BILLING_ENABLED) {
if (!IS_TEAM_BILLING_ENABLED || data.parentId) {
const team = await prisma.team.create({
data: {
...cloneData,
@@ -135,7 +144,7 @@ async function postHandler(req: NextApiRequest) {
return {
team: schemaTeamReadPublic.parse(team),
owner: schemaMembershipPublic.parse(team.members[0]),
message: `Team created successfully. We also made user with ID=${ownerId} the owner of this team.`,
message: `Team created successfully. We also made user with ID=${effectiveUserId} the owner of this team.`,
};
}
@@ -0,0 +1,112 @@
import prismaMock from "../../../../../../tests/libs/__mocks__/prismaMock";
import type { Request, Response } from "express";
import type { NextApiRequest, NextApiResponse } from "next";
import { createMocks } from "node-mocks-http";
import { describe, expect, test } from "vitest";
import handler from "../../../pages/api/attendees/_post";
type CustomNextApiRequest = NextApiRequest & Request;
type CustomNextApiResponse = NextApiResponse & Response;
describe("POST /api/attendees", () => {
describe("Errors", () => {
test("Returns 403 if user is not admin and has no booking", async () => {
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "POST",
body: {
bookingId: 1,
email: "test@example.com",
name: "Test User",
timeZone: "UTC",
},
});
prismaMock.booking.findFirst.mockResolvedValue(null);
req.userId = 123;
// req.isAdmin = false;
await handler(req, res);
expect(res.statusCode).toBe(403);
expect(JSON.parse(res._getData()).message).toBe("Forbidden");
});
test("Returns 200 if user is admin", async () => {
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "POST",
body: {
bookingId: 1,
email: "test@example.com",
name: "Test User",
timeZone: "UTC",
},
});
const attendeeData = {
id: 1,
email: "test@example.com",
name: "Test User",
timeZone: "UTC",
bookingId: 1,
};
prismaMock.attendee.create.mockResolvedValue(attendeeData);
req.isSystemWideAdmin = true;
req.userId = 123;
await handler(req, res);
expect(res.statusCode).toBe(200);
expect(JSON.parse(res._getData()).attendee).toEqual(attendeeData);
expect(JSON.parse(res._getData()).message).toBe("Attendee created successfully");
});
test("Returns 200 if user is not admin but has a booking", async () => {
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "POST",
body: {
bookingId: 1,
email: "test@example.com",
name: "Test User",
timeZone: "UTC",
},
});
const userBooking = { id: 1 };
prismaMock.booking.findFirst.mockResolvedValue(userBooking);
const attendeeData = {
id: 1,
email: "test@example.com",
name: "Test User",
timeZone: "UTC",
bookingId: 1,
};
prismaMock.attendee.create.mockResolvedValue(attendeeData);
req.userId = 123;
await handler(req, res);
expect(res.statusCode).toBe(200);
expect(JSON.parse(res._getData()).attendee).toEqual(attendeeData);
expect(JSON.parse(res._getData()).message).toBe("Attendee created successfully");
});
test("Returns 400 if request body is invalid", async () => {
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "POST",
body: {
// Missing required fields
},
});
req.userId = 123;
await handler(req, res);
expect(res.statusCode).toBe(400);
});
});
});
+37 -1
View File
@@ -11,6 +11,7 @@ import sendPayload from "@calcom/features/webhooks/lib/sendOrSchedulePayload";
import { ErrorCode } from "@calcom/lib/errorCodes";
import { buildBooking, buildEventType, buildWebhook } from "@calcom/lib/test/builder";
import prisma from "@calcom/prisma";
import type { Booking } from "@calcom/prisma";
import handler from "../../../pages/api/bookings/_post";
@@ -157,6 +158,7 @@ describe.skipIf(true)("POST /api/bookings", () => {
describe("Success", () => {
describe("Regular event-type", () => {
let createdBooking: Booking;
test("Creates one single booking", async () => {
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "POST",
@@ -181,9 +183,43 @@ describe.skipIf(true)("POST /api/bookings", () => {
await handler(req, res);
console.log({ statusCode: res._getStatusCode(), data: JSON.parse(res._getData()) });
createdBooking = JSON.parse(res._getData());
expect(prismaMock.booking.create).toHaveBeenCalledTimes(1);
});
test("Reschedule created booking", async () => {
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "POST",
body: {
name: "testReschedule",
start: dayjs().add(2, "day").format(),
end: dayjs().add(3, "day").format(),
eventTypeId: 2,
email: "test@example.com",
location: "Cal.com Video",
timeZone: "America/Montevideo",
language: "en",
customInputs: [],
metadata: {},
userId: 4,
rescheduleUid: createdBooking.uid,
},
prisma,
});
prismaMock.eventType.findUniqueOrThrow.mockResolvedValue(buildEventType());
prismaMock.booking.findMany.mockResolvedValue([]);
await handler(req, res);
console.log({ statusCode: res._getStatusCode(), data: JSON.parse(res._getData()) });
const rescheduledBooking = JSON.parse(res._getData()) as Booking;
expect(prismaMock.booking.create).toHaveBeenCalledTimes(1);
expect(rescheduledBooking.fromReschedule).toEqual(createdBooking.uid);
const previousBooking = await prisma.booking.findUnique({
where: { uid: createdBooking.uid },
});
expect(previousBooking?.status).toBe("cancelled");
});
});
describe("Recurring event-type", () => {
@@ -3,9 +3,9 @@ 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, vi, it, expect, afterEach } from "vitest";
import { describe, vi, it, expect, afterEach, beforeEach } from "vitest";
import checkLicense from "@calcom/features/ee/common/server/checkLicense";
import LicenseKeyService from "@calcom/ee/common/server/LicenseKeyService";
import prisma from "@calcom/prisma";
import { isAdminGuard } from "~/lib/utils/isAdmin";
@@ -20,20 +20,21 @@ afterEach(() => {
vi.resetAllMocks();
});
vi.mock("@calcom/features/ee/common/server/checkLicense", () => {
return {
default: vi.fn(),
};
});
vi.mock("~/lib/utils/isAdmin", () => {
return {
isAdminGuard: vi.fn(),
};
});
vi.mock("@calcom/prisma");
vi.mock("~/lib/utils/isAdmin", () => ({
isAdminGuard: vi.fn(),
}));
describe("Verify API key", () => {
it("It should throw an error if the api key is not valid", async () => {
let service: LicenseKeyService;
beforeEach(async () => {
service = await LicenseKeyService.create();
vi.spyOn(service, "checkLicense");
});
it("should throw an error if the api key is not valid", async () => {
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "POST",
body: {},
@@ -43,7 +44,7 @@ describe("Verify API key", () => {
fn: verifyApiKey,
};
vi.mocked(checkLicense).mockResolvedValue(false);
vi.mocked(service.checkLicense).mockResolvedValue(false);
vi.mocked(isAdminGuard).mockResolvedValue({ isAdmin: false, scope: null });
const serverNext = vi.fn((next: void) => Promise.resolve(next));
@@ -53,9 +54,11 @@ describe("Verify API key", () => {
await middleware.fn(req, res, serverNext);
expect(middlewareSpy).toBeCalled();
expect(res.statusCode).toBe(401);
});
it("It should throw an error if no api key is provided", async () => {
it("should throw an error if no api key is provided", async () => {
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "POST",
body: {},
@@ -65,7 +68,7 @@ describe("Verify API key", () => {
fn: verifyApiKey,
};
vi.mocked(checkLicense).mockResolvedValue(true);
vi.mocked(service.checkLicense).mockResolvedValue(true);
vi.mocked(isAdminGuard).mockResolvedValue({ isAdmin: false, scope: null });
const serverNext = vi.fn((next: void) => Promise.resolve(next));
@@ -75,10 +78,11 @@ describe("Verify API key", () => {
await middleware.fn(req, res, serverNext);
expect(middlewareSpy).toBeCalled();
expect(res.statusCode).toBe(401);
});
it("It should set correct permissions for system-wide admin", async () => {
it("should set correct permissions for system-wide admin", async () => {
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "POST",
body: {},
@@ -97,7 +101,7 @@ describe("Verify API key", () => {
fn: verifyApiKey,
};
vi.mocked(checkLicense).mockResolvedValue(true);
vi.mocked(service.checkLicense).mockResolvedValue(true);
vi.mocked(isAdminGuard).mockResolvedValue({ isAdmin: true, scope: ScopeOfAdmin.SystemWide });
const serverNext = vi.fn((next: void) => Promise.resolve(next));
@@ -107,11 +111,12 @@ describe("Verify API key", () => {
await middleware.fn(req, res, serverNext);
expect(middlewareSpy).toBeCalled();
expect(req.isSystemWideAdmin).toBe(true);
expect(req.isOrganizationOwnerOrAdmin).toBe(false);
});
it("It should set correct permissions for org-level admin", async () => {
it("should set correct permissions for org-level admin", async () => {
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "POST",
body: {},
@@ -130,7 +135,7 @@ describe("Verify API key", () => {
fn: verifyApiKey,
};
vi.mocked(checkLicense).mockResolvedValue(true);
vi.mocked(service.checkLicense).mockResolvedValue(true);
vi.mocked(isAdminGuard).mockResolvedValue({ isAdmin: true, scope: ScopeOfAdmin.OrgOwnerOrAdmin });
const serverNext = vi.fn((next: void) => Promise.resolve(next));
@@ -140,6 +145,7 @@ describe("Verify API key", () => {
await middleware.fn(req, res, serverNext);
expect(middlewareSpy).toBeCalled();
expect(req.isSystemWideAdmin).toBe(false);
expect(req.isOrganizationOwnerOrAdmin).toBe(true);
});
@@ -0,0 +1,121 @@
import prismaMock from "../../../../../../tests/libs/__mocks__/prismaMock";
import type { Request, Response } from "express";
import type { NextApiRequest, NextApiResponse } from "next";
import { createMocks } from "node-mocks-http";
import { describe, expect, test } from "vitest";
import { HttpError } from "@calcom/lib/http-error";
import handler from "../../../pages/api/selected-calendars/_post";
type CustomNextApiRequest = NextApiRequest & Request;
type CustomNextApiResponse = NextApiResponse & Response;
describe("POST /api/selected-calendars", () => {
describe("Errors", () => {
test("Returns 403 if non-admin user tries to set userId in body", async () => {
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "POST",
body: {
integration: "google",
externalId: "ext123",
userId: 444444,
},
});
req.userId = 333333;
try {
await handler(req, res);
} catch (e) {
expect(e).toBeInstanceOf(HttpError);
expect((e as HttpError).statusCode).toBe(403);
expect((e as HttpError).message).toBe("ADMIN required for userId");
}
});
test("Returns 400 if request body is invalid", async () => {
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "POST",
body: {
integration: "google",
},
});
req.userId = 333333;
req.isSystemWideAdmin = true;
await handler(req, res);
expect(res.statusCode).toBe(400);
expect(JSON.parse(res._getData()).message).toBe("invalid_type in 'externalId': Required");
});
});
describe("Success", () => {
test("Creates selected calendar if user is admin and sets bodyUserId", async () => {
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "POST",
query: {
apiKey: "validApiKey",
},
body: {
integration: "google",
externalId: "ext123",
userId: 444444,
},
});
req.userId = 333333;
req.isSystemWideAdmin = true;
prismaMock.user.findFirstOrThrow.mockResolvedValue({
id: 444444,
} as any);
prismaMock.selectedCalendar.create.mockResolvedValue({
credentialId: 1,
integration: "google",
externalId: "ext123",
userId: 444444,
});
await handler(req, res);
expect(res.statusCode).toBe(200);
const responseData = JSON.parse(res._getData());
expect(responseData.selected_calendar.credentialId).toBe(1);
expect(responseData.message).toBe("Selected Calendar created successfully");
});
test("Creates selected calendar if user is non-admin and does not set bodyUserId", async () => {
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "POST",
query: {
apiKey: "validApiKey",
},
body: {
integration: "google",
externalId: "ext123",
},
});
req.userId = 333333;
prismaMock.selectedCalendar.create.mockResolvedValue({
credentialId: 1,
integration: "google",
externalId: "ext123",
userId: 333333,
});
await handler(req, res);
expect(res.statusCode).toBe(200);
const responseData = JSON.parse(res._getData());
expect(responseData.selected_calendar.credentialId).toBe(1);
expect(responseData.message).toBe("Selected Calendar created successfully");
});
});
});
+3
View File
@@ -14,8 +14,11 @@ NEXT_PUBLIC_SENTRY_DSN=
# Stripe Billing
STRIPE_PRICE_ID_STARTER=
STRIPE_PRICE_ID_STARTER_OVERAGE=
STRIPE_PRICE_ID_ESSENTIALS=
STRIPE_PRICE_ID_ESSENTIALS_OVERAGE=
STRIPE_PRICE_ID_ENTERPRISE=
STRIPE_PRICE_ID_ENTERPRISE_OVERAGE=
STRIPE_API_KEY=
STRIPE_WEBHOOK_SECRET=
+2 -1
View File
@@ -10,5 +10,6 @@
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"setupFiles": ["<rootDir>/test/setEnvVars.ts"]
"setupFiles": ["<rootDir>/test/setEnvVars.ts"],
"reporters": ["default", "jest-summarizing-reporter"]
}
+4 -5
View File
@@ -18,23 +18,21 @@
"test:watch": "yarn dev:build && jest --watch",
"test:cov": "yarn dev:build && jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "yarn dev:build && jest --runInBand --config ./jest-e2e.json",
"test:e2e": "yarn dev:build && jest --runInBand --detectOpenHandles --forceExit --config ./jest-e2e.json",
"prisma": "yarn workspace @calcom/prisma prisma",
"generate-schemas": "yarn prisma generate && yarn prisma format",
"copy-swagger-module": "ts-node -r tsconfig-paths/register swagger/copy-swagger-module.ts"
},
"dependencies": {
"@calcom/platform-constants": "*",
"@calcom/platform-libraries-0.0.13": "npm:@calcom/platform-libraries@0.0.13",
"@calcom/platform-libraries-0.0.15": "npm:@calcom/platform-libraries@0.0.15",
"@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-libraries-0.0.14": "npm:@calcom/platform-libraries@0.0.14",
"@calcom/platform-libraries-0.0.22": "npm:@calcom/platform-libraries@0.0.22",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
"@calcom/prisma": "*",
"@golevelup/ts-jest": "^0.4.0",
"@microsoft/microsoft-graph-types-beta": "^0.42.0-preview",
"@nestjs/bull": "^10.1.1",
"@nestjs/common": "^10.0.0",
"@nestjs/config": "^3.1.1",
"@nestjs/core": "^10.0.0",
@@ -45,6 +43,7 @@
"@nestjs/throttler": "^5.1.2",
"@sentry/node": "^8.8.0",
"body-parser": "^1.20.2",
"bull": "^4.12.4",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"cookie-parser": "^1.4.6",
+14 -10
View File
@@ -11,10 +11,11 @@ import { JwtModule } from "@/modules/jwt/jwt.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { RedisModule } from "@/modules/redis/redis.module";
import { RedisService } from "@/modules/redis/redis.service";
import { BullModule } from "@nestjs/bull";
import { MiddlewareConsumer, Module, NestModule, RequestMethod } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { APP_INTERCEPTOR, RouterModule } from "@nestjs/core";
import { seconds, ThrottlerModule } from "@nestjs/throttler";
import { APP_GUARD, APP_INTERCEPTOR } from "@nestjs/core";
import { seconds, ThrottlerGuard, ThrottlerModule } from "@nestjs/throttler";
import { ThrottlerStorageRedisService } from "nestjs-throttler-storage-redis";
import { AppController } from "./app.controller";
@@ -26,21 +27,20 @@ import { AppController } from "./app.controller";
isGlobal: true,
load: [appConfig],
}),
RedisModule,
BullModule.forRoot({
redis: `${process.env.REDIS_URL}${process.env.NODE_ENV === "production" ? "?tls=true" : ""}`,
}),
ThrottlerModule.forRootAsync({
imports: [RedisModule],
inject: [RedisService],
useFactory: (redisService: RedisService) => ({
throttlers: [
{
name: "short",
ttl: seconds(10),
limit: 3,
},
{
name: "medium",
ttl: seconds(30),
limit: 10,
name: "long",
ttl: seconds(60), // Time to live for the long period in seconds
limit: 120, // Maximum number of requests within the long ttl
},
],
storage: new ThrottlerStorageRedisService(redisService.redis),
@@ -57,6 +57,10 @@ import { AppController } from "./app.controller";
provide: APP_INTERCEPTOR,
useClass: ResponseInterceptor,
},
{
provide: APP_GUARD,
useClass: ThrottlerGuard,
},
],
})
export class AppModule implements NestModule {
@@ -19,7 +19,7 @@ import { UserRepositoryFixture } from "test/fixtures/repository/users.repository
import { withApiAuth } from "test/utils/withApiAuth";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
import { handleNewBooking } from "@calcom/platform-libraries-0.0.2";
import { handleNewBooking } from "@calcom/platform-libraries-0.0.22";
import { ApiSuccessResponse, ApiResponse } from "@calcom/platform-types";
describe("Bookings Endpoints", () => {
@@ -139,7 +139,6 @@ describe("Bookings Endpoints", () => {
return request(app.getHttpServer())
.get("/v2/bookings?filters[status]=upcoming")
.then((response) => {
console.log("asap responseBody", JSON.stringify(response.body, null, 2));
const responseBody: GetBookingsOutput = response.body;
const fetchedBooking = responseBody.data.bookings[0];
@@ -240,7 +239,8 @@ describe("Bookings Endpoints", () => {
// });
// });
it("should cancel a booking", async () => {
// cancelling a booking hangs the test for some reason
it.skip("should cancel a booking", async () => {
const bookingId = createdBooking.id;
const body = {
@@ -1,7 +1,9 @@
import { CreateBookingInput } from "@/ee/bookings/inputs/create-booking.input";
import { CreateRecurringBookingInput } from "@/ee/bookings/inputs/create-recurring-booking.input";
import { MarkNoShowInput } from "@/ee/bookings/inputs/mark-no-show.input";
import { GetBookingOutput } from "@/ee/bookings/outputs/get-booking.output";
import { GetBookingsOutput } from "@/ee/bookings/outputs/get-bookings.output";
import { MarkNoShowOutput } from "@/ee/bookings/outputs/mark-no-show.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";
@@ -32,20 +34,19 @@ import { Request } from "express";
import { NextApiRequest } from "next/types";
import { X_CAL_CLIENT_ID } from "@calcom/platform-constants";
import { BOOKING_READ, SUCCESS_STATUS } from "@calcom/platform-constants";
import {
getAllUserBookings,
getBookingInfo,
handleCancelBooking,
getBookingForReschedule,
} from "@calcom/platform-libraries-0.0.2";
import { BOOKING_READ, SUCCESS_STATUS, BOOKING_WRITE } from "@calcom/platform-constants";
import {
handleNewBooking,
BookingResponse,
HttpError,
handleNewRecurringBooking,
handleInstantMeeting,
} from "@calcom/platform-libraries-0.0.2";
handleMarkNoShow,
getAllUserBookings,
getBookingInfo,
handleCancelBooking,
getBookingForReschedule,
} from "@calcom/platform-libraries-0.0.22";
import { GetBookingsInput, CancelBookingInput, Status } from "@calcom/platform-types";
import { ApiResponse } from "@calcom/platform-types";
import { PrismaClient } from "@calcom/prisma";
@@ -149,17 +150,21 @@ export class BookingsController {
@Req() req: BookingRequest,
@Body() body: CreateBookingInput,
@Headers(X_CAL_CLIENT_ID) clientId?: string
): Promise<ApiResponse<unknown>> {
): Promise<ApiResponse<Partial<BookingResponse>>> {
const oAuthClientId = clientId?.toString();
const { orgSlug, locationUrl } = body;
req.headers["x-cal-force-slug"] = orgSlug;
try {
const booking = await handleNewBooking(
await this.createNextApiBookingRequest(req, oAuthClientId, locationUrl)
);
void (await this.billingService.increaseUsageByClientId(oAuthClientId!));
if (booking.userId && booking.uid && booking.startTime) {
void (await this.billingService.increaseUsageByUserId(booking.userId, {
uid: booking.uid,
startTime: booking.startTime,
fromReschedule: booking.fromReschedule,
}));
}
return {
status: SUCCESS_STATUS,
data: booking,
@@ -176,14 +181,22 @@ export class BookingsController {
@Param("bookingId") bookingId: string,
@Body() _: CancelBookingInput,
@Headers(X_CAL_CLIENT_ID) clientId?: string
): Promise<ApiResponse> {
): Promise<ApiResponse<{ bookingId: number; bookingUid: string; onlyRemovedAttendee: boolean }>> {
const oAuthClientId = clientId?.toString();
if (bookingId) {
try {
req.body.id = parseInt(bookingId);
await handleCancelBooking(await this.createNextApiBookingRequest(req, oAuthClientId));
const res = await handleCancelBooking(await this.createNextApiBookingRequest(req, oAuthClientId));
if (!res.onlyRemovedAttendee) {
void (await this.billingService.cancelUsageByBookingUid(res.bookingUid));
}
return {
status: SUCCESS_STATUS,
data: {
bookingId: res.bookingId,
bookingUid: res.bookingUid,
onlyRemovedAttendee: res.onlyRemovedAttendee,
},
};
} catch (err) {
this.handleBookingErrors(err);
@@ -194,6 +207,27 @@ export class BookingsController {
throw new InternalServerErrorException("Could not cancel booking.");
}
@Post("/:bookingUid/mark-no-show")
@Permissions([BOOKING_WRITE])
@UseGuards(ApiAuthGuard)
async markNoShow(
@Body() body: MarkNoShowInput,
@Param("bookingUid") bookingUid: string
): Promise<MarkNoShowOutput> {
try {
const markNoShowResponse = await handleMarkNoShow({
bookingUid: bookingUid,
attendees: body.attendees,
noShowHost: body.noShowHost,
});
return { status: SUCCESS_STATUS, data: markNoShowResponse };
} catch (err) {
this.handleBookingErrors(err, "no-show");
}
throw new InternalServerErrorException("Could not mark no show.");
}
@Post("/recurring")
async createRecurringBooking(
@Req() req: BookingRequest,
@@ -206,7 +240,14 @@ export class BookingsController {
await this.createNextApiBookingRequest(req, oAuthClientId)
);
void (await this.billingService.increaseUsageByClientId(oAuthClientId!));
createdBookings.forEach(async (booking) => {
if (booking.userId && booking.uid && booking.startTime) {
void (await this.billingService.increaseUsageByUserId(booking.userId, {
uid: booking.uid,
startTime: booking.startTime,
}));
}
});
return {
status: SUCCESS_STATUS,
@@ -231,7 +272,15 @@ export class BookingsController {
await this.createNextApiBookingRequest(req, oAuthClientId)
);
void (await this.billingService.increaseUsageByClientId(oAuthClientId!));
if (instantMeeting.userId && instantMeeting.bookingUid) {
const now = new Date();
// add a 10 secondes delay to the usage incrementation to give some time to cancel the booking if needed
now.setSeconds(now.getSeconds() + 10);
void (await this.billingService.increaseUsageByUserId(instantMeeting.userId, {
uid: instantMeeting.bookingUid,
startTime: now,
}));
}
return {
status: SUCCESS_STATUS,
@@ -287,8 +336,14 @@ export class BookingsController {
return req as unknown as NextApiRequest & { userId?: number } & OAuthRequestParams;
}
private handleBookingErrors(err: Error | HttpError | unknown, type?: "recurring" | `instant`): void {
const errMsg = `Error while creating ${type ? type + " " : ""}booking.`;
private handleBookingErrors(
err: Error | HttpError | unknown,
type?: "recurring" | `instant` | "no-show"
): void {
const errMsg =
type === "no-show"
? `Error while marking no-show.`
: `Error while creating ${type ? type + " " : ""}booking.`;
if (err instanceof HttpError) {
const httpError = err as HttpError;
throw new HttpException(httpError?.message ?? errMsg, httpError?.statusCode ?? 500);
@@ -1,7 +1,7 @@
import { CreateBookingInput } from "@/ee/bookings/inputs/create-booking.input";
import { IsBoolean, IsNumber, IsOptional } from "class-validator";
import type { AppsStatus } from "@calcom/platform-libraries-0.0.2";
import type { AppsStatus } from "@calcom/platform-libraries-0.0.22";
export class CreateRecurringBookingInput extends CreateBookingInput {
@IsBoolean()
@@ -0,0 +1,22 @@
import { Type } from "class-transformer";
import { IsOptional, IsArray, IsEmail, IsBoolean, ValidateNested } from "class-validator";
class Attendee {
@IsEmail()
email!: string;
@IsBoolean()
noShow!: boolean;
}
export class MarkNoShowInput {
@IsBoolean()
@IsOptional()
noShowHost?: boolean;
@ValidateNested()
@Type(() => Attendee)
@IsArray()
@IsOptional()
attendees?: Attendee[];
}
@@ -0,0 +1,45 @@
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsString, IsEnum, IsOptional, ValidateNested, IsArray, IsEmail, IsBoolean } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
class Attendee {
@IsEmail()
email!: string;
@IsBoolean()
noShow!: boolean;
}
class HandleMarkNoShowData {
@IsString()
message!: string;
@IsBoolean()
@IsOptional()
noShowHost?: boolean;
@IsString()
@IsOptional()
messageKey?: string;
@ValidateNested()
@Type(() => Attendee)
@IsArray()
@IsOptional()
attendees?: Attendee[];
}
export class MarkNoShowOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
type: HandleMarkNoShowData,
})
@ValidateNested()
@Type(() => HandleMarkNoShowData)
data!: HandleMarkNoShowData;
}
@@ -1,3 +1,4 @@
import { CalendarsRepository } from "@/ee/calendars/calendars.repository";
import { CalendarsController } from "@/ee/calendars/controllers/calendars.controller";
import { AppleCalendarService } from "@/ee/calendars/services/apple-calendar.service";
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
@@ -21,6 +22,7 @@ import { Module } from "@nestjs/common";
AppleCalendarService,
SelectedCalendarsRepository,
AppsRepository,
CalendarsRepository,
],
controllers: [CalendarsController],
exports: [CalendarsService],
@@ -0,0 +1,51 @@
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";
const credentialForCalendarRepositorySelect = Prisma.validator<Prisma.CredentialSelect>()({
id: true,
appId: true,
type: true,
userId: true,
user: {
select: {
email: true,
},
},
teamId: true,
key: true,
invalid: true,
});
@Injectable()
export class CalendarsRepository {
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
async getCalendarCredentials(credentialId: number, userId: number) {
return await this.dbRead.prisma.credential.findFirst({
where: {
id: credentialId,
userId,
},
select: {
...credentialForCalendarRepositorySelect,
app: {
select: {
slug: true,
categories: true,
dirName: true,
},
},
},
});
}
async deleteCredentials(credentialId: number) {
return await this.dbWrite.prisma.credential.delete({
where: {
id: credentialId,
},
});
}
}
@@ -1,5 +1,6 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { DeletedCalendarCredentialsOutputResponseDto } from "@/ee/calendars/outputs/delete-calendar-credentials.output";
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
import { HttpExceptionFilter } from "@/filters/http-exception.filter";
import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter";
@@ -16,7 +17,9 @@ import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-cli
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 { CalendarsServiceMock } from "test/mocks/calendars-service-mock";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import {
GOOGLE_CALENDAR,
OFFICE_365_CALENDAR,
@@ -27,20 +30,6 @@ import { OFFICE_365_CALENDAR_ID, OFFICE_365_CALENDAR_TYPE } from "@calcom/platfo
const CLIENT_REDIRECT_URI = "http://localhost:5555";
class CalendarsServiceMock {
async getCalendars() {
return {
connectedCalendars: [
{
integration: {
type: "google_calendar",
},
},
],
};
}
}
describe("Platform Calendars Endpoints", () => {
let app: INestApplication;
@@ -66,8 +55,7 @@ describe("Platform Calendars Endpoints", () => {
.useValue({
canActivate: () => true,
})
.overrideProvider(CalendarsService)
.useClass(CalendarsServiceMock)
.compile();
app = moduleRef.createNestApplication();
@@ -85,6 +73,9 @@ describe("Platform Calendars Endpoints", () => {
accessTokenSecret = tokens.accessToken;
refreshTokenSecret = tokens.refreshToken;
await app.init();
jest
.spyOn(CalendarsService.prototype, "getCalendars")
.mockImplementation(CalendarsServiceMock.prototype.getCalendars);
});
async function createOAuthClient(organizationId: number) {
@@ -116,15 +107,16 @@ describe("Platform Calendars Endpoints", () => {
.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();
});
// TODO: Uncomment this once CI is ready to run proper Office365 tests
// 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())
@@ -200,6 +192,26 @@ describe("Platform Calendars Endpoints", () => {
.expect(200);
});
it.skip(`/POST/v2/calendars/${OFFICE_365_CALENDAR}/disconnect: it should respond with a 201 returning back the user deleted calendar credentials`, async () => {
const body = {
id: 10,
};
return request(app.getHttpServer())
.post(`/v2/calendars/${OFFICE_365_CALENDAR}/disconnect`)
.set("Authorization", `Bearer ${accessTokenSecret}`)
.send(body)
.expect(201)
.then(async (response) => {
const responseBody: Promise<DeletedCalendarCredentialsOutputResponseDto> = response.body;
expect((await responseBody).status).toEqual(SUCCESS_STATUS);
expect((await responseBody).data).toBeDefined();
expect((await responseBody).data.id).toEqual(body.id);
expect((await responseBody).data.userId).toEqual(user.id);
});
});
afterAll(async () => {
await oauthClientRepositoryFixture.delete(oAuthClient.id);
await teamRepositoryFixture.delete(organization.id);
@@ -1,5 +1,11 @@
import { CalendarsRepository } from "@/ee/calendars/calendars.repository";
import { DeleteCalendarCredentialsInputBodyDto } from "@/ee/calendars/input/delete-calendar-credentials.input";
import { GetBusyTimesOutput } from "@/ee/calendars/outputs/busy-times.output";
import { ConnectedCalendarsOutput } from "@/ee/calendars/outputs/connected-calendars.output";
import {
DeletedCalendarCredentialsOutputResponseDto,
DeletedCalendarCredentialsOutputDto,
} from "@/ee/calendars/outputs/delete-calendar-credentials.output";
import { AppleCalendarService } from "@/ee/calendars/services/apple-calendar.service";
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
import { GoogleCalendarService } from "@/ee/calendars/services/gcal.service";
@@ -27,6 +33,7 @@ import {
} from "@nestjs/common";
import { ApiTags as DocsTags } from "@nestjs/swagger";
import { User } from "@prisma/client";
import { plainToClass } from "class-transformer";
import { Request } from "express";
import { z } from "zod";
@@ -50,7 +57,8 @@ export class CalendarsController {
private readonly calendarsService: CalendarsService,
private readonly outlookService: OutlookService,
private readonly googleCalendarService: GoogleCalendarService,
private readonly appleCalendarService: AppleCalendarService
private readonly appleCalendarService: AppleCalendarService,
private readonly calendarsRepository: CalendarsRepository
) {}
@UseGuards(ApiAuthGuard)
@@ -183,4 +191,29 @@ export class CalendarsController {
);
}
}
@UseGuards(ApiAuthGuard)
@Post("/:calendar/disconnect")
@HttpCode(HttpStatus.OK)
async deleteCalendarCredentials(
@Param("calendar") calendar: string,
@Body() body: DeleteCalendarCredentialsInputBodyDto,
@GetUser() user: UserWithProfile
): Promise<DeletedCalendarCredentialsOutputResponseDto> {
const { id: credentialId } = body;
await this.calendarsService.checkCalendarCredentials(credentialId, user.id);
const { id, type, userId, teamId, appId, invalid } = await this.calendarsRepository.deleteCredentials(
credentialId
);
return {
status: SUCCESS_STATUS,
data: plainToClass(
DeletedCalendarCredentialsOutputDto,
{ id, type, userId, teamId, appId, invalid },
{ strategy: "excludeAll" }
),
};
}
}
@@ -0,0 +1,15 @@
import { ApiProperty } from "@nestjs/swagger";
import { Expose } from "class-transformer";
import { IsInt } from "class-validator";
export class DeleteCalendarCredentialsInputBodyDto {
@IsInt()
@Expose()
@ApiProperty({
example: 10,
description: "Credential ID of the calendar to delete, as returned by the /calendars endpoint",
type: "integer",
required: true,
})
readonly id!: number;
}
@@ -0,0 +1,42 @@
import { ApiProperty } from "@nestjs/swagger";
import { Expose, Type } from "class-transformer";
import { IsString, ValidateNested, IsEnum, IsInt, IsBoolean } from "class-validator";
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
export class DeletedCalendarCredentialsOutputDto {
@IsInt()
@Expose()
readonly id!: number;
@IsString()
@Expose()
readonly type!: string;
@IsInt()
@Expose()
readonly userId!: number | null;
@IsInt()
@Expose()
readonly teamId!: number | null;
@IsString()
@Expose()
readonly appId!: string | null;
@IsBoolean()
@Expose()
readonly invalid!: boolean | null;
}
export class DeletedCalendarCredentialsOutputResponseDto {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@Expose()
@ValidateNested()
@Type(() => DeletedCalendarCredentialsOutputDto)
data!: DeletedCalendarCredentialsOutputDto;
}
@@ -5,7 +5,7 @@ import { BadRequestException, UnauthorizedException } from "@nestjs/common";
import { Injectable } from "@nestjs/common";
import { SUCCESS_STATUS, APPLE_CALENDAR_TYPE, APPLE_CALENDAR_ID } from "@calcom/platform-constants";
import { symmetricEncrypt, CalendarService } from "@calcom/platform-libraries-0.0.14";
import { symmetricEncrypt, CalendarService } from "@calcom/platform-libraries-0.0.22";
@Injectable()
export class AppleCalendarService implements CredentialSyncCalendarApp {
@@ -1,3 +1,4 @@
import { CalendarsRepository } from "@/ee/calendars/calendars.repository";
import { AppsRepository } from "@/modules/apps/apps.repository";
import {
CredentialsRepository,
@@ -17,8 +18,7 @@ import { User } from "@prisma/client";
import { DateTime } from "luxon";
import { z } from "zod";
import { getConnectedDestinationCalendars } from "@calcom/platform-libraries-0.0.2";
import { getBusyCalendarTimes } from "@calcom/platform-libraries-0.0.2";
import { getConnectedDestinationCalendars, getBusyCalendarTimes } from "@calcom/platform-libraries-0.0.22";
import { Calendar } from "@calcom/platform-types";
import { PrismaClient } from "@calcom/prisma";
@@ -30,6 +30,7 @@ export class CalendarsService {
private readonly usersRepository: UsersRepository,
private readonly credentialsRepository: CredentialsRepository,
private readonly appsRepository: AppsRepository,
private readonly calendarsRepository: CalendarsRepository,
private readonly dbRead: PrismaReadService,
private readonly dbWrite: PrismaWriteService,
private readonly config: ConfigService
@@ -137,4 +138,11 @@ export class CalendarsService {
return { client_id, client_secret };
}
async checkCalendarCredentials(credentialId: number, userId: number) {
const credential = await this.calendarsRepository.getCalendarCredentials(credentialId, userId);
if (!credential) {
throw new NotFoundException("Calendar credentials not found");
}
}
}
@@ -35,7 +35,7 @@ import {
EventTypesPublic,
eventTypeBookingFields,
eventTypeLocations,
} from "@calcom/platform-libraries-0.0.2";
} from "@calcom/platform-libraries-0.0.22";
import { ApiSuccessResponse } from "@calcom/platform-types";
describe("Event types Endpoints", () => {
@@ -39,8 +39,8 @@ import {
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-0.0.2";
import { getEventTypesByViewer } from "@calcom/platform-libraries-0.0.2";
import { getPublicEvent, getEventTypesByViewer } from "@calcom/platform-libraries-0.0.2";
import { PrismaClient } from "@calcom/prisma";
@Controller({
@@ -4,7 +4,7 @@ import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { UserWithProfile } from "@/modules/users/users.repository";
import { Injectable } from "@nestjs/common";
import { getEventTypeById } from "@calcom/platform-libraries-0.0.2";
import { getEventTypeById } from "@calcom/platform-libraries-0.0.22";
import type { PrismaClient } from "@calcom/prisma";
@Injectable()
@@ -9,8 +9,12 @@ 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-0.0.2";
import { getEventTypesPublic, EventTypesPublic } from "@calcom/platform-libraries-0.0.2";
import {
createEventType,
updateEventType,
EventTypesPublic,
getEventTypesPublic,
} from "@calcom/platform-libraries-0.0.22";
import { EventType } from "@calcom/prisma/client";
@Injectable()
@@ -56,6 +60,7 @@ export class EventTypesService_2024_04_15 {
const profileId = user.movedToProfile?.id || null;
return {
id: user.id,
role: user.role,
organizationId: user.organizationId,
organization: { isOrgAdmin },
profile: { id: profileId },
@@ -9,10 +9,11 @@ import { UsersModule } from "@/modules/users/users.module";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import { PlatformOAuthClient, Team, User } from "@prisma/client";
import { PlatformOAuthClient, Team, User, Schedule } from "@prisma/client";
import * as request from "supertest";
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
import { SchedulesRepositoryFixture } from "test/fixtures/repository/schedules.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { withApiAuth } from "test/utils/withApiAuth";
@@ -63,12 +64,18 @@ describe("Event types Endpoints", () => {
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
let teamRepositoryFixture: TeamRepositoryFixture;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let schedulesRepostoryFixture: SchedulesRepositoryFixture;
const userEmail = "event-types-test-e2e@api.com";
const falseTestEmail = "false-event-types@api.com";
const name = "bob-the-builder";
const username = name;
let eventType: EventTypeOutput_2024_06_14;
let user: User;
let falseTestUser: User;
let firstSchedule: Schedule;
let secondSchedule: Schedule;
let falseTestSchedule: Schedule;
beforeAll(async () => {
const moduleRef = await withApiAuth(
@@ -91,6 +98,7 @@ describe("Event types Endpoints", () => {
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
schedulesRepostoryFixture = new SchedulesRepositoryFixture(moduleRef);
organization = await teamRepositoryFixture.create({ name: "organization" });
oAuthClient = await createOAuthClient(organization.id);
@@ -100,6 +108,30 @@ describe("Event types Endpoints", () => {
username,
});
falseTestUser = await userRepositoryFixture.create({
email: falseTestEmail,
name: "false-test",
username: falseTestEmail,
});
firstSchedule = await schedulesRepostoryFixture.create({
userId: user.id,
name: "work",
timeZone: "Europe/Rome",
});
secondSchedule = await schedulesRepostoryFixture.create({
userId: user.id,
name: "chill",
timeZone: "Europe/Rome",
});
falseTestSchedule = await schedulesRepostoryFixture.create({
userId: falseTestUser.id,
name: "work",
timeZone: "Europe/Rome",
});
await app.init();
});
@@ -123,6 +155,40 @@ describe("Event types Endpoints", () => {
expect(user).toBeDefined();
});
it("should not allow creating an event type with schedule user does not own", async () => {
const scheduleId = falseTestSchedule.id;
const body: CreateEventTypeInput_2024_06_14 = {
title: "Coding class",
slug: "coding-class",
description: "Let's learn how to code like a pro.",
lengthInMinutes: 60,
locations: [
{
type: "integration",
integration: "cal-video",
},
],
bookingFields: [
{
type: "select",
label: "select which language you want to learn",
slug: "select-language",
required: true,
placeholder: "select language",
options: ["javascript", "python", "cobol"],
},
],
scheduleId,
};
return request(app.getHttpServer())
.post("/api/v2/event-types")
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.send(body)
.expect(404);
});
it("should create an event type", async () => {
const body: CreateEventTypeInput_2024_06_14 = {
title: "Coding class",
@@ -145,6 +211,7 @@ describe("Event types Endpoints", () => {
options: ["javascript", "python", "cobol"],
},
],
scheduleId: firstSchedule.id,
};
return request(app.getHttpServer())
@@ -162,6 +229,7 @@ describe("Event types Endpoints", () => {
expect(createdEventType.locations).toEqual(body.locations);
expect(createdEventType.bookingFields).toEqual(body.bookingFields);
expect(createdEventType.ownerId).toEqual(user.id);
expect(createdEventType.scheduleId).toEqual(firstSchedule.id);
eventType = responseBody.data;
});
@@ -172,6 +240,7 @@ describe("Event types Endpoints", () => {
const body: UpdateEventTypeInput_2024_06_14 = {
title: newTitle,
scheduleId: secondSchedule.id,
};
return request(app.getHttpServer())
@@ -191,11 +260,25 @@ describe("Event types Endpoints", () => {
expect(updatedEventType.locations).toEqual(eventType.locations);
expect(updatedEventType.bookingFields).toEqual(eventType.bookingFields);
expect(updatedEventType.ownerId).toEqual(user.id);
expect(updatedEventType.scheduleId).toEqual(secondSchedule.id);
eventType.title = newTitle;
eventType.scheduleId = secondSchedule.id;
});
});
it("should not allow to update event type with scheduleId user does not own", async () => {
const body: UpdateEventTypeInput_2024_06_14 = {
scheduleId: falseTestSchedule.id,
};
return request(app.getHttpServer())
.patch(`/api/v2/event-types/${eventType.id}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.send(body)
.expect(404);
});
it(`/GET/:id`, async () => {
const response = await request(app.getHttpServer())
.get(`/api/v2/event-types/${eventType.id}`)
@@ -272,7 +355,7 @@ describe("Event types Endpoints", () => {
.expect(404);
});
it("should delete schedule", async () => {
it("should delete event type", async () => {
return request(app.getHttpServer()).delete(`/api/v2/event-types/${eventType.id}`).expect(200);
});
@@ -289,6 +372,11 @@ describe("Event types Endpoints", () => {
} catch (e) {
// User might have been deleted by the test
}
try {
await userRepositoryFixture.delete(falseTestUser.id);
} catch (e) {
// User might have been deleted by the test
}
await app.close();
});
});
@@ -77,7 +77,6 @@ export class EventTypesController_2024_06_14 {
}
@Get("/")
@Permissions([EVENT_TYPE_READ])
async getEventTypes(
@Query() queryParams: GetEventTypesQuery_2024_06_14
): Promise<GetEventTypesOutput_2024_06_14> {
@@ -3,6 +3,7 @@ import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_20
import { EventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/event-types.service";
import { InputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/input-event-types.service";
import { OutputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/output-event-types.service";
import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository";
import { MembershipsModule } from "@/modules/memberships/memberships.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { SelectedCalendarsModule } from "@/modules/selected-calendars/selected-calendars.module";
@@ -20,8 +21,14 @@ import { Module } from "@nestjs/common";
OutputEventTypesService_2024_06_14,
UsersRepository,
UsersService,
SchedulesRepository_2024_06_11,
],
controllers: [EventTypesController_2024_06_14],
exports: [EventTypesService_2024_06_14, EventTypesRepository_2024_06_14],
exports: [
EventTypesService_2024_06_14,
EventTypesRepository_2024_06_14,
InputEventTypesService_2024_06_14,
OutputEventTypesService_2024_06_14,
],
})
export class EventTypesModule_2024_06_14 {}
@@ -7,7 +7,7 @@ import {
getEventTypeById,
transformApiEventTypeBookingFields,
transformApiEventTypeLocations,
} from "@calcom/platform-libraries-0.0.13";
} from "@calcom/platform-libraries-0.0.22";
import { CreateEventTypeInput_2024_06_14 } from "@calcom/platform-types";
import type { PrismaClient } from "@calcom/prisma";
@@ -2,6 +2,7 @@ import { DEFAULT_EVENT_TYPES } from "@/ee/event-types/event-types_2024_06_14/con
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
import { InputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/input-event-types.service";
import { OutputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/output-event-types.service";
import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
@@ -9,9 +10,9 @@ import { UsersService } from "@/modules/users/services/users.service";
import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository";
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
import { createEventType, updateEventType } from "@calcom/platform-libraries";
import { getEventTypesPublic, EventTypesPublic } from "@calcom/platform-libraries";
import { dynamicEvent } from "@calcom/platform-libraries-0.0.13";
import { createEventType, updateEventType } from "@calcom/platform-libraries-0.0.22";
import { getEventTypesPublic, EventTypesPublic } from "@calcom/platform-libraries-0.0.22";
import { dynamicEvent } from "@calcom/platform-libraries-0.0.22";
import {
CreateEventTypeInput_2024_06_14,
UpdateEventTypeInput_2024_06_14,
@@ -30,7 +31,8 @@ export class EventTypesService_2024_06_14 {
private readonly usersRepository: UsersRepository,
private readonly usersService: UsersService,
private readonly selectedCalendarsRepository: SelectedCalendarsRepository,
private readonly dbWrite: PrismaWriteService
private readonly dbWrite: PrismaWriteService,
private readonly schedulesRepository: SchedulesRepository_2024_06_11
) {}
async createUserEventType(user: UserWithProfile, body: CreateEventTypeInput_2024_06_14) {
@@ -61,6 +63,7 @@ export class EventTypesService_2024_06_14 {
if (existsWithSlug) {
throw new BadRequestException("User already has an event type with this slug.");
}
await this.checkUserOwnsSchedule(userId, body.scheduleId);
}
async getEventTypeByUsernameAndSlug(username: string, eventTypeSlug: string) {
@@ -94,6 +97,7 @@ export class EventTypesService_2024_06_14 {
const profileId = user.movedToProfile?.id || null;
return {
id: user.id,
role: user.role,
organizationId: user.organizationId,
organization: { isOrgAdmin },
profile: { id: profileId },
@@ -202,7 +206,7 @@ export class EventTypesService_2024_06_14 {
}
async updateEventType(eventTypeId: number, body: UpdateEventTypeInput_2024_06_14, user: UserWithProfile) {
this.checkCanUpdateEventType(user.id, eventTypeId);
await this.checkCanUpdateEventType(user.id, eventTypeId, body.scheduleId);
const eventTypeUser = await this.getUserToUpdateEvent(user);
const bodyTransformed = this.inputEventTypesService.transformInputUpdateEventType(body);
await updateEventType({
@@ -224,12 +228,13 @@ export class EventTypesService_2024_06_14 {
return this.outputEventTypesService.getResponseEventType(user.id, eventType);
}
async checkCanUpdateEventType(userId: number, eventTypeId: number) {
async checkCanUpdateEventType(userId: number, eventTypeId: number, scheduleId: number | undefined) {
const existingEventType = await this.getUserEventType(userId, eventTypeId);
if (!existingEventType) {
throw new NotFoundException(`Event type with id ${eventTypeId} not found`);
}
this.checkUserOwnsEventType(userId, { id: eventTypeId, userId: existingEventType.ownerId });
await this.checkUserOwnsSchedule(userId, scheduleId);
}
async getUserToUpdateEvent(user: UserWithProfile) {
@@ -254,4 +259,16 @@ export class EventTypesService_2024_06_14 {
throw new ForbiddenException(`User with ID=${userId} does not own event type with ID=${eventType.id}`);
}
}
async checkUserOwnsSchedule(userId: number, scheduleId: number | null | undefined) {
if (!scheduleId) {
return;
}
const schedule = await this.schedulesRepository.getScheduleByIdAndUserId(scheduleId, userId);
if (!schedule) {
throw new NotFoundException(`User with ID=${userId} does not own schedule with ID=${scheduleId}`);
}
}
}
@@ -3,7 +3,7 @@ import { Injectable } from "@nestjs/common";
import {
transformApiEventTypeBookingFields,
transformApiEventTypeLocations,
} from "@calcom/platform-libraries-0.0.13";
} from "@calcom/platform-libraries-0.0.22";
import { CreateEventTypeInput_2024_06_14, UpdateEventTypeInput_2024_06_14 } from "@calcom/platform-types";
@Injectable()
@@ -29,12 +29,14 @@ export class InputEventTypesService_2024_06_14 {
}
transformInputUpdateEventType(inputEventType: UpdateEventTypeInput_2024_06_14) {
const { locations, bookingFields, ...rest } = inputEventType;
const { lengthInMinutes, locations, bookingFields, scheduleId, ...rest } = inputEventType;
const eventType = {
...rest,
length: lengthInMinutes,
locations: locations ? this.transformInputLocations(locations) : undefined,
bookingFields: bookingFields ? this.transformInputBookingFields(bookingFields) : undefined,
schedule: scheduleId,
};
return eventType;
@@ -9,7 +9,7 @@ import {
parseRecurringEvent,
TransformedLocationsSchema,
BookingFieldsSchema,
} from "@calcom/platform-libraries-0.0.13";
} from "@calcom/platform-libraries-0.0.22";
type EventTypeRelations = { users: User[]; schedule: Schedule | null };
type DatabaseEventType = EventType & EventTypeRelations;
@@ -41,7 +41,7 @@ type Input = Pick<
| "recurringEvent"
| "metadata"
| "users"
| "schedule"
| "scheduleId"
>;
@Injectable()
@@ -68,13 +68,13 @@ export class OutputEventTypesService_2024_06_14 {
successRedirectUrl,
seatsShowAvailabilityCount,
isInstantEvent,
scheduleId,
} = databaseEventType;
const locations = this.transformLocations(databaseEventType.locations);
const bookingFields = this.transformBookingFields(databaseEventType.bookingFields);
const recurringEvent = this.transformRecurringEvent(databaseEventType.recurringEvent);
const metadata = this.transformMetadata(databaseEventType.metadata) || {};
const schedule = await this.getSchedule(databaseEventType);
const users = this.transformUsers(databaseEventType.users);
return {
@@ -104,7 +104,7 @@ export class OutputEventTypesService_2024_06_14 {
seatsShowAvailabilityCount,
isInstantEvent,
users,
schedule,
scheduleId,
};
}
@@ -128,10 +128,6 @@ export class OutputEventTypesService_2024_06_14 {
return EventTypeMetaDataSchema.parse(metadata);
}
async getSchedule(databaseEventType: Input) {
return databaseEventType.schedule || null;
}
transformUsers(users: User[]) {
return users.map((user) => {
const metadata = user.metadata ? userMetadata.parse(user.metadata) : {};
@@ -16,23 +16,10 @@ import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-cli
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 { CalendarsServiceMock } from "test/mocks/calendars-service-mock";
const CLIENT_REDIRECT_URI = "http://localhost:5555";
class CalendarsServiceMock {
async getCalendars() {
return {
connectedCalendars: [
{
integration: {
type: "google_calendar",
},
},
],
};
}
}
describe("Platform Gcal Endpoints", () => {
let app: INestApplication;
@@ -57,8 +44,6 @@ describe("Platform Gcal Endpoints", () => {
.useValue({
canActivate: () => true,
})
.overrideProvider(CalendarsService)
.useClass(CalendarsServiceMock)
.compile();
app = moduleRef.createNestApplication();
@@ -76,6 +61,9 @@ describe("Platform Gcal Endpoints", () => {
accessTokenSecret = tokens.accessToken;
refreshTokenSecret = tokens.refreshToken;
await app.init();
jest
.spyOn(CalendarsService.prototype, "getCalendars")
.mockImplementation(CalendarsServiceMock.prototype.getCalendars);
});
async function createOAuthClient(organizationId: number) {
@@ -100,14 +88,14 @@ describe("Platform Gcal Endpoints", () => {
expect(user).toBeDefined();
});
it(`/GET/ee/gcal/oauth/auth-url: it should respond 401 with invalid access token`, async () => {
it(`/GET/gcal/oauth/auth-url: it should respond 401 with invalid access token`, async () => {
await request(app.getHttpServer())
.get(`/v2/gcal/oauth/auth-url`)
.set("Authorization", `Bearer invalid_access_token`)
.expect(401);
});
it(`/GET/ee/gcal/oauth/auth-url: it should auth-url to google oauth with valid access token `, async () => {
it(`/GET/gcal/oauth/auth-url: it should auth-url to google oauth with valid access token `, async () => {
const response = await request(app.getHttpServer())
.get(`/v2/gcal/oauth/auth-url`)
.set("Authorization", `Bearer ${accessTokenSecret}`)
@@ -117,42 +105,42 @@ describe("Platform Gcal Endpoints", () => {
expect(data.authUrl).toBeDefined();
});
it(`/GET/ee/gcal/oauth/save: without oauth code`, async () => {
it(`/GET/gcal/oauth/save: without oauth code`, async () => {
await request(app.getHttpServer())
.get(
`/v2/gcal/oauth/save?state=accessToken=${accessTokenSecret}&origin%3D${CLIENT_REDIRECT_URI}&scope=https://www.googleapis.com/auth/calendar.readonly%20https://www.googleapis.com/auth/calendar.events`
)
.expect(400);
.expect(301);
});
it(`/GET/ee/gcal/oauth/save: without access token`, async () => {
it(`/GET/gcal/oauth/save: without access token`, async () => {
await request(app.getHttpServer())
.get(
`/v2/gcal/oauth/save?state=origin%3D${CLIENT_REDIRECT_URI}&code=4/0AfJohXmBuT7QVrEPlAJLBu4ZcSnyj5jtDoJqSW_riPUhPXQ70RPGkOEbVO3xs-OzQwpPQw&scope=https://www.googleapis.com/auth/calendar.readonly%20https://www.googleapis.com/auth/calendar.events`
)
.expect(400);
.expect(301);
});
it(`/GET/ee/gcal/oauth/save: without origin`, async () => {
it(`/GET/gcal/oauth/save: without origin`, async () => {
await request(app.getHttpServer())
.get(
`/v2/gcal/oauth/save?state=accessToken=${accessTokenSecret}&code=4/0AfJohXmBuT7QVrEPlAJLBu4ZcSnyj5jtDoJqSW_riPUhPXQ70RPGkOEbVO3xs-OzQwpPQw&scope=https://www.googleapis.com/auth/calendar.readonly%20https://www.googleapis.com/auth/calendar.events`
)
.expect(400);
.expect(301);
});
it(`/GET/ee/gcal/check with access token but without origin`, async () => {
it(`/GET/gcal/check with access token but without origin`, async () => {
await request(app.getHttpServer())
.get(`/v2/gcal/check`)
.set("Authorization", `Bearer ${accessTokenSecret}`)
.expect(400);
});
it(`/GET/ee/gcal/check without access token`, async () => {
it(`/GET/gcal/check without access token`, async () => {
await request(app.getHttpServer()).get(`/v2/gcal/check`).expect(401);
});
it(`/GET/ee/gcal/check with access token and origin but no credentials`, async () => {
it(`/GET/gcal/check with access token and origin but no credentials`, async () => {
await request(app.getHttpServer())
.get(`/v2/gcal/check`)
.set("Authorization", `Bearer ${accessTokenSecret}`)
@@ -160,7 +148,7 @@ describe("Platform Gcal Endpoints", () => {
.expect(400);
});
it(`/GET/ee/gcal/check with access token and origin and gcal credentials`, async () => {
it(`/GET/gcal/check with access token and origin and gcal credentials`, async () => {
gcalCredentials = await credentialsRepositoryFixture.create(
"google_calendar",
{},
+2
View File
@@ -1,3 +1,4 @@
import { CalendarsRepository } from "@/ee/calendars/calendars.repository";
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
import { GcalController } from "@/ee/gcal/gcal.controller";
import { AppsRepository } from "@/modules/apps/apps.repository";
@@ -21,6 +22,7 @@ import { ConfigService } from "@nestjs/config";
GCalService,
CalendarsService,
UsersRepository,
CalendarsRepository,
],
controllers: [GcalController],
})
@@ -103,6 +103,21 @@ describe("Me Endpoints", () => {
});
});
it("should update user associated with access token given badly formatted timezone", async () => {
const bodyWithBadlyFormattedTimeZone: UpdateManagedUserInput = { timeZone: "America/New_york" };
return request(app.getHttpServer())
.patch("/v2/me")
.send(bodyWithBadlyFormattedTimeZone)
.expect(200)
.then(async (response) => {
const responseBody: ApiSuccessResponse<UserResponse> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data.timeZone).toEqual("America/New_York");
});
});
it("should not update user associated with access token given invalid timezone", async () => {
const bodyWithIncorrectTimeZone: UpdateManagedUserInput = { timeZone: "Narnia/Woods" };
+2 -2
View File
@@ -23,7 +23,7 @@ import { userSchemaResponse } from "@calcom/platform-types";
export class MeController {
constructor(
private readonly usersRepository: UsersRepository,
private readonly schedulesRepository: SchedulesService_2024_04_15
private readonly schedulesService: SchedulesService_2024_04_15
) {}
@Get("/")
@@ -45,7 +45,7 @@ export class MeController {
): Promise<UpdateMeOutput> {
const updatedUser = await this.usersRepository.update(user.id, bodySchedule);
if (bodySchedule.timeZone && user.defaultScheduleId) {
await this.schedulesRepository.updateUserSchedule(user, user.defaultScheduleId, {
await this.schedulesService.updateUserSchedule(user, user.defaultScheduleId, {
timeZone: bodySchedule.timeZone,
});
}
@@ -22,4 +22,7 @@ export class MeOutput {
@IsString()
timeZone!: string;
@IsInt()
organizationId!: number | null;
}
@@ -66,6 +66,37 @@ describe("Schedules Endpoints", () => {
expect(user).toBeDefined();
});
it("should not create an invalid schedule", async () => {
const scheduleName = "schedule-name";
const scheduleTimeZone = "Europe/Rome";
const isDefault = true;
const body = {
name: scheduleName,
timeZone: scheduleTimeZone,
isDefault,
availabilities: [
{
days: ["Monday"],
endTime: "11:15",
startTime: "10:00",
},
],
};
return request(app.getHttpServer())
.post("/api/v2/schedules")
.set(CAL_API_VERSION_HEADER, VERSION_2024_04_15)
.send(body)
.expect(400)
.then(async (response) => {
expect(response.body.status).toEqual("error");
expect(response.body.error.message).toEqual(
"Invalid datestring format. Expected format(ISO8061): 2025-04-12T13:17:56.324Z. Received: 11:15"
);
});
});
it("should create a default schedule", async () => {
const scheduleName = "schedule-name";
const scheduleTimeZone = "Europe/Rome";
@@ -19,8 +19,18 @@ export class CreateAvailabilityInput_2024_04_15 {
}
function transformStringToDate(value: string, key: string): Date {
// note(Lauris): incoming value is ISO8061 e.g. 2025-0412T13:17:56.324Z
if (!value) {
throw new BadRequestException(
`Missing ${key}. Expected value is in ISO8061 format e.g. 2025-0412T13:17:56.324Z`
);
}
const dateTimeParts = value.split("T");
if (dateTimeParts.length !== 2) {
throw new BadRequestException(
`Invalid datestring format. Expected format(ISO8061): 2025-04-12T13:17:56.324Z. Received: ${value}`
);
}
const timePart = dateTimeParts[1].split(".")[0]; // Removes milliseconds
const parts = timePart.split(":");
@@ -96,6 +96,17 @@ export class SchedulesRepository_2024_06_11 {
return schedule;
}
async getScheduleByIdAndUserId(scheduleId: number, userId: number) {
const schedule = await this.dbRead.prisma.schedule.findUnique({
where: {
id: scheduleId,
userId,
},
});
return schedule;
}
async updateSchedule(
userId: number,
scheduleId: number,
@@ -3,7 +3,7 @@ import { Injectable } from "@nestjs/common";
import {
transformApiScheduleOverrides,
transformApiScheduleAvailability,
} from "@calcom/platform-libraries-0.0.4";
} from "@calcom/platform-libraries-0.0.22";
import { CreateScheduleInput_2024_06_11, ScheduleAvailabilityInput_2024_06_11 } from "@calcom/platform-types";
import { ScheduleOverrideInput_2024_06_11 } from "@calcom/platform-types";
@@ -2,7 +2,7 @@ import { ArgumentsHost, Catch, ExceptionFilter, Logger } from "@nestjs/common";
import { Request } from "express";
import { ERROR_STATUS } from "@calcom/platform-constants";
import { TRPCError } from "@calcom/platform-libraries-0.0.2";
import { TRPCError } from "@calcom/platform-libraries-0.0.22";
import { Response } from "@calcom/platform-types";
@Catch(TRPCError)
+44
View File
@@ -0,0 +1,44 @@
export enum Locales {
AR = "ar",
CA = "ca",
DE = "de",
ES = "es",
EU = "eu",
HE = "he",
ID = "id",
JA = "ja",
LV = "lv",
PL = "pl",
RO = "ro",
SR = "sr",
TH = "th",
VI = "vi",
AZ = "az",
CS = "cs",
EL = "el",
ES_419 = "es-419",
FI = "fi",
HR = "hr",
IT = "it",
KM = "km",
NL = "nl",
PT = "pt",
RU = "ru",
SV = "sv",
TR = "tr",
ZH_CN = "zh-CN",
BG = "bg",
DA = "da",
EN = "en",
ET = "et",
FR = "fr",
HU = "hu",
IW = "iw",
KO = "ko",
NO = "no",
PT_BR = "pt-BR",
SK = "sk",
TA = "ta",
UK = "uk",
ZH_TW = "zh-TW",
}
@@ -6,6 +6,7 @@ import { NextAuthStrategy } from "@/modules/auth/strategies/next-auth/next-auth.
import { DeploymentsModule } from "@/modules/deployments/deployments.module";
import { MembershipsModule } from "@/modules/memberships/memberships.module";
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
import { ProfilesModule } from "@/modules/profiles/profiles.module";
import { RedisModule } from "@/modules/redis/redis.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
@@ -21,6 +22,7 @@ import { PassportModule } from "@nestjs/passport";
MembershipsModule,
TokensModule,
DeploymentsModule,
ProfilesModule,
],
providers: [NextAuthGuard, NextAuthStrategy, ApiAuthGuard, ApiAuthStrategy, OAuthFlowService],
exports: [NextAuthGuard, ApiAuthGuard],
@@ -0,0 +1,4 @@
import type { PlatformPlanType } from "@/modules/billing/types";
import { Reflector } from "@nestjs/core";
export const PlatformPlan = Reflector.createDecorator<PlatformPlanType>();
@@ -0,0 +1,33 @@
import { ExecutionContext } from "@nestjs/common";
import { createParamDecorator } from "@nestjs/common";
import { Membership } from "@calcom/prisma/client";
export type GetMembershipReturnType = Membership;
export const GetMembership = createParamDecorator<
keyof GetMembershipReturnType | (keyof GetMembershipReturnType)[],
ExecutionContext
>((data, ctx) => {
const request = ctx.switchToHttp().getRequest();
const membership = request.membership as GetMembershipReturnType;
if (!membership) {
throw new Error("GetMembership decorator : Membership not found");
}
if (Array.isArray(data)) {
return data.reduce((prev, curr) => {
return {
...prev,
[curr]: membership[curr],
};
}, {});
}
if (data) {
return membership[data];
}
return membership;
});
@@ -0,0 +1,123 @@
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
import { RedisService } from "@/modules/redis/redis.service";
import { createMock } from "@golevelup/ts-jest";
import { ExecutionContext } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
describe("PlatformPlanGuard", () => {
let guard: PlatformPlanGuard;
let reflector: Reflector;
let organizationsRepository: OrganizationsRepository;
let redisService: RedisService;
const mockContext = createMockExecutionContext({
params: { teamId: "1", orgId: "1" },
user: { id: "1" },
});
beforeEach(async () => {
reflector = new Reflector();
organizationsRepository = createMock<OrganizationsRepository>();
redisService = createMock<RedisService>({
redis: {
get: jest.fn().mockResolvedValue(null),
set: jest.fn().mockResolvedValue(null),
},
});
guard = new PlatformPlanGuard(reflector, organizationsRepository, redisService);
});
it("should be defined", () => {
expect(guard).toBeDefined();
});
it("should return true", async () => {
jest.spyOn(reflector, "get").mockReturnValue("ESSENTIALS");
jest.spyOn(organizationsRepository, "findByIdIncludeBilling").mockResolvedValue({
isPlatform: true,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
platformBilling: {
subscriptionId: "sub_123",
plan: "SCALE",
},
});
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
});
it("should return false if the organization does not exist", async () => {
jest.spyOn(reflector, "get").mockReturnValue("ESSENTIALS");
jest.spyOn(organizationsRepository, "findByIdIncludeBilling").mockResolvedValue(null);
await expect(guard.canActivate(mockContext)).resolves.toBe(false);
});
it("should return true if the organization is not platform", async () => {
jest.spyOn(reflector, "get").mockReturnValue("ESSENTIALS");
jest.spyOn(organizationsRepository, "findByIdIncludeBilling").mockResolvedValue({
isPlatform: false,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
platformBilling: undefined,
});
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
});
it("should return false if the organization has no subscription", async () => {
jest.spyOn(reflector, "get").mockReturnValue("ESSENTIALS");
jest.spyOn(organizationsRepository, "findByIdIncludeBilling").mockResolvedValue({
isPlatform: true,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
platformBilling: {
subscriptionId: null,
plan: "STARTER",
},
});
await expect(guard.canActivate(mockContext)).resolves.toBe(false);
});
it("should return false if the user has a lower plan than required", async () => {
jest.spyOn(reflector, "get").mockReturnValue("ESSENTIALS");
jest.spyOn(organizationsRepository, "findByIdIncludeBilling").mockResolvedValue({
isPlatform: true,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
platformBilling: {
subscriptionId: "sub_123",
plan: "STARTER",
},
});
await expect(guard.canActivate(mockContext)).resolves.toBe(false);
});
it("should return true if the result is cached in Redis", async () => {
jest.spyOn(reflector, "get").mockReturnValue("ESSENTIALS");
jest.spyOn(redisService.redis, "get").mockResolvedValue(JSON.stringify(true));
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
});
it("should return false if the result is cached in Redis", async () => {
jest.spyOn(reflector, "get").mockReturnValue("ESSENTIALS");
jest.spyOn(redisService.redis, "get").mockResolvedValue(JSON.stringify(false));
await expect(guard.canActivate(mockContext)).resolves.toBe(false);
});
function createMockExecutionContext(context: Record<string, string | object>): ExecutionContext {
return createMock<ExecutionContext>({
switchToHttp: () => ({
getRequest: () => ({
params: context.params,
user: context.user,
}),
}),
});
}
});
@@ -0,0 +1,79 @@
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
import { GetUserReturnType } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { PlatformPlanType } from "@/modules/billing/types";
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
import { RedisService } from "@/modules/redis/redis.service";
import { Injectable, CanActivate, ExecutionContext } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { Request } from "express";
@Injectable()
export class PlatformPlanGuard implements CanActivate {
constructor(
private reflector: Reflector,
private readonly organizationsRepository: OrganizationsRepository,
private readonly redisService: RedisService
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request>();
const teamId = request.params.teamId as string;
const orgId = request.params.orgId as string;
const user = request.user as GetUserReturnType;
const minimumPlan = this.reflector.get(PlatformPlan, context.getHandler()) as PlatformPlanType;
const REDIS_CACHE_KEY = `apiv2:user:${user?.id ?? "none"}:org:${orgId ?? "none"}:team:${
teamId ?? "none"
}:guard:platformbilling:${minimumPlan}`;
const cachedAccess = JSON.parse((await this.redisService.redis.get(REDIS_CACHE_KEY)) ?? "false");
if (cachedAccess) {
return cachedAccess;
}
let canAccess = false;
if (user && orgId) {
const team = await this.organizationsRepository.findByIdIncludeBilling(Number(orgId));
const isPlatform = team?.isPlatform;
const hasSubscription = team?.platformBilling?.subscriptionId;
if (!team) {
canAccess = false;
} else if (!isPlatform) {
canAccess = true;
} else if (!hasSubscription) {
canAccess = false;
} else {
canAccess = hasMinimumPlan({
currentPlan: team.platformBilling?.plan as PlatformPlanType,
minimumPlan: minimumPlan,
plans: ["STARTER", "ESSENTIALS", "SCALE", "ENTERPRISE"],
});
}
}
await this.redisService.redis.set(REDIS_CACHE_KEY, String(canAccess), "EX", 300);
return canAccess;
}
}
type HasMinimumPlanProp = {
currentPlan: PlatformPlanType;
minimumPlan: PlatformPlanType;
plans: PlatformPlanType[];
};
export function hasMinimumPlan(props: HasMinimumPlanProp): boolean {
const currentPlanIndex = props.plans.indexOf(props.currentPlan);
const minimumPlanIndex = props.plans.indexOf(props.minimumPlan);
if (currentPlanIndex === -1 || minimumPlanIndex === -1) {
throw new Error(
`Invalid platform billing plan provided. Current plan: ${props.currentPlan}, Minimum plan: ${props.minimumPlan}`
);
}
return currentPlanIndex >= minimumPlanIndex;
}
@@ -0,0 +1,42 @@
import { OrganizationsMembershipRepository } from "@/modules/organizations/repositories/organizations-membership.repository";
import {
Injectable,
CanActivate,
ExecutionContext,
ForbiddenException,
NotFoundException,
} from "@nestjs/common";
import { Request } from "express";
import { Membership } from "@calcom/prisma/client";
@Injectable()
export class IsMembershipInOrg implements CanActivate {
constructor(private organizationsMembershipRepository: OrganizationsMembershipRepository) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request & { membership: Membership }>();
const membershipId: string = request.params.membershipId;
const orgId: string = request.params.orgId;
if (!orgId) {
throw new ForbiddenException("No org id found in request params.");
}
if (!membershipId) {
throw new ForbiddenException("No membership id found in request params.");
}
const membership = await this.organizationsMembershipRepository.findOrgMembership(
Number(orgId),
Number(membershipId)
);
if (!membership) {
throw new NotFoundException(`Membership (${membershipId}) not found.`);
}
request.membership = membership;
return true;
}
}
@@ -1,14 +1,24 @@
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
import { RedisService } from "@/modules/redis/redis.service";
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common";
import { Request } from "express";
import { Team } from "@calcom/prisma/client";
type CachedData = {
org?: Team;
canAccess?: boolean;
};
@Injectable()
export class IsOrgGuard implements CanActivate {
constructor(private organizationsRepository: OrganizationsRepository) {}
constructor(
private organizationsRepository: OrganizationsRepository,
private readonly redisService: RedisService
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
let canAccess = false;
const request = context.switchToHttp().getRequest<Request & { organization: Team }>();
const organizationId: string = request.params.orgId;
@@ -16,13 +26,33 @@ export class IsOrgGuard implements CanActivate {
throw new ForbiddenException("No organization id found in request params.");
}
const org = await this.organizationsRepository.findById(Number(organizationId));
const REDIS_CACHE_KEY = `apiv2:org:${organizationId}:guard:isOrg`;
const cachedData = await this.redisService.redis.get(REDIS_CACHE_KEY);
if (org && org.isOrganization) {
request.organization = org;
return true;
if (cachedData) {
const { org: cachedOrg, canAccess: cachedCanAccess } = JSON.parse(cachedData) as CachedData;
if (cachedOrg?.id === Number(organizationId) && cachedCanAccess !== undefined) {
request.organization = cachedOrg;
return cachedCanAccess;
}
}
return false;
const org = await this.organizationsRepository.findById(Number(organizationId));
if (org?.isOrganization) {
request.organization = org;
canAccess = true;
}
if (org) {
await this.redisService.redis.set(
REDIS_CACHE_KEY,
JSON.stringify({ org: org, canAccess } satisfies CachedData),
"EX",
300
);
}
return canAccess;
}
}
@@ -14,7 +14,20 @@ describe("PermissionsGuard", () => {
beforeEach(async () => {
reflector = new Reflector();
guard = new PermissionsGuard(reflector, createMock<TokensRepository>(), createMock<ConfigService>());
guard = new PermissionsGuard(
reflector,
createMock<TokensRepository>(),
createMock<ConfigService>({
get: jest.fn().mockImplementation((key: string) => {
switch (key) {
case "api.apiKeyPrefix":
return "cal_";
default:
return null;
}
}),
})
);
});
it("should be defined", () => {
@@ -101,23 +101,23 @@ export class RolesGuard implements CanActivate {
// if the user is admin or owner of org, allow request because org > team
if (`ORG_${orgMembership.role}` === "ORG_ADMIN" || `ORG_${orgMembership.role}` === "ORG_OWNER") {
canAccess = true;
}
} else {
if (!teamMembership) {
this.logger.log(
`User (${user.id}) is not part of the team (${teamId}) and/or, is not an admin nor an owner of the organization (${orgId}).`
);
throw new ForbiddenException(
"User is not part of the team and/or, is not an admin nor an owner of the organization."
);
}
if (!teamMembership) {
this.logger.log(
`User (${user.id}) is not part of the team (${teamId}) and/or, is not an admin nor an owner of the organization (${orgId}).`
);
throw new ForbiddenException(
"User is not part of the team and/or, is not an admin nor an owner of the organization."
);
// if user is not admin nor an owner of org, and is part of the team, then check user team membership role
canAccess = hasMinimumRole({
checkRole: `TEAM_${teamMembership.role}`,
minimumRole: allowedRole,
roles: TEAM_ROLES,
});
}
// if user is not admin nor an owner of org, and is part of the team, then check user team membership role
canAccess = hasMinimumRole({
checkRole: `TEAM_${teamMembership.role}`,
minimumRole: allowedRole,
roles: TEAM_ROLES,
});
}
// if allowed role is a ORG ROLE, check org membersip role
@@ -129,7 +129,7 @@ export class RolesGuard implements CanActivate {
});
}
}
await this.redisService.redis.set(REDIS_CACHE_KEY, String(canAccess), "EX", 3600);
await this.redisService.redis.set(REDIS_CACHE_KEY, String(canAccess), "EX", 300);
return canAccess;
}
}
@@ -7,6 +7,7 @@ import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repo
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { ProfilesModule } from "@/modules/profiles/profiles.module";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { UsersRepository } from "@/modules/users/users.repository";
import { ExecutionContext, HttpException } from "@nestjs/common";
@@ -17,11 +18,14 @@ import { Test, TestingModule } from "@nestjs/testing";
import { PlatformOAuthClient, Team, User } from "@prisma/client";
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { TokensRepositoryFixture } from "test/fixtures/repository/tokens.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { MockedRedisService } from "test/mocks/mock-redis-service";
import { X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
import { ApiAuthStrategy } from "./api-auth.strategy";
describe("ApiAuthStrategy", () => {
@@ -33,19 +37,26 @@ describe("ApiAuthStrategy", () => {
let oAuthClient: PlatformOAuthClient;
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
let oAuthClientRepositoryFixture: OAuthClientRepositoryFixture;
let profilesRepositoryFixture: ProfileRepositoryFixture;
const validApiKeyEmail = "api-key-user-email@example.com";
const validAccessTokenEmail = "access-token-user-email@example.com";
const validOAuthEmail = "oauth-user@example.com";
let validApiKeyUser: User;
let validAccessTokenUser: User;
let validOAuthUser: User;
let module: TestingModule;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
beforeAll(async () => {
module = await Test.createTestingModule({
imports: [
ConfigModule.forRoot({
ignoreEnvFile: true,
isGlobal: true,
load: [appConfig],
}),
ProfilesModule,
],
providers: [
MockedRedisService,
@@ -71,6 +82,7 @@ describe("ApiAuthStrategy", () => {
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(module);
teamRepositoryFixture = new TeamRepositoryFixture(module);
oAuthClientRepositoryFixture = new OAuthClientRepositoryFixture(module);
profilesRepositoryFixture = new ProfileRepositoryFixture(module);
organization = await teamRepositoryFixture.create({ name: "organization" });
validApiKeyUser = await userRepositoryFixture.create({
email: validApiKeyEmail,
@@ -78,6 +90,18 @@ describe("ApiAuthStrategy", () => {
validAccessTokenUser = await userRepositoryFixture.create({
email: validAccessTokenEmail,
});
validOAuthUser = await userRepositoryFixture.create({
email: validOAuthEmail,
});
await profilesRepositoryFixture.create({
uid: "asd-asd",
username: validOAuthEmail,
user: { connect: { id: validOAuthUser.id } },
organization: { connect: { id: organization.id } },
});
const data = {
logo: "logo-url",
name: "name",
@@ -89,30 +113,14 @@ describe("ApiAuthStrategy", () => {
describe("authenticate with strategy", () => {
it("should return user associated with valid access token", async () => {
console.log("HERERERER");
const { accessToken } = await tokensRepositoryFixture.createTokens(
validAccessTokenUser.id,
oAuthClient.id
);
console.log("Access token", accessToken);
const context: ExecutionContext = {
switchToHttp: () => ({
getRequest: () => ({
headers: {
authorization: `Bearer ${accessToken}`,
},
get: (key: string) =>
({ Authorization: `Bearer ${accessToken}`, origin: "http://localhost:3000" }[key]),
}),
}),
} as ExecutionContext;
const request = context.switchToHttp().getRequest();
const user = await strategy.authenticate(request);
await expect(user.id).toEqual(validAccessTokenUser.id);
const user = await strategy.accessTokenStrategy(accessToken);
expect(user).toBeDefined();
expect(user?.id).toEqual(validAccessTokenUser.id);
});
it("should return user associated with valid api key", async () => {
@@ -120,22 +128,15 @@ describe("ApiAuthStrategy", () => {
now.setDate(now.getDate() + 1);
const { keyString } = await apiKeysRepositoryFixture.createApiKey(validApiKeyUser.id, now);
const context: ExecutionContext = {
switchToHttp: () => ({
getRequest: () => ({
headers: {
authorization: `Bearer cal_test_${keyString}`,
},
get: (key: string) =>
({ Authorization: `Bearer cal_test_${keyString}`, origin: "http://localhost:3000" }[key]),
}),
}),
} as ExecutionContext;
const request = context.switchToHttp().getRequest();
const user = await strategy.apiKeyStrategy(keyString);
expect(user).toBeDefined();
expect(user?.id).toEqual(validApiKeyUser.id);
});
const user = await strategy.authenticate(request);
expect(user.id).toEqual(validApiKeyUser.id);
it("should return user associated with valid OAuth client", async () => {
const user = await strategy.oAuthClientStrategy(oAuthClient.id, oAuthClient.secret);
expect(user).toBeDefined();
expect(user.id).toEqual(validOAuthUser.id);
});
it("should throw 401 if api key is invalid", async () => {
@@ -161,7 +162,55 @@ describe("ApiAuthStrategy", () => {
}
});
it("should throw 401 if Authorization header does not contain auth token", async () => {
it("should throw 401 if OAuth ID is invalid", async () => {
const context: ExecutionContext = {
switchToHttp: () => ({
getRequest: () => ({
headers: {
[X_CAL_CLIENT_ID]: `${oAuthClient.id}gibberish`,
[X_CAL_SECRET_KEY]: `secret`,
},
get: (key: string) =>
({ Authorization: `Bearer cal_test_badkey1234`, origin: "http://localhost:3000" }[key]),
}),
}),
} as ExecutionContext;
const request = context.switchToHttp().getRequest();
try {
await strategy.authenticate(request);
} catch (error) {
if (error instanceof HttpException) {
expect(error.getStatus()).toEqual(401);
}
}
});
it("should throw 401 if OAuth secret is invalid", async () => {
const context: ExecutionContext = {
switchToHttp: () => ({
getRequest: () => ({
headers: {
[X_CAL_CLIENT_ID]: `${oAuthClient.id}`,
[X_CAL_SECRET_KEY]: `gibberish`,
},
get: (key: string) =>
({ Authorization: `Bearer cal_test_badkey1234`, origin: "http://localhost:3000" }[key]),
}),
}),
} as ExecutionContext;
const request = context.switchToHttp().getRequest();
try {
await strategy.authenticate(request);
} catch (error) {
if (error instanceof HttpException) {
expect(error.getStatus()).toEqual(401);
}
}
});
it("should throw 401 if request does not contain Bearer token nor OAuth client credentials", async () => {
const context: ExecutionContext = {
switchToHttp: () => ({
getRequest: () => ({
@@ -180,4 +229,13 @@ describe("ApiAuthStrategy", () => {
}
});
});
afterAll(async () => {
await oAuthClientRepositoryFixture.delete(oAuthClient.id);
await userRepositoryFixture.delete(validApiKeyUser.id);
await userRepositoryFixture.delete(validAccessTokenUser.id);
await userRepositoryFixture.delete(validOAuthUser.id);
await teamRepositoryFixture.delete(organization.id);
module.close();
});
});
@@ -2,7 +2,9 @@ import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key";
import { BaseStrategy } from "@/lib/passport/strategies/types";
import { ApiKeyRepository } from "@/modules/api-key/api-key-repository";
import { DeploymentsService } from "@/modules/deployments/deployments.service";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
import { ProfilesRepository } from "@/modules/profiles/profiles.repository";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository";
import { Injectable, InternalServerErrorException, UnauthorizedException } from "@nestjs/common";
@@ -10,7 +12,7 @@ import { ConfigService } from "@nestjs/config";
import { PassportStrategy } from "@nestjs/passport";
import type { Request } from "express";
import { INVALID_ACCESS_TOKEN } from "@calcom/platform-constants";
import { INVALID_ACCESS_TOKEN, X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
@Injectable()
export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth") {
@@ -20,19 +22,74 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
private readonly oauthFlowService: OAuthFlowService,
private readonly tokensRepository: TokensRepository,
private readonly userRepository: UsersRepository,
private readonly apiKeyRepository: ApiKeyRepository
private readonly apiKeyRepository: ApiKeyRepository,
private readonly oauthRepository: OAuthClientRepository,
private readonly profilesRepository: ProfilesRepository
) {
super();
}
async authenticate(request: Request) {
const authString = request.get("Authorization")?.replace("Bearer ", "");
if (!authString) {
return this.error(new UnauthorizedException("No Authorization header provided"));
try {
const { params } = request;
const oAuthClientSecret = request.get(X_CAL_SECRET_KEY);
const oAuthClientId = params.clientId || request.get(X_CAL_CLIENT_ID);
const bearerToken = request.get("Authorization")?.replace("Bearer ", "");
if (oAuthClientId && oAuthClientSecret) {
return await this.authenticateOAuthClient(oAuthClientId, oAuthClientSecret);
}
if (bearerToken) {
const requestOrigin = request.get("Origin");
return await this.authenticateBearerToken(bearerToken, requestOrigin);
}
throw new UnauthorizedException(
"No authentication method provided. Either pass an API key as 'Bearer' header or OAuth client credentials as 'x-cal-secret-key' and 'x-cal-client-id' headers"
);
} catch (err) {
if (err instanceof Error) {
return this.error(err);
}
return this.error(
new InternalServerErrorException("An error occurred while authenticating the request")
);
}
}
async authenticateOAuthClient(oAuthClientId: string, oAuthClientSecret: string) {
const user = await this.oAuthClientStrategy(oAuthClientId, oAuthClientSecret);
return this.success(user);
}
async oAuthClientStrategy(oAuthClientId: string, oAuthClientSecret: string) {
const client = await this.oauthRepository.getOAuthClient(oAuthClientId);
if (!client) {
throw new UnauthorizedException(`Client with ID ${oAuthClientId} not found`);
}
const requestOrigin = request.get("Origin");
if (client.secret !== oAuthClientSecret) {
throw new UnauthorizedException("Invalid client secret");
}
const platformCreatorId = await this.profilesRepository.getPlatformOwnerUserId(client.organizationId);
if (!platformCreatorId) {
throw new UnauthorizedException("No owner ID found for this OAuth client");
}
const user = await this.userRepository.findByIdWithProfile(platformCreatorId);
if (!user) {
throw new UnauthorizedException("No user associated with the provided OAuth client");
}
return user;
}
async authenticateBearerToken(authString: string, requestOrigin: string | undefined) {
try {
const user = isApiKey(authString, this.config.get<string>("api.apiKeyPrefix") ?? "cal_")
? await this.apiKeyStrategy(authString)
@@ -1,3 +1,4 @@
import { BillingProcessor } from "@/modules/billing/billing.processor";
import { BillingRepository } from "@/modules/billing/billing.repository";
import { BillingController } from "@/modules/billing/controllers/billing.controller";
import { BillingConfigService } from "@/modules/billing/services/billing.config.service";
@@ -6,11 +7,24 @@ import { MembershipsModule } from "@/modules/memberships/memberships.module";
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { StripeModule } from "@/modules/stripe/stripe.module";
import { BullModule } from "@nestjs/bull";
import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule, StripeModule, MembershipsModule, OrganizationsModule],
providers: [BillingConfigService, BillingService, BillingRepository],
imports: [
PrismaModule,
StripeModule,
MembershipsModule,
OrganizationsModule,
BullModule.registerQueue({
name: "billing",
limiter: {
max: 1,
duration: 1000,
},
}),
],
providers: [BillingConfigService, BillingService, BillingRepository, BillingProcessor],
exports: [BillingService, BillingRepository],
controllers: [BillingController],
})
@@ -0,0 +1,91 @@
import { BillingRepository } from "@/modules/billing/billing.repository";
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
import { StripeService } from "@/modules/stripe/stripe.service";
import { Process, Processor } from "@nestjs/bull";
import { Logger } from "@nestjs/common";
import { Job } from "bull";
export const INCREMENT_JOB = "increment";
export const BILLING_QUEUE = "billing";
export type IncrementJobDataType = {
userId: number;
};
export type DecrementJobDataType = IncrementJobDataType;
@Processor(BILLING_QUEUE)
export class BillingProcessor {
private readonly logger = new Logger(BillingProcessor.name);
constructor(
public readonly stripeService: StripeService,
private readonly billingRepository: BillingRepository,
private readonly teamsRepository: OrganizationsRepository
) {}
@Process(INCREMENT_JOB)
async handleIncrement(job: Job<IncrementJobDataType>) {
const { userId } = job.data;
try {
// get the platform organization of the managed user
const team = await this.teamsRepository.findPlatformOrgFromUserId(userId);
const teamId = team.id;
if (!team.id) {
this.logger.error(`User (${userId}) is not part of the platform organization (${teamId}) `, {
teamId,
userId,
});
return;
}
const billingSubscription = await this.billingRepository.getBillingForTeam(teamId);
if (!billingSubscription || !billingSubscription?.subscriptionId) {
this.logger.error(`Team ${teamId} did not have stripe subscription associated to it`, {
teamId,
});
return;
}
const stripeSubscription = await this.stripeService.stripe.subscriptions.retrieve(
billingSubscription.subscriptionId
);
if (!stripeSubscription?.id) {
this.logger.error(`Failed to retrieve stripe subscription (${billingSubscription.subscriptionId})`, {
teamId,
subscriptionId: billingSubscription.subscriptionId,
});
return;
}
const meteredItem = stripeSubscription.items.data.find(
(item) => item.price?.recurring?.usage_type === "metered"
);
// no metered item found to increase usage, return early
if (!meteredItem) {
this.logger.error(`Stripe subscription (${stripeSubscription.id} is not usage based`, {
teamId,
subscriptionId: stripeSubscription.id,
});
return;
}
await this.stripeService.stripe.subscriptionItems.createUsageRecord(meteredItem.id, {
action: "increment",
quantity: 1,
timestamp: "now",
});
this.logger.log("Increased organization usage for subscription", {
subscriptionId: billingSubscription.subscriptionId,
teamId,
userId,
itemId: meteredItem.id,
});
} catch (err) {
this.logger.error("Failed to increase usage for Organization", {
userId,
err,
});
}
return;
}
}
@@ -3,21 +3,38 @@ import { Injectable } from "@nestjs/common";
@Injectable()
export class BillingConfigService {
private readonly config: Map<PlatformPlan, string>;
private readonly config: Map<
PlatformPlan,
{
base: string;
overage: string;
}
>;
constructor() {
this.config = new Map<PlatformPlan, string>();
this.config = new Map<
PlatformPlan,
{
base: string;
overage: string;
}
>();
const planKeys = Object.keys(PlatformPlan).filter((key) => isNaN(Number(key)));
for (const key of planKeys) {
this.config.set(
PlatformPlan[key.toUpperCase() as keyof typeof PlatformPlan],
process.env[`STRIPE_PRICE_ID_${key}`] ?? ""
);
this.config.set(PlatformPlan[key.toUpperCase() as keyof typeof PlatformPlan], {
base: process.env[`STRIPE_PRICE_ID_${key}`] ?? "",
overage: process.env[`STRIPE_PRICE_ID_${key}_OVERAGE`] ?? "",
});
}
}
get(plan: PlatformPlan): string | undefined {
get(plan: PlatformPlan):
| {
base: string;
overage: string;
}
| undefined {
return this.config.get(plan);
}
}
@@ -1,16 +1,19 @@
import { AppConfig } from "@/config/type";
import { BILLING_QUEUE, INCREMENT_JOB, IncrementJobDataType } from "@/modules/billing/billing.processor";
import { BillingRepository } from "@/modules/billing/billing.repository";
import { BillingConfigService } from "@/modules/billing/services/billing.config.service";
import { PlatformPlan } from "@/modules/billing/types";
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
import { StripeService } from "@/modules/stripe/stripe.service";
import { Injectable, InternalServerErrorException, Logger } from "@nestjs/common";
import { InjectQueue } from "@nestjs/bull";
import { Injectable, InternalServerErrorException, Logger, OnModuleDestroy } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Queue } from "bull";
import { DateTime } from "luxon";
import Stripe from "stripe";
@Injectable()
export class BillingService {
export class BillingService implements OnModuleDestroy {
private logger = new Logger("BillingService");
private readonly webAppUrl: string;
@@ -19,9 +22,10 @@ export class BillingService {
public readonly stripeService: StripeService,
private readonly billingRepository: BillingRepository,
private readonly configService: ConfigService<AppConfig>,
private readonly billingConfigService: BillingConfigService
private readonly billingConfigService: BillingConfigService,
@InjectQueue(BILLING_QUEUE) private readonly billingQueue: Queue
) {
this.webAppUrl = configService.get("app.baseUrl", { infer: true }) ?? "https://app.cal.com";
this.webAppUrl = this.configService.get("app.baseUrl", { infer: true }) ?? "https://app.cal.com";
}
async getBillingData(teamId: number) {
@@ -58,7 +62,10 @@ export class BillingService {
customer: customerId,
line_items: [
{
price: this.billingConfigService.get(plan),
price: this.billingConfigService.get(plan)?.overage,
},
{
price: this.billingConfigService.get(plan)?.base,
quantity: 1,
},
],
@@ -99,43 +106,56 @@ export class BillingService {
);
}
async increaseUsageForTeam(teamId: number) {
try {
const billingSubscription = await this.billingRepository.getBillingForTeam(teamId);
if (!billingSubscription || !billingSubscription?.subscriptionId) {
this.logger.error("Team did not have stripe subscription associated to it", {
teamId,
});
return void 0;
}
/**
*
* Adds a job to the queue to increment usage of a stripe subscription.
* we delay the job until the booking starts.
* the delay ensure we can adapt to cancel / reschedule.
*/
async increaseUsageByUserId(
userId: number,
booking: {
uid: string;
startTime: Date;
fromReschedule?: string | null;
}
) {
const { uid, startTime, fromReschedule } = booking;
const stripeSubscription = await this.stripeService.stripe.subscriptions.retrieve(
billingSubscription.subscriptionId
);
const item = stripeSubscription.items.data[0];
// legacy plans are licensed, we cannot create usage records against them
if (item.price?.recurring?.usage_type === "licensed") {
return void 0;
}
const delay = startTime.getTime() - Date.now();
if (fromReschedule) {
// cancel the usage increment job for the booking that is being rescheduled
await this.cancelUsageByBookingUid(fromReschedule);
this.logger.log(`Cancelled usage increment job for rescheduled booking uid: ${fromReschedule}`);
}
await this.billingQueue.add(
INCREMENT_JOB,
{
userId,
} satisfies IncrementJobDataType,
{ delay: delay > 0 ? delay : 0, jobId: `increment-${uid}`, removeOnComplete: true }
);
this.logger.log(`Added stripe usage increment job for booking ${uid} and user ${userId}`);
}
await this.stripeService.stripe.subscriptionItems.createUsageRecord(item.id, {
action: "increment",
quantity: 1,
timestamp: "now",
});
} catch (error) {
// don't fail the request, log it.
this.logger.error("Failed to increase usage for team", {
teamId: teamId,
error,
});
/**
*
* Cancels the usage increment job for a booking when it is cancelled.
* Removing an attendee from a booking does not cancel the usage increment job.
*/
async cancelUsageByBookingUid(bookingUid: string) {
const job = await this.billingQueue.getJob(`increment-${bookingUid}`);
if (job) {
await job.remove();
this.logger.log(`Removed increment job for cancelled booking ${bookingUid}`);
}
}
async increaseUsageByClientId(clientId: string) {
const team = await this.teamsRepository.findTeamIdFromClientId(clientId);
if (!team.id) return Promise.resolve(); // noop resolution.
return this.increaseUsageForTeam(team?.id);
async onModuleDestroy() {
try {
await this.billingQueue.close();
} catch (err) {
this.logger.error(err);
}
}
}
+2
View File
@@ -4,3 +4,5 @@ export enum PlatformPlan {
SCALE = "SCALE",
ENTERPRISE = "ENTERPRISE",
}
export type PlatformPlanType = "STARTER" | "ESSENTIALS" | "SCALE" | "ENTERPRISE";
@@ -0,0 +1,11 @@
import { Global, Module } from "@nestjs/common";
import { EmailService } from "./email.service";
@Global()
@Module({
imports: [],
providers: [EmailService],
exports: [EmailService],
})
export class EmailModule {}
@@ -0,0 +1,31 @@
import { Injectable } from "@nestjs/common";
import { sendSignupToOrganizationEmail, getTranslation } from "@calcom/platform-libraries-0.0.22";
@Injectable()
export class EmailService {
public async sendSignupToOrganizationEmail({
usernameOrEmail,
orgName,
orgId,
locale,
inviterName,
}: {
usernameOrEmail: string;
orgName: string;
orgId: number;
locale: string | null;
inviterName: string;
}) {
const translation = await getTranslation(locale || "en", "common");
await sendSignupToOrganizationEmail({
usernameOrEmail,
team: { name: orgName, parent: null },
inviterName: inviterName,
isOrg: true,
teamId: orgId,
translation,
});
}
}
@@ -1,6 +1,8 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { Injectable } from "@nestjs/common";
import { MembershipRole } from "@calcom/prisma/client";
@Injectable()
export class MembershipsRepository {
constructor(private readonly dbRead: PrismaReadService) {}
@@ -47,4 +49,17 @@ export class MembershipsRepository {
return !!adminMembership;
}
async createMembership(teamId: number, userId: number, role: MembershipRole, accepted: boolean) {
const membership = await this.dbRead.prisma.membership.create({
data: {
role,
teamId,
userId,
accepted,
},
});
return membership;
}
}
@@ -3,6 +3,7 @@ import { AppModule } from "@/app.module";
import { DEFAULT_EVENT_TYPES } from "@/ee/event-types/event-types_2024_04_15/constants/constants";
import { HttpExceptionFilter } from "@/filters/http-exception.filter";
import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter";
import { Locales } from "@/lib/enums/locales";
import {
CreateUserResponse,
UserReturned,
@@ -13,10 +14,11 @@ import { UsersModule } from "@/modules/users/users.module";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import { PlatformOAuthClient, Team, User } from "@prisma/client";
import { PlatformOAuthClient, Team, User, EventType } from "@prisma/client";
import * as request from "supertest";
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
import { SchedulesRepositoryFixture } from "test/fixtures/repository/schedules.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
@@ -77,9 +79,13 @@ describe("OAuth Client Users Endpoints", () => {
let teamRepositoryFixture: TeamRepositoryFixture;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let schedulesRepositoryFixture: SchedulesRepositoryFixture;
let profilesRepositoryFixture: ProfileRepositoryFixture;
let postResponseData: CreateUserResponse;
const platformAdminEmail = "platform-sensei@mail.com";
let platformAdmin: User;
const userEmail = "oauth-client-user@gmail.com";
const userTimeZone = "Europe/Rome";
@@ -97,9 +103,20 @@ describe("OAuth Client Users Endpoints", () => {
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
schedulesRepositoryFixture = new SchedulesRepositoryFixture(moduleRef);
profilesRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
platformAdmin = await userRepositoryFixture.create({ email: platformAdminEmail });
organization = await teamRepositoryFixture.create({ name: "organization" });
oAuthClient = await createOAuthClient(organization.id);
await profilesRepositoryFixture.create({
uid: "asd-asd",
username: userEmail,
user: { connect: { id: platformAdmin.id } },
organization: { connect: { id: organization.id } },
});
await app.init();
});
@@ -141,6 +158,7 @@ describe("OAuth Client Users Endpoints", () => {
timeZone: userTimeZone,
weekStart: "Monday",
timeFormat: 24,
locale: Locales.FR,
};
const response = await request(app.getHttpServer())
@@ -163,6 +181,7 @@ describe("OAuth Client Users Endpoints", () => {
expect(responseBody.data.user.timeZone).toEqual(requestBody.timeZone);
expect(responseBody.data.user.weekStart).toEqual(requestBody.weekStart);
expect(responseBody.data.user.timeFormat).toEqual(requestBody.timeFormat);
expect(responseBody.data.user.locale).toEqual(requestBody.locale);
expect(responseBody.data.accessToken).toBeDefined();
expect(responseBody.data.refreshToken).toBeDefined();
@@ -185,10 +204,16 @@ describe("OAuth Client Users Endpoints", () => {
// note(Lauris): to determine count see default event types created in EventTypesService.createUserDefaultEventTypes
expect(defaultEventTypes?.length).toEqual(4);
expect(
defaultEventTypes?.find((eventType) => eventType.length === DEFAULT_EVENT_TYPES.thirtyMinutes.length)
defaultEventTypes?.find((eventType) => eventType.slug === DEFAULT_EVENT_TYPES.thirtyMinutes.slug)
).toBeTruthy();
expect(
defaultEventTypes?.find((eventType) => eventType.length === DEFAULT_EVENT_TYPES.sixtyMinutes.length)
defaultEventTypes?.find((eventType) => eventType.slug === DEFAULT_EVENT_TYPES.sixtyMinutes.slug)
).toBeTruthy();
expect(
defaultEventTypes?.find((eventType) => eventType.slug === DEFAULT_EVENT_TYPES.thirtyMinutesVideo.slug)
).toBeTruthy();
expect(
defaultEventTypes?.find((eventType) => eventType.slug === DEFAULT_EVENT_TYPES.sixtyMinutesVideo.slug)
).toBeTruthy();
}
@@ -234,7 +259,7 @@ describe("OAuth Client Users Endpoints", () => {
it(`/PUT/:id`, async () => {
const userUpdatedEmail = "pineapple-pizza@gmail.com";
const body: UpdateManagedUserInput = { email: userUpdatedEmail };
const body: UpdateManagedUserInput = { email: userUpdatedEmail, locale: Locales.PT_BR };
const response = await request(app.getHttpServer())
.patch(`/api/v2/oauth-clients/${oAuthClient.id}/users/${postResponseData.user.id}`)
@@ -248,6 +273,7 @@ describe("OAuth Client Users Endpoints", () => {
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.email).toEqual(getOAuthUserEmail(oAuthClient.id, userUpdatedEmail));
expect(responseBody.data.locale).toEqual(Locales.PT_BR);
});
it(`/DELETE/:id`, () => {
@@ -273,6 +299,250 @@ describe("OAuth Client Users Endpoints", () => {
} catch (e) {
// User might have been deleted by the test
}
try {
await userRepositoryFixture.delete(platformAdmin.id);
} catch (e) {
// User might have been deleted by the test
}
await app.close();
});
});
describe("User team even-types", () => {
let app: INestApplication;
let oAuthClient1: PlatformOAuthClient;
let oAuthClient2: PlatformOAuthClient;
let organization: Team;
let team1: Team;
let team2: Team;
let owner: User;
let managedEventType1: EventType;
let userRepositoryFixture: UserRepositoryFixture;
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
let teamRepositoryFixture: TeamRepositoryFixture;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let profileRepositoryFixture: ProfileRepositoryFixture;
let postResponseData: CreateUserResponse;
const userEmail = "oauth-client-users-user@gmail.com";
const userTimeZone = "Europe/Rome";
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
providers: [PrismaExceptionFilter, HttpExceptionFilter],
imports: [AppModule, UsersModule],
}).compile();
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
organization = await teamRepositoryFixture.create({
name: "Testy Organization",
isOrganization: true,
});
owner = await userRepositoryFixture.create({
email: userEmail,
username: userEmail,
organization: { connect: { id: organization.id } },
});
await profileRepositoryFixture.create({
uid: `usr-${owner.id}`,
username: userEmail,
organization: {
connect: {
id: organization.id,
},
},
user: {
connect: {
id: owner.id,
},
},
});
oAuthClient1 = await createOAuthClient(organization.id);
oAuthClient2 = await createOAuthClient(organization.id);
team1 = await teamRepositoryFixture.create({
name: "Testy org team",
isOrganization: false,
parent: { connect: { id: organization.id } },
createdByOAuthClient: { connect: { id: oAuthClient1.id } },
});
team2 = await teamRepositoryFixture.create({
name: "Testy org team 2",
isOrganization: false,
parent: { connect: { id: organization.id } },
createdByOAuthClient: { connect: { id: oAuthClient2.id } },
});
// note(Lauris): team1 team event-types
await eventTypesRepositoryFixture.createTeamEventType({
schedulingType: "COLLECTIVE",
team: {
connect: { id: team1.id },
},
title: "Collective Event Type",
slug: "collective-event-type",
length: 30,
assignAllTeamMembers: true,
bookingFields: [],
locations: [],
});
managedEventType1 = await eventTypesRepositoryFixture.createTeamEventType({
schedulingType: "MANAGED",
team: {
connect: { id: team1.id },
},
title: "Managed Event Type",
slug: "managed-event-type",
length: 60,
assignAllTeamMembers: true,
bookingFields: [],
locations: [],
});
// note(Lauris): team2 team event-types
await eventTypesRepositoryFixture.createTeamEventType({
schedulingType: "COLLECTIVE",
team: {
connect: { id: team2.id },
},
title: "Collective Event Type team 2",
slug: "collective-event-type-team-2",
length: 30,
assignAllTeamMembers: true,
bookingFields: [],
locations: [],
});
await eventTypesRepositoryFixture.createTeamEventType({
schedulingType: "MANAGED",
team: {
connect: { id: team2.id },
},
title: "Managed Event Type team 2",
slug: "managed-event-type-team-2",
length: 60,
assignAllTeamMembers: true,
bookingFields: [],
locations: [],
});
await app.init();
});
async function createOAuthClient(organizationId: number) {
const data = {
logo: "logo-url",
name: "name",
redirectUris: [CLIENT_REDIRECT_URI],
permissions: 32,
};
const secret = "secret";
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
return client;
}
it("should be defined", () => {
expect(oauthClientRepositoryFixture).toBeDefined();
expect(userRepositoryFixture).toBeDefined();
expect(oAuthClient1).toBeDefined();
});
it(`should create managed user and update team event-types of OAuthClient marked as assignAllTeamMembers: true`, async () => {
const requestBody: CreateManagedUserInput = {
email: userEmail,
timeZone: userTimeZone,
weekStart: "Monday",
timeFormat: 24,
locale: Locales.FR,
};
const response = await request(app.getHttpServer())
.post(`/api/v2/oauth-clients/${oAuthClient1.id}/users`)
.set("x-cal-secret-key", oAuthClient1.secret)
.send(requestBody)
.expect(201);
const responseBody: ApiSuccessResponse<{
user: Omit<User, "password">;
accessToken: string;
refreshToken: string;
}> = response.body;
postResponseData = responseBody.data;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
await userHasCorrectEventTypes(responseBody.data.user.id);
await teamHasCorrectEventTypes(team1.id);
});
async function userHasCorrectEventTypes(userId: number) {
const eventTypes = await eventTypesRepositoryFixture.getAllUserEventTypes(userId);
expect(eventTypes?.length).toEqual(5);
// note(Lauris): managed event-types with assignAllTeamMembers: true
expect(eventTypes?.find((eventType) => eventType.slug === managedEventType1.slug)).toBeTruthy();
// note(Lauris): default event types
expect(
eventTypes?.find((eventType) => eventType.slug === DEFAULT_EVENT_TYPES.thirtyMinutes.slug)
).toBeTruthy();
expect(
eventTypes?.find((eventType) => eventType.slug === DEFAULT_EVENT_TYPES.sixtyMinutes.slug)
).toBeTruthy();
expect(
eventTypes?.find((eventType) => eventType.slug === DEFAULT_EVENT_TYPES.thirtyMinutesVideo.slug)
).toBeTruthy();
expect(
eventTypes?.find((eventType) => eventType.slug === DEFAULT_EVENT_TYPES.sixtyMinutesVideo.slug)
).toBeTruthy();
}
async function teamHasCorrectEventTypes(teamId: number) {
const eventTypes = await eventTypesRepositoryFixture.getAllTeamEventTypes(teamId);
expect(eventTypes?.length).toEqual(2);
// note(Lauris): managed event-types with assignAllTeamMembers: true
expect(eventTypes?.find((eventType) => eventType.slug === managedEventType1.slug)).toBeTruthy();
// note(Lauris): check if managed user added to collective event-type hosts given that it has assignAllTeamMembers: true
const collective = eventTypes?.find((eventType) => eventType.schedulingType === "COLLECTIVE");
expect(collective).toBeTruthy();
expect(collective?.hosts).toBeDefined();
expect(collective?.hosts?.length).toEqual(1);
expect(collective?.hosts[0].userId).toEqual(postResponseData.user.id);
}
afterAll(async () => {
await oauthClientRepositoryFixture.delete(oAuthClient1.id);
await teamRepositoryFixture.delete(organization.id);
await userRepositoryFixture.delete(owner.id);
try {
await userRepositoryFixture.delete(postResponseData.user.id);
} catch (e) {
// User might have been deleted by the test
}
await app.close();
});
});
@@ -1,10 +1,11 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { Locales } from "@/lib/enums/locales";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { CreateManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/create-managed-user.output";
import { GetManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/get-managed-user.output";
import { GetManagedUsersOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/get-managed-users.output";
import { ManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/managed-user.output";
import { KeysResponseDto } from "@/modules/oauth-clients/controllers/oauth-flow/responses/KeysResponse.dto";
import { OAuthClientCredentialsGuard } from "@/modules/oauth-clients/guards/oauth-client-credentials/oauth-client-credentials.guard";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
@@ -36,7 +37,7 @@ import { Pagination } from "@calcom/platform-types";
path: "/v2/oauth-clients/:clientId/users",
version: API_VERSIONS_VALUES,
})
@UseGuards(OAuthClientCredentialsGuard)
@UseGuards(ApiAuthGuard)
@DocsTags("Managed users")
export class OAuthClientUsersController {
private readonly logger = new Logger("UserController");
@@ -77,6 +78,7 @@ export class OAuthClientUsersController {
`Creating user with data: ${JSON.stringify(body, null, 2)} for OAuth Client with ID ${oAuthClientId}`
);
const client = await this.oauthRepository.getOAuthClient(oAuthClientId);
console.log("asap createUser client", JSON.stringify(client, null, 2));
const isPlatformManaged = true;
const { user, tokens } = await this.oAuthClientUsersService.createOauthClientUser(
@@ -191,6 +193,7 @@ export class OAuthClientUsersController {
createdDate: user.createdDate,
timeFormat: user.timeFormat,
defaultScheduleId: user.defaultScheduleId,
locale: user.locale as Locales,
};
}
}
@@ -1,5 +1,7 @@
import { Locales } from "@/lib/enums/locales";
import { ApiProperty } from "@nestjs/swagger";
import { Transform } from "class-transformer";
import { IsEnum, IsOptional } from "class-validator";
export class ManagedUserOutput {
@ApiProperty({ example: 1 })
@@ -26,4 +28,9 @@ export class ManagedUserOutput {
@ApiProperty({ example: null })
defaultScheduleId!: number | null;
@IsEnum(Locales)
@IsOptional()
@ApiProperty({ example: Locales.EN, enum: Locales })
locale?: Locales;
}
@@ -13,6 +13,7 @@ import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import { Membership, PlatformOAuthClient, Team, User } from "@prisma/client";
import * as request from "supertest";
import { PlatformBillingRepositoryFixture } from "test/fixtures/repository/billing.repository.fixture";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
@@ -62,6 +63,8 @@ describe("OAuth Clients Endpoints", () => {
let usersFixtures: UserRepositoryFixture;
let membershipFixtures: MembershipRepositoryFixture;
let teamFixtures: TeamRepositoryFixture;
let platformBillingRepositoryFixture: PlatformBillingRepositoryFixture;
let user: User;
let org: Team;
let app: INestApplication;
@@ -80,11 +83,14 @@ describe("OAuth Clients Endpoints", () => {
usersFixtures = new UserRepositoryFixture(moduleRef);
membershipFixtures = new MembershipRepositoryFixture(moduleRef);
teamFixtures = new TeamRepositoryFixture(moduleRef);
platformBillingRepositoryFixture = new PlatformBillingRepositoryFixture(moduleRef);
user = await usersFixtures.create({
email: userEmail,
});
org = await teamFixtures.create({
name: "apiOrg",
isOrganization: true,
metadata: {
isOrganization: true,
orgAutoAcceptEmail: "api.com",
@@ -94,6 +100,7 @@ describe("OAuth Clients Endpoints", () => {
isPlatform: false,
});
await membershipFixtures.addUserToOrg(user, org, "ADMIN", true);
await platformBillingRepositoryFixture.create(org.id);
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
@@ -114,6 +121,8 @@ describe("OAuth Clients Endpoints", () => {
let usersFixtures: UserRepositoryFixture;
let membershipFixtures: MembershipRepositoryFixture;
let teamFixtures: TeamRepositoryFixture;
let platformBillingRepositoryFixture: PlatformBillingRepositoryFixture;
let user: User;
let org: Team;
let app: INestApplication;
@@ -132,11 +141,14 @@ describe("OAuth Clients Endpoints", () => {
usersFixtures = new UserRepositoryFixture(moduleRef);
membershipFixtures = new MembershipRepositoryFixture(moduleRef);
teamFixtures = new TeamRepositoryFixture(moduleRef);
platformBillingRepositoryFixture = new PlatformBillingRepositoryFixture(moduleRef);
user = await usersFixtures.create({
email: userEmail,
});
org = await teamFixtures.create({
name: "apiOrg",
isOrganization: true,
metadata: {
isOrganization: true,
orgAutoAcceptEmail: "api.com",
@@ -145,12 +157,13 @@ describe("OAuth Clients Endpoints", () => {
},
isPlatform: true,
});
await platformBillingRepositoryFixture.create(org.id);
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
describe("User is not in an organization", () => {
describe("User is not part of an organization", () => {
it(`/GET`, () => {
return request(app.getHttpServer()).get("/api/v2/oauth-clients").expect(403);
});
@@ -358,6 +371,7 @@ describe("OAuth Clients Endpoints", () => {
afterAll(async () => {
await teamFixtures.delete(org.id);
await usersFixtures.delete(user.id);
await platformBillingRepositoryFixture.deleteSubscriptionForTeam(org.id);
await app.close();
});
});
@@ -15,6 +15,7 @@ import { Test, TestingModule } from "@nestjs/testing";
import { PlatformOAuthClient, Team, User } from "@prisma/client";
import * as request from "supertest";
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { withNextAuth } from "test/utils/withNextAuth";
@@ -58,6 +59,7 @@ describe("OAuthFlow Endpoints", () => {
let usersRepositoryFixtures: UserRepositoryFixture;
let organizationsRepositoryFixture: TeamRepositoryFixture;
let oAuthClientsRepositoryFixture: OAuthClientRepositoryFixture;
let profilesRepositoryFixture: ProfileRepositoryFixture;
let user: User;
let organization: Team;
@@ -83,11 +85,19 @@ describe("OAuthFlow Endpoints", () => {
oAuthClientsRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
usersRepositoryFixtures = new UserRepositoryFixture(moduleRef);
profilesRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
user = await usersRepositoryFixtures.create({
email: userEmail,
});
organization = await organizationsRepositoryFixture.create({ name: "organization" });
await profilesRepositoryFixture.create({
uid: "asd-asd",
username: userEmail,
user: { connect: { id: user.id } },
organization: { connect: { id: organization.id } },
});
oAuthClient = await createOAuthClient(organization.id);
});
@@ -113,7 +123,7 @@ describe("OAuthFlow Endpoints", () => {
const REDIRECT_STATUS = 302;
const response = await request(app.getHttpServer())
.post(`/oauth/${oAuthClient.id}/authorize`)
.post(`/v2/oauth/${oAuthClient.id}/authorize`)
.send(body)
.expect(REDIRECT_STATUS);
@@ -133,7 +143,7 @@ describe("OAuthFlow Endpoints", () => {
};
const response = await request(app.getHttpServer())
.post(`/oauth/${oAuthClient.id}/exchange`)
.post(`/v2/oauth/${oAuthClient.id}/exchange`)
.set("Authorization", authorizationToken)
.send(body)
.expect(200);
@@ -153,7 +163,7 @@ describe("OAuthFlow Endpoints", () => {
};
return request(app.getHttpServer())
.post(`/oauth/${oAuthClient.id}/refresh`)
.post(`/v2/oauth/${oAuthClient.id}/refresh`)
.set("x-cal-secret-key", secretKey)
.send(body)
.expect(200)
@@ -1,9 +1,9 @@
import { getEnv } from "@/env";
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { NextAuthGuard } from "@/modules/auth/guards/next-auth/next-auth.guard";
import { KeysResponseDto } from "@/modules/oauth-clients/controllers/oauth-flow/responses/KeysResponse.dto";
import { OAuthClientCredentialsGuard } from "@/modules/oauth-clients/guards/oauth-client-credentials/oauth-client-credentials.guard";
import { OAuthAuthorizeInput } from "@/modules/oauth-clients/inputs/authorize.input";
import { ExchangeAuthorizationCodeInput } from "@/modules/oauth-clients/inputs/exchange-code.input";
import { RefreshTokenInput } from "@/modules/oauth-clients/inputs/refresh-token.input";
@@ -137,7 +137,7 @@ export class OAuthFlowController {
@Post("/refresh")
@HttpCode(HttpStatus.OK)
@UseGuards(OAuthClientCredentialsGuard)
@UseGuards(ApiAuthGuard)
async refreshAccessToken(
@Param("clientId") clientId: string,
@Headers(X_CAL_SECRET_KEY) secretKey: string,
@@ -1,94 +0,0 @@
import { AppModule } from "@/app.module";
import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module";
import { createMock } from "@golevelup/ts-jest";
import { ExecutionContext, UnauthorizedException } from "@nestjs/common";
import { Test, TestingModule } from "@nestjs/testing";
import { PlatformOAuthClient, Team } from "@prisma/client";
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { X_CAL_SECRET_KEY } from "@calcom/platform-constants";
import { OAuthClientCredentialsGuard } from "./oauth-client-credentials.guard";
describe("OAuthClientCredentialsGuard", () => {
let guard: OAuthClientCredentialsGuard;
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
let teamRepositoryFixture: TeamRepositoryFixture;
let oauthClient: PlatformOAuthClient;
let organization: Team;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [AppModule, OAuthClientModule],
}).compile();
guard = module.get<OAuthClientCredentialsGuard>(OAuthClientCredentialsGuard);
teamRepositoryFixture = new TeamRepositoryFixture(module);
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(module);
organization = await teamRepositoryFixture.create({ name: "organization" });
const data = {
logo: "logo-url",
name: "name",
redirectUris: ["redirect-uri"],
permissions: 32,
};
const secret = "secret";
oauthClient = await oauthClientRepositoryFixture.create(organization.id, data, secret);
});
it("should be defined", () => {
expect(guard).toBeDefined();
expect(oauthClient).toBeDefined();
});
it("should return true if client ID and secret are valid", async () => {
const mockContext = createMockExecutionContext(
{ [X_CAL_SECRET_KEY]: oauthClient.secret },
{ clientId: oauthClient.id }
);
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
});
it("should return false if client ID is invalid", async () => {
const mockContext = createMockExecutionContext(
{ [X_CAL_SECRET_KEY]: oauthClient.secret },
{ clientId: "invalid id" }
);
await expect(guard.canActivate(mockContext)).rejects.toThrow(UnauthorizedException);
});
it("should return false if secret key is invalid", async () => {
const mockContext = createMockExecutionContext(
{ [X_CAL_SECRET_KEY]: "invalid secret" },
{ clientId: oauthClient.id }
);
await expect(guard.canActivate(mockContext)).rejects.toThrow(UnauthorizedException);
});
afterAll(async () => {
await oauthClientRepositoryFixture.delete(oauthClient.id);
await teamRepositoryFixture.delete(organization.id);
});
function createMockExecutionContext(
headers: Record<string, string>,
params: Record<string, string>
): ExecutionContext {
return createMock<ExecutionContext>({
switchToHttp: () => ({
getRequest: () => ({
headers,
params,
get: (headerName: string) => headers[headerName],
}),
}),
});
}
});
@@ -1,33 +0,0 @@
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from "@nestjs/common";
import { Request } from "express";
import { X_CAL_SECRET_KEY } from "@calcom/platform-constants";
@Injectable()
export class OAuthClientCredentialsGuard implements CanActivate {
constructor(private readonly oauthRepository: OAuthClientRepository) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request>();
const { params } = request;
const oauthClientId = params.clientId;
const oauthClientSecret = request.get(X_CAL_SECRET_KEY);
if (!oauthClientId) {
throw new UnauthorizedException("Missing client ID");
}
if (!oauthClientSecret) {
throw new UnauthorizedException("Missing client secret");
}
const client = await this.oauthRepository.getOAuthClient(oauthClientId);
if (!client || client.secret !== oauthClientSecret) {
throw new UnauthorizedException("Invalid client credentials");
}
return true;
}
}
@@ -6,11 +6,11 @@ import { MembershipsModule } from "@/modules/memberships/memberships.module";
import { OAuthClientUsersController } from "@/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller";
import { OAuthClientsController } from "@/modules/oauth-clients/controllers/oauth-clients/oauth-clients.controller";
import { OAuthFlowController } from "@/modules/oauth-clients/controllers/oauth-flow/oauth-flow.controller";
import { OAuthClientCredentialsGuard } from "@/modules/oauth-clients/guards/oauth-client-credentials/oauth-client-credentials.guard";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
import { OrganizationsTeamsService } from "@/modules/organizations/services/organizations-teams.service";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { RedisModule } from "@/modules/redis/redis.module";
import { StripeModule } from "@/modules/stripe/stripe.module";
@@ -36,12 +36,12 @@ import { Global, Module } from "@nestjs/common";
],
providers: [
OAuthClientRepository,
OAuthClientCredentialsGuard,
TokensRepository,
OAuthFlowService,
OAuthClientUsersService,
OrganizationsTeamsService,
],
controllers: [OAuthClientUsersController, OAuthClientsController, OAuthFlowController],
exports: [OAuthClientRepository, OAuthClientCredentialsGuard],
exports: [OAuthClientRepository],
})
export class OAuthClientModule {}

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