Compare commits

..
232 Commits
Author SHA1 Message Date
GitHub Actions 0466b7ebd6 chore: release v4.8.1
Post release / release (push) Canceled after 0s
2024-12-16 14:35:27 +00:00
Lauris Skraucis 09329707c1 fix: bookings parsing errors (#18200) 2024-12-16 16:31:04 +02:00
Anik Dhabal Babu 1773aba378 fix: fix data expose issue in routing form insights (#18207) 2024-12-16 15:30:25 +01:00
Somay ChauhanandMorgan f8be2b3833 feat: added office 365 video to conferencing atoms (#18067)
* feat: added office 365 video to conferencing atoms

* added documentation for conferencing atoms

* added props table to the documentation

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2024-12-16 16:20:59 +02:00
Eunjae Leeandsean-brydon 79d488faca feat: insights filter refactoring (#18167)
* Reapply "feat: add text & numeric filters to insights routing responses (#18016)"

This reverts commit 49070b96ae.

* support SINGLE_SELECT

* fix type error

* put back ts-expect-error

* remove playground

---------

Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
2024-12-16 13:47:28 +00:00
Venkata Rohan KambhampatiandUdit Takkar da2c57c302 fix: Booking questions order correction in confirmation email (#18192)
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-12-16 09:33:04 +00:00
Keith Williams abaf03adde fix: Next.js bundle analysis build error (#18193) 2024-12-15 17:40:59 +05:30
GitHub Actions 5b353c3d0c chore: release v4.8.0
Post release / release (push) Canceled after 0s
2024-12-15 11:29:06 +00:00
Benny Joo 1f4cda31ac update app layout (#17126) 2024-12-14 18:42:43 -05:00
Calcom BotandReplexica 73d1a6f468 feat: update translations via @replexica (#18158)
Co-authored-by: Replexica <support@replexica.com>
2024-12-14 19:05:59 +00:00
Benny JooandAlex van Andel f72748b6f2 fix: trpc/react-query — only one query client should exist (#18179)
* use prefetch function in ScheduleListItem and refactor

* modify app router trpc provider to match the original configs

* revert unneeded change

* move trpc provider up and do refactors

* remove log

* add comment

* fix: Some typing style fixes

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2024-12-13 20:26:51 -05:00
Benny Joo d12fc78fea chore: add viewport / favicon metadata (#18177)
* fix favicon name

* add viewport metadata for PageWrapperAppDir

* fix

* fix

* refactor
2024-12-13 20:25:03 +00:00
Carina WollendorferandCarinaWolli 3d26332443 fix: change trigger time for form submitted not booked webhook (#18176)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2024-12-13 20:16:11 +00:00
Omar López bdb490c894 Revert "fix: correct line-breaks in calendar event description" (#18175) 2024-12-13 20:03:56 +00:00
Morgan 01dbc686b9 chore: bump platform libraries version for api-v2 (#18174) 2024-12-13 19:35:18 +00:00
Carina WollendorferandCarinaWolli 376961f30d fix: don't send reassingment email to attendee for past events (#18173)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2024-12-13 19:02:54 +00:00
Morgan 18a0eb9736 fix: improve getAllUserBooking filter by teamIds (#18171)
* fix: improve getAllUserBooking filted by teamIds

* fixup! fix: improve getAllUserBooking filted by teamIds
2024-12-13 20:18:58 +02:00
Carina WollendorferandCarinaWolli 84fb4f68b2 fix: wrong workflow notifications when enabling workflows (#18169)
* only schedule workflow emails not send

* fix if statement

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2024-12-13 11:58:30 -05:00
Udit Takkar b5ab6fe3fa fix: add no show guest (#18075)
* fix: add booker no show

* chore: update name
2024-12-13 16:00:17 +00:00
Anik Dhabal BabuandUdit Takkar 645985c783 fix: Invalid time zone specified: Etc/Unknown (#18121)
* fix: Invalid time zone specified: Etc/Unknown

* update

* Update PaymentPage.tsx

* update

* update one more file

* address review

* update test

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-12-13 15:29:02 +00:00
GitHub Actions a84513eda5 chore: release v4.7.25
Post release / release (push) Canceled after 0s
2024-12-13 14:19:52 +00:00
Benny Joo b97331aeb3 chore: /availability - Remove Pages Router / Render App Router (#16517)
* /availability: Remove Pages Router / Render App Router

* comment out server side fetching

* fix

* refactor for safer code

* add to config.matcher
2024-12-13 12:44:57 +00:00
Morgan 851d96423b chore: small changes for platform booker embed (#18159) 2024-12-13 13:24:19 +01:00
Anik Dhabal BabuandPeer Richelsen ef830f11ec fix: Hyperlinks not highlighted in eventmeta (#18120)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2024-12-13 13:23:25 +01:00
Keith Williams 1ab4ec9c22 chore: Upgrade actions/upload-artifact to v4 (#18160) 2024-12-13 11:32:50 +00:00
Benny Joo 656a5bf423 chore: remove hardcoded strings (#18156) 2024-12-13 09:54:28 +01:00
Benny Joo 8a6450e854 chore: use getTranslate instead (#18148) 2024-12-13 13:06:47 +05:30
Benny Joo 51841e5e9c fix: ensure migrated pages are added to config.matcher in middleware (#18153) 2024-12-13 12:43:08 +05:30
Benny Joo 59dfcaa2fa chore: fix misleading test names (#18154) 2024-12-13 04:32:52 +00:00
Omar López 0d24706380 refactor: improved observability in google calendar service (#18147) 2024-12-13 03:47:43 +00:00
Alex van Andel 87b6e0b49c fix: Prisma validation error in generateMetadata (workflows) (#18149) 2024-12-13 03:33:19 +00:00
Calcom BotandReplexica 4244ca3117 feat: update translations via @replexica (#18139)
* chore: sync with main

* feat: update translations via @replexica

---------

Co-authored-by: Replexica <support@replexica.com>
2024-12-12 20:48:07 -05:00
Carina WollendorferandCarinaWolli 8d0f0d4899 fix: default to 100 when attribute weight undefined (#18145)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2024-12-12 22:57:43 +00:00
Anik Dhabal Babu 6463983d55 fix: Searching users in admin page not working (#18140) 2024-12-12 19:13:00 +00:00
Rajiv Sahal c8661563c8 fix: update get booking output for v2 (#18082)
* also include email field for attendees

* update tests

* update tests

* updating more tests

* fixup

* fixup

* fixup fixup
2024-12-12 18:55:28 +00:00
Sanchit Tewari 2262d56491 fix: show tooltip below the delete button (#18135) 2024-12-12 18:53:25 +00:00
GitHub Actions 6b1c747c0e chore: release v4.7.24
Post release / release (push) Canceled after 0s
2024-12-12 17:22:03 +00:00
Benny Joo 04036c32a7 fix: filling users logic (#18128)
* fix filling users logic

* fix type check

* refactor
2024-12-12 17:12:56 +00:00
Alex van Andel 49070b96ae Revert "feat: add text & numeric filters to insights routing responses (#18016)"
This reverts commit a7e7561efa.
2024-12-12 17:10:49 +00:00
GitHub Actions 237fea0a41 chore: release v4.7.23
Post release / release (push) Canceled after 0s
2024-12-12 16:40:04 +00:00
a7e7561efa feat: add text & numeric filters to insights routing responses (#18016)
* feat: improve text filters (WIP)

* move function to bottom

* apply some styles

* fix selection of TextFilterOptions

* rename value to operand

* remove unused file

* merge filters/filters into filters/utils

* fix regression of not putting url params correctly

* move makeWhereClause to filters/utils

* fix negative, empty, and not empty operators

* fix initial filtering from search state (url)

* fix type errors

* do not send an empty array to query

* update yarn.lock

* i18n for text filter operators

* extract logic as useColumnFilters()

* add missing import

* fix type error

* revert yarn.lock

* use i18n

* insensitive text match

* move data-table to @calcom/features

* fix type errors

* fix type errors

* fix type errors

* feat: support DataTable filters for Insights Routing WIP

* remove unused filters

* remove additionalFilters and fix types

* clean up filter components

* support icons for ActiveFilters

* support filters on json

* fix filter ui

* fix type error and clean up

* revert changes

* revert change

* clean up

* revert change

* fix compatibility with insights booking page

* remove unused params

* fix type errors

* update yarn.lock

* fix field filter and adjust ui

* chore: update yarn.lock

* fix text filter

* add Clear Filters button

* add more test data

* feat: support numeric filter WIP

* feat: support numeric filter

* feat: move People to an external filter

* fixing query states

* rename state to activeFilters

* add useDataTable

* fix ResponseCellValue bug and enable resizing for RoutingFormResponsesTable

* fix response values

* truncate attribute text

* seed booking attendees

* fix accessor (no actual change though)

* add a gap

* rename

* use pathname as default identifier for DataTable

* fix type error

* support text filter for assignment reason

* Apply suggestions from code review

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

* rename fields to headers

* use safeParse

* fix type error

* push yarn.lock with faker

* fix MemberList

---------

Co-authored-by: Udit Takkar <udit222001@gmail.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: sean-brydon <sean@cal.com>
2024-12-12 16:06:30 +00:00
Alex van Andel e366c10521 Convert update statement into unique select in getServerSession 2024-12-12 15:50:44 +00:00
Morgan 2ace1d1017 chore: add successRedirectUrl to event-types api v2 (#18136)
* chore: add successRedirectUrl to event-types api v2

* chore: add successRedirectUrl to event-types api v2 patch

* chore: add successRedirectUrl to event-types api v2 patch
2024-12-12 16:32:35 +02:00
sean-brydon fbe31ad82d early return if no users are found to prevent prisma error (#18133) 2024-12-12 13:37:39 +01:00
sean-brydonandUdit Takkar ee3c08918a feat: weights per attribute UI (#18109)
* wip attribut weights

* edit sheet and display in table

* add shrink so badges dont split

* push changes to support weights on csv export

* fix typing issue

* type error

* fall back to 100 weight

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-12-12 11:54:10 +00:00
Lauris Skraucis b6d6b7cdb4 fix: store default booking fields when creating event (#18108)
* refactor: store default booking fields when creating event

* Revert "refactor: store default booking fields when creating event"

This reverts commit 78b7c4eda2be61420b4410bc80a3d77b5557a967.

* refactor: v2 return default booking fields if they are null

* test: v2 returning default booking fields if they are null

* chore: platform-libraries export getBookingFieldsWithSystemFields
2024-12-12 10:58:45 +01:00
Hariom BalharaandUdit Takkar 1b82552e76 Fix crm error log (#18105)
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-12-12 13:15:14 +05:30
Joe Au-Yeung 13a90aba30 Fix logic (#18131) 2024-12-11 21:03:51 -07:00
Alex van Andel de0de64a16 build: Node alpine libssl symlink fix (#18127) 2024-12-12 00:39:36 +00:00
GitHub Actions 8e772d9046 chore: release v4.7.22
Post release / release (push) Canceled after 0s
2024-12-11 23:04:54 +00:00
Omar López 7ac7a14beb refactor: improved observability for Salesforce (#18125)
* refactor: better observability for Salesforce

* Update crmManager.ts

* Update CrmService.d.ts
2024-12-11 22:57:43 +00:00
Alex van Andel 9c127cbfc2 Revert "feat: migrate /apps/routing-forms/forms to /routing (#18001)"
This reverts commit 1de007ad21.
2024-12-11 22:16:50 +00:00
GitHub Actions 187a2c576e chore: release v4.7.22 2024-12-11 21:54:50 +00:00
5e8d994ac1 feat: Change 'add override' to 'save override' (#17434)
* feat: Change 'add override' to 'save override'

* fix: type check

* fix: type check

* autosave in availability edit sheet

* remove unused translation key

* added requested changes

* added requested changes

---------

Co-authored-by: Prashant Varma <prashantvarma5083@gmail.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-12-11 21:20:42 +00:00
VishnuandPrashant Varma 1de007ad21 feat: migrate /apps/routing-forms/forms to /routing (#18001)
* feat: add getServerSideProps and getStaticProps

* feat: add routing pages view

* feat: add routing and related pages

* feat: setup redirect

* feat: remove routing configs in getStaticProps and getServerSideProps

* feat: add redirects in next.config and middleware

* feat: add split icon

* feat: rename href to /routing/forms

* feat: fix redirects

* feat: finish routing page

* feat: remove /apps/routing-forms/forms redirect in next-config

* feat: revert back redirects in serverSideProps

* fix: minor issues

* fix: routing-form url cloning

* chore: remove comments

* fix: routing page link

* fix: routing hover link

---------

Co-authored-by: Prashant Varma <prashantvarma5083@gmail.com>
2024-12-11 21:18:46 +00:00
michaelvaletteandYounes Henni 06494a6999 fix: correct line-breaks in calendar event description (#18077)
* fix: correct line-breaks in calendar event description

* Update packages/lib/CalEventParser.ts

Co-authored-by: Younes Henni <youneshenniwrites@gmail.com>

* fix: review comments

* fix: better add spacing on who

---------

Co-authored-by: Younes Henni <youneshenniwrites@gmail.com>
2024-12-11 21:18:29 +00:00
Benny Joo 016c47d535 fix: do not export client components directly in app routes (#18122) 2024-12-11 21:17:53 +00:00
Alex van Andel 2aeb290bda fix: Error on webhooks page exporting client only comp directly (#18118)
* fix: Error on webhooks page exporting client only comp directly

* Wrap correctly, WebhooksView contains dynamic logic in the SettingsHeader
2024-12-11 19:42:04 +00:00
Keith Williams 95b4f67842 chore: Add CODEOWNERS (#18116)
* chore: Add CODEOWNERS

* Added the calcom org

* Removed non-critical calendar apps
2024-12-11 18:29:41 +00:00
Benny JooandUdit Takkar 1a4afe06c8 chore: Remove Pages Router / Render App Router - /video (#16518)
* /video: Remove Pages Router / Render App Router

* fix

* add meeting_unavailable i18n

* remove <Head> from videos view

* add correct metadata to video/[uid]

* add safety to videos-single-view logic

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-12-11 18:51:30 +01:00
Keith Williams 0eb72d66a7 revert: "fix: Request permissions to allow events to be created on shared Office365/Outlook calendars (#17760)" (#18111)
This reverts commit 1680cba5a5.
2024-12-11 14:57:57 +00:00
Eunjae Lee 7d2523e823 fix: prevent infinite rerendering of DataTable (#18087) 2024-12-11 14:03:17 +00:00
Benny JooandKeith Williams 6739ed3a87 feat: Title - AI translation for booking pages (#17794)
* feat: AI description - DB model + frontend + backend (fetch only)

* fix types and add validation to backend

* improve log

* improve

* import type

* add EventTypeTranslationRepository

* fix replexica error

* fix replexica error

* fix

* fix test

* wip

* wip

* add createManyDescriptionTranslations method

* improve logic

* refactor

* update replexica type

* improve typing

* Renamed descriptionTranslations to fieldTranslations

* log errors in replexica service

* handle translations in background

* don't create, upsert instead

* make sure that description is updated

* simplify

* fix

* Update packages/trpc/server/routers/viewer/eventTypes/update.handler.ts

* improve filter logic in frontend

* implement background job using Tasker

* update schema

* update schema again

* rename

* rename id to uid

* add migration

* fix type

* update

* update schema and migration sql

* fix migration

* fix type check

* fix type check 2

* improve find logic in EventMeta

* add more validation

* add logger

* rename

* upgrade replexica/sdk to 0.7.0

* upgrade replexica to 0.7.0

* use batchLocalizeText

* add comments

* override browser locale if user is a signed in calcom user

* fix type in replexica

* fix migration sql

* fix type

* use dynamic import

* do not use batchLocalizeText

* chore: drop sourceLang, targetLang and id columns

* fix

* wip

* add more validations

* fix type error

* address comment

* refactor

* update booking page to use translated title

* fix type error

* fix

* revert yarn.lock changes

* remove unused import

* remove duplication migrate.sql file

* fix test

* remove test tags

* fix function name

* refactor

* fix type check

* use Promise.all

* remove unneeded code

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-12-11 14:18:32 +01:00
Anik Dhabal Babu dd3505ad8b fix: Insights team filter behavior (#18084)
* fix: Insights team filter behavior

* fix type error
2024-12-11 14:43:25 +05:30
Benny Joo ce9df65b5d chore: Remove Pages Router / Use App Router - /workflows (#16519)
* /workflows: Remove Pages Router / Render App Router

* fix

* unneeded changes

* disable server side fetching for now

* revert unneeded changes
2024-12-11 09:51:22 +01:00
sean-brydon fc6e39c78a feat: assigned bookings per period (#17940)
* wip

* bring in users median status for selected range

* merge

* i18n

* routed to talbe period scroll x-y infinite

* insights i18n + hover card on user

* add search by name or email

* wip download button

* fix type error

* type fix

* type errors + status

* type error
2024-12-11 08:12:06 +00:00
Calcom BotandReplexica 931f3d3396 feat: update translations via @replexica (#18100)
* chore: sync with main

* feat: update translations via @replexica

---------

Co-authored-by: Replexica <support@replexica.com>
2024-12-11 00:12:17 +00:00
GitHub Actions 8a6fac6748 chore: release v4.7.21
Post release / release (push) Canceled after 0s
2024-12-11 00:07:34 +00:00
Benny Joo af43ede38e remove pages router and use app router for insights pages (#18088) 2024-12-10 16:56:56 -07:00
Benny Joo a66eb9dfac chore: remove extra fetching and get user locale from headers in settings pages (#18048)
* add settings pages to matcher

* fetch user locale from headers in settings pages
2024-12-10 16:56:12 -07:00
Benny Joo 5ba676b014 chore: app router migration - /enterprise and d/[link]/[slug] (#18101)
* use app router for /enterprise page

* use app router for /d page
2024-12-10 23:48:24 +00:00
Benny Joo c2c082c105 chore: Remove Pages Router / Use App Router - more, maintenance, upgrade, connect-and-join (#16531)
* Remove Pages Router / Render App Router

* remove head component from maintenance view

* use i18n metadata
2024-12-10 14:37:25 -07:00
Joe Au-Yeung 44d6c1f3fb feat: Show routing reason on booking page [CAL-4814] (#18053) 2024-12-10 14:31:48 -07:00
Benny Joo a466e8eef7 feat: remove setup-availability step in onboarding flow (#17799)
* feat: make the onboarding flow 3-step for google signups

* fix

* refactor

* rename

* improve tests

* remove test tag

* fix test and type error

* fix

* feat: remove setup-availability step in onboarding flow

* update test

* revert unneeded change

* add safety

* update create schedule options
2024-12-10 14:30:23 -07:00
VijayandPrashant Varma 370345da22 fix: Not able to create consecutive ooo (#17388)
* fix: able to create consecutive ooo

* added e2e test

* chore

* update to allow overlapping ooo and test for same

* refactor to reduce repeating code in tests

* nit

* added testcases for consecutive and reverse redirects

* fix:duplicate OOO creation

* prevent duplicate entries, also added test

* chore

* update after merges

* correct datetype in prisma query

* update to changes in rdp after merge

---------

Co-authored-by: Prashant Varma <prashantvarma5083@gmail.com>
2024-12-10 19:46:28 +05:30
Kartik SainiandKeith Williams a1fc6668d5 feat: ability to require an email (#18057)
* feat: ability to require an email

* fix: requireEmails empty bug

* Update apps/web/public/static/locales/en/common.json

* Update apps/web/public/static/locales/en/common.json

* add tests

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-12-10 17:27:32 +05:30
Calcom BotandReplexica 351ab0790f feat: update translations via @replexica (#18078)
Co-authored-by: Replexica <support@replexica.com>
2024-12-10 08:49:46 +00:00
Alex van Andel 504600b151 Change alpine image to one compatible with Prisma (#18080) 2024-12-10 02:50:00 +00:00
GitHub Actions 8b222490c2 chore: release v4.7.20
Post release / release (push) Canceled after 0s
2024-12-10 01:14:21 +00:00
Anwar SadathandAnik Dhabal Babu 0657a96795 feat: Add OAuth support for closecom app linking (#17936)
* feat: Add OAuth support for closecom app linking

* Fix types

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-12-10 00:18:30 +00:00
Kartik Saini 9c9d7fe0c3 fix: can't edit ooo (#18020)
* fix: can't edit ooo

* fix: unauthorized users can edit ooo
2024-12-09 23:39:56 +00:00
sean-brydon 950879b9c7 fix: sorting insights table for booking status (#17982)
* Revert "merge columns (#17938)"

This reverts commit a3ceefc00e.

* sort order by status
2024-12-09 23:39:10 +00:00
Peer Richelsen 55dc4bddb6 fixed days of trial (#18074) 2024-12-09 19:04:45 +00:00
Anik Dhabal Babu cbb1b285f5 fix: not able to mark no show (#17980)
* fix: not able to mark no show

* add test

* Update bookings-list.e2e.ts
2024-12-09 14:02:45 -05:00
Anik Dhabal Babu 45f8ecbab7 chore: Add location to workflow reminder template (#17449)
* chore: Add location to workflow reminder template

* update

* remove log
2024-12-09 18:58:17 +00:00
Toon ToetenelandToon Toetenel 1680cba5a5 fix: Request permissions to allow events to be created on shared Office365/Outlook calendars (#17760)
Co-authored-by: Toon Toetenel <toon@breakingbits.nl>
2024-12-09 16:17:48 +00:00
Benny Joo 046042a877 feat: remove 2 steps from the onboarding flow for google signups (#17787) 2024-12-09 15:44:42 +00:00
Morgan a926e0b9d6 fix: public booking page serialization error (#18070)
* fix: public booking page serialization error

* fixup! fix: public booking page serialization error
2024-12-09 14:48:06 +00:00
9334b67f2c feat: banner and avatars for Booker atom (#17821)
* init

* added bannerUrl to output

* cleanup

* fixup

* fix response DTOs

* remove logs

* send back avatar url for user

* enable passing avatar urls for managed user

* dont include urls for platform

* remove logs

* add team banner

* update docs

* nit

* PR feedback

* fix type error

* add prop to pass in bannerUrl

* update DTOs

* update tests

* fix tests

---------

Co-authored-by: Somay Chauhan <somaychauhan98@gmail.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2024-12-09 14:35:57 +00:00
Hariom Balhara ae1b844bea chore: Remove pages/settings (#18064)
* Remove unused org mkigration pages and delete pages/settings

* Remove more unused code causing ts errors

* Remove SettingsLayout
2024-12-09 15:19:12 +01:00
Morgan 15e93af2ac chore: enable markdown for platform atoms (#18069)
* chore: enable markdown for platform atoms

* fixup! chore: enable markdown for platform atoms
2024-12-09 16:11:02 +02:00
Rajiv Sahal 49c97c6380 rearrange order of platform tabs (#18068) 2024-12-09 18:50:59 +05:30
Keith Williams 8679d21d78 docs: Fix OAuth casing (#18066) 2024-12-09 13:18:44 +01:00
Vincent Lam 382f00e178 - Set stripe payment element to accordion layout (#18056)
- Removed hardcoded payment_method_types for checkouts and immediate payments
2024-12-09 11:37:46 +01:00
Anik Dhabal Babu e8eab10d17 fix: team event leading to a 404 page due slug conflict (#18055) 2024-12-09 11:36:15 +01:00
Eunjae Lee 7a7282a7ab fix: add members page back to settings (#18065) 2024-12-09 10:23:27 +00:00
Calcom BotandReplexica 19b3d6a633 feat: update translations via @replexica (#18063)
* chore: sync with main

* feat: update translations via @replexica

---------

Co-authored-by: Replexica <support@replexica.com>
2024-12-09 10:07:43 +00:00
Lauris Skraucis 046b7a0d08 feat: v2 teams memberships endpoints (#18041) 2024-12-09 10:52:01 +01:00
Benny Joo 30acf6b196 chore: use app router - /apps main page (#16980)
* chore: apps main page remove pages router

* revert unneeded changes
2024-12-09 09:48:22 +00:00
Eunjae Lee 5e95018870 feat: resizing in DataTable (#17973)
* feat: support resizing in data table

* wip

* clean up colors

* feat: resizable DataTable

* update implementation

* fix

* remove unused code

* improve dependency array

* enable resizing at DataTable level

* hide header label

* fix type error
2024-12-09 08:26:02 +00:00
Morgan e559e7a46f fix: add onTouchEnd for mobile apps (#18040) 2024-12-09 10:21:52 +02:00
Morgan 3b1123a29a chore: add user profile to org user endpoints apiv2 (#18039) 2024-12-09 10:21:28 +02:00
Udit TakkarandHariom be3e580107 feat: domain wide delegation UI (#18013)
Co-authored-by: Hariom <hariombalhara@gmail.com>
2024-12-09 08:27:16 +05:30
Ignite_99 b325cf8d00 Fixes #17863: When crating a new event from a team event list, the cancel button redirects to /event-types (#18058) 2024-12-08 17:19:04 +00:00
Hariom Balhara 6ce9defecf fix: Do not consider salesforce ownership when making rerouting decision (#18034) 2024-12-07 02:36:41 +00:00
Anik Dhabal Babu 8d05656b7b fix: Schedule a timezone change only allows you to select one day not a range (#18045) 2024-12-06 16:53:55 +00:00
Anik Dhabal Babu 091400b7d5 chore: Add host default language to workflow reminder weekdays (#18029)
* chore: Add host default language to workflow reminder weekdays

* update
2024-12-06 15:56:03 +00:00
Hariom BalharaandJoe Au-Yeung 6de26b3c43 feat: Enable SCIM logging that can be configured through constant (#18043)
* Enable SCIM logging that can be configured through constants

* Remove example

---------

Co-authored-by: Joe Au-Yeung <j.auyeung419@gmail.com>
2024-12-06 15:48:16 +00:00
Somay ChauhanandMorgan 063400fcbc feat: conferencing atoms (#17988)
* added api/v2 for zoom connect

* Update conferencing.controller.ts

* fix: added api for ConferencingAppsViewPlatformWrapper

* type fixes

* added getBulkEventTypes api for bulk-update default location

* added bulk-update-to-default-location

* fix:bulk-update-to-default-location

* invalidated

* fixed returnTo and onErrorReturnTo

* make logos work for conferencing atoms

* smalll improvements

* fixup

* share common types

* type fix, and fixed a typo

* fix: type-error

* fix: json.parse(json.parse())

* fix: invalidate query

* fix: removing app throws error in the main app

* undo platform-libraries-1.2.3

* update platform-libraries version

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2024-12-06 19:03:04 +05:30
Syed Ali Shahbaz 44115ac7fa feat: v1 booking "created at" field in response (#18038)
* Update booking.ts

* Update booking.ts
2024-12-06 10:22:47 +00:00
Hariom Balhara 7e76770ba6 feat: Okta(SCIM2.0) -> Attributes Sync and Group Option support (#17805) 2024-12-06 15:17:50 +05:30
GitHub Actions d5ef3e1d72 chore: release v4.7.19
Post release / release (push) Canceled after 0s
2024-12-06 09:31:35 +00:00
Benny Joo 0c19606e19 chore: technical debt - remove instances of testing /future routes (#18032) 2024-12-06 02:53:00 +00:00
Benny Joo b8219c273d feat: Reduce bundle size via better imports + install gcal + gvideo by default for google signups (#17810)
* wip

* use light imports

* fix

* perf: Reduce bundle size via better imports with `googleapis` (#17811)

* fix test

* refactor

* update vite.config.js

* fix

* fix unit test failing
2024-12-06 02:51:48 +00:00
Calcom BotandReplexica f75415e414 feat: update translations via @replexica (#18033)
Co-authored-by: Replexica <support@replexica.com>
2024-12-06 01:14:40 +00:00
Syed Ali ShahbazandCarinaWolli 4bf5e3cdf5 feat: Webhook support for Managed Events (#17986)
* managed event webhooks

* more fixes

* fix types --WIP

* fix types

* fix test

* add test

* remove remnant comments

* address feedback

* fix test

* fix test

* fix tests

* change of plans \(00)/

* remove console log

* remove unnecessary comments

* revert tests

* list active webhooks in child event types

* add translations

* allow editing own event types

* add locked info for children

* secure create handler

* fix for isLocked check

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2024-12-05 23:20:34 +00:00
Benny Joo 4181b5988a chore: remove unused files (#18025) 2024-12-05 22:01:24 +00:00
Keith Williams c41ba9d984 revert: "feat: refactor filters on insights response table (#17796)" (#18024)
* revert: "feat: refactor filters on insights response table (#17796)"

This reverts commit 5d88858995.

* Put back the search columns and options english terms
2024-12-05 17:29:09 +00:00
Benny Joo 2a578ea9d5 chore: drop sourceLang, targetLang and id columns (#17792)
* chore: drop sourceLang, targetLang and id columns

* remove copying for sourceLocale and targetLocale
2024-12-05 17:23:07 +01:00
GitHub Actions c96827e1c0 chore: release v4.7.18
Post release / release (push) Canceled after 0s
2024-12-05 16:18:37 +00:00
Calcom BotandReplexica 3f5363038c feat: update translations via @replexica (#18022)
Co-authored-by: Replexica <support@replexica.com>
2024-12-05 16:12:27 +00:00
Kartik SainiandPrashant Varma e2de28b933 feat: prevent rescheduling past bookings (#18003)
* feat: prevent rescheduling past bookings

* hide reschedule option on frontend

* fix

* addd null check for booking.endTime

---------

Co-authored-by: Prashant Varma <prashantvarma5083@gmail.com>
2024-12-05 16:36:00 +01:00
Eunjae LeeandUdit Takkar 5d88858995 feat: refactor filters on insights response table (#17796)
* feat: improve text filters (WIP)

* move function to bottom

* apply some styles

* fix selection of TextFilterOptions

* rename value to operand

* remove unused file

* merge filters/filters into filters/utils

* fix regression of not putting url params correctly

* move makeWhereClause to filters/utils

* fix negative, empty, and not empty operators

* fix initial filtering from search state (url)

* fix type errors

* do not send an empty array to query

* update yarn.lock

* i18n for text filter operators

* extract logic as useColumnFilters()

* add missing import

* fix type error

* revert yarn.lock

* use i18n

* insensitive text match

* move data-table to @calcom/features

* fix type errors

* fix type errors

* fix type errors

* feat: support DataTable filters for Insights Routing WIP

* remove unused filters

* remove additionalFilters and fix types

* clean up filter components

* support icons for ActiveFilters

* support filters on json

* fix filter ui

* fix type error and clean up

* revert changes

* revert change

* clean up

* revert change

* fix compatibility with insights booking page

* remove unused params

* fix type errors

* update yarn.lock

* fix field filter and adjust ui

* chore: update yarn.lock

* fix text filter

* add Clear Filters button

* add more test data

---------

Co-authored-by: Udit Takkar <udit222001@gmail.com>
2024-12-05 16:32:52 +01:00
Anik Dhabal Babu 82a500aeaa fix: days of the week in different language in the workflow (#17850)
* fix: days of the week in different language in the workflow

* Update
2024-12-05 15:19:45 +00:00
Hariom Balhara 2408337c78 fix: Don't force reschedule with same RR host if reschedule is actually rerouting (#17511)
* Dont force reschedule with same RR host if routed there

* Add unit tests
2024-12-05 09:30:22 -05:00
Rajiv Sahal 119edb5389 fix: localStorage not found for Booker atom (#18014) 2024-12-05 19:50:09 +05:30
Anwar SadathandPraash ae4474cd42 feat: Add slotFormat query parameter to slots API V2 endpoint to return start and end time of a slot (#17873)
* feat: Add formatAsStartAndEndTime query parameter to slots API V2 endpoint to return start and end time of a slot

* Update apps/api/v2/src/modules/slots/controllers/slots.controller.ts

Co-authored-by: Praash <99237795+Praashh@users.noreply.github.com>

* Fix duration was allowed to be any number

* Refactor code

* Show slotFormat property as optional in swagger doc

---------

Co-authored-by: Praash <99237795+Praashh@users.noreply.github.com>
2024-12-05 19:12:17 +05:30
Morgan 3290ae526a fix: custom implem markdownToSafeHTML for platform (#18018) 2024-12-05 12:18:45 +00:00
Morgan 16a7cf6d04 fix: fetching event-types of org users apiv2 (#18004) 2024-12-05 06:02:19 +00:00
Benny Joo ab2ab31eef chore: named export / default export same time (#18012) 2024-12-04 23:21:29 +01:00
GitHub Actions a08ae43533 chore: release v4.7.17
Post release / release (push) Canceled after 0s
2024-12-04 18:14:31 +00:00
Benny Joo d60b6c6a6d chore: settings/teams and settings/billing - remove pages router and use app router (#16751)
* remove pages router and use app router

* revert yarn.lock

* remove line break from yarn lock

* Add booking limits page to app dir

* revert yarn.lock

* do not render bookingLimits from pages router

* clean up code

* remove isAppDir

* revert unneeded change

* add layout to billing page

* remove files not needed

* rename imports

* remove APP_ROUTER_SETTINGS_DEVELOPER

* remove layout
2024-12-04 12:31:45 -05:00
Keith Williams 9085287fc6 revert: "chore: Move pre-commit commands to pre-push (#17996)" (#18009)
This reverts commit f63c06d68a.
2024-12-04 17:17:58 +00:00
Benny Joo 7e2b5b12b4 chore: settings/developer - remove pages router and use app router (#16794)
* remove pages router and use app router

* remove Meta / improve code quality

* remove isAppDir

* refactor code for better code

* fix test

* remove meta

* improve tests
2024-12-04 11:00:37 -05:00
sean-brydon 3211c5bcff fix data loading state (#18002) 2024-12-04 15:15:01 +00:00
Christoffer Bjelke a75a5b72b2 chore: enable eslint rule react/no-danger (#17983)
* enable react/no-danger and disable rule all places

* remove wild rule
2024-12-04 15:07:30 +00:00
Rajdeep Ghosh 509fc4b289 style: rename [...nextauth].tsx to use .ts file extention (#17992) 2024-12-04 14:52:31 +00:00
Keith Williams f63c06d68a chore: Move pre-commit commands to pre-push (#17996)
* chore: Move pre-commit commands to pre-push

* Is this fast

* Removed test commit

* Adding logging
2024-12-04 14:51:26 +00:00
Anik Dhabal Babu 3dff77fe59 chore: don't show parent managed eventType as event redirectionoption (#17993) 2024-12-04 20:08:52 +05:30
Calcom BotandReplexica a6f9952d75 feat: update translations via @replexica (#18000)
Co-authored-by: Replexica <support@replexica.com>
2024-12-04 13:13:51 +00:00
Benny JooandKeith Williams 03704b2e73 feat: fetch SEO data for booking pages in server (#17987)
* feat: fetch public event data for booking pages server-side

* fix types

* refactor

* Replace the getPublicEvent with other smaller calls

* Change username to teamSlug

* Fixing types

* Type checks

* Pushing latest for Benny - this still has type errors

* fix type checks

* fill data for team pages too

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-12-04 14:08:27 +01:00
sean-brydon 982a715c4b feat: add assignment reason to routing insights (#17984)
* add assignment reason

* add routing reason to csv export
2024-12-04 12:39:20 +00:00
Morgan eaff9e8c89 fix: disable mandatory reminder for platform (#17998)
* fix: disable mandatory reminder for platform

* bump libraries version

* fixup! bump libraries version
2024-12-04 12:33:53 +00:00
e2938ac39a refactor: v2 OAuth webhooks & workflows (#17959)
* refactor: handleMarkNoShow OAuth webhook handling

* refactor: confirm / decline booking OAuth webhook handling

* refactor: pass oauth client to calendar event

* refactor: pass oauth params

* fix import

* fix tests

* chore: bump libraries

* chore: bump libraries

* chore: republish platform libraries

* chore: republish platform libraries

* fix: use replexica key in v2 and unit test CI

* lock file

* fixup! Merge branch 'main' into lauris/cal-4807-platform-refactor-oauth-webhooks

* Revert "fix: use replexica key in v2 and unit test CI"

This reverts commit 0bd6c364535f9e11a4d1497a6bd8909885562080.

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: Morgan Vernay <morgan@cal.com>
2024-12-04 11:25:20 +01:00
Morgan b92db55380 chore: add REPLEXICA env to ci tests (#17970)
* chore: add REPLEXICA env to ci tests

* fixup! chore: add REPLEXICA env to ci tests
2024-12-04 09:37:25 +00:00
Somay Chauhan 3015c5a15e fix: trying to update the timeFormat throws error (#17991)
* timeFormat should be an intiger
2024-12-04 09:29:51 +01:00
Joe Au-YeungandUdit Takkar 2444598e38 Fuzzy search account website, fallback to contacts (#17989)
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-12-04 07:46:51 +00:00
Benny Joo 6eadadec73 fix: remove duplicate header in attributes page (#17985) 2024-12-03 20:32:56 +00:00
sean-brydon 145449e7b4 fix:z index hover card on routing form responses (#17981)
* Revert "merge columns (#17938)"

This reverts commit a3ceefc00e.

* fix hovercard on responses
2024-12-03 19:00:56 +00:00
1d8326ed83 feat: watchlist (#17947)
* lock users on signup if their email is in blacklist

* add to turbo env list

* prevent locked user or blocked email domain from using API

* Refactored to only run 1 query to find the user

* Fixing tests

* WIP

* WIP

* WIP

* Discard changes to turbo.json

* Fixed tests

* Update isLockedOrBlocked.test.ts

* Update isAdmin.integration-test.ts

* Update tsconfig.json

* chore: rename to watchlist

Signed-off-by: Omar López <zomars@me.com>

---------

Signed-off-by: Omar López <zomars@me.com>
Co-authored-by: sean-brydon <sean@cal.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
2024-12-03 18:43:01 +01:00
GitHub Actions 59993891b8 chore: release v4.7.16
Post release / release (push) Canceled after 0s
2024-12-03 17:36:59 +00:00
Alex van Andel a31d140761 fix: handling of defaultLabel (#17976) 2024-12-03 17:28:02 +00:00
Calcom BotandReplexica b055fe182a feat: update translations via @replexica (#17965)
Co-authored-by: Replexica <support@replexica.com>
2024-12-03 17:19:32 +00:00
392757fb55 fix: Remove user from managed event type when removing from org (#17369)
* Remove user from managed event type when removing from org

* added test for admin check, correct changes for membership, user info, profiles

* remove host assignment for removed members

* expand test to cover  event types removal

* chore: use var

* chore

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Udit Takkar <udit222001@gmail.com>
2024-12-03 17:54:04 +01:00
3222be9171 docs: v2 rolling window and authentication (#17825)
* docs: booking window rolling description

* docs: v2 auth and navigating endpoints

* Update apps/api/v2/swagger/documentation.json

* Update docs/api-reference/v2/introduction.mdx

* Update docs/api-reference/v2/introduction.mdx

* Update docs/api-reference/v2/introduction.mdx

* docs: refresh and force-refresh endpoints

* docs: booking fields and toasts guides

* fix docs

* fix: bookings docs

* docs: booking-redirects usebooking

* Update booking-fields.mdx

* Update booking-fields.mdx

* Update booking-fields.mdx

* Update booking-window.input.ts

* Update replacing-toasts.mdx

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2024-12-03 17:53:14 +01:00
Benny Joo 6378729962 chore: use markdownToSafeHTML in EventMeta (#17975) 2024-12-03 16:45:48 +00:00
Udit Takkar 1c5786b66f fix: org bio (#17971)
* fix: org bio

* chore: use in bookings
2024-12-03 16:30:03 +00:00
Peer Richelsen e61a0d8081 chore: safeMarkdown (#17972) 2024-12-03 15:47:58 +00:00
Rajiv SahalandMorgan Vernay 0cb955cc0b fix: build failure for v4.7.15 (#17968)
* fix: type error causing broken build

* fix typings

* fixup! fix typings

* fixup! fixup! fix typings

---------

Co-authored-by: Morgan Vernay <morgan@cal.com>
2024-12-03 15:44:42 +00:00
Hariom BalharaandAlex van Andel 00689fda0a fix: remove platform and console submodule (#17939)
* Fix: remove platform submodule

* Update git-setup.sh

* Update git-setup.sh

* Update git-setup.sh

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2024-12-03 11:49:52 +00:00
Joe Au-Yeung bd83b36b75 feat: Salesforce - add option to ignore guests (#17956)
* Enable option to ignore guests

* Ignore guests in CRM Manager
2024-12-03 11:19:57 +00:00
Eunjae Lee 05e2029bc9 fix: change case for column size vars (#17961)
* fix: change case for column size vars

* fix wrong import
2024-12-03 09:50:00 +00:00
Udit Takkar 9e3d369ac3 feat: return participants (#17950)
* feat: return participants

* fix: update tests
2024-12-03 09:36:59 +00:00
sean-brydon 356c603e3f Revert "merge columns (#17938)" (#17960)
This reverts commit a3ceefc00e.
2024-12-03 09:23:28 +00:00
5afe3aa54d feat: update translations via @replexica (#17952)
* chore: sync with main

* feat: update translations via @replexica

---------

Co-authored-by: Replexica <support@replexica.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2024-12-03 06:56:08 +00:00
Alex van Andel 985f216a43 test: fix organisation settings e2e (#17957) 2024-12-03 09:41:20 +05:30
Anwar SadathandHariom 3a472997ff fix: brand colors set by user not respected in embed (#17399)
Co-authored-by: Hariom <hariombalhara@gmail.com>
2024-12-03 04:10:58 +00:00
GitHub Actions 6dcb6aece4 chore: release v4.7.15
Post release / release (push) Canceled after 0s
2024-12-02 22:51:37 +00:00
GitHub Actions dc7ee1aba2 chore: release vv4.7.15 2024-12-02 22:41:39 +00:00
Joe Au-Yeung 2f9f29511b Reduce banner height on mobile booker (#17954) 2024-12-02 22:30:16 +00:00
Anik Dhabal BabuandPeer Richelsen 0aa58c1095 fix: Date Range Picker behavior (#17914)
* fix: Date Range Picker behaviour

* remove console log

* update

* update

* update css

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2024-12-03 02:10:23 +05:30
Eunjae LeeandAlex van Andel 58b77b2a16 fix: fix date comparison for OOO (#17756)
* fix: fix date comparison for OOO

* fix date comparison with today, and add tests

* remove wrong code

* remove logic that is no longer in use

* fix type error

* mock more methods

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2024-12-02 19:39:44 +00:00
Peer Richelsen caeaf9053b chore: fixed colored event margin (#17932) 2024-12-02 19:31:07 +00:00
fe209df2be fix: ai translations description for atoms (#17875)
* remove ai translations description for atoms

* PR feedback

* fixup

---------

Co-authored-by: Benny Joo <sldisek783@gmail.com>
Co-authored-by: Somay Chauhan <somaychauhan98@gmail.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2024-12-02 19:27:54 +00:00
CodeWithAdnanandPrashant Varma 2e7a5a84a6 feat: Show add to calendar section for customers after booking an event (#17761)
* Show 'Add to calendar' section for customers after booking an event

* removed comment

---------

Co-authored-by: Prashant Varma <prashantvarma5083@gmail.com>
2024-12-02 19:25:56 +00:00
Anik Dhabal Babu 410558f822 fix: Display option label in the routing form response email (#17898)
* fix: display option label int the routing form response email

* Update

* fix type error

* type error
2024-12-02 19:24:11 +00:00
sean-brydon a3ceefc00e merge columns (#17938) 2024-12-02 19:21:42 +00:00
Kartik SainiandAlex van Andel c524a3c1ef fix: host entry not created when user added to team in org (#17927)
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2024-12-02 19:19:05 +00:00
a5ba2682f6 fix: /members improvements (#17911)
* remove heading for more space

* fix: mobile support for mass attribute assigning bar

* change the order of Members item on Navigation

* refactor NavigationItem to support moreOnMobile property

* fix logic for nav

* decrease column widths on member list

* improve mobile design for mass attribute assign

* adjust mobile ui for DataTableSelectionBar

* add key to EventTypesList

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-12-02 19:13:08 +00:00
Anik Dhabal Babu 51c91e41da fix: navigation items weird behavior (#17929) 2024-12-02 19:08:13 +00:00
Alex van Andel cf6654dd1f Extract some refactor work from util.ts to improve error handling (#17912) 2024-12-02 19:06:45 +00:00
Benny Joo d057d7d37f fix: refactor OG link builder (#17897)
* fix: manually encode special characters

* fix test

* fix

* fix
2024-12-02 19:01:09 +00:00
Anik Dhabal BabuandAlex van Andel 262738f288 fix: Meeting URL does not save when changing the location to Gmeet or any static app (#17840)
* fix: meetingurl does not save when we change location

* update

* fix type error

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2024-12-02 19:00:43 +00:00
Eunjae Lee 27cd2457e8 fix: fix UI regression related to DataTable (#17943)
* fix: fix UI regression regarding DataTable

* update comment
2024-12-02 18:54:58 +00:00
Vishnu b33ca2f937 feat: no show guests on insights (#17919)
* feat: return noShowGuest data from eventsByStatus & eventsTimeline procedures

* feat: add noShowGuest Data in BookingKPICards

* feat: add noShowGuest Data in BookingStatusLineChart

* feat: track noShowGuests in outer query

* chore: fix noShowGuest naming
2024-12-02 18:53:32 +00:00
Alex van Andel 82f64f7d6a fix: Broken integration test due to month change (#17949) 2024-12-02 18:26:45 +00:00
Amit Sharma 8052d27d0b feat: show attributes in profile (#17920) 2024-11-30 22:53:05 +05:30
Aditya Pandey 5720ec8038 fix : Issue on docs (#17895) 2024-11-29 19:50:57 +00:00
Vincent Lam 01b1f5bcc8 fix: Added proper updates/interactivity on the "Getting started" app integ… (#17903)
* Added proper updates/interactivity on the "Getting started" app integrations.

- InstallAppButtonWithoutPlanCheck properly vends the mutation data in the render's props
- Fixed the improper props wiring in the AppConnectionItem
- Added a loading state for reactivity

* -Added toaster for error/success confirmation
2024-11-29 19:14:01 +00:00
Jesse KlotzandHariom 953a8119db build: Reduce bundle size from unnecessary bundeling react/jsx-runtime in embed-react package (#17790)
* Update vite.config.js

* linting fix

---------

Co-authored-by: Hariom <hariombalhara@gmail.com>
2024-11-29 22:19:16 +05:30
Eunjae Lee e8daa4f92f fix: bulk attribute assignment (#17896)
remove comment
2024-11-29 10:00:20 +00:00
f631abfdc4 fix: #16028 Org Settings - SEOIndexing,Disable Org Url (#16075)
Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>
Co-authored-by: Hariom <hariombalhara@gmail.com>
Co-authored-by: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com>
2024-11-29 10:21:14 +05:30
GitHub Actions b5c6d7af91 chore: release v4.7.14
Post release / release (push) Canceled after 0s
2024-11-28 21:50:59 +00:00
Omar López 249bcaca68 fix: locked users admin list and bypass query (#17902) 2024-11-28 21:45:40 +00:00
Joe Au-Yeung f2efd6dc28 feat: Salesforce - send routing form responses to Salesforce (#17890) 2024-11-28 12:37:45 -07:00
Joe Au-Yeung be2f0f986e fix: getAttributeRoutingConfig find the route based on the chosenRouteId (#17867)
## What does this PR do?

<!-- Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. -->

We were parsing routes based on `eventTypeId` but routes could be pointing to the same event type. This was causing misroutings.

- Fixes #XXXX (GitHub issue number)
- Fixes CAL-XXXX (Linear issue number - should be visible at the bottom of the GitHub issue description)

<!-- Please provide a loom video for visual changes to speed up reviews
 Loom Video: https://www.loom.com/
-->

## Mandatory Tasks (DO NOT REMOVE)

- [x] I have self-reviewed the code (A decent size PR without self-review might be rejected).
- [ ] I have updated the developer docs in /docs if this PR makes changes that would require a [documentation change](https://cal.com/docs). If N/A, write N/A here and check the checkbox.
- [ ] I confirm automated tests are in place that prove my fix is effective or that my feature works.
2024-11-28 12:31:06 -07:00
Udit TakkarandAlex van Andel 589c1dccb9 fix: remove from host when removed from org (#17893)
* fix: remove from host when removed from org

* test: add test for removing members

* fix: type errors, some unused params

* fix: test

* refactor: use prisma.count

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2024-11-28 15:56:17 +00:00
Eunjae Lee 4eac6c755d fix: revert bg color for pinned column at data table (#17892) 2024-11-28 10:04:53 +01:00
Lauris Skraucis 131b3d0984 refactor: v2 validate booking fields (#17878)
* refactor: v2 validate booking fields

* test
2024-11-28 10:37:42 +02:00
Omar López 693356d3c4 fix: Exclude locked users by default (#17889) 2024-11-27 17:03:58 -07:00
GitHub Actions 4b717108b0 chore: release v4.7.13
Post release / release (push) Canceled after 0s
2024-11-27 22:46:23 +00:00
Peer Richelsen ad8c888b77 chore: temporarily remove push notifications button until we have proper support (#17887) 2024-11-27 19:27:07 +00:00
Benny Joo d6fe955ea8 feat: hide the update button in case of no permission (#17883)
* feat: hide button if not owner of org

* fix
2024-11-27 11:43:46 -05:00
Benny Joo ab9b6024bb feat: add tooltip to button for better UX (#17881) 2024-11-27 15:47:08 +00:00
Benny Joo a7e99f3674 fix: Remove double encoding for OG image URLs (#17872)
* fix: do not use vercel image api for meeting link preview image

* fix

* fix

* remove double encoding

* fix

* fix test

* fix

* fix

* fix test

* fix

* fix

* remove test tag

* fix
2024-11-27 15:45:54 +00:00
Lauris Skraucis 999019c6f0 refactor: v2 return host username (#17880)
* fix: v2 return booking with host username

* auto gen docs

* auto gen docs
2024-11-27 15:15:12 +00:00
Lauris Skraucis 240ab60c5f fix: v2 return booking with split name attendee (#17877) 2024-11-27 16:22:58 +02:00
Benny Joo e8f84779f5 chore: make i18n.json the single source of truth (#17876) 2024-11-27 15:18:38 +01:00
Eunjae Lee 08a92b46f9 fix: load more rows if the existing ones do not fill the space (#17874) 2024-11-27 07:08:28 -07:00
Eunjae LeeandOmar López 877e4199e7 fix: move organization members list (#17531)
Co-authored-by: Omar López <zomars@me.com>
2024-11-27 13:53:36 +00:00
Hariom Balhara afd730e1c8 fix: (Hotfix)any group of rules in routing (#17869) 2024-11-27 10:44:45 +05:30
Morgan 471f5bd7eb chore: add Free to platform plan guard (#17862) 2024-11-27 01:40:39 +00:00
Joe Au-Yeung c676d47b7e fix: Routing form report filters (#17865) 2024-11-27 01:34:48 +00:00
GitHub Actions b3536728e0 chore: release v4.7.12
Post release / release (push) Canceled after 0s
2024-11-26 20:22:01 +00:00
Eunjae LeeandOmar López e22fec93b1 fix: provide fixed widths for DataTable columns (#17853)
Co-authored-by: Omar López <zomars@me.com>
2024-11-26 20:17:33 +00:00
Joe Au-Yeung f81716fbb2 feat: Salesforce - pass company booking response to the Lead field (#17860)
* Catch undefined app option properties

* Add `getCompanyNameFromBookingResponse` method

* Pass bookingResponse when Lead is being created

* Pass bookingResponse from CRMManager

* Handle empty company response

* Return the company value
2024-11-26 19:14:44 +00:00
Anik Dhabal Babu 0b66ddc6db fix: unable to remove banner (#17857) 2024-11-26 18:42:41 +00:00
Somay Chauhan 382a27f134 fix: round robin host reschedule error (#17855) 2024-11-26 18:08:58 +00:00
Calcom BotandReplexica ac5ee225d5 feat: update translations via @replexica (#17854)
* chore: sync with main

* feat: update translations via @replexica

---------

Co-authored-by: Replexica <support@replexica.com>
2024-11-26 16:46:54 +00:00
Anik Dhabal Babu d8fd6f5c25 chore: exclude emails form field refactor (#17824)
* chore: exclude emails refactor

* Update
2024-11-26 21:30:28 +05:30
Benny Joo 0e333aa71c fix: use tags icon for attr (#17841) 2024-11-26 12:52:21 -03:00
Carina WollendorferandCarinaWolli ae70786b2b fix: send attendee email with event updates of reassignment (#17836)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2024-11-26 10:33:00 -05:00
Benny Joo 2df553b58b chore: use app router for settings/platform/members page (#17849)
* use i18n strings

* migrate platform/members page
2024-11-26 15:33:01 +01:00
Morgan d4afa1436d chore: make throttler logs verbose (#17845) 2024-11-26 09:42:27 +00:00
Morgan 88efa0a2b2 fix: oauth client users controller permissions (#17822)
* fix: oauth client users controller permissions

* fixup! fix: oauth client users controller permissions

* fixup! fixup! fix: oauth client users controller permissions

* fixup! fixup! fixup! fix: oauth client users controller permissions
2024-11-26 09:41:03 +00:00
Calcom BotandReplexica b563e41623 feat: update translations via @replexica (#17842)
Co-authored-by: Replexica <support@replexica.com>
2024-11-25 22:17:54 +00:00
Benny Joo f097f852ad chore: settings/organizations - remove pages router and use app router (#16774)
* remove pages router and use app router

* remove future/settings/organizations files

* fix billing page

* sync SettingsLayoutAppDirClient and fix members page

* fix attribute pages

* remove isAppDir where not needed anymore

* add CTA button for members view
2024-11-25 16:49:00 -05:00
Joe Au-Yeung aea8588a21 fix: Salesforce owner check, handle no contact email (#17834) 2024-11-25 19:38:06 +00:00
Benny Joo 7e0c684348 fix: UI issue in Phone Input dropdown (#17837) 2024-11-25 19:35:09 +00:00
Keith Williams 21956f096d chore: Add check to skip DB migrations as part of build (#17827) 2024-11-25 16:33:51 -03:00
Benny Joo d1adfa3bf9 fix (#17812) 2024-11-25 14:07:55 -05:00
Benny JooandKeith Williams 62976a4c77 feat: AI description - Backend for adding translation objects to EventType model (#17657)
* feat: AI description - DB model + frontend + backend (fetch only)

* fix types and add validation to backend

* improve log

* improve

* import type

* add EventTypeTranslationRepository

* fix replexica error

* fix replexica error

* fix

* fix test

* wip

* wip

* add createManyDescriptionTranslations method

* improve logic

* refactor

* update replexica type

* improve typing

* Renamed descriptionTranslations to fieldTranslations

* log errors in replexica service

* handle translations in background

* don't create, upsert instead

* make sure that description is updated

* simplify

* fix

* Update packages/trpc/server/routers/viewer/eventTypes/update.handler.ts

* improve filter logic in frontend

* implement background job using Tasker

* update schema

* update schema again

* rename

* rename id to uid

* add migration

* fix type

* update

* update schema and migration sql

* fix migration

* fix type check

* fix type check 2

* improve find logic in EventMeta

* add more validation

* add logger

* rename

* upgrade replexica/sdk to 0.7.0

* upgrade replexica to 0.7.0

* use batchLocalizeText

* add comments

* override browser locale if user is a signed in calcom user

* fix type in replexica

* fix migration sql

* fix type

* use dynamic import

* do not use batchLocalizeText

* fix

* add more validations

* fix type error

* address comment

* update locales

* update locales

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-11-25 18:37:42 +00:00
852 changed files with 26853 additions and 10298 deletions
+6 -12
View File
@@ -119,7 +119,7 @@ NEXT_PUBLIC_FRESHCHAT_HOST=
# Google OAuth credentials
# To enable Login with Google you need to:
# 1. Set `GOOGLE_API_CREDENTIALS` above
# 1. Set `GOOGLE_API_CREDENTIALS` below
# 2. Set `GOOGLE_LOGIN_ENABLED` to `true`
# When self-hosting please ensure you configure the Google integration as an Internal app so no one else can login to your instance
# @see https://support.google.com/cloud/answer/6158849#public-and-internal&zippy=%2Cpublic-and-internal-applications
@@ -239,7 +239,8 @@ CLOUDFLARE_TURNSTILE_SECRET=
NEXT_PUBLIC_TEAM_IMPERSONATION=false
# Close.com internal CRM
CLOSECOM_API_KEY=
CLOSECOM_CLIENT_ID=
CLOSECOM_CLIENT_SECRET=
# Sendgrid internal sync service
SENDGRID_SYNC_API_KEY=
@@ -347,8 +348,6 @@ E2E_TEST_OIDC_USER_PASSWORD=
AB_TEST_BUCKET_PROBABILITY=50
# whether we redirect to the future/event-types from event-types or not
APP_ROUTER_EVENT_TYPES_ENABLED=0
APP_ROUTER_SETTINGS_DEVELOPER_ENABLED=0
APP_ROUTER_SETTINGS_ORG_ENABLED=0
APP_ROUTER_APPS_INSTALLED_CATEGORY_ENABLED=0
APP_ROUTER_APPS_SLUG_ENABLED=0
APP_ROUTER_APPS_SLUG_SETUP_ENABLED=0
@@ -358,14 +357,10 @@ APP_ROUTER_APPS_CATEGORIES_ENABLED=0
APP_ROUTER_APPS_CATEGORIES_CATEGORY_ENABLED=0
APP_ROUTER_BOOKING_ENABLED=0
APP_ROUTER_BOOKINGS_STATUS_ENABLED=0
APP_ROUTER_WORKFLOWS_ENABLED=0
APP_ROUTER_SETTINGS_TEAMS_ENABLED=0
APP_ROUTER_GETTING_STARTED_STEP_ENABLED=0
APP_ROUTER_APPS_ENABLED=0
APP_ROUTER_VIDEO_ENABLED=0
APP_ROUTER_TEAM_ENABLED=0
APP_ROUTER_TEAMS_ENABLED=0
APP_ROUTER_AVAILABILITY_ENABLED=0
APP_ROUTER_AUTH_FORGOT_PASSWORD_ENABLED=0
APP_ROUTER_AUTH_LOGIN_ENABLED=0
APP_ROUTER_AUTH_LOGOUT_ENABLED=0
@@ -374,10 +369,6 @@ APP_ROUTER_AUTH_SAML_ENABLED=0
APP_ROUTER_AUTH_ERROR_ENABLED=0
APP_ROUTER_AUTH_PLATFORM_ENABLED=0
APP_ROUTER_AUTH_OAUTH2_ENABLED=0
APP_ROUTER_MORE_ENABLED=0
APP_ROUTER_MAINTENANCE_ENABLED=0
APP_ROUTER_UPGRADE_ENABLED=0
APP_ROUTER_CONNECT_AND_JOIN_ENABLED=0
# disable setry server source maps
SENTRY_DISABLE_SERVER_WEBPACK_PLUGIN=1
@@ -417,3 +408,6 @@ NEXT_PUBLIC_LOGGER_LEVEL=
# Used to use Replexica SDK, a tool for real-time AI-powered localization
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=
+11
View File
@@ -0,0 +1,11 @@
/packages/app-store/applecalendar/**/* @calcom/Foundation
/packages/app-store/caldavcalendar/**/* @calcom/Foundation
/packages/app-store/exchange2013calendar/**/* @calcom/Foundation
/packages/app-store/exchange2016calendar/**/* @calcom/Foundation
/packages/app-store/exchangecalendar/**/* @calcom/Foundation
/packages/app-store/feishucalendar/**/* @calcom/Foundation
/packages/app-store/googlecalendar/**/* @calcom/Foundation
/packages/app-store/ics-feedcalendar/**/* @calcom/Foundation
/packages/app-store/larkcalendar/**/* @calcom/Foundation
/packages/app-store/office365calendar/**/* @calcom/Foundation
/packages/app-store/zohocalendar/**/* @calcom/Foundation
+1
View File
@@ -17,6 +17,7 @@ env:
NEXTAUTH_URL: ${{ secrets.CI_NEXTAUTH_URL }}
NODE_OPTIONS: --max-old-space-size=29000
REDIS_URL: "redis://localhost:6379"
REPLEXICA_API_KEY: ${{ secrets.CI_REPLEXICA_API_KEY }}
STRIPE_PRIVATE_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
STRIPE_API_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }}
+3 -2
View File
@@ -17,7 +17,6 @@ env:
DAILY_API_KEY: ${{ secrets.CI_DAILY_API_KEY }}
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }}
DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }}
E2E_TEST_APPLE_CALENDAR_EMAIL: ${{ secrets.E2E_TEST_APPLE_CALENDAR_EMAIL }}
E2E_TEST_APPLE_CALENDAR_PASSWORD: ${{ secrets.E2E_TEST_APPLE_CALENDAR_PASSWORD }}
E2E_TEST_MAILHOG_ENABLED: ${{ vars.E2E_TEST_MAILHOG_ENABLED }}
@@ -51,7 +50,8 @@ env:
jobs:
analyze:
if: always()
runs-on: buildjet-2vcpu-ubuntu-2204
runs-on: buildjet-4vcpu-ubuntu-2204
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/dangerous-git-checkout
@@ -60,6 +60,7 @@ jobs:
- name: Analyze bundle
run: |
cd apps/web
export NODE_OPTIONS="--max_old_space_size=8192"
npx -p nextjs-bundle-analysis@0.5.0 report
- name: Upload bundle
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
printf '{
"pr_number": ${{ github.event.pull_request.number }}
}' >> context.json
- uses: actions/upload-artifact@v3
- uses: actions/upload-artifact@v4
with:
name: context.json
path: ./
+2
View File
@@ -1,6 +1,8 @@
name: Unit
on:
workflow_call:
env:
REPLEXICA_API_KEY: ${{ secrets.CI_REPLEXICA_API_KEY }}
permissions:
contents: read
jobs:
+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
View File
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"
+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
View File
+7
View File
@@ -0,0 +1,7 @@
undecided:
- "@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"
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"
+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"
+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
+22 -9
View File
@@ -6,6 +6,7 @@ import { IS_PRODUCTION } from "@calcom/lib/constants";
import prisma from "@calcom/prisma";
import { isAdminGuard } from "../utils/isAdmin";
import { isLockedOrBlocked } from "../utils/isLockedOrBlocked";
import { ScopeOfAdmin } from "../utils/scopeOfAdmin";
// Used to check if the apiKey is not expired, could be extracted if reused. but not for now.
@@ -24,23 +25,35 @@ export const verifyApiKey: NextMiddleware = async (req, res, next) => {
if (!hasValidLicense && IS_PRODUCTION) {
return res.status(401).json({ error: "Invalid or missing CALCOM_LICENSE_KEY environment variable" });
}
// Check if the apiKey query param is provided.
if (!req.query.apiKey) return res.status(401).json({ message: "No apiKey provided" });
// remove the prefix from the user provided api_key. If no env set default to "cal_"
const strippedApiKey = `${req.query.apiKey}`.replace(process.env.API_KEY_PREFIX || "cal_", "");
// Hash the key again before matching against the database records.
const hashedKey = hashAPIKey(strippedApiKey);
// Check if the hashed api key exists in database.
const apiKey = await prisma.apiKey.findUnique({ where: { hashedKey } });
// If cannot find any api key. Throw a 401 Unauthorized.
if (!apiKey) return res.status(401).json({ error: "Your apiKey is not valid" });
const apiKey = await prisma.apiKey.findUnique({
where: { hashedKey },
include: {
user: {
select: { role: true, locked: true, email: true },
},
},
});
if (!apiKey) return res.status(401).json({ error: "Your API key is not valid." });
if (apiKey.expiresAt && dateNotInPast(apiKey.expiresAt)) {
return res.status(401).json({ error: "This apiKey is expired" });
return res.status(401).json({ error: "This API key is expired." });
}
if (!apiKey.userId) return res.status(404).json({ error: "No user found for this apiKey" });
if (!apiKey.userId || !apiKey.user)
return res.status(404).json({ error: "No user found for this API key." });
// save the user id in the request for later use
req.userId = apiKey.userId;
req.user = apiKey.user;
const { isAdmin, scope } = await isAdminGuard(req);
const userIsLockedOrBlocked = await isLockedOrBlocked(req);
if (userIsLockedOrBlocked)
return res.status(403).json({ error: "You are not authorized to perform this request." });
req.isSystemWideAdmin = isAdmin && scope === ScopeOfAdmin.SystemWide;
req.isOrganizationOwnerOrAdmin = isAdmin && scope === ScopeOfAdmin.OrgOwnerOrAdmin;
+3 -4
View File
@@ -6,8 +6,7 @@ import { UserPermissionRole, MembershipRole } from "@calcom/prisma/enums";
import { ScopeOfAdmin } from "./scopeOfAdmin";
export const isAdminGuard = async (req: NextApiRequest) => {
const { userId } = req;
const user = await prisma.user.findUnique({ where: { id: userId }, select: { role: true } });
const { user, userId } = req;
if (!user) return { isAdmin: false, scope: null };
const { role: userRole } = user;
@@ -34,7 +33,7 @@ export const isAdminGuard = async (req: NextApiRequest) => {
},
},
});
if (!orgOwnerOrAdminMemberships.length) return { isAdmin: false, scope: null };
if (orgOwnerOrAdminMemberships.length > 0) return { isAdmin: true, scope: ScopeOfAdmin.OrgOwnerOrAdmin };
return { isAdmin: true, scope: ScopeOfAdmin.OrgOwnerOrAdmin };
return { isAdmin: false, scope: null };
};
@@ -0,0 +1,9 @@
import type { NextApiRequest } from "next";
import { checkIfEmailInWatchlistController } 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));
}
+2
View File
@@ -23,6 +23,7 @@ const schemaBookingBaseBodyParams = Booking.pick({
status: true,
rescheduledBy: true,
cancelledBy: true,
createdAt: true,
}).partial();
export const schemaBookingCreateBodyParams = extendedBookingCreateBody.merge(schemaQueryUserId.partial());
@@ -119,4 +120,5 @@ export const schemaBookingReadPublic = Booking.extend({
fromReschedule: true,
cancelledBy: true,
rescheduledBy: true,
createdAt: true,
});
+1
View File
@@ -8,6 +8,7 @@ export declare module "next" {
session?: Session | null;
userId: number;
user?: { role: string; locked: boolean; email: string } | null;
method: string;
// session: { user: { id: number } };
// query: Partial<{ [key: string]: string | string[] }>;
@@ -1,17 +1,17 @@
import prismaMock from "../../../../../../tests/libs/__mocks__/prismaMock";
import prismock from "../../../../../../tests/libs/__mocks__/prisma";
import type { Request, Response } from "express";
import type { NextApiRequest, NextApiResponse } from "next";
import { createMocks } from "node-mocks-http";
import { describe, vi, it, expect, afterEach, beforeEach } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import LicenseKeyService from "@calcom/ee/common/server/LicenseKeyService";
import prisma from "@calcom/prisma";
import { MembershipRole, UserPermissionRole } from "@calcom/prisma/enums";
import { isAdminGuard } from "~/lib/utils/isAdmin";
import { hashAPIKey } from "~/../../../packages/features/ee/api-keys/lib/apiKeys";
import { verifyApiKey } from "../../../lib/helpers/verifyApiKey";
import { ScopeOfAdmin } from "../../../lib/utils/scopeOfAdmin";
type CustomNextApiRequest = NextApiRequest & Request;
type CustomNextApiResponse = NextApiResponse & Response;
@@ -20,11 +20,6 @@ afterEach(() => {
vi.resetAllMocks();
});
vi.mock("@calcom/prisma");
vi.mock("~/lib/utils/isAdmin", () => ({
isAdminGuard: vi.fn(),
}));
describe("Verify API key", () => {
let service: LicenseKeyService;
@@ -45,7 +40,6 @@ describe("Verify API key", () => {
};
vi.mocked(service.checkLicense).mockResolvedValue(false);
vi.mocked(isAdminGuard).mockResolvedValue({ isAdmin: false, scope: null });
const serverNext = vi.fn((next: void) => Promise.resolve(next));
@@ -69,7 +63,6 @@ describe("Verify API key", () => {
};
vi.mocked(service.checkLicense).mockResolvedValue(true);
vi.mocked(isAdminGuard).mockResolvedValue({ isAdmin: false, scope: null });
const serverNext = vi.fn((next: void) => Promise.resolve(next));
@@ -91,10 +84,18 @@ describe("Verify API key", () => {
},
prisma,
});
prismaMock.apiKey.findUnique.mockResolvedValue({
id: 1,
userId: 2,
const hashedKey = hashAPIKey("test_key");
await prismock.apiKey.create({
data: {
hashedKey,
user: {
create: {
email: "admin@example.com",
role: UserPermissionRole.ADMIN,
locked: false,
},
},
},
});
const middleware = {
@@ -102,7 +103,6 @@ describe("Verify API key", () => {
};
vi.mocked(service.checkLicense).mockResolvedValue(true);
vi.mocked(isAdminGuard).mockResolvedValue({ isAdmin: true, scope: ScopeOfAdmin.SystemWide });
const serverNext = vi.fn((next: void) => Promise.resolve(next));
@@ -125,10 +125,36 @@ describe("Verify API key", () => {
},
prisma,
});
prismaMock.apiKey.findUnique.mockResolvedValue({
id: 1,
userId: 2,
const hashedKey = hashAPIKey("test_key");
await prismock.apiKey.create({
data: {
hashedKey,
user: {
create: {
email: "org-admin@acme.com",
role: UserPermissionRole.USER,
locked: false,
teams: {
create: {
accepted: true,
role: MembershipRole.OWNER,
team: {
create: {
name: "ACME",
isOrganization: true,
organizationSettings: {
create: {
isAdminAPIEnabled: true,
orgAutoAcceptEmail: "acme.com",
},
},
},
},
},
},
},
},
},
});
const middleware = {
@@ -136,7 +162,6 @@ describe("Verify API key", () => {
};
vi.mocked(service.checkLicense).mockResolvedValue(true);
vi.mocked(isAdminGuard).mockResolvedValue({ isAdmin: true, scope: ScopeOfAdmin.OrgOwnerOrAdmin });
const serverNext = vi.fn((next: void) => Promise.resolve(next));
@@ -149,4 +174,44 @@ describe("Verify API key", () => {
expect(req.isSystemWideAdmin).toBe(false);
expect(req.isOrganizationOwnerOrAdmin).toBe(true);
});
it("should return 403 if user is locked or blocked", async () => {
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "POST",
body: {},
query: {
apiKey: "cal_test_key",
},
prisma,
});
const hashedKey = hashAPIKey("test_key");
await prismock.apiKey.create({
data: {
hashedKey,
user: {
create: {
email: "locked@example.com",
role: UserPermissionRole.USER,
locked: true,
},
},
},
});
const middleware = {
fn: verifyApiKey,
};
vi.mocked(service.checkLicense).mockResolvedValue(true);
const serverNext = vi.fn((next: void) => Promise.resolve(next));
const middlewareSpy = vi.spyOn(middleware, "fn");
await middleware.fn(req, res, serverNext);
expect(middlewareSpy).toBeCalled();
expect(res.statusCode).toBe(403);
expect(JSON.parse(res._getData())).toEqual({ error: "You are not authorized to perform this request." });
expect(serverNext).not.toHaveBeenCalled();
});
});
@@ -19,6 +19,7 @@ describe("isAdmin guard", () => {
});
req.userId = 0;
req.user = undefined;
const { isAdmin, scope } = await isAdminGuard(req);
@@ -35,6 +36,7 @@ describe("isAdmin guard", () => {
const memberUser = await prisma.user.findFirstOrThrow({ where: { email: "member2-acme@example.com" } });
req.userId = memberUser.id;
req.user = memberUser;
const { isAdmin, scope } = await isAdminGuard(req);
@@ -51,6 +53,7 @@ describe("isAdmin guard", () => {
const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "admin@example.com" } });
req.userId = adminUser.id;
req.user = adminUser;
const { isAdmin, scope } = await isAdminGuard(req);
@@ -67,6 +70,8 @@ describe("isAdmin guard", () => {
const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "owner1-acme@example.com" } });
req.userId = adminUser.id;
req.user = adminUser;
const { isAdmin, scope } = await isAdminGuard(req);
expect(isAdmin).toBe(true);
expect(scope).toBe(ScopeOfAdmin.OrgOwnerOrAdmin);
@@ -81,6 +86,8 @@ describe("isAdmin guard", () => {
const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "owner1-dunder@example.com" } });
req.userId = adminUser.id;
req.user = adminUser;
const { isAdmin } = await isAdminGuard(req);
expect(isAdmin).toBe(false);
});
@@ -0,0 +1,88 @@
import prismock from "../../../../../../tests/libs/__mocks__/prisma";
import { describe, expect, it, beforeEach } from "vitest";
import { isLockedOrBlocked } from "../../../lib/utils/isLockedOrBlocked";
describe("isLockedOrBlocked", () => {
beforeEach(async () => {
await prismock.watchlist.createMany({
data: [
{
type: "DOMAIN",
value: "spam.com",
createdById: 1,
},
{
type: "DOMAIN",
value: "blocked.com",
createdById: 1,
},
],
});
});
it("should return false if no user in request", async () => {
const req = { userId: null, user: null } as any;
const result = await isLockedOrBlocked(req);
expect(result).toBe(false);
});
it("should return false if user has no email", async () => {
const req = { userId: 123, user: { email: null } } as any;
const result = await isLockedOrBlocked(req);
expect(result).toBe(false);
});
it("should return true if user is locked", async () => {
const req = {
userId: 123,
user: {
locked: true,
email: "test@example.com",
},
} as any;
const result = await isLockedOrBlocked(req);
expect(result).toBe(true);
});
it("should return true if user email domain is watchlisted", async () => {
const req = {
userId: 123,
user: {
locked: false,
email: "test@blocked.com",
},
} as any;
const result = await isLockedOrBlocked(req);
expect(result).toBe(true);
});
it("should return false if user is not locked and email domain is not watchlisted", async () => {
const req = {
userId: 123,
user: {
locked: false,
email: "test@example.com",
},
} as any;
const result = await isLockedOrBlocked(req);
expect(result).toBe(false);
});
it("should handle email domains case-insensitively", async () => {
const req = {
userId: 123,
user: {
locked: false,
email: "test@BLOCKED.COM",
},
} as any;
const result = await isLockedOrBlocked(req);
expect(result).toBe(true);
});
});
+1
View File
@@ -12,6 +12,7 @@
},
"include": [
"next-env.d.ts",
"./next.d.ts",
"**/*.ts",
"**/*.tsx",
"../../../packages/types/*.d.ts",
+1
View File
@@ -6,6 +6,7 @@ ARG DATABASE_URL
WORKDIR /calcom
RUN set -eux;
RUN ln -s /usr/lib/libssl.so.3 /lib/libssl.so.3
ENV NODE_ENV="production"
ENV NODE_OPTIONS="--max-old-space-size=8192"
+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.63",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.72",
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
@@ -169,6 +169,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
expect(data.eventTypeId).toEqual(eventTypeId);
expect(data.attendees[0]).toEqual({
name: body.attendee.name,
email: body.attendee.email,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
@@ -0,0 +1,526 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output";
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { PrismaModule } from "@/modules/prisma/prisma.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 * as request from "supertest";
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { randomNumber } from "test/utils/randomNumber";
import { withApiAuth } from "test/utils/withApiAuth";
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants";
import {
CreateBookingInput_2024_08_13,
GetBookingOutput_2024_08_13,
GetSeatedBookingOutput_2024_08_13,
} from "@calcom/platform-types";
import { BookingOutput_2024_08_13 } from "@calcom/platform-types";
import { Booking, PlatformOAuthClient, Team, User } from "@calcom/prisma/client";
const suffix = randomNumber();
describe("Bookings Endpoints 2024-08-13", () => {
describe("Booking fields", () => {
let app: INestApplication;
let organization: Team;
let userRepositoryFixture: UserRepositoryFixture;
let bookingsRepositoryFixture: BookingsRepositoryFixture;
let schedulesService: SchedulesService_2024_04_15;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
let oAuthClient: PlatformOAuthClient;
let teamRepositoryFixture: TeamRepositoryFixture;
const userEmail = `alice-${suffix}@api.com`;
let user: User;
let bookingWithSplitName: Booking;
const splitName = {
firstName: "Oldie",
lastName: "Goldie",
};
let seatedBookingWithSplitName: Booking;
let eventTypeWithBookingFieldsId: number;
beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
})
)
.overrideGuard(PermissionsGuard)
.useValue({
canActivate: () => true,
})
.compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
organization = await teamRepositoryFixture.create({ name: `booking fields ${suffix}` });
oAuthClient = await createOAuthClient(organization.id);
user = await userRepositoryFixture.create({
email: userEmail,
platformOAuthClients: {
connect: {
id: oAuthClient.id,
},
},
});
const userSchedule: CreateScheduleInput_2024_04_15 = {
name: "working time",
timeZone: "Europe/Rome",
isDefault: true,
};
await schedulesService.createUserSchedule(user.id, userSchedule);
const event = await eventTypesRepositoryFixture.create(
{ title: "peer coding", slug: `normal-booking-${randomNumber()}`, length: 60 },
user.id
);
bookingWithSplitName = await bookingsRepositoryFixture.create({
user: {
connect: {
id: user.id,
},
},
startTime: new Date(Date.UTC(2020, 0, 8, 12, 0, 0)),
endTime: new Date(Date.UTC(2020, 0, 8, 13, 0, 0)),
title: "peer coding lets goo",
uid: "booking",
eventType: {
connect: {
id: event.id,
},
},
location: "integrations:daily",
customInputs: {},
metadata: {},
responses: {
name: splitName,
email: "oldie@gmail.com",
},
attendees: {
create: {
email: "oldie@gmail.com",
name: "Oldie Goldie",
locale: "lv",
timeZone: "Europe/Rome",
},
},
});
const seatedEvent = await eventTypesRepositoryFixture.create(
{ title: "peer coding", slug: `seated-${randomNumber()}`, length: 60, seatsPerTimeSlot: 3 },
user.id
);
seatedBookingWithSplitName = await bookingsRepositoryFixture.create({
user: {
connect: {
id: user.id,
},
},
startTime: new Date(Date.UTC(2020, 0, 8, 14, 0, 0)),
endTime: new Date(Date.UTC(2020, 0, 8, 15, 0, 0)),
title: "peer coding lets goo",
uid: "seated-booking",
eventType: {
connect: {
id: seatedEvent.id,
},
},
location: "integrations:daily",
customInputs: {},
metadata: {},
responses: {
name: splitName,
email: "oldie@gmail.com",
},
attendees: {
create: {
email: "oldie@gmail.com",
name: "Oldie Goldie",
locale: "lv",
timeZone: "Europe/Rome",
bookingSeat: {
create: {
referenceUid: "unique-seat-uid",
data: {
responses: {
email: "oldie@gmail.com",
name: {
firstName: "Oldie",
lastName: "Goldie",
},
},
},
metadata: {},
booking: {
connect: {
uid: "seated-booking",
},
},
},
},
},
},
});
const eventTypeWithBookingFields = await eventTypesRepositoryFixture.create(
{
title: "peer coding with booking fields",
slug: `with-custom-booking-fields-${randomNumber()}`,
length: 60,
bookingFields: [
{
name: "name",
type: "name",
label: "",
sources: [
{
id: "default",
type: "default",
label: "Default",
},
],
variant: "fullName",
editable: "system",
required: true,
placeholder: "",
defaultLabel: "your_name",
variantsConfig: {
variants: {
fullName: {
fields: [
{
name: "fullName",
type: "text",
label: "your_name",
required: true,
placeholder: "",
},
],
},
firstAndLastName: {
fields: [
{
name: "firstName",
type: "text",
label: "name",
required: true,
placeholder: "lauris",
},
{
name: "lastName",
type: "text",
label: "surname",
required: true,
placeholder: "skraucis",
},
],
},
},
},
disableOnPrefill: false,
},
{
name: "email",
type: "email",
sources: [
{
id: "default",
type: "default",
label: "Default",
},
],
editable: "system",
required: true,
defaultLabel: "email_address",
},
{
name: "location",
type: "radioInput",
sources: [
{
id: "default",
type: "default",
label: "Default",
},
],
editable: "system",
required: false,
defaultLabel: "location",
getOptionsAt: "locations",
optionsInputs: {
phone: {
type: "phone",
required: true,
placeholder: "",
},
somewhereElse: {
type: "text",
required: true,
placeholder: "",
},
attendeeInPerson: {
type: "address",
required: true,
placeholder: "",
},
},
hideWhenJustOneOption: true,
},
{
name: "title",
type: "text",
hidden: true,
sources: [
{
id: "default",
type: "default",
label: "Default",
},
],
editable: "system-but-optional",
required: true,
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",
hidden: false,
sources: [
{
id: "default",
type: "default",
label: "Default",
},
],
editable: "system-but-optional",
required: false,
defaultLabel: "additional_guests",
defaultPlaceholder: "email",
},
{
name: "rescheduleReason",
type: "textarea",
views: [
{
id: "reschedule",
label: "Reschedule View",
},
],
sources: [
{
id: "default",
type: "default",
label: "Default",
},
],
editable: "system-but-optional",
required: false,
defaultLabel: "reason_for_reschedule",
defaultPlaceholder: "reschedule_placeholder",
},
{
name: "favorite-movie",
type: "text",
label: "favorite movie",
sources: [
{
id: "user",
type: "user",
label: "User",
fieldRequired: true,
},
],
editable: "user",
required: true,
placeholder: "matrix",
disableOnPrefill: false,
},
],
},
user.id
);
eventTypeWithBookingFieldsId = eventTypeWithBookingFields.id;
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
async function createOAuthClient(organizationId: number) {
const data = {
logo: "logo-url",
name: "name",
redirectUris: ["http://localhost:5555"],
permissions: 32,
};
const secret = "secret";
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
return client;
}
describe("get individual booking", () => {
it("should should get a seated booking with split name responses", async () => {
return request(app.getHttpServer())
.get(`/v2/bookings/${bookingWithSplitName.uid}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: GetBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsBooking(responseBody.data)).toBe(true);
if (responseDataIsBooking(responseBody.data)) {
const data: BookingOutput_2024_08_13 = responseBody.data;
expect(data.attendees[0].name).toEqual(`${splitName.firstName} ${splitName.lastName}`);
expect(data.bookingFieldsResponses.name).toEqual(splitName);
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibily recurring bookings"
);
}
});
});
it("should should get a seated booking with split name responses", async () => {
return request(app.getHttpServer())
.get(`/v2/bookings/${seatedBookingWithSplitName.uid}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: GetBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsBooking(responseBody.data)).toBe(true);
if (responseDataIsBooking(responseBody.data)) {
// eslint-disable-next-line
// @ts-ignore
const data: GetSeatedBookingOutput_2024_08_13 = responseBody.data;
console.log("asap data", JSON.stringify(data, null, 2));
expect(data.attendees[0].name).toEqual(`${splitName.firstName} ${splitName.lastName}`);
expect(data.attendees[0].bookingFieldsResponses.name).toEqual(splitName);
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibily recurring bookings"
);
}
});
});
});
describe("make booking", () => {
it("should not be able to book an event type with custom required booking fields if they are missing in bookingFieldsResponses", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(),
eventTypeId: eventTypeWithBookingFieldsId,
attendee: {
name: "Mr Proper",
email: "mr_proper@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
location: "https://meet.google.com/abc-def-ghi",
};
return request(app.getHttpServer())
.post(`/v2/bookings`)
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(400);
});
it("should be able to book an event type with custom required booking fields", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(),
eventTypeId: eventTypeWithBookingFieldsId,
attendee: {
name: "Mr Proper",
email: "mr_proper@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
location: "https://meet.google.com/abc-def-ghi",
bookingFieldsResponses: {
"favorite-movie": "lord of the rings",
},
};
return request(app.getHttpServer())
.post(`/v2/bookings`)
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201)
.then(async (response) => {
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsBooking(responseBody.data)).toBe(true);
if (responseDataIsBooking(responseBody.data)) {
const data: BookingOutput_2024_08_13 = responseBody.data;
expect(data.bookingFieldsResponses["favorite-movie"]).toEqual("lord of the rings");
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibily recurring bookings"
);
}
});
});
});
afterAll(async () => {
await oauthClientRepositoryFixture.delete(oAuthClient.id);
await teamRepositoryFixture.delete(organization.id);
await userRepositoryFixture.deleteByEmail(user.email);
await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email);
await app.close();
});
});
function responseDataIsBooking(data: any): data is BookingOutput_2024_08_13 {
return !Array.isArray(data) && typeof data === "object" && data && "id" in data;
}
});
@@ -34,6 +34,7 @@ import {
OrganizerCancelledEmail,
AttendeeCancelledEmail,
OrganizerReassignedEmail,
AttendeeUpdatedEmail,
} from "@calcom/platform-libraries";
import {
CreateBookingInput_2024_08_13,
@@ -61,10 +62,12 @@ jest
jest
.spyOn(OrganizerCancelledEmail.prototype, "getHtml")
.mockImplementation(() => Promise.resolve("<p>email</p>"));
jest
.spyOn(OrganizerReassignedEmail.prototype, "getHtml")
.mockImplementation(() => Promise.resolve("<p>email</p>"));
jest
.spyOn(AttendeeUpdatedEmail.prototype, "getHtml")
.mockImplementation(() => Promise.resolve("<p>email</p>"));
type EmailSetup = {
team: Team;
@@ -681,6 +684,7 @@ describe("Bookings Endpoints 2024-08-13 team emails", () => {
expect(responseBody.data).toBeDefined();
expect(AttendeeCancelledEmail.prototype.getHtml).not.toHaveBeenCalled();
expect(OrganizerScheduledEmail.prototype.getHtml).not.toHaveBeenCalled();
expect(AttendeeUpdatedEmail.prototype.getHtml).not.toHaveBeenCalled();
emailsDisabledSetup.roundRobinEventType.currentHostId = reassignToId;
});
});
@@ -697,6 +701,7 @@ describe("Bookings Endpoints 2024-08-13 team emails", () => {
expect(AttendeeCancelledEmail.prototype.getHtml).not.toHaveBeenCalled();
expect(OrganizerScheduledEmail.prototype.getHtml).not.toHaveBeenCalled();
expect(OrganizerReassignedEmail.prototype.getHtml).not.toHaveBeenCalled();
expect(AttendeeUpdatedEmail.prototype.getHtml).not.toHaveBeenCalled();
});
});
});
@@ -898,6 +903,7 @@ describe("Bookings Endpoints 2024-08-13 team emails", () => {
expect(responseBody.data).toBeDefined();
expect(AttendeeCancelledEmail.prototype.getHtml).not.toHaveBeenCalled();
expect(OrganizerScheduledEmail.prototype.getHtml).toHaveBeenCalled();
expect(AttendeeUpdatedEmail.prototype.getHtml).toHaveBeenCalled();
emailsDisabledSetup.roundRobinEventType.currentHostId = reassignToId;
});
});
@@ -914,6 +920,7 @@ describe("Bookings Endpoints 2024-08-13 team emails", () => {
expect(AttendeeCancelledEmail.prototype.getHtml).not.toHaveBeenCalled();
expect(OrganizerScheduledEmail.prototype.getHtml).toHaveBeenCalled();
expect(OrganizerReassignedEmail.prototype.getHtml).toHaveBeenCalled();
expect(AttendeeUpdatedEmail.prototype.getHtml).toHaveBeenCalled();
});
});
});
@@ -181,6 +181,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
expect(firstBooking.eventTypeId).toEqual(recurringEventTypeId);
expect(firstBooking.attendees[0]).toEqual({
name: body.attendee.name,
email: body.attendee.email,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
@@ -201,6 +202,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
expect(secondBooking.recurringBookingUid).toBeDefined();
expect(secondBooking.attendees[0]).toEqual({
name: body.attendee.name,
email: body.attendee.email,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
@@ -256,6 +258,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
expect(firstBooking.eventTypeId).toEqual(recurringEventTypeId);
expect(firstBooking.attendees[0]).toEqual({
name: body.attendee.name,
email: body.attendee.email,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
@@ -277,6 +280,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
expect(secondBooking.recurringBookingUid).toBeDefined();
expect(secondBooking.attendees[0]).toEqual({
name: body.attendee.name,
email: body.attendee.email,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
@@ -296,6 +300,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
expect(thirdBooking.recurringBookingUid).toBeDefined();
expect(thirdBooking.attendees[0]).toEqual({
name: body.attendee.name,
email: body.attendee.email,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
@@ -205,6 +205,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
expect(data.attendees.length).toEqual(1);
expect(data.attendees[0]).toEqual({
name: body.attendee.name,
email: body.attendee.email,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
@@ -278,6 +279,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
const firstAttendee = data.attendees.find((attendee) => attendee.name === nameAttendeeOne);
expect(firstAttendee).toEqual({
name: createdSeatedBooking.attendees[0].name,
email: createdSeatedBooking.attendees[0].email,
timeZone: createdSeatedBooking.attendees[0].timeZone,
language: createdSeatedBooking.attendees[0].language,
absent: false,
@@ -291,6 +293,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
const secondAttendee = data.attendees.find((attendee) => attendee.name === nameAttendeeTwo);
expect(secondAttendee).toEqual({
name: body.attendee.name,
email: body.attendee.email,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
@@ -368,6 +371,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
expect(firstBooking.attendees.length).toEqual(1);
expect(firstBooking.attendees[0]).toEqual({
name: body.attendee.name,
email: body.attendee.email,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
@@ -402,6 +406,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
expect(secondBooking.attendees.length).toEqual(1);
expect(secondBooking.attendees[0]).toEqual({
name: body.attendee.name,
email: body.attendee.email,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
@@ -576,6 +581,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
const attendee = createdSeatedBooking.attendees.find((a) => a.seatUid === body.seatUid);
expect(data.attendees[0]).toEqual({
name: attendee?.name,
email: attendee?.email,
timeZone: attendee?.timeZone,
language: attendee?.language,
absent: false,
@@ -835,6 +841,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
expect(firstBooking.attendees.length).toEqual(1);
expect(firstBooking.attendees[0]).toEqual({
name: body.attendee.name,
email: body.attendee.email,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
@@ -869,6 +876,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
expect(secondBooking.attendees.length).toEqual(1);
expect(secondBooking.attendees[0]).toEqual({
name: body.attendee.name,
email: body.attendee.email,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
@@ -946,6 +954,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
expect(firstBooking.attendees.length).toEqual(2);
expect(firstBooking.attendees[0]).toEqual({
name: nameAttendeeOne,
email: emailAttendeeOne,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
@@ -958,6 +967,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
});
expect(firstBooking.attendees[1]).toEqual({
name: nameAttendeeTwo,
email: emailAttendeeTwo,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
@@ -992,6 +1002,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
expect(secondBooking.attendees.length).toEqual(2);
expect(secondBooking.attendees[0]).toEqual({
name: nameAttendeeOne,
email: emailAttendeeOne,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
@@ -1004,6 +1015,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
});
expect(secondBooking.attendees[1]).toEqual({
name: nameAttendeeTwo,
email: emailAttendeeTwo,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
@@ -307,6 +307,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
expect(data.attendees.length).toEqual(1);
expect(data.attendees[0]).toEqual({
name: body.attendee.name,
email: body.attendee.email,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
@@ -359,12 +360,14 @@ describe("Bookings Endpoints 2024-08-13", () => {
expect(data.attendees.length).toEqual(2);
expect(data.attendees[0]).toEqual({
name: body.attendee.name,
email: body.attendee.email,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
});
expect(data.attendees[1]).toEqual({
name: teamUser2.name,
email: teamUser2.email,
timeZone: teamUser2.timeZone,
language: teamUser2.locale,
absent: false,
@@ -96,6 +96,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
user = await userRepositoryFixture.create({
email: userEmail,
username: `bob-${Math.floor(Math.random() * 1000)}@gmail.com`,
platformOAuthClients: {
connect: {
id: oAuthClient.id,
@@ -351,6 +352,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
expect(data.id).toBeDefined();
expect(data.uid).toBeDefined();
expect(data.hosts[0].id).toEqual(user.id);
expect(data.hosts[0].username).toEqual(user.username);
expect(data.status).toEqual("accepted");
expect(data.start).toEqual(body.start);
expect(data.end).toEqual(new Date(Date.UTC(2030, 0, 8, 14, 0, 0)).toISOString());
@@ -362,6 +364,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
});
expect(data.attendees[0]).toEqual({
name: body.attendee.name,
email: body.attendee.email,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
@@ -432,6 +435,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
expect(firstBooking.eventTypeId).toEqual(recurringEventTypeId);
expect(firstBooking.attendees[0]).toEqual({
name: body.attendee.name,
email: body.attendee.email,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
@@ -453,6 +457,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
expect(secondBooking.recurringBookingUid).toBeDefined();
expect(secondBooking.attendees[0]).toEqual({
name: body.attendee.name,
email: body.attendee.email,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
@@ -473,6 +478,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
expect(thirdBooking.recurringBookingUid).toBeDefined();
expect(thirdBooking.attendees[0]).toEqual({
name: body.attendee.name,
email: body.attendee.email,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
@@ -508,6 +514,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
expect(data.id).toEqual(createdBooking.id);
expect(data.uid).toEqual(createdBooking.uid);
expect(data.hosts[0].id).toEqual(user.id);
expect(data.hosts[0].username).toEqual(user.username);
expect(data.status).toEqual(createdBooking.status);
expect(data.start).toEqual(createdBooking.start);
expect(data.end).toEqual(createdBooking.end);
@@ -10,7 +10,12 @@ import {
RecurringBookingOutput_2024_08_13,
} from "@calcom/platform-types";
@ApiExtraModels(BookingOutput_2024_08_13, RecurringBookingOutput_2024_08_13)
@ApiExtraModels(
BookingOutput_2024_08_13,
RecurringBookingOutput_2024_08_13,
GetSeatedBookingOutput_2024_08_13,
GetRecurringSeatedBookingOutput_2024_08_13
)
export class CancelBookingOutput_2024_08_13 {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
@@ -10,7 +10,12 @@ import {
CreateRecurringSeatedBookingOutput_2024_08_13,
} from "@calcom/platform-types";
@ApiExtraModels(BookingOutput_2024_08_13, RecurringBookingOutput_2024_08_13)
@ApiExtraModels(
BookingOutput_2024_08_13,
RecurringBookingOutput_2024_08_13,
CreateSeatedBookingOutput_2024_08_13,
CreateRecurringSeatedBookingOutput_2024_08_13
)
export class CreateBookingOutput_2024_08_13 {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
@@ -21,6 +26,8 @@ export class CreateBookingOutput_2024_08_13 {
oneOf: [
{ $ref: getSchemaPath(BookingOutput_2024_08_13) },
{ type: "array", items: { $ref: getSchemaPath(RecurringBookingOutput_2024_08_13) } },
{ $ref: getSchemaPath(CreateSeatedBookingOutput_2024_08_13) },
{ type: "array", items: { $ref: getSchemaPath(CreateRecurringSeatedBookingOutput_2024_08_13) } },
],
description:
"Booking data, which can be either a BookingOutput object or an array of RecurringBookingOutput objects",
@@ -12,7 +12,12 @@ import {
RecurringBookingOutput_2024_08_13,
} from "@calcom/platform-types";
@ApiExtraModels(BookingOutput_2024_08_13, RecurringBookingOutput_2024_08_13)
@ApiExtraModels(
BookingOutput_2024_08_13,
RecurringBookingOutput_2024_08_13,
CreateSeatedBookingOutput_2024_08_13,
CreateRecurringSeatedBookingOutput_2024_08_13
)
export class RescheduleBookingOutput_2024_08_13 {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
@@ -10,6 +10,7 @@ import { UsersRepository, UserWithProfile } from "@/modules/users/users.reposito
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
import { BadRequestException } from "@nestjs/common";
import { Request } from "express";
import { z } from "zod";
import {
handleNewBooking,
@@ -38,6 +39,7 @@ import {
CancelBookingInput,
} from "@calcom/platform-types";
import { PrismaClient } from "@calcom/prisma";
import { EventType } from "@calcom/prisma/client";
type CreatedBooking = {
hosts: { id: number }[];
@@ -45,6 +47,14 @@ type CreatedBooking = {
start: string;
};
const eventTypeBookingFieldSchema = z.object({
name: z.string(),
required: z.boolean(),
editable: z.string(),
});
const eventTypeBookingFieldsSchema = z.array(eventTypeBookingFieldSchema);
@Injectable()
export class BookingsService_2024_08_13 {
private readonly logger = new Logger("BookingsService");
@@ -70,6 +80,8 @@ export class BookingsService_2024_08_13 {
const isRecurring = !!eventType?.recurringEvent;
const isSeated = !!eventType?.seatsPerTimeSlot;
await this.hasRequiredBookingFieldsResponses(body, eventType);
if (isRecurring && isSeated) {
return await this.createRecurringSeatedBooking(request, body);
}
@@ -91,6 +103,28 @@ export class BookingsService_2024_08_13 {
}
}
async hasRequiredBookingFieldsResponses(body: CreateBookingInput, eventType: EventType | null) {
const bookingFields = body.bookingFieldsResponses;
if (!bookingFields || !eventType || !eventType.bookingFields) {
return true;
}
// note(Lauris): we filter out system fields, because some of them are set by default and name and email are passed in the body.attendee
const eventTypeBookingFields = eventTypeBookingFieldsSchema
.parse(eventType.bookingFields)
.filter((field) => !field.editable.startsWith("system"));
for (const field of eventTypeBookingFields) {
if (field.required && !(field.name in bookingFields)) {
throw new BadRequestException(`
Missing required booking field response: ${field.name} - it is required by the event type booking fields, but missing in the bookingFieldsResponses.
You can fetch the event type with ID ${eventType.id} to see the required fields.`);
}
}
return true;
}
async createInstantBooking(request: Request, body: CreateInstantBookingInput_2024_08_13) {
const bookingRequest = await this.inputService.createBookingRequest(request, body);
const booking = await handleInstantMeeting(bookingRequest);
@@ -329,12 +363,17 @@ export class BookingsService_2024_08_13 {
async markAbsent(bookingUid: string, bookingOwnerId: number, body: MarkAbsentBookingInput_2024_08_13) {
const bodyTransformed = this.inputService.transformInputMarkAbsentBooking(body);
const bookingBefore = await this.bookingsRepository.getByUid(bookingUid);
const platformClientParams = bookingBefore?.eventTypeId
? await this.inputService.getOAuthClientParams(bookingBefore.eventTypeId)
: undefined;
await handleMarkNoShow({
bookingUid,
attendees: bodyTransformed.attendees,
noShowHost: bodyTransformed.noShowHost,
userId: bookingOwnerId,
platformClientParams,
});
const booking = await this.bookingsRepository.getByUidWithAttendeesAndUserAndEvent(bookingUid);
@@ -389,7 +428,11 @@ export class BookingsService_2024_08_13 {
throw new NotFoundException(`Booking with uid=${bookingUid} was not found in the database`);
}
const emailsEnabled = booking.eventTypeId ? await this.getEmailsEnabled(booking.eventTypeId) : true;
const platformClientParams = booking.eventTypeId
? await this.inputService.getOAuthClientParams(booking.eventTypeId)
: undefined;
const emailsEnabled = platformClientParams ? platformClientParams.arePlatformEmailsEnabled : true;
const profile = this.usersService.getUserMainProfile(requestUser);
@@ -397,6 +440,7 @@ export class BookingsService_2024_08_13 {
bookingId: booking.id,
orgId: profile?.organizationId || null,
emailsEnabled,
platformClientParams,
});
const reassigned = await this.bookingsRepository.getByUidWithUser(bookingUid);
@@ -407,12 +451,6 @@ export class BookingsService_2024_08_13 {
return this.outputService.getOutputReassignedBooking(reassigned);
}
async getEmailsEnabled(eventTypeId: number) {
const oAuthParams = await this.inputService.getOAuthClientParams(eventTypeId);
const emailsEnabled = oAuthParams ? oAuthParams.arePlatformEmailsEnabled : true;
return emailsEnabled;
}
async reassignBookingToUser(
bookingUid: string,
newUserId: number,
@@ -429,7 +467,11 @@ export class BookingsService_2024_08_13 {
throw new NotFoundException(`User with id=${newUserId} was not found in the database`);
}
const emailsEnabled = booking.eventTypeId ? await this.getEmailsEnabled(booking.eventTypeId) : true;
const platformClientParams = booking.eventTypeId
? await this.inputService.getOAuthClientParams(booking.eventTypeId)
: undefined;
const emailsEnabled = platformClientParams ? platformClientParams.arePlatformEmailsEnabled : true;
const profile = this.usersService.getUserMainProfile(user);
@@ -440,6 +482,7 @@ export class BookingsService_2024_08_13 {
reassignReason: body.reason,
reassignedById,
emailsEnabled,
platformClientParams,
});
return this.outputService.getOutputReassignedBooking(reassigned);
@@ -451,7 +494,11 @@ export class BookingsService_2024_08_13 {
throw new NotFoundException(`Booking with uid=${bookingUid} was not found in the database`);
}
const emailsEnabled = booking.eventTypeId ? await this.getEmailsEnabled(booking.eventTypeId) : true;
const platformClientParams = booking.eventTypeId
? await this.inputService.getOAuthClientParams(booking.eventTypeId)
: undefined;
const emailsEnabled = platformClientParams ? platformClientParams.arePlatformEmailsEnabled : true;
await confirmBookingHandler({
ctx: {
@@ -462,6 +509,7 @@ export class BookingsService_2024_08_13 {
confirmed: true,
recurringEventId: booking.recurringEventId,
emailsEnabled,
platformClientParams,
},
});
@@ -474,7 +522,11 @@ export class BookingsService_2024_08_13 {
throw new NotFoundException(`Booking with uid=${bookingUid} was not found in the database`);
}
const emailsEnabled = booking.eventTypeId ? await this.getEmailsEnabled(booking.eventTypeId) : true;
const platformClientParams = booking.eventTypeId
? await this.inputService.getOAuthClientParams(booking.eventTypeId)
: undefined;
const emailsEnabled = platformClientParams ? platformClientParams.arePlatformEmailsEnabled : true;
await confirmBookingHandler({
ctx: {
@@ -486,6 +538,7 @@ export class BookingsService_2024_08_13 {
recurringEventId: booking.recurringEventId,
reason,
emailsEnabled,
platformClientParams,
},
});
@@ -1,7 +1,7 @@
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
import {
bookingResponsesSchema,
seatedBookingResponsesSchema,
seatedBookingDataSchema,
} from "@/ee/bookings/2024-08-13/services/output.service";
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key";
@@ -340,7 +340,7 @@ export class InputBookingsService_2024_08_13 {
throw new NotFoundException(`Seat with uid=${inputBooking.seatUid} does not exist.`);
}
const { responses: bookingResponses } = seatedBookingResponsesSchema.parse(seat.data);
const { responses: bookingResponses } = seatedBookingDataSchema.parse(seat.data);
const attendee = booking.attendees.find((attendee) => attendee.email === bookingResponses.email);
if (!attendee) {
@@ -1,4 +1,11 @@
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
import {
defaultBookingMetadata,
defaultBookingResponses,
defaultSeatedBookingData,
defaultSeatedBookingMetadata,
} from "@/lib/safe-parse/default-responses-booking";
import { safeParse } from "@/lib/safe-parse/safe-parse";
import { Injectable } from "@nestjs/common";
import { plainToClass } from "class-transformer";
import { DateTime } from "luxon";
@@ -20,22 +27,40 @@ import { Booking, BookingSeat } from "@calcom/prisma/client";
export const bookingResponsesSchema = z
.object({
email: z.string(),
name: z.string(),
name: z.union([
z.string(),
z.object({
firstName: z.string(),
lastName: z.string(),
}),
]),
guests: z.array(z.string()).optional(),
rescheduleReason: z.string().optional(),
})
.passthrough();
.passthrough()
.describe("BookingResponses");
export const seatedBookingResponsesSchema = z
export const seatedBookingDataSchema = z
.object({
responses: z
.object({
email: z.string(),
name: z.string(),
name: z.union([
z.string(),
z.object({
firstName: z.string(),
lastName: z.string(),
}),
]),
})
.passthrough(),
})
.passthrough();
.passthrough()
.describe("SeatedBookingData");
const seatedBookingMetadataSchema = z.object({}).catchall(z.string()).describe("SeatedBookingMetadata");
type DatabaseUser = { id: number; name: string | null; email: string; username: string | null };
type DatabaseBooking = Booking & {
eventType: {
@@ -50,16 +75,14 @@ type DatabaseBooking = Booking & {
noShow: boolean | null;
bookingSeat?: BookingSeat | null;
}[];
user: { id: number; name: string | null; email: string } | null;
user: DatabaseUser | null;
createdAt: Date;
};
type BookingWithUser = Booking & { user: { id: number; name: string | null; email: string } | null };
type BookingWithUser = Booking & { user: DatabaseUser | null };
type DatabaseMetadata = z.infer<typeof bookingMetadataSchema>;
const seatedBookingMetadataSchema = z.object({}).catchall(z.string());
@Injectable()
export class OutputBookingsService_2024_08_13 {
constructor(private readonly bookingsRepository: BookingsRepository_2024_08_13) {}
@@ -68,8 +91,12 @@ export class OutputBookingsService_2024_08_13 {
const dateStart = DateTime.fromISO(databaseBooking.startTime.toISOString());
const dateEnd = DateTime.fromISO(databaseBooking.endTime.toISOString());
const duration = dateEnd.diff(dateStart, "minutes").minutes;
const bookingResponses = bookingResponsesSchema.parse(databaseBooking.responses);
const metadata = bookingMetadataSchema.parse(databaseBooking.metadata);
const bookingResponses = safeParse(
bookingResponsesSchema,
databaseBooking.responses,
defaultBookingResponses
);
const metadata = safeParse(bookingMetadataSchema, databaseBooking.metadata, defaultBookingMetadata);
const location = metadata?.videoCallUrl || databaseBooking.location;
const booking = {
@@ -77,7 +104,7 @@ export class OutputBookingsService_2024_08_13 {
uid: databaseBooking.uid,
title: databaseBooking.title,
description: databaseBooking.description,
hosts: [databaseBooking.user],
hosts: [this.getHost(databaseBooking.user)],
status: databaseBooking.status.toLowerCase(),
cancellationReason: databaseBooking.cancellationReason || undefined,
reschedulingReason: bookingResponses?.rescheduledReason,
@@ -118,6 +145,22 @@ export class OutputBookingsService_2024_08_13 {
return userDefinedMetadata;
}
getHost(user: DatabaseUser | null) {
if (!user) {
return {
id: "unknown",
name: "unknown",
email: "unknown",
username: "unknown",
};
}
return {
...user,
username: user.username || "unknown",
};
}
async getOutputRecurringBookings(bookingsIds: number[]) {
const transformed = [];
@@ -137,8 +180,12 @@ export class OutputBookingsService_2024_08_13 {
const dateStart = DateTime.fromISO(databaseBooking.startTime.toISOString());
const dateEnd = DateTime.fromISO(databaseBooking.endTime.toISOString());
const duration = dateEnd.diff(dateStart, "minutes").minutes;
const bookingResponses = bookingResponsesSchema.parse(databaseBooking.responses);
const metadata = bookingMetadataSchema.parse(databaseBooking.metadata);
const bookingResponses = safeParse(
bookingResponsesSchema,
databaseBooking.responses,
defaultBookingResponses
);
const metadata = safeParse(bookingMetadataSchema, databaseBooking.metadata, defaultBookingMetadata);
const location = metadata?.videoCallUrl || databaseBooking.location;
const booking = {
@@ -146,7 +193,7 @@ export class OutputBookingsService_2024_08_13 {
uid: databaseBooking.uid,
title: databaseBooking.title,
description: databaseBooking.description,
hosts: [databaseBooking.user],
hosts: [this.getHost(databaseBooking.user)],
status: databaseBooking.status.toLowerCase(),
cancellationReason: databaseBooking.cancellationReason || undefined,
reschedulingReason: bookingResponses?.rescheduledReason,
@@ -195,7 +242,7 @@ export class OutputBookingsService_2024_08_13 {
const dateStart = DateTime.fromISO(databaseBooking.startTime.toISOString());
const dateEnd = DateTime.fromISO(databaseBooking.endTime.toISOString());
const duration = dateEnd.diff(dateStart, "minutes").minutes;
const metadata = bookingMetadataSchema.parse(databaseBooking.metadata);
const metadata = safeParse(bookingMetadataSchema, databaseBooking.metadata, defaultBookingMetadata);
const location = metadata?.videoCallUrl || databaseBooking.location;
const booking = {
@@ -203,7 +250,7 @@ export class OutputBookingsService_2024_08_13 {
uid: databaseBooking.uid,
title: databaseBooking.title,
description: databaseBooking.description,
hosts: [databaseBooking.user],
hosts: [this.getHost(databaseBooking.user)],
status: databaseBooking.status.toLowerCase(),
rescheduledFromUid: databaseBooking.fromReschedule || undefined,
start: databaseBooking.startTime,
@@ -224,7 +271,11 @@ export class OutputBookingsService_2024_08_13 {
// note(Lauris): I don't know why plainToClass erases booking.attendees[n].responses so attaching manually
parsed.attendees = databaseBooking.attendees.map((attendee) => {
const { responses } = seatedBookingResponsesSchema.parse(attendee.bookingSeat?.data);
const { responses } = safeParse(
seatedBookingDataSchema,
attendee.bookingSeat?.data,
defaultSeatedBookingData
);
const attendeeData = {
name: attendee.name,
@@ -237,7 +288,11 @@ export class OutputBookingsService_2024_08_13 {
};
const attendeeParsed = plainToClass(SeatedAttendee, attendeeData, { strategy: "excludeAll" });
attendeeParsed.bookingFieldsResponses = responses || {};
attendeeParsed.metadata = seatedBookingMetadataSchema.parse(attendee.bookingSeat?.metadata);
attendeeParsed.metadata = safeParse(
seatedBookingMetadataSchema,
attendee.bookingSeat?.metadata,
defaultSeatedBookingMetadata
);
// note(Lauris): as of now email is not returned for privacy
delete attendeeParsed.bookingFieldsResponses.email;
@@ -290,7 +345,7 @@ export class OutputBookingsService_2024_08_13 {
const dateStart = DateTime.fromISO(databaseBooking.startTime.toISOString());
const dateEnd = DateTime.fromISO(databaseBooking.endTime.toISOString());
const duration = dateEnd.diff(dateStart, "minutes").minutes;
const metadata = bookingMetadataSchema.parse(databaseBooking.metadata);
const metadata = safeParse(bookingMetadataSchema, databaseBooking.metadata, defaultBookingMetadata);
const location = metadata?.videoCallUrl || databaseBooking.location;
const booking = {
@@ -298,7 +353,7 @@ export class OutputBookingsService_2024_08_13 {
uid: databaseBooking.uid,
title: databaseBooking.title,
description: databaseBooking.description,
hosts: [databaseBooking.user],
hosts: [this.getHost(databaseBooking.user)],
status: databaseBooking.status.toLowerCase(),
cancellationReason: databaseBooking.cancellationReason || undefined,
rescheduledFromUid: databaseBooking.fromReschedule || undefined,
@@ -323,7 +378,11 @@ export class OutputBookingsService_2024_08_13 {
// note(Lauris): I don't know why plainToClass erases booking.attendees[n].responses so attaching manually
parsed.attendees = databaseBooking.attendees.map((attendee) => {
const { responses } = seatedBookingResponsesSchema.parse(attendee.bookingSeat?.data);
const { responses } = safeParse(
seatedBookingDataSchema,
attendee.bookingSeat?.data,
defaultSeatedBookingData
);
const attendeeData = {
name: attendee.name,
@@ -336,7 +395,11 @@ export class OutputBookingsService_2024_08_13 {
};
const attendeeParsed = plainToClass(SeatedAttendee, attendeeData, { strategy: "excludeAll" });
attendeeParsed.bookingFieldsResponses = responses || {};
attendeeParsed.metadata = seatedBookingMetadataSchema.parse(attendee.bookingSeat?.metadata);
attendeeParsed.metadata = safeParse(
seatedBookingMetadataSchema,
attendee.bookingSeat?.metadata,
defaultSeatedBookingMetadata
);
// note(Lauris): as of now email is not returned for privacy
delete attendeeParsed.bookingFieldsResponses.email;
return attendeeParsed;
@@ -113,7 +113,7 @@ describe("Platform Calendars Endpoints", () => {
});
// TODO: Uncomment this once CI is ready to run proper Office365 tests
// it(`/GET/v2/calendars/${OFFICE_365_CALENDAR}/connect: it should redirect to auth-url for office 365 calendar oauth with valid access token `, async () => {
// it(`/GET/v2/calendars/${OFFICE_365_CALENDAR}/connect: it should redirect to auth-url for office 365 calendar OAuth with valid access token `, async () => {
// const response = await request(app.getHttpServer())
// .get(`/v2/calendars/${OFFICE_365_CALENDAR}/connect`)
// .set("Authorization", `Bearer ${accessTokenSecret}`)
@@ -123,7 +123,7 @@ describe("Platform Calendars Endpoints", () => {
// expect(data.authUrl).toBeDefined();
// });
it(`/GET/v2/calendars/${GOOGLE_CALENDAR}/connect: it should redirect to auth-url for google calendar oauth with valid access token `, async () => {
it(`/GET/v2/calendars/${GOOGLE_CALENDAR}/connect: it should redirect to auth-url for google calendar OAuth with valid access token `, async () => {
const response = await request(app.getHttpServer())
.get(`/v2/calendars/${GOOGLE_CALENDAR}/connect`)
.set("Authorization", `Bearer ${accessTokenSecret}`)
@@ -4,13 +4,14 @@ import { AppsRepository } from "@/modules/apps/apps.repository";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { calendar_v3 } from "@googleapis/calendar";
import { Logger, NotFoundException } from "@nestjs/common";
import { BadRequestException, UnauthorizedException } from "@nestjs/common";
import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Prisma } from "@prisma/client";
import { Request } from "express";
import { google } from "googleapis";
import { OAuth2Client } from "googleapis-common";
import { z } from "zod";
import { SUCCESS_STATUS, GOOGLE_CALENDAR_TYPE } from "@calcom/platform-constants";
@@ -78,7 +79,7 @@ export class GoogleCalendarService implements OAuthCalendarApp {
const { client_id, client_secret } = this.gcalResponseSchema.parse(app.keys);
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirectUri);
const oAuth2Client = new OAuth2Client(client_id, client_secret, redirectUri);
return oAuth2Client;
}
@@ -90,7 +91,7 @@ export class GoogleCalendarService implements OAuthCalendarApp {
}
if (gcalCredentials.invalid) {
throw new BadRequestException("Invalid google oauth credentials.");
throw new BadRequestException("Invalid google OAuth credentials.");
}
const { connectedCalendars } = await this.calendarsService.getCalendars(userId);
@@ -134,8 +135,7 @@ export class GoogleCalendarService implements OAuthCalendarApp {
oAuth2Client.setCredentials(key);
const calendar = google.calendar({
version: "v3",
const calendar = new calendar_v3.Calendar({
auth: oAuth2Client,
});
@@ -78,4 +78,11 @@ export class EventTypesRepository_2024_04_15 {
async deleteEventType(eventTypeId: number) {
return this.dbWrite.prisma.eventType.delete({ where: { id: eventTypeId } });
}
async getEventTypeWithDuration(eventTypeId: number) {
return this.dbRead.prisma.eventType.findUnique({
where: { id: eventTypeId },
select: { length: true },
});
}
}
@@ -10,10 +10,12 @@ import { UsersModule } from "@/modules/users/users.module";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import { PlatformOAuthClient, Team, User, Schedule } from "@prisma/client";
import { PlatformOAuthClient, Team, User, Schedule, EventType } 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 { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
import { SchedulesRepositoryFixture } from "test/fixtures/repository/schedules.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
@@ -75,18 +77,22 @@ describe("Event types Endpoints", () => {
let teamRepositoryFixture: TeamRepositoryFixture;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let schedulesRepostoryFixture: SchedulesRepositoryFixture;
let profileRepositoryFixture: ProfileRepositoryFixture;
let membershipsRepositoryFixture: MembershipRepositoryFixture;
const userEmail = "event-types-test-e2e@api.com";
const falseTestEmail = "false-event-types@api.com";
const name = "bob-the-builder";
const username = name;
let eventType: EventTypeOutput_2024_06_14;
let user: User;
let orgUser: User;
let falseTestUser: User;
let firstSchedule: Schedule;
let secondSchedule: Schedule;
let falseTestSchedule: Schedule;
let orgUserEventType1: EventType;
let orgUserEventType2: EventType;
let orgUserEventType3: EventType;
beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmail,
@@ -109,8 +115,12 @@ describe("Event types Endpoints", () => {
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
schedulesRepostoryFixture = new SchedulesRepositoryFixture(moduleRef);
organization = await teamRepositoryFixture.create({ name: "organization" });
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
organization = await teamRepositoryFixture.create({
name: "organization",
slug: "event-type-2024-06-14-org-slug",
});
oAuthClient = await createOAuthClient(organization.id);
user = await userRepositoryFixture.create({
email: userEmail,
@@ -118,6 +128,49 @@ describe("Event types Endpoints", () => {
username,
});
orgUser = await userRepositoryFixture.create({
email: "event-types-2024-06-14-org-user@example.com",
name: "event-types-2024-06-14-org-user",
username: "event-types-2024-06-14-org-user",
});
profileRepositoryFixture.create({
uid: `usr-${orgUser.id}`,
username: orgUser.username as string,
organization: {
connect: {
id: organization.id,
},
},
user: {
connect: {
id: orgUser.id,
},
},
});
orgUserEventType1 = await eventTypesRepositoryFixture.create(
{ title: "orgUserEventType1", slug: "org-event-type-1", length: 60, locations: [] },
orgUser.id
);
orgUserEventType2 = await eventTypesRepositoryFixture.create(
{ title: "orgUserEventType2", slug: "org-event-type-2", length: 60, locations: [] },
orgUser.id
);
orgUserEventType3 = await eventTypesRepositoryFixture.create(
{ title: "orgUserEventType3", slug: "org-event-type-3", length: 60, locations: [] },
orgUser.id
);
await membershipsRepositoryFixture.create({
role: "MEMBER",
user: { connect: { id: orgUser.id } },
team: { connect: { id: organization.id } },
accepted: true,
});
falseTestUser = await userRepositoryFixture.create({
email: falseTestEmail,
name: "false-test",
@@ -342,7 +395,7 @@ describe("Event types Endpoints", () => {
});
});
it(`/GET/even-types by username`, async () => {
it(`/GET/event-types by username`, async () => {
const response = await request(app.getHttpServer())
.get(`/api/v2/event-types?username=${username}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
@@ -386,6 +439,61 @@ describe("Event types Endpoints", () => {
expect(fetchedEventType.color).toEqual(eventType.color);
});
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)
// note: bearer token value mocked using "withAccessTokenAuth" for user which id is used when creating event type above
.set("Authorization", `Bearer whatever`)
.expect(200);
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14[]> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data?.length).toEqual(3);
expect(responseBody.data?.find((e) => e.id === orgUserEventType1.id)?.id).toBeDefined();
expect(responseBody.data?.find((e) => e.id === orgUserEventType2.id)?.id).toBeDefined();
expect(responseBody.data?.find((e) => e.id === orgUserEventType3.id)?.id).toBeDefined();
});
it(`/GET/event-types by username and orgSlug and eventSlug`, async () => {
const response = await request(app.getHttpServer())
.get(
`/api/v2/event-types?username=${orgUser.username}&orgSlug=${organization.slug}&eventSlug=${orgUserEventType1.slug}`
)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
// note: bearer token value mocked using "withAccessTokenAuth" for user which id is used when creating event type above
.set("Authorization", `Bearer whatever`)
.expect(200);
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14[]> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data?.length).toEqual(1);
expect(responseBody.data?.find((e) => e.id === orgUserEventType1.id)?.id).toBeDefined();
});
it(`/GET/event-types by username and orgId`, async () => {
const response = await request(app.getHttpServer())
.get(`/api/v2/event-types?username=${orgUser.username}&orgId=${organization.id}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
// note: bearer token value mocked using "withAccessTokenAuth" for user which id is used when creating event type above
.set("Authorization", `Bearer whatever`)
.expect(200);
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14[]> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data?.length).toEqual(3);
expect(responseBody.data?.find((e) => e.id === orgUserEventType1.id)?.id).toBeDefined();
expect(responseBody.data?.find((e) => e.id === orgUserEventType2.id)?.id).toBeDefined();
expect(responseBody.data?.find((e) => e.id === orgUserEventType3.id)?.id).toBeDefined();
});
it("should return an error when creating an event type with seats enabled and multiple locations", async () => {
const body: CreateEventTypeInput_2024_06_14 = {
title: "Coding class 2",
@@ -919,6 +1027,12 @@ describe("Event types Endpoints", () => {
} catch (e) {
// User might have been deleted by the test
}
try {
await userRepositoryFixture.delete(orgUser.id);
} catch (e) {
// User might have been deleted by the test
}
await app.close();
});
});
@@ -1321,6 +1435,73 @@ describe("Event types Endpoints", () => {
});
});
it("should return event type with default bookingFields if they are not defined", async () => {
const eventTypeInput = {
title: "undefined booking fields",
description: "undefined booking fields",
length: 40,
hidden: false,
slug: "undefined-booking-fields",
locations: [],
schedulingType: SchedulingType.ROUND_ROBIN,
};
const eventType = await eventTypesRepositoryFixture.create(eventTypeInput, user.id);
return request(app.getHttpServer())
.get(`/api/v2/event-types/${eventType.id}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.expect(200)
.then(async (response) => {
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
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,
type: "radioInput",
slug: "location",
required: false,
},
{
isDefault: true,
type: "text",
slug: "title",
required: true,
},
{
isDefault: true,
type: "textarea",
slug: "notes",
required: false,
},
{
isDefault: true,
type: "multiemail",
slug: "guests",
required: false,
},
{
isDefault: true,
type: "textarea",
slug: "rescheduleReason",
required: false,
},
]);
});
});
afterAll(async () => {
await oauthClientRepositoryFixture.delete(oAuthClient.id);
await teamRepositoryFixture.delete(organization.id);
@@ -17,7 +17,7 @@ export class EventTypeResponseTransformPipe implements PipeTransform {
private transformEventType(eventType: EventTypeResponse): EventTypeOutput_2024_06_14 {
return plainToClass(
EventTypeOutput_2024_06_14,
this.outputEventTypesService.getResponseEventType(eventType.ownerId, eventType),
this.outputEventTypesService.getResponseEventType(eventType.ownerId, eventType, false),
{ strategy: "exposeAll" }
);
}
@@ -74,8 +74,13 @@ export class EventTypesService_2024_06_14 {
await this.checkUserOwnsSchedule(userId, body.scheduleId);
}
async getEventTypeByUsernameAndSlug(username: string, eventTypeSlug: string) {
const user = await this.usersRepository.findByUsername(username);
async getEventTypeByUsernameAndSlug(
username: string,
eventTypeSlug: string,
orgSlug?: string,
orgId?: number
) {
const user = await this.usersRepository.findByUsername(username, orgSlug, orgId);
if (!user) {
return null;
}
@@ -92,8 +97,8 @@ export class EventTypesService_2024_06_14 {
};
}
async getEventTypesByUsername(username: string) {
const user = await this.usersRepository.findByUsername(username);
async getEventTypesByUsername(username: string, orgSlug?: string, orgId?: number) {
const user = await this.usersRepository.findByUsername(username, orgSlug, orgId);
if (!user) {
return [];
}
@@ -152,27 +157,26 @@ export class EventTypesService_2024_06_14 {
}
async getEventTypes(queryParams: GetEventTypesQuery_2024_06_14) {
const { username, eventSlug, usernames } = queryParams;
const { username, eventSlug, usernames, orgSlug, orgId } = queryParams;
if (username && eventSlug) {
const eventType = await this.getEventTypeByUsernameAndSlug(username, eventSlug);
const eventType = await this.getEventTypeByUsernameAndSlug(username, eventSlug, orgSlug, orgId);
return eventType ? [eventType] : [];
}
if (username) {
return await this.getEventTypesByUsername(username);
return await this.getEventTypesByUsername(username, orgSlug, orgId);
}
if (usernames) {
const dynamicEventType = await this.getDynamicEventType(usernames);
const dynamicEventType = await this.getDynamicEventType(usernames, orgSlug, orgId);
return [dynamicEventType];
}
return [];
}
async getDynamicEventType(usernames: string[]) {
const users = await this.usersService.getByUsernames(usernames);
async getDynamicEventType(usernames: string[], orgSlug?: string, orgId?: number) {
const users = await this.usersService.getByUsernames(usernames, orgSlug, orgId);
const usersFiltered: UserWithProfile[] = [];
for (const user of users) {
if (user) {
@@ -21,6 +21,7 @@ import {
transformSeatsInternalToApi,
InternalLocation,
BookingFieldSchema,
getBookingFieldsWithSystemFields,
} from "@calcom/platform-libraries";
import {
TransformFutureBookingsLimitSchema_2024_06_14,
@@ -88,7 +89,11 @@ type Input = Pick<
@Injectable()
export class OutputEventTypesService_2024_06_14 {
getResponseEventType(ownerId: number, databaseEventType: Input): EventTypeOutput_2024_06_14 {
getResponseEventType(
ownerId: number,
databaseEventType: Input,
isOrgTeamEvent: boolean
): EventTypeOutput_2024_06_14 {
const {
id,
length,
@@ -122,7 +127,7 @@ export class OutputEventTypesService_2024_06_14 {
const customName = databaseEventType?.eventName ?? undefined;
const bookingFields = databaseEventType.bookingFields
? this.transformBookingFields(databaseEventType.bookingFields)
: [];
: this.getDefaultBookingFields(isOrgTeamEvent);
const recurrence = this.transformRecurringEvent(databaseEventType.recurringEvent);
const metadata = this.transformMetadata(databaseEventType.metadata) || {};
const users = this.transformUsers(databaseEventType.users || []);
@@ -240,6 +245,18 @@ export class OutputEventTypesService_2024_06_14 {
return [...transformBookingFieldsInternalToApi(knownBookingFields), ...unknownBookingFields];
}
getDefaultBookingFields(isOrgTeamEvent: boolean) {
const defaultBookingFields = getBookingFieldsWithSystemFields({
disableGuests: false,
bookingFields: null,
customInputs: [],
metadata: null,
workflows: [],
isOrgTeamEvent,
});
return this.transformBookingFields(defaultBookingFields);
}
transformRecurringEvent(recurringEvent: any) {
if (!recurringEvent) return null;
const recurringEventParsed = parseRecurringEvent(recurringEvent);
@@ -95,7 +95,7 @@ describe("Platform Gcal Endpoints", () => {
.expect(401);
});
it(`/GET/gcal/oauth/auth-url: it should auth-url to google oauth with valid access token `, async () => {
it(`/GET/gcal/oauth/auth-url: it should auth-url to google OAuth with valid access token `, async () => {
const response = await request(app.getHttpServer())
.get(`/v2/gcal/oauth/auth-url`)
.set("Authorization", `Bearer ${accessTokenSecret}`)
@@ -105,7 +105,7 @@ describe("Platform Gcal Endpoints", () => {
expect(data.authUrl).toBeDefined();
});
it(`/GET/gcal/oauth/save: without oauth code`, async () => {
it(`/GET/gcal/oauth/save: without OAuth code`, async () => {
await request(app.getHttpServer())
.get(
`/v2/gcal/oauth/save?state=accessToken=${accessTokenSecret}&origin%3D${CLIENT_REDIRECT_URI}&scope=https://www.googleapis.com/auth/calendar.readonly%20https://www.googleapis.com/auth/calendar.events`
+1 -4
View File
@@ -27,10 +27,7 @@ import {
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
import { Prisma } from "@prisma/client";
import { Request } from "express";
import { google } from "googleapis";
import { z } from "zod";
import { APPS_READ, GOOGLE_CALENDAR_TYPE, SUCCESS_STATUS } from "@calcom/platform-constants";
@@ -103,7 +100,7 @@ export class GcalController {
}
if (gcalCredentials.invalid) {
throw new BadRequestException("Invalid google oauth credentials.");
throw new BadRequestException("Invalid google OAuth credentials.");
}
const { connectedCalendars } = await this.calendarsService.getCalendars(userId);
@@ -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 { TeamsMembershipsModule } from "@/modules/teams/memberships/teams-memberships.module";
import type { MiddlewareConsumer, NestModule } from "@nestjs/common";
import { Module } from "@nestjs/common";
@@ -24,6 +25,7 @@ import { Module } from "@nestjs/common";
CalendarsModule,
BookingsModule_2024_04_15,
BookingsModule_2024_08_13,
TeamsMembershipsModule,
SlotsModule,
],
})
+3 -17
View File
@@ -1,6 +1,6 @@
import { WinstonTransport as AxiomTransport } from "@axiomhq/winston";
import type { LoggerOptions } from "winston";
import { format, transports as Transports } from "winston";
import { format, transports as Transports, config } from "winston";
import type Transport from "winston-transport";
const formattedTimestamp = format.timestamp({
@@ -8,14 +8,7 @@ const formattedTimestamp = format.timestamp({
});
const colorizer = format.colorize({
colors: {
fatal: "red",
error: "red",
warn: "yellow",
info: "blue",
debug: "white",
trace: "grey",
},
colors: config.npm.colors,
});
const WINSTON_DEV_FORMAT = format.combine(
@@ -26,14 +19,7 @@ const WINSTON_DEV_FORMAT = format.combine(
);
const WINSTON_PROD_FORMAT = format.combine(format.errors({ stack: true }), formattedTimestamp, format.json());
export const logLevels = {
fatal: 0,
error: 1,
warn: 2,
info: 3,
debug: 4,
trace: 5,
} as const;
export const logLevels = config.npm.levels;
export const loggerConfig = (): LoggerOptions => {
const isProduction = process.env.NODE_ENV === "production";
@@ -0,0 +1,12 @@
export const defaultBookingResponses = {
name: "unknown",
email: "unknown",
guests: [],
rescheduleReason: "unknown",
};
export const defaultBookingMetadata = { videoCallUrl: "unknown" };
export const defaultSeatedBookingData = { responses: { name: "unknown", email: "unknown" } };
export const defaultSeatedBookingMetadata = {};
@@ -0,0 +1,23 @@
import { Logger } from "@nestjs/common";
import { ZodSchema } from "zod";
const logger = new Logger("safeParse");
export function safeParse<T>(schema: ZodSchema<T>, value: unknown, defaultValue: T): T {
const result = schema.safeParse(value);
if (result.success) {
return result.data;
} else {
const errorStack = new Error().stack;
logger.error(
`Zod parsing failed.\n` +
`1. Schema: ${schema.description || "UnnamedSchema"}\n` +
`2. Input: ${JSON.stringify(value, null, 2)}\n` +
`3. Zod Error: ${result.error}\n` +
`4. Call Stack: ${errorStack}`
);
return defaultValue;
}
}
+8 -8
View File
@@ -56,7 +56,7 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
const request = context.switchToHttp().getRequest<Request>();
const response = context.switchToHttp().getResponse<Response>();
const tracker = await this.getTracker(request);
this.logger.log(
this.logger.verbose(
`Tracker "${tracker}" generated based on: Bearer token "${request.get(
"Authorization"
)}", OAuth client ID "${request.get(X_CAL_CLIENT_ID)}" and IP "${request.ip}"`
@@ -89,7 +89,7 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
private async handleNonApiKeyRequest(tracker: string, response: Response): Promise<boolean> {
const rateLimit = this.getDefaultRateLimit(tracker);
this.logger.log(`Tracker "${tracker}" uses default rate limits because it is not tracking api key:
this.logger.verbose(`Tracker "${tracker}" uses default rate limits because it is not tracking api key:
${JSON.stringify(rateLimit, null, 2)}
`);
@@ -135,7 +135,7 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
const cachedRateLimits = await this.storageService.redis.get(cacheKey);
if (cachedRateLimits) {
this.logger.log(`Tracker "${tracker}" rate limits retrieved from redis cache:
this.logger.verbose(`Tracker "${tracker}" rate limits retrieved from redis cache:
${cachedRateLimits}
`);
return rateLimitsSchema.parse(JSON.parse(cachedRateLimits));
@@ -158,13 +158,13 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
});
if (rateLimits) {
this.logger.log(`Tracker "${tracker}" rate limits retrieved from database:
this.logger.verbose(`Tracker "${tracker}" rate limits retrieved from database:
${JSON.stringify(rateLimits, null, 2)}`);
}
if (!rateLimits || rateLimits.length === 0) {
rateLimits = [this.getDefaultRateLimit(tracker)];
this.logger.log(`Tracker "${tracker}" rate limits not found in database. Using default rate limits:
this.logger.verbose(`Tracker "${tracker}" rate limits not found in database. Using default rate limits:
${JSON.stringify(rateLimits, null, 2)}`);
}
@@ -194,10 +194,10 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
);
response.setHeader(`X-RateLimit-Reset-${nameFirstUpper}`, timeToBlockExpire || timeToExpire);
this.logger.log(
this.logger.verbose(
`Tracker "${tracker}" rate limit "${name}" incremented. isBlocked ${isBlocked}, totalHits ${totalHits}, timeToExpire ${timeToExpire}, timeToBlockExpire ${timeToBlockExpire}`
);
this.logger.log(
this.logger.verbose(
`Tracker "${tracker}" rate limit "${name}" response headers:
X-RateLimit-Limit-${nameFirstUpper}: ${limit},
X-RateLimit-Remaining-${nameFirstUpper}: ${timeToBlockExpire ? 0 : Math.max(0, limit - totalHits)},
@@ -227,7 +227,7 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
return `ip_${request.ip}`;
}
this.logger.log(`no tracker found: ${request.url}`);
this.logger.verbose(`no tracker found: ${request.url}`);
return "unknown";
}
}
@@ -22,6 +22,15 @@ export class AppsRepository {
});
}
async deleteAppCredentials(credentialIdsToDelete: number[], userId: number) {
return this.dbWrite.prisma.credential.deleteMany({
where: {
id: { in: credentialIdsToDelete },
userId,
},
});
}
async createTeamAppCredential(type: string, key: Prisma.InputJsonValue, teamId: number, appId: string) {
return this.dbWrite.prisma.credential.create({
data: {
@@ -32,4 +41,28 @@ export class AppsRepository {
},
});
}
async findAppCredential({
type,
appId,
userId,
teamId,
}: {
type: string;
appId: string;
userId?: number;
teamId?: number;
}) {
return this.dbWrite.prisma.credential.findMany({
select: {
id: true,
},
where: {
type,
userId,
teamId,
appId,
},
});
}
}
@@ -1,6 +1,6 @@
import { AppsRepository } from "@/modules/apps/apps.repository";
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
import { google } from "googleapis";
import { OAuth2Client } from "googleapis-common";
import { z } from "zod";
@Injectable()
@@ -21,7 +21,7 @@ export class GCalService {
const { client_id, client_secret } = this.gcalResponseSchema.parse(app.keys);
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirectUri);
const oAuth2Client = new OAuth2Client(client_id, client_secret, redirectUri);
return oAuth2Client;
}
}
@@ -2,6 +2,7 @@ import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_0
import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository";
import { AtomsRepository } from "@/modules/atoms/atoms.repository";
import { AtomsController } from "@/modules/atoms/controllers/atoms.controller";
import { ConferencingAtomsService } from "@/modules/atoms/services/conferencing-atom.service";
import { EventTypesAtomService } from "@/modules/atoms/services/event-types-atom.service";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
@@ -15,6 +16,7 @@ import { Module } from "@nestjs/common";
imports: [PrismaModule, EventTypesModule_2024_06_14, OrganizationsModule],
providers: [
EventTypesAtomService,
ConferencingAtomsService,
MembershipsRepository,
CredentialsRepository,
UsersRepository,
@@ -1,5 +1,9 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { EventTypesAppInput } from "@/modules/atoms/inputs/event-types-app.input";
import {
BulkUpdateEventTypeToDefaultLocationDto,
EventTypesAppInput,
} from "@/modules/atoms/inputs/event-types-app.input";
import { ConferencingAtomsService } from "@/modules/atoms/services/conferencing-atom.service";
import { EventTypesAtomService } from "@/modules/atoms/services/event-types-atom.service";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
@@ -18,8 +22,9 @@ import {
} from "@nestjs/common";
import { ApiTags as DocsTags, ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
import type { UpdateEventTypeReturn } from "@calcom/platform-libraries";
import { ConnectedApps } from "@calcom/platform-libraries";
import { ApiResponse } from "@calcom/platform-types";
/*
@@ -36,7 +41,10 @@ these endpoints should not be recommended for use by third party and are exclude
@DocsTags("Atoms - endpoints for atoms")
@DocsExcludeController(true)
export class AtomsController {
constructor(private readonly eventTypesService: EventTypesAtomService) {}
constructor(
private readonly eventTypesService: EventTypesAtomService,
private readonly conferencingService: ConferencingAtomsService
) {}
@Get("event-types/:eventTypeId")
@Version(VERSION_NEUTRAL)
@@ -52,6 +60,17 @@ export class AtomsController {
};
}
@Get("/event-types")
@Version(VERSION_NEUTRAL)
@UseGuards(ApiAuthGuard)
async getAtomEventTypes(@GetUser("id") userId: number): Promise<ApiResponse<unknown>> {
const eventType = await this.eventTypesService.getUserEventTypes(userId);
return {
status: SUCCESS_STATUS,
data: eventType,
};
}
@Get("event-types-app/:appSlug")
@Version(VERSION_NEUTRAL)
@UseGuards(ApiAuthGuard)
@@ -84,6 +103,19 @@ export class AtomsController {
};
}
@Patch("/event-types/bulk-update-to-default-location")
@Version(VERSION_NEUTRAL)
@UseGuards(ApiAuthGuard)
async bulkUpdateAtomEventTypes(
@GetUser() user: UserWithProfile,
@Body() body: BulkUpdateEventTypeToDefaultLocationDto
): Promise<{ status: typeof SUCCESS_STATUS | typeof ERROR_STATUS }> {
await this.eventTypesService.bulkUpdateEventTypesDefaultLocation(user, body.eventTypeIds);
return {
status: SUCCESS_STATUS,
};
}
@Patch("event-types/:eventTypeId")
@Version(VERSION_NEUTRAL)
@UseGuards(ApiAuthGuard)
@@ -114,4 +146,13 @@ export class AtomsController {
data: eventType,
};
}
@Get("/conferencing")
@Version(VERSION_NEUTRAL)
@UseGuards(ApiAuthGuard)
async listInstalledConferencingApps(@GetUser() user: UserWithProfile): Promise<ApiResponse<ConnectedApps>> {
const conferencingApps = await this.conferencingService.getConferencingApps(user);
return { status: SUCCESS_STATUS, data: conferencingApps };
}
}
@@ -1,5 +1,5 @@
import { Transform } from "class-transformer";
import { IsNumber, IsOptional } from "class-validator";
import { Transform, Type } from "class-transformer";
import { ArrayNotEmpty, IsArray, IsInt, IsNumber, IsOptional } from "class-validator";
export class EventTypesAppInput {
@Transform(({ value }: { value: string }) => value && parseInt(value))
@@ -7,3 +7,10 @@ export class EventTypesAppInput {
@IsOptional()
teamId?: number;
}
export class BulkUpdateEventTypeToDefaultLocationDto {
@IsArray()
@ArrayNotEmpty()
@IsInt({ each: true })
eventTypeIds!: number[];
}
@@ -0,0 +1,25 @@
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { UserWithProfile } from "@/modules/users/users.repository";
import { Logger } from "@nestjs/common";
import { Injectable } from "@nestjs/common";
import { getConnectedApps, ConnectedApps } from "@calcom/platform-libraries";
import { PrismaClient } from "@calcom/prisma";
@Injectable()
export class ConferencingAtomsService {
private logger = new Logger("ConferencingAtomService");
constructor(private readonly dbWrite: PrismaWriteService) {}
async getConferencingApps(user: UserWithProfile): Promise<ConnectedApps> {
return getConnectedApps({
user,
input: {
variant: "conferencing",
onlyInstalled: true,
},
prisma: this.dbWrite.prisma as unknown as PrismaClient,
});
}
}
@@ -19,6 +19,9 @@ import {
getAppFromSlug,
MembershipRole,
EventTypeMetaDataSchema,
getClientSecretFromPayment,
getBulkEventTypes,
bulkUpdateEventsToDefaultLocation,
} from "@calcom/platform-libraries";
import type {
App,
@@ -29,8 +32,7 @@ import type {
TDependencyData,
CredentialPayload,
} from "@calcom/platform-libraries";
import { getClientSecretFromPayment } from "@calcom/platform-libraries";
import { PrismaClient } from "@calcom/prisma/client";
import { PrismaClient } from "@calcom/prisma";
type EnabledAppType = App & {
credential: CredentialDataWithTeamName;
@@ -81,6 +83,10 @@ export class EventTypesAtomService {
return eventType;
}
async getUserEventTypes(userId: number) {
return getBulkEventTypes(userId);
}
async updateTeamEventType(
eventTypeId: number,
body: TUpdateEventTypeInputSchema,
@@ -317,4 +323,12 @@ export class EventTypesAtomService {
profile,
};
}
async bulkUpdateEventTypesDefaultLocation(user: UserWithProfile, eventTypeIds: number[]) {
return bulkUpdateEventsToDefaultLocation({
eventTypeIds,
user,
prisma: this.dbWrite.prisma as unknown as PrismaClient,
});
}
}
@@ -49,7 +49,7 @@ export class PlatformPlanGuard implements CanActivate {
canAccess = hasMinimumPlan({
currentPlan: team.platformBilling?.plan as PlatformPlanType,
minimumPlan: minimumPlan,
plans: ["STARTER", "ESSENTIALS", "SCALE", "ENTERPRISE"],
plans: ["FREE", "STARTER", "ESSENTIALS", "SCALE", "ENTERPRISE"],
});
}
}
@@ -102,7 +102,7 @@ describe("PermissionsGuard", () => {
});
});
describe("when oauth id is provided", () => {
describe("when OAuth id is provided", () => {
it("should return true for valid permissions", async () => {
const mockContext = createMockExecutionContext({ "x-cal-client-id": "100" });
jest.spyOn(reflector, "get").mockReturnValue([SCHEDULE_WRITE]);
@@ -1,20 +1,29 @@
import { AppsRepository } from "@/modules/apps/apps.repository";
import { ConferencingController } from "@/modules/conferencing/controllers/conferencing.controller";
import { ConferencingRepository } from "@/modules/conferencing/repositories/conferencing.respository";
import { ConferencingService } from "@/modules/conferencing/services/conferencing.service";
import { GoogleMeetService } from "@/modules/conferencing/services/google-meet.service";
import { Office365VideoService } from "@/modules/conferencing/services/office365-video.service";
import { ZoomVideoService } from "@/modules/conferencing/services/zoom-video.service";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { UsersRepository } from "@/modules/users/users.repository";
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
@Module({
imports: [PrismaModule],
imports: [PrismaModule, ConfigModule],
providers: [
ConferencingService,
ConferencingRepository,
GoogleMeetService,
CredentialsRepository,
UsersRepository,
TokensRepository,
ZoomVideoService,
Office365VideoService,
AppsRepository,
],
exports: [],
controllers: [ConferencingController],
@@ -1,17 +1,28 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import {
ConferencingAppsOauthUrlOutputDto,
GetConferencingAppsOauthUrlResponseDto,
} from "@/modules/conferencing/outputs/get-conferencing-apps-oauth-url";
import {
ConferencingAppsOutputResponseDto,
ConferencingAppOutputResponseDto,
ConferencingAppsOutputDto,
DisconnectConferencingAppOutputResponseDto,
} from "@/modules/conferencing/outputs/get-conferencing-apps.output";
import { GetDefaultConferencingAppOutputResponseDto } from "@/modules/conferencing/outputs/get-default-conferencing-app.output";
import { SetDefaultConferencingAppOutputResponseDto } from "@/modules/conferencing/outputs/set-default-conferencing-app.output";
import { ConferencingService } from "@/modules/conferencing/services/conferencing.service";
import { GoogleMeetService } from "@/modules/conferencing/services/google-meet.service";
import { Office365VideoService } from "@/modules/conferencing/services/office365-video.service";
import { ZoomVideoService } from "@/modules/conferencing/services/zoom-video.service";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { UserWithProfile } from "@/modules/users/users.repository";
import {
Controller,
Get,
Query,
HttpCode,
HttpStatus,
Logger,
@@ -20,11 +31,24 @@ import {
Param,
BadRequestException,
Delete,
Headers,
Redirect,
UnauthorizedException,
Req,
} from "@nestjs/common";
import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
import { plainToInstance } from "class-transformer";
import { Request } from "express";
import { CONFERENCING_APPS, GOOGLE_MEET, SUCCESS_STATUS } from "@calcom/platform-constants";
import { GOOGLE_MEET, ZOOM, SUCCESS_STATUS, OFFICE_365_VIDEO } from "@calcom/platform-constants";
export type OAuthCallbackState = {
accessToken: string;
teamId?: number;
fromApp?: boolean;
returnTo?: string;
onErrorReturnTo?: string;
};
@Controller({
path: "/v2/conferencing",
@@ -35,8 +59,11 @@ export class ConferencingController {
private readonly logger = new Logger("Platform Gcal Provider");
constructor(
private readonly tokensRepository: TokensRepository,
private readonly conferencingService: ConferencingService,
private readonly googleMeetService: GoogleMeetService
private readonly googleMeetService: GoogleMeetService,
private readonly zoomVideoService: ZoomVideoService,
private readonly office365VideoService: Office365VideoService
) {}
@Post("/:app/connect")
@@ -56,16 +83,105 @@ export class ConferencingController {
default:
throw new BadRequestException(
"Invalid conferencing app, available apps are: ",
CONFERENCING_APPS.join(", ")
[GOOGLE_MEET].join(", ")
);
}
}
@Get("/:app/oauth/auth-url")
@HttpCode(HttpStatus.OK)
@UseGuards(ApiAuthGuard)
@ApiOperation({ summary: "Get OAuth conferencing app auth url" })
async redirect(
@Req() req: Request,
@Headers("Authorization") authorization: string,
@Param("app") app: string,
@Query("returnTo") returnTo?: string,
@Query("onErrorReturnTo") onErrorReturnTo?: string
): Promise<GetConferencingAppsOauthUrlResponseDto> {
let credential;
const origin = req.headers.origin;
const accessToken = authorization.replace("Bearer ", "");
const state: OAuthCallbackState = {
returnTo: returnTo ?? origin,
onErrorReturnTo: onErrorReturnTo ?? origin,
fromApp: false,
accessToken,
};
switch (app) {
case ZOOM:
credential = await this.zoomVideoService.generateZoomAuthUrl(JSON.stringify(state));
return {
status: SUCCESS_STATUS,
data: plainToInstance(ConferencingAppsOauthUrlOutputDto, credential),
};
case OFFICE_365_VIDEO:
credential = await this.office365VideoService.generateOffice365AuthUrl(JSON.stringify(state));
return {
status: SUCCESS_STATUS,
data: plainToInstance(ConferencingAppsOauthUrlOutputDto, credential),
};
default:
throw new BadRequestException(
"Invalid conferencing app, available apps are: ",
[ZOOM, OFFICE_365_VIDEO].join(", ")
);
}
}
@Get("/:app/oauth/callback")
@UseGuards()
@Redirect(undefined, 301)
@ApiOperation({ summary: "conferencing apps oauths callback" })
async save(
@Query("state") state: string,
@Param("app") app: string,
@Query("code") code: string,
@Query("error") error: string | undefined,
@Query("error_description") error_description: string | undefined
): Promise<{ url: string }> {
const decodedCallbackState: OAuthCallbackState = JSON.parse(state);
try {
const userId = await this.tokensRepository.getAccessTokenOwnerId(decodedCallbackState.accessToken);
if (error) {
throw new BadRequestException(error_description);
}
if (!userId) {
throw new UnauthorizedException("Invalid Access token.");
}
switch (app) {
case ZOOM:
return await this.zoomVideoService.connectZoomApp(decodedCallbackState, code, userId);
case OFFICE_365_VIDEO:
return await this.office365VideoService.connectOffice365App(decodedCallbackState, code, userId);
default:
throw new BadRequestException(
"Invalid conferencing app, available apps are: ",
[ZOOM, OFFICE_365_VIDEO].join(", ")
);
}
} catch (error) {
return {
url: decodedCallbackState.onErrorReturnTo ?? "",
};
}
}
@Get("/")
@HttpCode(HttpStatus.OK)
@UseGuards(ApiAuthGuard)
@ApiOperation({ summary: "List your conferencing applications" })
async listConferencingApps(@GetUser("id") userId: number): Promise<ConferencingAppsOutputResponseDto> {
async listInstalledConferencingApps(
@GetUser("id") userId: number
): Promise<ConferencingAppsOutputResponseDto> {
const conferencingApps = await this.conferencingService.getConferencingApps(userId);
const data = conferencingApps.map((conferencingApps) =>
@@ -83,18 +199,17 @@ export class ConferencingController {
@GetUser("id") userId: number,
@Param("app") app: string
): Promise<SetDefaultConferencingAppOutputResponseDto> {
switch (app) {
case GOOGLE_MEET:
await this.googleMeetService.setDefault(userId);
await this.conferencingService.setDefaultConferencingApp(userId, app);
return { status: SUCCESS_STATUS };
}
return { status: SUCCESS_STATUS };
default:
throw new BadRequestException(
"Invalid conferencing app, available apps are: ",
CONFERENCING_APPS.join(", ")
);
}
@Get("/default")
@HttpCode(HttpStatus.OK)
@UseGuards(ApiAuthGuard)
@ApiOperation({ summary: "Get your default conferencing application" })
async getDefault(@GetUser("id") userId: number): Promise<GetDefaultConferencingAppOutputResponseDto> {
const defaultconferencingApp = await this.conferencingService.getUserDefaultConferencingApp(userId);
return { status: SUCCESS_STATUS, data: defaultconferencingApp };
}
@Delete("/:app/disconnect")
@@ -102,20 +217,10 @@ export class ConferencingController {
@UseGuards(ApiAuthGuard)
@ApiOperation({ summary: "Disconnect your conferencing application" })
async disconnect(
@GetUser("id") userId: number,
@GetUser() user: UserWithProfile,
@Param("app") app: string
): Promise<ConferencingAppOutputResponseDto> {
switch (app) {
case GOOGLE_MEET:
const credential = await this.googleMeetService.disconnectGoogleMeetApp(userId);
return { status: SUCCESS_STATUS, data: plainToInstance(ConferencingAppsOutputDto, credential) };
default:
throw new BadRequestException(
"Invalid conferencing app, available apps are: ",
CONFERENCING_APPS.join(", ")
);
}
): Promise<DisconnectConferencingAppOutputResponseDto> {
await this.conferencingService.disconnectConferencingApp(user, app);
return { status: SUCCESS_STATUS };
}
}
@@ -0,0 +1,22 @@
import { ApiProperty } from "@nestjs/swagger";
import { Expose, Type } from "class-transformer";
import { IsString, ValidateNested, IsEnum } from "class-validator";
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
export class ConferencingAppsOauthUrlOutputDto {
@IsString()
@Expose()
readonly url!: string;
}
export class GetConferencingAppsOauthUrlResponseDto {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@Expose()
@ValidateNested()
@Type(() => ConferencingAppsOauthUrlOutputDto)
data!: ConferencingAppsOauthUrlOutputDto;
}
@@ -48,3 +48,9 @@ export class ConferencingAppOutputResponseDto {
@Type(() => ConferencingAppsOutputDto)
data!: ConferencingAppsOutputDto;
}
export class DisconnectConferencingAppOutputResponseDto {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
}
@@ -0,0 +1,29 @@
import { ApiProperty } from "@nestjs/swagger";
import { Expose, Type } from "class-transformer";
import { IsEnum, IsOptional, IsString, ValidateNested } from "class-validator";
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
export class DefaultConferencingAppsOutputDto {
@IsString()
@IsOptional()
@Expose()
readonly appSlug?: string;
@IsString()
@IsOptional()
@Expose()
readonly appLink?: string;
}
export class GetDefaultConferencingAppOutputResponseDto {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@Expose()
@ValidateNested()
@IsOptional()
@Type(() => DefaultConferencingAppsOutputDto)
data?: DefaultConferencingAppsOutputDto;
}
@@ -22,4 +22,10 @@ export class ConferencingRepository {
where: { userId, type: GOOGLE_MEET_TYPE },
});
}
async findConferencingApp(userId: number, app: string) {
return this.dbRead.prisma.credential.findFirst({
where: { userId, appId: app },
});
}
}
@@ -1,14 +1,61 @@
import { ConferencingRepository } from "@/modules/conferencing/repositories/conferencing.respository";
import { Logger } from "@nestjs/common";
import { UserWithProfile } from "@/modules/users/users.repository";
import { UsersRepository } from "@/modules/users/users.repository";
import { BadRequestException, InternalServerErrorException, Logger } from "@nestjs/common";
import { Injectable } from "@nestjs/common";
import { CONFERENCING_APPS, CAL_VIDEO } from "@calcom/platform-constants";
import { userMetadata, handleDeleteCredential } from "@calcom/platform-libraries";
@Injectable()
export class ConferencingService {
private logger = new Logger("ConferencingService");
constructor(private readonly conferencingRepository: ConferencingRepository) {}
constructor(
private readonly conferencingRepository: ConferencingRepository,
private readonly usersRepository: UsersRepository
) {}
async getConferencingApps(userId: number) {
return this.conferencingRepository.findConferencingApps(userId);
}
async getUserDefaultConferencingApp(userId: number) {
const user = await this.usersRepository.findById(userId);
return userMetadata.parse(user?.metadata)?.defaultConferencingApp;
}
async checkAppIsValidAndConnected(userId: number, app: string) {
if (!CONFERENCING_APPS.includes(app)) {
throw new BadRequestException("Invalid app, available apps are: ", CONFERENCING_APPS.join(", "));
}
const credential = await this.conferencingRepository.findConferencingApp(userId, app);
if (!credential) {
throw new BadRequestException(`${app} not connected.`);
}
return credential;
}
async disconnectConferencingApp(user: UserWithProfile, app: string) {
const credential = await this.checkAppIsValidAndConnected(user.id, app);
return handleDeleteCredential({
userId: user.id,
userMetadata: user?.metadata,
credentialId: credential.id,
});
}
async setDefaultConferencingApp(userId: number, app: string) {
// cal-video is global, so we can skip this check
if (app !== CAL_VIDEO) {
await this.checkAppIsValidAndConnected(userId, app);
}
const user = await this.usersRepository.setDefaultConferencingApp(userId, app);
const metadata = user.metadata as { defaultConferencingApp?: { appSlug?: string } };
if (metadata?.defaultConferencingApp?.appSlug !== app) {
throw new InternalServerErrorException(`Could not set ${app} as default conferencing app`);
}
return true;
}
}
@@ -43,29 +43,4 @@ export class GoogleMeetService {
return googleMeetCredential;
}
async disconnectGoogleMeetApp(userId: number) {
const googleMeet = await this.conferencingRepository.findGoogleMeet(userId);
if (!googleMeet) {
throw new BadRequestException("Google Meet is not connected.");
}
const googleMeetCredential = await this.credentialsRepository.deleteUserCredentialById(
userId,
googleMeet.id
);
return googleMeetCredential;
}
async setDefault(userId: number) {
const user = await this.usersRepository.setDefaultConferencingApp(userId, GOOGLE_MEET);
const metadata = user.metadata as { defaultConferencingApp?: { appSlug?: string } };
if (metadata?.defaultConferencingApp?.appSlug !== GOOGLE_MEET) {
throw new InternalServerErrorException("Could not set Google Meet as default conferencing app");
}
return true;
}
}
@@ -0,0 +1,121 @@
import { AppsRepository } from "@/modules/apps/apps.repository";
import { OAuthCallbackState } from "@/modules/conferencing/controllers/conferencing.controller";
import { BadRequestException, Logger, NotFoundException } from "@nestjs/common";
import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import type { Prisma } from "@prisma/client";
import { z } from "zod";
import { OFFICE_365_VIDEO, OFFICE_365_VIDEO_TYPE } from "@calcom/platform-constants";
import stringify = require("qs-stringify");
const zoomAppKeysSchema = z.object({
client_id: z.string(),
client_secret: z.string(),
});
@Injectable()
export class Office365VideoService {
private logger = new Logger("Office365VideoService");
private redirectUri = `${this.config.get("api.url")}/conferencing/${OFFICE_365_VIDEO}/oauth/callback`;
private scopes = ["OnlineMeetings.ReadWrite", "offline_access"];
constructor(private readonly config: ConfigService, private readonly appsRepository: AppsRepository) {}
async getOffice365AppKeys() {
const app = await this.appsRepository.getAppBySlug(OFFICE_365_VIDEO);
const { client_id, client_secret } = zoomAppKeysSchema.parse(app?.keys);
if (!client_id) {
throw new NotFoundException("Office365 app not found");
}
if (!client_secret) {
throw new NotFoundException("Office365 app not found");
}
return { client_id, client_secret };
}
async generateOffice365AuthUrl(state: string) {
const { client_id } = await this.getOffice365AppKeys();
const params = {
response_type: "code",
client_id,
scope: this.scopes.join(" "),
redirect_uri: this.redirectUri,
state: state,
};
const query = stringify(params);
const url = `https://login.microsoftonline.com/common/oauth2/v2.0/authorize?${query}`;
return { url };
}
async connectOffice365App(state: OAuthCallbackState, code: string, userId: number) {
const { client_id, client_secret } = await this.getOffice365AppKeys();
const toUrlEncoded = (payload: Record<string, string>) =>
Object.keys(payload)
.map((key) => `${key}=${encodeURIComponent(payload[key])}`)
.join("&");
const body = toUrlEncoded({
client_id,
grant_type: "authorization_code",
code,
scope: this.scopes.join(" "),
redirect_uri: this.redirectUri,
client_secret,
});
const response = await fetch("https://login.microsoftonline.com/common/oauth2/v2.0/token", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
},
body,
});
const responseBody = await response.json();
if (!response.ok) {
throw new BadRequestException(responseBody.error);
}
const whoami = await fetch("https://graph.microsoft.com/v1.0/me", {
headers: { Authorization: `Bearer ${responseBody.access_token}` },
});
const graphUser = await whoami.json();
// In some cases, graphUser.mail is null. Then graphUser.userPrincipalName most likely contains the email address.
responseBody.email = graphUser.mail ?? graphUser.userPrincipalName;
responseBody.expiry_date = Math.round(+new Date() / 1000 + responseBody.expires_in); // set expiry date in seconds
delete responseBody.expires_in;
const existingCredentialOffice365Video = await this.appsRepository.findAppCredential({
type: OFFICE_365_VIDEO_TYPE,
userId,
appId: OFFICE_365_VIDEO,
});
const credentialIdsToDelete = existingCredentialOffice365Video.map((item) => item.id);
if (credentialIdsToDelete.length > 0) {
await this.appsRepository.deleteAppCredentials(credentialIdsToDelete, userId);
}
await this.appsRepository.createAppCredential(
OFFICE_365_VIDEO_TYPE,
responseBody as unknown as Prisma.InputJsonObject,
userId,
OFFICE_365_VIDEO
);
return { url: state.returnTo ?? "" };
}
}
@@ -0,0 +1,113 @@
import { AppsRepository } from "@/modules/apps/apps.repository";
import { OAuthCallbackState } from "@/modules/conferencing/controllers/conferencing.controller";
import { BadRequestException, Logger, NotFoundException, UnauthorizedException } from "@nestjs/common";
import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import type { Prisma } from "@prisma/client";
import { z } from "zod";
import { ZOOM, ZOOM_TYPE } from "@calcom/platform-constants";
import stringify = require("qs-stringify");
const zoomAppKeysSchema = z.object({
client_id: z.string(),
client_secret: z.string(),
});
@Injectable()
export class ZoomVideoService {
private logger = new Logger("ZoomVideoService");
private redirectUri = `${this.config.get("api.url")}/conferencing/${ZOOM}/oauth/callback`;
constructor(private readonly config: ConfigService, private readonly appsRepository: AppsRepository) {}
async getZoomAppKeys() {
const app = await this.appsRepository.getAppBySlug(ZOOM);
const { client_id, client_secret } = zoomAppKeysSchema.parse(app?.keys);
if (!client_id) {
throw new NotFoundException("Zoom app not found");
}
if (!client_secret) {
throw new NotFoundException("Zoom app not found");
}
return { client_id, client_secret };
}
async generateZoomAuthUrl(state: string) {
const { client_id } = await this.getZoomAppKeys();
const params = {
response_type: "code",
client_id,
redirect_uri: this.redirectUri,
state: state,
};
const query = stringify(params);
const url = `https://zoom.us/oauth/authorize?${query}`;
return { url };
}
async connectZoomApp(state: OAuthCallbackState, code: string, userId: number) {
const { client_id, client_secret } = await this.getZoomAppKeys();
const redirectUri = encodeURI(this.redirectUri);
const authHeader = `Basic ${Buffer.from(`${client_id}:${client_secret}`).toString("base64")}`;
const result = await fetch(
`https://zoom.us/oauth/token?grant_type=authorization_code&code=${code}&redirect_uri=${redirectUri}`,
{
method: "POST",
headers: {
Authorization: authHeader,
},
}
);
if (result.status !== 200) {
let errorMessage = "Something is wrong with Zoom API";
try {
const responseBody = await result.json();
errorMessage = responseBody.error;
} catch (e) {
errorMessage = await result.clone().text();
}
throw new BadRequestException(errorMessage);
}
const responseBody = await result.json();
if (responseBody.error) {
throw new BadRequestException(responseBody.error);
}
responseBody.expiry_date = Math.round(Date.now() + responseBody.expires_in * 1000);
delete responseBody.expires_in;
if (!userId) {
throw new UnauthorizedException("Invalid Access token.");
}
const existingCredentialZoomVideo = await this.appsRepository.findAppCredential({
type: ZOOM_TYPE,
userId,
appId: ZOOM,
});
const credentialIdsToDelete = existingCredentialZoomVideo.map((item) => item.id);
if (credentialIdsToDelete.length > 0) {
await this.appsRepository.deleteAppCredentials(credentialIdsToDelete, userId);
}
await this.appsRepository.createAppCredential(
ZOOM_TYPE,
responseBody as unknown as Prisma.InputJsonObject,
userId,
ZOOM
);
return { url: state.returnTo ?? "" };
}
}
@@ -16,6 +16,7 @@ import { Test } from "@nestjs/testing";
import { PlatformOAuthClient, Team, User, EventType } 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 { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
import { SchedulesRepositoryFixture } from "test/fixtures/repository/schedules.repository.fixture";
@@ -79,6 +80,7 @@ describe("OAuth Client Users Endpoints", () => {
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let schedulesRepositoryFixture: SchedulesRepositoryFixture;
let profilesRepositoryFixture: ProfileRepositoryFixture;
let membershipsRepositoryFixture: MembershipRepositoryFixture;
let postResponseData: CreateManagedUserOutput["data"];
@@ -103,26 +105,33 @@ describe("OAuth Client Users Endpoints", () => {
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
schedulesRepositoryFixture = new SchedulesRepositoryFixture(moduleRef);
profilesRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
platformAdmin = await userRepositoryFixture.create({ email: platformAdminEmail });
organization = await teamRepositoryFixture.create({ name: "organization" });
organization = await teamRepositoryFixture.create({
name: "organization",
isPlatform: true,
isOrganization: true,
});
oAuthClient = await createOAuthClient(organization.id);
await profilesRepositoryFixture.create({
uid: "asd-asd",
uid: "asd1qwwqeqw-asddsadasd",
username: platformAdminEmail,
organization: { connect: { id: organization.id } },
movedFromUser: {
connect: {
id: platformAdmin.id,
},
},
user: {
connect: { id: platformAdmin.id },
},
});
await membershipsRepositoryFixture.create({
role: "OWNER",
user: { connect: { id: platformAdmin.id } },
team: { connect: { id: organization.id } },
accepted: true,
});
await app.init();
});
@@ -150,6 +159,7 @@ describe("OAuth Client Users Endpoints", () => {
email: userEmail,
timeZone: "incorrect-time-zone",
name: "Alice Smith",
avatarUrl: "https://cal.com/api/avatar/2b735186-b01b-46d3-87da-019b8f61776b.png",
};
await request(app.getHttpServer())
@@ -182,6 +192,7 @@ describe("OAuth Client Users Endpoints", () => {
timeFormat: 24,
locale: Locales.FR,
name: "Alice Smith",
avatarUrl: "https://cal.com/api/avatar/2b735186-b01b-46d3-87da-019b8f61776b.png",
};
const response = await request(app.getHttpServer())
@@ -202,6 +213,7 @@ describe("OAuth Client Users Endpoints", () => {
expect(responseBody.data.user.weekStart).toEqual(requestBody.weekStart);
expect(responseBody.data.user.timeFormat).toEqual(requestBody.timeFormat);
expect(responseBody.data.user.locale).toEqual(requestBody.locale);
expect(responseBody.data.user.avatarUrl).toEqual(requestBody.avatarUrl);
expect(responseBody.data.accessToken).toBeDefined();
expect(responseBody.data.refreshToken).toBeDefined();
@@ -256,6 +268,7 @@ describe("OAuth Client Users Endpoints", () => {
timeFormat: 24,
locale: Locales.FR,
name: "Alice Smith",
avatarUrl: "https://cal.com/api/avatar/2b735186-b01b-46d3-87da-019b8f61776b.png",
};
const response = await request(app.getHttpServer())
@@ -370,7 +383,7 @@ describe("OAuth Client Users Endpoints", () => {
let teamRepositoryFixture: TeamRepositoryFixture;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let profileRepositoryFixture: ProfileRepositoryFixture;
let membershipsRepositoryFixture: MembershipRepositoryFixture;
let postResponseData: CreateManagedUserOutput["data"];
const userEmail = "oauth-client-users-user@gmail.com";
@@ -390,10 +403,11 @@ describe("OAuth Client Users Endpoints", () => {
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
organization = await teamRepositoryFixture.create({
name: "Testy Organization",
isOrganization: true,
isPlatform: true,
});
owner = await userRepositoryFixture.create({
@@ -417,6 +431,13 @@ describe("OAuth Client Users Endpoints", () => {
},
});
await membershipsRepositoryFixture.create({
role: "OWNER",
user: { connect: { id: owner.id } },
team: { connect: { id: organization.id } },
accepted: true,
});
oAuthClient1 = await createOAuthClient(organization.id);
oAuthClient2 = await createOAuthClient(organization.id);
@@ -518,6 +539,7 @@ describe("OAuth Client Users Endpoints", () => {
timeFormat: 24,
locale: Locales.FR,
name: "Alice Smith",
avatarUrl: "https://cal.com/api/avatar/2b735186-b01b-46d3-87da-019b8f61776b.png",
};
const response = await request(app.getHttpServer())
@@ -1,6 +1,8 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { Locales } from "@/lib/enums/locales";
import { MembershipRoles } from "@/modules/auth/decorators/roles/membership-roles.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { OrganizationRolesGuard } from "@/modules/auth/guards/organization-roles/organization-roles.guard";
import { CreateManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/create-managed-user.output";
import { GetManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/get-managed-user.output";
import { GetManagedUsersOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/get-managed-users.output";
@@ -29,7 +31,7 @@ import {
NotFoundException,
} from "@nestjs/common";
import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
import { User } from "@prisma/client";
import { User, MembershipRole } from "@prisma/client";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { Pagination } from "@calcom/platform-types";
@@ -38,7 +40,7 @@ import { Pagination } from "@calcom/platform-types";
path: "/v2/oauth-clients/:clientId/users",
version: API_VERSIONS_VALUES,
})
@UseGuards(ApiAuthGuard, OAuthClientGuard)
@UseGuards(ApiAuthGuard, OAuthClientGuard, OrganizationRolesGuard)
@DocsTags("Platform / Managed Users")
export class OAuthClientUsersController {
private readonly logger = new Logger("UserController");
@@ -52,6 +54,7 @@ export class OAuthClientUsersController {
@Get("/")
@ApiOperation({ summary: "Get all managed users" })
@MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER])
async getManagedUsers(
@Param("clientId") oAuthClientId: string,
@Query() queryParams: Pagination
@@ -73,6 +76,7 @@ export class OAuthClientUsersController {
@Post("/")
@ApiOperation({ summary: "Create a managed user" })
@MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER])
async createUser(
@Param("clientId") oAuthClientId: string,
@Body() body: CreateManagedUserInput
@@ -104,6 +108,7 @@ export class OAuthClientUsersController {
@Get("/:userId")
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Get a managed user" })
@MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER])
async getUserById(
@Param("clientId") clientId: string,
@Param("userId") userId: number
@@ -119,6 +124,7 @@ export class OAuthClientUsersController {
@Patch("/:userId")
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Update a managed user" })
@MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER])
async updateUser(
@Param("clientId") clientId: string,
@Param("userId") userId: number,
@@ -138,6 +144,7 @@ export class OAuthClientUsersController {
@Delete("/:userId")
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Delete a managed user" })
@MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER])
async deleteUser(
@Param("clientId") clientId: string,
@Param("userId") userId: number
@@ -155,7 +162,12 @@ export class OAuthClientUsersController {
@Post("/:userId/force-refresh")
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Force refresh tokens" })
@ApiOperation({
summary: "Force refresh tokens",
description: `If you have lost managed user access or refresh token, then you can get new ones by using OAuth credentials.
Each access token is valid for 60 minutes and each refresh token for 1 year. Make sure to store them later in your database, for example, by updating the User model to have \`calAccessToken\` and \`calRefreshToken\` columns.`,
})
@MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER])
async forceRefresh(
@Param("userId") userId: number,
@Param("clientId") oAuthClientId: string
@@ -201,6 +213,7 @@ export class OAuthClientUsersController {
timeFormat: user.timeFormat,
defaultScheduleId: user.defaultScheduleId,
locale: user.locale as Locales,
avatarUrl: user.avatarUrl,
};
}
}
@@ -1,7 +1,7 @@
import { Locales } from "@/lib/enums/locales";
import { ApiProperty } from "@nestjs/swagger";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Transform } from "class-transformer";
import { IsEnum, IsOptional } from "class-validator";
import { IsEnum, IsOptional, IsUrl } from "class-validator";
export class ManagedUserOutput {
@ApiProperty({ example: 1 })
@@ -36,4 +36,14 @@ export class ManagedUserOutput {
@IsOptional()
@ApiProperty({ example: Locales.EN, enum: Locales })
locale?: Locales;
@IsUrl()
@IsOptional()
@ApiPropertyOptional({
type: String,
example: "https://cal.com/api/avatar/2b735186-b01b-46d3-87da-019b8f61776b.png",
description: `URL of the user's avatar image`,
required: false,
})
avatarUrl?: string | null;
}
@@ -24,10 +24,10 @@ import { withNextAuth } from "test/utils/withNextAuth";
import { PlatformOAuthClient, Team, Webhook } from "@calcom/prisma/client";
describe("EventTypes WebhooksController (e2e)", () => {
describe("OAuth client WebhooksController (e2e)", () => {
let app: INestApplication;
const userEmail = "event-types-webhook-controller-e2e@api.com";
const otherUserEmail = "other-event-types-webhook-controller-e2e@api.com";
const userEmail = "oauth-client-webhook-controller-e2e@api.com";
const otherUserEmail = "other-oauth-client-webhook-controller-e2e@api.com";
let user: UserWithProfile;
let otherUser: UserWithProfile;
let oAuthClient: PlatformOAuthClient;
@@ -244,7 +244,7 @@ describe("EventTypes WebhooksController (e2e)", () => {
});
});
});
it("/webhooks (GET) should fail to get webhooks of oauth client that doesn't belong to you", () => {
it("/webhooks (GET) should fail to get webhooks of OAuth client that doesn't belong to you", () => {
return request(app.getHttpServer()).get(`/v2/oauth-clients/${otherOAuthClient.id}/webhooks`).expect(403);
});
@@ -267,7 +267,7 @@ describe("EventTypes WebhooksController (e2e)", () => {
});
});
it("/oauth-clients/:oAuthClientId/webhooks/:webhookId (DELETE) should fail to delete webhooks of an oauth client that doesn't belong to you", () => {
it("/oauth-clients/:oAuthClientId/webhooks/:webhookId (DELETE) should fail to delete webhooks of an OAuth client that doesn't belong to you", () => {
return request(app.getHttpServer())
.delete(`/v2/oauth-clients/${otherOAuthClient.id}/webhooks/${otherOAuthClientWebhook.id}`)
.expect(403);
@@ -30,6 +30,7 @@ import {
ApiExcludeEndpoint as DocsExcludeEndpoint,
ApiBadRequestResponse as DocsBadRequestResponse,
ApiHeader as DocsHeader,
ApiOperation,
} from "@nestjs/swagger";
import { Response as ExpressResponse } from "express";
@@ -39,7 +40,6 @@ import { SUCCESS_STATUS, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
path: "/v2/oauth/:clientId",
version: API_VERSIONS_VALUES,
})
@DocsTags("OAuth")
export class OAuthFlowController {
constructor(
private readonly oauthClientRepository: OAuthClientRepository,
@@ -115,13 +115,18 @@ export class OAuthFlowController {
@Post("/refresh")
@HttpCode(HttpStatus.OK)
@UseGuards(ApiAuthGuard)
@DocsTags("Managed users")
@DocsTags("Platform / Managed Users")
@DocsHeader({
name: X_CAL_SECRET_KEY,
description: "OAuth client secret key.",
required: true,
})
async refreshAccessToken(
@ApiOperation({
summary: "Refresh managed user tokens",
description: `If managed user access token is expired then get a new one using this endpoint. Each access token is valid for 60 minutes and
each refresh token for 1 year. Make sure to store them later in your database, for example, by updating the User model to have \`calAccessToken\` and \`calRefreshToken\` columns.`,
})
async refreshTokens(
@Param("clientId") clientId: string,
@Headers(X_CAL_SECRET_KEY) secretKey: string,
@Body() body: RefreshTokenInput
@@ -66,9 +66,11 @@ export class OAuthClientUsersService {
const updatedUser = await this.userRepository.update(user.id, {
name: body.name,
locale: body.locale,
avatarUrl: body.avatarUrl,
});
user.locale = updatedUser.locale;
user.name = updatedUser.name;
user.avatarUrl = updatedUser.avatarUrl;
}
const { accessToken, refreshToken, accessTokenExpiresAt } = await this.tokensRepository.createOAuthTokens(
@@ -6,7 +6,7 @@ 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 { SchedulingType, 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";
@@ -235,6 +235,7 @@ describe("Organizations Event Types Endpoints", () => {
it("should create a collective team event-type", async () => {
const body: CreateTeamEventTypeInput_2024_06_14 = {
successRedirectUrl: "https://masterchief.com/argentina/flan/video/1234",
title: "Coding consultation collective",
slug: "coding-consultation collective",
description: "Our team will review your codebase.",
@@ -334,7 +335,7 @@ describe("Organizations Event Types Endpoints", () => {
expect(data.hideCalendarEventDetails).toEqual(body.hideCalendarEventDetails);
expect(data.lockTimeZoneToggleOnBookingPage).toEqual(body.lockTimeZoneToggleOnBookingPage);
expect(data.color).toEqual(body.color);
expect(data.successRedirectUrl).toEqual("https://masterchief.com/argentina/flan/video/1234");
collectiveEventType = responseBody.data;
});
});
@@ -496,6 +497,7 @@ describe("Organizations Event Types Endpoints", () => {
const body: UpdateTeamEventTypeInput_2024_06_14 = {
hosts: newHosts,
successRedirectUrl: "https://new-url-success.com",
};
return request(app.getHttpServer())
@@ -507,6 +509,7 @@ describe("Organizations Event Types Endpoints", () => {
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const eventType = responseBody.data;
expect(eventType.successRedirectUrl).toEqual("https://new-url-success.com");
expect(eventType.title).toEqual(collectiveEventType.title);
expect(eventType.hosts.length).toEqual(1);
evaluateHost(eventType.hosts[0], newHosts[0]);
@@ -526,6 +529,7 @@ describe("Organizations Event Types Endpoints", () => {
const body: UpdateTeamEventTypeInput_2024_06_14 = {
title: newTitle,
hosts: newHosts,
successRedirectUrl: "https://new-url-success-managed.com",
};
return request(app.getHttpServer())
@@ -569,6 +573,7 @@ describe("Organizations Event Types Endpoints", () => {
expect(responseTeammate1Event?.title).toEqual(newTitle);
managedEventType = responseBody.data[0];
expect(managedEventType.successRedirectUrl).toEqual("https://new-url-success-managed.com");
});
});
@@ -649,6 +654,81 @@ describe("Organizations Event Types Endpoints", () => {
.expect(200);
});
it("should return event type with default bookingFields if they are not defined", async () => {
const eventTypeInput = {
title: "unknown field event type two",
description: "unknown field event type description two",
length: 40,
hidden: false,
slug: "unknown-field-type-two",
locations: [],
schedulingType: SchedulingType.ROUND_ROBIN,
};
const eventType = await eventTypesRepositoryFixture.createTeamEventType({
...eventTypeInput,
team: { connect: { id: team.id } },
});
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/teams/${team.id}/event-types/${eventType.id}`)
.expect(200)
.then(async (response) => {
const responseBody: ApiSuccessResponse<TeamEventTypeOutput_2024_06_14> = response.body;
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,
type: "radioInput",
slug: "location",
required: false,
},
{
isDefault: true,
type: "text",
slug: "title",
required: true,
},
{
isDefault: true,
type: "textarea",
slug: "notes",
required: false,
},
{
isDefault: true,
type: "multiemail",
slug: "guests",
required: false,
},
{
isDefault: true,
type: "textarea",
slug: "rescheduleReason",
required: false,
},
]);
});
});
function evaluateHost(expected: Host, received: Host | undefined) {
expect(expected.userId).toEqual(received?.userId);
expect(expected.mandatory).toEqual(received?.mandatory);
@@ -12,7 +12,7 @@ export class OutputTeamEventTypesResponsePipe implements PipeTransform {
private async transformEventType(item: DatabaseTeamEventType): Promise<TeamEventTypeOutput_2024_06_14> {
return plainToClass(
TeamEventTypeOutput_2024_06_14,
await this.outputOrganizationsEventTypesService.getResponseTeamEventType(item),
await this.outputOrganizationsEventTypesService.getResponseTeamEventType(item, true),
{ strategy: "exposeAll" }
);
}
@@ -137,6 +137,8 @@ describe("Organizations Team Endpoints", () => {
.post(`/v2/organizations/${org.id}/teams`)
.send({
name: "Team created via API",
slug: "team-created-via-api",
bio: "This is our test team created via API",
} satisfies CreateOrgTeamDto)
.expect(201)
.then(async (response) => {
@@ -144,6 +146,8 @@ describe("Organizations Team Endpoints", () => {
expect(responseBody.status).toEqual(SUCCESS_STATUS);
teamCreatedViaApi = responseBody.data;
expect(teamCreatedViaApi.name).toEqual("Team created via API");
expect(teamCreatedViaApi.slug).toEqual("team-created-via-api");
expect(teamCreatedViaApi.bio).toEqual("This is our test team created via API");
expect(teamCreatedViaApi.parentId).toEqual(org.id);
const membership = await membershipsRepositoryFixture.getUserMembershipByTeamId(
user.id,
@@ -171,6 +175,9 @@ describe("Organizations Team Endpoints", () => {
.patch(`/v2/organizations/${org.id}/teams/${teamCreatedViaApi.id}`)
.send({
name: "Team created via API Updated",
weekStart: "Monday",
logoUrl: "https://i.cal.com/api/avatar/b0b58752-68ad-4c0d-8024-4fa382a77752.png",
bannerUrl: "https://i.cal.com/api/avatar/949be534-7a88-4185-967c-c020b0c0bef3.png",
} satisfies CreateOrgTeamDto)
.expect(200)
.then((response) => {
@@ -178,6 +185,13 @@ describe("Organizations Team Endpoints", () => {
expect(responseBody.status).toEqual(SUCCESS_STATUS);
teamCreatedViaApi = responseBody.data;
expect(teamCreatedViaApi.name).toEqual("Team created via API Updated");
expect(teamCreatedViaApi.weekStart).toEqual("Monday");
expect(teamCreatedViaApi.logoUrl).toEqual(
"https://i.cal.com/api/avatar/b0b58752-68ad-4c0d-8024-4fa382a77752.png"
);
expect(teamCreatedViaApi.bannerUrl).toEqual(
"https://i.cal.com/api/avatar/949be534-7a88-4185-967c-c020b0c0bef3.png"
);
expect(teamCreatedViaApi.parentId).toEqual(org.id);
});
});
@@ -12,10 +12,12 @@ import { IsUserInOrg } from "@/modules/auth/guards/users/is-user-in-org.guard";
import { CreateOrganizationUserInput } from "@/modules/organizations/inputs/create-organization-user.input";
import { GetOrganizationsUsersInput } from "@/modules/organizations/inputs/get-organization-users.input";
import { UpdateOrganizationUserInput } from "@/modules/organizations/inputs/update-organization-user.input";
import { GetOrganizationUsersOutput } from "@/modules/organizations/outputs/get-organization-users.output";
import {
GetOrganizationUsersResponseDTO,
GetOrgUsersWithProfileOutput,
} from "@/modules/organizations/outputs/get-organization-users.output";
import { GetOrganizationUserOutput } from "@/modules/organizations/outputs/get-organization-users.output";
import { OrganizationsUsersService } from "@/modules/organizations/services/organizations-users-service";
import { GetUserOutput } from "@/modules/users/outputs/get-users.output";
import { UserWithProfile } from "@/modules/users/users.repository";
import {
Controller,
@@ -55,7 +57,7 @@ export class OrganizationsUsersController {
async getOrganizationsUsers(
@Param("orgId", ParseIntPipe) orgId: number,
@Query() query: GetOrganizationsUsersInput
): Promise<GetOrganizationUsersOutput> {
): Promise<GetOrganizationUsersResponseDTO> {
const users = await this.organizationsUsersService.getUsers(
orgId,
query.emails,
@@ -65,7 +67,13 @@ export class OrganizationsUsersController {
return {
status: SUCCESS_STATUS,
data: users.map((user) => plainToInstance(GetUserOutput, user, { strategy: "excludeAll" })),
data: users.map((user) =>
plainToInstance(
GetOrgUsersWithProfileOutput,
{ ...user, profile: user?.profiles?.[0] ?? {} },
{ strategy: "excludeAll" }
)
),
};
}
@@ -86,7 +94,11 @@ export class OrganizationsUsersController {
);
return {
status: SUCCESS_STATUS,
data: plainToInstance(GetUserOutput, user, { strategy: "excludeAll" }),
data: plainToInstance(
GetOrgUsersWithProfileOutput,
{ ...user, profile: user?.profiles?.[0] ?? {} },
{ strategy: "excludeAll" }
),
};
}
@@ -104,7 +116,11 @@ export class OrganizationsUsersController {
const user = await this.organizationsUsersService.updateUser(orgId, userId, input);
return {
status: SUCCESS_STATUS,
data: plainToInstance(GetUserOutput, user, { strategy: "excludeAll" }),
data: plainToInstance(
GetOrgUsersWithProfileOutput,
{ ...user, profile: user?.profiles?.[0] ?? {} },
{ strategy: "excludeAll" }
),
};
}
@@ -120,7 +136,11 @@ export class OrganizationsUsersController {
const user = await this.organizationsUsersService.deleteUser(orgId, userId);
return {
status: SUCCESS_STATUS,
data: plainToInstance(GetUserOutput, user, { strategy: "excludeAll" }),
data: plainToInstance(
GetOrgUsersWithProfileOutput,
{ ...user, profile: user?.profiles?.[0] ?? {} },
{ strategy: "excludeAll" }
),
};
}
}
@@ -1,6 +1,7 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { EmailService } from "@/modules/email/email.service";
import { GetOrgUsersWithProfileOutput } from "@/modules/organizations/outputs/get-organization-users.output";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
@@ -233,10 +234,18 @@ describe("Organizations Users Endpoints", () => {
it("should get all org users", async () => {
const { body } = await request(app.getHttpServer()).get(`/v2/organizations/${org.id}/users`);
const userData = body.data;
const userData = body.data as GetOrgUsersWithProfileOutput[];
expect(body.status).toBe(SUCCESS_STATUS);
expect(userData.length).toBe(4);
console.log(
"profiles",
{ userData },
userData.map((u) => u.profile)
);
expect(userData.find((u) => u.profile.username === "member1@org.com")).toBeDefined();
expect(userData.find((u) => u.profile.username === "member2@org.com")).toBeDefined();
expect(userData.find((u) => u.profile.username === "member3@org.com")).toBeDefined();
expect(userData.filter((user: { email: string }) => user.email === nonMemberEmail).length).toBe(0);
});
@@ -250,12 +259,13 @@ describe("Organizations Users Endpoints", () => {
.set("Content-Type", "application/json")
.set("Accept", "application/json");
const userData = body.data;
const userData = body.data as GetOrgUsersWithProfileOutput[];
expect(body.status).toBe(SUCCESS_STATUS);
expect(userData.length).toBe(1);
expect(userData.filter((user: { email: string }) => user.email === userEmail).length).toBe(1);
expect(userData.filter((user) => user.email === userEmail).length).toBe(1);
expect(userData.find((u) => u.profile.username === user.username)).toBeDefined();
});
it("should get users within the specified emails array", async () => {
@@ -1,16 +1,25 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsBoolean, IsOptional, IsString, IsUrl, Length } from "class-validator";
export class CreateOrgTeamDto {
@IsString()
@Length(1)
@ApiProperty({ description: "Name of the team", example: "CalTeam", required: true })
readonly name!: string;
@IsOptional()
@IsString()
@ApiPropertyOptional({ type: String, description: "Team slug", example: "caltel", required: false })
readonly slug?: string;
@IsOptional()
@IsUrl()
@ApiPropertyOptional({
type: String,
example: "https://i.cal.com/api/avatar/b0b58752-68ad-4c0d-8024-4fa382a77752.png",
description: `URL of the teams logo image`,
required: false,
})
readonly logoUrl?: string;
@IsOptional()
@@ -59,6 +68,12 @@ export class CreateOrgTeamDto {
@IsOptional()
@IsUrl()
@ApiPropertyOptional({
type: String,
example: "https://i.cal.com/api/avatar/949be534-7a88-4185-967c-c020b0c0bef3.png",
description: `URL of the teams banner image which is shown on booker`,
required: false,
})
readonly bannerUrl?: string;
@IsOptional()
@@ -67,10 +82,23 @@ export class CreateOrgTeamDto {
@IsOptional()
@IsString()
@ApiPropertyOptional({
type: String,
example: "America/New_York",
description: `Timezone is used to create teams's default schedule from Monday to Friday from 9AM to 5PM. It will default to Europe/London if not passed.`,
required: false,
default: "Europe/London",
})
readonly timeZone?: string = "Europe/London";
@IsOptional()
@IsString()
@ApiPropertyOptional({
type: String,
example: "Monday",
required: false,
default: "Sunday",
})
readonly weekStart?: string = "Sunday";
@IsOptional()
@@ -1,16 +1,25 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { IsBoolean, IsOptional, IsString, IsUrl, Length } from "class-validator";
export class UpdateOrgTeamDto {
@IsString()
@Length(1)
@ApiPropertyOptional({ description: "Name of the team", example: "CalTeam", required: false })
readonly name?: string;
@IsOptional()
@IsString()
@ApiPropertyOptional({ type: String, description: "Team slug", example: "caltel", required: false })
readonly slug?: string;
@IsOptional()
@IsUrl()
@ApiPropertyOptional({
type: String,
example: "https://i.cal.com/api/avatar/b0b58752-68ad-4c0d-8024-4fa382a77752.png",
description: `URL of the teams logo image`,
required: false,
})
readonly logoUrl?: string;
@IsOptional()
@@ -59,6 +68,12 @@ export class UpdateOrgTeamDto {
@IsOptional()
@IsUrl()
@ApiPropertyOptional({
type: String,
example: "https://i.cal.com/api/avatar/949be534-7a88-4185-967c-c020b0c0bef3.png",
description: `URL of the teams banner image which is shown on booker`,
required: false,
})
readonly bannerUrl?: string;
@IsOptional()
@@ -67,10 +82,23 @@ export class UpdateOrgTeamDto {
@IsOptional()
@IsString()
@ApiPropertyOptional({
type: String,
example: "America/New_York",
description: `Timezone is used to create teams's default schedule from Monday to Friday from 9AM to 5PM. It will default to Europe/London if not passed.`,
required: false,
default: "Europe/London",
})
readonly timeZone?: string = "Europe/London";
@IsOptional()
@IsString()
@ApiPropertyOptional({
type: String,
example: "Monday",
required: false,
default: "Sunday",
})
readonly weekStart?: string = "Sunday";
@IsOptional()
@@ -1,20 +1,64 @@
import { GetUserOutput } from "@/modules/users/outputs/get-users.output";
import { ApiProperty } from "@nestjs/swagger";
import { IsEnum } from "class-validator";
import { Expose, Type } from "class-transformer";
import { IsEnum, IsInt, IsString, ValidateNested, IsArray } from "class-validator";
import { ERROR_STATUS } from "@calcom/platform-constants";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
export class GetOrganizationUsersOutput {
export class ProfileOutput {
@IsInt()
@Expose()
@ApiProperty({ type: Number, required: true, description: "The ID of the profile of user", example: 1 })
id!: number;
@IsInt()
@Expose()
@ApiProperty({
type: Number,
required: true,
description: "The ID of the organization of user",
example: 1,
})
organizationId!: number;
@IsInt()
@Expose()
@ApiProperty({ type: Number, required: true, description: "The IDof the user", example: 1 })
userId!: number;
@IsString()
@Expose()
@ApiProperty({
type: String,
nullable: true,
required: false,
description: "The username of the user within the organization context",
example: "john_doe",
})
username!: string;
}
export class GetOrgUsersWithProfileOutput extends GetUserOutput {
@ApiProperty({
description: "organization user profile, contains user data within the organizaton context",
})
@Expose()
@ValidateNested()
@IsArray()
@Type(() => ProfileOutput)
profile!: ProfileOutput;
}
export class GetOrganizationUsersResponseDTO {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
data!: GetUserOutput[];
data!: GetOrgUsersWithProfileOutput[];
}
export class GetOrganizationUserOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
data!: GetUserOutput;
data!: GetOrgUsersWithProfileOutput;
}
@@ -24,7 +24,13 @@ export class OrganizationsEventTypesRepository {
slug: eventTypeSlug,
},
},
include: { users: true, schedule: true, hosts: true, destinationCalendar: true },
include: {
users: true,
schedule: true,
hosts: true,
destinationCalendar: true,
team: { select: { bannerUrl: true } },
},
});
}
@@ -33,7 +39,13 @@ export class OrganizationsEventTypesRepository {
where: {
teamId,
},
include: { users: true, schedule: true, hosts: true, destinationCalendar: true },
include: {
users: true,
schedule: true,
hosts: true,
destinationCalendar: true,
team: { select: { bannerUrl: true } },
},
});
}
@@ -24,6 +24,13 @@ export class OrganizationsUsersRepository {
...this.filterOnOrgMembership(orgId),
...(emailArray && emailArray.length ? { email: { in: emailArray } } : {}),
},
include: {
profiles: {
where: {
organizationId: orgId,
},
},
},
skip,
take,
});
@@ -35,6 +42,13 @@ export class OrganizationsUsersRepository {
username,
...this.filterOnOrgMembership(orgId),
},
include: {
profiles: {
where: {
organizationId: orgId,
},
},
},
});
}
@@ -44,6 +58,13 @@ export class OrganizationsUsersRepository {
email,
...this.filterOnOrgMembership(orgId),
},
include: {
profiles: {
where: {
organizationId: orgId,
},
},
},
});
}
@@ -62,6 +83,13 @@ export class OrganizationsUsersRepository {
organizationId: orgId,
},
data: updateUserBody,
include: {
profiles: {
where: {
organizationId: orgId,
},
},
},
});
}
@@ -71,6 +99,13 @@ export class OrganizationsUsersRepository {
id: userId,
organizationId: orgId,
},
include: {
profiles: {
where: {
organizationId: orgId,
},
},
},
});
}
}
@@ -3,7 +3,7 @@ import { OrganizationsEventTypesRepository } from "@/modules/organizations/repos
import { UsersRepository } from "@/modules/users/users.repository";
import { Injectable } from "@nestjs/common";
import type { EventType, User, Schedule, Host, DestinationCalendar } from "@prisma/client";
import { SchedulingType } from "@prisma/client";
import { SchedulingType, Team } from "@prisma/client";
import { HostPriority, TeamEventTypeResponseHost } from "@calcom/platform-types";
@@ -12,6 +12,7 @@ type EventTypeRelations = {
schedule: Schedule | null;
hosts: Host[];
destinationCalendar?: DestinationCalendar | null;
team?: Pick<Team, "bannerUrl"> | null;
};
export type DatabaseTeamEventType = EventType & EventTypeRelations;
@@ -66,6 +67,7 @@ type Input = Pick<
| "eventName"
| "useEventTypeDestinationCalendarEmail"
| "hideCalendarEventDetails"
| "team"
>;
@Injectable()
@@ -76,12 +78,13 @@ export class OutputOrganizationsEventTypesService {
private readonly usersRepository: UsersRepository
) {}
async getResponseTeamEventType(databaseEventType: Input) {
async getResponseTeamEventType(databaseEventType: Input, isOrgTeamEvent: boolean) {
const { teamId, userId, parentId, assignAllTeamMembers } = databaseEventType;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { ownerId, users, ...rest } = this.outputEventTypesService.getResponseEventType(
0,
databaseEventType
databaseEventType,
isOrgTeamEvent
);
const hosts =
databaseEventType.schedulingType === "MANAGED"
@@ -95,6 +98,7 @@ export class OutputOrganizationsEventTypesService {
ownerId: userId,
parentEventTypeId: parentId,
schedulingType: databaseEventType.schedulingType,
bannerUrl: databaseEventType?.team?.bannerUrl,
assignAllTeamMembers: teamId ? assignAllTeamMembers : undefined,
};
}
@@ -129,9 +133,14 @@ export class OutputOrganizationsEventTypesService {
name: databaseUser?.name || "",
mandatory: databaseHost.isFixed,
priority: getPriorityLabel(databaseHost.priority || 2),
avatarUrl: databaseUser?.avatarUrl,
});
} else {
transformedHosts.push({ userId: databaseHost.userId, name: databaseUser?.name || "" });
transformedHosts.push({
userId: databaseHost.userId,
name: databaseUser?.name || "",
avatarUrl: databaseUser?.avatarUrl,
});
}
}
@@ -5,6 +5,7 @@ import { ApiTags as DocsTags, ApiCreatedResponse, ApiOkResponse, ApiOperation }
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";
@@ -89,9 +90,31 @@ export class SlotsController {
type: "array",
items: {
type: "object",
properties: {
time: { type: "string", format: "date-time", example: "2024-09-25T08:00:00.000Z" },
},
oneOf: [
{
properties: {
time: {
type: "string",
format: "date-time",
example: "2024-09-25T08:00:00.000Z",
},
},
},
{
properties: {
startTime: {
type: "string",
format: "date-time",
example: "2024-09-25T08:00:00.000Z",
},
endTime: {
type: "string",
format: "date-time",
example: "2024-09-25T08:30:00.000Z",
},
},
},
],
},
},
},
@@ -102,11 +125,18 @@ export class SlotsController {
status: "success",
data: {
slots: {
// Default format (when slotFormat is 'time' or not provided)
"2024-09-25": [{ time: "2024-09-25T08:00:00.000Z" }, { time: "2024-09-25T08:15:00.000Z" }],
// Alternative format (when slotFormat is 'range')
"2024-09-26": [
{ time: "2024-09-26T08:00:00.000Z" },
{ time: "2024-09-26T08:15:00.000Z" },
{ time: "2024-09-26T08:30:00.000Z" },
{
startTime: "2024-09-26T08:00:00.000Z",
endTime: "2024-09-26T08:30:00.000Z",
},
{
startTime: "2024-09-26T08:15:00.000Z",
endTime: "2024-09-26T08:45:00.000Z",
},
],
},
},
@@ -129,8 +159,20 @@ export class SlotsController {
},
});
const transformedSlots =
query.slotFormat === SlotFormat.Range
? await this.slotsService.formatSlots(
availableSlots,
query.duration,
query.eventTypeId,
query.slotFormat
)
: availableSlots.slots;
return {
data: availableSlots,
data: {
slots: transformedSlots,
},
status: SUCCESS_STATUS,
};
}
@@ -1,8 +1,9 @@
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 } from "@nestjs/common";
import { Injectable, NotFoundException, BadRequestException } from "@nestjs/common";
import { v4 as uuid } from "uuid";
import { SlotFormat } from "@calcom/platform-enums";
import { ReserveSlotInput } from "@calcom/platform-types";
@Injectable()
@@ -54,4 +55,51 @@ 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;
}, {});
}
}

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