Compare commits

...
45 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
240 changed files with 7112 additions and 1969 deletions
+4
View File
@@ -18,6 +18,10 @@
# 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"
# ***********************************************************************************************************
+53 -38
View File
@@ -1,41 +1,56 @@
---
name: Feature request
description: "Suggest an idea for this project \U0001F680"
about: Suggest a feature or idea
title: ""
labels: ["✨ feature", "🚨 needs approval"],
assignees: []
body:
- type: textarea
id: problem-description
attributes:
label: Is your feature request related to a problem? Please describe.
description: A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
validations:
required: true
- type: textarea
id: solution-description
attributes:
label: Describe the solution you'd like
description: A clear and concise description of what you want to happen.
validations:
required: true
- type: textarea
id: alternate-solution-description
attributes:
label: Describe alternatives you've considered
description: A clear and concise description of any alternative solutions or features you've considered.
validations:
required: false
- type: textarea
id: additional-context
attributes:
label: Additional context
description: Add any other context or screenshots about the feature request here.
validations:
required: false
- type: markdown
id: cal-info
attributes:
value: |
### How we code at Cal.com
labels: ["✨ feature", "🚨 needs approval"]
assignees: ""
---
- Follow Best Practices lined out in our [Contributor Docs](https://github.com/calcom/cal.com/blob/main/CONTRIBUTING.md)
### Is your proposal related to a problem?
<!--
Provide a clear and concise description of what the problem is.
For example, "I'm always frustrated when..."
-->
(Write your answer here.)
### Describe the solution you'd like
<!--
Provide a clear and concise description of what you want to happen.
-->
(Describe your proposed solution here.)
### Describe alternatives you've considered
<!--
Let us know about other solutions you've tried or researched.
-->
(Write your answer here.)
### Additional context
<!--
Is there anything else you can add about the proposal?
You might want to link to related issues here, if you haven't already.
-->
(Write your answer here.)
### Requirement/Document
<!--
Is there any type of document that could support that feature?
-->
(Share it here.)
---
##### House rules
- 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()
@@ -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
@@ -67,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
+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:
+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.`,
};
}
+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", () => {
+1 -1
View File
@@ -26,7 +26,7 @@
"dependencies": {
"@calcom/platform-constants": "*",
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
"@calcom/platform-libraries-0.0.21": "npm:@calcom/platform-libraries@0.0.21",
"@calcom/platform-libraries-0.0.22": "npm:@calcom/platform-libraries@0.0.22",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
"@calcom/prisma": "*",
+11 -19
View File
@@ -14,8 +14,8 @@ 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";
@@ -29,15 +29,8 @@ import { AppController } from "./app.controller";
}),
RedisModule,
BullModule.forRootAsync({
imports: [RedisModule],
useFactory: async (redisService: RedisService) => ({
redis: {
host: redisService.redis.options.host,
port: redisService.redis.options.port,
},
}),
inject: [RedisService],
BullModule.forRoot({
redis: `${process.env.REDIS_URL}${process.env.NODE_ENV === "production" ? "?tls=true" : ""}`,
}),
ThrottlerModule.forRootAsync({
imports: [RedisModule],
@@ -45,14 +38,9 @@ import { AppController } from "./app.controller";
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),
@@ -69,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.21";
import { handleNewBooking } from "@calcom/platform-libraries-0.0.22";
import { ApiSuccessResponse, ApiResponse } from "@calcom/platform-types";
describe("Bookings Endpoints", () => {
@@ -46,7 +46,7 @@ import {
getBookingInfo,
handleCancelBooking,
getBookingForReschedule,
} from "@calcom/platform-libraries-0.0.21";
} 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";
@@ -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.21";
import type { AppsStatus } from "@calcom/platform-libraries-0.0.22";
export class CreateRecurringBookingInput extends CreateBookingInput {
@IsBoolean()
@@ -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.21";
import { symmetricEncrypt, CalendarService } from "@calcom/platform-libraries-0.0.22";
@Injectable()
export class AppleCalendarService implements CredentialSyncCalendarApp {
@@ -18,7 +18,7 @@ import { User } from "@prisma/client";
import { DateTime } from "luxon";
import { z } from "zod";
import { getConnectedDestinationCalendars, getBusyCalendarTimes } from "@calcom/platform-libraries-0.0.21";
import { getConnectedDestinationCalendars, getBusyCalendarTimes } from "@calcom/platform-libraries-0.0.22";
import { Calendar } from "@calcom/platform-types";
import { PrismaClient } from "@calcom/prisma";
@@ -35,7 +35,7 @@ import {
EventTypesPublic,
eventTypeBookingFields,
eventTypeLocations,
} from "@calcom/platform-libraries-0.0.21";
} from "@calcom/platform-libraries-0.0.22";
import { ApiSuccessResponse } from "@calcom/platform-types";
describe("Event types Endpoints", () => {
@@ -4,7 +4,7 @@ import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { UserWithProfile } from "@/modules/users/users.repository";
import { Injectable } from "@nestjs/common";
import { getEventTypeById } from "@calcom/platform-libraries-0.0.21";
import { getEventTypeById } from "@calcom/platform-libraries-0.0.22";
import type { PrismaClient } from "@calcom/prisma";
@Injectable()
@@ -14,7 +14,7 @@ import {
updateEventType,
EventTypesPublic,
getEventTypesPublic,
} from "@calcom/platform-libraries-0.0.21";
} from "@calcom/platform-libraries-0.0.22";
import { EventType } from "@calcom/prisma/client";
@Injectable()
@@ -7,7 +7,7 @@ import {
getEventTypeById,
transformApiEventTypeBookingFields,
transformApiEventTypeLocations,
} from "@calcom/platform-libraries-0.0.21";
} from "@calcom/platform-libraries-0.0.22";
import { CreateEventTypeInput_2024_06_14 } from "@calcom/platform-types";
import type { PrismaClient } from "@calcom/prisma";
@@ -10,9 +10,9 @@ import { UsersService } from "@/modules/users/services/users.service";
import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository";
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
import { createEventType, updateEventType } from "@calcom/platform-libraries-0.0.21";
import { getEventTypesPublic, EventTypesPublic } from "@calcom/platform-libraries-0.0.21";
import { dynamicEvent } from "@calcom/platform-libraries-0.0.21";
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,
@@ -3,7 +3,7 @@ import { Injectable } from "@nestjs/common";
import {
transformApiEventTypeBookingFields,
transformApiEventTypeLocations,
} from "@calcom/platform-libraries-0.0.21";
} from "@calcom/platform-libraries-0.0.22";
import { CreateEventTypeInput_2024_06_14, UpdateEventTypeInput_2024_06_14 } from "@calcom/platform-types";
@Injectable()
@@ -9,7 +9,7 @@ import {
parseRecurringEvent,
TransformedLocationsSchema,
BookingFieldsSchema,
} from "@calcom/platform-libraries-0.0.21";
} from "@calcom/platform-libraries-0.0.22";
type EventTypeRelations = { users: User[]; schedule: Schedule | null };
type DatabaseEventType = EventType & EventTypeRelations;
@@ -22,4 +22,7 @@ export class MeOutput {
@IsString()
timeZone!: string;
@IsInt()
organizationId!: number | null;
}
@@ -3,7 +3,7 @@ import { Injectable } from "@nestjs/common";
import {
transformApiScheduleOverrides,
transformApiScheduleAvailability,
} from "@calcom/platform-libraries-0.0.21";
} 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.21";
import { TRPCError } from "@calcom/platform-libraries-0.0.22";
import { Response } from "@calcom/platform-types";
@Catch(TRPCError)
@@ -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,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;
}
@@ -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,11 +37,17 @@ 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;
beforeAll(async () => {
module = await Test.createTestingModule({
imports: [
@@ -46,6 +56,7 @@ describe("ApiAuthStrategy", () => {
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",
@@ -94,22 +118,9 @@ describe("ApiAuthStrategy", () => {
oAuthClient.id
);
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.accessTokenStrategy(accessToken);
await expect(user).toBeDefined();
if (user) await expect(user.id).toEqual(validAccessTokenUser.id);
expect(user).toBeDefined();
expect(user?.id).toEqual(validAccessTokenUser.id);
});
it("should return user associated with valid api key", async () => {
@@ -117,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);
await expect(user).toBeDefined();
if (user) expect(user.id).toEqual(validApiKeyUser.id);
expect(user).toBeDefined();
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 () => {
@@ -158,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: () => ({
@@ -179,8 +231,11 @@ describe("ApiAuthStrategy", () => {
});
afterAll(async () => {
await userRepositoryFixture.deleteByEmail(validApiKeyEmail);
await userRepositoryFixture.deleteByEmail(validAccessTokenEmail);
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)
+2
View File
@@ -4,3 +4,5 @@ export enum PlatformPlan {
SCALE = "SCALE",
ENTERPRISE = "ENTERPRISE",
}
export type PlatformPlanType = "STARTER" | "ESSENTIALS" | "SCALE" | "ENTERPRISE";
@@ -1,6 +1,6 @@
import { Injectable } from "@nestjs/common";
import { sendSignupToOrganizationEmail, getTranslation } from "@calcom/platform-libraries-0.0.21";
import { sendSignupToOrganizationEmail, getTranslation } from "@calcom/platform-libraries-0.0.22";
@Injectable()
export class EmailService {
@@ -14,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";
@@ -78,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";
@@ -98,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();
});
@@ -188,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();
}
@@ -277,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,11 +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";
@@ -37,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");
@@ -78,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(
@@ -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);
});
@@ -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 {}
@@ -1,5 +1,6 @@
import { EventTypesService_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/services/event-types.service";
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
import { OrganizationsTeamsService } from "@/modules/organizations/services/organizations-teams.service";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { CreateManagedUserInput } from "@/modules/users/inputs/create-managed-user.input";
import { UpdateManagedUserInput } from "@/modules/users/inputs/update-managed-user.input";
@@ -7,7 +8,7 @@ import { UsersRepository } from "@/modules/users/users.repository";
import { BadRequestException, Injectable } from "@nestjs/common";
import { User } from "@prisma/client";
import { createNewUsersConnectToOrgIfExists, slugify } from "@calcom/platform-libraries-0.0.21";
import { createNewUsersConnectToOrgIfExists, slugify } from "@calcom/platform-libraries-0.0.22";
@Injectable()
export class OAuthClientUsersService {
@@ -15,7 +16,8 @@ export class OAuthClientUsersService {
private readonly userRepository: UsersRepository,
private readonly tokensRepository: TokensRepository,
private readonly eventTypesService: EventTypesService_2024_04_15,
private readonly schedulesService: SchedulesService_2024_04_15
private readonly schedulesService: SchedulesService_2024_04_15,
private readonly organizationsTeamsService: OrganizationsTeamsService
) {}
async createOauthClientUser(
@@ -78,6 +80,8 @@ export class OAuthClientUsersService {
user.defaultScheduleId = defaultSchedule.id;
}
await this.organizationsTeamsService.addUserToPlatformTeamEvents(user.id, organizationId, oAuthClientId);
return {
user,
tokens: {
@@ -1,7 +1,9 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
@@ -32,6 +34,7 @@ import { ApiTags as DocsTags } from "@nestjs/swagger";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import {
CreateTeamEventTypeInput_2024_06_14,
GetTeamEventTypesQuery_2024_06_14,
SkipTakePagination,
UpdateTeamEventTypeInput_2024_06_14,
} from "@calcom/platform-types";
@@ -40,13 +43,13 @@ import {
path: "/v2/organizations/:orgId",
version: API_VERSIONS_VALUES,
})
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard)
@DocsTags("Organizations Event Types")
export class OrganizationsEventTypesController {
constructor(private readonly organizationsEventTypesService: OrganizationsEventTypesService) {}
@Roles("TEAM_ADMIN")
@UseGuards(IsTeamInOrg)
@PlatformPlan("ESSENTIALS")
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard)
@Post("/teams/:teamId/event-types")
async createTeamEventType(
@GetUser() user: UserWithProfile,
@@ -68,7 +71,8 @@ export class OrganizationsEventTypesController {
}
@Roles("TEAM_ADMIN")
@UseGuards(IsTeamInOrg)
@PlatformPlan("ESSENTIALS")
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard)
@Get("/teams/:teamId/event-types/:eventTypeId")
async getTeamEventType(
@Param("teamId", ParseIntPipe) teamId: number,
@@ -86,10 +90,22 @@ export class OrganizationsEventTypesController {
};
}
@Roles("TEAM_ADMIN")
@UseGuards(IsTeamInOrg)
@UseGuards(IsOrgGuard, IsTeamInOrg)
@Get("/teams/:teamId/event-types")
async getTeamEventTypes(@Param("teamId", ParseIntPipe) teamId: number): Promise<GetTeamEventTypesOutput> {
async getTeamEventTypes(
@Param("teamId", ParseIntPipe) teamId: number,
@Query() queryParams: GetTeamEventTypesQuery_2024_06_14
): Promise<GetTeamEventTypesOutput> {
const { eventSlug } = queryParams;
if (eventSlug) {
const eventType = await this.organizationsEventTypesService.getTeamEventTypeBySlug(teamId, eventSlug);
return {
status: SUCCESS_STATUS,
data: eventType ? [eventType] : [],
};
}
const eventTypes = await this.organizationsEventTypesService.getTeamEventTypes(teamId);
return {
@@ -99,6 +115,8 @@ export class OrganizationsEventTypesController {
}
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard)
@Get("/teams/event-types")
async getTeamsEventTypes(
@Param("orgId", ParseIntPipe) orgId: number,
@@ -114,7 +132,8 @@ export class OrganizationsEventTypesController {
}
@Roles("TEAM_ADMIN")
@UseGuards(IsTeamInOrg)
@PlatformPlan("ESSENTIALS")
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard)
@Patch("/teams/:teamId/event-types/:eventTypeId")
async updateTeamEventType(
@Param("teamId", ParseIntPipe) teamId: number,
@@ -136,7 +155,8 @@ export class OrganizationsEventTypesController {
}
@Roles("TEAM_ADMIN")
@UseGuards(IsTeamInOrg)
@PlatformPlan("ESSENTIALS")
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard)
@Delete("/teams/:teamId/event-types/:eventTypeId")
@HttpCode(HttpStatus.OK)
async deleteTeamEventType(
@@ -248,17 +248,15 @@ describe("Organizations Event Types Endpoints", () => {
options: ["javascript", "python", "cobol"],
},
],
schedulingType: "COLLECTIVE",
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
schedulingType: "collective",
hosts: [
{
userId: teammate1.id,
mandatory: true,
priority: "high",
},
{
userId: teammate2.id,
mandatory: false,
priority: "low",
},
],
};
@@ -274,6 +272,7 @@ describe("Organizations Event Types Endpoints", () => {
const data = responseBody.data;
expect(data.title).toEqual(body.title);
expect(data.hosts.length).toEqual(2);
expect(data.schedulingType).toEqual("COLLECTIVE");
evaluateHost(body.hosts[0], data.hosts[0]);
evaluateHost(body.hosts[1], data.hosts[1]);
@@ -389,7 +388,6 @@ describe("Organizations Event Types Endpoints", () => {
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const data = responseBody.data;
console.log("asap responseBody.data", JSON.stringify(responseBody.data, null, 2));
expect(data.length).toEqual(2);
const eventTypeCollective = data.find((eventType) => eventType.schedulingType === "COLLECTIVE");
@@ -431,8 +429,6 @@ describe("Organizations Event Types Endpoints", () => {
const newHosts: UpdateTeamEventTypeInput_2024_06_14["hosts"] = [
{
userId: teammate1.id,
mandatory: true,
priority: "medium",
},
];
@@ -603,7 +599,9 @@ describe("Organizations Event Types Endpoints", () => {
await userRepositoryFixture.deleteByEmail(teammate2.email);
await userRepositoryFixture.deleteByEmail(falseTestUser.email);
await teamsRepositoryFixture.delete(team.id);
await teamsRepositoryFixture.delete(falseTestTeam.id);
await organizationsRepositoryFixture.delete(org.id);
await organizationsRepositoryFixture.delete(falseTestOrg.id);
await app.close();
});
});
@@ -1,7 +1,9 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
import { GetMembership } from "@/modules/auth/decorators/get-membership/get-membership.decorator";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
import { IsMembershipInOrg } from "@/modules/auth/guards/memberships/is-membership-in-org.guard";
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
@@ -39,12 +41,13 @@ import { Membership } from "@calcom/prisma/client";
path: "/v2/organizations/:orgId/memberships",
version: API_VERSIONS_VALUES,
})
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard)
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard)
@DocsTags("Organizations Memberships")
export class OrganizationsMembershipsController {
constructor(private organizationsMembershipService: OrganizationsMembershipService) {}
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@Get("/")
@HttpCode(HttpStatus.OK)
async getAllMemberships(
@@ -66,6 +69,7 @@ export class OrganizationsMembershipsController {
}
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@Post("/")
@HttpCode(HttpStatus.CREATED)
async createMembership(
@@ -80,6 +84,7 @@ export class OrganizationsMembershipsController {
}
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@UseGuards(IsMembershipInOrg)
@Get("/:membershipId")
@HttpCode(HttpStatus.OK)
@@ -91,6 +96,7 @@ export class OrganizationsMembershipsController {
}
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@UseGuards(IsMembershipInOrg)
@Delete("/:membershipId")
@HttpCode(HttpStatus.OK)
@@ -107,6 +113,7 @@ export class OrganizationsMembershipsController {
@UseGuards(IsMembershipInOrg)
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@Patch("/:membershipId")
@HttpCode(HttpStatus.OK)
async updateMembership(
@@ -1,7 +1,9 @@
import { SchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/schedules.service";
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { IsUserInOrg } from "@/modules/auth/guards/users/is-user-in-org.guard";
@@ -38,7 +40,7 @@ import { SkipTakePagination } from "@calcom/platform-types";
path: "/v2/organizations/:orgId",
version: API_VERSIONS_VALUES,
})
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard)
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard)
@DocsTags("Organizations Schedules")
export class OrganizationsSchedulesController {
constructor(
@@ -47,6 +49,7 @@ export class OrganizationsSchedulesController {
) {}
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@Get("/schedules")
async getOrganizationSchedules(
@Param("orgId", ParseIntPipe) orgId: number,
@@ -63,6 +66,7 @@ export class OrganizationsSchedulesController {
}
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@UseGuards(IsUserInOrg)
@Post("/users/:userId/schedules")
async createUserSchedule(
@@ -78,6 +82,7 @@ export class OrganizationsSchedulesController {
}
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@UseGuards(IsUserInOrg)
@Get("/users/:userId/schedules/:scheduleId")
async getUserSchedule(
@@ -93,6 +98,7 @@ export class OrganizationsSchedulesController {
}
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@UseGuards(IsUserInOrg)
@Get("/users/:userId/schedules")
async getUserSchedules(
@@ -107,6 +113,7 @@ export class OrganizationsSchedulesController {
}
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@UseGuards(IsUserInOrg)
@Patch("/users/:userId/schedules/:scheduleId")
async updateUserSchedule(
@@ -123,6 +130,7 @@ export class OrganizationsSchedulesController {
}
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@UseGuards(IsUserInOrg)
@Delete("/users/:userId/schedules/:scheduleId")
@HttpCode(HttpStatus.OK)
@@ -1,6 +1,8 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
@@ -38,7 +40,7 @@ import { SkipTakePagination } from "@calcom/platform-types";
path: "/v2/organizations/:orgId/teams/:teamId/memberships",
version: API_VERSIONS_VALUES,
})
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg)
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard)
@DocsTags("Organizations Teams")
export class OrganizationsTeamsMembershipsController {
constructor(
@@ -50,6 +52,7 @@ export class OrganizationsTeamsMembershipsController {
@ApiOperation({ summary: "Get all the memberships of a team of an organization." })
@UseGuards()
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@HttpCode(HttpStatus.OK)
async getAllOrgTeamMemberships(
@Param("orgId", ParseIntPipe) orgId: number,
@@ -75,6 +78,7 @@ export class OrganizationsTeamsMembershipsController {
@ApiOperation({ summary: "Get the membership of an organization's team by ID" })
@UseGuards()
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@HttpCode(HttpStatus.OK)
async getOrgTeamMembership(
@Param("orgId", ParseIntPipe) orgId: number,
@@ -93,6 +97,7 @@ export class OrganizationsTeamsMembershipsController {
}
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@Delete("/:membershipId")
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Delete the membership of an organization's team by ID" })
@@ -113,6 +118,7 @@ export class OrganizationsTeamsMembershipsController {
}
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@Patch("/:membershipId")
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Update the membership of an organization's team by ID" })
@@ -135,6 +141,7 @@ export class OrganizationsTeamsMembershipsController {
}
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@Post("/")
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: "Create a membership of an organization's team" })
@@ -11,20 +11,20 @@ import { Test } from "@nestjs/testing";
import { User } from "@prisma/client";
import * as request from "supertest";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.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";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { SUCCESS_STATUS, X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
import { ApiSuccessResponse } from "@calcom/platform-types";
import { Team } from "@calcom/prisma/client";
import { PlatformOAuthClient, Team } from "@calcom/prisma/client";
describe("Organizations Team Endpoints", () => {
describe("User Authentication - User is Org Admin", () => {
let app: INestApplication;
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: TeamRepositoryFixture;
let teamsRepositoryFixture: TeamRepositoryFixture;
let membershipsRepositoryFixture: MembershipRepositoryFixture;
@@ -32,6 +32,7 @@ describe("Organizations Team Endpoints", () => {
let team: Team;
let team2: Team;
let teamCreatedViaApi: Team;
let teamCreatedViaApi2: Team;
const userEmail = "org-admin-teams-controller-e2e@api.com";
let user: User;
@@ -45,7 +46,6 @@ describe("Organizations Team Endpoints", () => {
).compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
@@ -54,7 +54,7 @@ describe("Organizations Team Endpoints", () => {
username: userEmail,
});
org = await organizationsRepositoryFixture.create({
org = await teamsRepositoryFixture.create({
name: "Test Organization",
isOrganization: true,
});
@@ -85,7 +85,7 @@ describe("Organizations Team Endpoints", () => {
it("should be defined", () => {
expect(userRepositoryFixture).toBeDefined();
expect(organizationsRepositoryFixture).toBeDefined();
expect(teamsRepositoryFixture).toBeDefined();
expect(user).toBeDefined();
expect(org).toBeDefined();
});
@@ -147,6 +147,7 @@ describe("Organizations Team Endpoints", () => {
teamCreatedViaApi.id
);
expect(membership?.role ?? "").toEqual("OWNER");
expect(membership?.accepted).toEqual(true);
});
});
@@ -199,11 +200,35 @@ describe("Organizations Team Endpoints", () => {
return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/teams/123132145`).expect(404);
});
it("should create the team of the org without auto-accepting creator", async () => {
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/teams`)
.send({
name: "Team II created via API",
autoAcceptCreator: false,
} satisfies CreateOrgTeamDto)
.expect(201)
.then(async (response) => {
const responseBody: ApiSuccessResponse<Team> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
teamCreatedViaApi2 = responseBody.data;
expect(teamCreatedViaApi2.name).toEqual("Team II created via API");
expect(teamCreatedViaApi2.parentId).toEqual(org.id);
const membership = await membershipsRepositoryFixture.getUserMembershipByTeamId(
user.id,
teamCreatedViaApi2.id
);
expect(membership?.role ?? "").toEqual("OWNER");
expect(membership?.accepted).toEqual(false);
});
});
afterAll(async () => {
await userRepositoryFixture.deleteByEmail(user.email);
await teamsRepositoryFixture.delete(team.id);
await teamsRepositoryFixture.delete(team2.id);
await organizationsRepositoryFixture.delete(org.id);
await teamsRepositoryFixture.delete(teamCreatedViaApi2.id);
await teamsRepositoryFixture.delete(org.id);
await app.close();
});
});
@@ -214,7 +239,6 @@ describe("Organizations Team Endpoints", () => {
let app: INestApplication;
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: TeamRepositoryFixture;
let teamsRepositoryFixture: TeamRepositoryFixture;
let membershipsRepositoryFixture: MembershipRepositoryFixture;
@@ -234,7 +258,6 @@ describe("Organizations Team Endpoints", () => {
).compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
@@ -243,7 +266,7 @@ describe("Organizations Team Endpoints", () => {
username: userEmail,
});
org = await organizationsRepositoryFixture.create({
org = await teamsRepositoryFixture.create({
name: "Test Organization",
isOrganization: true,
});
@@ -274,7 +297,7 @@ describe("Organizations Team Endpoints", () => {
it("should be defined", () => {
expect(userRepositoryFixture).toBeDefined();
expect(organizationsRepositoryFixture).toBeDefined();
expect(teamsRepositoryFixture).toBeDefined();
expect(user).toBeDefined();
expect(org).toBeDefined();
});
@@ -317,7 +340,7 @@ describe("Organizations Team Endpoints", () => {
await userRepositoryFixture.deleteByEmail(user.email);
await teamsRepositoryFixture.delete(team.id);
await teamsRepositoryFixture.delete(team2.id);
await organizationsRepositoryFixture.delete(org.id);
await teamsRepositoryFixture.delete(org.id);
await app.close();
});
});
@@ -448,3 +471,158 @@ describe("Organizations Team Endpoints", () => {
});
});
});
describe("Organizations Team Endpoints", () => {
describe("Platform teams", () => {
let app: INestApplication;
let userRepositoryFixture: UserRepositoryFixture;
let teamsRepositoryFixture: TeamRepositoryFixture;
let membershipsRepositoryFixture: MembershipRepositoryFixture;
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
let oAuthClient1: PlatformOAuthClient;
let oAuthClient2: PlatformOAuthClient;
let org: Team;
let team1: Team;
let team2: Team;
const userEmail = "platform-org-member-teams-owner-controller-e2e@api.com";
let user: User;
beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
})
).compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
user = await userRepositoryFixture.create({
email: userEmail,
username: userEmail,
});
org = await teamsRepositoryFixture.create({
name: "Platform Test Organization",
isOrganization: true,
});
await membershipsRepositoryFixture.create({
role: "ADMIN",
user: { connect: { id: user.id } },
team: { connect: { id: org.id } },
});
oAuthClient1 = await createOAuthClient(org.id);
oAuthClient2 = await createOAuthClient(org.id);
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
async function createOAuthClient(organizationId: number) {
const data = {
logo: "logo-url",
name: "name",
redirectUris: ["redirect-uri"],
permissions: 32,
};
const secret = "secret";
return await oauthClientRepositoryFixture.create(organizationId, data, secret);
}
it("should be defined", () => {
expect(userRepositoryFixture).toBeDefined();
expect(teamsRepositoryFixture).toBeDefined();
expect(user).toBeDefined();
expect(org).toBeDefined();
});
it("should create first oAuth client team", async () => {
const teamName = "Platform team created via API";
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/teams`)
.set(X_CAL_CLIENT_ID, oAuthClient1.id)
.send({
name: teamName,
} satisfies CreateOrgTeamDto)
.expect(201)
.then(async (response) => {
const responseBody: ApiSuccessResponse<Team> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
team1 = responseBody.data;
expect(team1.name).toEqual(teamName);
expect(team1.parentId).toEqual(org.id);
const membership = await membershipsRepositoryFixture.getUserMembershipByTeamId(user.id, team1.id);
expect(membership?.role ?? "").toEqual("OWNER");
expect(membership?.accepted).toEqual(true);
});
});
it("should create second oAuth client team", async () => {
const teamName = "Platform team II created via API";
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/teams`)
.set(X_CAL_CLIENT_ID, oAuthClient2.id)
.set(X_CAL_SECRET_KEY, oAuthClient2.secret)
.send({
name: teamName,
} satisfies CreateOrgTeamDto)
.expect(201)
.then(async (response) => {
const responseBody: ApiSuccessResponse<Team> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
team2 = responseBody.data;
expect(team2.name).toEqual(teamName);
expect(team2.parentId).toEqual(org.id);
const membership = await membershipsRepositoryFixture.getUserMembershipByTeamId(user.id, team2.id);
expect(membership?.role ?? "").toEqual("OWNER");
expect(membership?.accepted).toEqual(true);
});
});
it("should get all the platform teams correctly tied to OAuth clients", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/teams`)
.expect(200)
.then(async (response) => {
const responseBody: ApiSuccessResponse<Team[]> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data[0].id).toEqual(team1.id);
expect(responseBody.data[1].id).toEqual(team2.id);
const oAuthClientTeams = await teamsRepositoryFixture.getPlatformOrgTeams(org.id, oAuthClient1.id);
expect(oAuthClientTeams.length).toEqual(1);
const oAuthClientTeam = oAuthClientTeams[0];
expect(oAuthClientTeam.id).toEqual(team1.id);
expect(oAuthClientTeam.name).toEqual(team1.name);
const oAuthClient2Teams = await teamsRepositoryFixture.getPlatformOrgTeams(org.id, oAuthClient2.id);
expect(oAuthClient2Teams.length).toEqual(1);
const oAuthClientTeam2 = oAuthClient2Teams[0];
expect(oAuthClientTeam2.id).toEqual(team2.id);
expect(oAuthClientTeam2.name).toEqual(team2.name);
});
});
afterAll(async () => {
await userRepositoryFixture.deleteByEmail(user.email);
await teamsRepositoryFixture.delete(team1.id);
await teamsRepositoryFixture.delete(org.id);
await app.close();
});
});
});
@@ -1,16 +1,18 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
import { GetTeam } from "@/modules/auth/decorators/get-team/get-team.decorator";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
import { CreateOrgTeamDto } from "@/modules/organizations/inputs/create-organization-team.input";
import { UpdateOrgTeamDto } from "@/modules/organizations/inputs/update-organization-team.input";
import {
OrgMeTeamOutputDto,
OrgMeTeamsOutputResponseDto,
OrgTeamOutputDto,
OrgTeamOutputResponseDto,
OrgTeamsOutputResponseDto,
} from "@/modules/organizations/outputs/organization-team.output";
@@ -27,11 +29,13 @@ import {
Patch,
Post,
Body,
Headers,
} from "@nestjs/common";
import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
import { plainToClass } from "class-transformer";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { SUCCESS_STATUS, X_CAL_CLIENT_ID } from "@calcom/platform-constants";
import { OrgTeamOutputDto } from "@calcom/platform-types";
import { SkipTakePagination } from "@calcom/platform-types";
import { Team } from "@calcom/prisma/client";
@@ -39,7 +43,7 @@ import { Team } from "@calcom/prisma/client";
path: "/v2/organizations/:orgId/teams",
version: API_VERSIONS_VALUES,
})
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard)
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard)
@DocsTags("Organizations Teams")
export class OrganizationsTeamsController {
constructor(private organizationsTeamsService: OrganizationsTeamsService) {}
@@ -47,6 +51,7 @@ export class OrganizationsTeamsController {
@Get()
@ApiOperation({ summary: "Get all the teams of an organization." })
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
async getAllTeams(
@Param("orgId", ParseIntPipe) orgId: number,
@Query() queryParams: SkipTakePagination
@@ -62,6 +67,7 @@ export class OrganizationsTeamsController {
@Get("/me")
@ApiOperation({ summary: "Get the organization's teams user is a member of" })
@Roles("ORG_MEMBER")
@PlatformPlan("ESSENTIALS")
async getMyTeams(
@Param("orgId", ParseIntPipe) orgId: number,
@Query() queryParams: SkipTakePagination,
@@ -88,6 +94,7 @@ export class OrganizationsTeamsController {
@UseGuards(IsTeamInOrg)
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@Get("/:teamId")
@ApiOperation({ summary: "Get a team of the organization by ID." })
async getTeam(@GetTeam() team: Team): Promise<OrgTeamOutputResponseDto> {
@@ -99,6 +106,7 @@ export class OrganizationsTeamsController {
@UseGuards(IsTeamInOrg)
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@Delete("/:teamId")
@ApiOperation({ summary: "Delete a team of the organization by ID." })
async deleteTeam(
@@ -114,12 +122,13 @@ export class OrganizationsTeamsController {
@UseGuards(IsTeamInOrg)
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@Patch("/:teamId")
@ApiOperation({ summary: "Update a team of the organization by ID." })
async updateTeam(
@Param("orgId", ParseIntPipe) orgId: number,
@Param("teamId", ParseIntPipe) teamId: number,
@Body() body: CreateOrgTeamDto
@Body() body: UpdateOrgTeamDto
): Promise<OrgTeamOutputResponseDto> {
const team = await this.organizationsTeamsService.updateOrgTeam(orgId, teamId, body);
return {
@@ -130,13 +139,18 @@ export class OrganizationsTeamsController {
@Post()
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@ApiOperation({ summary: "Create a team for an organization." })
async createTeam(
@Param("orgId", ParseIntPipe) orgId: number,
@Body() body: CreateOrgTeamDto,
@GetUser() user: UserWithProfile
@GetUser() user: UserWithProfile,
@Headers(X_CAL_CLIENT_ID) oAuthClientId?: string
): Promise<OrgTeamOutputResponseDto> {
const team = await this.organizationsTeamsService.createOrgTeam(orgId, body, user);
const team = oAuthClientId
? await this.organizationsTeamsService.createPlatformOrgTeam(orgId, oAuthClientId, body, user)
: await this.organizationsTeamsService.createOrgTeam(orgId, body, user);
return {
status: SUCCESS_STATUS,
data: plainToClass(OrgTeamOutputDto, team, { strategy: "excludeAll" }),
@@ -1,8 +1,10 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
import { GetOrg } from "@/modules/auth/decorators/get-org/get-org.decorator";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { IsUserInOrg } from "@/modules/auth/guards/users/is-user-in-org.guard";
@@ -39,7 +41,7 @@ import { Team } from "@calcom/prisma/client";
version: API_VERSIONS_VALUES,
})
@UseInterceptors(ClassSerializerInterceptor)
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard)
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard)
@UseGuards(IsOrgGuard)
@DocsTags("Organizations Users")
export class OrganizationsUsersController {
@@ -47,6 +49,7 @@ export class OrganizationsUsersController {
@Get()
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
async getOrganizationsUsers(
@Param("orgId", ParseIntPipe) orgId: number,
@Query() query: GetOrganizationsUsersInput
@@ -66,6 +69,7 @@ export class OrganizationsUsersController {
@Post()
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
async createOrganizationUser(
@Param("orgId", ParseIntPipe) orgId: number,
@GetOrg() org: Team,
@@ -85,6 +89,7 @@ export class OrganizationsUsersController {
@Patch("/:userId")
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@UseGuards(IsUserInOrg)
async updateOrganizationUser(
@Param("orgId", ParseIntPipe) orgId: number,
@@ -101,6 +106,7 @@ export class OrganizationsUsersController {
@Delete("/:userId")
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@UseGuards(IsUserInOrg)
async deleteOrganizationUser(
@Param("orgId", ParseIntPipe) orgId: number,
@@ -8,6 +8,7 @@ import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import * as request from "supertest";
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
@@ -15,7 +16,7 @@ import { UserRepositoryFixture } from "test/fixtures/repository/users.repository
import { withApiAuth } from "test/utils/withApiAuth";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { User, Team, Membership } from "@calcom/prisma/client";
import { User, Team, EventType } from "@calcom/prisma/client";
describe("Organizations Users Endpoints", () => {
describe("Member role", () => {
@@ -29,7 +30,6 @@ describe("Organizations Users Endpoints", () => {
const userEmail = "member1@org.com";
let user: User;
let org: Team;
let membership: Membership;
beforeAll(async () => {
const moduleRef = await withApiAuth(
@@ -70,7 +70,7 @@ describe("Organizations Users Endpoints", () => {
},
});
membership = await membershipFixtures.addUserToOrg(user, org, "MEMBER", true);
await membershipFixtures.addUserToOrg(user, org, "MEMBER", true);
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
@@ -344,4 +344,165 @@ describe("Organizations Users Endpoints", () => {
await app.close();
});
});
describe("Member event-types", () => {
let app: INestApplication;
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: TeamRepositoryFixture;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let membershipFixtures: MembershipRepositoryFixture;
let profileRepositoryFixture: ProfileRepositoryFixture;
const authEmail = "auth@org.com";
let user: User;
let org: Team;
let team: Team;
let managedEventType: EventType;
let createdUser: User;
beforeAll(async () => {
const moduleRef = await withApiAuth(
authEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
})
).compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
membershipFixtures = new MembershipRepositoryFixture(moduleRef);
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
org = await organizationsRepositoryFixture.create({
name: "Test org 4",
isOrganization: true,
});
team = await organizationsRepositoryFixture.create({
name: "Test org 4 team",
isOrganization: false,
parent: { connect: { id: org.id } },
});
user = await userRepositoryFixture.create({
email: authEmail,
username: authEmail,
organization: { connect: { id: org.id } },
});
await profileRepositoryFixture.create({
uid: `usr-${user.id}`,
username: authEmail,
organization: {
connect: {
id: org.id,
},
},
user: {
connect: {
id: user.id,
},
},
});
await membershipFixtures.addUserToOrg(user, org, "ADMIN", true);
await eventTypesRepositoryFixture.createTeamEventType({
schedulingType: "COLLECTIVE",
team: {
connect: { id: team.id },
},
title: "Collective Event Type",
slug: "collective-event-type",
length: 30,
assignAllTeamMembers: true,
bookingFields: [],
locations: [],
});
managedEventType = await eventTypesRepositoryFixture.createTeamEventType({
schedulingType: "MANAGED",
team: {
connect: { id: team.id },
},
title: "Managed Event Type",
slug: "managed-event-type",
length: 60,
assignAllTeamMembers: true,
bookingFields: [],
locations: [],
});
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
it("should be defined", () => {
expect(userRepositoryFixture).toBeDefined();
expect(organizationsRepositoryFixture).toBeDefined();
expect(user).toBeDefined();
expect(org).toBeDefined();
});
it("should create a new org user with team event-types", async () => {
const newOrgUser = {
email: "new-org-member-d@org.com",
organizationRole: "MEMBER",
autoAccept: true,
};
const { body } = await request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/users`)
.send({
email: newOrgUser.email,
})
.set("Content-Type", "application/json")
.set("Accept", "application/json");
const userData = body.data;
expect(body.status).toBe(SUCCESS_STATUS);
userHasCorrectEventTypes(userData.id);
createdUser = userData;
teamHasCorrectEventTypes(team.id);
});
async function userHasCorrectEventTypes(userId: number) {
const eventTypes = await eventTypesRepositoryFixture.getAllUserEventTypes(userId);
expect(eventTypes?.length).toEqual(1);
// note(Lauris): managed event-types with assignAllTeamMembers: true
expect(eventTypes?.find((eventType) => eventType.slug === managedEventType.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 === managedEventType.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(createdUser.id);
}
afterAll(async () => {
// await membershipFixtures.delete(membership.id);
await userRepositoryFixture.deleteByEmail(user.email);
await userRepositoryFixture.deleteByEmail(createdUser.email);
await organizationsRepositoryFixture.delete(org.id);
await app.close();
await app.close();
});
});
});
@@ -72,4 +72,8 @@ export class CreateOrgTeamDto {
@IsOptional()
@IsString()
readonly weekStart?: string = "Sunday";
@IsOptional()
@IsBoolean()
readonly autoAcceptCreator?: boolean = true;
}
@@ -0,0 +1,75 @@
import { IsBoolean, IsOptional, IsString, IsUrl, Length } from "class-validator";
export class UpdateOrgTeamDto {
@IsString()
@Length(1)
readonly name?: string;
@IsOptional()
@IsString()
readonly slug?: string;
@IsOptional()
@IsUrl()
readonly logoUrl?: string;
@IsOptional()
@IsUrl()
readonly calVideoLogo?: string;
@IsOptional()
@IsUrl()
readonly appLogo?: string;
@IsOptional()
@IsUrl()
readonly appIconLogo?: string;
@IsOptional()
@IsString()
readonly bio?: string;
@IsOptional()
@IsBoolean()
readonly hideBranding?: boolean = false;
@IsOptional()
@IsBoolean()
readonly isPrivate?: boolean;
@IsOptional()
@IsBoolean()
readonly hideBookATeamMember?: boolean;
@IsOptional()
@IsString()
readonly metadata?: string; // Assuming metadata is a JSON string. Adjust accordingly if it's a nested object.
@IsOptional()
@IsString()
readonly theme?: string;
@IsOptional()
@IsString()
readonly brandColor?: string;
@IsOptional()
@IsString()
readonly darkBrandColor?: string;
@IsOptional()
@IsUrl()
readonly bannerUrl?: string;
@IsOptional()
@IsString()
readonly timeFormat?: number;
@IsOptional()
@IsString()
readonly timeZone?: string = "Europe/London";
@IsOptional()
@IsString()
readonly weekStart?: string = "Sunday";
}
@@ -1,122 +1,9 @@
import { ApiProperty } from "@nestjs/swagger";
import { Expose, Type } from "class-transformer";
import {
IsBoolean,
IsEnum,
IsInt,
IsOptional,
IsString,
IsUrl,
Length,
ValidateNested,
} from "class-validator";
import { IsEnum, IsString, ValidateNested } from "class-validator";
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
export class OrgTeamOutputDto {
@IsInt()
@Expose()
readonly id!: number;
@IsInt()
@IsOptional()
@Expose()
readonly parentId?: number;
@IsString()
@Length(1)
@Expose()
readonly name!: string;
@IsOptional()
@IsString()
@Expose()
readonly slug?: string;
@IsOptional()
@IsUrl()
@Expose()
readonly logoUrl?: string;
@IsOptional()
@IsUrl()
@Expose()
readonly calVideoLogo?: string;
@IsOptional()
@IsUrl()
@Expose()
readonly appLogo?: string;
@IsOptional()
@IsUrl()
@Expose()
readonly appIconLogo?: string;
@IsOptional()
@IsString()
@Expose()
readonly bio?: string;
@IsOptional()
@IsBoolean()
@Expose()
readonly hideBranding?: boolean;
@IsBoolean()
@Expose()
readonly isOrganization?: boolean;
@IsOptional()
@IsBoolean()
@Expose()
readonly isPrivate?: boolean;
@IsOptional()
@IsBoolean()
@Expose()
readonly hideBookATeamMember?: boolean = false;
@IsOptional()
@IsString()
@Expose()
readonly metadata?: string;
@IsOptional()
@IsString()
@Expose()
readonly theme?: string;
@IsOptional()
@IsString()
@Expose()
readonly brandColor?: string;
@IsOptional()
@IsString()
@Expose()
readonly darkBrandColor?: string;
@IsOptional()
@IsUrl()
@Expose()
readonly bannerUrl?: string;
@IsOptional()
@IsString()
@Expose()
readonly timeFormat?: number;
@IsOptional()
@IsString()
@Expose()
readonly timeZone?: string = "Europe/London";
@IsOptional()
@IsString()
@Expose()
readonly weekStart?: string = "Sunday";
}
import { OrgTeamOutputDto } from "@calcom/platform-types";
export class OrgMeTeamOutputDto extends OrgTeamOutputDto {
@IsString()
@@ -16,6 +16,18 @@ export class OrganizationsEventTypesRepository {
});
}
async getTeamEventTypeBySlug(teamId: number, eventTypeSlug: string) {
return this.dbRead.prisma.eventType.findUnique({
where: {
teamId_slug: {
teamId,
slug: eventTypeSlug,
},
},
include: { users: true, schedule: true, hosts: true },
});
}
async getTeamEventTypes(teamId: number) {
return this.dbRead.prisma.eventType.findMany({
where: {
@@ -1,4 +1,5 @@
import { CreateOrgTeamDto } from "@/modules/organizations/inputs/create-organization-team.input";
import { UpdateOrgTeamDto } from "@/modules/organizations/inputs/update-organization-team.input";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { Injectable } from "@nestjs/common";
@@ -16,6 +17,14 @@ export class OrganizationsTeamsRepository {
});
}
async findOrgTeams(organizationId: number) {
return this.dbRead.prisma.team.findMany({
where: {
parentId: organizationId,
},
});
}
async deleteOrgTeam(organizationId: number, teamId: number) {
return this.dbRead.prisma.team.delete({
where: {
@@ -32,7 +41,26 @@ export class OrganizationsTeamsRepository {
});
}
async updateOrgTeam(organizationId: number, teamId: number, data: CreateOrgTeamDto) {
async createPlatformOrgTeam(organizationId: number, oAuthClientId: string, data: CreateOrgTeamDto) {
return this.dbRead.prisma.team.create({
data: {
...data,
parentId: organizationId,
createdByOAuthClientId: oAuthClientId,
},
});
}
async getPlatformOrgTeams(organizationId: number, oAuthClientId: string) {
return this.dbRead.prisma.team.findMany({
where: {
parentId: organizationId,
createdByOAuthClientId: oAuthClientId,
},
});
}
async updateOrgTeam(organizationId: number, teamId: number, data: UpdateOrgTeamDto) {
return this.dbRead.prisma.team.update({
data: { ...data },
where: { id: teamId, parentId: organizationId, isOrganization: false },
@@ -2,12 +2,13 @@ import { InputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_
import { OrganizationsEventTypesRepository } from "@/modules/organizations/repositories/organizations-event-types.repository";
import { OrganizationsTeamsRepository } from "@/modules/organizations/repositories/organizations-teams.repository";
import { UsersRepository } from "@/modules/users/users.repository";
import { Injectable } from "@nestjs/common";
import { BadRequestException, Injectable } from "@nestjs/common";
import {
CreateTeamEventTypeInput_2024_06_14,
UpdateTeamEventTypeInput_2024_06_14,
HostPriority,
SchedulingType,
} from "@calcom/platform-types";
@Injectable()
@@ -30,7 +31,9 @@ export class InputOrganizationsEventTypesService {
const teamEventType = {
...eventType,
hosts: assignAllTeamMembers ? await this.getAllTeamMembers(teamId) : this.transformInputHosts(hosts),
hosts: assignAllTeamMembers
? await this.getAllTeamMembers(teamId, inputEventType.schedulingType)
: this.transformInputHosts(hosts, inputEventType.schedulingType),
assignAllTeamMembers,
metadata,
};
@@ -46,6 +49,11 @@ export class InputOrganizationsEventTypesService {
const { hosts, assignAllTeamMembers, ...rest } = inputEventType;
const eventType = this.inputEventTypesService.transformInputUpdateEventType(rest);
const dbEventType = await this.orgEventTypesRepository.getTeamEventType(teamId, eventTypeId);
if (!dbEventType) {
throw new BadRequestException("Event type to update not found");
}
const children = await this.getChildEventTypesForManagedEventType(eventTypeId, inputEventType, teamId);
const teamEventType = {
@@ -53,8 +61,8 @@ export class InputOrganizationsEventTypesService {
// note(Lauris): we don't populate hosts for managed event-types because they are handled by the children
hosts: !children
? assignAllTeamMembers
? await this.getAllTeamMembers(teamId)
: this.transformInputHosts(hosts)
? await this.getAllTeamMembers(teamId, dbEventType.schedulingType)
: this.transformInputHosts(hosts, dbEventType.schedulingType)
: undefined,
assignAllTeamMembers,
children,
@@ -118,28 +126,34 @@ export class InputOrganizationsEventTypesService {
});
}
async getAllTeamMembers(teamId: number) {
async getAllTeamMembers(teamId: number, schedulingType: SchedulingType | null) {
const membersIds = await this.organizationsTeamsRepository.getTeamMembersIds(teamId);
const isFixed = schedulingType === "COLLECTIVE" ? true : false;
return membersIds.map((id) => ({
userId: id,
isFixed: false,
isFixed,
priority: 2,
}));
}
transformInputHosts(inputHosts: CreateTeamEventTypeInput_2024_06_14["hosts"] | undefined) {
transformInputHosts(
inputHosts: CreateTeamEventTypeInput_2024_06_14["hosts"] | undefined,
schedulingType: SchedulingType | null
) {
if (!inputHosts) {
return undefined;
}
const defaultMandatory = false;
const defaultPriority = "medium";
const defaultIsFixed = false;
return inputHosts.map((host) => ({
userId: host.userId,
isFixed: host.mandatory || defaultMandatory,
priority: getPriorityValue(host.priority || defaultPriority),
isFixed: schedulingType === "COLLECTIVE" ? true : host.mandatory || defaultIsFixed,
priority: getPriorityValue(
schedulingType === "COLLECTIVE" ? "medium" : host.priority || defaultPriority
),
}));
}
}
@@ -9,7 +9,7 @@ import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { UserWithProfile } from "@/modules/users/users.repository";
import { Injectable, NotFoundException } from "@nestjs/common";
import { createEventType, updateEventType } from "@calcom/platform-libraries-0.0.21";
import { createEventType, updateEventType } from "@calcom/platform-libraries-0.0.22";
import {
CreateTeamEventTypeInput_2024_06_14,
UpdateTeamEventTypeInput_2024_06_14,
@@ -98,6 +98,19 @@ export class OrganizationsEventTypesService {
return this.outputService.getResponseTeamEventType(eventType);
}
async getTeamEventTypeBySlug(teamId: number, eventTypeSlug: string) {
const eventType = await this.organizationEventTypesRepository.getTeamEventTypeBySlug(
teamId,
eventTypeSlug
);
if (!eventType) {
return null;
}
return this.outputService.getResponseTeamEventType(eventType);
}
async getTeamEventTypes(teamId: number) {
const eventTypes = await this.organizationEventTypesRepository.getTeamEventTypes(teamId);
@@ -1,9 +1,11 @@
import { OutputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/output-event-types.service";
import { OrganizationsEventTypesRepository } from "@/modules/organizations/repositories/organizations-event-types.repository";
import { UsersRepository } from "@/modules/users/users.repository";
import { Injectable } from "@nestjs/common";
import type { EventType, User, Schedule, Host } from "@prisma/client";
import { HostPriority } from "@calcom/platform-types";
import { HostPriority, TeamEventTypeResponseHost } from "@calcom/platform-types";
import { SchedulingType } from "@calcom/prisma/enums";
type EventTypeRelations = { users: User[]; schedule: Schedule | null; hosts: Host[] };
type DatabaseEventType = EventType & EventTypeRelations;
@@ -47,7 +49,8 @@ type Input = Pick<
export class OutputOrganizationsEventTypesService {
constructor(
private readonly outputEventTypesService: OutputEventTypesService_2024_06_14,
private readonly organizationEventTypesRepository: OrganizationsEventTypesRepository
private readonly organizationEventTypesRepository: OrganizationsEventTypesRepository,
private readonly usersRepository: UsersRepository
) {}
async getResponseTeamEventType(databaseEventType: Input) {
@@ -60,7 +63,7 @@ export class OutputOrganizationsEventTypesService {
const hosts =
databaseEventType.schedulingType === "MANAGED"
? await this.getManagedEventTypeHosts(databaseEventType.id)
: this.transformHosts(databaseEventType.hosts);
: await this.transformHosts(databaseEventType.hosts, databaseEventType.schedulingType);
return {
...rest,
@@ -74,25 +77,41 @@ export class OutputOrganizationsEventTypesService {
async getManagedEventTypeHosts(eventTypeId: number) {
const children = await this.organizationEventTypesRepository.getEventTypeChildren(eventTypeId);
const hostsIds: number[] = [];
const transformedHosts: TeamEventTypeResponseHost[] = [];
for (const child of children) {
if (child.userId) {
hostsIds.push(child.userId);
const user = await this.usersRepository.findById(child.userId);
transformedHosts.push({ userId: child.userId, name: user?.name || "" });
}
}
return hostsIds.map((userId) => ({ userId }));
return transformedHosts;
}
transformHosts(hosts: Host[]) {
if (!hosts) return [];
async transformHosts(
databaseHosts: Host[],
schedulingType: SchedulingType | null
): Promise<TeamEventTypeResponseHost[]> {
if (!schedulingType) return [];
return hosts.map((host) => {
return {
userId: host.userId,
mandatory: host.isFixed,
priority: getPriorityLabel(host.priority || 2),
};
});
const transformedHosts: TeamEventTypeResponseHost[] = [];
const databaseUsers = await this.usersRepository.findByIds(databaseHosts.map((host) => host.userId));
for (const databaseHost of databaseHosts) {
const databaseUser = databaseUsers.find((u) => u.id === databaseHost.userId);
if (schedulingType === "ROUND_ROBIN") {
// note(Lauris): round robin is the only team event where mandatory (isFixed) and priority are used
transformedHosts.push({
userId: databaseHost.userId,
name: databaseUser?.name || "",
mandatory: databaseHost.isFixed,
priority: getPriorityLabel(databaseHost.priority || 2),
});
} else {
transformedHosts.push({ userId: databaseHost.userId, name: databaseUser?.name || "" });
}
}
return transformedHosts;
}
}
@@ -1,9 +1,12 @@
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { CreateOrgTeamDto } from "@/modules/organizations/inputs/create-organization-team.input";
import { UpdateOrgTeamDto } from "@/modules/organizations/inputs/update-organization-team.input";
import { OrganizationsTeamsRepository } from "@/modules/organizations/repositories/organizations-teams.repository";
import { UserWithProfile } from "@/modules/users/users.repository";
import { Injectable } from "@nestjs/common";
import { updateNewTeamMemberEventTypes } from "@calcom/platform-libraries-0.0.22";
@Injectable()
export class OrganizationsTeamsService {
constructor(
@@ -31,16 +34,58 @@ export class OrganizationsTeamsService {
return team;
}
async updateOrgTeam(organizationId: number, teamId: number, data: CreateOrgTeamDto) {
async updateOrgTeam(organizationId: number, teamId: number, data: UpdateOrgTeamDto) {
const team = await this.organizationsTeamRepository.updateOrgTeam(organizationId, teamId, data);
return team;
}
async createOrgTeam(organizationId: number, data: CreateOrgTeamDto, user: UserWithProfile) {
const team = await this.organizationsTeamRepository.createOrgTeam(organizationId, data);
const { autoAcceptCreator, ...rest } = data;
const team = await this.organizationsTeamRepository.createOrgTeam(organizationId, rest);
if (user.role !== "ADMIN") {
await this.membershipsRepository.createMembership(team.id, user.id, "OWNER", true);
await this.membershipsRepository.createMembership(team.id, user.id, "OWNER", !!autoAcceptCreator);
}
return team;
}
async createPlatformOrgTeam(
organizationId: number,
oAuthClientId: string,
data: CreateOrgTeamDto,
user: UserWithProfile
) {
const { autoAcceptCreator, ...rest } = data;
const team = await this.organizationsTeamRepository.createPlatformOrgTeam(
organizationId,
oAuthClientId,
rest
);
if (user.role !== "ADMIN") {
await this.membershipsRepository.createMembership(team.id, user.id, "OWNER", !!autoAcceptCreator);
}
return team;
}
async addUserToTeamEvents(userId: number, organizationId: number) {
const orgTeams = await this.organizationsTeamRepository.findOrgTeams(organizationId);
for (const team of orgTeams) {
await updateNewTeamMemberEventTypes(userId, team.id);
}
}
async addUserToPlatformTeamEvents(userId: number, organizationId: number, oAuthClientId: string) {
const oAuthClientTeams = await this.organizationsTeamRepository.getPlatformOrgTeams(
organizationId,
oAuthClientId
);
for (const team of oAuthClientTeams) {
await updateNewTeamMemberEventTypes(userId, team.id);
}
}
}
@@ -2,17 +2,19 @@ import { EmailService } from "@/modules/email/email.service";
import { CreateOrganizationUserInput } from "@/modules/organizations/inputs/create-organization-user.input";
import { UpdateOrganizationUserInput } from "@/modules/organizations/inputs/update-organization-user.input";
import { OrganizationsUsersRepository } from "@/modules/organizations/repositories/organizations-users.repository";
import { OrganizationsTeamsService } from "@/modules/organizations/services/organizations-teams.service";
import { CreateUserInput } from "@/modules/users/inputs/create-user.input";
import { Injectable, ConflictException } from "@nestjs/common";
import { plainToInstance } from "class-transformer";
import { createNewUsersConnectToOrgIfExists } from "@calcom/platform-libraries-0.0.21";
import { createNewUsersConnectToOrgIfExists } from "@calcom/platform-libraries-0.0.22";
import { Team } from "@calcom/prisma/client";
@Injectable()
export class OrganizationsUsersService {
constructor(
private readonly organizationsUsersRepository: OrganizationsUsersRepository,
private readonly organizationsTeamsService: OrganizationsTeamsService,
private readonly emailService: EmailService
) {}
@@ -77,6 +79,8 @@ export class OrganizationsUsersService {
updateUserBody
);
await this.organizationsTeamsService.addUserToTeamEvents(user.id, org.id);
// Need to send email to new user to create password
await this.emailService.sendSignupToOrganizationEmail({
usernameOrEmail,
@@ -0,0 +1,10 @@
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { ProfilesRepository } from "@/modules/profiles/profiles.repository";
import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule],
providers: [ProfilesRepository],
exports: [ProfilesRepository],
})
export class ProfilesModule {}
@@ -0,0 +1,20 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { Injectable } from "@nestjs/common";
@Injectable()
export class ProfilesRepository {
constructor(private readonly dbRead: PrismaReadService) {}
async getPlatformOwnerUserId(organizationId: number) {
const profile = await this.dbRead.prisma.profile.findFirst({
where: {
organizationId,
},
orderBy: {
createdAt: "asc",
},
});
return profile?.userId;
}
}
@@ -5,8 +5,8 @@ import { ApiTags as DocsTags } from "@nestjs/swagger";
import { Response as ExpressResponse, Request as ExpressRequest } from "express";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { getAvailableSlots } from "@calcom/platform-libraries-0.0.21";
import type { AvailableSlotsType } from "@calcom/platform-libraries-0.0.21";
import { getAvailableSlots } from "@calcom/platform-libraries-0.0.22";
import type { AvailableSlotsType } from "@calcom/platform-libraries-0.0.22";
import { RemoveSelectedSlotInput, ReserveSlotInput } from "@calcom/platform-types";
import { ApiResponse, GetAvailableSlotsInput } from "@calcom/platform-types";
@@ -3,7 +3,7 @@ import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
import { DateTime } from "luxon";
import { MINUTES_TO_BOOK } from "@calcom/platform-libraries-0.0.21";
import { MINUTES_TO_BOOK } from "@calcom/platform-libraries-0.0.22";
import { ReserveSlotInput } from "@calcom/platform-types";
@Injectable()
@@ -4,7 +4,7 @@ import { Controller, Get } from "@nestjs/common";
import { ApiTags as DocsTags } from "@nestjs/swagger";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import type { CityTimezones } from "@calcom/platform-libraries-0.0.21";
import type { CityTimezones } from "@calcom/platform-libraries-0.0.22";
import { ApiResponse } from "@calcom/platform-types";
@Controller({
@@ -1,8 +1,8 @@
import { RedisService } from "@/modules/redis/redis.service";
import { Injectable } from "@nestjs/common";
import { cityTimezonesHandler } from "@calcom/platform-libraries-0.0.21";
import type { CityTimezones } from "@calcom/platform-libraries-0.0.21";
import { cityTimezonesHandler } from "@calcom/platform-libraries-0.0.22";
import type { CityTimezones } from "@calcom/platform-libraries-0.0.22";
@Injectable()
export class TimezonesService {
@@ -90,6 +90,16 @@ export class UsersRepository {
});
}
async findByIds(userIds: number[]) {
return this.dbRead.prisma.user.findMany({
where: {
id: {
in: userIds,
},
},
});
}
async findByIdWithCalendars(userId: number) {
return this.dbRead.prisma.user.findUnique({
where: {
+369 -4
View File
@@ -962,6 +962,14 @@
"schema": {
"type": "number"
}
},
{
"name": "x-cal-client-id",
"required": true,
"in": "header",
"schema": {
"type": "string"
}
}
],
"requestBody": {
@@ -991,6 +999,57 @@
]
}
},
"/v2/organizations/{orgId}/teams/me": {
"get": {
"operationId": "OrganizationsTeamsController_getMyTeams",
"summary": "Get the organization's teams user is a member of",
"parameters": [
{
"name": "orgId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
},
{
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"schema": {
"type": "number"
}
},
{
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"example": 0,
"schema": {
"type": "number"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/OrgMeTeamsOutputResponseDto"
}
}
}
}
},
"tags": [
"Organizations Teams"
]
}
},
"/v2/organizations/{orgId}/teams/{teamId}": {
"get": {
"operationId": "OrganizationsTeamsController_getTeam",
@@ -1075,7 +1134,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateOrgTeamDto"
"$ref": "#/components/schemas/UpdateOrgTeamDto"
}
}
}
@@ -1160,6 +1219,16 @@
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateScheduleInput_2024_06_11"
}
}
}
},
"responses": {
"201": {
"description": "",
@@ -1262,6 +1331,16 @@
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateScheduleInput_2024_06_11"
}
}
}
},
"responses": {
"200": {
"description": "",
@@ -2301,6 +2380,16 @@
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateScheduleInput_2024_04_15"
}
}
}
},
"responses": {
"200": {
"description": "",
@@ -5100,6 +5189,29 @@
"data"
]
},
"OrgMeTeamsOutputResponseDto": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "success",
"enum": [
"success",
"error"
]
},
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/OrgTeamOutputDto"
}
}
},
"required": [
"status",
"data"
]
},
"OrgTeamOutputResponseDto": {
"type": "object",
"properties": {
@@ -5120,6 +5232,69 @@
"data"
]
},
"UpdateOrgTeamDto": {
"type": "object",
"properties": {
"name": {
"type": "string",
"minLength": 1
},
"slug": {
"type": "string"
},
"logoUrl": {
"type": "string"
},
"calVideoLogo": {
"type": "string"
},
"appLogo": {
"type": "string"
},
"appIconLogo": {
"type": "string"
},
"bio": {
"type": "string"
},
"hideBranding": {
"type": "boolean",
"default": false
},
"isPrivate": {
"type": "boolean"
},
"hideBookATeamMember": {
"type": "boolean"
},
"metadata": {
"type": "string"
},
"theme": {
"type": "string"
},
"brandColor": {
"type": "string"
},
"darkBrandColor": {
"type": "string"
},
"bannerUrl": {
"type": "string"
},
"timeFormat": {
"type": "number"
},
"timeZone": {
"type": "string",
"default": "Europe/London"
},
"weekStart": {
"type": "string",
"default": "Sunday"
}
}
},
"CreateOrgTeamDto": {
"type": "object",
"properties": {
@@ -5180,6 +5355,10 @@
"weekStart": {
"type": "string",
"default": "Sunday"
},
"autoAcceptCreator": {
"type": "boolean",
"default": true
}
},
"required": [
@@ -5329,6 +5508,57 @@
"data"
]
},
"CreateScheduleInput_2024_06_11": {
"type": "object",
"properties": {
"name": {
"type": "string",
"example": "One-on-one coaching"
},
"timeZone": {
"type": "string",
"example": "Europe/Rome"
},
"availability": {
"example": [
{
"days": [
"Monday",
"Tuesday"
],
"startTime": "09:00",
"endTime": "10:00"
}
],
"type": "array",
"items": {
"$ref": "#/components/schemas/ScheduleAvailabilityInput_2024_06_11"
}
},
"isDefault": {
"type": "boolean",
"example": true
},
"overrides": {
"example": [
{
"date": "2024-05-20",
"startTime": "12:00",
"endTime": "14:00"
}
],
"type": "array",
"items": {
"$ref": "#/components/schemas/ScheduleOverrideInput_2024_06_11"
}
}
},
"required": [
"name",
"timeZone",
"isDefault"
]
},
"CreateScheduleOutput_2024_06_11": {
"type": "object",
"properties": {
@@ -5377,6 +5607,52 @@
"data"
]
},
"UpdateScheduleInput_2024_06_11": {
"type": "object",
"properties": {
"name": {
"type": "string",
"example": "One-on-one coaching"
},
"timeZone": {
"type": "string",
"example": "Europe/Rome"
},
"availability": {
"example": [
{
"days": [
"Monday",
"Tuesday"
],
"startTime": "09:00",
"endTime": "10:00"
}
],
"type": "array",
"items": {
"$ref": "#/components/schemas/ScheduleAvailabilityInput_2024_06_11"
}
},
"isDefault": {
"type": "boolean",
"example": true
},
"overrides": {
"example": [
{
"date": "2024-05-20",
"startTime": "12:00",
"endTime": "14:00"
}
],
"type": "array",
"items": {
"$ref": "#/components/schemas/ScheduleOverrideInput_2024_06_11"
}
}
}
},
"UpdateScheduleOutput_2024_06_11": {
"type": "object",
"properties": {
@@ -6012,9 +6288,12 @@
"freq"
]
},
"Host": {
"TeamEventTypeResponseHost": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"userId": {
"type": "number"
},
@@ -6028,6 +6307,7 @@
}
},
"required": [
"name",
"userId"
]
},
@@ -6145,7 +6425,7 @@
"hosts": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Host"
"$ref": "#/components/schemas/TeamEventTypeResponseHost"
}
},
"assignAllTeamMembers": {
@@ -6467,6 +6747,26 @@
"userId"
]
},
"GetDefaultScheduleOutput_2024_06_11": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "success",
"enum": [
"success",
"error"
]
},
"data": {
"$ref": "#/components/schemas/ScheduleOutput_2024_06_11"
}
},
"required": [
"status",
"data"
]
},
"CreateAvailabilityInput_2024_04_15": {
"type": "object",
"properties": {
@@ -6764,6 +7064,66 @@
"data"
]
},
"UpdateScheduleInput_2024_04_15": {
"type": "object",
"properties": {
"timeZone": {
"type": "string"
},
"name": {
"type": "string"
},
"isDefault": {
"type": "boolean"
},
"schedule": {
"example": [
[],
[
{
"start": "2022-01-01T00:00:00.000Z",
"end": "2022-01-02T00:00:00.000Z"
}
],
[],
[],
[],
[],
[]
],
"items": {
"type": "array"
},
"type": "array"
},
"dateOverrides": {
"example": [
[],
[
{
"start": "2022-01-01T00:00:00.000Z",
"end": "2022-01-02T00:00:00.000Z"
}
],
[],
[],
[],
[],
[]
],
"items": {
"type": "array"
},
"type": "array"
}
},
"required": [
"timeZone",
"name",
"isDefault",
"schedule"
]
},
"EventTypeModel_2024_04_15": {
"type": "object",
"properties": {
@@ -7033,6 +7393,10 @@
},
"timeZone": {
"type": "string"
},
"organizationId": {
"type": "number",
"nullable": true
}
},
"required": [
@@ -7042,7 +7406,8 @@
"timeFormat",
"defaultScheduleId",
"weekStart",
"timeZone"
"timeZone",
"organizationId"
]
},
"GetMeOutput": {
@@ -4,6 +4,8 @@ import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { TestingModule } from "@nestjs/testing";
import { EventType } from "@prisma/client";
import { Prisma } from "@calcom/prisma/client";
export class EventTypesRepositoryFixture {
private primaReadClient: PrismaReadService["prisma"];
private prismaWriteClient: PrismaWriteService["prisma"];
@@ -26,6 +28,9 @@ export class EventTypesRepositoryFixture {
where: {
teamId,
},
include: {
hosts: true,
},
});
}
@@ -41,6 +46,10 @@ export class EventTypesRepositoryFixture {
});
}
async createTeamEventType(data: Prisma.EventTypeCreateInput) {
return this.prismaWriteClient.eventType.create({ data });
}
async delete(eventTypeId: EventType["id"]) {
return this.prismaWriteClient.eventType.delete({ where: { id: eventTypeId } });
}
@@ -23,4 +23,13 @@ export class TeamRepositoryFixture {
async delete(teamId: Team["id"]) {
return this.prismaWriteClient.team.delete({ where: { id: teamId } });
}
async getPlatformOrgTeams(organizationId: number, oAuthClientId: string) {
return this.primaReadClient.team.findMany({
where: {
parentId: organizationId,
createdByOAuthClientId: oAuthClientId,
},
});
}
}
+4 -1
View File
@@ -14,7 +14,10 @@ export default function withEmbedSsrAppDir<T extends Record<string, any>>(
) {
return async (context: GetServerSidePropsContext): Promise<T> => {
const { embed, layout } = context.query;
const isCOEPEnabled = context.query["flag.coep"] === "true";
if (isCOEPEnabled) {
context.res.setHeader("Cross-Origin-Embedder-Policy", "require-corp");
}
try {
const props = await getData(context);
@@ -99,7 +99,7 @@ const EventTypeCard: FC<EventTypeCardProps> = ({
<input
id={`${id}`}
checked={selected}
className="bg-default border-default h-4 w-4 shrink-0 cursor-pointer rounded-[4px] border ring-offset-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed"
className="bg-default border-default h-4 w-4 shrink-0 cursor-pointer rounded-[4px] border ring-offset-2 transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed"
type="checkbox"
/>
<label htmlFor={`${id}`} className="cursor-pointer text-sm">
@@ -27,10 +27,6 @@ import type { ActionType } from "@calcom/ui";
import {
Badge,
Button,
Dialog,
DialogClose,
DialogContent,
DialogFooter,
Dropdown,
DropdownItem,
DropdownMenuCheckboxItem,
@@ -43,7 +39,6 @@ import {
MeetingTimeInTimezones,
showToast,
TableActions,
TextAreaField,
Tooltip,
} from "@calcom/ui";
@@ -75,19 +70,12 @@ function BookingListItem(booking: BookingItemProps) {
i18n: { language },
} = useLocale();
const utils = trpc.useUtils();
const [rejectionReason, setRejectionReason] = useState<string>("");
const [rejectionDialogIsOpen, setRejectionDialogIsOpen] = useState(false);
const [chargeCardDialogIsOpen, setChargeCardDialogIsOpen] = useState(false);
const [viewRecordingsDialogIsOpen, setViewRecordingsDialogIsOpen] = useState<boolean>(false);
const cardCharged = booking?.payment[0]?.success;
const mutation = trpc.viewer.bookings.confirm.useMutation({
onSuccess: (data) => {
if (data?.status === BookingStatus.REJECTED) {
setRejectionDialogIsOpen(false);
showToast(t("booking_rejection_success"), "success");
} else {
showToast(t("booking_confirmation_success"), "success");
}
onSuccess: () => {
showToast(t("booking_confirmation_success"), "success");
utils.viewer.bookings.invalidate();
},
onError: () => {
@@ -122,7 +110,7 @@ function BookingListItem(booking: BookingItemProps) {
let body = {
bookingId: booking.id,
confirmed: confirm,
reason: rejectionReason,
reason: "",
};
/**
* Only pass down the recurring event id when we need to confirm the entire series, which happens in
@@ -145,9 +133,7 @@ function BookingListItem(booking: BookingItemProps) {
{
id: "reject",
label: (isTabRecurring || isTabUnconfirmed) && isRecurring ? t("reject_all") : t("reject"),
onClick: () => {
setRejectionDialogIsOpen(true);
},
href: `/booking/${booking.uid}?reject=true`,
icon: "ban",
disabled: mutation.isPending,
},
@@ -347,38 +333,7 @@ function BookingListItem(booking: BookingItemProps) {
timeFormat={userTimeFormat ?? null}
/>
)}
{/* NOTE: Should refactor this dialog component as is being rendered multiple times */}
<Dialog open={rejectionDialogIsOpen} onOpenChange={setRejectionDialogIsOpen}>
<DialogContent title={t("rejection_reason_title")} description={t("rejection_reason_description")}>
<div>
<TextAreaField
name="rejectionReason"
label={
<>
{t("rejection_reason")}
<span className="text-subtle font-normal"> (Optional)</span>
</>
}
value={rejectionReason}
onChange={(e) => setRejectionReason(e.target.value)}
/>
</div>
<DialogFooter>
<DialogClose />
<Button
disabled={mutation.isPending}
data-testid="rejection-confirm"
onClick={() => {
bookingConfirm(false);
}}>
{t("rejection_confirmation")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<tr data-testid="booking-item" className="hover:bg-muted group flex flex-col sm:flex-row">
<tr data-testid="booking-item" className="hover:bg-muted group flex flex-col transition sm:flex-row">
<td className="hidden align-top ltr:pl-6 rtl:pr-6 sm:table-cell sm:min-w-[12rem]">
<Link href={bookingLink}>
<div className="cursor-pointer py-4">
@@ -721,7 +676,7 @@ const Attendee = (attendeeProps: AttendeeProps & NoShowProps) => {
<button
data-testid="guest"
onClick={(e) => e.stopPropagation()}
className="radix-state-open:text-blue-500 hover:text-blue-500">
className="radix-state-open:text-blue-500 transition hover:text-blue-500">
{noShow ? (
<s>
{name || email} <Icon name="eye-off" className="inline h-4" />
@@ -839,7 +794,7 @@ const GroupedAttendees = (groupedAttendeeProps: GroupedAttendeeProps) => {
<button
data-testid="more-guests"
onClick={(e) => e.stopPropagation()}
className="radix-state-open:text-blue-500 hover:text-blue-500 focus:outline-none">
className="radix-state-open:text-blue-500 transition hover:text-blue-500 focus:outline-none">
{t("plus_more", { count: attendees.length - 1 })}
</button>
</DropdownMenuTrigger>
@@ -900,7 +855,7 @@ const GroupedGuests = ({ guests }: { guests: AttendeeProps[] }) => {
<DropdownMenuTrigger asChild>
<button
onClick={(e) => e.stopPropagation()}
className="radix-state-open:text-blue-500 hover:text-blue-500 focus:outline-none">
className="radix-state-open:text-blue-500 transition hover:text-blue-500 focus:outline-none">
{t("plus_more", { count: guests.length - 1 })}
</button>
</DropdownMenuTrigger>
@@ -0,0 +1,88 @@
import { useCallback, useState } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Button, TextArea } from "@calcom/ui";
type Props = {
booking: {
uid?: string | null;
id: number;
recurringEventId: string | null;
};
setIsRejectionMode: () => void;
};
export default function RejectBooking(props: Props) {
const [rejectionReason, setRejectionReason] = useState<string>("");
const { t } = useLocale();
const utils = trpc.useUtils();
const { booking } = props;
const isRecurring = booking.recurringEventId !== null;
const mutation = trpc.viewer.bookings.confirm.useMutation({
onSuccess: () => {
props.setIsRejectionMode();
utils.viewer.bookings.invalidate();
},
onError: () => {
utils.viewer.bookings.invalidate();
},
});
const bookingConfirm = async (confirm: boolean) => {
let body = {
bookingId: booking.id,
confirmed: confirm,
reason: rejectionReason,
};
if (isRecurring) {
body = Object.assign({}, body, { recurringEventId: booking.recurringEventId });
}
mutation.mutate(body);
};
const rejectBookingRef = useCallback((node: HTMLTextAreaElement) => {
if (node !== null) {
node.scrollIntoView({ behavior: "smooth" });
node.focus();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<>
<div className="mt-5 sm:mt-6">
<label className="text-default font-medium">
<>
{t("rejection_reason")}
<span className="text-subtle font-normal"> (Optional)</span>
</>
</label>
<TextArea
ref={rejectBookingRef}
placeholder={t("rejection_reason_placeholder")}
value={rejectionReason}
onChange={(e) => setRejectionReason(e.target.value)}
className="mb-4 mt-2 w-full "
rows={3}
/>
<div className="flex flex-col-reverse rtl:space-x-reverse ">
<div className="ml-auto flex w-full space-x-4 ">
<Button className="ml-auto" color="secondary" onClick={() => props.setIsRejectionMode()}>
{t("nevermind")}
</Button>
<Button
loading={mutation.isPending}
data-testid="rejection-confirm"
onClick={() => {
bookingConfirm(false);
}}>
{t("rejection_confirmation")}
</Button>
</div>
</div>
</div>
</>
);
}
@@ -1,14 +1,18 @@
import * as RadioGroup from "@radix-ui/react-radio-group";
import { useSession } from "next-auth/react";
import type { EventTypeSetup } from "pages/event-types/[type]";
import { useState } from "react";
import React, { useState } from "react";
import { useFormContext, Controller } from "react-hook-form";
import { z } from "zod";
import { getTemplateFieldsSchema } from "@calcom/features/ee/cal-ai-phone/getTemplateFieldsSchema";
import { TEMPLATES_FIELDS } from "@calcom/features/ee/cal-ai-phone/template-fields-map";
import type { TemplateType } from "@calcom/features/ee/cal-ai-phone/zod-utils";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
import type { FormValues } from "@calcom/features/eventtypes/lib/types";
import { ComponentForField } from "@calcom/features/form-builder/FormBuilderField";
import { classNames } from "@calcom/lib";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { AIPhoneSettingSchema } from "@calcom/prisma/zod-utils";
import { trpc } from "@calcom/trpc/react";
import {
Button,
@@ -17,9 +21,9 @@ import {
SettingsToggle,
Divider,
TextField,
TextAreaField,
PhoneInput,
showToast,
Icon,
} from "@calcom/ui";
type AIEventControllerProps = {
@@ -85,10 +89,64 @@ export default function AIEventController({ eventType, isTeamEvent }: AIEventCon
);
}
const ErrorMessage = ({ fieldName, message }: { fieldName: string; message: string }) => {
const { t } = useLocale();
return (
<div data-testid={`error-message-${fieldName}`} className="mt-2 flex items-center text-sm text-red-700 ">
<Icon name="info" className="h-3 w-3 ltr:mr-2 rtl:ml-2" />
<p>{t(message || "invalid_input")}</p>
</div>
);
};
const TemplateFields = () => {
const { t } = useLocale();
const formMethods = useFormContext<FormValues>();
const { control, watch } = formMethods;
const templateType = watch("aiPhoneCallConfig.templateType");
const fields = TEMPLATES_FIELDS[templateType as TemplateType];
return (
<div className="space-y-4">
{fields?.map((field) => (
<div key={field.name}>
<Controller
control={control}
name={`aiPhoneCallConfig.${field.name}`}
render={({ field: { value, onChange }, fieldState: { error } }) => {
const { variableName, ...restField } = field;
const variableInfo = !!variableName ? `: ${t("variable")} {{${variableName}}}` : "";
return (
<div>
<ComponentForField
field={{
...restField,
label: `${t(field.defaultLabel)}${variableInfo}`,
placeholder: t(field.placeholder),
}}
value={value ?? ""}
readOnly={false}
setValue={(val: unknown) => {
onChange(val);
}}
/>
{error?.message && <ErrorMessage message={error.message} fieldName={field.name} />}
</div>
);
}}
/>
</div>
))}
</div>
);
};
const AISettings = ({ eventType }: { eventType: EventTypeSetup }) => {
const { t } = useLocale();
const formMethods = useFormContext<FormValues>();
const [calApiKey, setCalApiKey] = useState("");
const createCallMutation = trpc.viewer.organizations.createPhoneCall.useMutation({
@@ -98,43 +156,25 @@ const AISettings = ({ eventType }: { eventType: EventTypeSetup }) => {
}
},
onError: (err) => {
showToast(t("something_went_wrong"), "error");
const message = err?.message ? err.message : t("something_went_wrong");
showToast(message, "error");
},
});
// const [createModalOpen, setCreateModalOpen] = useState(false);
// const NewPhoneButton = () => {
// const { t } = useLocale();
// return (
// <Button
// color="primary"
// data-testid="new_phone_number"
// StartIcon={Plus}
// onClick={() => setCreateModalOpen(true)}>
// {t("New Phone number")}
// </Button>
// );
// };
// v1 will require the user to log in to Retellai.com to create a phone number, and an agent and
// authorize it with the Cal.com API key / OAuth
// const retellAuthorized = true; // TODO: call retellAPI here
const handleSubmit = async () => {
try {
const values = formMethods.getValues("aiPhoneCallConfig");
const { templateType } = values;
const data = await AIPhoneSettingSchema.parseAsync({
generalPrompt: values.generalPrompt,
beginMessage: values.beginMessage,
enabled: values.enabled,
guestName: values.guestName,
guestEmail: values.guestEmail.trim().length ? values.guestEmail : undefined,
guestCompany: values.guestCompany.trim().length ? values.guestCompany : undefined,
const schema = getTemplateFieldsSchema({ templateType });
const data = schema.parse({
...values,
guestEmail: values.guestEmail && values.guestEmail.trim().length ? values.guestEmail : undefined,
guestCompany:
values.guestCompany && values.guestCompany.trim().length ? values.guestCompany : undefined,
guestName: values.guestName && values.guestName.trim().length ? values.guestName : undefined,
eventTypeId: eventType.id,
numberToCall: values.numberToCall,
yourPhoneNumber: values.yourPhoneNumber,
calApiKey,
});
@@ -144,6 +184,15 @@ const AISettings = ({ eventType }: { eventType: EventTypeSetup }) => {
const fieldName = err.issues?.[0]?.path?.[0];
const message = err.issues?.[0]?.message;
showToast(`Error on ${fieldName}: ${message} `, "error");
const issues = err.issues;
for (const issue of issues) {
const fieldName = `aiPhoneCallConfig.${issue.path[0]}` as unknown as keyof FormValues;
formMethods.setError(fieldName, {
type: "custom",
message: issue.message,
});
}
} else {
showToast(t("something_went_wrong"), "error");
}
@@ -157,68 +206,89 @@ const AISettings = ({ eventType }: { eventType: EventTypeSetup }) => {
<Label>{t("your_phone_number")}</Label>
<Controller
name="aiPhoneCallConfig.yourPhoneNumber"
render={({ field: { onChange, value } }) => {
render={({ field: { onChange, value, name }, fieldState: { error } }) => {
return (
<PhoneInput
required
placeholder={t("your_phone_number")}
id="aiPhoneCallConfig.yourPhoneNumber"
name="aiPhoneCallConfig.yourPhoneNumber"
value={value}
onChange={(val) => {
onChange(val);
}}
/>
<div>
<PhoneInput
required
placeholder={t("your_phone_number")}
id="aiPhoneCallConfig.yourPhoneNumber"
name="aiPhoneCallConfig.yourPhoneNumber"
value={value}
onChange={(val) => {
onChange(val);
}}
/>
{error?.message && <ErrorMessage message={error.message} fieldName={name} />}
</div>
);
}}
/>
<>
<RadioGroup.Root
defaultValue={eventType?.aiPhoneCallConfig?.templateType ?? "CUSTOM_TEMPLATE"}
onValueChange={(val) => {
formMethods.setValue("aiPhoneCallConfig.templateType", val, { shouldDirty: true });
}}>
<div className="flex gap-2">
<div className="flex flex-1 rounded-lg border p-4">
<RadioGroup.Item
id="CHECK_IN_APPOINTMENT"
key="CHECK_IN_APPOINTMENT"
value="CHECK_IN_APPOINTMENT"
className="min-w-4 bg-default border-default flex h-4 w-4 cursor-pointer items-center rounded-full border focus:border-2 focus:outline-none ltr:mr-2 rtl:ml-2 ">
<RadioGroup.Indicator className="after:bg-inverted relative flex h-4 w-4 items-center justify-center after:block after:h-2 after:w-2 after:rounded-full" />
</RadioGroup.Item>
<label className="h-full" htmlFor="CHECK_IN_APPOINTMENT">
<h2 className="font-semibold leading-tight">{t("check_in_assistant")}</h2>
<p className="text-subtle mt-2">{t("check_in_assistant_description")}</p>
</label>
</div>
<div className="flex flex-1 rounded-lg border p-4">
<RadioGroup.Item
id="CUSTOM_TEMPLATE"
key="CUSTOM_TEMPLATE"
value="CUSTOM_TEMPLATE"
className="min-w-4 bg-default border-default flex h-4 w-4 cursor-pointer items-center rounded-full border focus:border-2 focus:outline-none ltr:mr-2 rtl:ml-2">
<RadioGroup.Indicator className="after:bg-inverted relative flex h-4 w-4 items-center justify-center after:block after:h-2 after:w-2 after:rounded-full" />
</RadioGroup.Item>
<label htmlFor="CUSTOM_TEMPLATE">
<h2 className="font-semibold leading-tight">{t("custom_template")}</h2>
<p className="text-subtle mt-2">{t("create_your_own_prompt")}</p>
</label>
</div>
</div>
</RadioGroup.Root>
</>
<Label>{t("number_to_call")}</Label>
<Controller
name="aiPhoneCallConfig.numberToCall"
render={({ field: { onChange, value } }) => {
render={({ field: { onChange, value, name }, fieldState: { error } }) => {
return (
<PhoneInput
required
placeholder={t("phone_number")}
id="aiPhoneCallConfig.numberToCall"
name="aiPhoneCallConfig.numberToCall"
value={value}
onChange={(val) => {
onChange(val);
}}
/>
<div>
<PhoneInput
required
placeholder={t("phone_number")}
id="aiPhoneCallConfig.numberToCall"
name="aiPhoneCallConfig.numberToCall"
value={value}
onChange={(val) => {
onChange(val);
}}
/>
{error?.message && <ErrorMessage message={error.message} fieldName={name} />}
</div>
);
}}
/>
<TemplateFields />
<Divider />
</>
<TextField
type="text"
hint="Variable: {{name}}"
label={t("guest_name")}
placeholder="Jane Doe"
{...formMethods.register("aiPhoneCallConfig.guestName")}
/>
<TextField
type="text"
hint="Variable: {{email}}"
label={t("guest_email")}
placeholder="jane@acme.com"
{...formMethods.register("aiPhoneCallConfig.guestEmail")}
/>
<TextField
type="text"
hint="Variable: {{company}}"
label={t("guest_company")}
placeholder="Acme"
{...formMethods.register("aiPhoneCallConfig.guestCompany")}
/>
<TextField
type="text"
hint="For eg:- cal_live_0123.."
@@ -232,26 +302,6 @@ const AISettings = ({ eventType }: { eventType: EventTypeSetup }) => {
/>
<Divider />
<TextAreaField
rows={3}
required
placeholder={t("general_prompt")}
label={t("general_prompt")}
{...formMethods.register("aiPhoneCallConfig.generalPrompt")}
onChange={(e) => {
formMethods.setValue("aiPhoneCallConfig.generalPrompt", e.target.value, { shouldDirty: true });
}}
/>
<TextAreaField
rows={3}
placeholder={t("begin_message")}
label={t("begin_message")}
{...formMethods.register("aiPhoneCallConfig.beginMessage")}
onChange={(e) => {
formMethods.setValue("aiPhoneCallConfig.beginMessage", e.target.value, { shouldDirty: true });
}}
/>
<Button
disabled={createCallMutation.isPending}
@@ -268,34 +318,6 @@ const AISettings = ({ eventType }: { eventType: EventTypeSetup }) => {
and learn how to build workflows.
</small> */}
</div>
{/* TODO:
<>
<EmptyScreen
Icon={Phone}
headline={t("Create your phone number")}
description={t(
"This phone number can be called by guests but can also do proactive outbound calls by the AI agent."
)}
buttonRaw={
<div className="flex justify-between gap-2">
<NewPhoneButton />
<Button color="secondary">{t("learn_more")}</Button>
</div>
}
/>
<Dialog open={createModalOpen} onOpenChange={(isOpen) => !isOpen && setCreateModalOpen(false)}>
<DialogContent
enableOverflow
title={t("Create phone number")}
description={t("This number can later be called or can do proactive outbound calls")}>
<div className="mb-12 mt-4">
<TextField placeholder="+415" hint="Area Code" />
</div>
</DialogContent>
</Dialog>
</>
*/}
</div>
);
};
@@ -53,45 +53,63 @@ const CustomEventTypeModalForm: FC<CustomEventTypeModalFormProps> = (props) => {
type="text"
placeholder={placeHolder_}
{...register("customEventName", {
validate: (value) =>
validateCustomEventName(value, t("invalid_event_name_variables"), event.bookingFields),
validate: (value) => {
const validationResult = validateCustomEventName(value, event.bookingFields);
return typeof validationResult === "string"
? t("invalid_event_name_variables", { item: validationResult })
: validationResult;
},
})}
className="mb-0"
/>
<div className="pt-6 text-sm">
<div className="bg-subtle mb-6 rounded-md p-2">
<h1 className="text-emphasis mb-2 ml-1 font-medium">{t("available_variables")}</h1>
<div className="mb-2.5 flex font-normal">
<p className="text-subtle ml-1 mr-5 w-28">{`{Event type title}`}</p>
<p className="text-emphasis">{t("event_name_info")}</p>
</div>
<div className="mb-2.5 flex font-normal">
<p className="text-subtle ml-1 mr-5 w-28">{`{Organiser}`}</p>
<p className="text-emphasis">{t("your_full_name")}</p>
</div>
<div className="mb-2.5 flex font-normal">
<p className="text-subtle ml-1 mr-5 w-28">{`{Organiser first name}`}</p>
<p className="text-emphasis">{t("organizer_first_name")}</p>
</div>
<div className="mb-2.5 flex font-normal">
<p className="text-subtle ml-1 mr-5 w-28">{`{Scheduler}`}</p>
<p className="text-emphasis">{t("scheduler_full_name")}</p>
</div>
{isNameFieldSplit && (
<div className="scroll-bar h-[216px] overflow-y-auto">
<div className="mb-2.5 flex font-normal">
<p className="text-subtle ml-1 mr-5 w-28">{`{Scheduler first name}`}</p>
<p className="text-emphasis">{t("scheduler_first_name")}</p>
<p className="text-subtle ml-1 mr-5 w-32">{`{Event type title}`}</p>
<p className="text-emphasis">{t("event_name_info")}</p>
</div>
)}
{isNameFieldSplit && (
<div className="mb-2.5 flex font-normal">
<p className="text-subtle ml-1 mr-5 w-28">{`{Scheduler last name}`}</p>
<p className="text-emphasis">{t("scheduler_last_name")}</p>
<p className="text-subtle ml-1 mr-5 w-32">{`{Organiser}`}</p>
<p className="text-emphasis">{t("your_full_name")}</p>
</div>
)}
<div className="mb-1 flex font-normal">
<p className="text-subtle ml-1 mr-5 w-28">{`{Location}`}</p>
<p className="text-emphasis">{t("location_info")}</p>
<div className="mb-2.5 flex font-normal">
<p className="text-subtle ml-1 mr-5 w-32">{`{Organiser first name}`}</p>
<p className="text-emphasis">{t("organizer_first_name")}</p>
</div>
<div className="mb-2.5 flex font-normal">
<p className="text-subtle ml-1 mr-5 w-32">{`{Scheduler}`}</p>
<p className="text-emphasis">{t("scheduler_full_name")}</p>
</div>
{isNameFieldSplit && (
<div className="mb-2.5 flex font-normal">
<p className="text-subtle ml-1 mr-5 w-32">{`{Scheduler first name}`}</p>
<p className="text-emphasis">{t("scheduler_first_name")}</p>
</div>
)}
{isNameFieldSplit && (
<div className="mb-2.5 flex font-normal">
<p className="text-subtle ml-1 mr-5 w-32">{`{Scheduler last name}`}</p>
<p className="text-emphasis">{t("scheduler_last_name")}</p>
</div>
)}
<div className="mb-2.5 flex font-normal">
<p className="text-subtle ml-1 mr-5 w-32">{`{Location}`}</p>
<p className="text-emphasis">{t("location_info")}</p>
</div>
{event.bookingFields && (
<p className="text-subtle mb-2 ml-1 font-medium">{t("booking_question_response_variables")}</p>
)}
{event.bookingFields &&
Object.keys(event.bookingFields).map((bookingfield, index) => (
<div key={index} className="mb-2.5 flex font-normal">
<p className="text-subtle ml-1 mr-5 w-32">{`{${bookingfield}}`}</p>
<p className="text-emphasis capitalize">
{event.bookingFields?.[bookingfield]?.toString()}
</p>
</div>
))}
</div>
</div>
<h1 className="mb-2 text-[14px] font-medium leading-4">{t("preview")}</h1>
@@ -53,6 +53,7 @@ type Props = {
isUserOrganizationAdmin: boolean;
bookerUrl: string;
activeWebhooksNumber: number;
onDelete: () => void;
};
type getNavigationProps = {
@@ -117,7 +118,12 @@ function DeleteDialog({
eventTypeId,
open,
onOpenChange,
}: { isManagedEvent: string; eventTypeId: number } & Pick<DialogProps, "open" | "onOpenChange">) {
onDelete,
}: {
isManagedEvent: string;
eventTypeId: number;
onDelete: () => void;
} & Pick<DialogProps, "open" | "onOpenChange">) {
const utils = trpc.useUtils();
const { t } = useLocale();
const router = useRouter();
@@ -125,6 +131,7 @@ function DeleteDialog({
onSuccess: async () => {
await utils.viewer.eventTypes.invalidate();
showToast(t("event_type_deleted_successfully"), "success");
onDelete();
router.push("/event-types");
onOpenChange?.(false);
},
@@ -181,6 +188,7 @@ function EventTypeSingleLayout({
isUserOrganizationAdmin,
bookerUrl,
activeWebhooksNumber,
onDelete,
}: Props) {
const { t } = useLocale();
const eventTypesLockedByOrg = eventType.team?.parent?.organizationSettings?.lockEventTypeCreationForUsers;
@@ -510,6 +518,7 @@ function EventTypeSingleLayout({
isManagedEvent={isManagedEvent}
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
onDelete={onDelete}
/>
<EventTypeEmbedDialog />
@@ -46,6 +46,7 @@ export const OAuthClientsList = ({ oauthClients, isDeleting, handleDelete }: OAu
isLoading={isDeleting}
onDelete={handleDelete}
areEmailsEnabled={client.areEmailsEnabled}
organizationId={client.organizationId}
/>
);
})}
@@ -22,6 +22,7 @@ type OAuthClientCardProps = {
secret: string;
onDelete: (id: string) => Promise<void>;
isLoading: boolean;
organizationId: number;
};
export const OAuthClientCard = ({
@@ -38,6 +39,7 @@ export const OAuthClientCard = ({
onDelete,
isLoading,
areEmailsEnabled,
organizationId,
}: OAuthClientCardProps) => {
const router = useRouter();
@@ -113,6 +115,21 @@ export const OAuthClientCard = ({
/>
</div>
</div>
<div className="flex flex-col gap-2">
<div className="flex flex-row items-center gap-2">
<div className="font-semibold">Organization Id:</div>
<div>{organizationId}</div>
<Icon
name="clipboard"
type="button"
className="h-4 w-4 cursor-pointer"
onClick={() => {
navigator.clipboard.writeText(organizationId.toString());
showToast("Organization id copied to clipboard.", "success");
}}
/>
</div>
</div>
<div className="border-subtle flex text-sm">
<span className="font-semibold">Permissions: </span>
{permissions ? <div className="flex">{clientPermissions}</div> : <>&nbsp;Disabled</>}
@@ -148,11 +165,7 @@ export const OAuthClientCard = ({
onClick={() => router.push(`/settings/platform/oauth-clients/${id}/edit`)}>
Edit
</Button>
<Button
className="bg-red-500 text-white hover:bg-red-600"
loading={isLoading}
disabled={isLoading}
onClick={() => onDelete(id)}>
<Button color="destructive" loading={isLoading} disabled={isLoading} onClick={() => onDelete(id)}>
Delete
</Button>
</div>
@@ -77,7 +77,7 @@ export const OAuthClientForm = ({
<input
{...register(`${permissionKey}Read`)}
id={`${permissionKey}Read`}
className="bg-default border-default h-4 w-4 shrink-0 cursor-pointer rounded-[4px] border ring-offset-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed"
className="bg-default border-default h-4 w-4 shrink-0 cursor-pointer rounded-[4px] border ring-offset-2 transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed"
type="checkbox"
disabled={!!defaultValues}
/>
@@ -89,7 +89,7 @@ export const OAuthClientForm = ({
<input
{...register(`${permissionKey}Write`)}
id={`${permissionKey}Write`}
className="bg-default border-default h-4 w-4 shrink-0 cursor-pointer rounded-[4px] border ring-offset-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed"
className="bg-default border-default h-4 w-4 shrink-0 cursor-pointer rounded-[4px] border ring-offset-2 transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed"
type="checkbox"
disabled={!!defaultValues}
/>
@@ -224,7 +224,7 @@ export const OAuthClientForm = ({
<input
{...register("areEmailsEnabled")}
id="areEmailsEnabled"
className="bg-default border-default h-4 w-4 shrink-0 cursor-pointer rounded-[4px] border ring-offset-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed"
className="bg-default border-default h-4 w-4 shrink-0 cursor-pointer rounded-[4px] border ring-offset-2 transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed"
type="checkbox"
disabled={isFormDisabled}
/>
@@ -48,7 +48,7 @@ const CheckboxField = forwardRef<HTMLInputElement, Props>(
{...rest}
ref={ref}
type="checkbox"
className="text-emphasis focus:ring-emphasis dark:text-muted border-default bg-default h-4 w-4 rounded"
className="text-emphasis focus:ring-emphasis dark:text-muted border-default bg-default h-4 w-4 rounded transition"
/>
</div>
<span className="ms-2 text-sm">{description}</span>
+29
View File
@@ -0,0 +1,29 @@
import type { EventTypeAssignedUsers, EventTypeHosts } from "~/event-types/views/event-types-single-view";
// This function checks if EventType requires assignment.
// returns true: if EventType requires assignment but there is no assignment yet done by the user.
// returns false: for all other scenarios.
export function checkForEmptyAssignment({
assignedUsers,
hosts,
isManagedEventType,
assignAllTeamMembers,
}: {
assignedUsers: EventTypeAssignedUsers;
hosts: EventTypeHosts;
isManagedEventType: boolean;
assignAllTeamMembers: boolean;
}): boolean {
// If Team-events have assignAllTeamMembers checked, return false as assignemnt is complete.
if (assignAllTeamMembers) {
return false;
}
// For managed eventtype check if assigned users are empty.
// For non-managed eventtype check if hosts are empty.
if (isManagedEventType ? assignedUsers.length === 0 : hosts.length === 0) {
return true;
}
return false;
}
+2
View File
@@ -1574,6 +1574,7 @@ async function createOrg(
metadata: {
...(data.metadata || {}),
},
createdByOAuthClient: undefined,
},
});
}
@@ -1591,6 +1592,7 @@ async function createTeamOutsideOrg(
...(data.metadata || {}),
isOrganization: false,
},
createdByOAuthClient: undefined,
},
});
}
+132
View File
@@ -133,6 +133,24 @@ describe("withEmbedSsr", () => {
},
});
});
it('should add Cross-Origin-Embedder-Policy header when "flag.coep" query param is true', async () => {
const withEmbedGetSsr = withEmbedSsr(
getServerSidePropsFnGenerator({
redirectUrl: "/reschedule",
})
);
const context = getServerSidePropsContextArg({
embedRelatedParams: {
layout: "week_view",
embed: "",
"flag.coep": "true",
},
});
await withEmbedGetSsr(context);
expect(context.res.getHeader("Cross-Origin-Embedder-Policy")).toBe("require-corp");
});
});
describe("when redirect destination is absolute, should add /embed to end of the path", () => {
@@ -206,6 +224,23 @@ describe("withEmbedSsr", () => {
},
});
});
it('should add Cross-Origin-Embedder-Policy header when "flag.coep" query param is true', async () => {
const withEmbedGetSsr = withEmbedSsr(
getServerSidePropsFnGenerator({
redirectUrl: "https://calcom.cal.local/owner",
})
);
const context = getServerSidePropsContextArg({
embedRelatedParams: {
layout: "week_view",
embed: "",
"flag.coep": "true",
},
});
await withEmbedGetSsr(context);
expect(context.res.getHeader("Cross-Origin-Embedder-Policy")).toBe("require-corp");
});
});
});
@@ -233,6 +268,85 @@ describe("withEmbedSsr", () => {
},
});
});
it('should add Cross-Origin-Embedder-Policy header when "flag.coep" query param is true', async () => {
const withEmbedGetSsr = withEmbedSsr(
getServerSidePropsFnGenerator({
props: {
prop1: "value1",
},
})
);
const context = getServerSidePropsContextArg({
embedRelatedParams: {
layout: "week_view",
embed: "",
"flag.coep": "true",
},
});
await withEmbedGetSsr(context);
expect(context.res.getHeader("Cross-Origin-Embedder-Policy")).toBe("require-corp");
});
it('should not add Cross-Origin-Embedder-Policy header when "flag.coep" query param is false', async () => {
const withEmbedGetSsr = withEmbedSsr(
getServerSidePropsFnGenerator({
props: {
prop1: "value1",
},
})
);
const context = getServerSidePropsContextArg({
embedRelatedParams: {
layout: "week_view",
embed: "",
"flag.coep": "false",
},
});
await withEmbedGetSsr(context);
expect(context.res.getHeader("Cross-Origin-Embedder-Policy")).toBeUndefined();
});
it('should not add Cross-Origin-Embedder-Policy header when "flag.coep" query param is not set', async () => {
const withEmbedGetSsr = withEmbedSsr(
getServerSidePropsFnGenerator({
props: {
prop1: "value1",
},
})
);
const context = getServerSidePropsContextArg({
embedRelatedParams: {
layout: "week_view",
embed: "",
},
});
await withEmbedGetSsr(context);
expect(context.res.getHeader("Cross-Origin-Embedder-Policy")).toBeUndefined();
});
it('should not add Cross-Origin-Embedder-Policy header when "flag.coep" query param is set to something other than true', async () => {
const withEmbedGetSsr = withEmbedSsr(
getServerSidePropsFnGenerator({
props: {
prop1: "value1",
},
})
);
const context = getServerSidePropsContextArg({
embedRelatedParams: {
layout: "week_view",
embed: "",
"flag.coep": "abasdf",
},
});
await withEmbedGetSsr(context);
expect(context.res.getHeader("Cross-Origin-Embedder-Policy")).toBeUndefined();
});
});
describe("when gSSP doesn't have props or redirect ", () => {
@@ -254,5 +368,23 @@ describe("withEmbedSsr", () => {
expect(ret).toEqual({ notFound: true });
});
it('should add Cross-Origin-Embedder-Policy header when "flag.coep" query param is true', async () => {
const withEmbedGetSsr = withEmbedSsr(
getServerSidePropsFnGenerator({
notFound: true,
})
);
const context = getServerSidePropsContextArg({
embedRelatedParams: {
layout: "week_view",
embed: "",
"flag.coep": "true",
},
});
await withEmbedGetSsr(context);
expect(context.res.getHeader("Cross-Origin-Embedder-Policy")).toBe("require-corp");
});
});
});
+5
View File
@@ -11,6 +11,10 @@ export default function withEmbedSsr(getServerSideProps: GetServerSideProps) {
const ssrResponse = await getServerSideProps(context);
const embed = context.query.embed;
const layout = context.query.layout;
const isCOEPEnabled = context.query["flag.coep"] === "true";
if (isCOEPEnabled) {
context.res.setHeader("Cross-Origin-Embedder-Policy", "require-corp");
}
if ("redirect" in ssrResponse) {
const destinationUrl = ssrResponse.redirect.destination;
@@ -44,6 +48,7 @@ export default function withEmbedSsr(getServerSideProps: GetServerSideProps) {
if (!("props" in ssrResponse)) {
return ssrResponse;
}
return {
...ssrResponse,
props: {
@@ -63,6 +63,7 @@ import {
} from "@calcom/ui";
import PageWrapper from "@calcom/web/components/PageWrapper";
import CancelBooking from "@calcom/web/components/booking/CancelBooking";
import RejectBooking from "@calcom/web/components/booking/RejectBooking";
import EventReservationSchema from "@calcom/web/components/schemas/EventReservationSchema";
import { timeZone } from "@calcom/web/lib/clock";
@@ -78,6 +79,7 @@ const querySchema = z.object({
email: z.string().optional(),
eventTypeSlug: z.string().optional(),
cancel: stringToBoolean,
reject: stringToBoolean,
allRemainingBookings: stringToBoolean,
changes: stringToBoolean,
reschedule: stringToBoolean,
@@ -114,6 +116,7 @@ export default function Success(props: PageProps) {
allRemainingBookings,
isSuccessBookingPage,
cancel: isCancellationMode,
reject: isRejectionMode,
formerTime,
email,
seatReferenceUid,
@@ -209,6 +212,16 @@ export default function Success(props: PageProps) {
router.replace(`${pathname}?${_searchParams.toString()}`);
}
function setIsRejectionMode() {
const _searchParams = new URLSearchParams(searchParams ?? undefined);
if (_searchParams.get("reject")) {
_searchParams.delete("reject");
}
router.replace(`${pathname}?${_searchParams.toString()}`);
}
let evtName = eventType.eventName;
if (eventType.isDynamic && bookingInfo.responses?.title) {
evtName = bookingInfo.responses.title as string;
@@ -660,6 +673,7 @@ export default function Success(props: PageProps) {
{!requiresLoginToUpdate &&
(!needsConfirmation || !userIsOwner) &&
isReschedulable &&
!isRejectionMode &&
(!isCancellationMode ? (
<>
<hr className="border-subtle mb-8" />
@@ -714,6 +728,19 @@ export default function Success(props: PageProps) {
/>
</>
))}
{userIsOwner && !isCancelled && isRejectionMode && (
<>
<hr className="border-subtle" />
<RejectBooking
booking={{
id: bookingInfo.id,
uid: bookingInfo?.uid,
recurringEventId: bookingInfo.recurringEventId,
}}
setIsRejectionMode={setIsRejectionMode}
/>
</>
)}
{userIsOwner &&
!needsConfirmation &&
!isCancellationMode &&
@@ -15,6 +15,10 @@ import { z } from "zod";
import checkForMultiplePaymentApps from "@calcom/app-store/_utils/payments/checkForMultiplePaymentApps";
import { getEventLocationType } from "@calcom/app-store/locations";
import { validateCustomEventName } from "@calcom/core/event";
import {
DEFAULT_PROMPT_VALUE,
DEFAULT_BEGIN_MESSAGE,
} from "@calcom/features/ee/cal-ai-phone/promptTemplates";
import type { Workflow } from "@calcom/features/ee/workflows/lib/types";
import type { ChildrenEventType } from "@calcom/features/eventtypes/components/ChildrenEventTypeSelect";
import type { FormValues } from "@calcom/features/eventtypes/lib/types";
@@ -26,6 +30,7 @@ import { HttpError } from "@calcom/lib/http-error";
import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
import { validateBookerLayouts } from "@calcom/lib/validateBookerLayouts";
import type { Prisma } from "@calcom/prisma/client";
import { SchedulingType } from "@calcom/prisma/enums";
import type { customInputSchema, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import { eventTypeBookingFields } from "@calcom/prisma/zod-utils";
import type { RouterOutputs } from "@calcom/trpc/react";
@@ -33,47 +38,12 @@ import { trpc } from "@calcom/trpc/react";
import { Form, showToast } from "@calcom/ui";
import type { AppProps } from "@lib/app-providers";
import { checkForEmptyAssignment } from "@lib/checkForEmptyAssignment";
import { EventTypeSingleLayout } from "@components/eventtype/EventTypeSingleLayout";
import { type PageProps } from "~/event-types/views/event-types-single-view.getServerSideProps";
const DEFAULT_PROMPT_VALUE = `## You are helping user set up a call with the support team. The appointment is 15 min long. You are a pleasant and friendly.
## Style Guardrails
Be Concise: Respond succinctly, addressing one topic at most.
Embrace Variety: Use diverse language and rephrasing to enhance clarity without repeating content.
Be Conversational: Use everyday language, making the chat feel like talking to a friend.
Be Proactive: Lead the conversation, often wrapping up with a question or next-step suggestion.
Avoid multiple questions in a single response.
Get clarity: If the user only partially answers a question, or if the answer is unclear, keep asking to get clarity.
Use a colloquial way of referring to the date (like Friday, Jan 14th, or Tuesday, Jan 12th, 2024 at 8am).
If you are saying a time like 8:00 AM, just say 8 AM and emit the trailing zeros.
## Response Guideline
Adapt and Guess: Try to understand transcripts that may contain transcription errors. Avoid mentioning \"transcription error\" in the response.
Stay in Character: Keep conversations within your role'''s scope, guiding them back creatively without repeating.
Ensure Fluid Dialogue: Respond in a role-appropriate, direct manner to maintain a smooth conversation flow.
## Schedule Rule
Current time is {{current_time}}. You only schedule time in current calendar year, you cannot schedule time that'''s in the past.
## Task Steps
1. I am here to learn more about your issue and help schedule an appointment with our support team.
2. If {{email}} is not unknown then Use name {{name}} and email {{email}} for creating booking else Ask for user name and email and Confirm the name and email with user by reading it back to user.
3. Ask user for \"When would you want to meet with one of our representive\".
4. Call function check_availability to check for availability in the user provided time range.
- if availability exists, inform user about the availability range (do not repeat the detailed available slot) and ask user to choose from it. Make sure user chose a slot within detailed available slot.
- if availability does not exist, ask user to select another time range for the appointment, repeat this step 3.
5. Confirm the date and time selected by user: \"Just to confirm, you want to book the appointment at ...\".
6. Once confirmed, call function book_appointment to book the appointment.
- if booking returned booking detail, it means booking is successful, proceed to step 7.
- if booking returned error message, let user know why the booking was not successful, and maybe start over with step 3.
7. Inform the user booking is successful, and ask if user have any questions. Answer them if there are any.
8. After all questions answered, call function end_call to hang up.`;
const DEFAULT_BEGIN_MESSAGE = "Hi. How are you doing?";
// These can't really be moved into calcom/ui due to the fact they use infered getserverside props typings;
const EventSetupTab = dynamic(() =>
import("@components/eventtype/EventSetupTab").then((mod) => mod.EventSetupTab)
@@ -144,6 +114,8 @@ const querySchema = z.object({
export type EventTypeSetupProps = RouterOutputs["viewer"]["eventTypes"]["get"];
export type EventTypeSetup = RouterOutputs["viewer"]["eventTypes"]["get"]["eventType"];
export type EventTypeAssignedUsers = RouterOutputs["viewer"]["eventTypes"]["get"]["eventType"]["children"];
export type EventTypeHosts = RouterOutputs["viewer"]["eventTypes"]["get"]["eventType"]["hosts"];
export const locationsResolver = (t: TFunction) => {
return z
@@ -226,6 +198,7 @@ const EventTypePage = (props: EventTypeSetupProps & { allActiveWorkflows?: Workf
const [isOpenAssignmentWarnDialog, setIsOpenAssignmentWarnDialog] = useState<boolean>(false);
const [pendingRoute, setPendingRoute] = useState("");
const leaveWithoutAssigningHosts = useRef(false);
const isTeamEventTypeDeleted = useRef(false);
const [animationParentRef] = useAutoAnimate<HTMLDivElement>();
const updateMutation = trpc.viewer.eventTypes.update.useMutation({
onSuccess: async () => {
@@ -367,6 +340,8 @@ const EventTypePage = (props: EventTypeSetupProps & { allActiveWorkflows?: Workf
guestCompany: eventType.aiPhoneCallConfig?.guestCompany,
yourPhoneNumber: eventType.aiPhoneCallConfig?.yourPhoneNumber,
numberToCall: eventType.aiPhoneCallConfig?.numberToCall,
templateType: eventType.aiPhoneCallConfig?.templateType ?? "CUSTOM_TEMPLATE",
schedulerName: eventType.aiPhoneCallConfig?.schedulerName,
},
};
}, [eventType, periodDates, metadata]);
@@ -379,13 +354,15 @@ const EventTypePage = (props: EventTypeSetupProps & { allActiveWorkflows?: Workf
// Make it optional because it's not submitted from all tabs of the page
eventName: z
.string()
.refine(
(val) =>
validateCustomEventName(val, t("invalid_event_name_variables"), bookingFields) === true,
{
message: t("invalid_event_name_variables"),
.superRefine((val, ctx) => {
const validationResult = validateCustomEventName(val, bookingFields);
if (validationResult !== true) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t("invalid_event_name_variables", { item: validationResult }),
});
}
)
})
.optional(),
length: z.union([z.string().transform((val) => +val), z.number()]).optional(),
offsetStart: z.union([z.string().transform((val) => +val), z.number()]).optional(),
@@ -400,40 +377,42 @@ const EventTypePage = (props: EventTypeSetupProps & { allActiveWorkflows?: Workf
formState: { isDirty: isFormDirty, dirtyFields },
} = formMethods;
// useEffect(() => {
// const handleRouteChange = (url: string) => {
// const paths = url.split("/");
//
// // Check if event is managed event type - skip if there is assigned users
// const assignedUsers = eventType.children;
// const isManagedEventType = eventType.schedulingType === SchedulingType.MANAGED;
// if (eventType.assignAllTeamMembers) {
// return;
// } else if (isManagedEventType && assignedUsers.length > 0) {
// return;
// }
//
// const hosts = eventType.hosts;
// if (
// !leaveWithoutAssigningHosts.current &&
// !!team &&
// (hosts.length === 0 || assignedUsers.length === 0) &&
// (url === "/event-types" || paths[1] !== "event-types")
// ) {
// setIsOpenAssignmentWarnDialog(true);
// setPendingRoute(url);
// router.events.emit(
// "routeChangeError",
// new Error(`Aborted route change to ${url} because none was assigned to team event`)
// );
// throw "Aborted";
// }
// };
// router.events.on("routeChangeStart", handleRouteChange);
// return () => {
// router.events.off("routeChangeStart", handleRouteChange);
// };
// }, [router]);
const onDelete = () => {
isTeamEventTypeDeleted.current = true;
};
useEffect(() => {
const handleRouteChange = (url: string) => {
const paths = url.split("/");
// If the event-type is deleted, we can't show the empty assignment warning
if (isTeamEventTypeDeleted.current) return;
if (
!!team &&
!leaveWithoutAssigningHosts.current &&
(url === "/event-types" || paths[1] !== "event-types") &&
checkForEmptyAssignment({
assignedUsers: eventType.children,
hosts: eventType.hosts,
assignAllTeamMembers: eventType.assignAllTeamMembers,
isManagedEventType: eventType.schedulingType === SchedulingType.MANAGED,
})
) {
setIsOpenAssignmentWarnDialog(true);
setPendingRoute(url);
router.events.emit(
"routeChangeError",
new Error(`Aborted route change to ${url} because none was assigned to team event`)
);
throw "Aborted";
}
};
router.events.on("routeChangeStart", handleRouteChange);
return () => {
router.events.off("routeChangeStart", handleRouteChange);
};
}, [router, eventType.hosts, eventType.children, eventType.assignAllTeamMembers]);
const appsMetadata = formMethods.getValues("metadata")?.apps;
const availability = formMethods.watch("availability");
@@ -717,7 +696,8 @@ const EventTypePage = (props: EventTypeSetupProps & { allActiveWorkflows?: Workf
disableBorder={true}
currentUserMembership={currentUserMembership}
bookerUrl={eventType.bookerUrl}
isUserOrganizationAdmin={props.isUserOrganizationAdmin}>
isUserOrganizationAdmin={props.isUserOrganizationAdmin}
onDelete={onDelete}>
<Form
form={formMethods}
id="event-type-form"
@@ -837,13 +817,13 @@ const EventTypePage = (props: EventTypeSetupProps & { allActiveWorkflows?: Workf
}}
/>
) : null}
{/*<AssignmentWarningDialog*/}
{/* isOpenAssignmentWarnDialog={isOpenAssignmentWarnDialog}*/}
{/* setIsOpenAssignmentWarnDialog={setIsOpenAssignmentWarnDialog}*/}
{/* pendingRoute={pendingRoute}*/}
{/* leaveWithoutAssigningHosts={leaveWithoutAssigningHosts}*/}
{/* id={eventType.id}*/}
{/*/>*/}
<AssignmentWarningDialog
isOpenAssignmentWarnDialog={isOpenAssignmentWarnDialog}
setIsOpenAssignmentWarnDialog={setIsOpenAssignmentWarnDialog}
pendingRoute={pendingRoute}
leaveWithoutAssigningHosts={leaveWithoutAssigningHosts}
id={eventType.id}
/>
</>
);
};
+27
View File
@@ -387,6 +387,13 @@ const nextConfig = {
};
},
async headers() {
// This header can be set safely as it ensures the browser will load the resources even when COEP is set.
// But this header must be set only on those resources that are safe to be loaded in a cross-origin context e.g. all embeddable pages's resources
const CORP_CROSS_ORIGIN_HEADER = {
key: "Cross-Origin-Resource-Policy",
value: "cross-origin",
};
return [
{
source: "/auth/:path*",
@@ -419,6 +426,26 @@ const nextConfig = {
},
],
},
{
source: "/embed/embed.js",
headers: [CORP_CROSS_ORIGIN_HEADER],
},
{
source: "/:path*/embed",
// COEP require-corp header is set conditionally when flag.coep is set to true
headers: [CORP_CROSS_ORIGIN_HEADER],
},
// These resources loads through embed as well, so they need to have CORP_CROSS_ORIGIN_HEADER
...[
{
source: "/api/avatar/:path*",
headers: [CORP_CROSS_ORIGIN_HEADER],
},
{
source: "/avatar.svg",
headers: [CORP_CROSS_ORIGIN_HEADER],
},
],
...(isOrganizationsEnabled
? [
{
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "4.3.3",
"version": "4.3.4",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
+8 -4
View File
@@ -3,6 +3,7 @@ import type { NextApiResponse } from "next";
import calcomSignupHandler from "@calcom/feature-auth/signup/handlers/calcomHandler";
import selfHostedSignupHandler from "@calcom/feature-auth/signup/handlers/selfHostedHandler";
import { type RequestWithUsernameStatus } from "@calcom/features/auth/signup/username";
import { getFeatureFlag } from "@calcom/features/flags/server/utils";
import { IS_PREMIUM_USERNAME_ENABLED } from "@calcom/lib/constants";
import getIP from "@calcom/lib/getIP";
import { HttpError } from "@calcom/lib/http-error";
@@ -10,17 +11,20 @@ import logger from "@calcom/lib/logger";
import { checkCfTurnstileToken } from "@calcom/lib/server/checkCfTurnstileToken";
import { signupSchema } from "@calcom/prisma/zod-utils";
function ensureSignupIsEnabled(req: RequestWithUsernameStatus) {
async function ensureSignupIsEnabled(req: RequestWithUsernameStatus) {
const { token } = signupSchema
.pick({
token: true,
})
.parse(req.body);
// Stil allow signups if there is a team invite
// Still allow signups if there is a team invite
if (token) return;
if (process.env.NEXT_PUBLIC_DISABLE_SIGNUP === "true") {
const prisma = await import("@calcom/prisma").then((mod) => mod.default);
const signupDisabled = await getFeatureFlag(prisma, "disable-signup");
if (process.env.NEXT_PUBLIC_DISABLE_SIGNUP === "true" || signupDisabled) {
throw new HttpError({
statusCode: 403,
message: "Signup is disabled",
@@ -47,7 +51,7 @@ export default async function handler(req: RequestWithUsernameStatus, res: NextA
});
ensureReqIsPost(req);
ensureSignupIsEnabled(req);
await ensureSignupIsEnabled(req);
/**
* Im not sure its worth merging these two handlers. They are different enough to be separate.
+3 -3
View File
@@ -18,7 +18,7 @@ describe("moveUserToMatchingOrg", () => {
});
it("should not proceed if no matching organization is found", async () => {
organizationScenarios.OrganizationRepository.findUniqueByMatchingAutoAcceptEmail.fakeNoMatch();
organizationScenarios.OrganizationRepository.findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail.fakeNoMatch();
await moveUserToMatchingOrg({ email });
@@ -44,7 +44,7 @@ describe("moveUserToMatchingOrg", () => {
requestedSlug: "requested-test-org",
};
organizationScenarios.OrganizationRepository.findUniqueByMatchingAutoAcceptEmail.fakeReturnOrganization(
organizationScenarios.OrganizationRepository.findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail.fakeReturnOrganization(
org,
{ email }
);
@@ -65,7 +65,7 @@ describe("moveUserToMatchingOrg", () => {
requestedSlug: "requested-test-org",
};
organizationScenarios.OrganizationRepository.findUniqueByMatchingAutoAcceptEmail.fakeReturnOrganization(
organizationScenarios.OrganizationRepository.findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail.fakeReturnOrganization(
org,
{ email }
);
+1 -1
View File
@@ -19,7 +19,7 @@ const USER_ALREADY_EXISTING_MESSAGE = "A User already exists with this email";
// TODO: To be unit tested
export async function moveUserToMatchingOrg({ email }: { email: string }) {
const org = await OrganizationRepository.findUniqueByMatchingAutoAcceptEmail({ email });
const org = await OrganizationRepository.findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail({ email });
if (!org) {
return;
@@ -3,11 +3,11 @@ import type { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod";
import dayjs from "@calcom/dayjs";
import { ZGetRetellLLMSchema } from "@calcom/features/ee/cal-ai-phone/zod-utils";
import type { TGetRetellLLMSchema } from "@calcom/features/ee/cal-ai-phone/zod-utils";
import { fetcher } from "@calcom/lib/retellAIFetcher";
import { defaultHandler } from "@calcom/lib/server";
import prisma from "@calcom/prisma";
import { getRetellLLMSchema } from "@calcom/prisma/zod-utils";
import type { TGetRetellLLMSchema } from "@calcom/prisma/zod-utils";
import { getAvailableSlots } from "@calcom/trpc/server/routers/viewer/slots/util";
dayjs.extend(advancedFormat);
@@ -53,7 +53,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
const body = response.data;
const retellLLM = await fetcher(`/get-retell-llm/${body.llm_id}`).then(getRetellLLMSchema.parse);
const retellLLM = await fetcher(`/get-retell-llm/${body.llm_id}`).then(ZGetRetellLLMSchema.parse);
const { eventTypeId, timezone } = getEventTypeIdFromRetellLLM(retellLLM);
+22 -11
View File
@@ -4,8 +4,10 @@ import { URLSearchParams } from "url";
import { z } from "zod";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { buildEventUrlFromBooking } from "@calcom/lib/bookings/buildEventUrlFromBooking";
import { getDefaultEvent } from "@calcom/lib/defaultEvents";
import { maybeGetBookingUidFromSeat } from "@calcom/lib/server/maybeGetBookingUidFromSeat";
import { UserRepository } from "@calcom/lib/server/repository/user";
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
import { BookingStatus } from "@calcom/prisma/client";
@@ -20,11 +22,12 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
const { uid: bookingUid, seatReferenceUid } = z
.object({ uid: z.string(), seatReferenceUid: z.string().optional() })
.parse(context.query);
const coepFlag = context.query["flag.coep"];
const { uid, seatReferenceUid: maybeSeatReferenceUid } = await maybeGetBookingUidFromSeat(
prisma,
bookingUid
);
const booking = await prisma.booking.findUnique({
where: {
uid,
@@ -41,6 +44,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
slug: true,
team: {
select: {
parentId: true,
slug: true,
},
},
@@ -125,20 +129,27 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
const eventType = booking.eventType ? booking.eventType : getDefaultEvent(dynamicEventSlugRef);
const eventPage = `${
eventType.team
? `team/${eventType.team.slug}`
: dynamicEventSlugRef
? booking.dynamicGroupSlugRef
: booking.user?.username || "rick" /* This shouldn't happen */
}/${eventType?.slug}`;
const destinationUrl = new URLSearchParams();
const enrichedBookingUser = booking.user
? await UserRepository.enrichUserWithItsProfile({ user: booking.user })
: null;
destinationUrl.set("rescheduleUid", seatReferenceUid || bookingUid);
const eventUrl = await buildEventUrlFromBooking({
eventType,
dynamicGroupSlugRef: booking.dynamicGroupSlugRef ?? null,
profileEnrichedBookingUser: enrichedBookingUser,
});
const destinationUrlSearchParams = new URLSearchParams();
destinationUrlSearchParams.set("rescheduleUid", seatReferenceUid || bookingUid);
// TODO: I think we should just forward all the query params here including coep flag
if (coepFlag) {
destinationUrlSearchParams.set("flag.coep", coepFlag as string);
}
return {
redirect: {
destination: `/${eventPage}?${destinationUrl.toString()}${
destination: `${eventUrl}?${destinationUrlSearchParams.toString()}${
eventType.seatsPerTimeSlot ? "&bookingUid=null" : ""
}`,
permanent: false,
@@ -641,8 +641,7 @@ const ProfileForm = ({
{showRemoveAvatarButton && (
<Button
color="secondary"
className="hover:bg-red-600"
color="destructive"
onClick={() => {
onChange(null);
}}>
@@ -703,6 +702,7 @@ const ProfileForm = ({
disableLists
firstRender={firstRender}
setFirstRender={setFirstRender}
height="80px"
/>
</div>
{/* // For Non-Cal indentities, we merge the values from DB and the user logging in,
@@ -716,8 +716,8 @@ const ProfileForm = ({
<span className="text-default ml-2 text-sm">{user.identityProviderEmail}</span>
)}
<div className="flex flex-1 justify-end">
<Button color="destructive" onClick={onDisconnect} size="sm">
Disconnect
<Button color="destructive" onClick={onDisconnect}>
{t("disconnect")}
</Button>
</div>
</div>
+6 -1
View File
@@ -323,7 +323,12 @@ export default function Signup({
<div className="ml-auto mr-auto mt-0 flex w-full max-w-xl flex-col px-4 pt-6 sm:px-16 md:px-20 lg:mt-12 2xl:px-28">
{/* Header */}
{errors.apiError && (
<Alert severity="error" message={errors.apiError?.message} data-testid="signup-error-message" />
<Alert
className="mb-3"
severity="error"
message={errors.apiError?.message}
data-testid="signup-error-message"
/>
)}
<div className="flex flex-col gap-2">
<h1 className="font-cal text-[28px] leading-none ">
+2 -2
View File
@@ -190,7 +190,7 @@ testBothFutureAndLegacyRoutes.describe("Event Types tests", () => {
await gotoBookingPage(page);
await selectFirstAvailableTimeSlotNextMonth(page);
await page.locator(`[data-fob-field-name="location"] input`).fill("9199999999");
await page.locator(`[data-fob-field-name="location"] input`).fill("19199999999");
await bookTimeSlot(page);
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
@@ -204,7 +204,7 @@ testBothFutureAndLegacyRoutes.describe("Event Types tests", () => {
await page.locator(`text="Organizer Phone Number"`).click();
const locationInputName = "locations[0].hostPhoneNumber";
await page.locator(`input[name="${locationInputName}"]`).waitFor();
await page.locator(`input[name="${locationInputName}"]`).fill("9199999999");
await page.locator(`input[name="${locationInputName}"]`).fill("19199999999");
await saveEventType(page);
await gotoBookingPage(page);
+18 -3
View File
@@ -709,6 +709,13 @@ const createUserFixture = (user: UserWithIncludes, page: Page) => {
},
delete: async () => await prisma.user.delete({ where: { id: store.user.id } }),
confirmPendingPayment: async () => confirmPendingPayment(store.page),
getFirstProfile: async () => {
return prisma.profile.findFirstOrThrow({
where: {
userId: user.id,
},
});
},
};
};
@@ -738,6 +745,7 @@ type CustomUserOpts = Partial<Pick<Prisma.User, CustomUserOptsKeys>> & {
schedule?: Schedule;
password?: string | null;
emailDomain?: string;
profileUsername?: string;
};
// creates the actual user in the db.
@@ -749,11 +757,12 @@ const createUser = (
})
| null
): PrismaType.UserUncheckedCreateInput => {
const suffixToMakeUsernameUnique = `-${workerInfo.workerIndex}-${Date.now()}`;
// build a unique name for our user
const uname =
opts?.useExactUsername && opts?.username
? opts.username
: `${opts?.username || "user"}-${workerInfo.workerIndex}-${Date.now()}`;
: `${opts?.username || "user"}${suffixToMakeUsernameUnique}`;
const emailDomain = opts?.emailDomain || "example.com";
return {
@@ -772,7 +781,11 @@ const createUser = (
role: opts?.role ?? "USER",
twoFactorEnabled: opts?.twoFactorEnabled ?? false,
disableImpersonation: opts?.disableImpersonation ?? false,
...getOrganizationRelatedProps({ organizationId: opts?.organizationId, role: opts?.roleInOrganization }),
...getOrganizationRelatedProps({
organizationId: opts?.organizationId,
role: opts?.roleInOrganization,
profileUsername: opts?.profileUsername,
}),
schedules:
opts?.completedOnboarding ?? true
? {
@@ -792,9 +805,11 @@ const createUser = (
function getOrganizationRelatedProps({
organizationId,
role,
profileUsername,
}: {
organizationId: number | null | undefined;
role: MembershipRole | undefined;
profileUsername?: string;
}) {
if (!organizationId) {
return null;
@@ -807,7 +822,7 @@ const createUser = (
profiles: {
create: {
uid: ProfileRepository.generateProfileUid(),
username: uname,
username: profileUsername ? `${profileUsername}${suffixToMakeUsernameUnique}` : uname,
organization: {
connect: {
id: organizationId,
+34 -3
View File
@@ -1,5 +1,5 @@
import type { Frame, Page } from "@playwright/test";
import { expect } from "@playwright/test";
import type { Frame, Page, Request as PlaywrightRequest } from "@playwright/test";
import { createHash } from "crypto";
import EventEmitter from "events";
import type { IncomingMessage, ServerResponse } from "http";
@@ -352,20 +352,51 @@ export async function fillStripeTestCheckout(page: Page) {
await page.click(".SubmitButton--complete-Shimmer");
}
export function goToUrlWithErrorHandling({ page, url }: { page: Page; url: string }) {
return new Promise<{ success: boolean; url: string }>(async (resolve) => {
const onRequestFailed = (request: PlaywrightRequest) => {
const failedToLoadUrl = request.url();
console.log("goToUrlWithErrorHandling: Failed to load URL:", failedToLoadUrl);
resolve({ success: false, url: failedToLoadUrl });
};
page.on("requestfailed", onRequestFailed);
try {
await page.goto(url);
} catch (e) {}
page.off("requestfailed", onRequestFailed);
resolve({ success: true, url: page.url() });
});
}
/**
* Within this function's callback if a non-org domain is opened, it is considered an org domain identfied from `orgSlug`
*/
export async function doOnOrgDomain(
{ orgSlug, page }: { orgSlug: string | null; page: Page },
callback: ({ page }: { page: Page }) => Promise<void>
callback: ({
page,
}: {
page: Page;
goToUrlWithErrorHandling: (url: string) => ReturnType<typeof goToUrlWithErrorHandling>;
}) => Promise<any>
) {
if (!orgSlug) {
throw new Error("orgSlug is not available");
}
page.setExtraHTTPHeaders({
"x-cal-force-slug": orgSlug,
});
await callback({ page });
const callbackResult = await callback({
page,
goToUrlWithErrorHandling: (url: string) => {
return goToUrlWithErrorHandling({ page, url });
},
});
await page.setExtraHTTPHeaders({
"x-cal-force-slug": "",
});
return callbackResult;
}
// When App directory is there, this is the 404 page text. We should work on fixing the 404 page as it changed due to app directory.
@@ -3,11 +3,13 @@ import { expect } from "@playwright/test";
import type { createUsersFixture } from "playwright/fixtures/users";
import { uuid } from "short-uuid";
import { fieldTypesConfigMap } from "@calcom/features/form-builder/fieldTypes";
import prisma from "@calcom/prisma";
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
import type { CalendarEvent } from "@calcom/types/Calendar";
import { test } from "./lib/fixtures";
import { createNewEventType } from "./lib/testUtils";
import { createHttpServer, selectFirstAvailableTimeSlotNextMonth } from "./lib/testUtils";
function getLabelLocator(field: Locator) {
@@ -716,3 +718,164 @@ async function expectWebhookToBeCalled(
expect(body).toMatchObject(expectedBody);
}
test.describe("Text area min and max characters text", () => {
test("Create a new event", async ({ page, users }) => {
const eventTitle = `Min Max Characters Test`;
const fieldType = fieldTypesConfigMap["textarea"];
const MAX_LENGTH = fieldType?.supportsLengthCheck?.maxLength;
// We create a new event type
const user = await users.create();
await user.apiLogin();
await page.goto("/event-types");
// We wait until loading is finished
await page.waitForSelector('[data-testid="event-types"]');
await createNewEventType(page, { eventTitle });
await page.waitForSelector(`text=${eventTitle}`);
// Click on the event type
await page.click(`text=${eventTitle}`);
await page.waitForSelector(`text=${eventTitle}`);
// goto the advanced tab
await page.click('[href$="tabName=advanced"]');
const insertQuestion = async (questionName: string) => {
await page.click('[data-testid="add-field"]');
const locatorForSelect = page.locator("[id=test-field-type]").nth(0);
await locatorForSelect.click();
await locatorForSelect.locator(`text="Long Text"`).click();
await page.fill('[name="name"]', questionName);
await page.fill('[name="label"]', questionName);
await page.fill('[name="placeholder"]', questionName);
};
const saveQuestion = async () => {
await page.click('[data-testid="field-add-save"]');
};
const cancelQuestion = async () => {
await page.click('[data-testid="dialog-rejection"]');
};
const minLengthSelector = '[name="minLength"]';
const maxLengthSelector = '[name="maxLength"]';
const minInput = await page.locator(minLengthSelector);
const maxInput = await page.locator(maxLengthSelector);
let questionName;
await test.step("Add a new field with no min and max characters", async () => {
// Add a new field with no min and max characters
questionName = "Text area without min & max";
await insertQuestion(questionName);
await saveQuestion();
});
await test.step("Add a new field with min characters only", async () => {
// Add a new field with min characters only
questionName = "Text area with min = 5";
await insertQuestion(questionName);
await page.fill(minLengthSelector, "5");
await saveQuestion();
});
await test.step("Add a new field with max characters only", async () => {
// Add a new field with max characters only
questionName = "Text area with max = 10";
await insertQuestion(questionName);
await page.fill(maxLengthSelector, "10");
await saveQuestion();
});
await test.step("Add a new field with min and max characters where min < max", async () => {
// Add a new field with min and max characters where min < max
questionName = "Text area with min = 5 & max = 10";
await insertQuestion(questionName);
await page.fill(minLengthSelector, "5");
await page.fill(maxLengthSelector, "10");
await saveQuestion();
});
await test.step("Add a new field with min and max characters where min > max", async () => {
// Add a new field with min and max characters where min > max
questionName = "Text area with min = 10 & max = 5";
await insertQuestion(questionName);
await page.fill(minLengthSelector, "10");
await page.fill(maxLengthSelector, "5");
await saveQuestion();
});
await test.step("Try with different inputs and check for validation", async () => {
// Expect the native <input> element to show an error message
let validationMessage = await minInput?.evaluate((input: any) => input?.validationMessage as string);
expect(validationMessage?.toString()).toBe("Value must be less than or equal to 5.");
await page.fill(minLengthSelector, "0");
await page.fill(maxLengthSelector, "100000");
await saveQuestion();
// Expect the native <input> element to show an error message
validationMessage = await maxInput?.evaluate((input: any) => input?.validationMessage as string);
expect(validationMessage?.toString()).toBe(
`Value must be less than or equal to ${MAX_LENGTH || 1000}.`
);
await cancelQuestion();
// Save the event type
await page.locator("[data-testid=update-eventtype]").click();
// Get the url of data-testid="preview-button"
const previewButton = await page.locator('[data-testid="preview-button"]');
const previewButtonHref = (await previewButton.getAttribute("href")) ?? "";
await page.goto(previewButtonHref);
// wait until the button with data-testid="time" is visible
await page.locator('[data-testid="time"]').isVisible();
await page.getByTestId("incrementMonth").click();
// Get first button with data-testid="time"
const timeButton = page.locator('[data-testid="time"]').first();
await timeButton.click();
await page.locator('text="Additional notes"');
// Form fields:
const textAreaWithoutMinMax = page.locator('[name="Text-area-without-min---max"]');
const textAreaWithMin5 = page.locator('[name="Text-area-with-min---5"]');
const textAreaWithMax10 = page.locator('[name="Text-area-with-max---10"]');
const textAreaWithMin5Max10 = page.locator('[name="Text-area-with-min---5---max---10"]');
// Get button with data-testid="confirm-book-button"
const submitForm = async () => await page.locator('[data-testid="confirm-book-button"]').click();
await page.fill('[name="name"]', "Booker");
await page.fill('[name="email"]', "booker@example.com");
await textAreaWithoutMinMax.fill("1234567890");
await textAreaWithMin5.fill("1234");
await textAreaWithMax10.fill("12345678901");
await textAreaWithMin5Max10.fill("1234");
await submitForm();
// Expect the text: Min. 5 characters to be visible
expect(await page.locator(`text=Min. 5 characters required`).isVisible()).toBe(true);
// update the text area with min 5 to have 5 characters
await textAreaWithMin5.fill("12345");
await submitForm();
// Expect the text: Min. 5 characters to still be visible because textAreaWithMin5Max10 has less than 5 characters
expect(await page.locator(`text=Min. 5 characters required`).isVisible()).toBe(true);
// Expect the text: Max. 10 characters to be visible and have value 1234567890
expect(await textAreaWithMax10.inputValue()).toBe("1234567890");
await submitForm();
// update the text area with min 5 and max 10 to have 6 characters
await textAreaWithMin5Max10.fill("123456");
await submitForm();
// Expect the text: Max. 5 characters to be hidden
expect(await page.locator(`text=Min. 5 characters required`).isVisible()).toBe(false);
await expect(page.locator('text="This meeting is scheduled"')).toBeVisible();
});
});
});

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