Compare commits

..
148 Commits
Author SHA1 Message Date
GitHub Actions 1619f2daf2 chore: release v4.8.14
Post release / release (push) Canceled after 0s
2025-01-07 20:06:08 +00:00
Alex van Andel 57bd0b3df3 fix: type + build errors (#18502) 2025-01-07 18:35:06 +01:00
Udit Takkar e359d13d9a Revert "fix: improve useInViewObserver hook to trigger when observer is alrea…" (#18504)
This reverts commit 22f92f1ee3.
2025-01-07 16:41:55 +00:00
Hariom Balhara 3b7cb542ed fix checklyt tests (#18501) 2025-01-07 12:58:39 +00:00
Alex van Andel fd7f6c32ae chore: Remove all instances of useIntercom (#18500) 2025-01-07 07:43:46 -05:00
Hariom Balhara 06d8b98b15 fix: 404 on the public form page for a sub-team form when the owner itself was removed from the org (#18495)
* wip

* fix: 404 for a form of a member that has been removed.

- Moved existing authorization check to isAuthorizedToViewFormOnOrgDomain function to handle form access control
- Add comprehensive test suite for org domain authorization scenarios
- Refactor getServerSideProps to use the new authorization function
- Improve code organization by separating domain-specific authorization logic

Test Coverage:
- Non-org domain access
- Org member's form access
- Sub-team form access within org
- Access denial for non-org members/teams
2025-01-07 12:13:19 +00:00
Benny Joo 0c69c5dfea remove MainLayout / MainLayoutAppDir (#18498) 2025-01-07 12:13:00 +00:00
Benny Joo 00f7d8be0d feat: bookings - render layout only once (#18490) 2025-01-07 11:37:17 +00:00
Hariom Balhara 2e9beb8513 fix: Make it easier to manage group options and also improve attribute options performance with 100s of options (#18486) 2025-01-07 11:16:52 +00:00
Omar López b6c6fa7365 Update repository.ts (#18482) 2025-01-07 11:10:52 +00:00
Benny Joo c623785ec2 chore: remove unused code from SideBar (#18487) 2025-01-07 10:56:09 +00:00
Alex van Andel 6a381cdc79 perf: Remove zod from edge runtime (#18494) 2025-01-07 10:47:48 +00:00
Benny Joo 9d4496133e feat: Prevent re-renders (moving SessionProvider to Root level for both app router / pages router) (#18493) 2025-01-07 11:46:08 +01:00
Benny Joo 1452672495 withEmbedSsr is missing in a embed route (#18483) 2025-01-07 07:28:55 -03:00
Lauris Skraucis 9159c591ff fix: v2 slots timeZone parameter (#18488)
* fix: v2 slots timeZone parameter

* test: v2 slots
2025-01-07 15:24:20 +05:30
Benny Joo 408aee5851 insights layout (#18489) 2025-01-07 09:02:52 +00:00
Vishnu 29bbada77a fix: cancel workflows on cancel booking (#17871)
* feat: cancel scheduled email from sendGrid while cancelling the booking

* fix: remove deleteScheduledSend in scheduledEmailReminder cron api

* fix: use cancelScheduledEmailSend

* chore: remove cancelling email workflow in deleteScheduledEmailReminder

* refactor: bring back deleteScheduleSend

* fix: get only past reminders
2025-01-07 00:06:57 -05:00
PraashandHariom 6178092878 fix: Cannot read properties of undefined (reading 'timeZone') (#18107)
Co-authored-by: Hariom <hariombalhara@gmail.com>
2025-01-07 04:17:17 +00:00
Hariom BalharaandOmar López 6f531d0be5 feat: Single domain setup when only one org in the system (#18383)
Co-authored-by: Omar López <zomars@me.com>
2025-01-07 09:28:44 +05:30
GitHub Actions d7b723c914 chore: release v4.8.13
Post release / release (push) Canceled after 0s
2025-01-06 21:59:03 +00:00
Anik Dhabal BabuandAlex van Andel aad3988d60 fix: Zoho Calendar is allowing bookings during busy schedules (#18473)
* fix: zoho calendar allowing to book busy schedule

* Improved more readable version

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-01-06 21:00:05 +05:30
c8172ab790 refactor: v2 default booking fields (#18287)
* feat: allow title,notes,reschedule reason inputs

* refactor: v2 event type default booking fields as in web app

* wip: input validation

* input field validation

* fix: tests

* chore: bump libraries

* remove change in docs

* fix input type

* chore: ensure additional notes at the end

* fixup! Merge branch 'main' into lauris/cal-4932-platform-refactor-default-booking-fields

---------

Co-authored-by: Morgan Vernay <morgan@cal.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2025-01-06 16:49:57 +02:00
657f9dfd41 chore: ignore oAuthClientId for Booker Embed bookings (#18314)
* chore: ignore oAuthClientId for Booker Embed bookings

* remove logs

* chore: isEmbed header and emails

* fixup! chore: isEmbed header and emails

* fixup! fixup! chore: isEmbed header and emails

* fixup! fixup! fixup! chore: isEmbed header and emails

* fixup! fixup! fixup! fixup! chore: isEmbed header and emails

* fixup! fixup! fixup! fixup! fixup! chore: isEmbed header and emails

---------

Co-authored-by: Ryukemeister <sahalrajiv6900@gmail.com>
Co-authored-by: Rajiv Sahal <sahalrajiv-extc@atharvacoe.ac.in>
2025-01-06 16:21:38 +02:00
Calcom BotandReplexica c48b209c04 feat: update translations via @replexica (#18472)
* chore: sync with main

* feat: update translations via @replexica

---------

Co-authored-by: Replexica <support@replexica.com>
2025-01-06 11:13:03 -03:00
Hariom BalharaandPeer Richelsen b9a460d8b4 chore: Bump version for embed (#18285)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2025-01-06 12:42:13 +00:00
a7727b5ca6 feat: shorten sms links using dub (#17925)
* feat: add getshortenLinks function

* feat: use shortenLinks in SMS Worflows

* chore: use getShortenLink and cleanup getShortenLinks

* fix: Duplicate keys error

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-01-06 12:39:54 +00:00
Lauris Skraucis 9b661b75f1 docs: OAuth client redirectUri with wildcard character (#18267) 2025-01-06 09:39:31 -03:00
Kartik SainiandPeer Richelsen 487cc0751d feat: add cancel and reschedule reason variables in workflows (#18305)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2025-01-06 11:56:06 +00:00
Akash Jayan f7f36978e1 fix: initialize useCustomTemplate state based on payloadTemplate prop… (#18257) 2025-01-06 12:37:23 +01:00
Amit SharmaandPeer Richelsen c1dbb7cffd feat: refund policies for payment apps (#18428)
* feat: refund policies for payment apps

* tests for payment refund

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2025-01-06 11:36:37 +00:00
Morgan e722e75a6f docs: cal-api-version header example for bookings controller v2 (#18271)
* docs: api v2 cal-api-version header example for bookings controller

* fixup! docs: api v2 cal-api-version header example for bookings controller
2025-01-06 11:36:03 +00:00
Amit SharmaandAnik Dhabal Babu 455572b5f4 feat: Info lost when navigating without warning routing form (#18386)
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-01-06 12:19:58 +01:00
Alex van Andel 2fe4462f62 perf: Use improved checkIfAvailable and remove dupe code (#18463) 2025-01-06 09:53:39 +00:00
Amit Sharma f9dc788007 fix: It's hard to click on the last available Event Duration (#18462) 2025-01-04 15:07:36 +00:00
René Aaron 6e293d2985 fix: improve qr readability of alby payment integration (#18266)
* fix: optimize qr for better readability

* fix: optimize readability
2025-01-04 12:18:31 +01:00
Benny Joo 00334c5610 chore: app router - /signup page (#18151)
* app router - /signup page

* add to config.matcher

* fix
2025-01-03 22:27:03 +00:00
Benny JooandHariom Balhara 2f9c8cb441 chore: app router - /team, /org, /[user] booking pages (excl. embeds) (#18186)
* update env vars

* update middleware

* remove pages router and move pages to /app

* move to /team

* update imports

* fix

* remove pages router and move org pages to /app

* wip

* fix orgSlug/user pages

* fix orgSlug/user/type pages

* remove generateMetadata from embed pages

* fix

* remove pages router for /user pages

* generateMetadata is not needed in embed pages

* remove future/org

* no layout in [user] page

* simplify

* fix

* fix OG image for [user]

* fix OG images for org/user and team/slug

* fix OG images for booking page

* fix all metadata

* use isBrandingHidden

* remove BookerSeo and its usages

* rename excludeAppNameFromTitle -> hideBranding

* remove logic for meeting type in HeadSeo

* remove BookerSeo instances from team-view and users-public-view

* create generateMeetingMetadata util and use it to reduce duplicate code

* remove BookerSeo imports

* fix spacing

* remove constructMeetingImage mock from head-seo.test

* fetch avatarUrl using user id for user page metadata

* fix test

* remove unused test cases

* index and follow must be true by default

* invert noindex/nofollow flags

* remove HeadSeo for already migrated pages and refactor prepareMetadata

* fix organization-settings.e2e.ts

* fix order

* enable parallel test execution for dynamic-booking e2e test

* fix

* + could be %2B in app router

* refactor handling logic for embeds in app router

* fix isEmbed

* fix embed-core

* remove dead code

* move embed pages back to /future

* add back embed pages in pages router

* revert some changes

* fix import type checks

* simplify

* fix

* feat: Implement generateBookingPageMetadata function for improved SEO and metadata handling across user and event pages (#18440)

- Added a new utility function `generateBookingPageMetadata` to streamline the generation of metadata for booking and user profile pages.
- Updated multiple page components to utilize the new function, enhancing SEO indexing and metadata consistency.
- Removed redundant code related to previous metadata generation methods, improving code clarity and maintainability.

* fix dirs

* Pr-review-fixes-app-router-team-pages (#18450)

* Remove unnecessary getPublicEvent call

* Remove unused variable

* Fix 404 for team page (#18451)

* Remove console log

---------

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2025-01-03 22:26:30 +00:00
GitHub Actions 39173900b1 chore: release v4.8.12
Post release / release (push) Canceled after 0s
2025-01-03 21:53:06 +00:00
Syed Ali Shahbaz bd61e01a5d fix: Show managed event bookings for team admins/owners (#18434)
* add managed event bookings for team admins/owners

* remove unnecessary OR

* address change suggestion

* improve readability
2025-01-03 21:43:05 +00:00
Cao Hoang aa0b3a2778 fix: improve useInViewObserver hook to trigger when observer is already in view on mount (#18455) 2025-01-03 21:20:59 +00:00
Joe Au-Yeung 170feeae5e chore: Salesforce - Add additional logging and error checks (#18448) 2025-01-03 12:57:24 -05:00
Eunjae Lee a67b57b216 feat: server side sorting for insights routing (#18113)
* feat: server side sorting for insights routing

^ Conflicts:
^	packages/prisma/playground.ts

* fix download csv

* fix hooks locations
2025-01-03 10:58:01 +00:00
821a16648f fix: Timezone in Email Embed not working #15525 (#15537)
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: Hariom <hariombalhara@gmail.com>
2025-01-03 08:43:09 +00:00
Hariom Balhara 727f7df2c2 fix: Enhance SCIM payload handling and attribute creation (#18427)
* feat: Enhance SCIM user attribute handling and sync process

- Introduced a new PR_TODO.md file to track ongoing tasks and fixes.
- Updated `getAttributesFromScimPayload` to accept a directoryId and ignore core user attributes during syncing.
- Modified `handleUserEvents` to pass directoryId when syncing custom attributes.
- Improved attribute creation logic to handle unique options and added support for optional isGroup property in attribute options.
- Added utility function `getOptionsWithValidContains` to ensure valid sub-options in attribute options.
- Updated tests to reflect changes in attribute handling and ensure proper functionality.

This commit addresses issues with user attribute syncing and enhances the overall attribute management process.

* test: Add unit tests for getOptionsWithValidContains utility function

- Introduced a new test file for the getOptionsWithValidContains function.
- Added tests to verify the removal of duplicate options, initialization of empty contains arrays, filtering of non-existent sub-options, and handling of empty options arrays.
- Ensured comprehensive coverage of the utility's functionality to enhance reliability and maintainability.
2025-01-03 13:36:21 +05:30
Anik Dhabal Babu be5f7f83b5 fix: Zoho Bigin not pulling information from booking (#18445)
* fix: Zoho Bigin not pulling information from booking

* update

* missed

* fix contactcreation
2025-01-03 08:41:18 +05:30
amrit 02a49e2b4b fix: react incompatiblity with nextjs error (#18433) 2025-01-03 01:03:46 +00:00
Cao Hoang 22f92f1ee3 fix: improve useInViewObserver hook to trigger when observer is already in view on mount (#18446) 2025-01-02 23:58:40 +00:00
Udit Takkar c7a797c0e0 fix: get bookings ordering (#18439)
* fix: get bookings ordering

* chore: update query

* chore: add log for debuggin on prod

* chore
2025-01-02 23:41:30 +00:00
Calcom BotandReplexica 447436dfff feat: update translations via @replexica (#18438)
* chore: sync with main

* feat: update translations via @replexica

---------

Co-authored-by: Replexica <support@replexica.com>
2025-01-02 22:23:30 +00:00
Eunjae LeeandUdit Takkar c415b2d7f5 feat: apply full width automatically for DataTable (#18357)
* feat: apply full width automatically for DataTable

* change implementation

* load all columns of insights routing table at the same time

* update team member list

* sticky columns for >= sm

* fix type error

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2025-01-02 14:32:39 +00:00
Lauris Skraucis c10c87c98e fix: booker atom booking fields (#18441) 2025-01-02 11:31:43 +00:00
Benny Joo 85f96f492d chore: migrate /icons page to App Router (#18436)
* migrate icons page

* migrate main index page

* remove /icons page

* use i18n string

* fix imports

* add icons_showcase i18n string

* add /app dir to tailwind preset config

* use classname

* do not migrate main index page
2025-01-02 15:18:19 +05:30
Morgan b1350d0764 fix: atoms globals.css invalid class format (#18295) 2025-01-02 09:32:50 +00:00
Hariom BalharaandUdit Takkar bac2d1f6f0 feat: Add dry run mode functionality and message display in booking process (#18429)
- Introduced a new `DryRunMessage` component to inform users when in dry run mode.
- Implemented utility function `isBookingDryRun` to check for dry run status based on URL search parameters.
- Updated the `Booker` component to conditionally render the dry run message.
- Adjusted booking mutation input mapping to reuse the utility

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2025-01-01 11:40:53 +00:00
Anik Dhabal Babu fec504496a fix: Add to google leads to a 403 page (#18430) 2024-12-31 14:11:42 +00:00
Alex van Andel 890fc92148 fix: Some recently discovered type errors breaking build (not sure why undetected before) (#18432) 2024-12-31 13:50:43 +00:00
Benny Joo 557bd8bf25 chore: Migrate /403 and /500 pages to App Router (#18424)
* global-error is not needed

* remove /403 and /500 pages from pages router

* add 403 and 500 pages in App Router

* add i18n strings

* fix i18n string

* fix styling
2024-12-31 11:43:15 +05:30
Cao HoangandUdit Takkar d8027fe248 Fix duplicated data returned by Prisma query in team availability view (#18416)
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-12-30 21:15:27 +00:00
Anik Dhabal Babu 116c765c36 fix: Error changing location for meeting (#18401) 2024-12-30 14:59:07 +00:00
GitHub Actions c6c3e228a3 chore: release v4.8.11
Post release / release (push) Canceled after 0s
2024-12-30 13:56:52 +00:00
Vijay 7584645b8c fix: flaky OOO tests without time dependency (#18412)
* fix flaky OOO tests without time dependency

* chore
2024-12-30 14:13:53 +05:30
Benny Joo 32ff7ffed1 chore: reduce test time for onboarding e2e test (#18403) 2024-12-30 13:50:58 +05:30
55113f20fe fix: add typeform logo and fix alt text in /apps/typeform/how-to-use (#18393)
* :truck:: add typeform logo and fix alt text

* trying to fix the test

* Update testUtils.ts

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: unknown <adhabal2002@gmail.com>
2024-12-30 11:11:32 +05:30
Anik Dhabal Babu 0a293308e7 Revert "update OOO e2e tests to remove flakiness (#18367)" (#18411)
This reverts commit e6ef4e1988.
2024-12-29 15:14:20 +00:00
Nizzy e558daaa0b fix: embed showing chat (#18404)
* fix: embed showing chat

* change url check to starts with

* hashmap through pathname
2024-12-29 03:07:24 +00:00
devin-ai-integration[bot] 3f0a6718bc fix: reduce mobile navigation bottom margin from 0.625rem to 0.25rem (#18398) 2024-12-28 12:30:14 +01:00
GitHub Actions 8ffebdba44 chore: release v4.8.10
Post release / release (push) Canceled after 0s
2024-12-27 21:07:33 +00:00
Udit TakkarandBailey Pumfleet cc1101ac43 refactor: email handler (#18391)
Co-authored-by: Bailey Pumfleet <bailey@pumfleet.co.uk>
2024-12-27 21:02:05 +00:00
Anik Dhabal BabuandUdit Takkar 7a80a5f929 fix: localized date (#18388)
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-12-27 20:59:53 +00:00
NizzyandUdit Takkar a27e1498b8 fix: removed plain chat from app.cal.com/video (#18385)
* fix: removed plain chat from app.cal.com/video

* take user session and open plain out of use effect

* move undefined check and screen size check above useeffect

* turned restrcited paths into env var

* undefeined window below hooks

* chore: add window check

* fix: infinite re rendering

* remove console.log

---------

Co-authored-by: Udit Takkar <udit222001@gmail.com>
2024-12-27 18:42:36 +00:00
Calcom BotandReplexica a697857fa0 feat: update translations via @replexica (#18389)
* chore: sync with main

* feat: update translations via @replexica

---------

Co-authored-by: Replexica <support@replexica.com>
2024-12-27 17:33:26 +00:00
Udit Takkar e2f45bdf5a perf: improve checkIsAvailable in getAvailable slots (#18352)
* refactor: Update booking query to use userId and optimize availability check logic

- Changed the booking query to use userId instead of nested user object for filtering active bookings.
- Refactored availability check logic to improve readability and performance by using valueOf() for time comparisons and consolidating multiple checks into a single return statement.

* fix: update tests

* chore: use toDate()

* tests: add checkavailable unit tests
2024-12-27 16:01:54 +00:00
Hariom Balhara d2930bc243 fix: Handle more dry-run cases (#18286) 2024-12-27 16:23:14 +01:00
GitHub Actions b0b93f612e chore: release v4.8.9
Post release / release (push) Canceled after 0s
2024-12-27 11:17:18 +00:00
Udit Takkar 558c8be740 perf: getAvailableSlots improvements (#18382) 2024-12-27 11:09:49 +00:00
Alex van Andel 1aee1742d5 chore: rely less on autoinfer, be able to use initial data types in consumers (#18359) 2024-12-27 16:23:49 +05:30
Hariom BalharaandUdit Takkar ab942ed4cd Fix routing form field input loosing focus on typing (#18381)
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-12-27 14:30:10 +05:30
Hariom Balhara b5bec79354 perf: /router endpoint perf improvement (#18366) 2024-12-27 08:56:52 +00:00
Nizzy b48d0e1f48 remove yarnlock (#18377) 2024-12-27 00:44:21 -05:00
GitHub Actions 4a38b4dc89 chore: release v4.8.8
Post release / release (push) Canceled after 0s
2024-12-26 19:31:22 +00:00
Syed Ali Shahbaz ff12e387b3 fix: update contact email for CalDAV (#18351) 2024-12-26 19:24:11 +00:00
Kartik Saini 6b5e144042 feat: add name and email url params when rescheduling past bookings (#18318)
* feat: add name and email as url params when rescheduling past bookings

* remove logs

* pass booking seat name and email as url params
2024-12-26 19:23:28 +00:00
Anwar Sadath cd7b93a51e fix: Last used label in button shown in 2-factor code and backup screen (#18341) 2024-12-26 19:20:11 +00:00
Anik Dhabal Babu a76366cbcb fix: Recurring meeting creates increasing number of Zoom events (#18360) 2024-12-26 19:19:24 +00:00
Anwar Sadath 860c04bbda fix: discrepancy in description hyperlinks and calendar invite (#18365) 2024-12-26 19:18:54 +00:00
+6 225313e391 feat: Plain chat (#18284)
* plain custom desin

* No display on card

* dynamic plain chat component and hmac hash

* re-route users to plain.com chat instead of intercom chat when going to /support

* provider errors

* yarn lock fix

* plain chat removed unneeded hmac

* fix ts error

* error handling improved

* remove intercome provider from app-dir

* Create getting-started.mdx (#18342)

* Delete help directory (#18343)

* chore: moved docs/help to /help (#18345)

* chore: Delete unused guides directory (#18346)

* feat: booking filters (#18303)

Co-authored-by: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com>

* chore: Remove `HeadSeo` components where no longer needed + improve app router metadata logic (#18348)

* remove HeadSeo for already migrated pages and refactor prepareMetadata

* create _generateMetadataWithoutImage and refactor _generateMetadata

* chore: app router - /bookings status page (#18183)

* chore: app router - /bookings page

* remove env vars

* fix

* Update middleware.ts

* revert unneeded change

* refactor for the better

* fix

* cache i18n instances (#18309)

* feat: virtual queues tab in insights (#18260)

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: sean-brydon <sean@cal.com>
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Omar López <zomars@me.com>

* feat: update translations via @replexica (#18361)

Co-authored-by: Replexica <support@replexica.com>

* update OOO e2e tests to remove flakiness (#18367)

* fix: metadata is overwritten for child managed eventType when updating parent (#18059)

* fix: metadata is overwirten

* Update

* type error

* Update

* fix test

* fix type error

* chore: added routing support link (#18369)

* Added routing form support link

* small change

* Type fix

---------

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

* chore: refactor handling logic for embeds in app router (#18362)

* refactor handling logic for embeds in app router

* fix type checks

* add test for withEmbedSsrAppDir

* fix

* review changes

* yarn lock

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Calcom Bot <109866826+calcom-bot@users.noreply.github.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com>
Co-authored-by: Benny Joo <sldisek783@gmail.com>
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: sean-brydon <sean@cal.com>
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
Co-authored-by: Omar López <zomars@me.com>
Co-authored-by: Replexica <support@replexica.com>
Co-authored-by: Vijay <vijayraghav22@gmail.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: Joe Au-Yeung <j.auyeung419@gmail.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2024-12-26 19:07:08 +00:00
Benny Joo 3994929a51 chore: refactor handling logic for embeds in app router (#18362)
* refactor handling logic for embeds in app router

* fix type checks

* add test for withEmbedSsrAppDir

* fix
2024-12-26 10:39:07 +01:00
Peer RichelsenandJoe Au-Yeung ae38027d76 chore: added routing support link (#18369)
* Added routing form support link

* small change

* Type fix

---------

Co-authored-by: Joe Au-Yeung <j.auyeung419@gmail.com>
2024-12-24 17:19:37 +00:00
Anik Dhabal Babu 9036080fcf fix: metadata is overwritten for child managed eventType when updating parent (#18059)
* fix: metadata is overwirten

* Update

* type error

* Update

* fix test

* fix type error
2024-12-24 21:21:43 +05:30
Vijay e6ef4e1988 update OOO e2e tests to remove flakiness (#18367) 2024-12-24 13:49:49 +00:00
Calcom BotandReplexica 3c2619a7a3 feat: update translations via @replexica (#18361)
Co-authored-by: Replexica <support@replexica.com>
2024-12-23 22:42:02 +00:00
cfb09a1b21 feat: virtual queues tab in insights (#18260)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: sean-brydon <sean@cal.com>
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Omar López <zomars@me.com>
2024-12-23 19:16:38 +00:00
Benny Joo 5fe4e09975 cache i18n instances (#18309) 2024-12-23 17:14:52 +00:00
Benny Joo 542799e2cf chore: app router - /bookings status page (#18183)
* chore: app router - /bookings page

* remove env vars

* fix

* Update middleware.ts

* revert unneeded change

* refactor for the better

* fix
2024-12-23 10:12:22 +01:00
Benny Joo 3eda7858c0 chore: Remove HeadSeo components where no longer needed + improve app router metadata logic (#18348)
* remove HeadSeo for already migrated pages and refactor prepareMetadata

* create _generateMetadataWithoutImage and refactor _generateMetadata
2024-12-23 10:02:59 +01:00
Udit TakkarandSyed Ali Shahbaz b59bf7d1ee feat: booking filters (#18303)
Co-authored-by: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com>
2024-12-23 14:06:29 +05:30
Peer Richelsen 0dadeac352 chore: Delete unused guides directory (#18346) 2024-12-22 21:44:43 +01:00
Peer Richelsen 5eb7a4d56e chore: moved docs/help to /help (#18345) 2024-12-22 21:43:53 +01:00
Calcom Bot 43fa944e2f Delete help directory (#18343) 2024-12-22 21:36:22 +01:00
Peer Richelsen 1866b2845c Create getting-started.mdx (#18342) 2024-12-22 21:35:30 +01:00
Anik Dhabal Babu 2cfc63e6e3 fix: Reschedule email for RR event shows wrong timezone to attendee (#18262)
Post release / release (push) Canceled after 0s
2024-12-20 14:02:03 -06:00
GitHub Actions 4f66f3d2c6 chore: release v4.8.7 2024-12-20 20:01:15 +00:00
Morgan ca3e7bd97d fix: hotfix hide hidden booking fields in event-types GET / (#18306) 2024-12-20 19:37:32 +00:00
Benny Joo cafa3f9cf3 fix: make sure to call refresh for pages rendered by app router (#18304) 2024-12-20 18:44:35 +00:00
Benny Joo e04faffcbb chore: add cache to static pages in app router (#18278)
* add cache

* fix

* refactor
2024-12-20 13:37:20 -05:00
Peer Richelsen 6f1556a2fd feat: added chatbase app (#18302) 2024-12-20 17:50:19 +00:00
mintlify[bot] d72a954896 Mintlify Docs Edits (#18301)
Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
2024-12-20 17:10:39 +00:00
amrit abeb20373c fix portugese (pt-BR) password validation from a required eight to seven. (#18273) 2024-12-20 17:33:46 +05:30
MorganandRajiv Sahal 422ef2f5b8 chore: add cal.com logo to platform booker embed (#18269)
* chore: add cal.com logo to platform booker embed

* fixup! chore: add cal.com logo to platform booker embed

---------

Co-authored-by: Rajiv Sahal <sahalrajiv-extc@atharvacoe.ac.in>
2024-12-20 12:55:25 +02:00
Omar López ec920eb2a8 fix: prevents cron to be clogged with calendars with missing credentialId (#18281) 2024-12-19 23:53:51 +00:00
GitHub Actions 795eecf300 chore: release v4.8.6
Post release / release (push) Canceled after 0s
2024-12-19 22:42:27 +00:00
Calcom BotandReplexica 8ae3d6d55b feat: update translations via @replexica (#18279)
Co-authored-by: Replexica <support@replexica.com>
2024-12-19 20:51:57 +00:00
85070574da feat: Support event level conflict checking (#18052)
Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: Morgan Vernay <morgan@cal.com>
Co-authored-by: Omar López <zomars@me.com>
2024-12-19 19:34:24 +00:00
Morgan d1c2872f6e fix: platform booker timezone select (#18277) 2024-12-19 17:08:34 +00:00
Morgan 42d78f8099 fix: platform booker icons and styling (#18276) 2024-12-19 16:45:05 +00:00
Lauris Skraucis f0d7a757d4 fix: Booker atom default booking fields (#18275)
* refactor: rename transformBookingFieldsApiToInternal to transformBookingFieldsApiRequestToInternal

* refactor: default booking fields can required or not required

* fix: render system fields in booker

* fix: hidden property of booking fields

* fix: default field labels and placeholders

* chore: bump platform libraries

* fix: tests

* fix: e2e tests
2024-12-19 16:30:14 +00:00
GitHub Actions 0b2f09ab7d chore: release v4.8.5
Post release / release (push) Canceled after 0s
2024-12-19 15:10:13 +00:00
Benny Joo 355aaa7010 fix: include app directory in rewrites generation logic + migrate /reschedule pages to App Router (#18261)
* chore: app router - /reschedule pages (#18150)

* app router - /reschedule pages

* add to config.matcher

* generateMetdata is not needed

* include app directory in rewrites generation logic

* make it more readable

* make code more elegant

* fix

* fix

* fix

* fix

* test and refactor
2024-12-19 06:22:19 -07:00
Rajiv Sahal c27b73aa9a fix: event type atom availability (#18263) 2024-12-19 16:38:05 +05:30
Somay Chauhan a622c7b624 fix: seats option not working for booker atom (#18265) 2024-12-19 08:07:40 +00:00
GitHub Actions 19bd3e5849 chore: release v4.8.4
Post release / release (push) Canceled after 0s
2024-12-18 23:21:28 +00:00
Benny Joo 7827028e0a Revert "chore: app router - /reschedule pages (#18150)" (#18258)
This reverts commit a7f24e777b.
2024-12-18 22:48:03 +00:00
Joe Au-Yeung ebc3ced687 chore: CRM logging, add booking uid (#18256)
* Add more logging

* Add conditional
2024-12-18 21:23:04 +01:00
Nizzy b56824e626 removed posthog identifier (#18253) 2024-12-18 15:03:17 -05:00
Benny Joo 460e511459 chore: app router - /booking pages (#18182)
* chore: app router - /booking pages

* move pages to /app

* add to config matcher
2024-12-18 14:09:34 -05:00
Benny Joo b4fd6f3442 chore: bring back removed onboarding steps (#18254)
* add connected-calendar and connected-video steps back

* adapt test

* fix name
2024-12-18 20:05:17 +01:00
Benny Joo 0a7a0e3687 chore: app router - /teams single page (#18184)
* chore: app router - /teams single page

* fix type check
2024-12-18 17:51:01 +00:00
Calcom BotandReplexica 595fffbea0 feat: update translations via @replexica (#18249)
* chore: sync with main

* feat: update translations via @replexica

---------

Co-authored-by: Replexica <support@replexica.com>
2024-12-18 17:44:50 +00:00
GitHub Actions 218c9152da chore: release v4.8.3
Post release / release (push) Canceled after 0s
2024-12-18 17:22:22 +00:00
Omar López 77fe23da49 fix: skip failed cache subscriptions from retrying (#18252) 2024-12-18 17:16:59 +00:00
sean-brydon a583db3fd4 refactor and fix bulk attribute handling (#18231) 2024-12-18 16:31:03 +00:00
cb1f803d0a feat: ooo calibration (#17864)
* first version of ooo calibration

* decrease precision when checking for full day event

Co-authored-by: Alex van Andel <me@alexvanandel.com>

* improve variable naming

* don't include hosts created after OOO

* fix failing tests

* fix all tests

* add test for ooo calibration

* first implementation to fix full day busy event reaching the current month

* allow mutiple day events

* make getAvailabilityWithTimeZones optional

* fix type error

* fix type errors

* fix tests

* fix type error

* fix test

* code clean up

* remove time adjustment from map

* code clean up

* fix tests

* getAllCalendars -> this.getAllCalendars

* fix typo

* extract mergeOverlappingRanges

* code improvements from feedback

* fix getCalIdsWithTimeZone

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Benny Joo <sldisek783@gmail.com>
2024-12-18 16:26:59 +00:00
Peer Richelsen 479d6b5961 Update i18n.json 2024-12-18 16:28:22 +01:00
Alex van Andel 4ab3ec73c0 chore: Set the stage for a primary key with value uuid() (#18245)
* chore: Set the stage for a primary key  with value uuid()

* fix: Test and ensure it validates the id value is set and defined

* Add unique index manually, prisma doesn't support this

* revert unique
2024-12-18 15:14:04 +00:00
Joe Au-Yeung 8dda418127 feat: Salesforce skip ownership check if attendee email is a free domain (#17916)
* Add email domain array

* Create numbered email domain object

* Check email domain

* Rename function

* Add tests

* Frontend enable skip ownership check if free email domain

* Backend ignore adding ownership to return records if free email domain check is enabled

* feat: Only require confirmation for free email domains (#17917)

* Add requiresConfirmationForFreeEmail to db

* Add option to event type settings

* Get requiresConfirmationForFreeEmail for event type page

* Include requiresConfirmationForFreeEmail in fetching event type

* Pass bookerEmail to `getRequiresConfirmationFlags`

* Add free email domain check to `determineRequiresConfirmation`

* Add `requiresConfirmationForFreeEmail` to types

* Add severity to Watchlist table

* Add migration for watchlist severity

* Add `getEmailDomainInWatchlist` method to watchlist repository

* Use watchlist repository to check for free email domain

* Mock watchlist repository in test

* Update test

* Rename method

* Add severity to blocked list

* Move check free email domain to async

* Type checks

* Adjust for promise returned

* Fix tests

* Fix

* Fix tests
2024-12-18 15:02:42 +00:00
Benny Joo a7f24e777b chore: app router - /reschedule pages (#18150)
* app router - /reschedule pages

* add to config.matcher

* generateMetdata is not needed
2024-12-18 14:54:15 +01:00
Benny Joo f78fca594a chore: app router - /getting-started page (#18185)
* remove env vars

* move pages to /app

* remove Head component

* remove pages router

* add to config matcher

* fix
2024-12-18 14:41:03 +01:00
Lauris Skraucis e6a7ae7da3 fix: platform decrease usage when cancelling booking (#18241)
* fix: decrement billing upon cancelled booking

* docs: explain billing in FAQ

* update docs
2024-12-18 14:50:31 +02:00
Lauris Skraucis ad5d92dff0 refactor: v2 calendars busy-times (#18244)
* refactor: make dateFrom and dateTo required

* refactor: make dateFrom and dateTo required

* refactor: useCalendarsBusyTimes enabled only if dateFrom & dateTo passed
2024-12-18 13:36:26 +01:00
Anik Dhabal Babu 8bf46ab8b6 fix: missing n8n app screenshot and add margin to the back button (#18240)
* fix: n8n app screenshot missing

* remove log
2024-12-18 12:35:26 +00:00
sean-brydon 34b0753f8d feat: match edit user sheet designs (#18243)
* edit sheet design changes

* remove attribute header
2024-12-18 11:50:19 +00:00
Calcom BotandReplexica 0bd7227150 feat: update translations via @replexica (#18234)
* chore: sync with main

* feat: update translations via @replexica

---------

Co-authored-by: Replexica <support@replexica.com>
2024-12-17 23:22:10 -05:00
Omar López 4d192ee328 fix: single rejection caused cache cron to fail (#18239) 2024-12-17 23:04:08 -05:00
63013d402e feat: Plain integration (#18130)
* revert: "fix: Request permissions to allow events to be created on shared Office365/Outlook calendars (#17760)"

This reverts commit 1680cba5a5.

* plain.com card

* detiled error handling for get customer

* working email and id

* hmac

* Revert "fix: correct line-breaks in calendar event description (#18077)"

This reverts commit 06494a6999.

* pr changes requet

* remove pan for now

* add-new-implementation-for-early-review

* Pushing fix for createHmac stringify

* added validation and user repository for email check

* add apiRouteMiddleware which handles the error handling

* HMAC_SECRET_KEY -> PLAIN_HMAC_SECRET_KEY

* Use the right error

* Convey right error to consumer

* Fixup apiRouteMiddleware to handle handler

* Don't export handler, only export POST

* changed to app directory

* working unkown user card

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Omar López <zomars@me.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2024-12-17 20:24:07 -05:00
Lauris Skraucis 6b3ccbc61a fix: v2 isSystemAdmin (#18162)
* refactor: ApiAuthStrategy return isSystemAdmin and define returned type

* refactor: use ApiAuthGuardUser instead of GetUserReturnType

* regenerate docs
2024-12-17 19:32:01 +00:00
Lauris SkraucisandSomay Chauhan 2af5cba0d7 feat: v2 teams event types (#18085)
* feat: v2 teams event-types endpoints

* refactor: rely on teams event types service and repository

* finish mergin main into this branch

* test: teams event types

* refactor: move teams event types output from org to teams folder

* refactor: move teams event types output from org to teams folder

* chore: finish merge main

* refactor: logger name and unused import

* chore: finish main merge

---------

Co-authored-by: Somay Chauhan <somaychauhan98@gmail.com>
2024-12-17 20:25:42 +01:00
Omar LópezandKeith Williams ae7bbaf414 fix: calendar cache date ranges (#18237)
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-12-17 19:20:19 +00:00
Hariom Balhara 4df4383e1e fixes:CAL-4905 (#18222) 2024-12-17 22:51:42 +05:30
624 changed files with 26868 additions and 6668 deletions
+14 -5
View File
@@ -107,6 +107,15 @@ NEXT_PUBLIC_POSTHOG_KEY=
NEXT_PUBLIC_POSTHOG_HOST=
# plain.com config
PLAIN_API_KEY=
PLAIN_API_URL=https://api.plain.com/v1
PLAIN_HMAC_SECRET_KEY=
PLAIN_CHAT_ID=
PLAIN_CHAT_HMAC_SECRET_KEY=
NEXT_PUBLIC_PLAIN_CHAT_EXCLUDED_PATHS=
# Zendesk Config
NEXT_PUBLIC_ZENDESK_KEY=
@@ -355,12 +364,7 @@ APP_ROUTER_APPS_SLUG_SETUP_ENABLED=0
APP_ROUTER_APPS_CATEGORIES_ENABLED=0
# whether we redirect to the future/apps/categories/[category] from /apps/categories/[category] or not
APP_ROUTER_APPS_CATEGORIES_CATEGORY_ENABLED=0
APP_ROUTER_BOOKING_ENABLED=0
APP_ROUTER_BOOKINGS_STATUS_ENABLED=0
APP_ROUTER_GETTING_STARTED_STEP_ENABLED=0
APP_ROUTER_APPS_ENABLED=0
APP_ROUTER_TEAM_ENABLED=0
APP_ROUTER_TEAMS_ENABLED=0
APP_ROUTER_AUTH_FORGOT_PASSWORD_ENABLED=0
APP_ROUTER_AUTH_LOGIN_ENABLED=0
APP_ROUTER_AUTH_LOGOUT_ENABLED=0
@@ -411,3 +415,8 @@ REPLEXICA_API_KEY=
# Comma-separated list of DSyncData.directoryId to log SCIM API requests for. It can be enabled temporarily for debugging the requests being sent to SCIM server.
DIRECTORY_IDS_TO_LOG=
# Set this when Cal.com is used to serve only one organization's booking pages
# Read more about it in the README.md
NEXT_PUBLIC_SINGLE_ORG_SLUG=
View File
+6
View File
@@ -0,0 +1,6 @@
undecided:
- "@calcom/app-store-cli"
- "@calcom/platform-constants"
- "@calcom/platform-enums"
- "@calcom/platform-types"
- "@calcom/platform-utils"
View File
+8
View File
@@ -0,0 +1,8 @@
undecided:
- calcom-monorepo
- "@calcom/app-store-cli"
- "@calcom/platform-constants"
- "@calcom/platform-enums"
- "@calcom/platform-types"
- "@calcom/platform-utils"
- "@calcom/prisma"
View File
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@calcom/prisma"
+3
View File
@@ -0,0 +1,3 @@
undecided:
- calcom-monorepo
- "@calcom/prisma"
+2
View File
@@ -0,0 +1,2 @@
undecided:
- calcom-monorepo
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@calcom/prisma"
+8
View File
@@ -0,0 +1,8 @@
undecided:
- calcom-monorepo
- "@calcom/app-store-cli"
- "@calcom/platform-constants"
- "@calcom/platform-enums"
- "@calcom/platform-types"
- "@calcom/platform-utils"
- "@calcom/prisma"
+2
View File
@@ -0,0 +1,2 @@
undecided:
- calcom-monorepo
View File
+9
View File
@@ -485,6 +485,15 @@ Don't code but still want to contribute? Join our [Discussions](https://github.c
- Set CSP_POLICY="non-strict" env variable, which enables [Strict CSP](https://web.dev/strict-csp/) except for unsafe-inline in style-src . If you have some custom changes in your instance, you might have to make some code change to make your instance CSP compatible. Right now it enables strict CSP only on login page and on other SSR pages it is enabled in Report only mode to detect possible issues. On, SSG pages it is still not supported.
## Single Org Mode
If you want to have booker.yourcompany.com to be the domain used for both dashboard(e.g. https://booker.yourcompany.com/event-types) and booking pages(e.g. https://booker.yourcompany.com/john.joe/15min).
- Set the `NEXT_PUBLIC_SINGLE_ORG_SLUG` environment variable to the slug of the organization you want to use. `NEXT_PUBLIC_SINGLE_ORG_SLUG=booker`
- Set the `NEXT_PUBLIC_WEBAPP_URL` environment variable to the URL of the Cal.com self-hosted instance e.g. `NEXT_PUBLIC_WEBAPP_URL=https://booker.yourcompany.com`.
- Set the `NEXT_PUBLIC_WEBSITE_URL` environment variable to the URL of the Cal.com self-hosted instance e.g. `NEXT_PUBLIC_WEBSITE_URL=https://booker.yourcompany.com`.
- Set the `NEXTAUTH_URL` environment variable to the URL of the Cal.com self-hosted instance e.g. `NEXTAUTH_URL=https://booker.yourcompany.com`.
Note: It causes root to serve the dashboard and not the organization profile page which shows all bookable users in the organization.
## Integrations
### Obtaining the Google API Credentials
+5 -5
View File
@@ -11,15 +11,15 @@ test.describe("Org", () => {
await expectPageToBeRenderedWithEmbedSsr(page);
});
test("Org User(Peer) Page should be embeddable", async ({ page }) => {
const response = await page.goto("https://i.cal.com/peer/embed");
test("Org User(Rick) Page should be embeddable", async ({ page }) => {
const response = await page.goto("https://i.cal.com/team-rick/embed");
expect(response?.status()).toBe(200);
await expect(page.locator("text=Peer Richelsen")).toBeVisible();
await expect(page.locator("text=Rick in i.cal.com")).toBeVisible();
await expectPageToBeRenderedWithEmbedSsr(page);
});
test("Org User Event(peer/meet) Page should be embeddable", async ({ page }) => {
const response = await page.goto("https://i.cal.com/peer/meet/embed");
test("Org User Event(/team-rick/test-event) Page should be embeddable", async ({ page }) => {
const response = await page.goto("https://i.cal.com/team-rick/test-event/embed");
expect(response?.status()).toBe(200);
await expect(page.locator('[data-testid="decrementMonth"]')).toBeVisible();
await expect(page.locator('[data-testid="incrementMonth"]')).toBeVisible();
@@ -61,6 +61,8 @@ describe("rateLimitApiKey middleware", () => {
reset: Date.now(),
};
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
(checkRateLimitAndThrowError as any).mockImplementationOnce(({ onRateLimiterResponse }) => {
onRateLimiterResponse(rateLimiterResponse);
});
+2 -2
View File
@@ -1,9 +1,9 @@
import type { NextApiRequest } from "next";
import { checkIfEmailInWatchlistController } from "@calcom/features/watchlist/operations/check-if-email-in-watchlist.controller";
import { checkIfEmailIsBlockedInWatchlistController } from "@calcom/features/watchlist/operations/check-if-email-in-watchlist.controller";
export async function isLockedOrBlocked(req: NextApiRequest) {
const user = req.user;
if (!user?.email) return false;
return user.locked || (await checkIfEmailInWatchlistController(user.email));
return user.locked || (await checkIfEmailIsBlockedInWatchlistController(user.email));
}
@@ -9,11 +9,25 @@ export const schemaSelectedCalendarBaseBodyParams = SelectedCalendar;
export const schemaSelectedCalendarPublic = SelectedCalendar.omit({});
export const schemaSelectedCalendarBodyParams = schemaSelectedCalendarBaseBodyParams.partial({
userId: true,
});
export const schemaSelectedCalendarBodyParams = schemaSelectedCalendarBaseBodyParams
.partial({
userId: true,
})
.omit({
// id will be set by the database
id: true,
// No eventTypeId support in API v1
eventTypeId: true,
});
export const schemaSelectedCalendarUpdateBodyParams = schemaSelectedCalendarBaseBodyParams.partial();
export const schemaSelectedCalendarUpdateBodyParams = schemaSelectedCalendarBaseBodyParams
.omit({
// id is decided by DB
id: true,
// No eventTypeId support in API v1
eventTypeId: true,
})
.partial();
export const selectedCalendarIdSchema = schemaQueryIdAsString.transform((v, ctx) => {
/** We can assume the first part is the userId since it's an integer */
+2 -1
View File
@@ -14,7 +14,8 @@
"lint:fix": "eslint . --ext .ts,.js,.tsx,.jsx --fix",
"start": "PORT=3003 next start",
"docker-start-api": "PORT=80 next start",
"type-check": "tsc --pretty --noEmit"
"type-check": "tsc --pretty --noEmit",
"type-check:ci": "tsc-absolute --pretty --noEmit"
},
"devDependencies": {
"@calcom/tsconfig": "*",
@@ -1,9 +1,10 @@
import type { NextApiRequest } from "next";
import type { UserWithCalendars } from "@calcom/lib/getConnectedDestinationCalendars";
import { getConnectedDestinationCalendars } from "@calcom/lib/getConnectedDestinationCalendars";
import { getConnectedDestinationCalendarsAndEnsureDefaultsInDb } from "@calcom/lib/getConnectedDestinationCalendars";
import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server";
import { UserRepository } from "@calcom/lib/server/repository/user";
import prisma from "@calcom/prisma";
import { extractUserIdsFromQuery } from "~/lib/utils/extractUserIdsFromQuery";
@@ -104,12 +105,8 @@ async function getHandler(req: NextApiRequest) {
const userIds = req.query.userId ? extractUserIdsFromQuery(req) : [userId];
const usersWithCalendars = await prisma.user.findMany({
where: { id: { in: userIds } },
include: {
selectedCalendars: true,
destinationCalendar: true,
},
const usersWithCalendars = await UserRepository.findManyByIdsIncludeDestinationAndSelectedCalendars({
ids: userIds,
});
return await getConnectedCalendars(usersWithCalendars);
@@ -117,11 +114,12 @@ async function getHandler(req: NextApiRequest) {
async function getConnectedCalendars(users: UserWithCalendars[]) {
const connectedDestinationCalendarsPromises = users.map((user) =>
getConnectedDestinationCalendars(user, false, prisma).then((connectedCalendarsResult) =>
connectedCalendarsResult.connectedCalendars.map((calendar) => ({
userId: user.id,
...calendar,
}))
getConnectedDestinationCalendarsAndEnsureDefaultsInDb({ user, onboarding: false, prisma }).then(
(connectedCalendarsResult) =>
connectedCalendarsResult.connectedCalendars.map((calendar) => ({
userId: user.id,
...calendar,
}))
)
);
const connectedDestinationCalendars = await Promise.all(connectedDestinationCalendarsPromises);
@@ -1,7 +1,7 @@
import type { NextApiRequest } from "next";
import { defaultResponder } from "@calcom/lib/server";
import prisma from "@calcom/prisma";
import { SelectedCalendarRepository } from "@calcom/lib/server/repository/selectedCalendar";
import { selectedCalendarIdSchema } from "~/lib/validations/selected-calendar";
@@ -49,7 +49,9 @@ import { selectedCalendarIdSchema } from "~/lib/validations/selected-calendar";
export async function deleteHandler(req: NextApiRequest) {
const { query } = req;
const userId_integration_externalId = selectedCalendarIdSchema.parse(query);
await prisma.selectedCalendar.delete({ where: { userId_integration_externalId } });
await SelectedCalendarRepository.deleteUserLevel({
where: userId_integration_externalId,
});
return { message: `Selected Calendar with id: ${query.id} deleted successfully` };
}
@@ -1,7 +1,7 @@
import type { NextApiRequest } from "next";
import { defaultResponder } from "@calcom/lib/server";
import prisma from "@calcom/prisma";
import { SelectedCalendarRepository } from "@calcom/lib/server/repository/selectedCalendar";
import { schemaSelectedCalendarPublic, selectedCalendarIdSchema } from "~/lib/validations/selected-calendar";
@@ -49,8 +49,8 @@ import { schemaSelectedCalendarPublic, selectedCalendarIdSchema } from "~/lib/va
export async function getHandler(req: NextApiRequest) {
const { query } = req;
const userId_integration_externalId = selectedCalendarIdSchema.parse(query);
const data = await prisma.selectedCalendar.findUniqueOrThrow({
where: { userId_integration_externalId },
const data = await SelectedCalendarRepository.findUserLevelUniqueOrThrow({
where: userId_integration_externalId,
});
return { selected_calendar: schemaSelectedCalendarPublic.parse(data) };
}
@@ -3,6 +3,8 @@ import type { NextApiRequest } from "next";
import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server";
import type { UpdateArguments } from "@calcom/lib/server/repository/selectedCalendar";
import { SelectedCalendarRepository } from "@calcom/lib/server/repository/selectedCalendar";
import prisma from "@calcom/prisma";
import {
@@ -56,7 +58,7 @@ export async function patchHandler(req: NextApiRequest) {
const { query, isSystemWideAdmin } = req;
const userId_integration_externalId = selectedCalendarIdSchema.parse(query);
const { userId: bodyUserId, ...data } = schemaSelectedCalendarUpdateBodyParams.parse(req.body);
const args: Prisma.SelectedCalendarUpdateArgs = { where: { userId_integration_externalId }, data };
const args: UpdateArguments = { where: { ...userId_integration_externalId }, data };
if (!isSystemWideAdmin && bodyUserId)
throw new HttpError({ statusCode: 403, message: `ADMIN required for userId` });
@@ -67,7 +69,7 @@ export async function patchHandler(req: NextApiRequest) {
args.data.userId = bodyUserId;
}
const result = await prisma.selectedCalendar.update(args);
const result = await SelectedCalendarRepository.updateUserLevel(args);
return { selected_calendar: schemaSelectedCalendarPublic.parse(result) };
}
@@ -1,9 +1,9 @@
import type { Prisma } from "@prisma/client";
import type { NextApiRequest } from "next";
import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server";
import prisma from "@calcom/prisma";
import type { FindManyArgs } from "@calcom/lib/server/repository/selectedCalendar";
import { SelectedCalendarRepository } from "@calcom/lib/server/repository/selectedCalendar";
import { schemaSelectedCalendarPublic } from "~/lib/validations/selected-calendar";
import { schemaQuerySingleOrMultipleUserIds } from "~/lib/validations/shared/queryUserId";
@@ -34,7 +34,7 @@ import { schemaQuerySingleOrMultipleUserIds } from "~/lib/validations/shared/que
async function getHandler(req: NextApiRequest) {
const { userId, isSystemWideAdmin } = req;
/* Admin gets all selected calendar by default, otherwise only the user's ones */
const args: Prisma.SelectedCalendarFindManyArgs = isSystemWideAdmin ? {} : { where: { userId } };
const args: FindManyArgs = isSystemWideAdmin ? {} : { where: { userId } };
/** Only admins can query other users */
if (!isSystemWideAdmin && req.query.userId)
@@ -46,7 +46,7 @@ async function getHandler(req: NextApiRequest) {
if (Array.isArray(query.userId)) args.orderBy = { userId: "asc" };
}
const data = await prisma.selectedCalendar.findMany(args);
const data = await SelectedCalendarRepository.findManyUserLevel(args);
return { selected_calendars: data.map((v) => schemaSelectedCalendarPublic.parse(v)) };
}
@@ -3,6 +3,7 @@ import type { NextApiRequest } from "next";
import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server";
import { SelectedCalendarRepository } from "@calcom/lib/server/repository/selectedCalendar";
import prisma from "@calcom/prisma";
import {
@@ -52,7 +53,11 @@ import {
async function postHandler(req: NextApiRequest) {
const { userId, isSystemWideAdmin } = req;
const { userId: bodyUserId, ...body } = schemaSelectedCalendarBodyParams.parse(req.body);
const args: Prisma.SelectedCalendarCreateArgs = { data: { ...body, userId } };
const args: {
data: Prisma.SelectedCalendarUncheckedCreateInput;
} = {
data: { ...body, userId },
};
if (!isSystemWideAdmin && bodyUserId)
throw new HttpError({ statusCode: 403, message: `ADMIN required for userId` });
@@ -63,7 +68,7 @@ async function postHandler(req: NextApiRequest) {
args.data.userId = bodyUserId;
}
const data = await prisma.selectedCalendar.create(args);
const data = await SelectedCalendarRepository.create(args.data);
return {
selected_calendar: schemaSelectedCalendarPublic.parse(data),
+4 -1
View File
@@ -52,6 +52,8 @@ describe("POST /api/attendees", () => {
bookingId: 1,
};
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
prismaMock.attendee.create.mockResolvedValue(attendeeData);
req.isSystemWideAdmin = true;
req.userId = 123;
@@ -84,7 +86,8 @@ describe("POST /api/attendees", () => {
timeZone: "UTC",
bookingId: 1,
};
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
prismaMock.attendee.create.mockResolvedValue(attendeeData);
req.userId = 123;
+1 -1
View File
@@ -11,7 +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 type { Booking } from "@calcom/prisma/client";
import handler from "../../../pages/api/bookings/_post";
@@ -21,6 +21,8 @@ describe("DELETE /api/event-types/[id]", () => {
beforeEach(() => {
vi.resetAllMocks();
// Mocking membership.findFirst
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
prismaMock.membership.findFirst.mockImplementation(({ where }) => {
const { userId, teamId, accepted, role } = where;
const mockData = [
@@ -72,6 +72,7 @@ describe("POST /api/event-types", () => {
},
});
req.userId = memberUserId;
// @ts-expect-error - Return type is wrong
vi.mocked(canUserAccessTeamWithRole).mockImplementationOnce(async () => false);
await handler(req, res);
@@ -114,6 +115,7 @@ describe("POST /api/event-types", () => {
req.isSystemWideAdmin = true;
req.userId = adminUserId;
// @ts-expect-error - Return type is wrong
vi.mocked(canUserAccessTeamWithRole).mockImplementationOnce(async () => false);
await handler(req, res);
@@ -138,6 +140,7 @@ describe("POST /api/event-types", () => {
req.isSystemWideAdmin = true;
req.userId = adminUserId;
// @ts-expect-error - Return type is wrong
vi.mocked(canUserAccessTeamWithRole).mockImplementationOnce(async () => true);
vi.mocked(checkParentEventOwnership).mockImplementationOnce(async () => undefined);
vi.mocked(checkUserMembership).mockImplementationOnce(async () => undefined);
@@ -5,6 +5,7 @@ import type { NextApiRequest, NextApiResponse } from "next";
import { createMocks } from "node-mocks-http";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ILicenseKeyService } from "@calcom/ee/common/server/LicenseKeyService";
import LicenseKeyService from "@calcom/ee/common/server/LicenseKeyService";
import prisma from "@calcom/prisma";
import { MembershipRole, UserPermissionRole } from "@calcom/prisma/enums";
@@ -21,7 +22,7 @@ afterEach(() => {
});
describe("Verify API key", () => {
let service: LicenseKeyService;
let service: ILicenseKeyService;
beforeEach(async () => {
service = await LicenseKeyService.create();
@@ -79,6 +79,15 @@ describe("POST /api/selected-calendars", () => {
integration: "google",
externalId: "ext123",
userId: 444444,
id: "xxx-xxx",
eventTypeId: null,
domainWideDelegationCredentialId: null,
googleChannelId: null,
googleChannelKind: null,
googleChannelResourceId: null,
googleChannelResourceUri: null,
googleChannelExpiration: null,
error: null,
});
await handler(req, res);
@@ -104,10 +113,19 @@ describe("POST /api/selected-calendars", () => {
req.userId = 333333;
prismaMock.selectedCalendar.create.mockResolvedValue({
id: "xxx-xxx",
credentialId: 1,
integration: "google",
externalId: "ext123",
userId: 333333,
googleChannelId: null,
googleChannelKind: null,
googleChannelResourceId: null,
googleChannelResourceUri: null,
googleChannelExpiration: null,
domainWideDelegationCredentialId: null,
eventTypeId: null,
error: null,
});
await handler(req, res);
@@ -2,6 +2,8 @@ import prismock from "../../../../../../tests/libs/__mocks__/prisma";
import { describe, expect, it, beforeEach } from "vitest";
import { WatchlistSeverity } from "@calcom/prisma/enums";
import { isLockedOrBlocked } from "../../../lib/utils/isLockedOrBlocked";
describe("isLockedOrBlocked", () => {
@@ -16,6 +18,7 @@ describe("isLockedOrBlocked", () => {
{
type: "DOMAIN",
value: "blocked.com",
severity: WatchlistSeverity.CRITICAL,
createdById: 1,
},
],
+1 -1
View File
@@ -29,7 +29,7 @@
"@axiomhq/winston": "^1.2.0",
"@calcom/platform-constants": "*",
"@calcom/platform-enums": "*",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.73",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.79",
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
+2
View File
@@ -18,6 +18,7 @@ import {
CAL_API_VERSION_HEADER,
X_CAL_CLIENT_ID,
X_CAL_SECRET_KEY,
X_CAL_PLATFORM_EMBED,
} from "@calcom/platform-constants";
import { TRPCExceptionFilter } from "./filters/trpc-exception.filter";
@@ -45,6 +46,7 @@ export const bootstrap = (app: NestExpressApplication): NestExpressApplication =
allowedHeaders: [
X_CAL_CLIENT_ID,
X_CAL_SECRET_KEY,
X_CAL_PLATFORM_EMBED,
CAL_API_VERSION_HEADER,
"Accept",
"Authorization",
@@ -37,7 +37,7 @@ import { Request } from "express";
import { NextApiRequest } from "next/types";
import { v4 as uuidv4 } from "uuid";
import { X_CAL_CLIENT_ID } from "@calcom/platform-constants";
import { X_CAL_CLIENT_ID, X_CAL_PLATFORM_EMBED } from "@calcom/platform-constants";
import { BOOKING_READ, SUCCESS_STATUS, BOOKING_WRITE } from "@calcom/platform-constants";
import {
handleNewRecurringBooking,
@@ -161,14 +161,15 @@ export class BookingsController_2024_04_15 {
async createBooking(
@Req() req: BookingRequest,
@Body() body: CreateBookingInput_2024_04_15,
@Headers(X_CAL_CLIENT_ID) clientId?: string
@Headers(X_CAL_CLIENT_ID) clientId?: string,
@Headers(X_CAL_PLATFORM_EMBED) isEmbed?: string
): Promise<ApiResponse<Partial<BookingResponse>>> {
const oAuthClientId = clientId?.toString();
const { orgSlug, locationUrl } = body;
req.headers["x-cal-force-slug"] = orgSlug;
try {
const booking = await handleNewBooking(
await this.createNextApiBookingRequest(req, oAuthClientId, locationUrl)
await this.createNextApiBookingRequest(req, oAuthClientId, locationUrl, isEmbed)
);
if (booking.userId && booking.uid && booking.startTime) {
void (await this.billingService.increaseUsageByUserId(booking.userId, {
@@ -192,13 +193,16 @@ export class BookingsController_2024_04_15 {
@Req() req: BookingRequest,
@Param("bookingId") bookingId: string,
@Body() _: CancelBookingInput_2024_04_15,
@Headers(X_CAL_CLIENT_ID) clientId?: string
@Headers(X_CAL_CLIENT_ID) clientId?: string,
@Headers(X_CAL_PLATFORM_EMBED) isEmbed?: string
): Promise<ApiResponse<{ bookingId: number; bookingUid: string; onlyRemovedAttendee: boolean }>> {
const oAuthClientId = clientId?.toString();
if (bookingId) {
try {
req.body.id = parseInt(bookingId);
const res = await handleCancelBooking(await this.createNextApiBookingRequest(req, oAuthClientId));
const res = await handleCancelBooking(
await this.createNextApiBookingRequest(req, oAuthClientId, undefined, isEmbed)
);
if (!res.onlyRemovedAttendee) {
void (await this.billingService.cancelUsageByBookingUid(res.bookingUid));
}
@@ -246,7 +250,8 @@ export class BookingsController_2024_04_15 {
async createRecurringBooking(
@Req() req: BookingRequest,
@Body() _: CreateRecurringBookingInput_2024_04_15[],
@Headers(X_CAL_CLIENT_ID) clientId?: string
@Headers(X_CAL_CLIENT_ID) clientId?: string,
@Headers(X_CAL_PLATFORM_EMBED) isEmbed?: string
): Promise<ApiResponse<BookingResponse[]>> {
const oAuthClientId = clientId?.toString();
try {
@@ -258,7 +263,7 @@ export class BookingsController_2024_04_15 {
}
const createdBookings: BookingResponse[] = await handleNewRecurringBooking(
await this.createNextApiRecurringBookingRequest(req, oAuthClientId)
await this.createNextApiRecurringBookingRequest(req, oAuthClientId, undefined, isEmbed)
);
createdBookings.forEach(async (booking) => {
@@ -284,13 +289,14 @@ export class BookingsController_2024_04_15 {
async createInstantBooking(
@Req() req: BookingRequest,
@Body() _: CreateBookingInput_2024_04_15,
@Headers(X_CAL_CLIENT_ID) clientId?: string
@Headers(X_CAL_CLIENT_ID) clientId?: string,
@Headers(X_CAL_PLATFORM_EMBED) isEmbed?: string
): Promise<ApiResponse<Awaited<ReturnType<typeof handleInstantMeeting>>>> {
const oAuthClientId = clientId?.toString();
req.userId = (await this.getOwnerId(req)) ?? -1;
try {
const instantMeeting = await handleInstantMeeting(
await this.createNextApiBookingRequest(req, oAuthClientId)
await this.createNextApiBookingRequest(req, oAuthClientId, undefined, isEmbed)
);
if (instantMeeting.userId && instantMeeting.bookingUid) {
@@ -332,8 +338,14 @@ export class BookingsController_2024_04_15 {
}
}
private async getOAuthClientsParams(clientId: string): Promise<OAuthRequestParams> {
private async getOAuthClientsParams(clientId: string, isEmbed = false): Promise<OAuthRequestParams> {
const res = { ...DEFAULT_PLATFORM_PARAMS };
if (isEmbed) {
// embed should ignore oauth client settings and enable emails by default
return { ...res, arePlatformEmailsEnabled: true };
}
try {
const client = await this.oAuthClientRepository.getOAuthClient(clientId);
// fetch oAuthClient from db and use data stored in db to set these values
@@ -354,13 +366,14 @@ export class BookingsController_2024_04_15 {
private async createNextApiBookingRequest(
req: BookingRequest,
oAuthClientId?: string,
platformBookingLocation?: string
platformBookingLocation?: string,
isEmbed?: string
): Promise<NextApiRequest & { userId?: number } & OAuthRequestParams> {
const requestId = req.get("X-Request-Id");
const clone = { ...req };
const userId = (await this.getOwnerId(req)) ?? -1;
const oAuthParams = oAuthClientId
? await this.getOAuthClientsParams(oAuthClientId)
? await this.getOAuthClientsParams(oAuthClientId, this.transformToBoolean(isEmbed))
: DEFAULT_PLATFORM_PARAMS;
this.logger.log(`createNextApiBookingRequest_2024_04_15`, {
requestId,
@@ -377,12 +390,13 @@ export class BookingsController_2024_04_15 {
private async createNextApiRecurringBookingRequest(
req: BookingRequest,
oAuthClientId?: string,
platformBookingLocation?: string
platformBookingLocation?: string,
isEmbed?: string
): Promise<NextApiRequest & { userId?: number } & OAuthRequestParams> {
const clone = { ...req };
const userId = (await this.getOwnerId(req)) ?? -1;
const oAuthParams = oAuthClientId
? await this.getOAuthClientsParams(oAuthClientId)
? await this.getOAuthClientsParams(oAuthClientId, this.transformToBoolean(isEmbed))
: DEFAULT_PLATFORM_PARAMS;
const requestId = req.get("X-Request-Id");
this.logger.log(`createNextApiRecurringBookingRequest_2024_04_15`, {
@@ -424,4 +438,8 @@ export class BookingsController_2024_04_15 {
throw new InternalServerErrorException(errMsg);
}
private transformToBoolean(v?: string): boolean {
return v && typeof v === "string" ? v.toLowerCase() === "true" : false;
}
}
@@ -5,7 +5,7 @@ import { MarkAbsentBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/out
import { ReassignBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reassign-booking.output";
import { RescheduleBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reschedule-booking.output";
import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service";
import { VERSION_2024_08_13_VALUE } from "@/lib/api-versions";
import { VERSION_2024_08_13_VALUE, VERSION_2024_08_13 } from "@/lib/api-versions";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
@@ -65,6 +65,7 @@ import {
@ApiHeader({
name: "cal-api-version",
description: `Must be set to \`2024-08-13\``,
example: VERSION_2024_08_13,
required: true,
})
export class BookingsController_2024_08_13 {
@@ -952,7 +952,10 @@ describe("Bookings Endpoints 2024-08-13", () => {
slug: recurringSeatedTventTypeSlug,
});
expect(firstBooking.attendees.length).toEqual(2);
expect(firstBooking.attendees[0]).toEqual({
const firstAttendee = firstBooking.attendees.find(
(attendee) => attendee.name === nameAttendeeOne
);
expect(firstAttendee).toEqual({
name: nameAttendeeOne,
email: emailAttendeeOne,
timeZone: body.attendee.timeZone,
@@ -965,7 +968,10 @@ describe("Bookings Endpoints 2024-08-13", () => {
},
metadata: createdRecurringSeatedBooking[0].attendees[0].metadata,
});
expect(firstBooking.attendees[1]).toEqual({
const secondAttendee = firstBooking.attendees.find(
(attendee) => attendee.name === nameAttendeeTwo
);
expect(secondAttendee).toEqual({
name: nameAttendeeTwo,
email: emailAttendeeTwo,
timeZone: body.attendee.timeZone,
@@ -340,7 +340,10 @@ export class BookingsService_2024_08_13 {
}
const bookingRequest = await this.inputService.createCancelBookingRequest(request, bookingUid, body);
await handleCancelBooking(bookingRequest);
const res = await handleCancelBooking(bookingRequest);
if (!res.onlyRemovedAttendee) {
await this.billingService.cancelUsageByBookingUid(res.bookingUid);
}
if ("cancelSubsequentBookings" in body && body.cancelSubsequentBookings) {
return this.getAllRecurringBookingsByIndividualUid(bookingUid);
@@ -86,18 +86,16 @@ export class CalendarsController {
@UseGuards(ApiAuthGuard)
@Get("/busy-times")
@ApiOperation({ summary: "Get busy times" })
@ApiOperation({
summary: "Get busy times",
description:
"Get busy times from a calendar. Example request URL is `https://api.cal.com/v2/calendars/busy-times?loggedInUsersTz=Europe%2FMadrid&dateFrom=2024-12-18&dateTo=2024-12-18&calendarsToLoad[0][credentialId]=135&calendarsToLoad[0][externalId]=skrauciz%40gmail.com`",
})
async getBusyTimes(
@Query() queryParams: CalendarBusyTimesInput,
@GetUser() user: UserWithProfile
): Promise<GetBusyTimesOutput> {
const { loggedInUsersTz, dateFrom, dateTo, calendarsToLoad } = queryParams;
if (!dateFrom || !dateTo) {
return {
status: SUCCESS_STATUS,
data: [],
};
}
const busyTimes = await this.calendarsService.getBusyTimes(
calendarsToLoad,
@@ -19,7 +19,10 @@ import { DateTime } from "luxon";
import { z } from "zod";
import { APPS_TYPE_ID_MAPPING } from "@calcom/platform-constants";
import { getConnectedDestinationCalendars, getBusyCalendarTimes } from "@calcom/platform-libraries";
import {
getConnectedDestinationCalendarsAndEnsureDefaultsInDb,
getBusyCalendarTimes,
} from "@calcom/platform-libraries";
import { Calendar } from "@calcom/platform-types";
import { PrismaClient } from "@calcom/prisma";
@@ -41,12 +44,18 @@ export class CalendarsService {
if (!userWithCalendars) {
throw new NotFoundException("User not found");
}
return getConnectedDestinationCalendars(
userWithCalendars,
false,
this.dbWrite.prisma as unknown as PrismaClient
);
return getConnectedDestinationCalendarsAndEnsureDefaultsInDb({
user: {
...userWithCalendars,
allSelectedCalendars: userWithCalendars.selectedCalendars,
userLevelSelectedCalendars: userWithCalendars.selectedCalendars.filter(
(calendar) => !calendar.eventTypeId
),
},
onboarding: false,
eventTypeId: null,
prisma: this.dbWrite.prisma as unknown as PrismaClient,
});
}
async getBusyTimes(
@@ -64,7 +73,6 @@ export class CalendarsService {
);
try {
const calendarBusyTimes = await getBusyCalendarTimes(
"",
credentials,
dateFrom,
dateTo,
@@ -33,11 +33,18 @@ import {
ApiSuccessResponse,
CreateEventTypeInput_2024_06_14,
EventTypeOutput_2024_06_14,
NameFieldInput_2024_06_14,
NameDefaultFieldInput_2024_06_14,
NotesDefaultFieldInput_2024_06_14,
UpdateEventTypeInput_2024_06_14,
} from "@calcom/platform-types";
import { SchedulingType } from "@calcom/prisma/enums";
const orderBySlug = (a: { slug: string }, b: { slug: string }) => {
if (a.slug < b.slug) return -1;
if (a.slug > b.slug) return 1;
return 0;
};
describe("Event types Endpoints", () => {
describe("Not authenticated", () => {
let app: INestApplication;
@@ -93,6 +100,68 @@ describe("Event types Endpoints", () => {
let orgUserEventType1: EventType;
let orgUserEventType2: EventType;
let orgUserEventType3: EventType;
const defaultResponseBookingFieldName = {
isDefault: true,
type: "name",
slug: "name",
required: true,
disableOnPrefill: false,
};
const defaultResponseBookingFieldEmail = {
isDefault: true,
type: "email",
slug: "email",
required: true,
disableOnPrefill: false,
};
const defaultResponseBookingFieldLocation = {
isDefault: true,
type: "radioInput",
slug: "location",
required: false,
hidden: false,
disableOnPrefill: false,
};
const defaultResponseBookingFieldTitle = {
isDefault: true,
type: "text",
slug: "title",
required: true,
hidden: true,
disableOnPrefill: false,
};
const defaultResponseBookingFieldNotes = {
isDefault: true,
type: "textarea",
slug: "notes",
required: false,
hidden: false,
disableOnPrefill: false,
};
const defaultResponseBookingFieldGuests = {
isDefault: true,
type: "multiemail",
slug: "guests",
required: false,
hidden: false,
disableOnPrefill: false,
};
const defaultResponseBookingFieldRescheduleReason = {
isDefault: true,
type: "textarea",
slug: "rescheduleReason",
required: false,
hidden: false,
disableOnPrefill: false,
};
beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmail,
@@ -253,7 +322,7 @@ describe("Event types Endpoints", () => {
});
it("should create an event type", async () => {
const nameBookingField: NameFieldInput_2024_06_14 = {
const nameBookingField: NameDefaultFieldInput_2024_06_14 = {
type: "name",
label: "Your name sir / madam",
placeholder: "john doe",
@@ -291,6 +360,7 @@ describe("Event types Endpoints", () => {
placeholder: "select language",
options: ["javascript", "python", "cobol"],
disableOnPrefill: true,
hidden: false,
},
],
scheduleId: firstSchedule.id,
@@ -378,16 +448,25 @@ describe("Event types Endpoints", () => {
);
expect(createdEventType.color).toEqual(body.color);
const requestBookingFields = body.bookingFields || [];
const expectedBookingFields = [
{ isDefault: true, required: true, slug: "name", ...nameBookingField },
{ isDefault: true, required: true, slug: "email", type: "email" },
// note(Lauris): location booking field is added if multiple locations are passed
{ isDefault: true, required: false, slug: "location", type: "radioInput" },
{ isDefault: true, required: false, slug: "rescheduleReason", type: "textarea" },
...requestBookingFields
.filter((field) => field.type !== "name" && field.type !== "email")
.map((field) => ({ isDefault: false, ...field })),
{ ...defaultResponseBookingFieldName, ...nameBookingField },
{ ...defaultResponseBookingFieldEmail },
{ ...defaultResponseBookingFieldLocation },
{ ...defaultResponseBookingFieldTitle },
{ ...defaultResponseBookingFieldGuests },
{ ...defaultResponseBookingFieldRescheduleReason },
{
type: "select",
label: "select which language you want to learn",
slug: "select-language",
required: true,
placeholder: "select language",
options: ["javascript", "python", "cobol"],
disableOnPrefill: true,
hidden: false,
isDefault: false,
},
{ ...defaultResponseBookingFieldNotes },
];
expect(createdEventType.bookingFields).toEqual(expectedBookingFields);
@@ -417,7 +496,9 @@ describe("Event types Endpoints", () => {
expect(fetchedEventType?.lengthInMinutes).toEqual(eventType.lengthInMinutes);
expect(fetchedEventType?.lengthInMinutesOptions).toEqual(eventType.lengthInMinutesOptions);
expect(fetchedEventType?.locations).toEqual(eventType.locations);
expect(fetchedEventType?.bookingFields).toEqual(eventType.bookingFields);
expect(fetchedEventType?.bookingFields.sort(orderBySlug)).toEqual(
eventType.bookingFields.sort(orderBySlug).filter((f) => ("hidden" in f ? !f?.hidden : true))
);
expect(fetchedEventType?.ownerId).toEqual(user.id);
expect(fetchedEventType.bookingLimitsCount).toEqual(eventType.bookingLimitsCount);
expect(fetchedEventType.onlyShowFirstAvailableSlot).toEqual(eventType.onlyShowFirstAvailableSlot);
@@ -440,7 +521,6 @@ describe("Event types Endpoints", () => {
});
it(`/GET/event-types by username and orgSlug`, async () => {
console.log(organization);
const response = await request(app.getHttpServer())
.get(`/api/v2/event-types?username=${orgUser.username}&orgSlug=${organization.slug}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
@@ -621,7 +701,6 @@ describe("Event types Endpoints", () => {
.expect(201);
const createdEventType = createResponse.body.data;
console.log("createdEventType: ", createdEventType);
expect(createdEventType).toMatchObject({
id: expect.any(Number),
@@ -764,13 +843,20 @@ describe("Event types Endpoints", () => {
});
it("should update event type", async () => {
const nameBookingField: NameFieldInput_2024_06_14 = {
const nameBookingField: NameDefaultFieldInput_2024_06_14 = {
type: "name",
label: "Your name sir / madam",
placeholder: "john doe",
disableOnPrefill: true,
};
const notesBookingField: NotesDefaultFieldInput_2024_06_14 = {
slug: "notes",
label: "lemme take some notes",
placeholder: "write your notes here",
required: true,
};
const newTitle = "Coding class in Italian!";
const body: UpdateEventTypeInput_2024_06_14 = {
@@ -787,7 +873,9 @@ describe("Event types Endpoints", () => {
placeholder: "select language",
options: ["javascript", "python", "cobol"],
disableOnPrefill: false,
hidden: false,
},
notesBookingField,
],
bookingLimitsCount: {
day: 4,
@@ -850,14 +938,25 @@ describe("Event types Endpoints", () => {
expect(updatedEventType.lengthInMinutes).toEqual(eventType.lengthInMinutes);
expect(updatedEventType.locations).toEqual(eventType.locations);
const requestBookingFields = body.bookingFields || [];
const expectedBookingFields = [
{ isDefault: true, required: true, slug: "name", ...nameBookingField },
{ isDefault: true, required: true, slug: "email", type: "email" },
{ isDefault: true, required: false, slug: "rescheduleReason", type: "textarea" },
...requestBookingFields
.filter((field) => field.type !== "name" && field.type !== "email")
.map((field) => ({ isDefault: false, ...field })),
{ ...defaultResponseBookingFieldName, ...nameBookingField },
{ ...defaultResponseBookingFieldEmail },
{ ...defaultResponseBookingFieldLocation },
{ ...defaultResponseBookingFieldTitle },
{ ...defaultResponseBookingFieldGuests },
{ ...defaultResponseBookingFieldRescheduleReason },
{
type: "select",
label: "select which language you want to learn",
slug: "select-language",
required: true,
placeholder: "select language",
options: ["javascript", "python", "cobol"],
disableOnPrefill: false,
hidden: false,
isDefault: false,
},
{ ...defaultResponseBookingFieldNotes, ...notesBookingField },
];
expect(updatedEventType.bookingFields).toEqual(expectedBookingFields);
@@ -974,7 +1073,9 @@ describe("Event types Endpoints", () => {
expect(fetchedEventType?.lengthInMinutes).toEqual(eventType.lengthInMinutes);
expect(fetchedEventType?.lengthInMinutesOptions).toEqual(eventType.lengthInMinutesOptions);
expect(fetchedEventType?.locations).toEqual(eventType.locations);
expect(fetchedEventType?.bookingFields).toEqual(eventType.bookingFields);
expect(fetchedEventType?.bookingFields.sort(orderBySlug)).toEqual(
eventType.bookingFields.sort(orderBySlug).filter((f) => ("hidden" in f ? !f?.hidden : true))
);
expect(fetchedEventType?.ownerId).toEqual(user.id);
expect(fetchedEventType.bookingLimitsCount).toEqual(eventType.bookingLimitsCount);
expect(fetchedEventType.onlyShowFirstAvailableSlot).toEqual(eventType.onlyShowFirstAvailableSlot);
@@ -1055,14 +1156,44 @@ describe("Event types Endpoints", () => {
let legacyEventTypeId2: number;
const expectedReturnSystemFields = [
{ isDefault: true, required: true, slug: "name", type: "name" },
{ isDefault: true, required: true, slug: "email", type: "email" },
{ isDefault: true, type: "radioInput", slug: "location", required: false },
{ isDefault: true, required: true, slug: "title", type: "text" },
{ isDefault: true, required: false, slug: "notes", type: "textarea" },
{ isDefault: true, required: false, slug: "guests", type: "multiemail" },
{ isDefault: true, required: false, slug: "rescheduleReason", type: "textarea" },
{ isDefault: true, type: "phone", slug: "attendeePhoneNumber", required: false },
{ isDefault: true, required: true, slug: "name", type: "name", disableOnPrefill: false },
{ isDefault: true, required: true, slug: "email", type: "email", disableOnPrefill: false },
{
isDefault: true,
type: "radioInput",
slug: "location",
required: false,
disableOnPrefill: false,
hidden: false,
label: "",
placeholder: "",
},
{ isDefault: true, required: true, slug: "title", type: "text", disableOnPrefill: false, hidden: true },
{
isDefault: true,
required: false,
slug: "guests",
type: "multiemail",
disableOnPrefill: false,
hidden: false,
},
{
isDefault: true,
required: false,
slug: "rescheduleReason",
type: "textarea",
disableOnPrefill: false,
hidden: false,
},
{ isDefault: true, type: "phone", slug: "attendeePhoneNumber", required: false, hidden: true },
{
isDefault: true,
required: false,
slug: "notes",
type: "textarea",
disableOnPrefill: false,
hidden: false,
},
];
beforeAll(async () => {
@@ -1186,15 +1317,6 @@ describe("Event types Endpoints", () => {
defaultLabel: "what_is_this_meeting_about",
defaultPlaceholder: "",
},
{
name: "notes",
type: "textarea",
sources: [{ id: "default", type: "default", label: "Default" }],
editable: "system-but-optional",
required: false,
defaultLabel: "additional_notes",
defaultPlaceholder: "share_additional_notes",
},
{
name: "guests",
type: "multiemail",
@@ -1230,6 +1352,15 @@ describe("Event types Endpoints", () => {
required: false,
defaultLabel: "phone_number",
},
{
name: "notes",
type: "textarea",
sources: [{ id: "default", type: "default", label: "Default" }],
editable: "system-but-optional",
required: false,
defaultLabel: "additional_notes",
defaultPlaceholder: "share_additional_notes",
},
],
};
const legacyEventType = await eventTypesRepositoryFixture.create(legacyEventTypeInput, user.id);
@@ -1316,15 +1447,6 @@ describe("Event types Endpoints", () => {
defaultLabel: "what_is_this_meeting_about",
defaultPlaceholder: "",
},
{
name: "notes",
type: "textarea",
sources: [{ id: "default", type: "default", label: "Default" }],
editable: "system-but-optional",
required: false,
defaultLabel: "additional_notes",
defaultPlaceholder: "share_additional_notes",
},
{
name: "guests",
type: "multiemail",
@@ -1360,6 +1482,15 @@ describe("Event types Endpoints", () => {
required: false,
defaultLabel: "phone_number",
},
{
name: "notes",
type: "textarea",
sources: [{ id: "default", type: "default", label: "Default" }],
editable: "system-but-optional",
required: false,
defaultLabel: "additional_notes",
defaultPlaceholder: "share_additional_notes",
},
],
};
const legacyEventType = await eventTypesRepositoryFixture.create(legacyEventTypeInput, user.id);
@@ -1373,17 +1504,21 @@ describe("Event types Endpoints", () => {
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
const fetchedEventType = responseBody.data;
expect(fetchedEventType.bookingFields).toEqual([
...expectedReturnSystemFields,
{
isDefault: false,
type: userDefinedBookingField.type,
slug: userDefinedBookingField.name,
label: userDefinedBookingField.label,
required: userDefinedBookingField.required,
placeholder: userDefinedBookingField.placeholder,
},
]);
expect(fetchedEventType.bookingFields.sort(orderBySlug)).toEqual(
[
{
isDefault: false,
type: userDefinedBookingField.type,
slug: userDefinedBookingField.name,
label: userDefinedBookingField.label,
required: userDefinedBookingField.required,
placeholder: userDefinedBookingField.placeholder,
disableOnPrefill: false,
hidden: false,
},
...expectedReturnSystemFields,
].sort(orderBySlug)
);
});
});
@@ -1461,42 +1596,54 @@ describe("Event types Endpoints", () => {
type: "name",
slug: "name",
required: true,
disableOnPrefill: false,
},
{
isDefault: true,
type: "email",
slug: "email",
required: true,
disableOnPrefill: false,
},
{
isDefault: true,
type: "radioInput",
slug: "location",
required: false,
disableOnPrefill: false,
hidden: false,
},
{
isDefault: true,
type: "text",
slug: "title",
required: true,
},
{
isDefault: true,
type: "textarea",
slug: "notes",
required: false,
disableOnPrefill: false,
hidden: true,
},
{
isDefault: true,
type: "multiemail",
slug: "guests",
required: false,
disableOnPrefill: false,
hidden: false,
},
{
isDefault: true,
type: "textarea",
slug: "rescheduleReason",
required: false,
disableOnPrefill: false,
hidden: false,
},
{
isDefault: true,
type: "textarea",
slug: "notes",
required: false,
disableOnPrefill: false,
hidden: false,
},
]);
});
@@ -34,6 +34,7 @@ import {
UpdateEventTypeInput_2024_06_14,
GetEventTypesQuery_2024_06_14,
CreateEventTypeInput_2024_06_14,
EventTypeOutput_2024_06_14,
} from "@calcom/platform-types";
@Controller({
@@ -113,10 +114,26 @@ export class EventTypesController_2024_06_14 {
@Query() queryParams: GetEventTypesQuery_2024_06_14
): Promise<GetEventTypesOutput_2024_06_14> {
const eventTypes = await this.eventTypesService.getEventTypes(queryParams);
const eventTypesFormatted = this.eventTypeResponseTransformPipe.transform(eventTypes);
const eventTypesWithoutHiddenFields = eventTypesFormatted.map((eventType) => {
return {
...eventType,
bookingFields: Array.isArray(eventType?.bookingFields)
? eventType?.bookingFields
.map((field) => {
if ("hidden" in field) {
return field.hidden !== true ? field : null;
}
return field;
})
.filter((f) => f)
: [],
};
}) as EventTypeOutput_2024_06_14[];
return {
status: SUCCESS_STATUS,
data: this.eventTypeResponseTransformPipe.transform(eventTypes),
data: eventTypesWithoutHiddenFields,
};
}
@@ -12,9 +12,12 @@ import {
transformRecurrenceApiToInternal,
systemBeforeFieldName,
systemBeforeFieldEmail,
systemBeforeFieldLocation,
systemAfterFieldTitle,
systemAfterFieldNotes,
systemAfterFieldGuests,
systemAfterFieldRescheduleReason,
EventTypeMetaDataSchema,
systemBeforeFieldLocation,
transformBookerLayoutsApiToInternal,
transformConfirmationPolicyApiToInternal,
transformEventColorsApiToInternal,
@@ -217,27 +220,46 @@ export class InputEventTypesService_2024_06_14 {
inputBookingFields: CreateEventTypeInput_2024_06_14["bookingFields"],
hasMultipleLocations: boolean
) {
const customFields: (SystemField | CustomField)[] = inputBookingFields
const internalFields: (SystemField | CustomField)[] = inputBookingFields
? transformBookingFieldsApiToInternal(inputBookingFields)
: [];
const customFieldsWithoutNameEmail = customFields.filter(
(field) => field.type !== "name" && field.type !== "email"
const systemCustomFields = internalFields.filter((field) => !this.isUserCustomField(field));
const userCustomFields = internalFields.filter((field) => this.isUserCustomField(field));
const systemCustomNameField = systemCustomFields?.find((field) => field.type === "name");
const systemCustomEmailField = systemCustomFields?.find((field) => field.type === "email");
const systemCustomTitleField = systemCustomFields?.find((field) => field.name === "title");
const systemCustomNotesField = systemCustomFields?.find((field) => field.name === "notes");
const systemCustomGuestsField = systemCustomFields?.find((field) => field.name === "guests");
const systemCustomRescheduleReasonField = systemCustomFields?.find(
(field) => field.name === "rescheduleReason"
);
const customNameField = customFields?.find((field) => field.type === "name");
const customEmailField = customFields?.find((field) => field.type === "email");
const defaultFieldsBefore: (SystemField | CustomField)[] = [
customNameField || systemBeforeFieldName,
customEmailField || systemBeforeFieldEmail,
systemCustomNameField || systemBeforeFieldName,
systemCustomEmailField || systemBeforeFieldEmail,
systemBeforeFieldLocation,
];
// note(Lauris): if event type has multiple locations then a radio button booking field has to be displayed to allow booker to pick location
if (hasMultipleLocations) {
defaultFieldsBefore.push(systemBeforeFieldLocation);
}
const defaultFieldsAfter = [systemAfterFieldRescheduleReason];
const defaultFieldsAfter = [
systemCustomTitleField || systemAfterFieldTitle,
systemCustomNotesField || systemAfterFieldNotes,
systemCustomGuestsField || systemAfterFieldGuests,
systemCustomRescheduleReasonField || systemAfterFieldRescheduleReason,
];
return [...defaultFieldsBefore, ...customFieldsWithoutNameEmail, ...defaultFieldsAfter];
return [...defaultFieldsBefore, ...userCustomFields, ...defaultFieldsAfter];
}
isUserCustomField(field: SystemField | CustomField): field is CustomField {
return (
field.type !== "name" &&
field.type !== "email" &&
field.name !== "title" &&
field.name !== "notes" &&
field.name !== "guests" &&
field.name !== "rescheduleReason"
);
}
transformInputIntervalLimits(inputBookingFields: CreateEventTypeInput_2024_06_14["bookingLimitsCount"]) {
@@ -242,7 +242,19 @@ export class OutputEventTypesService_2024_06_14 {
}
}
return [...transformBookingFieldsInternalToApi(knownBookingFields), ...unknownBookingFields];
const fields = [...transformBookingFieldsInternalToApi(knownBookingFields), ...unknownBookingFields];
// ensure additional notes are always at the end
return fields.sort((a, b) => {
if (!a?.slug || !b?.slug) return 0;
if (a.slug === "notes" && b.slug !== "notes") {
return 1;
}
if (b.slug === "notes" && a.slug !== "notes") {
return -1;
}
return 0;
});
}
getDefaultBookingFields(isOrgTeamEvent: boolean) {
@@ -9,6 +9,7 @@ import { ProviderModule } from "@/ee/provider/provider.module";
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
import { SchedulesModule_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.module";
import { SlotsModule } from "@/modules/slots/slots.module";
import { TeamsEventTypesModule } from "@/modules/teams/event-types/teams-event-types.module";
import { TeamsModule } from "@/modules/teams/teams/teams.module";
import { TeamsMembershipsModule } from "@/modules/teams/memberships/teams-memberships.module";
import type { MiddlewareConsumer, NestModule } from "@nestjs/common";
@@ -20,6 +21,7 @@ import { Module } from "@nestjs/common";
ProviderModule,
SchedulesModule_2024_04_15,
SchedulesModule_2024_06_11,
TeamsEventTypesModule,
MeModule,
EventTypesModule_2024_04_15,
EventTypesModule_2024_06_14,
@@ -10,7 +10,7 @@ export class OutputSchedulesService_2024_06_11 {
async getResponseSchedule(databaseSchedule: Schedule & { availability: Availability[] }) {
if (!databaseSchedule.timeZone) {
throw new Error("Failed to create schedule because its timezone is not set.");
databaseSchedule.timeZone = "Europe/London";
}
const ownerDefaultScheduleId = await this.usersRepository.getUserScheduleDefaultId(
@@ -41,6 +41,18 @@ describe("isOriginAllowed", () => {
const origin = "https://sub.mydomain.com/path/to/resource";
expect(isOriginAllowed(origin, allowedOrigins)).toBe(true);
});
it("should handle patterns with wildcard for routes correctly", () => {
const allowedOrigins = ["https://domain.com/*"];
const origin = "https://domain.com/dashboard";
expect(isOriginAllowed(origin, allowedOrigins)).toBe(true);
});
it("should handle patterns with wildcard for root route correctly", () => {
const allowedOrigins = ["https://domain.com*"];
const origin = "https://domain.com";
expect(isOriginAllowed(origin, allowedOrigins)).toBe(true);
});
});
describe("is not allowed", () => {
@@ -8,12 +8,13 @@ import { CredentialsRepository } from "@/modules/credentials/credentials.reposit
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TeamsEventTypesModule } from "@/modules/teams/event-types/teams-event-types.module";
import { UsersService } from "@/modules/users/services/users.service";
import { UsersRepository } from "@/modules/users/users.repository";
import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule, EventTypesModule_2024_06_14, OrganizationsModule],
imports: [PrismaModule, EventTypesModule_2024_06_14, OrganizationsModule, TeamsEventTypesModule],
providers: [
EventTypesAtomService,
ConferencingAtomsService,
@@ -2,10 +2,9 @@ import { EventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_
import { AtomsRepository } from "@/modules/atoms/atoms.repository";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { OrganizationsEventTypesService } from "@/modules/organizations/services/event-types/organizations-event-types.service";
import { TeamsEventTypesService } from "@/modules/teams/event-types/services/teams-event-types.service";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
import { UsersService } from "@/modules/users/services/users.service";
import { UserWithProfile } from "@/modules/users/users.repository";
import { Injectable, NotFoundException, ForbiddenException } from "@nestjs/common";
@@ -50,7 +49,7 @@ export class EventTypesAtomService {
private readonly dbWrite: PrismaWriteService,
private readonly dbRead: PrismaReadService,
private readonly eventTypeService: EventTypesService_2024_06_14,
private readonly teamEventTypeService: OrganizationsEventTypesService
private readonly teamEventTypeService: TeamsEventTypesService
) {}
async getUserEventType(user: UserWithProfile, eventTypeId: number) {
@@ -1,22 +1,18 @@
import { UserWithProfile } from "@/modules/users/users.repository";
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
import { ExecutionContext } from "@nestjs/common";
import { createParamDecorator } from "@nestjs/common";
export type GetUserReturnType = UserWithProfile & { isSystemAdmin: boolean };
export const GetUser = createParamDecorator<
keyof GetUserReturnType | (keyof GetUserReturnType)[],
keyof ApiAuthGuardUser | (keyof ApiAuthGuardUser)[],
ExecutionContext
>((data, ctx) => {
const request = ctx.switchToHttp().getRequest();
const user = request.user as GetUserReturnType;
const user = request.user as ApiAuthGuardUser;
if (!user) {
throw new Error("GetUser decorator : User not found");
}
user.isSystemAdmin = user.role === "ADMIN";
if (Array.isArray(data)) {
return data.reduce((prev, curr) => {
return {
@@ -1,5 +1,5 @@
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
import { GetUserReturnType } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
import { PlatformPlanType } from "@/modules/billing/types";
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
import { RedisService } from "@/modules/redis/redis.service";
@@ -19,7 +19,7 @@ export class PlatformPlanGuard implements CanActivate {
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 user = request.user as ApiAuthGuardUser;
const minimumPlan = this.reflector.get(PlatformPlan, context.getHandler()) as PlatformPlanType;
const REDIS_CACHE_KEY = `apiv2:user:${user?.id ?? "none"}:org:${orgId ?? "none"}:team:${
@@ -1,6 +1,6 @@
import { ORG_ROLES, TEAM_ROLES, SYSTEM_ADMIN_ROLE } from "@/lib/roles/constants";
import { GetUserReturnType } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { RedisService } from "@/modules/redis/redis.service";
import { Injectable, CanActivate, ExecutionContext, ForbiddenException, Logger } from "@nestjs/common";
@@ -22,7 +22,7 @@ export class RolesGuard implements CanActivate {
const request = context.switchToHttp().getRequest<Request & { team: Team }>();
const teamId = request.params.teamId as string;
const orgId = request.params.orgId as string;
const user = request.user as GetUserReturnType;
const user = request.user as ApiAuthGuardUser;
const allowedRole = this.reflector.get(Roles, context.getHandler());
const REDIS_CACHE_KEY = `apiv2:user:${user.id ?? "none"}:org:${orgId ?? "none"}:team:${
teamId ?? "none"
@@ -17,6 +17,8 @@ import { getToken } from "next-auth/jwt";
import { INVALID_ACCESS_TOKEN, X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
export type ApiAuthGuardUser = UserWithProfile & { isSystemAdmin: boolean };
@Injectable()
export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth") {
constructor(
@@ -75,12 +77,19 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
async authenticateNextAuth(token: { email?: string | null }) {
const user = await this.nextAuthStrategy(token);
return this.success(user);
return this.success(this.getSuccessUser(user));
}
getSuccessUser(user: UserWithProfile): ApiAuthGuardUser {
return {
...user,
isSystemAdmin: user.role === "ADMIN",
};
}
async authenticateOAuthClient(oAuthClientId: string, oAuthClientSecret: string) {
const user = await this.oAuthClientStrategy(oAuthClientId, oAuthClientSecret);
return this.success(user);
return this.success(this.getSuccessUser(user));
}
async oAuthClientStrategy(oAuthClientId: string, oAuthClientSecret: string) {
@@ -119,7 +128,7 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
return this.error(new UnauthorizedException("No user associated with the provided token"));
}
return this.success(user);
return this.success(this.getSuccessUser(user));
} catch (err) {
if (err instanceof Error) {
return this.error(err);
@@ -170,7 +179,7 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
if (origin && !isOriginAllowed(origin, client.redirectUris)) {
throw new UnauthorizedException(
`Invalid request origin - please open https://app.cal.com/settings/platform and add the origin '${origin}' to the 'Redirect uris' of your OAuth client.`
`Invalid request origin - please open https://app.cal.com/settings/platform and add the origin '${origin}' to the 'Redirect uris' of your OAuth client with ID '${client.id}'`
);
}
@@ -1,4 +1,4 @@
import { GetUserReturnType } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { UsersService } from "@/modules/users/services/users.service";
import {
@@ -15,8 +15,8 @@ export class OAuthClientGuard implements CanActivate {
constructor(private oAuthClientRepository: OAuthClientRepository, private usersService: UsersService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request & { user: GetUserReturnType }>();
const user: GetUserReturnType = request.user;
const request = context.switchToHttp().getRequest<Request & { user: ApiAuthGuardUser }>();
const user: ApiAuthGuardUser = request.user;
const organizationId = user ? this.usersService.getUserMainOrgId(user) : null;
const oAuthClientId = request.params.clientId;
@@ -1,6 +1,5 @@
import { CreatePhoneCallInput } from "@/ee/event-types/event-types_2024_06_14/inputs/create-phone-call.input";
import { CreatePhoneCallOutput } from "@/ee/event-types/event-types_2024_06_14/outputs/create-phone-call.output";
import { InputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/input-event-types.service";
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";
@@ -11,15 +10,15 @@ import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-a
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 { CreateTeamEventTypeOutput } from "@/modules/organizations/controllers/event-types/outputs/teams/create-team-event-type.output";
import { DeleteTeamEventTypeOutput } from "@/modules/organizations/controllers/event-types/outputs/teams/delete-team-event-type.output";
import { GetTeamEventTypeOutput } from "@/modules/organizations/controllers/event-types/outputs/teams/get-team-event-type.output";
import { GetTeamEventTypesOutput } from "@/modules/organizations/controllers/event-types/outputs/teams/get-team-event-types.output";
import { UpdateTeamEventTypeOutput } from "@/modules/organizations/controllers/event-types/outputs/teams/update-team-event-type.output";
import { OutputTeamEventTypesResponsePipe } from "@/modules/organizations/controllers/pipes/event-types/team-event-types-response.transformer";
import { InputOrganizationsEventTypesService } from "@/modules/organizations/services/event-types/input.service";
import { OrganizationsEventTypesService } from "@/modules/organizations/services/event-types/organizations-event-types.service";
import { DatabaseTeamEventType } from "@/modules/organizations/services/event-types/output.service";
import { CreateTeamEventTypeOutput } from "@/modules/teams/event-types/outputs/create-team-event-type.output";
import { DeleteTeamEventTypeOutput } from "@/modules/teams/event-types/outputs/delete-team-event-type.output";
import { GetTeamEventTypeOutput } from "@/modules/teams/event-types/outputs/get-team-event-type.output";
import { GetTeamEventTypesOutput } from "@/modules/teams/event-types/outputs/get-team-event-types.output";
import { UpdateTeamEventTypeOutput } from "@/modules/teams/event-types/outputs/update-team-event-type.output";
import { UserWithProfile } from "@/modules/users/users.repository";
import {
Controller,
@@ -62,7 +61,6 @@ export class OrganizationsEventTypesController {
constructor(
private readonly organizationsEventTypesService: OrganizationsEventTypesService,
private readonly inputService: InputOrganizationsEventTypesService,
private readonly inputUserEventTypesService: InputEventTypesService_2024_06_14,
private readonly outputTeamEventTypesResponsePipe: OutputTeamEventTypesResponsePipe
) {}
@@ -180,7 +178,11 @@ export class OrganizationsEventTypesController {
@Query() queryParams: SkipTakePagination
): Promise<GetTeamEventTypesOutput> {
const { skip, take } = queryParams;
const eventTypes = await this.organizationsEventTypesService.getTeamsEventTypes(orgId, skip, take);
const eventTypes = await this.organizationsEventTypesService.getOrganizationsTeamsEventTypes(
orgId,
skip,
take
);
return {
status: SUCCESS_STATUS,
@@ -677,53 +677,48 @@ describe("Organizations Event Types Endpoints", () => {
const fetchedEventType = responseBody.data;
expect(fetchedEventType.bookingFields).toEqual([
{
isDefault: true,
type: "name",
slug: "name",
required: true,
},
{
isDefault: true,
type: "email",
slug: "email",
required: true,
},
{
isDefault: true,
required: false,
slug: "attendeePhoneNumber",
type: "phone",
},
{ isDefault: true, required: true, slug: "name", type: "name", disableOnPrefill: false },
{ isDefault: true, required: true, slug: "email", type: "email", disableOnPrefill: false },
{ isDefault: true, type: "phone", slug: "attendeePhoneNumber", required: false, hidden: true },
{
isDefault: true,
type: "radioInput",
slug: "location",
required: false,
disableOnPrefill: false,
hidden: false,
},
{
isDefault: true,
type: "text",
slug: "title",
required: true,
slug: "title",
type: "text",
disableOnPrefill: false,
hidden: true,
},
{
isDefault: true,
type: "textarea",
slug: "notes",
required: false,
},
{
isDefault: true,
type: "multiemail",
slug: "guests",
required: false,
type: "multiemail",
disableOnPrefill: false,
hidden: false,
},
{
isDefault: true,
type: "textarea",
slug: "rescheduleReason",
required: false,
slug: "rescheduleReason",
type: "textarea",
disableOnPrefill: false,
hidden: false,
},
{
isDefault: true,
required: false,
slug: "notes",
type: "textarea",
disableOnPrefill: false,
hidden: false,
},
]);
});
@@ -15,8 +15,8 @@ import {
OrgTeamMembershipsOutputResponseDto,
OrgTeamMembershipOutputResponseDto,
} from "@/modules/organizations/outputs/organization-teams-memberships.output";
import { OrganizationsEventTypesService } from "@/modules/organizations/services/event-types/organizations-event-types.service";
import { OrganizationsTeamsMembershipsService } from "@/modules/organizations/services/organizations-teams-memberships.service";
import { TeamsEventTypesService } from "@/modules/teams/event-types/services/teams-event-types.service";
import {
Controller,
UseGuards,
@@ -51,7 +51,7 @@ export class OrganizationsTeamsMembershipsController {
constructor(
private organizationsTeamsMembershipsService: OrganizationsTeamsMembershipsService,
private oganizationsEventTypesService: OrganizationsEventTypesService,
private teamsEventTypesService: TeamsEventTypesService,
private readonly organizationsRepository: OrganizationsRepository
) {}
@@ -119,7 +119,7 @@ export class OrganizationsTeamsMembershipsController {
membershipId
);
await this.oganizationsEventTypesService.deleteUserTeamEventTypesAndHosts(membership.userId, teamId);
await this.teamsEventTypesService.deleteUserTeamEventTypesAndHosts(membership.userId, teamId);
return {
status: SUCCESS_STATUS,
@@ -39,6 +39,7 @@ import { OrganizationsService } from "@/modules/organizations/services/organizat
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { RedisModule } from "@/modules/redis/redis.module";
import { StripeModule } from "@/modules/stripe/stripe.module";
import { TeamsEventTypesModule } from "@/modules/teams/event-types/teams-event-types.module";
import { TeamsModule } from "@/modules/teams/teams/teams.module";
import { UsersModule } from "@/modules/users/users.module";
import { WebhooksService } from "@/modules/webhooks/services/webhooks.service";
@@ -54,6 +55,7 @@ import { Module } from "@nestjs/common";
RedisModule,
EmailModule,
EventTypesModule_2024_06_14,
TeamsEventTypesModule,
TeamsModule,
],
providers: [
@@ -1,91 +1,10 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
@Injectable()
export class OrganizationsEventTypesRepository {
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
async getTeamEventType(teamId: number, eventTypeId: number) {
return this.dbRead.prisma.eventType.findUnique({
where: {
id: eventTypeId,
teamId,
},
include: { users: true, schedule: true, hosts: true, destinationCalendar: true },
});
}
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,
destinationCalendar: true,
team: {
select: {
bannerUrl: true,
name: true,
logoUrl: true,
slug: true,
weekStart: true,
brandColor: true,
darkBrandColor: true,
theme: true,
},
},
},
});
}
async getTeamEventTypes(teamId: number) {
return this.dbRead.prisma.eventType.findMany({
where: {
teamId,
},
include: {
users: true,
schedule: true,
hosts: true,
destinationCalendar: true,
team: {
select: {
bannerUrl: true,
name: true,
logoUrl: true,
slug: true,
weekStart: true,
brandColor: true,
darkBrandColor: true,
theme: true,
},
},
},
});
}
async getEventTypeById(eventTypeId: number) {
return this.dbRead.prisma.eventType.findUnique({
where: { id: eventTypeId },
include: { users: true, schedule: true, hosts: true, destinationCalendar: true },
});
}
async getEventTypeChildren(eventTypeId: number) {
return this.dbRead.prisma.eventType.findMany({
where: { parentId: eventTypeId },
include: { users: true, schedule: true, hosts: true, destinationCalendar: true },
});
}
async getTeamsEventTypes(orgId: number, skip: number, take: number) {
constructor(private readonly dbRead: PrismaReadService) {}
async getOrganizationTeamsEventTypes(orgId: number, skip: number, take: number) {
return this.dbRead.prisma.eventType.findMany({
where: {
team: {
@@ -97,33 +16,4 @@ export class OrganizationsEventTypesRepository {
include: { users: true, schedule: true, hosts: true, destinationCalendar: true },
});
}
async getEventTypeByIdWithChildren(eventTypeId: number) {
return this.dbRead.prisma.eventType.findUnique({
where: { id: eventTypeId },
include: { children: true },
});
}
async deleteUserManagedTeamEventTypes(userId: number, teamId: number) {
return this.dbWrite.prisma.eventType.deleteMany({
where: {
parent: {
teamId,
},
userId,
},
});
}
async removeUserFromTeamEventTypesHosts(userId: number, teamId: number) {
return this.dbWrite.prisma.host.deleteMany({
where: {
userId,
eventType: {
teamId,
},
},
});
}
}
@@ -1,5 +1,5 @@
import { InputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/input-event-types.service";
import { OrganizationsEventTypesRepository } from "@/modules/organizations/repositories/organizations-event-types.repository";
import { TeamsEventTypesRepository } from "@/modules/teams/event-types/teams-event-types.repository";
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
import { UsersRepository } from "@/modules/users/users.repository";
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
@@ -17,7 +17,7 @@ export class InputOrganizationsEventTypesService {
private readonly inputEventTypesService: InputEventTypesService_2024_06_14,
private readonly teamsRepository: TeamsRepository,
private readonly usersRepository: UsersRepository,
private readonly orgEventTypesRepository: OrganizationsEventTypesRepository
private readonly teamsEventTypesRepository: TeamsEventTypesRepository
) {}
async transformAndValidateCreateTeamEventTypeInput(
userId: number,
@@ -115,7 +115,7 @@ export class InputOrganizationsEventTypesService {
const { hosts, assignAllTeamMembers, ...rest } = inputEventType;
const eventType = await this.inputEventTypesService.transformInputUpdateEventType(rest, eventTypeId);
const dbEventType = await this.orgEventTypesRepository.getTeamEventType(teamId, eventTypeId);
const dbEventType = await this.teamsEventTypesRepository.getTeamEventType(teamId, eventTypeId);
if (!dbEventType) {
throw new BadRequestException("Event type to update not found");
@@ -144,7 +144,7 @@ export class InputOrganizationsEventTypesService {
) {
let eventType = null;
if (eventTypeId) {
eventType = await this.orgEventTypesRepository.getEventTypeByIdWithChildren(eventTypeId);
eventType = await this.teamsEventTypesRepository.getEventTypeByIdWithChildren(eventTypeId);
if (!eventType || eventType.schedulingType !== "MANAGED") {
return undefined;
}
@@ -1,14 +1,13 @@
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
import { EventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/event-types.service";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { OrganizationsEventTypesRepository } from "@/modules/organizations/repositories/organizations-event-types.repository";
import { DatabaseTeamEventType } from "@/modules/organizations/services/event-types/output.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { TeamsEventTypesService } from "@/modules/teams/event-types/services/teams-event-types.service";
import { UsersService } from "@/modules/users/services/users.service";
import { UserWithProfile } from "@/modules/users/users.repository";
import { Injectable, NotFoundException, Logger } from "@nestjs/common";
import { Injectable, Logger } from "@nestjs/common";
import { createEventType, updateEventType } from "@calcom/platform-libraries";
import { createEventType } from "@calcom/platform-libraries";
import { InputTeamEventTransformed_2024_06_14 } from "@calcom/platform-types";
@Injectable()
@@ -16,10 +15,9 @@ export class OrganizationsEventTypesService {
private readonly logger = new Logger("OrganizationsEventTypesService");
constructor(
private readonly eventTypesService: EventTypesService_2024_06_14,
private readonly dbWrite: PrismaWriteService,
private readonly organizationEventTypesRepository: OrganizationsEventTypesRepository,
private readonly eventTypesRepository: EventTypesRepository_2024_06_14,
private readonly teamsEventTypesService: TeamsEventTypesService,
private readonly membershipsRepository: MembershipsRepository,
private readonly usersService: UsersService
) {}
@@ -44,15 +42,7 @@ export class OrganizationsEventTypesService {
},
});
return this.updateTeamEventType(eventTypeCreated.id, teamId, body, user);
}
async validateEventTypeExists(teamId: number, eventTypeId: number) {
const eventType = await this.organizationEventTypesRepository.getTeamEventType(teamId, eventTypeId);
if (!eventType) {
throw new NotFoundException(`Event type with id ${eventTypeId} not found`);
}
return this.teamsEventTypesService.updateTeamEventType(eventTypeCreated.id, teamId, body, user);
}
async getUserToCreateTeamEvent(user: UserWithProfile, organizationId: number) {
@@ -71,34 +61,23 @@ export class OrganizationsEventTypesService {
}
async getTeamEventType(teamId: number, eventTypeId: number): Promise<DatabaseTeamEventType | null> {
const eventType = await this.organizationEventTypesRepository.getTeamEventType(teamId, eventTypeId);
if (!eventType) {
return null;
}
return eventType;
return this.teamsEventTypesService.getTeamEventType(teamId, eventTypeId);
}
async getTeamEventTypeBySlug(teamId: number, eventTypeSlug: string): Promise<DatabaseTeamEventType | null> {
const eventType = await this.organizationEventTypesRepository.getTeamEventTypeBySlug(
teamId,
eventTypeSlug
);
if (!eventType) {
return null;
}
return eventType;
return this.teamsEventTypesService.getTeamEventTypeBySlug(teamId, eventTypeSlug);
}
async getTeamEventTypes(teamId: number): Promise<DatabaseTeamEventType[]> {
return await this.organizationEventTypesRepository.getTeamEventTypes(teamId);
return await this.teamsEventTypesService.getTeamEventTypes(teamId);
}
async getTeamsEventTypes(orgId: number, skip = 0, take = 250): Promise<DatabaseTeamEventType[]> {
return await this.organizationEventTypesRepository.getTeamsEventTypes(orgId, skip, take);
async getOrganizationsTeamsEventTypes(
orgId: number,
skip = 0,
take = 250
): Promise<DatabaseTeamEventType[]> {
return await this.organizationEventTypesRepository.getOrganizationTeamsEventTypes(orgId, skip, take);
}
async updateTeamEventType(
@@ -107,57 +86,10 @@ export class OrganizationsEventTypesService {
body: InputTeamEventTransformed_2024_06_14,
user: UserWithProfile
): Promise<DatabaseTeamEventType | DatabaseTeamEventType[]> {
await this.validateEventTypeExists(teamId, eventTypeId);
const eventTypeUser = await this.eventTypesService.getUserToUpdateEvent(user);
await updateEventType({
input: { id: eventTypeId, ...body },
ctx: {
user: eventTypeUser,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
prisma: this.dbWrite.prisma,
},
});
const eventType = await this.organizationEventTypesRepository.getEventTypeById(eventTypeId);
if (!eventType) {
throw new NotFoundException(`Event type with id ${eventTypeId} not found`);
}
if (eventType.schedulingType !== "MANAGED") {
return eventType;
}
const childrenEventTypes = await this.organizationEventTypesRepository.getEventTypeChildren(eventType.id);
return [eventType, ...childrenEventTypes];
return this.teamsEventTypesService.updateTeamEventType(eventTypeId, teamId, body, user);
}
async deleteTeamEventType(teamId: number, eventTypeId: number) {
const existingEventType = await this.organizationEventTypesRepository.getTeamEventType(
teamId,
eventTypeId
);
if (!existingEventType) {
throw new NotFoundException(`Event type with ID=${eventTypeId} does not exist.`);
}
return this.eventTypesRepository.deleteEventType(eventTypeId);
}
async deleteUserTeamEventTypesAndHosts(userId: number, teamId: number) {
try {
await this.organizationEventTypesRepository.deleteUserManagedTeamEventTypes(userId, teamId);
await this.organizationEventTypesRepository.removeUserFromTeamEventTypesHosts(userId, teamId);
} catch (err) {
this.logger.error("Could not remove user from all team event-types.", {
error: err,
userId,
teamId,
});
}
return this.teamsEventTypesService.deleteTeamEventType(teamId, eventTypeId);
}
}
@@ -1,5 +1,5 @@
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 { TeamsEventTypesRepository } from "@/modules/teams/event-types/teams-event-types.repository";
import { UsersRepository } from "@/modules/users/users.repository";
import { Injectable } from "@nestjs/common";
import type { EventType, User, Schedule, Host, DestinationCalendar } from "@prisma/client";
@@ -77,7 +77,7 @@ type Input = Pick<
export class OutputOrganizationsEventTypesService {
constructor(
private readonly outputEventTypesService: OutputEventTypesService_2024_06_14,
private readonly organizationEventTypesRepository: OrganizationsEventTypesRepository,
private readonly teamsEventTypesRepository: TeamsEventTypesRepository,
private readonly usersRepository: UsersRepository
) {}
@@ -117,7 +117,7 @@ export class OutputOrganizationsEventTypesService {
}
async getManagedEventTypeHosts(eventTypeId: number) {
const children = await this.organizationEventTypesRepository.getEventTypeChildren(eventTypeId);
const children = await this.teamsEventTypesRepository.getEventTypeChildren(eventTypeId);
const transformedHosts: TeamEventTypeResponseHost[] = [];
for (const child of children) {
if (child.userId) {
@@ -2,50 +2,62 @@ import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
// It ensures that we work on userLevel calendars only
const ensureUserLevelWhere = {
eventTypeId: null,
};
@Injectable()
export class SelectedCalendarsRepository {
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
upsertSelectedCalendar(externalId: string, credentialId: number, userId: number, integration: string) {
return this.dbWrite.prisma.selectedCalendar.upsert({
create: {
userId,
externalId,
credentialId,
integration,
},
update: {
userId,
externalId,
credentialId,
integration,
},
where: {
userId_integration_externalId: {
userId,
integration,
externalId,
async upsertSelectedCalendar(
externalId: string,
credentialId: number,
userId: number,
integration: string
) {
// Why we can't use .upsert here, see server/repository/selectedCalendar.ts#upsert
const existingUserSelectedCalendar = await this.getUserSelectedCalendar(userId, integration, externalId);
const data = {
userId,
externalId,
credentialId,
integration,
...ensureUserLevelWhere,
};
if (existingUserSelectedCalendar) {
return this.dbWrite.prisma.selectedCalendar.update({
where: {
id: existingUserSelectedCalendar.id,
},
},
data,
});
}
return this.dbWrite.prisma.selectedCalendar.create({
data,
});
}
getUserSelectedCalendars(userId: number) {
// It would be unique result but we can't use .findUnique here because of eventTypeId being nullable
return this.dbRead.prisma.selectedCalendar.findMany({
where: {
userId,
...ensureUserLevelWhere,
},
});
}
getUserSelectedCalendar(userId: number, integration: string, externalId: string) {
return this.dbRead.prisma.selectedCalendar.findUnique({
return this.dbRead.prisma.selectedCalendar.findFirst({
where: {
userId_integration_externalId: {
userId,
externalId,
integration,
},
userId,
externalId,
integration,
...ensureUserLevelWhere,
},
});
}
@@ -56,33 +68,46 @@ export class SelectedCalendarsRepository {
externalId: string,
credentialId: number
) {
return await this.dbWrite.prisma.selectedCalendar.upsert({
where: {
userId_integration_externalId: {
userId,
integration,
externalId,
},
},
create: {
const existingUserSelectedCalendar = await this.getUserSelectedCalendar(userId, integration, externalId);
if (existingUserSelectedCalendar) {
return;
}
return await this.dbWrite.prisma.selectedCalendar.create({
data: {
userId,
integration,
externalId,
credentialId,
...ensureUserLevelWhere,
},
// already exists
update: {},
});
}
async removeUserSelectedCalendar(userId: number, integration: string, externalId: string) {
// Using deleteMany because userId_externalId_integration_eventTypeId is a unique constraint but with eventTypeId being nullable, causing it to be not used as a unique constraint
const records = await this.dbWrite.prisma.selectedCalendar.findMany({
where: {
userId,
externalId,
integration,
...ensureUserLevelWhere,
},
});
// Make the behaviour same as .delete which throws error if no record is found
if (records.length === 0) {
throw new Error("No SelectedCalendar found.");
}
if (records.length > 1) {
throw new Error("Multiple SelecteCalendars found. Skipping deletion");
}
return await this.dbWrite.prisma.selectedCalendar.delete({
where: {
userId_integration_externalId: {
userId,
externalId,
integration,
},
id: records[0].id,
},
});
}
@@ -0,0 +1,490 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { SchedulesModule_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.module";
import { SchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/schedules.service";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { SlotsModule } from "@/modules/slots/slots.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import { User } from "@prisma/client";
import * as request from "supertest";
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
import { SelectedSlotsRepositoryFixture } from "test/fixtures/repository/selected-slots.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { randomNumber } from "test/utils/randomNumber";
import { withApiAuth } from "test/utils/withApiAuth";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
const expectedSlotsUTC = {
slots: {
"2050-09-05": [
{ time: "2050-09-05T07:00:00.000Z" },
{ time: "2050-09-05T08:00:00.000Z" },
{ time: "2050-09-05T09:00:00.000Z" },
{ time: "2050-09-05T10:00:00.000Z" },
{ time: "2050-09-05T11:00:00.000Z" },
{ time: "2050-09-05T12:00:00.000Z" },
{ time: "2050-09-05T13:00:00.000Z" },
{ time: "2050-09-05T14:00:00.000Z" },
],
"2050-09-06": [
{ time: "2050-09-06T07:00:00.000Z" },
{ time: "2050-09-06T08:00:00.000Z" },
{ time: "2050-09-06T09:00:00.000Z" },
{ time: "2050-09-06T10:00:00.000Z" },
{ time: "2050-09-06T11:00:00.000Z" },
{ time: "2050-09-06T12:00:00.000Z" },
{ time: "2050-09-06T13:00:00.000Z" },
{ time: "2050-09-06T14:00:00.000Z" },
],
"2050-09-07": [
{ time: "2050-09-07T07:00:00.000Z" },
{ time: "2050-09-07T08:00:00.000Z" },
{ time: "2050-09-07T09:00:00.000Z" },
{ time: "2050-09-07T10:00:00.000Z" },
{ time: "2050-09-07T11:00:00.000Z" },
{ time: "2050-09-07T12:00:00.000Z" },
{ time: "2050-09-07T13:00:00.000Z" },
{ time: "2050-09-07T14:00:00.000Z" },
],
"2050-09-08": [
{ time: "2050-09-08T07:00:00.000Z" },
{ time: "2050-09-08T08:00:00.000Z" },
{ time: "2050-09-08T09:00:00.000Z" },
{ time: "2050-09-08T10:00:00.000Z" },
{ time: "2050-09-08T11:00:00.000Z" },
{ time: "2050-09-08T12:00:00.000Z" },
{ time: "2050-09-08T13:00:00.000Z" },
{ time: "2050-09-08T14:00:00.000Z" },
],
"2050-09-09": [
{ time: "2050-09-09T07:00:00.000Z" },
{ time: "2050-09-09T08:00:00.000Z" },
{ time: "2050-09-09T09:00:00.000Z" },
{ time: "2050-09-09T10:00:00.000Z" },
{ time: "2050-09-09T11:00:00.000Z" },
{ time: "2050-09-09T12:00:00.000Z" },
{ time: "2050-09-09T13:00:00.000Z" },
{ time: "2050-09-09T14:00:00.000Z" },
],
},
};
const expectedSlotsRome = {
slots: {
"2050-09-05": [
{ time: "2050-09-05T09:00:00.000+02:00" },
{ time: "2050-09-05T10:00:00.000+02:00" },
{ time: "2050-09-05T11:00:00.000+02:00" },
{ time: "2050-09-05T12:00:00.000+02:00" },
{ time: "2050-09-05T13:00:00.000+02:00" },
{ time: "2050-09-05T14:00:00.000+02:00" },
{ time: "2050-09-05T15:00:00.000+02:00" },
{ time: "2050-09-05T16:00:00.000+02:00" },
],
"2050-09-06": [
{ time: "2050-09-06T09:00:00.000+02:00" },
{ time: "2050-09-06T10:00:00.000+02:00" },
{ time: "2050-09-06T11:00:00.000+02:00" },
{ time: "2050-09-06T12:00:00.000+02:00" },
{ time: "2050-09-06T13:00:00.000+02:00" },
{ time: "2050-09-06T14:00:00.000+02:00" },
{ time: "2050-09-06T15:00:00.000+02:00" },
{ time: "2050-09-06T16:00:00.000+02:00" },
],
"2050-09-07": [
{ time: "2050-09-07T09:00:00.000+02:00" },
{ time: "2050-09-07T10:00:00.000+02:00" },
{ time: "2050-09-07T11:00:00.000+02:00" },
{ time: "2050-09-07T12:00:00.000+02:00" },
{ time: "2050-09-07T13:00:00.000+02:00" },
{ time: "2050-09-07T14:00:00.000+02:00" },
{ time: "2050-09-07T15:00:00.000+02:00" },
{ time: "2050-09-07T16:00:00.000+02:00" },
],
"2050-09-08": [
{ time: "2050-09-08T09:00:00.000+02:00" },
{ time: "2050-09-08T10:00:00.000+02:00" },
{ time: "2050-09-08T11:00:00.000+02:00" },
{ time: "2050-09-08T12:00:00.000+02:00" },
{ time: "2050-09-08T13:00:00.000+02:00" },
{ time: "2050-09-08T14:00:00.000+02:00" },
{ time: "2050-09-08T15:00:00.000+02:00" },
{ time: "2050-09-08T16:00:00.000+02:00" },
],
"2050-09-09": [
{ time: "2050-09-09T09:00:00.000+02:00" },
{ time: "2050-09-09T10:00:00.000+02:00" },
{ time: "2050-09-09T11:00:00.000+02:00" },
{ time: "2050-09-09T12:00:00.000+02:00" },
{ time: "2050-09-09T13:00:00.000+02:00" },
{ time: "2050-09-09T14:00:00.000+02:00" },
{ time: "2050-09-09T15:00:00.000+02:00" },
{ time: "2050-09-09T16:00:00.000+02:00" },
],
},
};
const expectedSlotsUTCRange = {
slots: {
"2050-09-05": [
{ startTime: "2050-09-05T07:00:00.000Z", endTime: "2050-09-05T08:00:00.000Z" },
{ startTime: "2050-09-05T08:00:00.000Z", endTime: "2050-09-05T09:00:00.000Z" },
{ startTime: "2050-09-05T09:00:00.000Z", endTime: "2050-09-05T10:00:00.000Z" },
{ startTime: "2050-09-05T10:00:00.000Z", endTime: "2050-09-05T11:00:00.000Z" },
{ startTime: "2050-09-05T11:00:00.000Z", endTime: "2050-09-05T12:00:00.000Z" },
{ startTime: "2050-09-05T12:00:00.000Z", endTime: "2050-09-05T13:00:00.000Z" },
{ startTime: "2050-09-05T13:00:00.000Z", endTime: "2050-09-05T14:00:00.000Z" },
{ startTime: "2050-09-05T14:00:00.000Z", endTime: "2050-09-05T15:00:00.000Z" },
],
"2050-09-06": [
{ startTime: "2050-09-06T07:00:00.000Z", endTime: "2050-09-06T08:00:00.000Z" },
{ startTime: "2050-09-06T08:00:00.000Z", endTime: "2050-09-06T09:00:00.000Z" },
{ startTime: "2050-09-06T09:00:00.000Z", endTime: "2050-09-06T10:00:00.000Z" },
{ startTime: "2050-09-06T10:00:00.000Z", endTime: "2050-09-06T11:00:00.000Z" },
{ startTime: "2050-09-06T11:00:00.000Z", endTime: "2050-09-06T12:00:00.000Z" },
{ startTime: "2050-09-06T12:00:00.000Z", endTime: "2050-09-06T13:00:00.000Z" },
{ startTime: "2050-09-06T13:00:00.000Z", endTime: "2050-09-06T14:00:00.000Z" },
{ startTime: "2050-09-06T14:00:00.000Z", endTime: "2050-09-06T15:00:00.000Z" },
],
"2050-09-07": [
{ startTime: "2050-09-07T07:00:00.000Z", endTime: "2050-09-07T08:00:00.000Z" },
{ startTime: "2050-09-07T08:00:00.000Z", endTime: "2050-09-07T09:00:00.000Z" },
{ startTime: "2050-09-07T09:00:00.000Z", endTime: "2050-09-07T10:00:00.000Z" },
{ startTime: "2050-09-07T10:00:00.000Z", endTime: "2050-09-07T11:00:00.000Z" },
{ startTime: "2050-09-07T11:00:00.000Z", endTime: "2050-09-07T12:00:00.000Z" },
{ startTime: "2050-09-07T12:00:00.000Z", endTime: "2050-09-07T13:00:00.000Z" },
{ startTime: "2050-09-07T13:00:00.000Z", endTime: "2050-09-07T14:00:00.000Z" },
{ startTime: "2050-09-07T14:00:00.000Z", endTime: "2050-09-07T15:00:00.000Z" },
],
"2050-09-08": [
{ startTime: "2050-09-08T07:00:00.000Z", endTime: "2050-09-08T08:00:00.000Z" },
{ startTime: "2050-09-08T08:00:00.000Z", endTime: "2050-09-08T09:00:00.000Z" },
{ startTime: "2050-09-08T09:00:00.000Z", endTime: "2050-09-08T10:00:00.000Z" },
{ startTime: "2050-09-08T10:00:00.000Z", endTime: "2050-09-08T11:00:00.000Z" },
{ startTime: "2050-09-08T11:00:00.000Z", endTime: "2050-09-08T12:00:00.000Z" },
{ startTime: "2050-09-08T12:00:00.000Z", endTime: "2050-09-08T13:00:00.000Z" },
{ startTime: "2050-09-08T13:00:00.000Z", endTime: "2050-09-08T14:00:00.000Z" },
{ startTime: "2050-09-08T14:00:00.000Z", endTime: "2050-09-08T15:00:00.000Z" },
],
"2050-09-09": [
{ startTime: "2050-09-09T07:00:00.000Z", endTime: "2050-09-09T08:00:00.000Z" },
{ startTime: "2050-09-09T08:00:00.000Z", endTime: "2050-09-09T09:00:00.000Z" },
{ startTime: "2050-09-09T09:00:00.000Z", endTime: "2050-09-09T10:00:00.000Z" },
{ startTime: "2050-09-09T10:00:00.000Z", endTime: "2050-09-09T11:00:00.000Z" },
{ startTime: "2050-09-09T11:00:00.000Z", endTime: "2050-09-09T12:00:00.000Z" },
{ startTime: "2050-09-09T12:00:00.000Z", endTime: "2050-09-09T13:00:00.000Z" },
{ startTime: "2050-09-09T13:00:00.000Z", endTime: "2050-09-09T14:00:00.000Z" },
{ startTime: "2050-09-09T14:00:00.000Z", endTime: "2050-09-09T15:00:00.000Z" },
],
},
};
const expectedSlotsRomeRange = {
slots: {
"2050-09-05": [
{ startTime: "2050-09-05T09:00:00.000+02:00", endTime: "2050-09-05T10:00:00.000+02:00" },
{ startTime: "2050-09-05T10:00:00.000+02:00", endTime: "2050-09-05T11:00:00.000+02:00" },
{ startTime: "2050-09-05T11:00:00.000+02:00", endTime: "2050-09-05T12:00:00.000+02:00" },
{ startTime: "2050-09-05T12:00:00.000+02:00", endTime: "2050-09-05T13:00:00.000+02:00" },
{ startTime: "2050-09-05T13:00:00.000+02:00", endTime: "2050-09-05T14:00:00.000+02:00" },
{ startTime: "2050-09-05T14:00:00.000+02:00", endTime: "2050-09-05T15:00:00.000+02:00" },
{ startTime: "2050-09-05T15:00:00.000+02:00", endTime: "2050-09-05T16:00:00.000+02:00" },
{ startTime: "2050-09-05T16:00:00.000+02:00", endTime: "2050-09-05T17:00:00.000+02:00" },
],
"2050-09-06": [
{ startTime: "2050-09-06T09:00:00.000+02:00", endTime: "2050-09-06T10:00:00.000+02:00" },
{ startTime: "2050-09-06T10:00:00.000+02:00", endTime: "2050-09-06T11:00:00.000+02:00" },
{ startTime: "2050-09-06T11:00:00.000+02:00", endTime: "2050-09-06T12:00:00.000+02:00" },
{ startTime: "2050-09-06T12:00:00.000+02:00", endTime: "2050-09-06T13:00:00.000+02:00" },
{ startTime: "2050-09-06T13:00:00.000+02:00", endTime: "2050-09-06T14:00:00.000+02:00" },
{ startTime: "2050-09-06T14:00:00.000+02:00", endTime: "2050-09-06T15:00:00.000+02:00" },
{ startTime: "2050-09-06T15:00:00.000+02:00", endTime: "2050-09-06T16:00:00.000+02:00" },
{ startTime: "2050-09-06T16:00:00.000+02:00", endTime: "2050-09-06T17:00:00.000+02:00" },
],
"2050-09-07": [
{ startTime: "2050-09-07T09:00:00.000+02:00", endTime: "2050-09-07T10:00:00.000+02:00" },
{ startTime: "2050-09-07T10:00:00.000+02:00", endTime: "2050-09-07T11:00:00.000+02:00" },
{ startTime: "2050-09-07T11:00:00.000+02:00", endTime: "2050-09-07T12:00:00.000+02:00" },
{ startTime: "2050-09-07T12:00:00.000+02:00", endTime: "2050-09-07T13:00:00.000+02:00" },
{ startTime: "2050-09-07T13:00:00.000+02:00", endTime: "2050-09-07T14:00:00.000+02:00" },
{ startTime: "2050-09-07T14:00:00.000+02:00", endTime: "2050-09-07T15:00:00.000+02:00" },
{ startTime: "2050-09-07T15:00:00.000+02:00", endTime: "2050-09-07T16:00:00.000+02:00" },
{ startTime: "2050-09-07T16:00:00.000+02:00", endTime: "2050-09-07T17:00:00.000+02:00" },
],
"2050-09-08": [
{ startTime: "2050-09-08T09:00:00.000+02:00", endTime: "2050-09-08T10:00:00.000+02:00" },
{ startTime: "2050-09-08T10:00:00.000+02:00", endTime: "2050-09-08T11:00:00.000+02:00" },
{ startTime: "2050-09-08T11:00:00.000+02:00", endTime: "2050-09-08T12:00:00.000+02:00" },
{ startTime: "2050-09-08T12:00:00.000+02:00", endTime: "2050-09-08T13:00:00.000+02:00" },
{ startTime: "2050-09-08T13:00:00.000+02:00", endTime: "2050-09-08T14:00:00.000+02:00" },
{ startTime: "2050-09-08T14:00:00.000+02:00", endTime: "2050-09-08T15:00:00.000+02:00" },
{ startTime: "2050-09-08T15:00:00.000+02:00", endTime: "2050-09-08T16:00:00.000+02:00" },
{ startTime: "2050-09-08T16:00:00.000+02:00", endTime: "2050-09-08T17:00:00.000+02:00" },
],
"2050-09-09": [
{ startTime: "2050-09-09T09:00:00.000+02:00", endTime: "2050-09-09T10:00:00.000+02:00" },
{ startTime: "2050-09-09T10:00:00.000+02:00", endTime: "2050-09-09T11:00:00.000+02:00" },
{ startTime: "2050-09-09T11:00:00.000+02:00", endTime: "2050-09-09T12:00:00.000+02:00" },
{ startTime: "2050-09-09T12:00:00.000+02:00", endTime: "2050-09-09T13:00:00.000+02:00" },
{ startTime: "2050-09-09T13:00:00.000+02:00", endTime: "2050-09-09T14:00:00.000+02:00" },
{ startTime: "2050-09-09T14:00:00.000+02:00", endTime: "2050-09-09T15:00:00.000+02:00" },
{ startTime: "2050-09-09T15:00:00.000+02:00", endTime: "2050-09-09T16:00:00.000+02:00" },
{ startTime: "2050-09-09T16:00:00.000+02:00", endTime: "2050-09-09T17:00:00.000+02:00" },
],
},
};
describe("Slots Endpoints", () => {
describe("Individual user slots", () => {
let app: INestApplication;
let userRepositoryFixture: UserRepositoryFixture;
let schedulesService: SchedulesService_2024_06_11;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let selectedSlotsRepositoryFixture: SelectedSlotsRepositoryFixture;
let bookingsRepositoryFixture: BookingsRepositoryFixture;
const userEmail = `slots-${randomNumber()}-controller-e2e@api.com`;
const userName = "bob";
let user: User;
let eventTypeId: number;
let eventTypeSlug: string;
let reservedSlotUid: string;
beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
imports: [
AppModule,
PrismaModule,
UsersModule,
TokensModule,
SchedulesModule_2024_06_11,
SlotsModule,
],
})
)
.overrideGuard(PermissionsGuard)
.useValue({
canActivate: () => true,
})
.compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
schedulesService = moduleRef.get<SchedulesService_2024_06_11>(SchedulesService_2024_06_11);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
selectedSlotsRepositoryFixture = new SelectedSlotsRepositoryFixture(moduleRef);
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
user = await userRepositoryFixture.create({
email: userEmail,
name: "bob slot",
username: userName,
});
// nxte(Lauris): this creates default schedule monday to friday from 9AM to 5PM in Europe/Rome timezone
await schedulesService.createUserDefaultSchedule(user.id, "Europe/Rome");
const event = await eventTypesRepositoryFixture.create(
{ title: "frisbee match", slug: "frisbee-match", length: 60 },
user.id
);
eventTypeId = event.id;
eventTypeSlug = event.slug;
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
it("should get slots in UTC by event type id", async () => {
return request(app.getHttpServer())
.get(`/api/v2/slots/available?eventTypeId=${eventTypeId}&startTime=2050-09-05&endTime=2050-09-10`)
.expect(200)
.expect((res) => {
expect(res.body).toEqual({
status: SUCCESS_STATUS,
data: expectedSlotsUTC,
});
});
});
it("should get slots in specified time zone by event type id", async () => {
return request(app.getHttpServer())
.get(
`/api/v2/slots/available?eventTypeId=${eventTypeId}&startTime=2050-09-05&endTime=2050-09-10&timeZone=Europe/Rome`
)
.expect(200)
.expect((res) => {
expect(res.body).toEqual({
status: SUCCESS_STATUS,
data: expectedSlotsRome,
});
});
});
it("should get slots in UTC by event type id in range format", async () => {
return request(app.getHttpServer())
.get(
`/api/v2/slots/available?eventTypeId=${eventTypeId}&startTime=2050-09-05&endTime=2050-09-10&slotFormat=range`
)
.expect(200)
.expect((res) => {
expect(res.body).toEqual({
status: SUCCESS_STATUS,
data: expectedSlotsUTCRange,
});
});
});
it("should get slots in specified time zone by event type id in range format", async () => {
return request(app.getHttpServer())
.get(
`/api/v2/slots/available?eventTypeId=${eventTypeId}&startTime=2050-09-05&endTime=2050-09-10&timeZone=Europe/Rome&slotFormat=range`
)
.expect(200)
.expect((res) => {
expect(res.body).toEqual({
status: SUCCESS_STATUS,
data: expectedSlotsRomeRange,
});
});
});
it("should get slots in UTC by event type slug", async () => {
return request(app.getHttpServer())
.get(
`/api/v2/slots/available?eventTypeSlug=${eventTypeSlug}&usernameList[]=${userName}&startTime=2050-09-05&endTime=2050-09-10`
)
.expect(200)
.expect((res) => {
expect(res.body).toEqual({
status: SUCCESS_STATUS,
data: expectedSlotsUTC,
});
});
});
it("should get slots in specified time zone by event type slug", async () => {
return request(app.getHttpServer())
.get(
`/api/v2/slots/available?eventTypeSlug=${eventTypeSlug}&usernameList[]=${userName}&startTime=2050-09-05&endTime=2050-09-10&timeZone=Europe/Rome`
)
.expect(200)
.expect((res) => {
expect(res.body).toEqual({
status: SUCCESS_STATUS,
data: expectedSlotsRome,
});
});
});
it("should reserve a slot and it should not appear in available slots", async () => {
const slotStartTime = "2050-09-05T10:00:00.000Z";
const reserveResponse = await request(app.getHttpServer())
.post(`/api/v2/slots/reserve`)
.send({
eventTypeId,
slotUtcStartDate: slotStartTime,
slotUtcEndDate: "2050-09-05T11:00:00.000Z",
})
.expect(201);
const reserveResponseBody = reserveResponse.body;
expect(reserveResponseBody.status).toEqual(SUCCESS_STATUS);
const uid: string = reserveResponseBody.data;
expect(uid).toBeDefined();
if (!uid) {
throw new Error("Reserved slot uid is undefined");
}
reservedSlotUid = uid;
const response = await request(app.getHttpServer())
.get(`/api/v2/slots/available?eventTypeId=${eventTypeId}&startTime=2050-09-05&endTime=2050-09-10`)
.expect(200);
const responseBody = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const slots = responseBody.data;
expect(slots).toBeDefined();
const days = Object.keys(slots.slots);
expect(days.length).toEqual(5);
const expectedSlotsUTC2050_09_05 = expectedSlotsUTC.slots["2050-09-05"].filter(
(slot) => slot.time !== slotStartTime
);
expect(slots).toEqual({
slots: { ...expectedSlotsUTC.slots, "2050-09-05": expectedSlotsUTC2050_09_05 },
});
});
it("should delete reserved slot", async () => {
await request(app.getHttpServer())
.delete(`/api/v2/slots/selected-slot?uid=${reservedSlotUid}`)
.expect(200);
});
it("should do a booking and slot should not be available at that time", async () => {
const startTime = "2050-09-05T11:00:00.000Z";
await bookingsRepositoryFixture.create({
uid: `booking-uid-${eventTypeId}`,
title: "booking title",
startTime,
endTime: "2050-09-05T12:00:00.000Z",
eventType: {
connect: {
id: eventTypeId,
},
},
metadata: {},
responses: {
name: "tester",
email: "tester@example.com",
guests: [],
},
user: {
connect: {
id: user.id,
},
},
});
const response = await request(app.getHttpServer())
.get(`/api/v2/slots/available?eventTypeId=${eventTypeId}&startTime=2050-09-05&endTime=2050-09-10`)
.expect(200);
const responseBody = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const slots = responseBody.data;
expect(slots).toBeDefined();
const days = Object.keys(slots.slots);
expect(days.length).toEqual(5);
const expectedSlotsUTC2050_09_05 = expectedSlotsUTC.slots["2050-09-05"].filter(
(slot) => slot.time !== startTime
);
expect(slots).toEqual({
slots: { ...expectedSlotsUTC.slots, "2050-09-05": expectedSlotsUTC2050_09_05 },
});
});
afterAll(async () => {
await userRepositoryFixture.deleteByEmail(user.email);
await selectedSlotsRepositoryFixture.deleteByUId(reservedSlotUid);
await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email);
await app.close();
});
});
});
@@ -1,11 +1,11 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { SlotsOutputService } from "@/modules/slots/services/slots-output.service";
import { SlotsService } from "@/modules/slots/services/slots.service";
import { Query, Body, Controller, Get, Delete, Post, Req, Res } from "@nestjs/common";
import { ApiTags as DocsTags, ApiCreatedResponse, ApiOkResponse, ApiOperation } from "@nestjs/swagger";
import { Response as ExpressResponse, Request as ExpressRequest } from "express";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { SlotFormat } from "@calcom/platform-enums";
import { getAvailableSlots } from "@calcom/platform-libraries";
import type { AvailableSlotsType } from "@calcom/platform-libraries";
import { RemoveSelectedSlotInput, ReserveSlotInput } from "@calcom/platform-types";
@@ -17,7 +17,10 @@ import { ApiResponse, GetAvailableSlotsInput } from "@calcom/platform-types";
})
@DocsTags("Slots")
export class SlotsController {
constructor(private readonly slotsService: SlotsService) {}
constructor(
private readonly slotsService: SlotsService,
private readonly slotsOutputService: SlotsOutputService
) {}
@Post("/reserve")
@ApiCreatedResponse({
@@ -159,19 +162,17 @@ export class SlotsController {
},
});
const transformedSlots =
query.slotFormat === SlotFormat.Range
? await this.slotsService.formatSlots(
availableSlots,
query.duration,
query.eventTypeId,
query.slotFormat
)
: availableSlots.slots;
const { slots } = await this.slotsOutputService.getOutputSlots(
availableSlots,
query.duration,
query.eventTypeId,
query.slotFormat,
query.timeZone
);
return {
data: {
slots: transformedSlots,
slots,
},
status: SUCCESS_STATUS,
};
@@ -0,0 +1,96 @@
import { EventTypesRepository_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/event-types.repository";
import { Injectable, BadRequestException } from "@nestjs/common";
import { DateTime } from "luxon";
import { SlotFormat } from "@calcom/platform-enums";
type TimeSlots = { slots: Record<string, { time: string }[]> };
type RangeSlots = { slots: Record<string, { startTime: string; endTime: string }[]> };
@Injectable()
export class SlotsOutputService {
constructor(private readonly eventTypesRepository: EventTypesRepository_2024_04_15) {}
async getOutputSlots(
availableSlots: TimeSlots,
duration?: number,
eventTypeId?: number,
slotFormat?: SlotFormat,
timeZone?: string
): Promise<TimeSlots | RangeSlots> {
if (!slotFormat) {
return timeZone ? this.setTimeZone(availableSlots, timeZone) : availableSlots;
}
const formattedSlots = await this.formatSlots(availableSlots, duration, eventTypeId, slotFormat);
return timeZone ? this.setTimeZoneRange(formattedSlots, timeZone) : formattedSlots;
}
private setTimeZone(slots: TimeSlots, timeZone: string): TimeSlots {
const formattedSlots = Object.entries(slots.slots).reduce((acc, [date, daySlots]) => {
acc[date] = daySlots.map((slot) => ({
time: DateTime.fromISO(slot.time).setZone(timeZone).toISO() || "unknown-time",
}));
return acc;
}, {} as Record<string, { time: string }[]>);
return { slots: formattedSlots };
}
private setTimeZoneRange(slots: RangeSlots, timeZone: string): RangeSlots {
const formattedSlots = Object.entries(slots.slots).reduce((acc, [date, daySlots]) => {
acc[date] = daySlots.map((slot) => ({
startTime: DateTime.fromISO(slot.startTime).setZone(timeZone).toISO() || "unknown-start-time",
endTime: DateTime.fromISO(slot.endTime).setZone(timeZone).toISO() || "unknown-end-time",
}));
return acc;
}, {} as Record<string, { startTime: string; endTime: string }[]>);
return { slots: formattedSlots };
}
private async formatSlots(
availableSlots: TimeSlots,
duration?: number,
eventTypeId?: number,
slotFormat?: SlotFormat
): Promise<RangeSlots> {
if (slotFormat && !Object.values(SlotFormat).includes(slotFormat)) {
throw new BadRequestException("Invalid slot format. Must be either 'range' or 'time'");
}
const slotDuration = await this.getDuration(duration, eventTypeId);
const slots = Object.entries(availableSlots.slots).reduce<
Record<string, { startTime: string; endTime: string }[]>
>((acc, [date, slots]) => {
acc[date] = (slots as { time: string }[]).map((slot) => {
const startTime = new Date(slot.time);
const endTime = new Date(startTime.getTime() + slotDuration * 60000);
return {
startTime: startTime.toISOString(),
endTime: endTime.toISOString(),
};
});
return acc;
}, {});
return { slots };
}
private async getDuration(duration?: number, eventTypeId?: number): Promise<number> {
if (duration) {
return duration;
}
if (eventTypeId) {
const eventType = await this.eventTypesRepository.getEventTypeWithDuration(eventTypeId);
if (!eventType) {
throw new Error("Event type not found");
}
return eventType.length;
}
throw new Error("duration or eventTypeId is required");
}
}
@@ -1,6 +1,7 @@
import { EventTypesRepository_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/event-types.repository";
import { SlotsRepository } from "@/modules/slots/slots.repository";
import { Injectable, NotFoundException, BadRequestException } from "@nestjs/common";
import { DateTime } from "luxon";
import { v4 as uuid } from "uuid";
import { SlotFormat } from "@calcom/platform-enums";
@@ -55,51 +56,4 @@ export class SlotsService {
const event = await this.eventTypeRepo.getEventTypeById(eventTypeId);
return !!event?.teamId;
}
async getEventTypeWithDuration(eventTypeId: number) {
return await this.eventTypeRepo.getEventTypeWithDuration(eventTypeId);
}
async getDuration(duration?: number, eventTypeId?: number): Promise<number> {
if (duration) {
return duration;
}
if (eventTypeId) {
const eventType = await this.eventTypeRepo.getEventTypeWithDuration(eventTypeId);
if (!eventType) {
throw new Error("Event type not found");
}
return eventType.length;
}
throw new Error("duration or eventTypeId is required");
}
async formatSlots(
availableSlots: { slots: Record<string, { time: string }[]> },
duration?: number,
eventTypeId?: number,
slotFormat?: SlotFormat
): Promise<Record<string, { startTime: string; endTime: string }[]>> {
if (slotFormat && !Object.values(SlotFormat).includes(slotFormat)) {
throw new BadRequestException("Invalid slot format. Must be either 'range' or 'time'");
}
const slotDuration = await this.getDuration(duration, eventTypeId);
return Object.entries(availableSlots.slots).reduce<
Record<string, { startTime: string; endTime: string }[]>
>((acc, [date, slots]) => {
acc[date] = (slots as { time: string }[]).map((slot) => {
const startTime = new Date(slot.time);
const endTime = new Date(startTime.getTime() + slotDuration * 60000);
return {
startTime: startTime.toISOString(),
endTime: endTime.toISOString(),
};
});
return acc;
}, {});
}
}
@@ -1,13 +1,14 @@
import { EventTypesModule_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/event-types.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { SlotsController } from "@/modules/slots/controllers/slots.controller";
import { SlotsOutputService } from "@/modules/slots/services/slots-output.service";
import { SlotsService } from "@/modules/slots/services/slots.service";
import { SlotsRepository } from "@/modules/slots/slots.repository";
import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule, EventTypesModule_2024_04_15],
providers: [SlotsRepository, SlotsService],
providers: [SlotsRepository, SlotsService, SlotsOutputService],
controllers: [SlotsController],
exports: [SlotsService],
})
@@ -0,0 +1,570 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import { User } from "@prisma/client";
import * as request from "supertest";
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.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 { randomNumber } from "test/utils/randomNumber";
import { withApiAuth } from "test/utils/withApiAuth";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import {
BookingWindowPeriodInputTypeEnum_2024_06_14,
BookerLayoutsInputEnum_2024_06_14,
ConfirmationPolicyEnum,
NoticeThresholdUnitEnum,
} from "@calcom/platform-enums";
import {
ApiSuccessResponse,
CreateTeamEventTypeInput_2024_06_14,
Host,
TeamEventTypeOutput_2024_06_14,
UpdateTeamEventTypeInput_2024_06_14,
} from "@calcom/platform-types";
import { Team } from "@calcom/prisma/client";
describe("Organizations Event Types Endpoints", () => {
describe("User Authentication - User is Org Admin", () => {
let app: INestApplication;
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let teamsRepositoryFixture: TeamRepositoryFixture;
let membershipsRepositoryFixture: MembershipRepositoryFixture;
let profileRepositoryFixture: ProfileRepositoryFixture;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let team: Team;
let falseTestTeam: Team;
const userEmail = `auth-${randomNumber()}@api.com`;
let userAdmin: User;
const teammate1Email = "teammate111@team.com";
const teammate2Email = "teammate221@team.com";
const falseTestUserEmail = "false-user@false-team.com";
let teamMember1: User;
let teamMember2: User;
let falseTestUser: User;
let collectiveEventType: TeamEventTypeOutput_2024_06_14;
let managedEventType: TeamEventTypeOutput_2024_06_14;
beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
})
).compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
userAdmin = await userRepositoryFixture.create({
email: userEmail,
username: userEmail,
role: "ADMIN",
});
teamMember1 = await userRepositoryFixture.create({
email: teammate1Email,
username: teammate1Email,
});
teamMember2 = await userRepositoryFixture.create({
email: teammate2Email,
username: teammate2Email,
});
falseTestUser = await userRepositoryFixture.create({
email: falseTestUserEmail,
username: falseTestUserEmail,
});
team = await teamsRepositoryFixture.create({
name: "Test org team",
isOrganization: false,
});
falseTestTeam = await teamsRepositoryFixture.create({
name: "Outside org team",
isOrganization: false,
});
await membershipsRepositoryFixture.create({
role: "ADMIN",
user: { connect: { id: userAdmin.id } },
team: { connect: { id: team.id } },
accepted: true,
});
await membershipsRepositoryFixture.create({
role: "MEMBER",
user: { connect: { id: teamMember1.id } },
team: { connect: { id: team.id } },
accepted: true,
});
await membershipsRepositoryFixture.create({
role: "MEMBER",
user: { connect: { id: teamMember2.id } },
team: { connect: { id: team.id } },
accepted: true,
});
await membershipsRepositoryFixture.create({
role: "MEMBER",
user: { connect: { id: falseTestUser.id } },
team: { connect: { id: falseTestTeam.id } },
accepted: true,
});
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
it("should not be able to create event-type for user outside team", async () => {
const userId = falseTestUser.id;
const body: CreateTeamEventTypeInput_2024_06_14 = {
title: "Coding consultation",
slug: "coding-consultation",
description: "Our team will review your codebase.",
lengthInMinutes: 60,
locations: [
{
type: "integration",
integration: "cal-video",
},
],
schedulingType: "COLLECTIVE",
hosts: [
{
userId,
mandatory: true,
priority: "high",
},
],
};
return request(app.getHttpServer()).post(`/v2/teams/${team.id}/event-types`).send(body).expect(404);
});
it("should create a collective team event-type", async () => {
const body: CreateTeamEventTypeInput_2024_06_14 = {
title: "Coding consultation collective",
slug: "coding-consultation collective",
description: "Our team will review your codebase.",
lengthInMinutes: 60,
locations: [
{
type: "integration",
integration: "cal-video",
},
],
bookingFields: [
{
type: "select",
label: "select which language is your codebase in",
slug: "select-language",
required: true,
placeholder: "select language",
options: ["javascript", "python", "cobol"],
},
],
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
schedulingType: "collective",
hosts: [
{
userId: teamMember1.id,
},
{
userId: teamMember2.id,
},
],
bookingLimitsCount: {
day: 2,
week: 5,
},
onlyShowFirstAvailableSlot: true,
bookingLimitsDuration: {
day: 60,
week: 100,
},
offsetStart: 30,
bookingWindow: {
type: BookingWindowPeriodInputTypeEnum_2024_06_14.calendarDays,
value: 30,
rolling: true,
},
bookerLayouts: {
enabledLayouts: [
BookerLayoutsInputEnum_2024_06_14.column,
BookerLayoutsInputEnum_2024_06_14.month,
BookerLayoutsInputEnum_2024_06_14.week,
],
defaultLayout: BookerLayoutsInputEnum_2024_06_14.month,
},
confirmationPolicy: {
type: ConfirmationPolicyEnum.TIME,
noticeThreshold: {
count: 60,
unit: NoticeThresholdUnitEnum.MINUTES,
},
blockUnconfirmedBookingsInBooker: true,
},
requiresBookerEmailVerification: true,
hideCalendarNotes: true,
hideCalendarEventDetails: true,
lockTimeZoneToggleOnBookingPage: true,
color: {
darkThemeHex: "#292929",
lightThemeHex: "#fafafa",
},
};
return request(app.getHttpServer())
.post(`/v2/teams/${team.id}/event-types`)
.send(body)
.expect(201)
.then((response) => {
const responseBody: ApiSuccessResponse<TeamEventTypeOutput_2024_06_14> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
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]);
expect(data.bookingLimitsCount).toEqual(body.bookingLimitsCount);
expect(data.onlyShowFirstAvailableSlot).toEqual(body.onlyShowFirstAvailableSlot);
expect(data.bookingLimitsDuration).toEqual(body.bookingLimitsDuration);
expect(data.offsetStart).toEqual(body.offsetStart);
expect(data.bookingWindow).toEqual(body.bookingWindow);
expect(data.bookerLayouts).toEqual(body.bookerLayouts);
expect(data.confirmationPolicy).toEqual(body.confirmationPolicy);
expect(data.requiresBookerEmailVerification).toEqual(body.requiresBookerEmailVerification);
expect(data.hideCalendarNotes).toEqual(body.hideCalendarNotes);
expect(data.hideCalendarEventDetails).toEqual(body.hideCalendarEventDetails);
expect(data.lockTimeZoneToggleOnBookingPage).toEqual(body.lockTimeZoneToggleOnBookingPage);
expect(data.color).toEqual(body.color);
collectiveEventType = responseBody.data;
});
});
it("should create a managed team event-type", async () => {
const body: CreateTeamEventTypeInput_2024_06_14 = {
title: "Coding consultation managed",
slug: "coding-consultation-managed",
description: "Our team will review your codebase.",
lengthInMinutes: 60,
locations: [
{
type: "integration",
integration: "cal-video",
},
],
schedulingType: "MANAGED",
hosts: [
{
userId: teamMember1.id,
mandatory: true,
priority: "high",
},
{
userId: teamMember2.id,
mandatory: false,
priority: "low",
},
],
};
return request(app.getHttpServer())
.post(`/v2/teams/${team.id}/event-types`)
.send(body)
.expect(201)
.then(async (response) => {
const responseBody: ApiSuccessResponse<TeamEventTypeOutput_2024_06_14[]> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const data = responseBody.data;
expect(data.length).toEqual(3);
const teammate1EventTypes = await eventTypesRepositoryFixture.getAllUserEventTypes(teamMember1.id);
const teammate2EventTypes = await eventTypesRepositoryFixture.getAllUserEventTypes(teamMember2.id);
const teamEventTypes = await eventTypesRepositoryFixture.getAllTeamEventTypes(team.id);
expect(teammate1EventTypes.length).toEqual(1);
expect(teammate2EventTypes.length).toEqual(1);
expect(teamEventTypes.filter((eventType) => eventType.schedulingType === "MANAGED").length).toEqual(
1
);
const responseTeamEvent = responseBody.data.find((event) => event.teamId === team.id);
expect(responseTeamEvent).toBeDefined();
if (!responseTeamEvent) {
throw new Error("Team event not found");
}
const responseTeammate1Event = responseBody.data.find((event) => event.ownerId === teamMember1.id);
expect(responseTeammate1Event).toBeDefined();
expect(responseTeammate1Event?.parentEventTypeId).toEqual(responseTeamEvent?.id);
const responseTeammate2Event = responseBody.data.find((event) => event.ownerId === teamMember2.id);
expect(responseTeammate2Event).toBeDefined();
expect(responseTeammate2Event?.parentEventTypeId).toEqual(responseTeamEvent?.id);
managedEventType = responseTeamEvent;
});
});
it("should not get a non existing event-type", async () => {
return request(app.getHttpServer()).get(`/v2/teams/${team.id}/event-types/999999`).expect(404);
});
it("should get a team event-type", async () => {
return request(app.getHttpServer())
.get(`/v2/teams/${team.id}/event-types/${collectiveEventType.id}`)
.expect(200)
.then((response) => {
const responseBody: ApiSuccessResponse<TeamEventTypeOutput_2024_06_14> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const data = responseBody.data;
expect(data.title).toEqual(collectiveEventType.title);
expect(data.hosts.length).toEqual(2);
evaluateHost(collectiveEventType.hosts[0], data.hosts[0]);
evaluateHost(collectiveEventType.hosts[1], data.hosts[1]);
collectiveEventType = responseBody.data;
});
});
it("should get team event-types", async () => {
return request(app.getHttpServer())
.get(`/v2/teams/${team.id}/event-types`)
.expect(200)
.then((response) => {
const responseBody: ApiSuccessResponse<TeamEventTypeOutput_2024_06_14[]> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const data = responseBody.data;
expect(data.length).toEqual(2);
const eventTypeCollective = data.find((eventType) => eventType.schedulingType === "COLLECTIVE");
const eventTypeManaged = data.find((eventType) => eventType.schedulingType === "MANAGED");
expect(eventTypeCollective?.title).toEqual(collectiveEventType.title);
expect(eventTypeCollective?.hosts.length).toEqual(2);
expect(eventTypeManaged?.title).toEqual(managedEventType.title);
expect(eventTypeManaged?.hosts.length).toEqual(2);
evaluateHost(collectiveEventType.hosts[0], eventTypeCollective?.hosts[0]);
evaluateHost(collectiveEventType.hosts[1], eventTypeCollective?.hosts[1]);
});
});
it("should not be able to update non existing event-type", async () => {
const body: UpdateTeamEventTypeInput_2024_06_14 = {
title: "Clean code consultation",
};
return request(app.getHttpServer())
.patch(`/v2/teams/${team.id}/event-types/999999`)
.send(body)
.expect(400);
});
it("should update collective event-type", async () => {
const newHosts: UpdateTeamEventTypeInput_2024_06_14["hosts"] = [
{
userId: teamMember1.id,
},
];
const body: UpdateTeamEventTypeInput_2024_06_14 = {
hosts: newHosts,
};
return request(app.getHttpServer())
.patch(`/v2/teams/${team.id}/event-types/${collectiveEventType.id}`)
.send(body)
.expect(200)
.then((response) => {
const responseBody: ApiSuccessResponse<TeamEventTypeOutput_2024_06_14> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const eventType = responseBody.data;
expect(eventType.title).toEqual(collectiveEventType.title);
expect(eventType.hosts.length).toEqual(1);
evaluateHost(eventType.hosts[0], newHosts[0]);
});
});
it("should update managed event-type", async () => {
const newTitle = "Coding consultation managed updated";
const newHosts: UpdateTeamEventTypeInput_2024_06_14["hosts"] = [
{
userId: teamMember1.id,
mandatory: true,
priority: "medium",
},
];
const body: UpdateTeamEventTypeInput_2024_06_14 = {
title: newTitle,
hosts: newHosts,
};
return request(app.getHttpServer())
.patch(`/v2/teams/${team.id}/event-types/${managedEventType.id}`)
.send(body)
.expect(200)
.then(async (response) => {
const responseBody: ApiSuccessResponse<TeamEventTypeOutput_2024_06_14[]> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const data = responseBody.data;
expect(data.length).toEqual(2);
const teammate1EventTypes = await eventTypesRepositoryFixture.getAllUserEventTypes(teamMember1.id);
const teammate2EventTypes = await eventTypesRepositoryFixture.getAllUserEventTypes(teamMember2.id);
const teamEventTypes = await eventTypesRepositoryFixture.getAllTeamEventTypes(team.id);
const managedTeamEventTypes = teamEventTypes.filter(
(eventType) => eventType.schedulingType === "MANAGED"
);
expect(teammate1EventTypes.length).toEqual(1);
expect(teammate1EventTypes[0].title).toEqual(newTitle);
expect(teammate2EventTypes.length).toEqual(0);
expect(managedTeamEventTypes.length).toEqual(1);
expect(managedTeamEventTypes[0].assignAllTeamMembers).toEqual(false);
expect(
teamEventTypes.filter((eventType) => eventType.schedulingType === "MANAGED")?.[0]?.title
).toEqual(newTitle);
const responseTeamEvent = responseBody.data.find(
(eventType) => eventType.schedulingType === "MANAGED"
);
expect(responseTeamEvent).toBeDefined();
expect(responseTeamEvent?.title).toEqual(newTitle);
expect(responseTeamEvent?.assignAllTeamMembers).toEqual(false);
const responseTeammate1Event = responseBody.data.find(
(eventType) => eventType.ownerId === teamMember1.id
);
expect(responseTeammate1Event).toBeDefined();
expect(responseTeammate1Event?.title).toEqual(newTitle);
managedEventType = responseBody.data[0];
});
});
it("should assign all members to managed event-type", async () => {
const body: UpdateTeamEventTypeInput_2024_06_14 = {
assignAllTeamMembers: true,
};
return request(app.getHttpServer())
.patch(`/v2/teams/${team.id}/event-types/${managedEventType.id}`)
.send(body)
.expect(200)
.then(async (response) => {
const responseBody: ApiSuccessResponse<TeamEventTypeOutput_2024_06_14[]> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const data = responseBody.data;
// note(Lauris): we expect 4 because we have 2 team members, 1 team admin and 4th is the event object itself.
expect(data.length).toEqual(4);
const teammate1EventTypes = await eventTypesRepositoryFixture.getAllUserEventTypes(teamMember1.id);
const teammate2EventTypes = await eventTypesRepositoryFixture.getAllUserEventTypes(teamMember2.id);
const teamEventTypes = await eventTypesRepositoryFixture.getAllTeamEventTypes(team.id);
const managedTeamEventTypes = teamEventTypes.filter(
(eventType) => eventType.schedulingType === "MANAGED"
);
expect(teammate1EventTypes.length).toEqual(1);
expect(teammate2EventTypes.length).toEqual(1);
expect(managedTeamEventTypes.length).toEqual(1);
expect(managedTeamEventTypes[0].assignAllTeamMembers).toEqual(true);
const responseTeamEvent = responseBody.data.find(
(eventType) => eventType.schedulingType === "MANAGED"
);
expect(responseTeamEvent).toBeDefined();
expect(responseTeamEvent?.teamId).toEqual(team.id);
expect(responseTeamEvent?.assignAllTeamMembers).toEqual(true);
const responseTeammate1Event = responseBody.data.find(
(eventType) => eventType.ownerId === teamMember1.id
);
expect(responseTeammate1Event).toBeDefined();
expect(responseTeammate1Event?.parentEventTypeId).toEqual(responseTeamEvent?.id);
const responseTeammate2Event = responseBody.data.find(
(eventType) => eventType.ownerId === teamMember2.id
);
expect(responseTeammate1Event).toBeDefined();
expect(responseTeammate2Event?.parentEventTypeId).toEqual(responseTeamEvent?.id);
if (responseTeamEvent) {
managedEventType = responseTeamEvent;
}
});
});
it("should delete event-type not part of the team", async () => {
return request(app.getHttpServer()).delete(`/v2/teams/${team.id}/event-types/99999`).expect(404);
});
it("should delete collective event-type", async () => {
return request(app.getHttpServer())
.delete(`/v2/teams/${team.id}/event-types/${collectiveEventType.id}`)
.expect(200);
});
it("should delete managed event-type", async () => {
return request(app.getHttpServer())
.delete(`/v2/teams/${team.id}/event-types/${managedEventType.id}`)
.expect(200);
});
function evaluateHost(expected: Host, received: Host | undefined) {
expect(expected.userId).toEqual(received?.userId);
expect(expected.mandatory).toEqual(received?.mandatory);
expect(expected.priority).toEqual(received?.priority);
}
afterAll(async () => {
await userRepositoryFixture.deleteByEmail(userAdmin.email);
await userRepositoryFixture.deleteByEmail(teamMember1.email);
await userRepositoryFixture.deleteByEmail(teamMember2.email);
await userRepositoryFixture.deleteByEmail(falseTestUser.email);
await teamsRepositoryFixture.delete(team.id);
await teamsRepositoryFixture.delete(falseTestTeam.id);
await app.close();
});
});
});
@@ -0,0 +1,208 @@
import { CreatePhoneCallInput } from "@/ee/event-types/event-types_2024_06_14/inputs/create-phone-call.input";
import { CreatePhoneCallOutput } from "@/ee/event-types/event-types_2024_06_14/outputs/create-phone-call.output";
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 { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { OutputTeamEventTypesResponsePipe } from "@/modules/organizations/controllers/pipes/event-types/team-event-types-response.transformer";
import { InputOrganizationsEventTypesService } from "@/modules/organizations/services/event-types/input.service";
import { DatabaseTeamEventType } from "@/modules/organizations/services/event-types/output.service";
import { CreateTeamEventTypeOutput } from "@/modules/teams/event-types/outputs/create-team-event-type.output";
import { DeleteTeamEventTypeOutput } from "@/modules/teams/event-types/outputs/delete-team-event-type.output";
import { GetTeamEventTypeOutput } from "@/modules/teams/event-types/outputs/get-team-event-type.output";
import { GetTeamEventTypesOutput } from "@/modules/teams/event-types/outputs/get-team-event-types.output";
import { UpdateTeamEventTypeOutput } from "@/modules/teams/event-types/outputs/update-team-event-type.output";
import { TeamsEventTypesService } from "@/modules/teams/event-types/services/teams-event-types.service";
import { UserWithProfile } from "@/modules/users/users.repository";
import {
Controller,
UseGuards,
Get,
Post,
Param,
ParseIntPipe,
Body,
Patch,
Delete,
HttpCode,
HttpStatus,
NotFoundException,
Query,
} from "@nestjs/common";
import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
import { handleCreatePhoneCall } from "@calcom/platform-libraries";
import {
CreateTeamEventTypeInput_2024_06_14,
GetTeamEventTypesQuery_2024_06_14,
SkipTakePagination,
TeamEventTypeOutput_2024_06_14,
UpdateTeamEventTypeInput_2024_06_14,
} from "@calcom/platform-types";
export type EventTypeHandlerResponse = {
data: DatabaseTeamEventType[] | DatabaseTeamEventType;
status: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
};
@Controller({
path: "/v2/teams/:teamId/event-types",
version: API_VERSIONS_VALUES,
})
@DocsTags("Teams / Event Types")
export class TeamsEventTypesController {
constructor(
private readonly teamsEventTypesService: TeamsEventTypesService,
private readonly inputService: InputOrganizationsEventTypesService,
private readonly outputTeamEventTypesResponsePipe: OutputTeamEventTypesResponsePipe
) {}
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@UseGuards(ApiAuthGuard, RolesGuard)
@Post("/")
@ApiOperation({ summary: "Create an event type" })
async createTeamEventType(
@GetUser() user: UserWithProfile,
@Param("teamId", ParseIntPipe) teamId: number,
@Body() bodyEventType: CreateTeamEventTypeInput_2024_06_14
): Promise<CreateTeamEventTypeOutput> {
const transformedBody = await this.inputService.transformAndValidateCreateTeamEventTypeInput(
user.id,
teamId,
bodyEventType
);
const eventType = await this.teamsEventTypesService.createTeamEventType(user, teamId, transformedBody);
return {
status: SUCCESS_STATUS,
data: await this.outputTeamEventTypesResponsePipe.transform(eventType),
};
}
@Roles("TEAM_ADMIN")
@UseGuards(ApiAuthGuard, RolesGuard)
@Get("/:eventTypeId")
@ApiOperation({ summary: "Get an event type" })
async getTeamEventType(
@Param("teamId", ParseIntPipe) teamId: number,
@Param("eventTypeId") eventTypeId: number
): Promise<GetTeamEventTypeOutput> {
const eventType = await this.teamsEventTypesService.getTeamEventType(teamId, eventTypeId);
if (!eventType) {
throw new NotFoundException(`Event type with id ${eventTypeId} not found`);
}
return {
status: SUCCESS_STATUS,
data: (await this.outputTeamEventTypesResponsePipe.transform(
eventType
)) as TeamEventTypeOutput_2024_06_14,
};
}
@Roles("TEAM_ADMIN")
@Post("/:eventTypeId/create-phone-call")
@UseGuards(ApiAuthGuard, RolesGuard)
@ApiOperation({ summary: "Create a phone call" })
async createPhoneCall(
@Param("eventTypeId") eventTypeId: number,
@Param("orgId", ParseIntPipe) orgId: number,
@Body() body: CreatePhoneCallInput,
@GetUser() user: UserWithProfile
): Promise<CreatePhoneCallOutput> {
const data = await handleCreatePhoneCall({
user: {
id: user.id,
timeZone: user.timeZone,
profile: { organization: { id: orgId } },
},
input: { ...body, eventTypeId },
});
return {
status: SUCCESS_STATUS,
data,
};
}
@Get("/")
@ApiOperation({ summary: "Get a team event type" })
async getTeamEventTypes(
@Param("teamId", ParseIntPipe) teamId: number,
@Query() queryParams: GetTeamEventTypesQuery_2024_06_14
): Promise<GetTeamEventTypesOutput> {
const { eventSlug } = queryParams;
if (eventSlug) {
const eventType = await this.teamsEventTypesService.getTeamEventTypeBySlug(teamId, eventSlug);
return {
status: SUCCESS_STATUS,
data: await this.outputTeamEventTypesResponsePipe.transform(eventType ? [eventType] : []),
};
}
const eventTypes = await this.teamsEventTypesService.getTeamEventTypes(teamId);
return {
status: SUCCESS_STATUS,
data: await this.outputTeamEventTypesResponsePipe.transform(eventTypes),
};
}
@Roles("TEAM_ADMIN")
@UseGuards(ApiAuthGuard, RolesGuard)
@Patch("/:eventTypeId")
@ApiOperation({ summary: "Update a team event type" })
async updateTeamEventType(
@Param("teamId", ParseIntPipe) teamId: number,
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
@GetUser() user: UserWithProfile,
@Body() bodyEventType: UpdateTeamEventTypeInput_2024_06_14
): Promise<UpdateTeamEventTypeOutput> {
const transformedBody = await this.inputService.transformAndValidateUpdateTeamEventTypeInput(
user.id,
eventTypeId,
teamId,
bodyEventType
);
const eventType = await this.teamsEventTypesService.updateTeamEventType(
eventTypeId,
teamId,
transformedBody,
user
);
return {
status: SUCCESS_STATUS,
data: await this.outputTeamEventTypesResponsePipe.transform(eventType),
};
}
@Roles("TEAM_ADMIN")
@UseGuards(ApiAuthGuard, RolesGuard)
@Delete("/:eventTypeId")
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Delete a team event type" })
async deleteTeamEventType(
@Param("teamId", ParseIntPipe) teamId: number,
@Param("eventTypeId", ParseIntPipe) eventTypeId: number
): Promise<DeleteTeamEventTypeOutput> {
const eventType = await this.teamsEventTypesService.deleteTeamEventType(teamId, eventTypeId);
return {
status: SUCCESS_STATUS,
data: {
id: eventTypeId,
title: eventType.title,
},
};
}
}
@@ -0,0 +1,148 @@
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
import { EventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/event-types.service";
import { DatabaseTeamEventType } from "@/modules/organizations/services/event-types/output.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { TeamsEventTypesRepository } from "@/modules/teams/event-types/teams-event-types.repository";
import { UsersService } from "@/modules/users/services/users.service";
import { UserWithProfile } from "@/modules/users/users.repository";
import { Injectable, NotFoundException, Logger } from "@nestjs/common";
import { createEventType, updateEventType } from "@calcom/platform-libraries";
import { InputTeamEventTransformed_2024_06_14 } from "@calcom/platform-types";
@Injectable()
export class TeamsEventTypesService {
private readonly logger = new Logger("TeamsEventTypesService");
constructor(
private readonly eventTypesService: EventTypesService_2024_06_14,
private readonly dbWrite: PrismaWriteService,
private readonly teamsEventTypesRepository: TeamsEventTypesRepository,
private readonly eventTypesRepository: EventTypesRepository_2024_06_14,
private readonly usersService: UsersService
) {}
async createTeamEventType(
user: UserWithProfile,
teamId: number,
body: InputTeamEventTransformed_2024_06_14
): Promise<DatabaseTeamEventType | DatabaseTeamEventType[]> {
const eventTypeUser = await this.getUserToCreateTeamEvent(user);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { hosts, children, destinationCalendar, ...rest } = body;
const { eventType: eventTypeCreated } = await createEventType({
input: { teamId: teamId, ...rest },
ctx: {
user: eventTypeUser,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
prisma: this.dbWrite.prisma,
},
});
return this.updateTeamEventType(eventTypeCreated.id, teamId, body, user);
}
async validateEventTypeExists(teamId: number, eventTypeId: number) {
const eventType = await this.teamsEventTypesRepository.getTeamEventType(teamId, eventTypeId);
if (!eventType) {
throw new NotFoundException(`Event type with id ${eventTypeId} not found`);
}
}
async getUserToCreateTeamEvent(user: UserWithProfile) {
const profileId = this.usersService.getUserMainProfile(user)?.id;
return {
id: user.id,
role: user.role,
organizationId: null,
organization: { id: null, isOrgAdmin: false, metadata: {}, requestedSlug: null },
profile: { id: profileId || null },
metadata: user.metadata,
};
}
async getTeamEventType(teamId: number, eventTypeId: number): Promise<DatabaseTeamEventType | null> {
const eventType = await this.teamsEventTypesRepository.getTeamEventType(teamId, eventTypeId);
if (!eventType) {
return null;
}
return eventType;
}
async getTeamEventTypeBySlug(teamId: number, eventTypeSlug: string): Promise<DatabaseTeamEventType | null> {
const eventType = await this.teamsEventTypesRepository.getTeamEventTypeBySlug(teamId, eventTypeSlug);
if (!eventType) {
return null;
}
return eventType;
}
async getTeamEventTypes(teamId: number): Promise<DatabaseTeamEventType[]> {
return await this.teamsEventTypesRepository.getTeamEventTypes(teamId);
}
async updateTeamEventType(
eventTypeId: number,
teamId: number,
body: InputTeamEventTransformed_2024_06_14,
user: UserWithProfile
): Promise<DatabaseTeamEventType | DatabaseTeamEventType[]> {
await this.validateEventTypeExists(teamId, eventTypeId);
const eventTypeUser = await this.eventTypesService.getUserToUpdateEvent(user);
await updateEventType({
input: { id: eventTypeId, ...body },
ctx: {
user: eventTypeUser,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
prisma: this.dbWrite.prisma,
},
});
const eventType = await this.teamsEventTypesRepository.getEventTypeById(eventTypeId);
if (!eventType) {
throw new NotFoundException(`Event type with id ${eventTypeId} not found`);
}
if (eventType.schedulingType !== "MANAGED") {
return eventType;
}
const childrenEventTypes = await this.teamsEventTypesRepository.getEventTypeChildren(eventType.id);
return [eventType, ...childrenEventTypes];
}
async deleteTeamEventType(teamId: number, eventTypeId: number) {
const existingEventType = await this.teamsEventTypesRepository.getTeamEventType(teamId, eventTypeId);
if (!existingEventType) {
throw new NotFoundException(`Event type with ID=${eventTypeId} does not exist.`);
}
return this.eventTypesRepository.deleteEventType(eventTypeId);
}
async deleteUserTeamEventTypesAndHosts(userId: number, teamId: number) {
try {
await this.teamsEventTypesRepository.deleteUserManagedTeamEventTypes(userId, teamId);
await this.teamsEventTypesRepository.removeUserFromTeamEventTypesHosts(userId, teamId);
} catch (err) {
this.logger.error("Could not remove user from all team event-types.", {
error: err,
userId,
teamId,
});
}
}
}
@@ -0,0 +1,36 @@
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
import { MembershipsModule } from "@/modules/memberships/memberships.module";
import { OutputTeamEventTypesResponsePipe } from "@/modules/organizations/controllers/pipes/event-types/team-event-types-response.transformer";
import { OrganizationsTeamsRepository } from "@/modules/organizations/repositories/organizations-teams.repository";
import { InputOrganizationsEventTypesService } from "@/modules/organizations/services/event-types/input.service";
import { OutputOrganizationsEventTypesService } from "@/modules/organizations/services/event-types/output.service";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { RedisModule } from "@/modules/redis/redis.module";
import { TeamsEventTypesController } from "@/modules/teams/event-types/controllers/teams-event-types.controller";
import { TeamsEventTypesService } from "@/modules/teams/event-types/services/teams-event-types.service";
import { TeamsEventTypesRepository } from "@/modules/teams/event-types/teams-event-types.repository";
import { TeamsModule } from "@/modules/teams/teams/teams.module";
import { UsersModule } from "@/modules/users/users.module";
import { Module } from "@nestjs/common";
@Module({
imports: [
PrismaModule,
RedisModule,
MembershipsModule,
EventTypesModule_2024_06_14,
UsersModule,
TeamsModule,
],
providers: [
TeamsEventTypesRepository,
TeamsEventTypesService,
InputOrganizationsEventTypesService,
OrganizationsTeamsRepository,
OutputTeamEventTypesResponsePipe,
OutputOrganizationsEventTypesService,
],
exports: [TeamsEventTypesRepository, TeamsEventTypesService],
controllers: [TeamsEventTypesController],
})
export class TeamsEventTypesModule {}
@@ -0,0 +1,116 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
@Injectable()
export class TeamsEventTypesRepository {
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
async getTeamEventType(teamId: number, eventTypeId: number) {
return this.dbRead.prisma.eventType.findUnique({
where: {
id: eventTypeId,
teamId,
},
include: { users: true, schedule: true, hosts: true, destinationCalendar: true },
});
}
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,
destinationCalendar: true,
team: {
select: {
bannerUrl: true,
name: true,
logoUrl: true,
slug: true,
weekStart: true,
brandColor: true,
darkBrandColor: true,
theme: true,
},
},
},
});
}
async getTeamEventTypes(teamId: number) {
return this.dbRead.prisma.eventType.findMany({
where: {
teamId,
},
include: {
users: true,
schedule: true,
hosts: true,
destinationCalendar: true,
team: {
select: {
bannerUrl: true,
name: true,
logoUrl: true,
slug: true,
weekStart: true,
brandColor: true,
darkBrandColor: true,
theme: true,
},
},
},
});
}
async getEventTypeById(eventTypeId: number) {
return this.dbRead.prisma.eventType.findUnique({
where: { id: eventTypeId },
include: { users: true, schedule: true, hosts: true, destinationCalendar: true },
});
}
async getEventTypeChildren(eventTypeId: number) {
return this.dbRead.prisma.eventType.findMany({
where: { parentId: eventTypeId },
include: { users: true, schedule: true, hosts: true, destinationCalendar: true },
});
}
async getEventTypeByIdWithChildren(eventTypeId: number) {
return this.dbRead.prisma.eventType.findUnique({
where: { id: eventTypeId },
include: { children: true },
});
}
async deleteUserManagedTeamEventTypes(userId: number, teamId: number) {
return this.dbWrite.prisma.eventType.deleteMany({
where: {
parent: {
teamId,
},
userId,
},
});
}
async removeUserFromTeamEventTypesHosts(userId: number, teamId: number) {
return this.dbWrite.prisma.host.deleteMany({
where: {
userId,
eventType: {
teamId,
},
},
});
}
}
@@ -2,7 +2,7 @@ import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { OrganizationsEventTypesService } from "@/modules/organizations/services/event-types/organizations-event-types.service";
import { TeamsEventTypesService } from "@/modules/teams/event-types/services/teams-event-types.service";
import { CreateTeamMembershipInput } from "@/modules/teams/memberships/inputs/create-team-membership.input";
import { UpdateTeamMembershipInput } from "@/modules/teams/memberships/inputs/update-team-membership.input";
import { CreateTeamMembershipOutput } from "@/modules/teams/memberships/outputs/create-team-membership.output";
@@ -45,7 +45,7 @@ export class TeamsMembershipsController {
constructor(
private teamsMembershipsService: TeamsMembershipsService,
private oganizationsEventTypesService: OrganizationsEventTypesService
private teamsEventTypesService: TeamsEventTypesService
) {}
@Roles("TEAM_ADMIN")
@@ -149,7 +149,7 @@ export class TeamsMembershipsController {
): Promise<DeleteTeamMembershipOutput> {
const membership = await this.teamsMembershipsService.deleteTeamMembership(teamId, membershipId);
await this.oganizationsEventTypesService.deleteUserTeamEventTypesAndHosts(membership.userId, teamId);
await this.teamsEventTypesService.deleteUserTeamEventTypesAndHosts(membership.userId, teamId);
return {
status: SUCCESS_STATUS,
@@ -2,13 +2,14 @@ import { MembershipsModule } from "@/modules/memberships/memberships.module";
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { RedisModule } from "@/modules/redis/redis.module";
import { TeamsEventTypesModule } from "@/modules/teams/event-types/teams-event-types.module";
import { TeamsMembershipsController } from "@/modules/teams/memberships/controllers/teams-memberships.controller";
import { TeamsMembershipsService } from "@/modules/teams/memberships/services/teams-memberships.service";
import { TeamsMembershipsRepository } from "@/modules/teams/memberships/teams-memberships.repository";
import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule, RedisModule, OrganizationsModule, MembershipsModule],
imports: [PrismaModule, RedisModule, OrganizationsModule, MembershipsModule, TeamsEventTypesModule],
providers: [TeamsMembershipsRepository, TeamsMembershipsService],
controllers: [TeamsMembershipsController],
})
@@ -1,4 +1,4 @@
import { GetUserReturnType } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { UsersService } from "@/modules/users/services/users.service";
import { WebhooksService } from "@/modules/webhooks/services/webhooks.service";
@@ -26,7 +26,7 @@ export class IsOAuthClientWebhookGuard implements CanActivate {
const request = context
.switchToHttp()
.getRequest<Request & { webhook: Webhook; oAuthClient: PlatformOAuthClient }>();
const user = request.user as GetUserReturnType;
const user = request.user as ApiAuthGuardUser;
const webhookId = request.params.webhookId;
const oAuthClientId = request.params.clientId;
const organizationId = this.usersService.getUserMainOrgId(user);
@@ -1,5 +1,5 @@
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
import { GetUserReturnType } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
import { WebhooksService } from "@/modules/webhooks/services/webhooks.service";
import {
BadRequestException,
@@ -23,7 +23,7 @@ export class IsUserEventTypeWebhookGuard implements CanActivate {
const request = context
.switchToHttp()
.getRequest<Request & { webhook: Webhook } & { eventType: EventType }>();
const user = request.user as GetUserReturnType;
const user = request.user as ApiAuthGuardUser;
const webhookId = request.params.webhookId;
const eventTypeId = request.params.eventTypeId;
@@ -1,4 +1,4 @@
import { GetUserReturnType } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
import { WebhooksService } from "@/modules/webhooks/services/webhooks.service";
import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common";
import { Request } from "express";
@@ -11,7 +11,7 @@ export class IsUserWebhookGuard implements CanActivate {
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request & { webhook: Webhook }>();
const user = request.user as GetUserReturnType;
const user = request.user as ApiAuthGuardUser;
const webhookId = request.params.webhookId;
if (!user || !webhookId) {
File diff suppressed because it is too large Load Diff
@@ -38,6 +38,11 @@ export class EventTypesRepositoryFixture {
return this.prismaWriteClient.eventType.create({
data: {
...data,
users: {
connect: {
id: userId,
},
},
owner: {
connect: {
id: userId,
@@ -0,0 +1,18 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { TestingModule } from "@nestjs/testing";
import { SelectedSlots } from "@prisma/client";
export class SelectedSlotsRepositoryFixture {
private prismaReadClient: PrismaReadService["prisma"];
private prismaWriteClient: PrismaWriteService["prisma"];
constructor(private readonly module: TestingModule) {
this.prismaReadClient = module.get(PrismaReadService).prisma;
this.prismaWriteClient = module.get(PrismaWriteService).prisma;
}
async deleteByUId(uid: SelectedSlots["uid"]) {
return this.prismaWriteClient.selectedSlots.deleteMany({ where: { uid } });
}
}
+9
View File
@@ -1,5 +1,14 @@
# @calcom/web
## 4.8.7
### Patch Changes
- Updated dependencies
- @calcom/embed-core@1.5.2
- @calcom/embed-react@1.5.2
- @calcom/embed-snippet@1.3.2
## 4.5.2
### Patch Changes
+3 -13
View File
@@ -1,7 +1,6 @@
import { getBucket } from "abTest/utils";
import type { NextMiddleware, NextRequest } from "next/server";
import { NextResponse, URLPattern } from "next/server";
import z from "zod";
import { FUTURE_ROUTES_ENABLED_COOKIE_NAME, FUTURE_ROUTES_OVERRIDE_COOKIE_NAME } from "@calcom/lib/constants";
@@ -20,11 +19,6 @@ const ROUTES: [URLPattern, boolean][] = [
["/auth/error", process.env.APP_ROUTER_AUTH_ERROR_ENABLED === "1"] as const,
["/auth/platform/:path*", process.env.APP_ROUTER_AUTH_PLATFORM_ENABLED === "1"] as const,
["/auth/oauth2/:path*", process.env.APP_ROUTER_AUTH_OAUTH2_ENABLED === "1"] as const,
["/getting-started/:step", process.env.APP_ROUTER_GETTING_STARTED_STEP_ENABLED === "1"] as const,
["/bookings/:status", process.env.APP_ROUTER_BOOKINGS_STATUS_ENABLED === "1"] as const,
["/booking/:path*", process.env.APP_ROUTER_BOOKING_ENABLED === "1"] as const,
["/team", process.env.APP_ROUTER_TEAM_ENABLED === "1"] as const,
["/teams", process.env.APP_ROUTER_TEAMS_ENABLED === "1"] as const,
].map(([pathname, enabled]) => [
new URLPattern({
pathname,
@@ -32,8 +26,6 @@ const ROUTES: [URLPattern, boolean][] = [
enabled,
]);
const bucketSchema = z.union([z.literal("legacy"), z.literal("future")]);
export const abTestMiddlewareFactory =
(next: (req: NextRequest) => Promise<NextResponse<unknown>>): NextMiddleware =>
async (req: NextRequest) => {
@@ -50,11 +42,9 @@ export const abTestMiddlewareFactory =
return response;
}
const safeParsedBucket = override
? { success: true as const, data: "future" as const }
: bucketSchema.safeParse(req.cookies.get(FUTURE_ROUTES_ENABLED_COOKIE_NAME)?.value);
const bucketValue = override ? "future" : req.cookies.get(FUTURE_ROUTES_ENABLED_COOKIE_NAME)?.value;
if (!safeParsedBucket.success) {
if (!bucketValue || !["future", "legacy"].includes(bucketValue)) {
// cookie does not exist or it has incorrect value
const bucket = getBucket();
@@ -73,7 +63,7 @@ export const abTestMiddlewareFactory =
return NextResponse.rewrite(url, response);
}
if (safeParsedBucket.data === "legacy") {
if (bucketValue === "legacy") {
return response;
}
@@ -1,20 +1,20 @@
import Head from "next/head";
import { _generateMetadata, getTranslate } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import { APP_NAME, WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button } from "@calcom/ui";
import PageWrapper from "@components/PageWrapper";
export const generateMetadata = () =>
_generateMetadata(
(t) => `${t("access_denied")} | ${APP_NAME}`,
() => ""
);
export default function AccessDenied() {
const { t } = useLocale();
async function Error403() {
const t = await getTranslate();
return (
<div className="bg-subtle flex h-screen">
<Head>
<title>{`${t("access_denied")} | ${APP_NAME}`}</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<div className="rtl: bg-default m-auto rounded-md p-10 text-right ltr:text-left">
<h1 className="font-cal text-emphasis text-6xl">403</h1>
<h2 className="text-emphasis mt-6 text-2xl font-medium">{t("dont_have_access_this_page")}</h2>
@@ -27,4 +27,4 @@ export default function AccessDenied() {
);
}
AccessDenied.PageWrapper = PageWrapper;
export default WithLayout({ ServerPage: Error403 });
+21
View File
@@ -0,0 +1,21 @@
"use client";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button, showToast } from "@calcom/ui";
export default function CopyButton({ error }: { error: string }) {
const { t } = useLocale();
return (
<Button
color="secondary"
className="mt-2 border-0 font-sans font-normal hover:bg-gray-300"
StartIcon="copy"
onClick={() => {
navigator.clipboard.writeText(error);
showToast("Link copied!", "success");
}}>
{t("copy")}
</Button>
);
}
+47
View File
@@ -0,0 +1,47 @@
import { _generateMetadata, getTranslate } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import { APP_NAME } from "@calcom/lib/constants";
import { Button } from "@calcom/ui";
import CopyButton from "./copy-button";
export const generateMetadata = () =>
_generateMetadata(
(t) => `${t("something_unexpected_occurred")} | ${APP_NAME}`,
() => ""
);
async function Error500({ searchParams }: { searchParams: { error?: string } }) {
const t = await getTranslate();
return (
<div className="bg-subtle flex h-screen">
<div className="rtl: bg-default m-auto rounded-md p-10 text-right ltr:text-left">
<h1 className="font-cal text-emphasis text-6xl">500</h1>
<h2 className="text-emphasis mt-6 text-2xl font-medium">{t("500_error_message")}</h2>
<p className="text-default mb-6 mt-4 max-w-2xl text-sm">{t("something_went_wrong_on_our_end")}</p>
{searchParams?.error && (
<div className="mb-8 flex flex-col">
<p className="text-default mb-4 max-w-2xl text-sm">
{t("please_provide_following_text_to_suppport")}:
</p>
<pre className="bg-emphasis text-emphasis w-full max-w-2xl whitespace-normal break-words rounded-md p-4">
{searchParams.error}
<br />
<CopyButton error={searchParams.error} />
</pre>
</div>
)}
<Button href="mailto:support@cal.com">{t("contact_support")}</Button>
<Button color="secondary" href="javascript:history.back()" className="ml-2">
{t("go_back")}
</Button>
</div>
</div>
);
}
export default WithLayout({
ServerPage: Error500,
});
+21 -14
View File
@@ -1,24 +1,31 @@
import type { GetStaticProps, GetStaticPropsContext } from "next";
import { unstable_cache } from "next/cache";
import { notFound, redirect } from "next/navigation";
export const withAppDirSsg =
<T extends Record<string, any>>(getStaticProps: GetStaticProps<T>) =>
<T extends Record<string, any>>(getStaticProps: GetStaticProps<T>, routePath: string) =>
async (context: GetStaticPropsContext) => {
const ssgResponse = await getStaticProps(context);
const cacheKey = JSON.stringify({
route: routePath,
params: context.params || {},
});
if ("redirect" in ssgResponse) {
redirect(ssgResponse.redirect.destination);
}
const getCachedProps = unstable_cache(async () => {
const ssgResponse = await getStaticProps(context);
if ("notFound" in ssgResponse) {
notFound();
}
if ("redirect" in ssgResponse) {
redirect(ssgResponse.redirect.destination);
}
const props = await Promise.resolve(ssgResponse.props);
if ("notFound" in ssgResponse) {
notFound();
}
return {
...ssgResponse.props,
// includes dehydratedState required for future page trpcPropvider
...("trpcState" in props && { dehydratedState: props.trpcState }),
};
return {
...ssgResponse.props,
...("trpcState" in ssgResponse.props && { dehydratedState: ssgResponse.props.trpcState }),
};
}, [`ssg-${cacheKey}`]);
return getCachedProps();
};
+250
View File
@@ -0,0 +1,250 @@
import type { Request, Response } from "express";
import type { NextApiRequest, NextApiResponse, Redirect } from "next";
import { redirect, notFound } from "next/navigation";
import { createMocks } from "node-mocks-http";
import { describe, expect, it, vi } from "vitest";
import withEmbedSsrAppDir from "./WithEmbedSSR";
export type CustomNextApiRequest = NextApiRequest & Request;
export type CustomNextApiResponse = NextApiResponse & Response;
export function createMockNextJsRequest(...args: Parameters<typeof createMocks>) {
return createMocks<CustomNextApiRequest, CustomNextApiResponse>(...args);
}
// eslint-disable-next-line @typescript-eslint/no-empty-function
const noop = () => {};
vi.mock("next/navigation", () => ({
redirect: vi.fn(),
notFound: vi.fn(),
}));
function getServerSidePropsFnGenerator(
config:
| { redirectUrl: string }
| { props: Record<string, unknown> }
| {
notFound: true;
}
) {
if ("redirectUrl" in config)
return async () => {
return {
redirect: {
permanent: false,
destination: config.redirectUrl,
} satisfies Redirect,
};
};
if ("props" in config)
return async () => {
return {
props: config.props,
};
};
if ("notFound" in config)
return async () => {
return {
notFound: true as const,
};
};
throw new Error("Invalid config");
}
interface ServerSidePropsContext {
embedRelatedParams?: Record<string, string>;
}
function getServerSidePropsContextArg({ embedRelatedParams = {} }: ServerSidePropsContext) {
const { req, res } = createMockNextJsRequest();
return {
req,
res,
query: {
...embedRelatedParams,
},
resolvedUrl: "/MOCKED_RESOLVED_URL",
};
}
describe("withEmbedSsrAppDir", () => {
describe("when gSSP returns redirect", () => {
describe("when redirect destination is relative", () => {
it("should redirect with layout and embed params from the current query", async () => {
const withEmbedGetSsr = withEmbedSsrAppDir(
getServerSidePropsFnGenerator({
redirectUrl: "/reschedule",
})
);
await withEmbedGetSsr(
getServerSidePropsContextArg({
embedRelatedParams: {
layout: "week_view",
embed: "namespace1",
},
})
).catch(noop);
expect(redirect).toHaveBeenCalledWith("/reschedule/embed?layout=week_view&embed=namespace1");
});
it("should preserve existing query params in redirect URL", async () => {
const withEmbedGetSsr = withEmbedSsrAppDir(
getServerSidePropsFnGenerator({
redirectUrl: "/reschedule?redirectParam=1",
})
);
await withEmbedGetSsr(
getServerSidePropsContextArg({
embedRelatedParams: {
layout: "week_view",
embed: "namespace1",
},
})
).catch(noop);
expect(redirect).toHaveBeenCalledWith(
"/reschedule/embed?redirectParam=1&layout=week_view&embed=namespace1"
);
});
it("should handle empty embed namespace", async () => {
const withEmbedGetSsr = withEmbedSsrAppDir(
getServerSidePropsFnGenerator({
redirectUrl: "/reschedule?redirectParam=1",
})
);
await withEmbedGetSsr(
getServerSidePropsContextArg({
embedRelatedParams: {
layout: "week_view",
embed: "",
},
})
).catch(noop);
expect(redirect).toHaveBeenCalledWith("/reschedule/embed?redirectParam=1&layout=week_view&embed=");
});
});
describe("when redirect destination is absolute", () => {
it("should handle HTTPS URLs", async () => {
const withEmbedGetSsr = withEmbedSsrAppDir(
getServerSidePropsFnGenerator({
redirectUrl: "https://calcom.cal.local/owner",
})
);
await withEmbedGetSsr(
getServerSidePropsContextArg({
embedRelatedParams: {
layout: "week_view",
embed: "namespace1",
},
})
).catch(noop);
expect(redirect).toHaveBeenCalledWith(
"https://calcom.cal.local/owner/embed?layout=week_view&embed=namespace1"
);
});
it("should handle HTTP URLs", async () => {
const withEmbedGetSsr = withEmbedSsrAppDir(
getServerSidePropsFnGenerator({
redirectUrl: "http://calcom.cal.local/owner",
})
);
await withEmbedGetSsr(
getServerSidePropsContextArg({
embedRelatedParams: {
layout: "week_view",
embed: "namespace1",
},
})
).catch(noop);
expect(redirect).toHaveBeenCalledWith(
"http://calcom.cal.local/owner/embed?layout=week_view&embed=namespace1"
);
});
it("should treat URLs without protocol as relative", async () => {
const withEmbedGetSsr = withEmbedSsrAppDir(
getServerSidePropsFnGenerator({
redirectUrl: "calcom.cal.local/owner",
})
);
await withEmbedGetSsr(
getServerSidePropsContextArg({
embedRelatedParams: {
layout: "week_view",
embed: "namespace1",
},
})
).catch(noop);
expect(redirect).toHaveBeenCalledWith(
"/calcom.cal.local/owner/embed?layout=week_view&embed=namespace1"
);
});
});
});
describe("when gSSP returns props", () => {
it("should add isEmbed=true prop", async () => {
const withEmbedGetSsr = withEmbedSsrAppDir(
getServerSidePropsFnGenerator({
props: {
prop1: "value1",
},
})
);
const ret = await withEmbedGetSsr(
getServerSidePropsContextArg({
embedRelatedParams: {
layout: "week_view",
embed: "",
},
})
);
expect(ret).toEqual({
prop1: "value1",
isEmbed: true,
});
});
});
describe("when gSSP returns notFound", () => {
it("should throw notFound", async () => {
const withEmbedGetSsr = withEmbedSsrAppDir(
getServerSidePropsFnGenerator({
notFound: true,
})
);
await withEmbedGetSsr(
getServerSidePropsContextArg({
embedRelatedParams: {
layout: "week_view",
embed: "",
},
})
).catch(noop);
expect(notFound).toHaveBeenCalled();
});
});
});
+36 -40
View File
@@ -1,6 +1,4 @@
import type { GetServerSidePropsContext } from "next";
import { isNotFoundError } from "next/dist/client/components/not-found";
import { getURLFromRedirectError, isRedirectError } from "next/dist/client/components/redirect";
import type { GetServerSideProps, GetServerSidePropsContext } from "next";
import { notFound, redirect } from "next/navigation";
import { WebAppURL } from "@calcom/lib/WebAppURL";
@@ -9,48 +7,46 @@ export type EmbedProps = {
isEmbed?: boolean;
};
export default function withEmbedSsrAppDir<T extends Record<string, any>>(
getData: (context: GetServerSidePropsContext) => Promise<T>
) {
return async (context: GetServerSidePropsContext): Promise<T> => {
const withEmbedSsrAppDir =
<T extends Record<string, any>>(getServerSideProps: GetServerSideProps<T>) =>
async (context: GetServerSidePropsContext): Promise<T> => {
const { embed, layout } = context.query;
try {
const props = await getData(context);
const ssrResponse = await getServerSideProps(context);
return {
...props,
isEmbed: true,
};
} catch (e) {
if (isRedirectError(e)) {
const destinationUrl = getURLFromRedirectError(e);
let urlPrefix = "";
if ("redirect" in ssrResponse) {
const destinationUrl = ssrResponse.redirect.destination;
let urlPrefix = "";
// Get the URL parsed from URL so that we can reliably read pathname and searchParams from it.
const destinationUrlObj = new WebAppURL(destinationUrl);
// Get the URL parsed from URL so that we can reliably read pathname and searchParams from it.
const destinationUrlObj = new WebAppURL(ssrResponse.redirect.destination);
// If it's a complete URL, use the origin as the prefix to ensure we redirect to the same domain.
if (destinationUrl.search(/^(http:|https:).*/) !== -1) {
urlPrefix = destinationUrlObj.origin;
} else {
// Don't use any prefix for relative URLs to ensure we stay on the same domain
urlPrefix = "";
}
const destinationQueryStr = destinationUrlObj.searchParams.toString();
// Make sure that redirect happens to /embed page and pass on embed query param as is for preserving Cal JS API namespace
const newDestinationUrl = `${urlPrefix}${destinationUrlObj.pathname}/embed?${
destinationQueryStr ? `${destinationQueryStr}&` : ""
}layout=${layout}&embed=${embed}`;
redirect(newDestinationUrl);
// If it's a complete URL, use the origin as the prefix to ensure we redirect to the same domain.
if (destinationUrl.search(/^(http:|https:).*/) !== -1) {
urlPrefix = destinationUrlObj.origin;
} else {
// Don't use any prefix for relative URLs to ensure we stay on the same domain
urlPrefix = "";
}
if (isNotFoundError(e)) {
notFound();
}
throw e;
const destinationQueryStr = destinationUrlObj.searchParams.toString();
// Make sure that redirect happens to /embed page and pass on embed query param as is for preserving Cal JS API namespace
const newDestinationUrl = `${urlPrefix}${destinationUrlObj.pathname}/embed?${
destinationQueryStr ? `${destinationQueryStr}&` : ""
}layout=${layout}&embed=${embed}`;
redirect(newDestinationUrl);
}
if ("notFound" in ssrResponse) {
notFound();
}
return {
...ssrResponse.props,
...("trpcState" in ssrResponse.props && {
dehydratedState: ssrResponse.props.trpcState,
}),
isEmbed: true,
};
};
}
export default withEmbedSsrAppDir;
@@ -1,6 +1,6 @@
import { withAppDirSsr } from "app/WithAppDirSsr";
import type { PageProps } from "app/_types";
import { _generateMetadata } from "app/_utils";
import { generateEventBookingPageMetadata } from "app/generateBookingPageMetadata";
import { WithLayout } from "app/layoutHOC";
import { headers, cookies } from "next/headers";
@@ -18,7 +18,7 @@ export const generateMetadata = async ({ params, searchParams }: PageProps) => {
const legacyCtx = buildLegacyCtx(headers(), cookies(), params, searchParams);
const props = await getData(legacyCtx);
const { booking, user: username, slug: eventSlug } = props;
const { booking, user: username, slug: eventSlug, isSEOIndexable, eventData, isBrandingHidden } = props;
const rescheduleUid = booking?.uid;
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(legacyCtx.req, legacyCtx.params?.orgSlug);
@@ -31,12 +31,21 @@ export const generateMetadata = async ({ params, searchParams }: PageProps) => {
});
const profileName = event?.profile?.name ?? "";
const profileImage = event?.profile.image;
const title = event?.title ?? "";
return await _generateMetadata(
(t) => `${rescheduleUid && !!booking ? t("reschedule") : ""} ${title} | ${profileName}`,
(t) => `${rescheduleUid ? t("reschedule") : ""} ${title}`
);
const metadata = await generateEventBookingPageMetadata({
event: {
hidden: event?.hidden ?? false,
title,
users: event?.users ?? [],
},
hideBranding: isBrandingHidden,
orgSlug: eventData?.entity.orgSlug ?? null,
isSEOIndexable: !!isSEOIndexable,
profile: { name: profileName, image: profileImage ?? "" },
isReschedule: !!rescheduleUid,
});
return metadata;
};
const getData = withAppDirSsr<LegacyPageProps>(getServerSideProps);
+39
View File
@@ -0,0 +1,39 @@
import { withAppDirSsr } from "app/WithAppDirSsr";
import type { PageProps } from "app/_types";
import { generateUserProfilePageMetadata } from "app/generateBookingPageMetadata";
import { WithLayout } from "app/layoutHOC";
import { headers, cookies } from "next/headers";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
import { getServerSideProps } from "@server/lib/[user]/getServerSideProps";
import type { PageProps as LegacyPageProps } from "~/users/views/users-public-view";
import LegacyPage from "~/users/views/users-public-view";
export const generateMetadata = async ({ params, searchParams }: PageProps) => {
const props = await getData(buildLegacyCtx(headers(), cookies(), params, searchParams));
const { profile, markdownStrippedBio, isOrgSEOIndexable, entity } = props;
const isOrg = !!profile?.organization;
const allowSEOIndexing = !!(
(!isOrg && profile.allowSEOIndexing) ||
(isOrg && isOrgSEOIndexable && profile.allowSEOIndexing)
);
return await generateUserProfilePageMetadata({
profile: {
name: profile.name,
image: profile.image ?? "",
username: profile.username ?? "",
markdownStrippedBio: markdownStrippedBio,
},
event: null,
hideBranding: false,
orgSlug: entity.orgSlug ?? null,
isSEOIndexable: allowSEOIndexing,
});
};
const getData = withAppDirSsr<LegacyPageProps>(getServerSideProps);
export default WithLayout({ getData, Page: LegacyPage })<"P">;
+73 -31
View File
@@ -3,28 +3,41 @@ import i18next from "i18next";
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
import { headers } from "next/headers";
import { constructGenericImage } from "@calcom/lib/OgImages";
import { IS_CALCOM, WEBAPP_URL, APP_NAME, SEO_IMG_OGIMG } from "@calcom/lib/constants";
import type { MeetingImageProps } from "@calcom/lib/OgImages";
import { constructGenericImage, constructMeetingImage } from "@calcom/lib/OgImages";
import { IS_CALCOM, WEBAPP_URL, APP_NAME, SEO_IMG_OGIMG, CAL_URL } from "@calcom/lib/constants";
import { buildCanonical } from "@calcom/lib/next-seo.config";
import { truncateOnWord } from "@calcom/lib/text";
//@ts-expect-error no type definitions
import config from "@calcom/web/next-i18next.config";
const create = async (locale: string, ns: string) => {
const i18nInstanceCache: Record<string, any> = {};
const createI18nInstance = async (locale: string, ns: string) => {
const cacheKey = `${locale}-${ns}`;
// Check module-level cache first
if (i18nInstanceCache[cacheKey]) {
return i18nInstanceCache[cacheKey];
}
const { _nextI18Next } = await serverSideTranslations(locale, [ns], config);
const _i18n = i18next.createInstance();
_i18n.init({
await _i18n.init({
lng: locale,
resources: _nextI18Next?.initialI18nStore,
fallbackLng: _nextI18Next?.userConfig?.i18n.defaultLocale,
});
// Cache the instance
i18nInstanceCache[cacheKey] = _i18n;
return _i18n;
};
export const getFixedT = async (locale: string, ns = "common") => {
const i18n = await create(locale, ns);
return i18n.getFixedT(locale, ns);
const getTranslationWithCache = async (locale: string, ns = "common") => {
const localeWithFallback = locale ?? "en";
const i18n = await createI18nInstance(localeWithFallback, ns);
return i18n.getFixedT(localeWithFallback, ns);
};
export const getTranslate = async () => {
@@ -32,51 +45,80 @@ export const getTranslate = async () => {
// If "x-locale" does not exist in header,
// ensure that config.matcher in middleware includes the page you are testing
const locale = headersList.get("x-locale");
const t = await getFixedT(locale ?? "en");
const t = await getTranslationWithCache(locale ?? "en");
return t;
};
export const _generateMetadata = async (
const _generateMetadataWithoutImage = async (
getTitle: (t: TFunction<string, undefined>) => string,
getDescription: (t: TFunction<string, undefined>) => string,
excludeAppNameFromTitle?: boolean
hideBranding?: boolean,
origin?: string
) => {
const h = headers();
const canonical = h.get("x-pathname") ?? "";
const pathname = h.get("x-pathname") ?? "";
const canonical = buildCanonical({ path: pathname, origin: origin ?? CAL_URL });
const locale = h.get("x-locale") ?? "en";
const t = await getFixedT(locale, "common");
const t = await getTranslationWithCache(locale);
const title = getTitle(t);
const description = getDescription(t);
const metadataBase = new URL(IS_CALCOM ? "https://cal.com" : WEBAPP_URL);
const image =
SEO_IMG_OGIMG +
constructGenericImage({
title,
description,
});
const titleSuffix = `| ${APP_NAME}`;
const displayedTitle =
title.includes(titleSuffix) || excludeAppNameFromTitle ? title : `${title} ${titleSuffix}`;
const displayedTitle = title.includes(titleSuffix) || hideBranding ? title : `${title} ${titleSuffix}`;
const metadataBase = new URL(IS_CALCOM ? "https://cal.com" : WEBAPP_URL);
return {
title: title.length === 0 ? APP_NAME : displayedTitle,
description,
alternates: {
canonical,
},
alternates: { canonical },
openGraph: {
description: truncateOnWord(description, 158),
url: canonical,
type: "website",
siteName: APP_NAME,
title,
images: [image],
title: displayedTitle,
},
metadataBase,
};
};
export const _generateMetadata = async (
getTitle: (t: TFunction<string, undefined>) => string,
getDescription: (t: TFunction<string, undefined>) => string,
hideBranding?: boolean,
origin?: string
) => {
const metadata = await _generateMetadataWithoutImage(getTitle, getDescription, hideBranding, origin);
const image =
SEO_IMG_OGIMG +
constructGenericImage({
title: metadata.title,
description: metadata.description,
});
return {
...metadata,
openGraph: {
...metadata.openGraph,
images: [image],
},
};
};
export const generateMeetingMetadata = async (
meeting: MeetingImageProps,
getTitle: (t: TFunction<string, undefined>) => string,
getDescription: (t: TFunction<string, undefined>) => string,
hideBranding?: boolean,
origin?: string
) => {
const metadata = await _generateMetadataWithoutImage(getTitle, getDescription, hideBranding, origin);
const image = SEO_IMG_OGIMG + constructMeetingImage(meeting);
return {
...metadata,
openGraph: {
...metadata.openGraph,
images: [image],
},
};
};
+255
View File
@@ -0,0 +1,255 @@
import { cardExamples } from "@pages/api/plain/example-cards";
import { createHmac } from "crypto";
import { headers } from "next/headers";
import { NextResponse } from "next/server";
import { z } from "zod";
import { apiRouteMiddleware } from "@calcom/lib/server/apiRouteMiddleware";
import { UserRepository } from "@calcom/lib/server/repository/user";
import { userMetadata } from "@calcom/prisma/zod-utils";
const inputSchema = z.object({
customer: z.object({
email: z.string().email(),
username: z.string().optional(),
timeZone: z.string().optional(),
emailVerified: z.boolean().optional(),
identityProvider: z.string().optional(),
twoFactorEnabled: z.boolean().optional(),
}),
cardKeys: z.array(z.string()),
});
async function handler(request: Request) {
const headersList = headers();
const requestBody = await request.json();
// HMAC verification
const incomingSignature = headersList.get("plain-request-signature");
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const expectedSignature = createHmac("sha-256", process.env.PLAIN_HMAC_SECRET_KEY!)
.update(JSON.stringify(requestBody))
.digest("hex");
if (incomingSignature !== expectedSignature) {
return new Response("Forbidden", { status: 403 });
}
// Validate request body
const { cardKeys, customer } = inputSchema.parse(requestBody);
const user = await UserRepository.findByEmail({ email: customer.email });
if (!user) {
return NextResponse.json({
cards: [
{
key: "customer-card",
timeToLiveSeconds: null,
components: [
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentRow: {
rowMainContent: [
{
componentText: {
text: "Email",
textColor: "MUTED",
},
},
],
rowAsideContent: [
{
componentText: {
text: customer.email || "Unknown",
},
},
],
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentRow: {
rowMainContent: [
{
componentText: {
text: "Email Verified?",
textColor: "MUTED",
},
},
],
rowAsideContent: [
{
componentBadge: {
badgeLabel: customer.emailVerified ? "Yes" : "No",
badgeColor: customer.emailVerified ? "GREEN" : "RED",
},
},
],
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentRow: {
rowMainContent: [
{
componentText: {
text: "Username",
textColor: "MUTED",
},
},
],
rowAsideContent: [
{
componentText: {
text: customer.username || "Unknown",
},
},
],
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentRow: {
rowMainContent: [
{
componentText: {
text: "User ID",
textColor: "MUTED",
},
},
],
rowAsideContent: [
{
componentText: {
text: "Unknown",
},
},
],
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentRow: {
rowMainContent: [
{
componentText: {
text: "Time Zone",
textColor: "MUTED",
},
},
],
rowAsideContent: [
{
componentText: {
text: customer.timeZone || "Unknown",
},
},
],
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentRow: {
rowMainContent: [
{
componentText: {
text: "Two Factor Enabled?",
textColor: "MUTED",
},
},
],
rowAsideContent: [
{
componentBadge: {
badgeLabel: customer.twoFactorEnabled ? "Yes" : "No",
badgeColor: customer.twoFactorEnabled ? "GREEN" : "RED",
},
},
],
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentRow: {
rowMainContent: [
{
componentText: {
text: "Identity Provider",
textColor: "MUTED",
},
},
],
rowAsideContent: [
{
componentText: {
text: customer.identityProvider || "Unknown",
},
},
],
},
},
],
},
],
});
}
// Parse user metadata
const parsedMetadata = userMetadata.parse(user.metadata);
const cards = await Promise.all(
cardExamples.map(async (cardFn) => {
return cardFn(
user.email,
user.id.toString(),
user.username || "Unknown",
user.timeZone,
user.emailVerified,
user.twoFactorEnabled
);
})
);
const filteredCards = cards.filter((card) => {
return cardKeys.length === 0 || cardKeys.includes(card.key);
});
return NextResponse.json({
cards: filteredCards,
user: {
...user,
metadata: parsedMetadata,
},
});
}
export const POST = apiRouteMiddleware(handler);
+49
View File
@@ -0,0 +1,49 @@
import { createHmac } from "crypto";
import { NextResponse } from "next/server";
import { z } from "zod";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { apiRouteMiddleware } from "@calcom/lib/server/apiRouteMiddleware";
const responseSchema = z.object({
hash: z.string(),
email: z.string().email(),
shortName: z.string(),
appId: z.string(),
fullName: z.string(),
chatAvatarUrl: z.string(),
});
async function handler(request: Request) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const session = await getServerSession({ req: request as any });
if (!session?.user?.email) {
return new Response("Unauthorized - No session email found", { status: 401 });
}
const secret = process.env.PLAIN_CHAT_HMAC_SECRET_KEY;
if (!secret) {
return new Response("Missing Plain Chat secret", { status: 500 });
}
const hmac = createHmac("sha256", secret);
hmac.update(session.user.email.toLowerCase().trim());
const hash = hmac.digest("hex");
const shortName =
(session.user.name?.split(" ")[0] || session.user.email).charAt(0).toUpperCase() +
(session.user.name?.split(" ")[0] || session.user.email).slice(1) || "User";
const response = responseSchema.parse({
hash,
email: session.user.email || "user@example.com",
shortName,
appId: process.env.PLAIN_CHAT_ID,
fullName: session.user.name || "User",
chatAvatarUrl: session.user.avatarUrl || "",
});
return NextResponse.json(response);
}
export const POST = apiRouteMiddleware(handler);
@@ -1,12 +1,9 @@
import { withAppDirSsr } from "app/WithAppDirSsr";
import withEmbedSsrAppDir from "app/WithEmbedSSR";
import { WithLayout } from "app/layoutHOC";
import OldPage from "~/bookings/views/bookings-single-view";
import { getServerSideProps, type PageProps } from "~/bookings/views/bookings-single-view.getServerSideProps";
const getData = withAppDirSsr<PageProps>(getServerSideProps);
const getEmbedData = withEmbedSsrAppDir(getData);
const getEmbedData = withEmbedSsrAppDir<PageProps>(getServerSideProps);
export default WithLayout({ getLayout: null, getData: getEmbedData, Page: OldPage });

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