Compare commits

..
159 Commits
Author SHA1 Message Date
GitHub Actions 19bd3e5849 chore: release v4.8.4
Post release / release (push) Canceled after 0s
2024-12-18 23:21:28 +00:00
Benny Joo 7827028e0a Revert "chore: app router - /reschedule pages (#18150)" (#18258)
This reverts commit a7f24e777b.
2024-12-18 22:48:03 +00:00
Joe Au-Yeung ebc3ced687 chore: CRM logging, add booking uid (#18256)
* Add more logging

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

* move pages to /app

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

* adapt test

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

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

* feat: update translations via @replexica

---------

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

* decrease precision when checking for full day event

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

* improve variable naming

* don't include hosts created after OOO

* fix failing tests

* fix all tests

* add test for ooo calibration

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

* allow mutiple day events

* make getAvailabilityWithTimeZones optional

* fix type error

* fix type errors

* fix tests

* fix type error

* fix test

* code clean up

* remove time adjustment from map

* code clean up

* fix tests

* getAllCalendars -> this.getAllCalendars

* fix typo

* extract mergeOverlappingRanges

* code improvements from feedback

* fix getCalIdsWithTimeZone

---------

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

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

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

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

* Create numbered email domain object

* Check email domain

* Rename function

* Add tests

* Frontend enable skip ownership check if free email domain

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

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

* Add requiresConfirmationForFreeEmail to db

* Add option to event type settings

* Get requiresConfirmationForFreeEmail for event type page

* Include requiresConfirmationForFreeEmail in fetching event type

* Pass bookerEmail to `getRequiresConfirmationFlags`

* Add free email domain check to `determineRequiresConfirmation`

* Add `requiresConfirmationForFreeEmail` to types

* Add severity to Watchlist table

* Add migration for watchlist severity

* Add `getEmailDomainInWatchlist` method to watchlist repository

* Use watchlist repository to check for free email domain

* Mock watchlist repository in test

* Update test

* Rename method

* Add severity to blocked list

* Move check free email domain to async

* Type checks

* Adjust for promise returned

* Fix tests

* Fix

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

* add to config.matcher

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

* move pages to /app

* remove Head component

* remove pages router

* add to config matcher

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

* docs: explain billing in FAQ

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

* refactor: make dateFrom and dateTo required

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

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

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

* feat: update translations via @replexica

---------

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

This reverts commit 1680cba5a5.

* plain.com card

* detiled error handling for get customer

* working email and id

* hmac

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

This reverts commit 06494a6999.

* pr changes requet

* remove pan for now

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

* Pushing fix for createHmac stringify

* added validation and user repository for email check

* add apiRouteMiddleware which handles the error handling

* HMAC_SECRET_KEY -> PLAIN_HMAC_SECRET_KEY

* Use the right error

* Convey right error to consumer

* Fixup apiRouteMiddleware to handle handler

* Don't export handler, only export POST

* changed to app directory

* working unkown user card

---------

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

* refactor: use ApiAuthGuardUser instead of GetUserReturnType

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

* refactor: rely on teams event types service and repository

* finish mergin main into this branch

* test: teams event types

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

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

* chore: finish merge main

* refactor: logger name and unused import

* chore: finish main merge

---------

Co-authored-by: Somay Chauhan <somaychauhan98@gmail.com>
2024-12-17 20:25:42 +01:00
Omar LópezandKeith Williams ae7bbaf414 fix: calendar cache date ranges (#18237)
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-12-17 19:20:19 +00:00
Hariom Balhara 4df4383e1e fixes:CAL-4905 (#18222) 2024-12-17 22:51:42 +05:30
GitHub Actions a0706d9b51 chore: release v4.8.2
Post release / release (push) Canceled after 0s
2024-12-17 17:05:26 +00:00
Omar LópezandKeith Williams 2eb385af48 feat: allows forcing/skipping calendar cache serving (#18224)
* feat: allows forcing/skipping calendar cache serving

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

* Update features.repository.ts

* Added to HNB

* type fixes

* Update packages/prisma/migrations/20241216000000_add_calendar_cache_serve/migration.sql

Co-authored-by: Keith Williams <keithwillcode@gmail.com>

* Update packages/prisma/zod-utils.ts

Co-authored-by: Keith Williams <keithwillcode@gmail.com>

* Update selectedCalendar.ts

---------

Signed-off-by: Omar López <zomars@me.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-12-17 16:54:55 +00:00
Lauris Skraucis a0f986f949 feat: v2 teams CRUD (#18019)
* feat: teams endpoints

* refactor: organization teams reuse team types

* refactor: move team specific functions out of organizations-teams.repository

* supporting files

* regenerate docs

* feat: stripe service to generate team creation checkout

* refactor: request payment when creating a team

* fix: tests

* fix env

* driveby: event type name and email booking fields

* finish merge main
2024-12-17 14:19:57 +00:00
Keith Williams b0094bad2d perf: More tracing for handleNewBooking (#18233) 2024-12-17 12:29:41 +00:00
Rajiv Sahal c732c06835 fix: bookerEmbed atom (#18168)
* update translations

* add custom hook for platform embed

* update event type ouput DTO response

* fixup

* display terms and conditions for embed

* update v2 docs

* PR feedback

* update docs

* PR feedback

* update docs

* update output DTO

* update docs

* update output typing

* docs update

* resolve merge conflicts

* merge conflicts resolving

* resolve merge conflicts

* fixup

* update docs
2024-12-17 12:07:47 +00:00
Anik Dhabal Babu c1334475c5 fix: private team does not hide team members from manged event type (#18166) 2024-12-17 17:05:15 +05:30
Lauris Skraucis b35dc43011 feat: v2 wildcard redirectURIs (#18165)
* feat: isOriginAllowed function

* feat: use isOriginAllowed in v2

* refactor: shorten isOriginAllowed
2024-12-17 11:21:59 +00:00
Morgan c7dc3ecc49 chore: add TLD in username of managed users (#18229)
* chore: add TLD in username of managed users

* fixup! chore: add TLD in username of managed users

* bump platform libraries
2024-12-17 12:45:48 +02:00
Somay Chauhan a613c975d3 refactor: added ApiPropertyOptional decorator for optional fields in api for better documentation (#18208)
* update decorators 1

* update decorators 2

* update decorators 3

* update decorators 4

* update decorators 4

* update decorators 6

* update decorators 7

* added documentation,json and openapi.json

* implemented feedback

* remove default values from update dto's

* update

* removed default from redirectUris
2024-12-17 10:29:07 +00:00
Morgan 5fad7c9478 fix: redirect on calendar oauth denied auth (#18228) 2024-12-17 09:05:10 +00:00
Udit Takkar 318470d866 fix: webhook improvement (#18220) 2024-12-17 14:32:38 +05:30
Morgan a0ce42e375 chore: improve prisma exception filter messages (#18227)
* chore: improve prisma exception filter messages

* fixup! chore: improve prisma exception filter messages
2024-12-17 08:40:19 +00:00
Anik Dhabal Babu 5293a5cc54 fix: column f.userid does not exist (#18215) 2024-12-17 08:37:48 +00:00
Benny Joo 42aefc9032 perf: limit # of host members for OG image (#18219)
* fetch only 5 hosts and only first user

* refactor

* limit to 3
2024-12-17 08:15:11 +00:00
Keith Williams 14acf4e9bc chore: New booking perf tracing (#18212)
* chore: Add perf tracing for handleNewBooking

* Added a few more to the event manager

* Added the sentry wrapper that enables perf tracing

* Revert "Added a few more to the event manager"

This reverts commit ccf3ee5200466b8b1d51f5663363fbb9dfd9a06f.

* Fixed where we wrap sentry

* Reverse sentry wrapper
2024-12-17 08:51:00 +01:00
Lauris Skraucis 80815bd8a5 feat: Booker atom slots callbacks (#18203) 2024-12-17 12:58:18 +05:30
Benny Joo 9ed8face03 Revert "feat: remove setup-availability step in onboarding flow (#17799)" (#18205)
This reverts commit a466e8eef7.
2024-12-16 22:03:41 +00:00
Udit Takkar 18a107d081 fix: trigger guest no show bug (#18164)
* fix: trigger guest no show

* test: add tests for trigger guest no show
2024-12-16 11:43:03 -05:00
Alex van Andel 04d7537e2f perf: improve insights where query and type fix (#18211)
* perf: insights perf improvement

* removed additional const
2024-12-16 15:50:20 +00:00
Udit Takkar 423b17a819 refactor: getContacts (#18137) 2024-12-16 10:17:59 -05:00
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
759 changed files with 34687 additions and 9176 deletions
+11 -11
View File
@@ -107,6 +107,12 @@ NEXT_PUBLIC_POSTHOG_KEY=
NEXT_PUBLIC_POSTHOG_HOST=
# plain.com config
PLAIN_API_KEY=
PLAIN_API_URL=https://api.plain.com/v1
PLAIN_HMAC_SECRET_KEY=
# Zendesk Config
NEXT_PUBLIC_ZENDESK_KEY=
@@ -239,7 +245,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=
@@ -354,15 +361,9 @@ APP_ROUTER_APPS_SLUG_SETUP_ENABLED=0
APP_ROUTER_APPS_CATEGORIES_ENABLED=0
# whether we redirect to the future/apps/categories/[category] from /apps/categories/[category] or not
APP_ROUTER_APPS_CATEGORIES_CATEGORY_ENABLED=0
APP_ROUTER_BOOKING_ENABLED=0
APP_ROUTER_BOOKINGS_STATUS_ENABLED=0
APP_ROUTER_WORKFLOWS_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
@@ -371,10 +372,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
@@ -414,3 +411,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
+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: ./
+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
+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
+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
+2
View File
@@ -0,0 +1,2 @@
undecided:
- calcom-monorepo
+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"
+2 -2
View File
@@ -1,9 +1,9 @@
import type { NextApiRequest } from "next";
import { checkIfEmailInWatchlistController } from "@calcom/features/watchlist/operations/check-if-email-in-watchlist.controller";
import { checkIfEmailIsBlockedInWatchlistController } from "@calcom/features/watchlist/operations/check-if-email-in-watchlist.controller";
export async function isLockedOrBlocked(req: NextApiRequest) {
const user = req.user;
if (!user?.email) return false;
return user.locked || (await checkIfEmailInWatchlistController(user.email));
return user.locked || (await checkIfEmailIsBlockedInWatchlistController(user.email));
}
+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,
});
@@ -2,6 +2,8 @@ import prismock from "../../../../../../tests/libs/__mocks__/prisma";
import { describe, expect, it, beforeEach } from "vitest";
import { WatchlistSeverity } from "@calcom/prisma/enums";
import { isLockedOrBlocked } from "../../../lib/utils/isLockedOrBlocked";
describe("isLockedOrBlocked", () => {
@@ -16,6 +18,7 @@ describe("isLockedOrBlocked", () => {
{
type: "DOMAIN",
value: "blocked.com",
severity: WatchlistSeverity.CRITICAL,
createdById: 1,
},
],
+1
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.69",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.73",
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
+2
View File
@@ -30,6 +30,8 @@ const loadConfig = (): AppConfig => {
stripe: {
apiKey: getEnv("STRIPE_API_KEY"),
webhookSecret: getEnv("STRIPE_WEBHOOK_SECRET"),
teamMonthlyPriceId: getEnv("STRIPE_TEAM_MONTHLY_PRICE_ID", "set-team-monthly-price-in-your-env"),
isTeamBillingEnabled: getEnv("IS_TEAM_BILLING_ENABLED", true),
},
app: {
baseUrl: getEnv("WEB_APP_URL", "https://app.cal.com"),
+2
View File
@@ -21,6 +21,8 @@ export type AppConfig = {
stripe: {
apiKey: string;
webhookSecret: string;
teamMonthlyPriceId: string;
isTeamBillingEnabled: boolean;
};
app: {
baseUrl: string;
@@ -1,3 +1,4 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Transform, Type } from "class-transformer";
import {
IsBoolean,
@@ -13,53 +14,66 @@ import {
class Location {
@IsString()
@ApiProperty()
optionValue!: string;
@IsString()
@ApiProperty()
value!: string;
}
class Response {
@IsString()
@ApiProperty()
name!: string;
@IsEmail()
@ApiProperty()
email!: string;
@IsArray()
@IsString({ each: true })
@ApiProperty({ type: [String] })
guests!: string[];
@IsOptional()
@ValidateNested()
@Type(() => Location)
@ApiPropertyOptional({ type: Location })
location?: Location;
@IsOptional()
@IsString()
@ApiPropertyOptional()
notes?: string;
}
export class CreateBookingInput_2024_04_15 {
@IsString()
@IsOptional()
@ApiPropertyOptional()
end?: string;
@IsString()
@ApiProperty()
start!: string;
@IsNumber()
@ApiProperty()
eventTypeId!: number;
@IsString()
@IsOptional()
@ApiPropertyOptional()
eventTypeSlug?: string;
@IsString()
@IsOptional()
@ApiPropertyOptional()
rescheduleUid?: string;
@IsTimeZone()
@ApiProperty()
timeZone!: string;
@Transform(({ value }: { value: string | string[] }) => {
@@ -68,38 +82,48 @@ export class CreateBookingInput_2024_04_15 {
@IsOptional()
@IsArray()
@IsString({ each: true })
@ApiPropertyOptional({ type: [String] })
user?: string[];
@IsString()
@ApiProperty()
language!: string;
@IsString()
@IsOptional()
@ApiPropertyOptional()
bookingUid?: string;
@IsObject()
@ApiProperty({ type: Object })
metadata!: Record<string, string>;
@IsBoolean()
@IsOptional()
@ApiPropertyOptional()
hasHashedBookingLink?: boolean;
@IsString()
@IsOptional()
@ApiPropertyOptional()
hashedLink!: string | null;
@IsString()
@IsOptional()
@ApiPropertyOptional()
seatReferenceUid?: string;
@Type(() => Response)
@ApiProperty({ type: Response })
responses!: Response;
@IsString()
@IsOptional()
@ApiPropertyOptional()
orgSlug?: string;
@IsString()
@IsOptional()
@ApiPropertyOptional()
locationUrl?: string;
}
@@ -1,4 +1,5 @@
import { CreateBookingInput_2024_04_15 } from "@/ee/bookings/2024-04-15/inputs/create-booking.input";
import { ApiPropertyOptional } from "@nestjs/swagger";
import { IsBoolean, IsString, IsNumber, IsOptional } from "class-validator";
import type { AppsStatus } from "@calcom/platform-libraries";
@@ -6,23 +7,29 @@ import type { AppsStatus } from "@calcom/platform-libraries";
export class CreateRecurringBookingInput_2024_04_15 extends CreateBookingInput_2024_04_15 {
@IsBoolean()
@IsOptional()
@ApiPropertyOptional()
noEmail?: boolean;
@IsOptional()
@IsNumber()
@ApiPropertyOptional()
recurringCount?: number;
@IsOptional()
@ApiPropertyOptional({ type: [Object] })
appsStatus?: AppsStatus[] | undefined;
@IsOptional()
@ApiPropertyOptional({ type: [Object] })
allRecurringDates?: Record<string, string>[];
@IsOptional()
@IsNumber()
@ApiPropertyOptional()
currentRecurringIndex?: number;
@IsString()
@IsOptional()
@ApiPropertyOptional()
recurringEventId?: string;
}
@@ -1,22 +1,27 @@
import { ApiPropertyOptional, ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsOptional, IsArray, IsEmail, IsBoolean, ValidateNested } from "class-validator";
class Attendee {
@IsEmail()
@ApiProperty()
email!: string;
@IsBoolean()
@ApiProperty()
noShow!: boolean;
}
export class MarkNoShowInput_2024_04_15 {
@IsBoolean()
@IsOptional()
@ApiPropertyOptional()
noShowHost?: boolean;
@ValidateNested()
@Type(() => Attendee)
@IsArray()
@IsOptional()
@ApiPropertyOptional({ type: [Attendee] })
attendees?: Attendee[];
}
@@ -1,4 +1,4 @@
import { ApiProperty } from "@nestjs/swagger";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer";
import {
IsString,
@@ -17,145 +17,185 @@ import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
class Metadata {
@IsUrl()
@ApiProperty()
videoCallUrl!: string;
}
class Location {
@IsString()
@ApiProperty()
optionValue!: string;
@IsString()
@ApiProperty()
value!: string;
}
class Response {
@IsString()
@ApiProperty()
name!: string;
@IsEmail()
@ApiProperty()
email!: string;
@IsString()
@ApiProperty()
notes!: string;
@IsArray()
@IsString({ each: true })
@ApiProperty({ type: [String] })
guests!: string[];
@ValidateNested()
@Type(() => Location)
@ApiProperty({ type: Location })
location!: Location;
}
class User {
@IsInt()
@ApiProperty()
id!: number;
@IsString()
@ApiProperty({ type: String, nullable: true })
name!: string | null;
@IsEmail()
@ApiProperty()
email!: string;
@IsString()
@ApiProperty({ type: String, nullable: true })
username!: string | null;
@IsString()
@ApiProperty()
timeZone!: string;
}
class Attendee {
@IsString()
@ApiProperty()
name!: string;
@IsEmail()
@ApiProperty()
email!: string;
@IsString()
@ApiProperty()
timeZone!: string;
}
class EventType {
@IsOptional()
@IsString()
@ApiProperty({ type: String, nullable: true })
eventName!: string | null;
@IsString()
@ApiProperty()
slug!: string;
@IsOptional()
@IsString()
@ApiProperty({ type: String, nullable: true })
timeZone!: string | null;
}
class GetBookingData_2024_04_15 {
@IsString()
@ApiProperty()
title!: string;
@IsInt()
@ApiProperty()
id!: number;
@IsString()
@ApiProperty()
uid!: string;
@IsString()
@ApiProperty({ type: String, nullable: true })
description!: string | null;
@IsObject()
@ApiProperty({ type: Object })
customInputs!: any;
@IsOptional()
@IsString()
@ApiProperty({ type: String, nullable: true })
smsReminderNumber!: string | null;
@IsOptional()
@IsString()
@ApiProperty({ type: String, nullable: true })
recurringEventId!: string | null;
@IsDateString()
@ApiProperty()
startTime!: Date;
@IsDateString()
@ApiProperty()
endTime!: Date;
@IsUrl()
@ApiProperty({ type: String, nullable: true })
location!: string | null;
@IsString()
@ApiProperty()
status!: string;
@Type(() => Metadata)
@ApiProperty({ type: Metadata })
metadata!: Metadata | any;
@IsOptional()
@IsString()
@ApiProperty({ type: String, nullable: true })
cancellationReason!: string | null;
@ValidateNested()
@Type(() => Response)
@ApiProperty({ type: Response })
responses!: Response | any;
@IsOptional()
@IsString()
@ApiProperty({ type: String, nullable: true })
rejectionReason!: string | null;
@IsString()
@IsEmail()
@ApiProperty({ type: String, nullable: true })
userPrimaryEmail!: string | null;
@ValidateNested()
@Type(() => User)
@ApiProperty({ type: User, nullable: true })
user!: User | null;
@ValidateNested()
@Type(() => Attendee)
@IsArray()
@ApiProperty({ type: [Attendee] })
attendees!: Attendee[];
@IsInt()
@ApiProperty({ type: Number, nullable: true })
eventTypeId!: number | null;
@ValidateNested()
@Type(() => EventType)
@ApiProperty({ type: EventType, nullable: true })
eventType!: EventType | null;
}
@@ -1,4 +1,4 @@
import { ApiProperty } from "@nestjs/swagger";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer";
import {
IsString,
@@ -26,179 +26,230 @@ enum Status {
class Attendee {
@IsInt()
@ApiProperty()
id!: number;
@IsEmail()
@ApiProperty()
email!: string;
@IsString()
@ApiProperty()
name!: string;
@IsString()
@ApiProperty()
timeZone!: string;
@IsString()
@ApiProperty({ type: String, nullable: true })
locale!: string | null;
@IsInt()
@ApiProperty({ type: Number, nullable: true })
bookingId!: number | null;
}
class EventType {
@IsString()
@IsOptional()
@IsString()
@ApiPropertyOptional()
slug?: string;
@IsOptional()
@IsInt()
@ApiPropertyOptional()
id?: number;
@IsOptional()
@IsString()
@ApiProperty({ type: String, nullable: true })
eventName?: string | null;
@IsInt()
@ApiProperty()
price!: number;
@IsOptional()
@ApiPropertyOptional()
recurringEvent?: any;
@IsString()
@ApiProperty()
currency!: string;
@IsObject()
@ApiProperty()
metadata!: any;
@IsBoolean()
@IsOptional()
seatsShowAttendees?: boolean | undefined | null;
@ApiPropertyOptional({ type: Boolean, nullable: true })
seatsShowAttendees?: boolean | null;
@IsBoolean()
@IsOptional()
seatsShowAvailabilityCount?: boolean | undefined | null;
@ApiPropertyOptional({ type: Boolean, nullable: true })
seatsShowAvailabilityCount?: boolean | null;
@IsOptional()
@ApiPropertyOptional({ nullable: true })
team?: any | null;
}
class Reference {
@IsInt()
@ApiProperty()
id!: number;
@IsString()
@ApiProperty()
type!: string;
@IsString()
@ApiProperty()
uid!: string;
@IsOptional()
@IsString()
@ApiProperty({ type: String, nullable: true })
meetingId?: string | null;
@IsOptional()
@IsString()
@ApiProperty({ type: String, nullable: true })
thirdPartyRecurringEventId?: string | null;
@IsString()
@ApiProperty({ type: String, nullable: true })
meetingPassword!: string | null;
@IsOptional()
@IsString()
@ApiProperty({ type: String, nullable: true })
meetingUrl?: string | null;
@IsInt()
@ApiProperty({ type: Number, nullable: true })
bookingId!: number | null;
@IsEmail()
@ApiProperty({ type: String, nullable: true })
externalCalendarId!: string | null;
@IsOptional()
@ApiPropertyOptional()
deleted?: any;
@IsInt()
@ApiProperty({ type: Number, nullable: true })
credentialId!: number | null;
}
class User {
@IsInt()
@ApiProperty()
id!: number;
@IsString()
@ApiProperty({ type: String, nullable: true })
name!: string | null;
@IsEmail()
@ApiProperty()
email!: string;
}
class GetBookingsDataEntry {
@IsInt()
@ApiProperty()
id!: number;
@IsString()
@ApiProperty()
title!: string;
@IsOptional()
@IsEmail()
@ApiProperty({ type: String, nullable: true })
userPrimaryEmail?: string | null;
@IsString()
@ApiProperty({ type: String, nullable: true })
description!: string | null;
@IsObject()
@ApiProperty({ type: Object })
customInputs!: object | any;
@IsDateString()
@ApiProperty()
startTime!: string;
@IsDateString()
@ApiProperty()
endTime!: string;
@ValidateNested({ each: true })
@Type(() => Attendee)
@IsArray()
@ApiProperty({ type: [Attendee] })
attendees!: Attendee[];
@ApiProperty()
metadata!: any;
@IsString()
@ApiProperty()
uid!: string;
@IsOptional()
@IsString()
@ApiProperty({ type: String, nullable: true })
recurringEventId!: string | null;
@IsUrl()
@ApiProperty({ type: String, nullable: true })
location!: string | null;
@ValidateNested()
@Type(() => EventType)
@ApiProperty({ type: EventType })
eventType!: EventType;
@IsEnum(Status)
status!: "CANCELLED" | "REJECTED" | "ACCEPTED" | "PENDING" | "AWAITING_HOST";
@ApiProperty({ enum: Status, type: String })
status!: Status;
@IsBoolean()
@ApiProperty({ type: Boolean })
paid!: boolean;
@IsArray()
@ApiProperty()
payment!: any[];
@ValidateNested()
@Type(() => Reference)
@IsArray()
@ApiProperty({ type: [Reference] })
references!: Reference[];
@IsBoolean()
@ApiProperty({ type: Boolean })
isRecorded!: boolean;
@IsArray()
@ApiProperty()
seatsReferences!: any[];
@ValidateNested()
@Type(() => User)
@ApiProperty({ type: User })
user!: User | null;
@IsOptional()
@ApiPropertyOptional()
rescheduled?: any;
}
@@ -206,17 +257,20 @@ class GetBookingsData_2024_04_15 {
@ValidateNested()
@Type(() => GetBookingsDataEntry)
@IsArray()
@ApiProperty({ type: [GetBookingsDataEntry] })
bookings!: GetBookingsDataEntry[];
@IsArray()
@ApiProperty()
recurringInfo!: any[];
@IsInt()
@ApiProperty({ type: Number, nullable: true })
nextCursor!: number | null;
}
export class GetBookingsOutput_2024_04_15 {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS], type: String })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@@ -1,4 +1,4 @@
import { ApiProperty } from "@nestjs/swagger";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsString, IsEnum, IsOptional, ValidateNested, IsArray, IsEmail, IsBoolean } from "class-validator";
@@ -6,24 +6,29 @@ import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
class Attendee {
@IsEmail()
@ApiProperty()
email!: string;
@IsBoolean()
@ApiProperty()
noShow!: boolean;
}
class HandleMarkNoShowData_2024_04_15 {
@IsString()
@ApiProperty()
message!: string;
@IsBoolean()
@IsOptional()
@ApiPropertyOptional()
noShowHost?: boolean;
@ValidateNested()
@Type(() => Attendee)
@IsArray()
@IsOptional()
@ApiPropertyOptional({ type: [Attendee] })
attendees?: Attendee[];
}
@@ -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,
@@ -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,
@@ -364,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,
@@ -434,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,
@@ -455,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,
@@ -475,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,
@@ -340,7 +340,10 @@ export class BookingsService_2024_08_13 {
}
const bookingRequest = await this.inputService.createCancelBookingRequest(request, bookingUid, body);
await handleCancelBooking(bookingRequest);
const res = await handleCancelBooking(bookingRequest);
if (!res.onlyRemovedAttendee) {
await this.billingService.cancelUsageByBookingUid(res.bookingUid);
}
if ("cancelSubsequentBookings" in body && body.cancelSubsequentBookings) {
return this.getAllRecurringBookingsByIndividualUid(bookingUid);
@@ -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";
@@ -30,9 +37,10 @@ export const bookingResponsesSchema = z
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({
@@ -47,7 +55,10 @@ export const seatedBookingResponsesSchema = z
})
.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 };
@@ -72,8 +83,6 @@ 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) {}
@@ -82,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 = {
@@ -167,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 = {
@@ -225,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 = {
@@ -254,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,
@@ -267,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;
@@ -320,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 = {
@@ -353,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,
@@ -366,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}`)
@@ -86,18 +86,16 @@ export class CalendarsController {
@UseGuards(ApiAuthGuard)
@Get("/busy-times")
@ApiOperation({ summary: "Get busy times" })
@ApiOperation({
summary: "Get busy times",
description:
"Get busy times from a calendar. Example request URL is `https://api.cal.com/v2/calendars/busy-times?loggedInUsersTz=Europe%2FMadrid&dateFrom=2024-12-18&dateTo=2024-12-18&calendarsToLoad[0][credentialId]=135&calendarsToLoad[0][externalId]=skrauciz%40gmail.com`",
})
async getBusyTimes(
@Query() queryParams: CalendarBusyTimesInput,
@GetUser() user: UserWithProfile
): Promise<GetBusyTimesOutput> {
const { loggedInUsersTz, dateFrom, dateTo, calendarsToLoad } = queryParams;
if (!dateFrom || !dateTo) {
return {
status: SUCCESS_STATUS,
data: [],
};
}
const busyTimes = await this.calendarsService.getBusyTimes(
calendarsToLoad,
@@ -1,4 +1,4 @@
import { ApiProperty } from "@nestjs/swagger";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import {
ArrayNotEmpty,
IsBoolean,
@@ -50,11 +50,10 @@ export class CreateIcsFeedInputDto {
urls!: string[];
@IsBoolean()
@ApiProperty({
@ApiPropertyOptional({
example: false,
description: "Whether to allowing writing to the calendar or not",
type: "boolean",
required: false,
default: true,
})
@IsOptional()
@@ -1,4 +1,4 @@
import { ApiProperty } from "@nestjs/swagger";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsArray, IsDate, IsEnum, IsOptional, IsString, ValidateNested } from "class-validator";
@@ -6,13 +6,16 @@ import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
export class BusyTimesOutput {
@IsDate()
@ApiProperty({ type: Date })
start!: Date;
@IsDate()
@ApiProperty({ type: Date })
end!: Date;
@IsOptional()
@IsString()
@ApiPropertyOptional({ type: String, nullable: true })
source?: string | null;
}
@@ -24,5 +27,6 @@ export class GetBusyTimesOutput {
@ValidateNested()
@Type(() => BusyTimesOutput)
@IsArray()
@ApiProperty({ type: [BusyTimesOutput] })
data!: BusyTimesOutput[];
}
@@ -1,4 +1,4 @@
import { ApiProperty } from "@nestjs/swagger";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer";
import {
IsArray,
@@ -18,195 +18,246 @@ import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
class Integration {
@IsOptional()
@IsObject()
@ApiPropertyOptional({ type: Object, nullable: true })
appData?: object | null;
@IsOptional()
@IsString()
@ApiPropertyOptional()
dirName?: string;
@IsOptional()
@IsString()
@ApiPropertyOptional()
__template?: string;
@IsString()
@ApiProperty()
name!: string;
@IsString()
@ApiProperty()
description!: string;
@IsOptional()
@IsBoolean()
@ApiPropertyOptional()
installed?: boolean;
@IsString()
@ApiProperty()
type!: string;
@IsOptional()
@IsString()
@ApiPropertyOptional()
title?: string;
@IsString()
@ApiProperty()
variant!: string;
@IsOptional()
@IsString()
@ApiPropertyOptional()
category?: string;
@IsArray()
@IsString({ each: true })
@ApiProperty({ type: [String] })
categories!: string[];
@IsString()
@ApiProperty()
logo!: string;
@IsString()
@ApiProperty()
publisher!: string;
@IsString()
@ApiProperty()
slug!: string;
@IsUrl()
@ApiProperty()
url!: string;
@IsEmail()
@ApiProperty()
email!: string;
@IsOptional()
@IsObject()
@ApiProperty({ type: Object, nullable: true })
locationOption!: object | null;
}
class Primary {
@IsEmail()
@ApiProperty()
externalId!: string;
@IsString()
@IsOptional()
@ApiPropertyOptional()
integration?: string;
@IsOptional()
@IsEmail()
@ApiPropertyOptional()
name?: string;
@IsBoolean()
@ApiProperty({ type: Boolean, nullable: true })
primary!: boolean | null;
@IsBoolean()
@ApiProperty()
readOnly!: boolean;
@IsEmail()
@IsOptional()
@ApiPropertyOptional()
email?: string;
@IsBoolean()
@ApiProperty()
isSelected!: boolean;
@IsInt()
@ApiProperty()
credentialId!: number;
}
export class Calendar {
@IsEmail()
@ApiProperty()
externalId!: string;
@IsString()
@IsOptional()
@ApiPropertyOptional()
integration?: string;
@IsEmail()
@IsOptional()
@ApiPropertyOptional()
name?: string;
@IsOptional()
@IsBoolean()
@ApiPropertyOptional({ type: Boolean, nullable: true })
primary?: boolean | null;
@IsBoolean()
@ApiProperty()
readOnly!: boolean;
@IsEmail()
@IsOptional()
@ApiPropertyOptional()
email?: string;
@IsBoolean()
@ApiProperty()
isSelected!: boolean;
@IsInt()
@ApiProperty()
credentialId!: number;
}
export class ConnectedCalendar {
@ValidateNested()
@IsObject()
@ApiProperty({ type: Integration })
integration!: Integration;
@IsInt()
@ApiProperty()
credentialId!: number;
@ValidateNested()
@IsObject()
@IsOptional()
@ApiPropertyOptional({ type: Primary })
primary?: Primary;
@ValidateNested({ each: true })
@IsArray()
@IsOptional()
@ApiPropertyOptional({ type: [Calendar] })
calendars?: Calendar[];
}
class DestinationCalendar {
@IsInt()
@ApiProperty()
id!: number;
@IsString()
@ApiProperty()
integration!: string;
@IsEmail()
@ApiProperty()
externalId!: string;
@IsEmail()
@ApiProperty({ type: String, nullable: true })
primaryEmail!: string | null;
@IsInt()
@ApiProperty({ nullable: true })
userId!: number | null;
@IsOptional()
@IsInt()
@ApiProperty({ type: Number, nullable: true })
eventTypeId!: number | null;
@IsInt()
@ApiProperty({ type: Number, nullable: true })
credentialId!: number | null;
@IsString()
@IsOptional()
@ApiPropertyOptional({ type: String, nullable: true })
name?: string | null;
@IsBoolean()
@IsOptional()
@ApiPropertyOptional()
primary?: boolean;
@IsBoolean()
@IsOptional()
@ApiPropertyOptional()
readOnly?: boolean;
@IsEmail()
@IsOptional()
@ApiPropertyOptional()
email?: string;
@IsString()
@IsOptional()
@ApiPropertyOptional()
integrationTitle?: string;
}
export class ConnectedCalendarsData {
@ValidateNested({ each: true })
@IsArray()
@ApiProperty({ type: [ConnectedCalendar] })
connectedCalendars!: ConnectedCalendar[];
@ValidateNested()
@IsObject()
@ApiProperty({ type: DestinationCalendar })
destinationCalendar!: DestinationCalendar;
}
export class ConnectedCalendarsOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
@@ -214,5 +265,6 @@ export class ConnectedCalendarsOutput {
@ValidateNested()
@Type(() => ConnectedCalendarsData)
@ApiProperty({ type: ConnectedCalendarsData })
data!: ConnectedCalendarsData;
}
@@ -64,7 +64,6 @@ export class CalendarsService {
);
try {
const calendarBusyTimes = await getBusyCalendarTimes(
"",
credentials,
dateFrom,
dateTo,
@@ -4,6 +4,7 @@ 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";
@@ -11,7 +12,6 @@ import { ConfigService } from "@nestjs/config";
import { Prisma } from "@prisma/client";
import { Request } from "express";
import { OAuth2Client } from "googleapis-common";
import { calendar_v3 } from "@googleapis/calendar";
import { z } from "zod";
import { SUCCESS_STATUS, GOOGLE_CALENDAR_TYPE } from "@calcom/platform-constants";
@@ -91,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);
@@ -116,7 +116,7 @@ export class GoogleCalendarService implements OAuthCalendarApp {
) {
// User chose not to authorize your app or didn't authorize your app
// redirect directly without oauth code
if (!code) {
if (!code || code === "undefined") {
return { url: redir || origin };
}
@@ -151,7 +151,7 @@ export class OutlookService implements OAuthCalendarApp {
redir?: string
) {
// if code is not defined, user denied to authorize office 365 app, just redirect straight away
if (!code) {
if (!code || code === "undefined") {
return { url: redir || origin };
}
@@ -1,5 +1,5 @@
import { EventTypeLocation_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/inputs/event-type-location.input";
import { ApiProperty as DocsProperty, ApiHideProperty } from "@nestjs/swagger";
import { ApiProperty as DocsProperty, ApiPropertyOptional, ApiHideProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import {
IsString,
@@ -36,7 +36,10 @@ export class CreateEventTypeInput_2024_04_15 {
@IsOptional()
@IsString()
@DocsProperty({ example: CREATE_EVENT_DESCRIPTION_EXAMPLE })
@ApiPropertyOptional({
type: String,
example: CREATE_EVENT_DESCRIPTION_EXAMPLE,
})
description?: string;
@IsOptional()
@@ -48,30 +51,38 @@ export class CreateEventTypeInput_2024_04_15 {
@ValidateNested({ each: true })
@Type(() => EventTypeLocation_2024_04_15)
@IsArray()
@ApiPropertyOptional({
type: [EventTypeLocation_2024_04_15],
})
locations?: EventTypeLocation_2024_04_15[];
@IsBoolean()
@IsOptional()
@IsBoolean()
@ApiPropertyOptional()
disableGuests?: boolean;
@IsOptional()
@IsInt()
@Min(0)
@IsOptional()
@ApiPropertyOptional()
slotInterval?: number;
@IsOptional()
@IsInt()
@Min(0)
@IsOptional()
@ApiPropertyOptional()
minimumBookingNotice?: number;
@IsOptional()
@IsInt()
@Min(0)
@IsOptional()
@ApiPropertyOptional()
beforeEventBuffer?: number;
@IsOptional()
@IsInt()
@Min(0)
@IsOptional()
@ApiPropertyOptional()
afterEventBuffer?: number;
// @ApiHideProperty()
@@ -1,4 +1,4 @@
import { ApiProperty as DocsProperty, ApiHideProperty } from "@nestjs/swagger";
import { ApiProperty, ApiHideProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsString, IsNumber, IsBoolean, IsOptional, IsUrl } from "class-validator";
// note(Lauris): We will gradually expose more properties if any customer needs them.
@@ -6,7 +6,7 @@ import { IsString, IsNumber, IsBoolean, IsOptional, IsUrl } from "class-validato
export class EventTypeLocation_2024_04_15 {
@IsString()
@DocsProperty({ example: "link" })
@ApiProperty({ example: "link" })
type!: string;
@IsOptional()
@@ -16,7 +16,7 @@ export class EventTypeLocation_2024_04_15 {
@IsOptional()
@IsUrl()
@DocsProperty({ example: "https://masterchief.com/argentina/flan/video/9129412" })
@ApiPropertyOptional({ example: "https://masterchief.com/argentina/flan/video/9129412" })
link?: string;
@IsOptional()
@@ -1,4 +1,4 @@
import { ApiProperty } from "@nestjs/swagger";
import { ApiPropertyOptional } from "@nestjs/swagger";
import { Transform } from "class-transformer";
import { IsBoolean, IsOptional, IsString } from "class-validator";
@@ -6,11 +6,11 @@ export class GetPublicEventTypeQueryParams_2024_04_15 {
@Transform(({ value }: { value: string }) => value === "true")
@IsBoolean()
@IsOptional()
@ApiProperty({ required: false })
@ApiPropertyOptional()
isTeamEvent?: boolean;
@ApiProperty({ required: false })
@IsString()
@IsOptional()
@IsString()
@ApiPropertyOptional({ type: String, nullable: true })
org?: string | null;
}
@@ -2,6 +2,7 @@ import { Editable } from "@/ee/event-types/event-types_2024_04_15/inputs/enums/e
import { BaseField } from "@/ee/event-types/event-types_2024_04_15/inputs/enums/field-type";
import { Frequency } from "@/ee/event-types/event-types_2024_04_15/inputs/enums/frequency";
import { EventTypeLocation_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/inputs/event-type-location.input";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer";
import {
IsString,
@@ -21,206 +22,259 @@ import {
class Option {
@IsString()
@ApiProperty()
value!: string;
@IsString()
@ApiProperty()
label!: string;
}
class Source {
@IsString()
@ApiProperty()
id!: string;
@IsString()
@ApiProperty()
type!: string;
@IsString()
@ApiProperty()
label!: string;
@IsOptional()
@IsString()
@ApiPropertyOptional()
editUrl?: string;
@IsOptional()
@IsBoolean()
@ApiPropertyOptional()
fieldRequired?: boolean;
}
class View {
@IsString()
@ApiProperty()
id!: string;
@IsString()
@ApiProperty()
label!: string;
@IsOptional()
@IsString()
@ApiPropertyOptional()
description?: string;
}
class OptionsInput {
@IsString()
@ApiProperty({
description: 'Type of the field, can be one of "address", "text", or "phone".',
enum: ["address", "text", "phone"],
example: "text",
})
type!: "address" | "text" | "phone";
@IsOptional()
@IsBoolean()
@ApiPropertyOptional()
required?: boolean;
@IsOptional()
@IsString()
@ApiPropertyOptional()
placeholder?: string;
}
class VariantField {
@IsString()
@ApiProperty()
type!: BaseField;
@IsString()
@ApiProperty()
name!: string;
@IsOptional()
@IsString()
@ApiPropertyOptional()
label?: string;
@IsOptional()
@IsString()
@ApiPropertyOptional()
labelAsSafeHtml?: string;
@IsOptional()
@IsString()
@ApiPropertyOptional()
placeholder?: string;
@IsOptional()
@IsBoolean()
@ApiPropertyOptional()
required?: boolean;
}
class Variant {
@ValidateNested({ each: true })
@Type(() => VariantField)
@ApiProperty({ type: [VariantField] })
fields!: VariantField[];
}
class VariantsConfig {
@ApiProperty({ type: Object })
variants!: Record<string, Variant>;
}
export class BookingField_2024_04_15 {
@IsEnum(BaseField)
@ApiProperty({ enum: BaseField })
type!: BaseField;
@IsString()
@ApiProperty()
name!: string;
@IsOptional()
@ValidateNested({ each: true })
@Type(() => Option)
@ApiPropertyOptional({ type: [Option] })
options?: Option[];
@IsOptional()
@IsString()
@ApiPropertyOptional()
label?: string;
@IsOptional()
@IsString()
@ApiPropertyOptional()
labelAsSafeHtml?: string;
@IsOptional()
@IsString()
@ApiPropertyOptional()
defaultLabel?: string;
@IsOptional()
@IsString()
@ApiPropertyOptional()
placeholder?: string;
@IsOptional()
@IsBoolean()
@ApiPropertyOptional()
required?: boolean;
@IsOptional()
@IsString()
@ApiPropertyOptional()
getOptionsAt?: string;
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => OptionsInput)
@ApiPropertyOptional()
optionsInputs?: Record<string, OptionsInput>;
@IsOptional()
@IsString()
@ApiPropertyOptional()
variant?: string;
@IsOptional()
@ValidateNested()
@Type(() => VariantsConfig)
@ApiPropertyOptional({ type: VariantsConfig })
variantsConfig?: VariantsConfig;
@IsOptional()
@ValidateNested({ each: true })
@Type(() => View)
@ApiPropertyOptional({ type: [View] })
views?: View[];
@IsOptional()
@IsBoolean()
@ApiPropertyOptional()
hideWhenJustOneOption?: boolean;
@IsOptional()
@IsBoolean()
@ApiPropertyOptional()
hidden?: boolean;
@IsOptional()
@IsEnum(Editable)
@ApiPropertyOptional()
editable?: Editable;
@IsOptional()
@ValidateNested({ each: true })
@Type(() => Source)
@ApiPropertyOptional({ type: [Source] })
sources?: Source[];
@IsOptional()
@IsBoolean()
@ApiPropertyOptional()
disableOnPrefill?: boolean;
}
export class RecurringEvent_2024_04_15 {
@IsDate()
@IsOptional()
@ApiPropertyOptional({ type: Date })
dtstart?: Date;
@IsInt()
@ApiProperty()
interval!: number;
@IsInt()
@ApiProperty()
count!: number;
@IsEnum(Frequency)
@ApiProperty({ enum: Frequency })
freq!: Frequency;
@IsDate()
@IsOptional()
@ApiPropertyOptional({ type: Date })
until?: Date;
@IsString()
@IsOptional()
@ApiPropertyOptional()
tzid?: string;
}
export class IntervalLimits_2024_04_15 {
@IsNumber()
@IsOptional()
@ApiPropertyOptional()
PER_DAY?: number;
@IsNumber()
@IsOptional()
@ApiPropertyOptional()
PER_WEEK?: number;
@IsNumber()
@IsOptional()
@ApiPropertyOptional()
PER_MONTH?: number;
@IsNumber()
@IsOptional()
@ApiPropertyOptional()
PER_YEAR?: number;
}
@@ -228,27 +282,33 @@ export class UpdateEventTypeInput_2024_04_15 {
@IsInt()
@Min(1)
@IsOptional()
@ApiPropertyOptional()
length?: number;
@IsString()
@IsOptional()
@ApiPropertyOptional()
slug?: string;
@IsString()
@IsOptional()
@ApiPropertyOptional()
title?: string;
@IsString()
@IsOptional()
@ApiPropertyOptional()
description?: string;
@IsBoolean()
@IsOptional()
@ApiPropertyOptional()
hidden?: boolean;
@ValidateNested({ each: true })
@Type(() => EventTypeLocation_2024_04_15)
@IsOptional()
@ApiPropertyOptional({ type: [EventTypeLocation_2024_04_15] })
locations?: EventTypeLocation_2024_04_15[];
// @IsInt()
@@ -283,6 +343,7 @@ export class UpdateEventTypeInput_2024_04_15 {
@IsArray()
@ValidateNested({ each: true })
@Type(() => BookingField_2024_04_15)
@ApiPropertyOptional({ type: [BookingField_2024_04_15] })
bookingFields?: BookingField_2024_04_15[];
// @IsString()
@@ -328,6 +389,7 @@ export class UpdateEventTypeInput_2024_04_15 {
@IsBoolean()
@IsOptional()
@ApiPropertyOptional()
disableGuests?: boolean;
// @IsBoolean()
@@ -337,16 +399,19 @@ export class UpdateEventTypeInput_2024_04_15 {
@IsInt()
@Min(0)
@IsOptional()
@ApiPropertyOptional()
minimumBookingNotice?: number;
@IsInt()
@Min(0)
@IsOptional()
@ApiPropertyOptional()
beforeEventBuffer?: number;
@IsInt()
@Min(0)
@IsOptional()
@ApiPropertyOptional()
afterEventBuffer?: number;
// @IsInt()
@@ -384,6 +449,7 @@ export class UpdateEventTypeInput_2024_04_15 {
@IsInt()
@Min(0)
@IsOptional()
@ApiPropertyOptional()
slotInterval?: number;
// @IsString()
@@ -12,7 +12,7 @@ import {
IntervalLimits_2024_04_15,
RecurringEvent_2024_04_15,
} from "@/ee/event-types/event-types_2024_04_15/inputs/update-event-type.input";
import { ApiProperty as DocsProperty, ApiHideProperty } from "@nestjs/swagger";
import { ApiHideProperty, ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import {
IsArray,
@@ -29,23 +29,23 @@ import {
export class EventTypeOutput {
@IsInt()
@DocsProperty({ example: 1 })
@ApiProperty({ example: 1 })
id!: number;
@IsInt()
@DocsProperty({ example: CREATE_EVENT_LENGTH_EXAMPLE })
@ApiProperty({ example: CREATE_EVENT_LENGTH_EXAMPLE })
length!: number;
@IsString()
@DocsProperty({ example: CREATE_EVENT_SLUG_EXAMPLE })
@ApiProperty({ example: CREATE_EVENT_SLUG_EXAMPLE })
slug!: string;
@IsString()
@DocsProperty({ example: CREATE_EVENT_TITLE_EXAMPLE })
@ApiProperty({ example: CREATE_EVENT_TITLE_EXAMPLE })
title!: string;
@IsString()
@DocsProperty({ example: CREATE_EVENT_DESCRIPTION_EXAMPLE })
@ApiProperty({ example: CREATE_EVENT_DESCRIPTION_EXAMPLE, nullable: true })
description!: string | null;
@IsBoolean()
@@ -55,6 +55,7 @@ export class EventTypeOutput {
@ValidateNested({ each: true })
@Type(() => EventTypeLocation_2024_04_15)
@IsArray()
@ApiProperty({ type: [EventTypeLocation_2024_04_15], nullable: true })
locations!: EventTypeLocation_2024_04_15[] | null;
@IsInt()
@@ -1,4 +1,4 @@
import { ApiProperty } from "@nestjs/swagger";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer";
import {
IsBoolean,
@@ -17,330 +17,421 @@ import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
class Location {
@IsString()
@ApiProperty()
type!: string;
}
class Source {
@IsString()
@ApiProperty()
id!: string;
@IsString()
@ApiProperty()
type!: string;
@IsString()
@ApiProperty()
label!: string;
}
class OptionInput {
@IsString()
@ApiProperty()
type!: string;
@IsBoolean()
@IsOptional()
@ApiPropertyOptional()
required?: boolean;
@IsString()
@IsOptional()
@ApiPropertyOptional()
placeholder?: string;
}
class BookingField {
@IsString()
@ApiProperty()
name!: string;
@IsString()
@ApiProperty()
type!: string;
@IsOptional()
@IsString()
@ApiPropertyOptional()
defaultLabel?: string;
@IsString()
@IsOptional()
@ApiPropertyOptional()
label?: string;
@IsString()
@IsOptional()
@ApiPropertyOptional()
placeholder?: string;
@IsBoolean()
@IsOptional()
@ApiPropertyOptional()
required?: boolean;
@IsOptional()
@ApiPropertyOptional()
getOptionsAt?: string;
@IsObject()
@IsOptional()
@ApiPropertyOptional()
optionsInputs?: { [key: string]: OptionInput };
@IsBoolean()
@IsOptional()
@ApiPropertyOptional()
hideWhenJustOneOption?: boolean;
@IsString()
@IsOptional()
@ApiPropertyOptional()
editable?: string;
@IsArray()
@ValidateNested({ each: true })
@Type(() => Source)
@IsOptional()
@ApiPropertyOptional({ type: [Source] })
sources?: Source[];
@IsOptional()
@IsBoolean()
@ApiPropertyOptional()
disableOnPrefill?: boolean;
}
class Organization {
@IsInt()
@ApiProperty()
id!: number;
@IsString()
@IsOptional()
@ApiPropertyOptional({ type: String, nullable: true })
slug?: string | null;
@IsString()
@ApiProperty()
name!: string;
@IsOptional()
metadata!: Record<string, any>;
@ApiPropertyOptional({ type: Object })
metadata?: Record<string, any>;
}
class Profile {
@IsString()
@ApiProperty({ type: String, nullable: true })
username!: string | null;
@IsInt()
@ApiProperty({ type: Number, nullable: true })
id!: number | null;
@IsInt()
@IsOptional()
@ApiPropertyOptional()
userId?: number;
@IsString()
@IsOptional()
@ApiPropertyOptional()
uid?: string;
@IsOptional()
@IsString()
@ApiPropertyOptional()
name?: string;
@IsInt()
@ApiProperty({ type: Number, nullable: true })
organizationId!: number | null;
@ValidateNested()
@Type(() => Organization)
@ApiPropertyOptional({ type: Organization, nullable: true })
organization?: Organization | null;
@IsString()
@ApiProperty()
upId!: string;
@IsString()
@IsOptional()
@ApiPropertyOptional()
image?: string;
@IsString()
@IsOptional()
@ApiPropertyOptional()
brandColor?: string;
@IsString()
@IsOptional()
@ApiPropertyOptional()
darkBrandColor?: string;
@IsString()
@IsOptional()
@ApiPropertyOptional()
theme?: string;
@IsOptional()
@ApiPropertyOptional()
bookerLayouts?: any;
}
class Owner {
@IsInt()
@ApiProperty()
id!: number;
@IsString()
@IsOptional()
@ApiPropertyOptional({ type: String, nullable: true })
avatarUrl?: string | null;
@IsString()
@ApiProperty({ type: String, nullable: true })
username!: string | null;
@IsString()
@ApiProperty({ type: String, nullable: true })
name!: string | null;
@IsString()
@ApiProperty()
weekStart!: string;
@IsString()
@IsOptional()
@ApiPropertyOptional({ type: String, nullable: true })
brandColor?: string | null;
@IsString()
@IsOptional()
@ApiPropertyOptional({ type: String, nullable: true })
darkBrandColor?: string | null;
@IsString()
@IsOptional()
@ApiPropertyOptional({ type: String, nullable: true })
theme?: string | null;
@IsOptional()
metadata!: any;
@ApiPropertyOptional()
metadata?: any;
@IsInt()
@IsOptional()
@ApiPropertyOptional({ type: Number, nullable: true })
defaultScheduleId?: number | null;
@IsString()
@ApiProperty({ type: String, nullable: true })
nonProfileUsername!: string | null;
@ValidateNested()
@Type(() => Profile)
@ApiProperty({ type: Profile })
profile!: Profile;
}
class User {
@IsString()
@ApiProperty({ type: String, nullable: true })
username!: string | null;
@IsString()
@ApiProperty({ type: String, nullable: true })
name!: string | null;
@IsString()
@ApiProperty()
weekStart!: string;
@IsInt()
@IsOptional()
@ApiPropertyOptional()
organizationId?: number;
@IsString()
@IsOptional()
@ApiPropertyOptional({ type: String, nullable: true })
avatarUrl?: string | null;
@ValidateNested()
@ApiProperty({ type: Profile })
profile!: Profile;
@IsString()
@ApiProperty()
bookerUrl!: string;
}
class Schedule {
@IsInt()
@ApiProperty()
id!: number;
@IsString()
@ApiProperty({ type: String, nullable: true })
timeZone!: string | null;
}
class PublicEventTypeOutput {
@IsInt()
@ApiProperty()
id!: number;
@IsString()
@ApiProperty()
title!: string;
@IsString()
@ApiProperty()
description!: string;
@IsString()
@IsOptional()
@ApiPropertyOptional({ type: String, nullable: true })
eventName?: string | null;
@IsString()
@ApiProperty()
slug!: string;
@IsBoolean()
@ApiProperty()
isInstantEvent!: boolean;
@IsOptional()
@ApiPropertyOptional()
aiPhoneCallConfig?: any;
@IsOptional()
@ApiPropertyOptional()
schedulingType?: any;
@IsInt()
@ApiProperty()
length!: number;
@IsArray()
@ValidateNested({ each: true })
@Type(() => Location)
@ApiProperty({ type: [Location] })
locations!: Location[];
@IsArray()
@ApiProperty({ type: [Object] })
customInputs!: any[];
@IsBoolean()
@ApiProperty()
disableGuests!: boolean;
@IsObject()
@ApiProperty({ type: Object, nullable: true })
metadata!: object | null;
@IsBoolean()
@ApiProperty()
lockTimeZoneToggleOnBookingPage!: boolean;
@IsBoolean()
@ApiProperty()
requiresConfirmation!: boolean;
@IsBoolean()
@ApiProperty()
requiresBookerEmailVerification!: boolean;
@IsOptional()
@ApiPropertyOptional()
recurringEvent?: any;
@IsNumber()
@ApiProperty()
price!: number;
@IsString()
@ApiProperty()
currency!: string;
@IsOptional()
@ApiPropertyOptional({ type: Number, nullable: true })
seatsPerTimeSlot?: number | null;
@IsBoolean()
@ApiProperty({ type: Boolean, nullable: true })
seatsShowAvailabilityCount!: boolean | null;
@IsArray()
@ValidateNested({ each: true })
@Type(() => BookingField)
@ApiProperty({ type: [BookingField] })
bookingFields!: BookingField[];
@IsOptional()
@ApiPropertyOptional()
team?: any;
@IsOptional()
@IsUrl()
@ApiPropertyOptional({ type: String, nullable: true })
successRedirectUrl?: string | null;
@IsArray()
@ApiProperty()
workflows!: any[];
@IsArray()
@ApiProperty()
hosts!: any[];
@ValidateNested()
@Type(() => Owner)
@ApiProperty({ type: Owner, nullable: true })
owner!: Owner | null;
@ValidateNested()
@Type(() => Schedule)
@ApiProperty({ type: Schedule, nullable: true })
schedule!: Schedule | null;
@IsBoolean()
@ApiProperty()
hidden!: boolean;
@IsBoolean()
@ApiProperty()
assignAllTeamMembers!: boolean;
@IsOptional()
@ApiPropertyOptional()
bookerLayouts?: any;
@IsArray()
@ValidateNested({ each: true })
@Type(() => User)
@ApiProperty({ type: [User] })
users!: User[];
@IsObject()
@ApiProperty({ type: Object })
entity!: object;
@IsBoolean()
@@ -1435,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);
@@ -1,4 +1,4 @@
import { ApiProperty as DocsProperty } from "@nestjs/swagger";
import { ApiProperty as DocsProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Transform } from "class-transformer";
import { IsString, IsBoolean, IsOptional, IsEnum, Matches } from "class-validator";
@@ -38,34 +38,34 @@ export class CreatePhoneCallInput {
@IsOptional()
@IsString()
@DocsProperty({ description: "Scheduler name" })
@ApiPropertyOptional({ description: "Scheduler name" })
schedulerName?: string;
@IsOptional()
@IsString()
@Transform(({ value }) => (value ? value : undefined))
@DocsProperty({ description: "Guest name" })
@ApiPropertyOptional({ description: "Guest name" })
guestName?: string;
@IsOptional()
@IsString()
@Transform(({ value }) => (value ? value : undefined))
@DocsProperty({ description: "Guest email" })
@ApiPropertyOptional({ description: "Guest email" })
guestEmail?: string;
@IsOptional()
@IsString()
@Transform(({ value }) => (value ? value : undefined))
@DocsProperty({ description: "Guest company" })
@ApiPropertyOptional({ description: "Guest company" })
guestCompany?: string;
@IsOptional()
@IsString()
@DocsProperty({ description: "Begin message" })
@ApiPropertyOptional({ description: "Begin message" })
beginMessage?: string;
@IsOptional()
@IsString()
@DocsProperty({ description: "General prompt" })
@ApiPropertyOptional({ description: "General prompt" })
generalPrompt?: string;
}
@@ -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" }
);
}
@@ -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 -1
View File
@@ -100,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);
+14 -2
View File
@@ -1,39 +1,51 @@
import { ApiProperty as DocsProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsInt, IsEmail, IsOptional, IsString, ValidateNested } from "class-validator";
import { IsString, IsOptional, IsInt, IsEmail, ValidateNested } from "class-validator";
export class MeOrgOutput {
@DocsProperty()
isPlatform!: boolean;
@DocsProperty()
id!: number;
}
export class MeOutput {
@IsInt()
@DocsProperty()
id!: number;
@IsString()
@DocsProperty()
username!: string;
@IsEmail()
@DocsProperty()
email!: string;
@IsInt()
@DocsProperty()
timeFormat!: number;
@IsInt()
@IsOptional()
@DocsProperty({ type: Number, nullable: true })
defaultScheduleId!: number | null;
@IsString()
@DocsProperty()
weekStart!: string;
@IsString()
@DocsProperty()
timeZone!: string;
@IsInt()
@DocsProperty({ type: Number, nullable: true })
organizationId!: number | null;
@IsOptional()
@ValidateNested()
@Type(() => MeOrgOutput)
@ApiPropertyOptional({ type: MeOrgOutput })
organization?: MeOrgOutput;
}
@@ -9,6 +9,9 @@ import { ProviderModule } from "@/ee/provider/provider.module";
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
import { SchedulesModule_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.module";
import { SlotsModule } from "@/modules/slots/slots.module";
import { TeamsEventTypesModule } from "@/modules/teams/event-types/teams-event-types.module";
import { TeamsModule } from "@/modules/teams/teams/teams.module";
import { TeamsMembershipsModule } from "@/modules/teams/memberships/teams-memberships.module";
import type { MiddlewareConsumer, NestModule } from "@nestjs/common";
import { Module } from "@nestjs/common";
@@ -18,13 +21,16 @@ import { Module } from "@nestjs/common";
ProviderModule,
SchedulesModule_2024_04_15,
SchedulesModule_2024_06_11,
TeamsEventTypesModule,
MeModule,
EventTypesModule_2024_04_15,
EventTypesModule_2024_06_14,
CalendarsModule,
BookingsModule_2024_04_15,
BookingsModule_2024_08_13,
TeamsMembershipsModule,
SlotsModule,
TeamsModule,
],
})
export class PlatformEndpointsModule implements NestModule {
@@ -1,20 +1,25 @@
import { CreateAvailabilityInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-availability.input";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsArray, IsBoolean, IsTimeZone, IsOptional, IsString, ValidateNested } from "class-validator";
export class CreateScheduleInput_2024_04_15 {
@IsString()
@ApiProperty()
name!: string;
@IsTimeZone()
@ApiProperty()
timeZone!: string;
@IsArray()
@ValidateNested({ each: true })
@Type(() => CreateAvailabilityInput_2024_04_15)
@IsOptional()
@ApiPropertyOptional({ type: [CreateAvailabilityInput_2024_04_15] })
availabilities?: CreateAvailabilityInput_2024_04_15[];
@IsBoolean()
@ApiProperty()
isDefault!: boolean;
}
@@ -1,95 +1,117 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsBoolean, IsInt, IsOptional, IsString, ValidateNested, IsArray } from "class-validator";
class EventTypeModel_2024_04_15 {
@IsInt()
@ApiProperty()
id!: number;
@IsOptional()
@IsString()
@ApiPropertyOptional({ type: String, nullable: true })
eventName?: string | null;
}
class AvailabilityModel_2024_04_15 {
@IsInt()
@ApiProperty()
id!: number;
@IsOptional()
@IsInt()
@ApiPropertyOptional({ type: Number, nullable: true })
userId?: number | null;
@IsOptional()
@IsInt()
@ApiPropertyOptional({ type: Number, nullable: true })
scheduleId?: number | null;
@IsOptional()
@IsInt()
@ApiPropertyOptional({ type: Number, nullable: true })
eventTypeId?: number | null;
@IsArray()
@IsInt({ each: true })
@ApiProperty({ type: [Number] })
days!: number[];
@IsOptional()
@Type(() => Date)
@IsString() // Assuming date is serialized/deserialized appropriately
@IsString()
@ApiPropertyOptional()
startTime?: Date;
@IsOptional()
@Type(() => Date)
@IsString() // Assuming date is serialized/deserialized appropriately
@IsString()
@ApiPropertyOptional()
endTime?: Date;
@IsOptional()
@Type(() => Date)
@IsString() // Assuming date is serialized/deserialized appropriately
@IsString()
@ApiPropertyOptional({ type: String, nullable: true })
date?: Date | null;
}
class ScheduleModel_2024_04_15 {
@IsInt()
@ApiProperty()
id!: number;
@IsInt()
@ApiProperty()
userId!: number;
@IsString()
@ApiProperty()
name!: string;
@IsOptional()
@IsString()
@ApiPropertyOptional({ type: String, nullable: true })
timeZone?: string | null;
@IsOptional()
@ValidateNested({ each: true })
@Type(() => EventTypeModel_2024_04_15)
@IsArray()
@ApiPropertyOptional({ type: [EventTypeModel_2024_04_15] })
eventType?: EventTypeModel_2024_04_15[];
@IsOptional()
@ValidateNested({ each: true })
@Type(() => AvailabilityModel_2024_04_15)
@IsArray()
@ApiPropertyOptional({ type: [AvailabilityModel_2024_04_15] })
availability?: AvailabilityModel_2024_04_15[];
}
export class UpdatedScheduleOutput_2024_04_15 {
@ValidateNested()
@Type(() => ScheduleModel_2024_04_15)
@ApiProperty({ type: ScheduleModel_2024_04_15 })
schedule!: ScheduleModel_2024_04_15;
@IsBoolean()
@ApiProperty()
isDefault!: boolean;
@IsOptional()
@IsString()
@ApiPropertyOptional()
timeZone?: string;
@IsOptional()
@IsInt()
@ApiPropertyOptional({ type: Number, nullable: true })
prevDefaultId?: number | null;
@IsOptional()
@IsInt()
@ApiPropertyOptional({ type: Number, nullable: true })
currentDefaultId?: number | null;
}
@@ -1,105 +1,132 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsDate, IsOptional, IsArray, IsBoolean, IsInt, IsString, ValidateNested } from "class-validator";
class AvailabilityModel {
@IsInt()
@ApiProperty()
id!: number;
@IsOptional()
@IsInt()
@ApiPropertyOptional({ type: Number, nullable: true })
userId?: number | null;
@IsOptional()
@IsInt()
@ApiPropertyOptional({ type: Number, nullable: true })
eventTypeId?: number | null;
@IsArray()
@IsInt({ each: true })
@ApiProperty({ type: [Number] })
days!: number[];
@IsDate()
@Type(() => Date)
@ApiProperty({ type: Date })
startTime!: Date;
@IsDate()
@Type(() => Date)
@ApiProperty({ type: Date })
endTime!: Date;
@IsOptional()
@IsDate()
@Type(() => Date)
@ApiPropertyOptional({ type: Date, nullable: true })
date?: Date | null;
@IsOptional()
@IsInt()
@ApiPropertyOptional({ type: Number, nullable: true })
scheduleId?: number | null;
}
class WorkingHours {
@IsArray()
@IsInt({ each: true })
@ApiProperty({ type: [Number] })
days!: number[];
@IsInt()
@ApiProperty()
startTime!: number;
@IsInt()
@ApiProperty()
endTime!: number;
@IsOptional()
@IsInt()
@ApiPropertyOptional({ type: Number, nullable: true })
userId?: number | null;
}
class TimeRange {
@IsOptional()
@IsInt()
@ApiPropertyOptional({ type: Number, nullable: true })
userId?: number | null;
@IsDate()
@ApiProperty({ type: Date })
start!: Date;
@IsDate()
@ApiProperty({ type: Date })
end!: Date;
}
export class ScheduleOutput {
@IsInt()
@ApiProperty()
id!: number;
@IsString()
@ApiProperty()
name!: string;
@IsBoolean()
@ApiProperty()
isManaged!: boolean;
@ValidateNested({ each: true })
@Type(() => WorkingHours)
@ApiProperty({ type: [WorkingHours] })
workingHours!: WorkingHours[];
@ValidateNested({ each: true })
@Type(() => AvailabilityModel)
@IsArray()
@ApiProperty({ type: [AvailabilityModel] })
schedule!: AvailabilityModel[];
@ApiProperty({ type: [[TimeRange]] })
availability!: TimeRange[][];
@IsString()
@ApiProperty()
timeZone!: string;
@ValidateNested({ each: true })
@IsArray()
@ApiPropertyOptional({ type: [Object] })
// note(Lauris) it should be
// dateOverrides!: { ranges: TimeRange[] }[];
// but docs aren't generating correctly it results in array of strings
dateOverrides!: unknown[];
@IsBoolean()
@ApiProperty()
isDefault!: boolean;
@IsBoolean()
@ApiProperty()
isLastSchedule!: boolean;
@IsBoolean()
@ApiProperty()
readOnly!: boolean;
}
+3 -1
View File
@@ -30,12 +30,14 @@ export type Environment = {
RATE_LIMIT_DEFAULT_BLOCK_DURATION_MS: number;
AXIOM_DATASET: string;
AXIOM_TOKEN: string;
STRIPE_TEAM_MONTHLY_PRICE_ID: string;
IS_TEAM_BILLING_ENABLED: boolean;
};
export const getEnv = <K extends keyof Environment>(key: K, fallback?: Environment[K]): Environment[K] => {
const value = process.env[key] as Environment[K] | undefined;
if (!value) {
if (value === undefined) {
if (fallback) {
return fallback;
}
@@ -9,7 +9,13 @@ import {
} from "@prisma/client/runtime/library";
import { Request } from "express";
import { ERROR_STATUS, INTERNAL_SERVER_ERROR } from "@calcom/platform-constants";
import {
BAD_REQUEST,
CONFLICT,
ERROR_STATUS,
INTERNAL_SERVER_ERROR,
NOT_FOUND,
} from "@calcom/platform-constants";
import { Response } from "@calcom/platform-types";
type PrismaError =
@@ -43,11 +49,37 @@ export class PrismaExceptionFilter implements ExceptionFilter {
method: request.method,
requestId,
});
response.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
let message = "There was an error, please try again later.";
let statusCode = HttpStatus.INTERNAL_SERVER_ERROR;
let errorCode = INTERNAL_SERVER_ERROR;
if (error instanceof PrismaClientKnownRequestError) {
switch (error.code) {
case "P2002": // Unique constraint failed
errorCode = CONFLICT;
statusCode = HttpStatus.CONFLICT;
message = "Invalid Input: Trying to create a record that already exists.";
break;
case "P2025": // Record not found
errorCode = NOT_FOUND;
statusCode = HttpStatus.NOT_FOUND;
message = "Invalid Query: The requested record was not found.";
break;
case "P2003": // Foreign key constraint failed
errorCode = BAD_REQUEST;
statusCode = HttpStatus.BAD_REQUEST;
message = "Invalid input: The referenced data does not exist.";
break;
default:
break;
}
}
response.status(statusCode).json({
status: ERROR_STATUS,
timestamp: new Date().toISOString(),
path: request.url,
error: { code: INTERNAL_SERVER_ERROR, message: "There was an error, please try again later." },
error: { code: errorCode, message: message },
});
}
}
@@ -0,0 +1,65 @@
import { isOriginAllowed } from "@/lib/is-origin-allowed/is-origin-allowed";
describe("isOriginAllowed", () => {
describe("is allowed", () => {
it("should return true for exact match without wildcard", () => {
const allowedOrigins = ["https://app.cal.com/callback"];
const origin = "https://app.cal.com/callback";
expect(isOriginAllowed(origin, allowedOrigins)).toBe(true);
});
it("should return true for wildcard domain match", () => {
const allowedOrigins = ["*.multiscreen.d1test.biz"];
const origin = "https://sub.multiscreen.d1test.biz";
expect(isOriginAllowed(origin, allowedOrigins)).toBe(true);
});
it("should return true for wildcard pattern matching any domain", () => {
const allowedOrigins = ["*/callback"];
const origin = "https://another.com/callback";
expect(isOriginAllowed(origin, allowedOrigins)).toBe(true);
});
it("should handle a wildcard only pattern", () => {
const allowedOrigins = ["*"];
const origin = "https://any.domain.com/anypath";
expect(isOriginAllowed(origin, allowedOrigins)).toBe(true);
});
it("should handle multiple allowed URIs where only one matches", () => {
const allowedOrigins = [
"https://app.cal.com/other",
"*.notthisone.com",
"https://app.cal.com/callback",
];
const origin = "https://app.cal.com/callback";
expect(isOriginAllowed(origin, allowedOrigins)).toBe(true);
});
it("should handle patterns with multiple wildcards correctly", () => {
const allowedOrigins = ["https://*.mydomain.com/*"];
const origin = "https://sub.mydomain.com/path/to/resource";
expect(isOriginAllowed(origin, allowedOrigins)).toBe(true);
});
});
describe("is not allowed", () => {
it("should return false if no allowed patterns match", () => {
const allowedOrigins = ["https://app.cal.com/callback", "*.multiscreen.d1test.biz", "*/callback"];
const origin = "https://unknown.com";
expect(isOriginAllowed(origin, allowedOrigins)).toBe(false);
});
it("should return false if wildcard pattern doesn't match the given origin", () => {
const allowedOrigins = ["*.example.com"];
const origin = "https://notexample.org";
expect(isOriginAllowed(origin, allowedOrigins)).toBe(false);
});
it("should handle empty allowedUris array correctly", () => {
const allowedOrigins: string[] = [];
const origin = "https://app.cal.com/callback";
expect(isOriginAllowed(origin, allowedOrigins)).toBe(false);
});
});
});
@@ -0,0 +1,18 @@
export function isOriginAllowed(origin: string, allowedOrigins: string[]): boolean {
return allowedOrigins.some((allowedOrigin) => {
if (allowedOrigin.includes("*")) {
return wildcardToRegex(allowedOrigin).test(origin);
}
return origin === allowedOrigin;
});
}
function wildcardToRegex(pattern: string): RegExp {
const escaped = escapeRegex(pattern);
const regexPattern = "^" + escaped.replace(/\\\*/g, ".*") + "$";
return new RegExp(regexPattern);
}
function escapeRegex(str: string): string {
return str.replace(/[.+*?^${}()|[\]\\]/g, "\\$&");
}
@@ -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;
}
}
@@ -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,
},
});
}
}
@@ -2,19 +2,22 @@ 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";
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TeamsEventTypesModule } from "@/modules/teams/event-types/teams-event-types.module";
import { UsersService } from "@/modules/users/services/users.service";
import { UsersRepository } from "@/modules/users/users.repository";
import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule, EventTypesModule_2024_06_14, OrganizationsModule],
imports: [PrismaModule, EventTypesModule_2024_06_14, OrganizationsModule, TeamsEventTypesModule],
providers: [
EventTypesAtomService,
ConferencingAtomsService,
MembershipsRepository,
CredentialsRepository,
UsersRepository,
@@ -2,6 +2,7 @@ import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
import { credentialForCalendarServiceSelect } from "@calcom/platform-libraries";
import { paymentDataSelect } from "@calcom/platform-libraries";
@Injectable()
@@ -14,4 +15,53 @@ export class AtomsRepository {
select: paymentDataSelect,
});
}
async getUserTeams(userId: number) {
const userTeams = await this.dbRead.prisma.team.findMany({
where: {
members: {
some: {
userId,
accepted: true,
},
},
},
select: {
id: true,
credentials: {
select: credentialForCalendarServiceSelect,
},
name: true,
logoUrl: true,
members: {
where: {
userId,
},
select: {
role: true,
},
},
parent: {
select: {
id: true,
credentials: {
select: credentialForCalendarServiceSelect,
},
name: true,
logoUrl: true,
members: {
where: {
userId,
},
select: {
role: true,
},
},
},
},
},
});
return userTeams;
}
}
@@ -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,9 +1,19 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Transform } from "class-transformer";
import { IsNumber, IsOptional } from "class-validator";
import { ArrayNotEmpty, IsArray, IsInt, IsNumber, IsOptional } from "class-validator";
export class EventTypesAppInput {
@Transform(({ value }: { value: string }) => value && parseInt(value))
@IsNumber()
@IsOptional()
@ApiPropertyOptional()
teamId?: number;
}
export class BulkUpdateEventTypeToDefaultLocationDto {
@IsArray()
@ArrayNotEmpty()
@IsInt({ each: true })
@ApiProperty({ type: [Number] })
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,
});
}
}
@@ -2,8 +2,7 @@ import { EventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_
import { AtomsRepository } from "@/modules/atoms/atoms.repository";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { OrganizationsTeamsRepository } from "@/modules/organizations/repositories/organizations-teams.repository";
import { OrganizationsEventTypesService } from "@/modules/organizations/services/event-types/organizations-event-types.service";
import { TeamsEventTypesService } from "@/modules/teams/event-types/services/teams-event-types.service";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { UsersService } from "@/modules/users/services/users.service";
@@ -19,6 +18,9 @@ import {
getAppFromSlug,
MembershipRole,
EventTypeMetaDataSchema,
getClientSecretFromPayment,
getBulkEventTypes,
bulkUpdateEventsToDefaultLocation,
} from "@calcom/platform-libraries";
import type {
App,
@@ -29,8 +31,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;
@@ -44,12 +45,11 @@ export class EventTypesAtomService {
private readonly membershipsRepository: MembershipsRepository,
private readonly credentialsRepository: CredentialsRepository,
private readonly atomsRepository: AtomsRepository,
private readonly organizationsTeamsRepository: OrganizationsTeamsRepository,
private readonly usersService: UsersService,
private readonly dbWrite: PrismaWriteService,
private readonly dbRead: PrismaReadService,
private readonly eventTypeService: EventTypesService_2024_06_14,
private readonly teamEventTypeService: OrganizationsEventTypesService
private readonly teamEventTypeService: TeamsEventTypesService
) {}
async getUserEventType(user: UserWithProfile, eventTypeId: number) {
@@ -81,6 +81,10 @@ export class EventTypesAtomService {
return eventType;
}
async getUserEventTypes(userId: number) {
return getBulkEventTypes(userId);
}
async updateTeamEventType(
eventTypeId: number,
body: TUpdateEventTypeInputSchema,
@@ -177,7 +181,7 @@ export class EventTypesAtomService {
let credentials = await this.credentialsRepository.getAllUserCredentialsById(userId);
let userTeams: TeamQuery[] = [];
if (teamId) {
const teamsQuery = await this.organizationsTeamsRepository.getUserTeamsById(userId);
const teamsQuery = await this.atomsRepository.getUserTeams(userId);
// If a team is a part of an org then include those apps
// Don't want to iterate over these parent teams
const filteredTeams: TeamQuery[] = [];
@@ -317,4 +321,12 @@ export class EventTypesAtomService {
profile,
};
}
async bulkUpdateEventTypesDefaultLocation(user: UserWithProfile, eventTypeIds: number[]) {
return bulkUpdateEventsToDefaultLocation({
eventTypeIds,
user,
prisma: this.dbWrite.prisma as unknown as PrismaClient,
});
}
}
@@ -1,22 +1,18 @@
import { UserWithProfile } from "@/modules/users/users.repository";
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
import { ExecutionContext } from "@nestjs/common";
import { createParamDecorator } from "@nestjs/common";
export type GetUserReturnType = UserWithProfile & { isSystemAdmin: boolean };
export const GetUser = createParamDecorator<
keyof GetUserReturnType | (keyof GetUserReturnType)[],
keyof ApiAuthGuardUser | (keyof ApiAuthGuardUser)[],
ExecutionContext
>((data, ctx) => {
const request = ctx.switchToHttp().getRequest();
const user = request.user as GetUserReturnType;
const user = request.user as ApiAuthGuardUser;
if (!user) {
throw new Error("GetUser decorator : User not found");
}
user.isSystemAdmin = user.role === "ADMIN";
if (Array.isArray(data)) {
return data.reduce((prev, curr) => {
return {
@@ -1,5 +1,5 @@
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
import { GetUserReturnType } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
import { PlatformPlanType } from "@/modules/billing/types";
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
import { RedisService } from "@/modules/redis/redis.service";
@@ -19,7 +19,7 @@ export class PlatformPlanGuard implements CanActivate {
const request = context.switchToHttp().getRequest<Request>();
const teamId = request.params.teamId as string;
const orgId = request.params.orgId as string;
const user = request.user as GetUserReturnType;
const user = request.user as ApiAuthGuardUser;
const minimumPlan = this.reflector.get(PlatformPlan, context.getHandler()) as PlatformPlanType;
const REDIS_CACHE_KEY = `apiv2:user:${user?.id ?? "none"}:org:${orgId ?? "none"}:team:${
@@ -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,6 +1,6 @@
import { ORG_ROLES, TEAM_ROLES, SYSTEM_ADMIN_ROLE } from "@/lib/roles/constants";
import { GetUserReturnType } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { RedisService } from "@/modules/redis/redis.service";
import { Injectable, CanActivate, ExecutionContext, ForbiddenException, Logger } from "@nestjs/common";
@@ -22,7 +22,7 @@ export class RolesGuard implements CanActivate {
const request = context.switchToHttp().getRequest<Request & { team: Team }>();
const teamId = request.params.teamId as string;
const orgId = request.params.orgId as string;
const user = request.user as GetUserReturnType;
const user = request.user as ApiAuthGuardUser;
const allowedRole = this.reflector.get(Roles, context.getHandler());
const REDIS_CACHE_KEY = `apiv2:user:${user.id ?? "none"}:org:${orgId ?? "none"}:team:${
teamId ?? "none"
@@ -1,5 +1,6 @@
import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key";
import { AuthMethods } from "@/lib/enums/auth-methods";
import { isOriginAllowed } from "@/lib/is-origin-allowed/is-origin-allowed";
import { BaseStrategy } from "@/lib/passport/strategies/types";
import { ApiKeyRepository } from "@/modules/api-key/api-key-repository";
import { DeploymentsService } from "@/modules/deployments/deployments.service";
@@ -16,6 +17,8 @@ import { getToken } from "next-auth/jwt";
import { INVALID_ACCESS_TOKEN, X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
export type ApiAuthGuardUser = UserWithProfile & { isSystemAdmin: boolean };
@Injectable()
export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth") {
constructor(
@@ -74,12 +77,19 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
async authenticateNextAuth(token: { email?: string | null }) {
const user = await this.nextAuthStrategy(token);
return this.success(user);
return this.success(this.getSuccessUser(user));
}
getSuccessUser(user: UserWithProfile): ApiAuthGuardUser {
return {
...user,
isSystemAdmin: user.role === "ADMIN",
};
}
async authenticateOAuthClient(oAuthClientId: string, oAuthClientSecret: string) {
const user = await this.oAuthClientStrategy(oAuthClientId, oAuthClientSecret);
return this.success(user);
return this.success(this.getSuccessUser(user));
}
async oAuthClientStrategy(oAuthClientId: string, oAuthClientSecret: string) {
@@ -118,7 +128,7 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
return this.error(new UnauthorizedException("No user associated with the provided token"));
}
return this.success(user);
return this.success(this.getSuccessUser(user));
} catch (err) {
if (err instanceof Error) {
return this.error(err);
@@ -167,7 +177,7 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
throw new UnauthorizedException("OAuth client not found given the access token");
}
if (origin && !client.redirectUris.some((uri) => uri.startsWith(origin))) {
if (origin && !isOriginAllowed(origin, client.redirectUris)) {
throw new UnauthorizedException(
`Invalid request origin - please open https://app.cal.com/settings/platform and add the origin '${origin}' to the 'Redirect uris' of your OAuth client.`
);
@@ -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;
}
@@ -1,4 +1,4 @@
import { ApiProperty } from "@nestjs/swagger";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Expose, Type } from "class-transformer";
import { IsString, ValidateNested, IsEnum, IsNumber, IsOptional, IsBoolean } from "class-validator";
@@ -20,7 +20,11 @@ export class ConferencingAppsOutputDto {
@IsNumber()
userId!: number;
@ApiProperty({ example: true, description: "Whether if the connection is working or not." })
@ApiPropertyOptional({
example: true,
description: "Whether if the connection is working or not.",
nullable: true,
})
@Expose()
@IsBoolean()
@IsOptional()
@@ -28,23 +32,31 @@ export class ConferencingAppsOutputDto {
}
export class ConferencingAppsOutputResponseDto {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@ApiProperty({ type: String, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@Expose()
@ValidateNested()
@ValidateNested({ each: true })
@Type(() => ConferencingAppsOutputDto)
@ApiProperty({ type: [ConferencingAppsOutputDto] })
data!: ConferencingAppsOutputDto[];
}
export class ConferencingAppOutputResponseDto {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@ApiProperty({ type: String, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@Expose()
@ValidateNested()
@Type(() => ConferencingAppsOutputDto)
@ApiProperty({ 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 ?? "" };
}
}
@@ -33,6 +33,16 @@ export class MembershipsRepository {
return membership;
}
async findUserMemberships(userId: number) {
const memberships = await this.dbRead.prisma.membership.findMany({
where: {
userId,
},
});
return memberships;
}
async findMembershipByOrgId(orgId: number, userId: number) {
return this.findMembershipByTeamId(orgId, userId);
}
@@ -159,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())
@@ -191,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())
@@ -211,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();
@@ -265,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())
@@ -535,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())
@@ -213,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 })
@@ -10,10 +10,10 @@ export class ManagedUserOutput {
@ApiProperty({ example: "alice+cluo37fwd0001khkzqqynkpj3@example.com" })
email!: string;
@ApiProperty({ example: "alice" })
@ApiProperty({ example: "alice", nullable: true })
username!: string | null;
@ApiProperty({ example: "alice" })
@ApiProperty({ example: "alice", nullable: true })
name!: string | null;
@ApiProperty({ example: "America/New_York" })
@@ -22,18 +22,28 @@ export class ManagedUserOutput {
@ApiProperty({ example: "Sunday" })
weekStart!: string;
@ApiProperty({ example: "2024-04-01T00:00:00.000Z", type: "string" })
@ApiProperty({ type: String, example: "2024-04-01T00:00:00.000Z" })
@Transform(({ value }) => value.toISOString())
createdDate!: Date;
@ApiProperty({ example: 12, nullable: true })
@ApiProperty({ type: Number, example: 12, nullable: true })
timeFormat!: number | null;
@ApiProperty({ example: null })
@ApiProperty({ type: Number, example: null, nullable: true })
defaultScheduleId!: number | null;
@IsEnum(Locales)
@IsOptional()
@ApiProperty({ example: Locales.EN, enum: Locales })
@ApiPropertyOptional({ type: String, 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`,
nullable: true,
})
avatarUrl?: string | null;
}
@@ -244,7 +244,7 @@ describe("OAuth client 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("OAuth client 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);
@@ -1,4 +1,4 @@
import { ApiProperty } from "@nestjs/swagger";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer";
import {
IsArray,
@@ -30,7 +30,7 @@ export class PlatformOAuthClientDto {
@IsNumber()
permissions!: number;
@ApiProperty({ example: "https://example.com/logo.png", required: false })
@ApiPropertyOptional({ example: "https://example.com/logo.png" })
@IsOptional()
@IsString()
logo!: string | null;
@@ -54,9 +54,7 @@ export class GetOAuthClientResponseDto {
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
type: PlatformOAuthClientDto,
})
@ApiProperty({ type: PlatformOAuthClientDto })
@IsNotEmptyObject()
@ValidateNested()
@Type(() => PlatformOAuthClientDto)
@@ -1,5 +1,6 @@
import { getEnv } from "@/env";
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { isOriginAllowed } from "@/lib/is-origin-allowed/is-origin-allowed";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { NextAuthGuard } from "@/modules/auth/guards/next-auth/next-auth.guard";
@@ -62,7 +63,7 @@ export class OAuthFlowController {
throw new BadRequestException(`OAuth client with ID '${clientId}' not found`);
}
if (!oauthClient?.redirectUris.includes(body.redirectUri)) {
if (!isOriginAllowed(body.redirectUri, oauthClient.redirectUris)) {
throw new BadRequestException("Invalid 'redirect_uri' value.");
}
@@ -1,4 +1,4 @@
import { GetUserReturnType } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { UsersService } from "@/modules/users/services/users.service";
import {
@@ -15,8 +15,8 @@ export class OAuthClientGuard implements CanActivate {
constructor(private oAuthClientRepository: OAuthClientRepository, private usersService: UsersService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request & { user: GetUserReturnType }>();
const user: GetUserReturnType = request.user;
const request = context.switchToHttp().getRequest<Request & { user: ApiAuthGuardUser }>();
const user: ApiAuthGuardUser = request.user;
const organizationId = user ? this.usersService.getUserMainOrgId(user) : null;
const oAuthClientId = request.params.clientId;
@@ -1,32 +1,40 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { IsArray, IsBoolean, IsOptional, IsString } from "class-validator";
export class UpdateOAuthClientInput {
@IsOptional()
@IsString()
@ApiPropertyOptional()
logo?: string;
@IsString()
@IsOptional()
@ApiPropertyOptional()
name?: string;
@IsArray()
@IsOptional()
@IsString({ each: true })
redirectUris?: string[] = [];
@ApiPropertyOptional({ type: [String] })
redirectUris?: string[];
@IsOptional()
@IsString()
@ApiPropertyOptional()
bookingRedirectUri?: string;
@IsOptional()
@IsString()
@ApiPropertyOptional()
bookingCancelRedirectUri?: string;
@IsOptional()
@IsString()
@ApiPropertyOptional()
bookingRescheduleRedirectUri?: string;
@IsOptional()
@IsBoolean()
@ApiPropertyOptional()
areEmailsEnabled?: boolean;
}
@@ -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(

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