Compare commits

...
163 Commits
Author SHA1 Message Date
GitHub Actions a5639a8290 chore: release v5.2.17
Post release / release (push) Canceled after 0s
2025-05-14 23:10:04 +00:00
Anik Dhabal Babu 44d9c4bcc5 fix: Seated events and round robin - all round robin hosts are being added to the calendar event (#21317)
* fix: add org email into ics

* fix: Seated events and round robin - all round robin hosts are being added to the calendar event

* added test
2025-05-14 22:52:26 +00:00
Anik Dhabal Babu 1b586560f8 fix: userprimaryemail not updating (#21318) 2025-05-14 22:01:43 +00:00
Benny Joo e082cc7d3d refactor: Handle apiv2 team slots in useSchedule hook (#21309) 2025-05-14 10:12:09 -07:00
Keith Williams d411e70ea3 chore: Add @calcom/Consumer as CODEOWNER for ui-playground (#21312) 2025-05-14 16:17:13 +00:00
Keith Williams 8dffa4d371 chore: Back down @calcom/Consumer CODEOWNERS (#21311) 2025-05-14 16:09:58 +00:00
Eunjae Lee b7bf6e80e6 fix: case insensitive filter for attendees on /bookings (#21302) 2025-05-14 15:51:22 +00:00
Eunjae Lee 2a93e661f9 feat: add denormalized table for RoutingFormResponse view (#21009)
* feat: add denormalized table for RoutingFormResponse

* WIP

* improve

* update schema

* update schema

* improvement

* fix type of bookingStatus

* cascading DELETE

* split update trigger

* denormalize fields into a separate table

* error handling

* address feedback

* fix

* fix constraints and relations

* delete rows when rows are deleted in the joined tables

* add tests and make booking nullable in RoutingFormResponseDenormalized

* add tests

* update test

* use e2e tests instead

* fix type error

* address feedback

* move e2e to integration test

* add missing tests

* move index migration to another file

* clean up migrations
2025-05-14 10:41:28 -04:00
Lauris Skraucis bfb51b82b2 fix: useLocale hook is being used outside of App Router & time zone dropdown timeout (#21306) 2025-05-14 14:07:35 +00:00
Keith Williams f50be6c08b chore: Lower number of errors sent to Sentry (#21300) 2025-05-14 12:21:13 +00:00
Hariom Balhara df75df7008 fix: DelegationCredential - Duplicate GoogleMeet and unnecessary serviceAccount key access (#21269)
* Remove unused return statement

* Fix return from fn instead of continuing the loop

* Ensure userId is set for existing records on update

* ensure that serviceAccountKey is only retrieved whenb assked
2025-05-14 09:49:47 +00:00
Eunjae Lee deb55c0536 fix: sticky columns in DataTable (#21278)
* fix: sticky columns in DataTable

* update th color

* remove rounded corner

* border change
2025-05-14 08:34:50 +00:00
Lauris Skraucis 7fc11ff341 fix: v2 bookings with sms reminders (#21191)
* fix: v2 bookings with sms reminders

* fix: falsey booking field values
2025-05-14 10:25:29 +03:00
Lauris Skraucis 1a70f83d34 fix: v2 sentry errors (#21214)
* fix: PATCH, POST event-types Address location must have an address

* fix: GET event-types Address location must have an address

* fix: POST /v2/bookings Address location must have an address

* refactor: GET /v2/slots Invalid time range given.

* fix: POST v2/event-types Cannot read properties of null (reading 'type')

* fix: POST /v2/bookings/:bookingUid/mark-absent Failed to update no-show status
2025-05-14 10:11:35 +03:00
39077a08f1 docs: correct documentation for slot format in 2024-09-04 API (#21196)
Co-authored-by: Somay Chauhan <somaychauhan98@gmail.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2025-05-14 06:57:41 +00:00
Benny Joo 9a671f9063 fix: add arg validation to ensureValidPhoneNumber (#21259) 2025-05-13 18:57:00 -07:00
3bc9fa11d0 chore: remove @calcom/lib barrel file (#21267)
* refactor: replace imports from @calcom/lib barrel file with direct imports

Co-Authored-By: benny@cal.com <benny@cal.com>

* fix: add deprecated barrel file with warning to support tests

Co-Authored-By: benny@cal.com <benny@cal.com>

* remove barrel file

* fix: restore barrel file with deprecation notice and add CreditType enum export

Co-Authored-By: benny@cal.com <benny@cal.com>

* fix: add CreditType enum definition to barrel file

Co-Authored-By: benny@cal.com <benny@cal.com>

* fix: add mock for CreditType enum in credit-service test

Co-Authored-By: benny@cal.com <benny@cal.com>

* fix: use importOriginal in CreditType enum mock

Co-Authored-By: benny@cal.com <benny@cal.com>

* fix: remove barrel file completely

Co-Authored-By: benny@cal.com <benny@cal.com>

* fix: restore barrel file with deprecation notice to support tests

Co-Authored-By: benny@cal.com <benny@cal.com>

* fix: update test-setup to use direct import from @calcom/ui/classNames

Co-Authored-By: benny@cal.com <benny@cal.com>

* fix: completely remove barrel file

Co-Authored-By: benny@cal.com <benny@cal.com>

* fix: update package.json to remove references to index.ts

Co-Authored-By: benny@cal.com <benny@cal.com>

* fix: remove main and types fields from package.json

Co-Authored-By: benny@cal.com <benny@cal.com>

* fix: restore barrel file with deprecation notice to support tests

Co-Authored-By: benny@cal.com <benny@cal.com>

* remove barrel file

* fix

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: benny@cal.com <benny@cal.com>
Co-authored-by: hbjORbj <sldisek783@gmail.com>
2025-05-13 21:55:58 -04:00
Joe Au-Yeung 7e699ab810 fix: Salesforce - fix option to create contact if account exists (#21288)
* Add `onCheckedChange`

* Remove console.logs
2025-05-13 22:17:57 +00:00
GitHub Actions 1bd17ba365 chore: release v5.2.16
Post release / release (push) Canceled after 0s
2025-05-13 21:54:23 +00:00
Benny Joo 4d5246cf4d chore: Remove bcryptjs dependency from @calcom/lib (#21286)
* remove bcryptjs lib

* update yarn lock
2025-05-13 22:41:42 +01:00
devin-ai-integration[bot]andzomars@cal.com 5d988453fa fix: add --ignore-missing flag to sentry-cli set-commits command (#21285)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: zomars@cal.com <zomars@cal.com>
2025-05-13 20:48:14 +00:00
JamieandTushar Bhatt 85b9782264 fix: use actual event duration mins for templated event names (#21263)
* fix: use actual event duration mins for templated names

* fix: use actual event duration mins for templated event name

* fix: use actual event duration for calendar links

* fix: use actual event duration for round robin reassignment

* chore: rm console logs

* fix: use actual event duration for emails and rr reassignment

* chore: remove unnecessary dayjs calls

---------

Co-authored-by: Tushar Bhatt <95581504+TusharBhatt1@users.noreply.github.com>
2025-05-13 18:51:43 +00:00
Udit Takkar c2b091aa67 perf: don't load ring.mp3 on booking page (#21272) 2025-05-13 16:48:14 +00:00
Farhan Javed 7093e1e4c6 fix: corrected path for cron in documentation (#21274)
* path-fix

* path-update2
2025-05-13 16:02:40 +00:00
ead42c1a9c feat: SMS credits for free users (#21245)
* Add credits section to billing

* create seperate router for credits

* add stripe checkout session

* schema changes + code improvements

* rename to creditBalance

* custom quantify input with error message

* add checkout session completed webhook endpoint

* fix typo

* UI fixes

* add payCredits handler

* add error toast message

* allow scheduling sms up as close to 15 minutes in the future

* schedule at most 2 hours in advance

* webhook to pay for sent sms

* continued work on twilio callback

* code clean up

* further implementation for credit handling

* add migration

* object as param for scheduleSMS

* object as param for sendSMS

* fix TrpcSessionUser imports

* fix imports

* add db changes

* add cron job for price setting

* twilio status callback to create expense log

* remove unused code

* set up low credit balance email

* fixes for buying credits

* fixes in api/twilio/webhook

* add test to save credits to credits balance

* fix typos

* add new helper function chargeCredits

* expand twilioProvider

* fix type errors

* adjust tests

* type errors

* clean up

* clean up

* fix subscription active check

* remove some user/org related code

* more changes to remove user/org support

* send emails seperatly to admins

* fixes for team billing page

* fix stripe success url

* fixes to creating expense log

* email imrovements and more

* get monthly team price from stripe

* fix import

* fix monthly credits calculation

* finsih low credit balance warning email

* credit balance limit reached email

* create CreditService

* cancel SMS and send as email instead

* add messageDispatcher

* fix type error

* fix type error

* fix type error

* fix import

* fix unit test

* clean up twilioProvider

* clean up chckSmsPrices/route

* add missing translations

* add skeleton loader

* add admin check to get handler

* code clean up + fixes

* improve scheduling with fallback

* fix type error

* add bookingUid to handleSendingSMS

* add unit tests for creditService

* add more tests to credit-service.test.ts

* add test for cancelScheduledMessagesAndScheduleEmails

* fix test and type error

* add back resolve

* fix empty resolve

* adjust limitReachedAt logic

* address mrge comment on styling

* add getAdminMembership to repository

* twilio/webhook clean up (feedback)

* feedback - clean up

* remove todo comment

* clean up twilio/webhook

* code clean up

* add use client

* add createOneTimeCheckout to stripe service

* refactor repository pattern

* small fixes + clean up

* fix type error

* add missing import

* fix hasAvailableCredits for user

* force-dynamic

* rename credits to creditBalance

* fix stripe import

* remove not needed code

* fix e2e tests

* improve low balance warning email

* dynamic-import CreditService

* index.ts

* add user logic checkSmsPrices endpoint

* fix e2e tests

* remove dynamic import CreditService

* Revert "remove dynamic import CreditService"

This reverts commit e272978a7ff3fc5a04139e656c9f8d2c84a40dda.

* no need to dynamic-import credit service

* Revert "no need to dynamic-import credit service"

This reverts commit ba5ae488d08979a65fb47b5d0722cda9f45d6ea0.

* fix twilio webhook

* add userId support in checkout.session.completed

* clean up code

* only select id in getAdminMembership

* revert billing/package.json

* fix type checks

* fix type checks

* adjust hasAvailableCredits function

* fixes for checkout sessioned completed

* add UI for user

* fix type errors

* adds requires credits badge

* remove team check from update.handler

* clean up inlcude statements

* fix credit-service tests

* add tests

* fix type errors

* fix type errors

* fix and add tests

* imrove badge

* code clean up

* add reminderScheduler test

* add additional credits as title

* fixes for warningSentAt and limitReachedAt

* mock stripe

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: hbjORbj <sldisek783@gmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2025-05-13 15:55:19 +02:00
Somay Chauhan e669996442 fix: Inconsistent Location Handling for Event Types API - Map office365-video to msteams in Validation Handling (#21255)
* refactor: rename office365-video to msteams across API and platform code

* Revert "refactor: rename office365-video to msteams across API and platform code"

This reverts commit 7a823ab6aa7ecd739da941d3b315735220bd99de.

* Fix: inconsistency in default location values for create and update event-types
2025-05-13 13:04:03 +00:00
Somay Chauhan 0e6f365dc2 fix: dont default to cal-video in case locations is not provided (#21256) 2025-05-13 09:57:16 +00:00
devin-ai-integration[bot]andbenny@cal.com 08b501625d chore: remove @calcom/lib/server/queries barrel file (#21268)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: benny@cal.com <benny@cal.com>
2025-05-13 05:18:00 -04:00
Benny Joo b89ff43921 chore: Remove @calcom/lib/schedules barrel file (#21266) 2025-05-13 05:17:01 -04:00
fadfba8830 feat: Credit System (SMS) (#20126)
* Add credits section to billing

* create seperate router for credits

* add stripe checkout session

* schema changes + code improvements

* rename to creditBalance

* custom quantify input with error message

* add checkout session completed webhook endpoint

* fix typo

* UI fixes

* add payCredits handler

* add error toast message

* allow scheduling sms up as close to 15 minutes in the future

* schedule at most 2 hours in advance

* webhook to pay for sent sms

* continued work on twilio callback

* code clean up

* further implementation for credit handling

* add migration

* object as param for scheduleSMS

* object as param for sendSMS

* fix TrpcSessionUser imports

* fix imports

* add db changes

* add cron job for price setting

* twilio status callback to create expense log

* remove unused code

* set up low credit balance email

* fixes for buying credits

* fixes in api/twilio/webhook

* add test to save credits to credits balance

* fix typos

* add new helper function chargeCredits

* expand twilioProvider

* fix type errors

* adjust tests

* type errors

* clean up

* clean up

* fix subscription active check

* remove some user/org related code

* more changes to remove user/org support

* send emails seperatly to admins

* fixes for team billing page

* fix stripe success url

* fixes to creating expense log

* email imrovements and more

* get monthly team price from stripe

* fix import

* fix monthly credits calculation

* finsih low credit balance warning email

* credit balance limit reached email

* create CreditService

* cancel SMS and send as email instead

* add messageDispatcher

* fix type error

* fix type error

* fix type error

* fix import

* fix unit test

* clean up twilioProvider

* clean up chckSmsPrices/route

* add missing translations

* add skeleton loader

* add admin check to get handler

* code clean up + fixes

* improve scheduling with fallback

* fix type error

* add bookingUid to handleSendingSMS

* add unit tests for creditService

* add more tests to credit-service.test.ts

* add test for cancelScheduledMessagesAndScheduleEmails

* fix test and type error

* add back resolve

* fix empty resolve

* adjust limitReachedAt logic

* address mrge comment on styling

* add getAdminMembership to repository

* twilio/webhook clean up (feedback)

* feedback - clean up

* remove todo comment

* clean up twilio/webhook

* code clean up

* add use client

* add createOneTimeCheckout to stripe service

* refactor repository pattern

* small fixes + clean up

* fix type error

* add missing import

* fix hasAvailableCredits for user

* force-dynamic

* rename credits to creditBalance

* fix stripe import

* remove not needed code

* fix e2e tests

* improve low balance warning email

* dynamic-import CreditService

* index.ts

* fix e2e tests

* remove dynamic import CreditService

* Revert "remove dynamic import CreditService"

This reverts commit e272978a7ff3fc5a04139e656c9f8d2c84a40dda.

* no need to dynamic-import credit service

* Revert "no need to dynamic-import credit service"

This reverts commit ba5ae488d08979a65fb47b5d0722cda9f45d6ea0.

* only select id in getAdminMembership

* revert billing/package.json

* fix type checks

* fix type checks

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: hbjORbj <sldisek783@gmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2025-05-13 09:11:44 +02:00
Benny Joo 43be9bd6a8 fix: localStorage error in AvailableTimeSlots (#21265) 2025-05-13 11:31:25 +05:30
Hariom Balhara caf184151d fix: Credentials cron for DelegationCredential not working (#21239)
* Remove unused return statement

* Fix return from fn instead of continuing the loop

* Ensure userId is set for existing records on update
2025-05-13 09:00:08 +05:30
GitHub Actions 22e3338709 chore: release v5.2.15
Post release / release (push) Canceled after 0s
2025-05-12 23:05:02 +00:00
048e56806c feat: useApiV2AvailableSlots (#21138)
* feat: useApiV2AvailableSlots

* feat: useApiV2AvailableSlots

* fix type check

* remove log

* fix unit tests

* simplify

* feat: add missing params to the slots endpoint

* updated documentation

* _shouldServeCache is correct

* fix types

* fix enabled prop

* fix type

* add embedConnectVersion to query key

* add teamId to slot type

* fix

* add fallback to trpc query if apiv2 fails

* add comment

* remove logs

* fix

* refactor: default to isTeamEvent in query params

* fix: only return v2 slots once fetch is success

* fix last e2e failing test

* add feature flag

* add migration sql

* Update packages/prisma/migrations/20250512153630_add_use_api_v2_for_team_slots_feature_flag/migration.sql

---------

Co-authored-by: hbjORbj <sldisek783@gmail.com>
Co-authored-by: Somay Chauhan <somaychauhan98@gmail.com>
Co-authored-by: supalarry <laurisskraucis@gmail.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2025-05-12 20:48:29 +00:00
Calcom BotandLingo.dev ade64c0d09 feat: update translations via @LingoDotDev (#20599)
Co-authored-by: Lingo.dev <support@lingo.dev>
2025-05-12 14:20:33 -04:00
Benny Joo 5beb2943b9 fix: localStorage error in useTheme (#21258) 2025-05-12 17:59:37 +00:00
Omar López b3d3cb1dee refactor: Uses withReporting to force named functions for tracing (#21219) 2025-05-12 13:22:52 -04:00
devin-ai-integration[bot] 79d695236f test: add e2e tests for filter segment functionality (#21160) 2025-05-12 12:52:54 -04:00
luzpazandAlex van Andel 2dbbdc21f2 fix: typos in apps/ui-playground (#21223)
Found via codespell

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-05-12 08:49:35 +00:00
luzpazandAlex van Andel e4254ef7cc fix: typos in apps/web (#21224)
Found via codespell

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-05-12 08:47:02 +00:00
luzpazandUdit Takkar 7f9709bdaa fix: typos in packages/ (#21216)
Found via codespell

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2025-05-12 09:30:43 +01:00
Morgan 77054d92df feat: add pagination to get bookings endpoints [v2] (#21173)
* feat: add pagination to get bookings endpoints [v2]

* add decorators for pagination

* fix: ensure correct values

* code review comment

* fixup! code review comment
2025-05-12 10:09:56 +02:00
Benny Joo 1277f5e9f7 chore: migrate signup API to appDir (#19975)
* refactor calcomHandler

* refactor selfHostedHandler

* fix

* migrate to appdir

* fix type checks
2025-05-12 09:38:03 +02:00
Rajiv SahalandMorgan 109c303b60 feat: active managed user billing (v1) (#20499)
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2025-05-10 16:13:09 +00:00
jen chanandamrit a52a0e90e7 feat: add autocomplete to login and signup (#21065)
Co-authored-by: amrit <iamamrit27@gmail.com>
2025-05-10 17:52:27 +02:00
+25 558140fdd6 fix: sentry builds (#20310)
* fix: sentry builds

* Update yarn.lock

* Upgrade Sentry

* Breaking changes

* Discard changes to apps/web/modules/connect-and-join/connect-and-join-view.tsx

* Discard changes to apps/web/modules/signup-view.tsx

* Discard changes to packages/app-store/make/pages/setup/index.tsx

* Discard changes to packages/app-store/zapier/pages/setup/index.tsx

* Discard changes to packages/emails/src/templates/AdminOrganizationNotificationEmail.tsx

* Discard changes to packages/features/eventtypes/components/tabs/apps/EventAppsTab.tsx

* Discard changes to packages/features/eventtypes/components/tabs/assignment/EventTeamAssignmentTab.tsx

* Discard changes to packages/features/eventtypes/components/tabs/webhooks/EventWebhooksTab.tsx

* Discard changes to packages/features/eventtypes/components/tabs/advanced/DisableAllEmailsSetting.tsx

* Discard changes to packages/emails/src/templates/BrokenIntegrationEmail.tsx

* Discard changes to packages/emails/src/templates/OrganizationCreationEmail.tsx

* Discard changes to packages/emails/src/templates/SlugReplacementEmail.tsx

* Discard changes to packages/emails/src/templates/TeamInviteEmail.tsx

* Discard changes to packages/features/bookings/Booker/components/BookEventForm/BookEventForm.tsx

* Discard changes to packages/features/bookings/Booker/components/OutOfOfficeInSlots.tsx

* Discard changes to packages/features/ee/teams/components/MemberInvitationModal.tsx

* Discard changes to packages/features/eventtypes/components/EditWeightsForAllTeamMembers.tsx

* Discard changes to packages/features/eventtypes/components/Locations.tsx

* Discard changes to packages/features/eventtypes/components/dialogs/ManagedEventDialog.tsx

* Discard changes to packages/features/eventtypes/components/tabs/advanced/RequiresConfirmationController.tsx

* Discard changes to packages/features/eventtypes/components/tabs/workflows/EventWorkfowsTab.tsx

* Discard changes to packages/features/settings/BookerLayoutSelector.tsx

* Discard changes to packages/features/settings/outOfOffice/OutOfOfficeEntriesList.tsx

* chore: upgrade dependencies and improve Sentry configuration

- Upgrade @sentry/nextjs to 9.13.0

- Upgrade next.js to 15.3.0

- Upgrade sharp to 0.34.1

- Update Sentry configuration files

- Add Sentry example files for testing

* chore: upgrade dependencies

- Upgrade next.js to 15.3.0

- Upgrade sharp to 0.34.1

* Update next.config.js

* Discard changes to packages/platform/atoms/connect/apple/AppleConnect.tsx

* build(apps/web): enabled Sentry source maps uploading.

* Create sentry-sourcemaps.yml

* fix: buggy skeleton rendering for month view (#20744)

* fix: trigger lingo.dev by removing duplicate value

* fix: buggy skeleton monthly view

* fix: Wrong schedule ID set for child managed event (#20745)

* fix: wrong schule id set for managed event

* Update handleChildrenEventTypes.ts

* feat: v2 book team events using teamSlug & eventTypeSlug (#20585)

* feat: v2 book team events using teamSlug & eventTypeSlug

* fix: tests

* fix: failing e2e test (#20751)

* feat: filter users by assigned attribute options (#20659)

* feat: assigned attributes options base

* feat: assigned attributes options base e2e

* feat: filter assigned options by other assigned options

* feat: filter assigned options by other assigned options

* fixup! feat: filter assigned options by other assigned options

* feat: get org users by assigned attributes

* adding missing input file

* push generated docs

* feat: add filter org users by teamIds

* feat: add filter assigned attribute options by teamIds

* chore; update doc

* chore: remove console log

* chore: minor e2e fix

* chore: minor e2e fix

* chore: improve docs

* fix: assigned options by teamIds + optionIds

* fix: Admin/owner unable to confirm managed bookings (#20743)

* fix: Admin/owner can't confirm managed booking

* Update confirm.handler.ts

* fix: v2 automatic round robin reassign error message (#20755)

* fix: return react fragment from all layout.tsx

* feat: add guards to stripe teams controller (#20540)

* feat: add guards to stripe teams controller

* remove logs and comments

* fix return type status

* refactor: move PlatformSubscription to a dedicated module

* reroute to `organizations/stripe/save` for teams

* fix: type errors

* feat: fixed it for conferencing apps

* feat: Add error handling and fallback URL support in Stripe callback

* Refactor OAuth callback handling and move token validation to service layer

* Add documentation for OAuth callback proxying in conferencing and stripe controllers

* Move OAuthCallbackState type from organizations to stripe service module

* chore: release v5.2.2

* perf: slim down middleware matcher (#20735)

* wip

* wip

* refactor

* add pathname

* fix

* fix: manually construct pathname for metadata functions

Co-Authored-By: benny@cal.com <benny@cal.com>

* fix: add pathname parameter to all metadata functions

Co-Authored-By: benny@cal.com <benny@cal.com>

* fix: add pathname parameter to remaining metadata functions

Co-Authored-By: benny@cal.com <benny@cal.com>

* fix

* fix

* fix type checks

* Refactor

* fix

* fix

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: benny@cal.com <benny@cal.com>

* Skip flakey tests (#20761)

* Write UTM params to Salesforce (#20759)

* chore: Add consent message when SMS workflows are related (#20741)

* Add consent message

* Add consent message if using SMS workflow

* Skip flakey tests

* Revert "Skip flakey tests"

This reverts commit 3bd6f3fd71469df4dbd67b69b8e17011657df9d8.

* chore: Remove verbose logging (#20732)

* fix: offer seats broken (#20758)

* Remove trailing } from utm param (#20763)

* fix: only managed users assignable to managed team event (#20754)

* fix: only managed users assignable to managed team event

* chore: bump platform libraries

* feat: make skeleton for booking embed accurate (#20765)

* fix: trigger lingo.dev by removing duplicate value

* feat: make skeleton for booking embed accurate

* fix: calendar connect atoms (#20766)

* fix: header overlaps in month view on short viewport (#20749)

* fix: header overlaps in month view on short viewport

* refactor: Merge classes into same condition

* fix: Remove booker margin top if isEmbed

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: Tushar Bhatt <95581504+TusharBhatt1@users.noreply.github.com>
Co-authored-by: amrit <iamamrit27@gmail.com>

* fx 404 for embed (#20120)

* Update rateLimit.ts (#20785)

* fix: v2 Sentry errors (#20788)

* fix: Only Team Event Types are supported for Instant Meeting

* refactor: Cannot read properties of undefined (reading 'timeZone')

* fix: Cannot read properties of undefined (reading 'map')

* refactor: rearrange function order

* refactor: booking_seats_full_error

* fix: invalid_round_robin_host

* refactor: booking_time_out_of_bounds_error

* refactor: Attempting to book a meeting in the past.

* fix: obj.phoneNumber.trim is not a function

* fix: Cannot use 'in' operator to search for 'disabled' in 1

* fix: Org onboarding handle error due to platform team being moved to regular org (#20786)

* Add better error logs and handle platform team

* Add better error logs and handle platform team

* fix: Wrong text EmbedTheme -> Theme (#20790)

* refactor: replace timeZone string validation with timeZoneSchema (#20719)

* refactor: replace timeZone string validation with timeZoneSchema - Replace all instances of timeZone: z.string() with timeZoneSchema - Preserve modifiers (.optional(), .default(), .nullish()) where present - Fix VideoApiAdapter to use organizer's timezone value - Remove redundant isSupportedTimeZone import from booking.ts - Add timeZoneSchema import where needed

* Update index.ts

* fix: conflicts

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

* Update yarn.lock

* Discard changes to yarn.lock

* Update booking.ts

* Discard changes to packages/platform/types/me.ts

* Discard changes to packages/platform/types/me/outputs/me.ts

* Discard changes to packages/platform/types/schedules/schedules-2024-04-15/schedules.ts

* Discard changes to packages/features/ee/cal-ai-phone/zod-utils.ts

---------

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

* fix: improved 'use-identity-login' error message for users trying to signInn using different provider (#20582)

* improved 'use-identity-login' error message for users trying to signin using different provider

* passing original identity provider as query parameter

* Update packages/features/auth/lib/next-auth-options.ts

* passed different error names for different types of error

---------

Co-authored-by: romit <romitgabani1@gmail.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: Tushar Bhatt <95581504+TusharBhatt1@users.noreply.github.com>

* fix: Location not changing when rescheduling (#20404)

* fix: location not chnging

* update

* update

* Update EventManager.ts

* update

* update

* Update reschedule.test.ts

---------

Co-authored-by: Tushar Bhatt <95581504+TusharBhatt1@users.noreply.github.com>
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>

* fix: sms workflow api v2 (#20795)

* fix: sms workflow api v2

* fixup! fix: sms workflow api v2

* fix: semi-colon in the bottom of layout UI (#20799)

* chore: Add layout.tsx to Foundation team CODEOWNERS (#20802)

* chore: remove swagger app and its dependencies (#20798)

* chore: release v5.2.3

* fix: Convert an error in webhook to info (#20806)

* Convert error to info

* Convert error to info

* feat: add "React (Atom)" to embed generator and rename rest (#20618)

* fix: trigger lingo.dev by removing duplicate value

* feat: add support for React Atom embed options

* fix: update locator for React code tab in embed code generator E2E tests

* fix: update embed tab names to clarify iframe usage

* fix failing test

* fix: indentation and installation commands for atoms

* fix: add note about iframe usage in Preview tab

* refactor: replace Cal component with BookerEmbed in EmbedCodes

* fix: Add EmbedConfig type import in Embed.tsx

- Added missing EmbedConfig type to the existing types import
- Resolves TypeScript error "Cannot find name 'EmbedConfig'"

* fix: update BookerEmbed usage in EmbedCodes component

* fix: enhance BookerEmbed implementation and update installation instructions in EmbedTabs

* fix: improve comments in EmbedCodes for clarity on layout options and styling references

* fix: streamline BookerEmbed usage in EmbedCodes with improved class handling and layout options

* fix: embed type selection

* fix: update layout selection in EmbedCodes to use dynamic configuration

* fix: update embed configuration to use theme instead of layout

* fix: conditionally render embed options based on if we are selecting atom

* fix: use simple props instead of embed specific logic to fix the active embed bug

* Fix double tabs activeness

* Revert isCurrent change

---------

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

* refactor: remove iframe usage note from Preview tab in EmbedTabs component (#20810)

* fix: trigger lingo.dev by removing duplicate value

* refactor: remove iframe usage note from Preview tab in EmbedTabs component

* fix: api v2 license check (#20813)

* fix: api v2 license check

* update cache key to avoid conflicts

* chore: Add more Foundation and Consumer team CODEOWNERS configuration (#20816)

* refactor: forbidden 403 v2 guard messages (#20794)

* refactor: delete unused proxy guard

* refactor: roles.guard.ts

* refactor: is-org.guard.ts

* refactor: permissions.guard.ts

* refactor: oauth-client-guard.ts

* refactor: is-user-event-type-webhook-guard.ts

* refactor: platform-plan.guard.ts

* refactor: roles.guard.ts

* refactor: cache guard result if canAccess=true

* refactor: is-admin-api-enabled.guard.ts

* refactor: is-team-in-org.guard.ts

* refactor: platform-plan.guard.ts

* refactor: is-webhook-in-org.guard.ts

* refactor: is-user-in-org.guard.ts

* refactor: is-user-webhook-guard.ts

* refactor: is-user-in-org-team.guard.ts

* refactor: is-membership-in-org.guard.ts

* refactor: is-oauth-client-webhook-guard.ts

* refactor: is-routing-form-in-team.guard.ts

* refactor: is-managed-org-in-manager-org.guard.ts

* fill the gaps

* refactor: api-auth.strategy.ts

* fix: test

* implement feedback

* fix: tests

* fix: refactor

* fix: test

* feat: update DeleteSegmentDialog to use ConfirmationDialogContent CAL-5399 (#20820)

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: eunjae@cal.com <eunjae@cal.com>

* feat: open dropdown right after adding a filter (#20819)

* feat: open dropdown right after adding a filter

* clean up timeout

* fix: remove window usage in not-found client (#20822)

* remove window usage in not-found client

* fix

* fix: user is already part of a team (#20774)

* better error message

* redirect to login if no user session

* fixup

* remove callback url since the same function is used by org and platform both

* remove extra exclamation mark

* implement PR feedback

* refactor: Remove kysely as its unused (#20817)

* perf: Fetch data server-side for Api Keys page (#20677)

* server-side fetching for api keys page

* refactor billing page

* fix

* simplify metadata in /availability/[schedule] route (#20827)

* feat: Optout of SMS workflows (#20769)

* Do not call dub if API key isn't present

* Add `WorkflowOptOutContact` table

* Add `sendTo` column to `WorkflowReminder`

* Add `workflowOptOutContactRepository`

* Add `WorkflowReminderRepository`

* Write `sendTo` number when scheduling SMS messages

* Add `p-limit` package

* Add `determineOptOutType` to `TwilioProvider`

* Add `deleteMultipleScheduledSMS`

* Create `WorkflowOptOutService`

* Add endpoint handler for Twilio SMS responses

* Add `isOptedOut` method to `workflowOptOutContactRepository`

* Verify phone number is not opted out before scheduling SMS

* Use `smsReminderNumber` instead of `sendTo`

* Type fix

* Add .env variable if opt out is available

* Add opt out message to SMS

* Import `pLimit` directly in method

* Address select comment

* Guard against undefined phone number from form

* Add early return to `deleteWorkflowReminders`

* Add request validation from Twilio

* Add fallback message as i18N string

* Only delete SMS attendee scheduled reminders

* chore: release v5.2.4

* fix: caching for sprite.svg (#20832)

* update cache-control header for sprite.svg

* updated regex

* fix: platform org members cannot access their public booking page (#20821)

* fix: platform member event pages

* chore: update slots docs

* refactor: user repository call

* refactor: remove newline

* refactor: Returns 400 error for ZodErrors (#20831)

* fix: eventType filter on /insights (#20823)

* fix: eventType filter on /insights

* remove log

* feat: custom reply To Email (#20771)

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

* feat(insights): add progress indicator to download button on /insights and /insights/routing (CAL-5408) (#20631)

* feat(insights): add progress indicator to routing form responses download button (CAL-5408)

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

* fix: address PR feedback - remove initial progress update and ensure no decimal parts in percentage

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

* feat(insights): add progress indicator to Download component

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

* feat: paginate rawData endpoint and implement batch fetching in Download component

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

* refactor: simplify pagination logic in trpc-router.ts

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

* show progress on tooltip

* fix download for /insights

* fix download when there is no data

* feat: replace tooltip progress indicator with toast notification

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

* fix: remove duplicate ProgressToast component from showToast.tsx

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

* fix: export ProgressToast component from index.ts

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

* fix: add missing translation for downloading_data

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

* update progress toast implementation

* fix error

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: eunjae@cal.com <eunjae@cal.com>
Co-authored-by: Eunjae Lee <hey@eunjae.dev>

* perf: SSR for /teams  (#20824)

* finish

* improve

* refactor

* add revalidations

* /settings/teams is not needed

* refactor

* rename

* redundant type casting

* perf: SSR - /availablilty/[schedule] (#20829)

* ssr for schedule page

* improve skeleton

* improve skeleton

* improve skeleton

* fix: getBookings max 1 month in the past when no date filters specified (#20911)

* fix: getBookings max 1 month in the past

* bump platform libraries

* Pass customReplyTo as a managed event type prop (#20909)

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>

* chore: release v5.2.5

* fix: Docs for deleting reserved slot (#20922)

* feat: ability to hide organizer email (#20782)

* feat: hide orgainzer email

* fix type error

* update

* update

* Update schema.prisma

* perf: Slim down trpc queries in Settings Layout   (#20918)

* slim down settings layout by removing server queries

* add check method

* fix type check

* refactor

* perf: remove next.redirect by going straight to event-types (#20920)

* perf: ssr for /settings/organizations/teams/other (#20927)

* refactor: v2 automatic team slugs (#20897)

* refactor: automatic team slugs

* refactor: automatic organizations team slugs

* refactor: automatic managed organizations slugs

* docs

* refactor: test

* feat: add loading component for /apps main page (#20924)

* perf: server-side fetching for /availability (#20804)

* availability

* refactor

* add loader

* improve

* update

* fix loader

* rename

* satisfying types

* refactor

* remove log

* add CTA to skeleton

* refactor

* fix revalidations

* refactor

* update

* finish

* fix: Include ordered attendees in booking select query (#20935)

* fix: correct ordering

* another

* docs: global platform event types and custom booking flows (#20940)

* docs: global event types

* docs: custom booking flow

* chore: release v5.2.6

* fix: v2 sentry errors (#20902)

* refactor: no_available_users_found_error for team event

* fix: hosts_unavailable_for_booking

* fix: Cannot read properties of undefined (reading 'replace')

* fix: Cannot read properties of undefined (reading 'phoneNumber')

* fix: No SelectedCalendar found.

* fix: Cannot read properties of undefined (reading 'length')

* refactor: add bookings errors service

* feat: add membership creation date to Organization Member List table (CAL-5406) (#20595)

* feat: add membership creation date to Organization Member List table (CAL-5406)

Co-Authored-By: keith@cal.com <keith@cal.com>

* feat: add migration for membership creation date (CAL-5406)

Co-Authored-By: keith@cal.com <keith@cal.com>

* feat: make createdAt and updatedAt nullable (CAL-5406)

Co-Authored-By: keith@cal.com <keith@cal.com>

* feat: add updatedAt column to Organization Member List table (CAL-5406)

Co-Authored-By: keith@cal.com <keith@cal.com>

* fix: use type assertion to access createdAt and updatedAt fields (CAL-5406)

Co-Authored-By: keith@cal.com <keith@cal.com>

* fix: display N/A for null date values (CAL-5406)

Co-Authored-By: keith@cal.com <keith@cal.com>

* fix: use proper type assertions for createdAt and updatedAt fields (CAL-5406)

Co-Authored-By: keith@cal.com <keith@cal.com>

* fix: add createdAt and updatedAt to UserTableUser mock in test (CAL-5406)

Co-Authored-By: keith@cal.com <keith@cal.com>

* feat: add PostgreSQL trigger for membership timestamps (CAL-5406)

Co-Authored-By: keith@cal.com <keith@cal.com>

* fix: use empty string instead of N/A and add translations for column headers (CAL-5406)

Co-Authored-By: keith@cal.com <keith@cal.com>

* add i18n text

* clean up type issue

* feat: add translation keys for column headers (CAL-5406)

Co-Authored-By: keith@cal.com <keith@cal.com>

* disable sort

* remove duplicated i18n texts

* feat: add filters for lastActiveAt, createdAt, and updatedAt (CAL-5406)

Co-Authored-By: keith@cal.com <keith@cal.com>

* support date range filter

* fix date range for end date

* hide columns by default

* revert wrong change

* add missing selects

* fix e2e test

* fix: remove PostgreSQL trigger and let application handle timestamps (CAL-5406)

Co-Authored-By: keith@cal.com <keith@cal.com>

* feat: add application-level timestamp handling for Membership model (CAL-5406)

Co-Authored-By: keith@cal.com <keith@cal.com>

* add more timestamp handling

* refactor: use Prisma's built-in decorators for Membership timestamps (CAL-5406)

Co-Authored-By: keith@cal.com <keith@cal.com>

* refactor: remove application-level timestamp handling in favor of Prisma decorators (CAL-5406)

Co-Authored-By: keith@cal.com <keith@cal.com>

* refactor: remove more application-level timestamp handling in favor of Prisma decorators (CAL-5406)

Co-Authored-By: keith@cal.com <keith@cal.com>

* fix e2e test

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: keith@cal.com <keith@cal.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: Eunjae Lee <hey@eunjae.dev>

* fix: Offset is required but not passed everywhere, default to zero. (#20917)

* fix: exclude email from replyTo (#20953)

* fix: exclude email from replyTo

* update

* Update scheduleEmailReminders.ts

* fix: added order by (#20959)

* fix: double end-date parsing when saving availability (#20963)

* fix: double end-date parsing when saving availablity

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

* fix: cache hits in current and next month

* Update CalendarService.test.ts

---------

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

* fix: Don't delete Cal video room upon requesting reschedule (#20969)

* Update requestReschedule.handler.ts

* Update requestReschedule.handler.ts

* chore: leverage error.tsx in app router (#20955)

* chore: better code - migrate getServerSideProps file directly to RSC for /apps (#20926)

* migrate getServerSideProps file directly to RSC for /apps

* fix

* fix: dont persist last booking response, temporarily (#20975)

* dont persist last booking response

* remove dead code to fix ts error

* perf: Improve performance of period limits (#20974)

* perf: Improve performance of period limits

* Type fixes

* Fixed correct check for instanceof -> isDayjs

* revert: "feat: add membership creation date to Organization Member List table (CAL-5406) (#20595)" (#20978)

This reverts commit d21f7ccbc8.

* perf: Add credentialId index to selectedCalendar (#20979)

* docs: add JSDoc documentation to FeaturesRepository class and methods (#20980)

* chore: release v5.2.7

* Handle acme.cal.local root path (#20981)

* Update sms-manager.ts (#20985)

* Do not retrieve already persisted data as well (#20982)

* perf: workflows—remove data fetching for metadata (#20734)

* Remove 30 seats minimum text from organization setup page (#20983)

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: hariom@cal.com <hariom@cal.com>

* fix: few details not populate (#20987)

* fix: cancel platform subscription on dashboard (#20787)

* add new endpoint to cancel user subscription

* remove comments

* add delete button to frontend

* update correct request method

* fixup

* frontend for cancelling subscription

* better warning message

* better function name

* update locales

* better error handling in case stripe api call fails

* rename teamId to organizationId

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>

* feat: added `hideOrganizerEmail` to api/v2/event-types (#21005)

* feat: added `hideOrganizerEmail` to api/v2/event-types

* updated docs

* fix: Skip already completed org onboarding, as webhook request can come when invoice is paid again next month (#20984)

* there is nothing to do for repeat invoice payment

* there is nothing to do for repeat invoice payment

* fix: wrong translation value (#17923)

* feat: ability to remove attendees from booking page

* update

* fix

* fix: unable to create meeting with ms teams (#20993)

* fix: few details not populate

* fix: unable to create meeting with ms teams

* Update CrmService.ts

* update

* fix: encoding bug (#21004)

* fix: encoding bug

* fix: use escapeValue

* perf: SSR - /event-types and /event-types/[type] (#20952)

* add revalidation

* ssr for event-types

* wip

* wip

* fix types

* fix skeleton

* better error component

* make skeleton better

* no need to export

* call revalidations early

* remove revalidations if not needed

* address comments

* address

* fix: schedule unscheduled emails with tasker in cron job (#20748)

Co-authored-by: CarinaWolli <wollencarina@gmail.com>

* fix: hide orgnizer email in cal video sidebar (#21011)

* refactor: Use Salesforce GraphQL for account ownership routing (#20655)

Co-authored-by: Omar López <zomars@me.com>

* fix: Handle calendar-cache with Delegation Credentials

Fixes CAL-5372

# Delegation Credentials with CalendarCache.

Following content is a snapshot of the [internal document](https://calendso.slack.com/docs/T08B8KA2BNF/F08L5JYU3V3)



**Problem-1 :** 

CalendarCache needs SelectedCalendar records to work but SelectedCalendar record is only created when a user connects their calendar and then enables some calendar for conflict checking. Because with Delegation, no manual connection is done by any of the members, we need a way to create SelectedCalendar records automatically.

**Problem-2**

CalendarCache connects to credential(regular credential) which doesn’t exist for Delegation Credential scenario. Also, DelegationCredential is common for all the members(different from Credential which is different for different members) of the organization and we need to identify to which user the CalendarCache belongs.  

**Solution for both problems**
- Create credential records for Delegation Credentials as well - Through Cron(new - we could schedule it every 5mins)
- Now create SelectedCalendar  records for those Credential records -  Through another Cron(new - we could schedule it every 5mins)
- Now CalendarCache records will automatically be created for those SelectedCalendar records -existing cron

## Fixed some Delegation Credentials bugs unrelated to calendar-cache
- If DestinationCalendar wasn't set(which is possible only with Delegation Credentials), then Google Meet wasn't used as a conferencing app - [Added a test]
- If no SelectedCalendar is there but Google Calendar connection exists(possible only with Delegation Credential) then we were not doing conflict checking. It is expected to not do it for Regular Credentials, but for Delegation Credential we must check for conflict in that case too [Added a test]
- Earlier if a user has Regular Credential as well as Delegation Credential for the same external id which is the member email(say member1@acme.com) then availability were retrieved twice because we weren't deduplicating credentials as it wasn't a trivial thing to do. Now that is being done.


**Env Variables:**
Note this PR doesn't introduce any new env variable. The existing env variable has been added to .env.example. But if this env variable isn't already set, it must be set.

`CALCOM_SERVICE_ACCOUNT_ENCRYPTION_KEY={SAME_AS_SET_FOR_V2_API}`

**Deployment Plan:**
1. Add Observability for SelectedCalendar when _error_ field is set
2. Follow https://github.com/calcom/cal.com/blob/calendar-cache-dwd-support/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/delegation-credential/delegation-credential.md#setting-up-delegation-credential-for-google-calendar-api to enable Delegation Credential for i.cal.com
3. Note that to be able to see the option to enable Delegation Credential for an organization, you need to enable `teamFeature` and `feature` for `delegation-credential`

## Automation Tests
- Introduced tests for calendar-cache.repository.ts
   - Tests all methods of the repository
- Added more tests for handleNewBooking/delegation-credential flow.
   - Added test to verify the bug fix when no DestinationCalendar exists and Google Meet should be used still 
- Added more tests for Google Calendar/CalendarService targeting DelegationCredential
- Added more tests for getCalendarsEvents. 
    - To test the new logic of calling getAvailability still if there are no selectedCalendars in case of Delegation Credential
    - Also introduced tests for `getAvailabitlityWithTimezones` which was an existing function but now has some new changes.
- Added tests for deduplication logic in CalendarManager.ts

## How to Test
Enable Calendar Cache and Delegation Credential feature for acme org through `features` and `teamFeatures` tables.
- Enable Delegation Credential for acme org
- Enable atleast 1 calendar for conflict checking for one of the users(say owner1)
- Ensure GOOGLE_WEBHOOK_TOKEN is set in .env file
- Ensure GOOGLE_WEBHOOK_URL is set to ngrok url of webapp in .env file
- Hit cron endpoint `curl http://localhost:3000/api/calendar-cache/cron\?apiKey\={API_KEY}` that would cache the freebusy result for the selected calendars



Followup 
- https://github.com/calcom/cal.com/pull/20698
- https://github.com/calcom/cal.com/pull/18619/files#r2046795643

* fix: UI Bug - ToggleGroup (#20997)

* fix #20986: UI Bug - ToggleGroup

* some changes

* some changes

* feat: /skip trial for teams (#20753)

* skip team trials

* add loading state

* update mocks

* cleanup

* add import oops

* fix tests files

---------

Co-authored-by: Sean Brydon <sean@Seans-Mac-mini.local>
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>

* fix: cleanup html reports when pr is merged/closed (#20089)

Co-authored-by: anik@cal.com <anik@cal.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix: allow only platform organisation or non-platform organisation (#20972)

* fix: platform customers cant subscribe to organizations plan

* fix: organization customers cant subscribe to platform plan

* chore: format calendar events to remove oauthClientIds (#21040)

* fix: meeting link fails when host requests to reschedule (#20999)

* fix: meeting link fails when host requests to reschedule

* Update EventManager.ts

* fixes

* Incorportate prisma integration

* Update package.json

* Delete apps/web/app/sentry-example-page/page.tsx

* Discard changes to packages/features/insights/server/trpc-router.ts

* Delete .github/workflows/sentry-sourcemaps.yml

* Delete apps/web/app/api/sentry-example-api/route.ts

* Update turbo.json

* Delete sentry.client.config.ts

* Delete sentry.client.config.ts

* fix

* Update package.json

* Update next.config.js

* Update package.json

* Update yarn.lock

* Discard changes to .env.example

* Discard changes to packages/embeds/embed-react/package.json

* Discard changes to packages/platform/atoms/package.json

* Discard changes to packages/features/ee/teams/components/TeamsListing.tsx

* Update instrumentation-client.ts

* Update instrumentation-client.ts

* Update sentry.edge.config.ts

* Update next.config.js

---------

Signed-off-by: Omar López <zomars@me.com>
Co-authored-by: Yadong Zhang <zhyd007@gmail.com>
Co-authored-by: amrit <iamamrit27@gmail.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: Lauris Skraucis <lauris.skraucis@gmail.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: Benny Joo <sldisek783@gmail.com>
Co-authored-by: Somay Chauhan <somaychauhan98@gmail.com>
Co-authored-by: GitHub Actions <github-actions@github.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: benny@cal.com <benny@cal.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Evan Liu <evanliu.dev@gmail.com>
Co-authored-by: Tushar Bhatt <95581504+TusharBhatt1@users.noreply.github.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: Ronit Raj <ronitrajofficial7@gmail.com>
Co-authored-by: Romit Gabani <85230081+romitg2@users.noreply.github.com>
Co-authored-by: romit <romitgabani1@gmail.com>
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: eunjae@cal.com <eunjae@cal.com>
Co-authored-by: Eunjae Lee <hey@eunjae.dev>
Co-authored-by: Rajiv Sahal <sahalrajiv-extc@atharvacoe.ac.in>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Vijay <vijayraghav22@gmail.com>
Co-authored-by: Joe Au-Yeung <j.auyeung419@gmail.com>
Co-authored-by: keith@cal.com <keith@cal.com>
Co-authored-by: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com>
Co-authored-by: hariom@cal.com <hariom@cal.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Sean Brydon <sean@Seans-Mac-mini.local>
Co-authored-by: anik@cal.com <anik@cal.com>
2025-05-09 19:44:48 +00:00
GitHub Actions fa09fd66ff chore: release v5.2.14
Post release / release (push) Canceled after 0s
2025-05-09 17:03:57 +00:00
Joe Au-Yeung b1cbeebe48 feat: Add option to include no shows in RR calculations (#21063)
* Show distribution option for RR scheduling type

* Add `includeNoShowInRRCalculation` to event type schema

* Add `includeNoShowInRRCalculation` initial event type data

* Add FE option for `includeNoShowInRRCalculation`

* Consider `includeNoShowInRRCalculation` when building `where` clause when getting bookings for RR

* Add `includeNoShowInRRCalculation` param to `getAllBookingsForRoundRobin`

* Add `includeNoShow...` param to `GetLuckyUserParams`

* Pass `includeNoShow...` param to `getBookingsOfInterval` calls

* Add FE option for `includeNoShow...` (actual commit)

* Type fixes

* Fix brackets

* Add booking repository test

* Remove unused method

* Add translation

* Type fix

* Type fix

* Test passing

* Fix tests

* Fix tests

* Fix test

* Type fixes
2025-05-09 16:29:57 +00:00
devin-ai-integration[bot] 93bad282d7 feat: add SMS_ATTENDEE action to activateEventType.handler.ts (#21201) 2025-05-09 18:08:21 +02:00
Anik Dhabal Babu 47d941676e fix: add org email into ics when hideorganizer email is true (#21212)
* fix: add org email into ics

* update test
2025-05-09 15:57:24 +00:00
Keith Williams 54c4b0b65e chore: Remove Sentry replay integration (#21211) 2025-05-09 11:44:30 -04:00
Udit Takkar 0f8356f417 chore: add some console logs for debugging (#21130)
* chore: add some console logs for debugging

* chore: log only essential
2025-05-09 08:26:12 +00:00
Hariom BalharaandTushar Bhatt cecfcc7c6f Setup delegation credential related crons (#21176)
Co-authored-by: Tushar Bhatt <95581504+TusharBhatt1@users.noreply.github.com>
2025-05-09 07:59:31 +00:00
amritandTushar Bhatt 0b3f9d06a9 test: add tests for the search dropdown in /bookings/upcoming filter (#21141)
* fix: trigger lingo.dev by removing duplicate value

* feat: add filter dropdown item search tests for bookings list

* refactor: update page navigation to wait for DOM content in bookings list tests and add new filter item search test

* remove comments

---------

Co-authored-by: Tushar Bhatt <95581504+TusharBhatt1@users.noreply.github.com>
2025-05-09 11:12:30 +05:30
Yadong (Adam) ZhangandOmar López 71e7300830 build(apps/web): enabled client code Sentry source maps uploading. (#21108)
Co-authored-by: Omar López <zomars@me.com>
2025-05-08 20:14:47 -07:00
Omar López bccffb328b refactor: removes copy-webpack-plugin (#21200) 2025-05-09 01:01:53 +00:00
Carina WollendorferandCarinaWolli 80fa172f79 feat: add feature flag for sending workflow emails with smtp (#21187)
* add feature flag

* add unit tests

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2025-05-08 22:39:14 +00:00
Joe Au-Yeung 50173658f8 refactor: Use Iffy for workflow body scanning (#21170)
* Add Iffy API key variable

* WIP using Iffy to scan comments

* Use Iffy for workflow body scanning

* Update entity

* Clean up

* Update test

* Fix client id

* Fix test
2025-05-08 23:38:49 +01:00
Lauris Skraucis dddbd7dad4 feat: v2 membership attributes (#21131)
* refactor: getByUserId.handler.ts

* feat: return org membership attributes

* docs

* export new library function

* test: attributes

* chore: bump libraries

* refactor: value -> option
2025-05-08 23:37:24 +01:00
Benny Joo ae2d85d81b chore: Remove @calcom/features/schedules barrel files (#21199)
* remove barrel files

* update imports
2025-05-08 23:15:51 +01:00
GitHub Actions 80c407a4b7 chore: release v5.2.13
Post release / release (push) Canceled after 0s
2025-05-08 21:44:17 +00:00
6e9ec113fe feat: Prerender booking link and reuse with headless router (#20720)
* mvp done

* wip

* fix ts errors and other code improvements

* fix ts errors

* ensure mobile layout support

* Make skeleton responsive on screen resize

* refactor

* Add test for EmbedElement

* make skeleton closer to pixel perfect

* Address PR feedback

* Router-preloading
## What does this PR do?

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

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

## Visual Demo (For contributors especially)

A visual demonstration is strongly recommended, for both the original and new change **(video / image - any one)**.

#### Video Demo (if applicable):

- Show screen recordings of the issue or feature.
- Demonstrate how to reproduce the issue, the behavior before and after the change.

#### Image Demo (if applicable):

- Add side-by-side screenshots of the original and updated change.
- Highlight any significant change(s).

## Mandatory Tasks (DO NOT REMOVE)

- [ ] I have self-reviewed the code (A decent size PR without self-review might be rejected).
- [ ] I have updated the developer docs in /docs if this PR makes changes that would require a [documentation change](https://cal.com/docs). If N/A, write N/A here and check the checkbox.
- [ ] I confirm automated tests are in place that prove my fix is effective or that my feature works.

## How should this be tested?

<!-- Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration. Write details that help to start the tests -->

- Are there environment variables that should be set?
- What are the minimal test data to have?
- What is expected (happy path) to have (input and output)?
- Any other important info that could help to test that PR

## Checklist

<!-- Remove bullet points below that don't apply to you -->

- I haven't read the [contributing guide](https://github.com/calcom/cal.com/blob/main/CONTRIBUTING.md)
- My code doesn't follow the style guidelines of this project
- I haven't commented my code, particularly in hard-to-understand areas
- I haven't checked if my changes generate no new warnings

* wip\

* wip

* fix mrge.io feedback

* wip

* Add README and lifecycle

* Add README and lifecycle

* Update routing form-seed and some other fixes

* remove linkFailed fix from the branch

* self-review

* self-review-2

* self-review-3

* Handle soft connect\

* Update README and fix a bug with query parmas

* Add one more case in routing-html playground

---------

Co-authored-by: Benny Joo <sldisek783@gmail.com>
Co-authored-by: amrit <iamamrit27@gmail.com>
2025-05-08 19:59:26 +01:00
Hariom Balhara 3ee6a7ecde update delegation creential readme (#21178) 2025-05-08 17:40:38 +00:00
Somay Chauhan 8ad62d183c feat: booking references api type filter (#21179)
* feat: add `type` filter for booking references API endpointa

* Update openapi.json

* feat: add enum validation for booking reference types in filter input

* refactor: rename externalUid to eventUid and add destinationCalendarId in booking references

* refactor: replace enum with const array for BookingReferenceType

* fix: type safety for booking references where clause using Prisma types

* refactor: rename BookingReferenceType to BookingReferences
2025-05-08 17:23:09 +00:00
Benny Joo f047799299 chore: remove @calcom/lib/server barrel file (#21190) 2025-05-08 22:36:30 +05:30
Lauris SkraucisandMorgan 3a9e353443 fix: don't allow double reserving already reserved slot (#21139)
* feat: prevent double reserving slots

* tests

* remove log

* logs

* refactor: releaseAt utc check

* fix: check overlap when updating slots

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2025-05-08 14:39:33 +02:00
Udit Takkar b73a3aeb0c feat: enforce log in cal video (#21082)
* feat: enforce log in cal video

* chore: remove un necessary text

* fix: type err

* fix: pass user name
2025-05-08 12:37:50 +00:00
Anik Dhabal Babu b6ff64ed74 fix: attendees not visible for hosts (#21156) 2025-05-08 12:23:27 +00:00
Anik Dhabal Babu 1b281fe269 fix: unable to change the workflow action (#21185) 2025-05-08 10:06:02 +00:00
Somay Chauhan 024913592b feat: Add handleSlotSelect prop for custom timeslot selection/reservation handling (#21133)
* feat: Add `handleSlotSelect` prop for custom timeslot selection/reservation handling

* Update custom-slot-select-flow.mdx

* Update custom-slot-select-flow.mdx

* refactor: rename handleSlotSelect to handleSlotReservation for clearer intent

* docs: add API reference link for slot reservation api
2025-05-08 15:13:58 +05:30
Udit TakkarandAnik Dhabal Babu 78a34038d2 test: add e2e test for round robin event types (#21159)
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-05-08 11:41:21 +05:30
Romit GabaniandTushar Bhatt 5f770a9789 fix: hiding And connect with specific team members option for non org teams (#21083)
* fix: hiding And connect with specific team members option for non org teams

* removed console logs

* added comment

---------

Co-authored-by: Tushar Bhatt <95581504+TusharBhatt1@users.noreply.github.com>
2025-05-08 05:09:11 +00:00
devin-ai-integration[bot]andmorgan@cal.com 24a43877ce perf: optimize event type query by using team IDs instead of complex join (#21162)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: morgan@cal.com <morgan@cal.com>
2025-05-07 13:00:49 -07:00
GitHub Actions 4a96be4175 chore: release v5.2.12
Post release / release (push) Canceled after 0s
2025-05-07 19:34:41 +00:00
594bb606b8 perf: Refactor bookings queries for API (#16407)
* perf: Refactor bookings queries for API

* fix syntax

* fixing vars

* Inverted the filter by attendees if

* fixed prisma filter issue

* Defaulting list of emails to empty array

* Only running 2 queries if we have data to filter by

* Reverted changes that have been separated out to other PR

* fix: ts build error

* adds tests + extracts logic for units

* fix integration tests

* fix tests expectes

* Fix test

* switching back

* attempt : fix tests

* test: add test for duplicate bookings (#21165)

* feat: Add tests for booking and duration limits (#21158)

* Add tests for booking and duration limits in getSchedule.test.ts

Co-Authored-By: keith@cal.com <keith@cal.com>

* fix: use SchedulingType enum instead of string for schedulingType

Co-Authored-By: keith@cal.com <keith@cal.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: keith@cal.com <keith@cal.com>

* test: add test to ensure no duplicate bookings when merging query results

Co-Authored-By: keith@cal.com <keith@cal.com>

---------

Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: keith@cal.com <keith@cal.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>

* fix: deduplicate bookings when merging results from multiple queries

Co-Authored-By: keith@cal.com <keith@cal.com>

* fix: ensure pagination works correctly when merging results from multiple queries (#21167)

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: keith@cal.com <keith@cal.com>

---------

Co-authored-by: Tushar Bhatt <95581504+TusharBhatt1@users.noreply.github.com>
Co-authored-by: Tushar <tusharbhatt0135@gmail.com>
Co-authored-by: sean-brydon <sean@cal.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: keith@cal.com <keith@cal.com>
2025-05-07 11:44:56 -07:00
Omar López 1c088f65c5 chore: Removes around app (#20650) 2025-05-07 14:41:46 -04:00
devin-ai-integration[bot]andkeith@cal.com 7d7a429780 feat: Add tests for booking and duration limits (#21158)
* Add tests for booking and duration limits in getSchedule.test.ts

Co-Authored-By: keith@cal.com <keith@cal.com>

* fix: use SchedulingType enum instead of string for schedulingType

Co-Authored-By: keith@cal.com <keith@cal.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: keith@cal.com <keith@cal.com>
2025-05-07 13:06:10 -04:00
GiridharandTushar Bhatt d3228441aa fix:decline-booking-reason-missing (#21144)
Co-authored-by: Tushar Bhatt <95581504+TusharBhatt1@users.noreply.github.com>
2025-05-07 21:01:33 +05:30
Morgan 21d6bf1fca fix: api v2 routing form responses flaky tests (#21150) 2025-05-07 16:22:02 +03:00
a4134aa5a3 feat: add 'View Responses' menu item to Routing Form dropdown (#21114)
* feat: add 'View Responses' menu item to Routing Form dropdown

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

* fix: use nuqs createSerializer for URL generation

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

* refactor: move serializer to data-table lib and use proper type constants

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

* fix: type errors in serializers and FormActions

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

* fix: use parseAsArrayOf with parseAsJson for proper nuqs serialization

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

* fix: update to use dataTableQueryParamsSerializer with proper nuqs pattern

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

* refactor: extract parsers from DataTableProvider to be reused in serializers

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

* fix: remove deprecated activeFiltersSerializer and use empty array instead of null

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

* fix: only show View Responses menu when form id and teamId exist

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

* remove teamId condition

* refactor: improve viewResponses URL generation with cleaner conditional

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

* Update packages/features/data-table/lib/parsers.ts

* refactor: make constants in parsers.ts non-exported

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

* fix: add explicit type annotations to constants in parsers.ts

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

* fix: add explicit type annotations and export DEFAULT_PAGE_SIZE in parsers.ts

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

* fix: add explicit type assertions to DEFAULT_ACTIVE_FILTERS and DEFAULT_SORTING

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: eunjae@cal.com <eunjae@cal.com>
Co-authored-by: Eunjae Lee <hey@eunjae.dev>
Co-authored-by: Benny Joo <sldisek783@gmail.com>
2025-05-07 13:10:21 +00:00
Anik Dhabal Babu 61397e5184 fix: e2e flakes (#20630)
* fix

* Update managed-event-types.e2e.ts

* fix
2025-05-07 18:04:06 +05:30
Somay Chauhan 6143cd60e5 feat: Add booking references API endpoints (#21127)
* feat: Add booking references API endpoints

* refactor: Implement booking-reference service and repositories

* Add description and reorder properties in BookingReference schema

* Refactor booking references service to separate output service
2025-05-07 12:30:53 +00:00
Eunjae Lee ac821f6b13 chore: convert booking_denormalized e2e test to integration test (#21148) 2025-05-07 14:23:25 +02:00
sean-brydon 2bce38fd2a feat(v3): table designs (#21147) 2025-05-07 09:54:36 +00:00
Hariom BalharaandOmar López 390ee79134 Reduce payload size (#21093)
Co-authored-by: Omar López <zomars@me.com>
2025-05-07 03:16:07 +00:00
cfc9f0bfc3 refactor: Consolidate date parameter extraction and booking period check logic (#21142)
Co-authored-by: keith@cal.com <keith@cal.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Omar López <zomars@me.com>
2025-05-06 16:33:22 -07:00
Morgan 58d5f2a17c fix: advanced text filters getBookings with kysely (#21143) 2025-05-06 21:08:14 +00:00
Anwar Sadath 37f271bd74 fix: delete organization user endpoint not working in api v2 (#21136) 2025-05-06 19:10:55 +00:00
devin-ai-integration[bot]andkeith@cal.com d770cfc65e perf: Optimize team bookings query by fetching data for multiple users at once (#21137)
Co-authored-by: keith@cal.com <keith@cal.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-05-06 11:47:06 -07:00
Eunjae Leeandsean-brydon ab8556790e feat: add denormalized table for BookingTimeStatus view (#20947)
* feat: add denormalized table for BookingTimeStatus

* Update packages/prisma/migrations/20240320000001_add_booking_time_status_denormalized/migration.sql

Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>

* add more indexes

* improvement

* update schema

* improve indexes

* apply feedback from mrge

* split update trigger for better perf

* remove unnecessary checks

* split migration into two

* less logging

* address feedback

* add updatedAt and userUsername

* remove timeStatus

* add deletion trigger

* clean up denorm table on row deletion at EventType or users

* add timeStatus back as a part of a new view

* keep user and eventType nullable

* rename triggers

* add tests

* clean up migrations

* update tests

* update tests

* update test clean up

* one more try

* similar updates

* use e2e test instead

---------

Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
2025-05-06 20:34:45 +02:00
Hariom Balhara 3d89b37cc6 Adding already released embed version (#21033) 2025-05-06 15:19:47 +00:00
GitHub Actions 78b213321c chore: release v5.2.11
Post release / release (push) Canceled after 0s
2025-05-06 14:43:34 +00:00
Carina WollendorferandCarinaWolli 276ba1179f fix: improvements for scheduling Email reminders (#21129)
* improvements cron job

* improvements emailReminderManager

* order bookings for reminders

* fix cleaning up past cancelled reminders

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2025-05-06 15:23:48 +01:00
Hariom BalharaandAnik Dhabal Babu 0109d166a1 Update delegation-credential.md (#21124)
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-05-06 13:29:13 +00:00
Morgan 779c014256 fix: get bookings advanced email and attendee filter (#21125)
* fix: get bookings advanced email and attendee filter

* bump libraries

* bump libraries
2025-05-06 09:28:57 +01:00
sean-brydonandUdit Takkar 073faf9e10 feat: Salesforce Event Type App v3 designs (#21100)
* section component

* more section UI

* wip

* core components styled

* all components using sections

* match icons to figma

* small inputs

* fix select small to be the same as small text

* center flex rows

* enforce inputs to be full spanning grid

* tidy up section container for single inputs

* fix select and i18n

* wip

* install available apps

* raw heading

* app install card

* fix app logos width x height

* fix gap

* match design for other button

* fix prop name

* push i18n string

* fix doc snippets

* fix docs

* chore: remove yarn.lock changes

---------

Co-authored-by: Udit Takkar <udit222001@gmail.com>
2025-05-06 08:21:20 +00:00
GitHub Actions d0e39cf2dd chore: release v5.2.10
Post release / release (push) Canceled after 0s
2025-05-06 02:50:01 +00:00
Benny Joo f9488a1866 fix: theme search param crashing booking page (#21120)
* fix

* fix
2025-05-06 00:31:40 +00:00
MorganandKeith Williams d4a3894a9e chore: get bookings with kysely (#21084)
* chore: get bookings with kysely

* fixup! chore: get bookings with kysely

* replace left joins with inner joins

* add missing filter and count with kysely

* main query done

* kysely use connection string

* fix attendee emails operator

* fix immutability and add attendee email filter

* chore: everything working but date filters

* limit offset

* chore: bump platform libraries

* fix bookingseat / attendee user email filter

* refactor, clean, and subquery updatedAt/createdAt

* fix import kysely types path

* fix missing references and payments fields

* create kysely module in apiv2

* fix before created at filter

* fix types

* update yarn.lock

* update yarn.lock

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2025-05-05 17:21:15 -07:00
Omar López 91288b41cd refactor: centralize feature flag operations in FeaturesRepository (#21032) 2025-05-05 19:06:41 -04:00
GitHub Actions 1aa2f68e73 chore: release v5.2.9
Post release / release (push) Canceled after 0s
2025-05-05 17:55:27 +00:00
Udit Takkar ad707b0ee4 chore: improve error message (#21112) 2025-05-05 16:41:44 +00:00
Alex van Andel 49a2f4d4f7 chore: Add proton.me to email provider list (#21117) 2025-05-05 16:09:43 +00:00
Benny Joo f576979345 perf: remove unnecessary routes from middleware (#21089)
* perf: Remove unnecessary routes from middleware

* remove tests not needed
2025-05-05 11:47:27 -04:00
Keith Williams b45399ab78 chore: Update CODEOWNERS for booking tRPC handlers (#21118) 2025-05-05 15:40:42 +00:00
306dc5e4fb fix: add back round robin data to insights/router-position (#21116)
* add back all data to router-position

* fix type error

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2025-05-05 15:30:55 +00:00
Anik Dhabal Babu 605304b93b fix: availability view error (#21115)
* fix: avaiability view error

* Update list.handler.ts
2025-05-05 14:41:17 +00:00
amritandTushar Bhatt d6a17b2b1e feat: booking URL validation to accept links without explicit mention of https:// (#21110)
* fix: trigger lingo.dev by removing duplicate value

* feat: allow urls without an explicit https://

* fixed the test that ensures malformed URLs like "http:/nope" are rejected

---------

Co-authored-by: Tushar Bhatt <95581504+TusharBhatt1@users.noreply.github.com>
2025-05-05 12:49:19 +00:00
Yadong (Adam) Zhang 4bee875a8c build(packages/features): resolved type error when building apps/web. (#21094) 2025-05-05 11:20:05 +00:00
Anik Dhabal Babu fe4ae3ae62 fix: {Organizer} placeholder in event title not updating on reschedule (#21034) 2025-05-05 10:22:29 +00:00
Anik Dhabal Babu a245578ff2 fix: meeting link fails when manually reassign (#21106)
* fix: meeting link fails when manually reassign

* update

* Update booking.ts
2025-05-05 09:32:56 +00:00
sean-brydonandEunjae Lee f5ec696e15 fix: button fixes v3 (#20557)
* fix button styling

* add button usage

---------

Co-authored-by: Eunjae Lee <hey@eunjae.dev>
2025-05-05 09:31:14 +00:00
sean-brydonandUdit Takkar ec9384858b move form builder email input away from routing form widget (#21077)
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2025-05-05 09:05:01 +00:00
Eunjae Lee 47613f796e feat: add membership creation date to Organization Member List table (CAL-5406) (#21008)
* feat: add membership creation date to Organization Member List table (CAL-5406)

This reverts commit 5aaca2b7aa.

* fill createdAt
2025-05-05 10:15:30 +02:00
luzpaz 446c780d85 fix: typos in apps/api/v2 (#21105)
Found via `codespell -q 3 -S "*.svg,./apps/web/public/static/locales,./packages/app-store/stripepayment/lib/currencyOptions.ts,./packages/lib/freeEmailDomainCheck/freeEmailDomains.ts" -L afterall,atleast,datea,fo,incase,ist,nam,notin,optionel,perview,reccuring`

Closes #21104
2025-05-04 15:58:37 +00:00
Shashwatt 615968c640 fix: add missing 'add' in profile username tip (#21049) (#21097) 2025-05-04 20:13:55 +05:30
Morgan fc89436b6a chore: bump platform libraries (#21101) 2025-05-04 10:13:28 +00:00
Joe Au-Yeung 3c1015cb11 Get correct userId (#21092) 2025-05-03 00:43:05 +00:00
Anik Dhabal Babu 46c513330c test: retry times for api v2 e2e test in CI (#21087)
* test: test retry times for api v2 test

* Update jest.setup-e2e.ts
2025-05-02 19:42:36 +00:00
sean-brydon ef1598faeb fix return (#21088) 2025-05-02 20:06:58 +01:00
Anik Dhabal Babu 42a6903057 fix: wrong info on cancelled email (#21085) 2025-05-02 17:19:19 +00:00
Morgan 0f0c2b8b92 feat: verified resources endpoint [v2] (#21006)
* feat: verified resources endpoint [v2]

* add tests and fixes

* chore: add endpoint throttler decorator and handling

* chore: code review comments

* chore: code review comments

* chore: code review comments

* bump platform libraries

* chore: add tests and fix issues
2025-05-02 13:37:19 +03:00
VijayandTushar Bhatt c400c36306 fix: between operator for attribute matching (#21031)
* update json logic for raqb between operator

* nit

* add tests

* e2e tests

* after merge: updated tests due to latest changes in routingforms v3

---------

Co-authored-by: Tushar Bhatt <95581504+TusharBhatt1@users.noreply.github.com>
2025-05-02 10:29:48 +00:00
sean-brydon 6410bd2eaf fix: rf v3 tidy up mobile issues (#21078)
* remove incomplete booking from mobile

* add incomplete booking to dropdown on mobile

* padding
2025-05-02 10:01:07 +00:00
Alex van Andel 2ddc5317b8 fix: disabled sms hanging, by calling resolve() (#21070) 2025-05-02 02:23:42 +00:00
Joe Au-Yeung 6fffe3377d refactor: Replace attendee email with user id in idempotency key (#21058)
* Use userId instead of attendee email

* Only write if booking is accepted

* Create `getBookingRequest` function

* Return already requested booking

* Type fixes

* Type fix

* Type fix

* Pass empty seatReferenceId

* Handle no userId

* Update test
2025-05-02 00:46:19 +01:00
GitHub Actions eedef35d02 chore: release v5.2.8
Post release / release (push) Canceled after 0s
2025-05-01 22:09:04 +00:00
Keith Williams 08d232a816 revert: "feat: Ability to set a locked timezone for event type (#20425)" (#21066)
This reverts commit 03240709dc.
2025-05-01 21:51:20 +00:00
Udit TakkarandSyed Ali Shahbaz d9b3f8657f feat: add setting to disable phone only sms notification (#21038)
* feat: add setting to disable phone only sms notification

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

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

---------

Co-authored-by: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com>
2025-05-01 18:18:52 +00:00
Eunjae Lee 99dc5837f5 revert: "fix: eventType filter on /insights (#20823)" (#21059)
This reverts commit 45904c07bc.
2025-05-01 15:51:49 +00:00
Tushar Bhatt 5a35540522 feat: show-utm-params (#20733)
* feat:utm

* locale

* yarn.lock

* fix-typecheck

* indentation

* edge-case

* Update apps/web/modules/bookings/views/bookings-single-view.tsx

* Update apps/web/modules/bookings/views/bookings-single-view.tsx
2025-05-01 14:46:42 +00:00
Keith Williams c01fc08f67 chore: Allow Foundation to approve on behalf of Consumer (#21057) 2025-05-01 14:37:35 +00:00
Peer Richelsen 7397722eb3 chore: fixed headline of timelessui (#21051)
* Update page.tsx

* Update apps/ui-playground/app/(home)/page.tsx
2025-05-01 13:23:22 +01:00
d51e03b14b feat: routing v3 design (#20607)
Co-authored-by: Sean Brydon <sean@Seans-Mac-mini.local>
Co-authored-by: Eunjae Lee <hey@eunjae.dev>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2025-05-01 10:35:17 +01:00
Anik Dhabal Babu c4af180a5b fix: attendeePhoneNumber not appearing in calendar event (#21035) 2025-05-01 07:48:16 +00:00
Hariom Balhara ee5f1dc879 fix unit test bug (#21046) 2025-05-01 07:05:12 +00:00
Rajiv Sahal 15b43176ff uncomment props (#20818) 2025-05-01 11:47:21 +05:30
Anik Dhabal Babu 03240709dc feat: Ability to set a locked timezone for event type (#20425)
* feat: ability to set locked timezone

* update

* Update update-event-type.input.ts

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

* Update documentation.json

* revert

* nit
2025-05-01 11:09:57 +05:30
Romit GabaniandHariom Balhara 7ee451ca02 fix: restricting platform user from creating another regular org (#20929)
* restricting platform user from creating another regular org

* changed as suggested, additional check of membership is accepted

---------

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2025-05-01 03:59:32 +00:00
Alex van Andel 55b066c475 fix: cal.cache not working (#21044)
* fix: cal.cache not working

* Thanks mrge
2025-05-01 00:42:57 +00:00
Anik Dhabal Babu c52bbc4be3 fix: meeting link fails when host requests to reschedule (#20999)
* fix: meeting link fails when host requests to reschedule

* Update EventManager.ts
2025-04-30 17:18:48 +00:00
Morgan 31b1f31d82 chore: format calendar events to remove oauthClientIds (#21040) 2025-04-30 16:01:34 +01:00
Lauris Skraucis ab1c1bb015 fix: allow only platform organisation or non-platform organisation (#20972)
* fix: platform customers cant subscribe to organizations plan

* fix: organization customers cant subscribe to platform plan
2025-04-30 10:33:01 +02:00
45219f4736 fix: cleanup html reports when pr is merged/closed (#20089)
Co-authored-by: anik@cal.com <anik@cal.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-04-29 18:48:05 +00:00
3296255f80 feat: /skip trial for teams (#20753)
* skip team trials

* add loading state

* update mocks

* cleanup

* add import oops

* fix tests files

---------

Co-authored-by: Sean Brydon <sean@Seans-Mac-mini.local>
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
2025-04-29 11:48:41 +00:00
Ronit Raj 81b4651d6e fix: UI Bug - ToggleGroup (#20997)
* fix #20986: UI Bug - ToggleGroup

* some changes

* some changes
2025-04-29 03:01:20 +00:00
Hariom Balhara e3bd90c4f2 fix: Handle calendar-cache with Delegation Credentials
Fixes CAL-5372

# Delegation Credentials with CalendarCache.

Following content is a snapshot of the [internal document](https://calendso.slack.com/docs/T08B8KA2BNF/F08L5JYU3V3)



**Problem-1 :** 

CalendarCache needs SelectedCalendar records to work but SelectedCalendar record is only created when a user connects their calendar and then enables some calendar for conflict checking. Because with Delegation, no manual connection is done by any of the members, we need a way to create SelectedCalendar records automatically.

**Problem-2**

CalendarCache connects to credential(regular credential) which doesn’t exist for Delegation Credential scenario. Also, DelegationCredential is common for all the members(different from Credential which is different for different members) of the organization and we need to identify to which user the CalendarCache belongs.  

**Solution for both problems**
- Create credential records for Delegation Credentials as well - Through Cron(new - we could schedule it every 5mins)
- Now create SelectedCalendar  records for those Credential records -  Through another Cron(new - we could schedule it every 5mins)
- Now CalendarCache records will automatically be created for those SelectedCalendar records -existing cron

## Fixed some Delegation Credentials bugs unrelated to calendar-cache
- If DestinationCalendar wasn't set(which is possible only with Delegation Credentials), then Google Meet wasn't used as a conferencing app - [Added a test]
- If no SelectedCalendar is there but Google Calendar connection exists(possible only with Delegation Credential) then we were not doing conflict checking. It is expected to not do it for Regular Credentials, but for Delegation Credential we must check for conflict in that case too [Added a test]
- Earlier if a user has Regular Credential as well as Delegation Credential for the same external id which is the member email(say member1@acme.com) then availability were retrieved twice because we weren't deduplicating credentials as it wasn't a trivial thing to do. Now that is being done.


**Env Variables:**
Note this PR doesn't introduce any new env variable. The existing env variable has been added to .env.example. But if this env variable isn't already set, it must be set.

`CALCOM_SERVICE_ACCOUNT_ENCRYPTION_KEY={SAME_AS_SET_FOR_V2_API}`

**Deployment Plan:**
1. Add Observability for SelectedCalendar when _error_ field is set
2. Follow https://github.com/calcom/cal.com/blob/calendar-cache-dwd-support/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/delegation-credential/delegation-credential.md#setting-up-delegation-credential-for-google-calendar-api to enable Delegation Credential for i.cal.com
3. Note that to be able to see the option to enable Delegation Credential for an organization, you need to enable `teamFeature` and `feature` for `delegation-credential`

## Automation Tests
- Introduced tests for calendar-cache.repository.ts
   - Tests all methods of the repository
- Added more tests for handleNewBooking/delegation-credential flow.
   - Added test to verify the bug fix when no DestinationCalendar exists and Google Meet should be used still 
- Added more tests for Google Calendar/CalendarService targeting DelegationCredential
- Added more tests for getCalendarsEvents. 
    - To test the new logic of calling getAvailability still if there are no selectedCalendars in case of Delegation Credential
    - Also introduced tests for `getAvailabitlityWithTimezones` which was an existing function but now has some new changes.
- Added tests for deduplication logic in CalendarManager.ts

## How to Test
Enable Calendar Cache and Delegation Credential feature for acme org through `features` and `teamFeatures` tables.
- Enable Delegation Credential for acme org
- Enable atleast 1 calendar for conflict checking for one of the users(say owner1)
- Ensure GOOGLE_WEBHOOK_TOKEN is set in .env file
- Ensure GOOGLE_WEBHOOK_URL is set to ngrok url of webapp in .env file
- Hit cron endpoint `curl http://localhost:3000/api/calendar-cache/cron\?apiKey\={API_KEY}` that would cache the freebusy result for the selected calendars



Followup 
- https://github.com/calcom/cal.com/pull/20698
- https://github.com/calcom/cal.com/pull/18619/files#r2046795643
2025-04-28 18:11:29 -03:00
Joe Au-YeungandOmar López 17f1932467 refactor: Use Salesforce GraphQL for account ownership routing (#20655)
Co-authored-by: Omar López <zomars@me.com>
2025-04-28 20:54:58 +00:00
Anik Dhabal Babu 1b04fe82a7 fix: hide orgnizer email in cal video sidebar (#21011) 2025-04-28 16:34:32 +00:00
Carina WollendorferandCarinaWolli ffb36509ff fix: schedule unscheduled emails with tasker in cron job (#20748)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2025-04-28 08:56:13 -07:00
Benny Joo dc361f7879 perf: SSR - /event-types and /event-types/[type] (#20952)
* add revalidation

* ssr for event-types

* wip

* wip

* fix types

* fix skeleton

* better error component

* make skeleton better

* no need to export

* call revalidations early

* remove revalidations if not needed

* address comments

* address
2025-04-28 16:58:53 +02:00
Udit Takkar 0b59a5d184 fix: encoding bug (#21004)
* fix: encoding bug

* fix: use escapeValue
2025-04-28 14:56:12 +00:00
Anik Dhabal Babu 54c17eb827 fix: unable to create meeting with ms teams (#20993)
* fix: few details not populate

* fix: unable to create meeting with ms teams

* Update CrmService.ts

* update
2025-04-28 13:41:02 +00:00
Anik Dhabal Babu bd1142ea7a fix: wrong translation value (#17923)
* feat: ability to remove attendees from booking page

* update

* fix
2025-04-28 13:16:18 +00:00
Hariom Balhara 26051833cf fix: Skip already completed org onboarding, as webhook request can come when invoice is paid again next month (#20984)
* there is nothing to do for repeat invoice payment

* there is nothing to do for repeat invoice payment
2025-04-28 18:36:14 +05:30
Somay Chauhan 1d8bc6ca49 feat: added hideOrganizerEmail to api/v2/event-types (#21005)
* feat: added `hideOrganizerEmail` to api/v2/event-types

* updated docs
2025-04-28 15:39:51 +03:00
Rajiv SahalandMorgan fbe8cf8223 fix: cancel platform subscription on dashboard (#20787)
* add new endpoint to cancel user subscription

* remove comments

* add delete button to frontend

* update correct request method

* fixup

* frontend for cancelling subscription

* better warning message

* better function name

* update locales

* better error handling in case stripe api call fails

* rename teamId to organizationId

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2025-04-28 08:41:25 +01:00
Anik Dhabal Babu ef8b3e83ac fix: few details not populate (#20987) 2025-04-28 04:47:56 +00:00
devin-ai-integration[bot]andhariom@cal.com 9d228a1d96 Remove 30 seats minimum text from organization setup page (#20983)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: hariom@cal.com <hariom@cal.com>
2025-04-28 08:30:03 +05:30
Benny Joo 57df25505d perf: workflows—remove data fetching for metadata (#20734) 2025-04-26 12:21:11 +00:00
Hariom Balhara 31cbea0e23 Do not retrieve already persisted data as well (#20982) 2025-04-26 17:32:08 +05:30
Syed Ali Shahbaz edd80c056c Update sms-manager.ts (#20985) 2025-04-26 11:56:30 +00:00
Hariom Balhara 6ebf0db389 Handle acme.cal.local root path (#20981) 2025-04-26 08:35:23 -03:00
750 changed files with 40259 additions and 7123 deletions
+13 -7
View File
@@ -159,13 +159,17 @@ SENDGRID_EMAIL=
NEXT_PUBLIC_SENDGRID_SENDER_NAME=
# Sentry
SENTRY_ORG=
SENTRY_PROJECT=
SENTRY_AUTH_TOKEN=
# Used for capturing exceptions and logging messages
NEXT_PUBLIC_SENTRY_DSN=
SENTRY_DEBUG=
SENTRY_DISABLE_CLIENT_SOURCE_MAPS=
SENTRY_DISABLE_SERVER_SOURCE_MAPS=
SENTRY_MAX_SPANS=
SENTRY_SAMPLE_RATE=
SENTRY_TRACES_SAMPLE_RATE=
SENTRY_REPLAYS_SESSION_SAMPLE_RATE=
SENTRY_REPLAYS_ON_ERROR_SAMPLE_RATE=
# Formbricks Experience Management Integration
NEXT_PUBLIC_FORMBRICKS_HOST_URL=https://app.formbricks.com
@@ -198,6 +202,7 @@ NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE=
NEXT_PUBLIC_IS_PREMIUM_NEW_PLAN=0
NEXT_PUBLIC_STRIPE_PREMIUM_NEW_PLAN_PRICE=
STRIPE_TEAM_MONTHLY_PRICE_ID=
NEXT_PUBLIC_STRIPE_CREDITS_PRICE_ID=
STRIPE_TEAM_PRODUCT_ID=
# It is a price ID in the product with id STRIPE_ORG_PRODUCT_ID
STRIPE_ORG_MONTHLY_PRICE_ID=
@@ -363,9 +368,6 @@ E2E_TEST_OIDC_USER_PASSWORD=
# ***********************************************************************************************************
# disable setry server source maps
SENTRY_DISABLE_SERVER_WEBPACK_PLUGIN=1
# api v2
NEXT_PUBLIC_API_V2_URL="http://localhost:5555/api/v2"
@@ -425,7 +427,7 @@ DIRECTORY_IDS_TO_LOG=
# Read more about it in the README.md
NEXT_PUBLIC_SINGLE_ORG_SLUG=
AKISMET_API_KEY=
IFFY_API_KEY=
## Env variables related to avoiding booking failures
# Request for checking reservation would be attempted to send every these seconds if the request is stale at that time
@@ -443,4 +445,8 @@ NEXT_PUBLIC_QUICK_AVAILABILITY_ROLLOUT=10
NEXT_PUBLIC_HEAD_SCRIPTS=
NEXT_PUBLIC_BODY_SCRIPTS=
DATABASE_CHUNK_SIZE=
DATABASE_CHUNK_SIZE=
# Service Account Encryption Key for encrypting/decrypting service account keys
# You can use: `openssl rand -base64 24` to generate one
CALCOM_SERVICE_ACCOUNT_ENCRYPTION_KEY=
+5 -5
View File
@@ -1,9 +1,7 @@
/**/package.json @calcom/Foundation
/apps/api/**/* @calcom/Platform @calcom/Foundation
/apps/ui-playground/**/* @calcom/Consumer
/apps/web/components/**/* @calcom/Consumer
/apps/web/modules/**/* @calcom/Consumer
/apps/web/**/layout.tsx @calcom/Foundation
/apps/ui-playground/**/* @calcom/Consumer @calcom/Foundation
/apps/web/**/layout.tsx @calcom/Consumer @calcom/Foundation
/apps/web/lib/**/* @calcom/Foundation
/apps/web/middleware.ts @calcom/Foundation
/deploy/**/* @calcom/Foundation
@@ -26,5 +24,7 @@
/packages/lib/slots.ts @calcom/Foundation
/packages/platform/**/* @calcom/Platform @calcom/Foundation
/packages/prisma/**/* @calcom/Foundation
/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts @calcom/Foundation
/packages/trpc/server/routers/viewer/bookings/get.handler.ts @calcom/Foundation
/packages/trpc/server/routers/viewer/slots/**/* @calcom/Foundation
/packages/ui/**/* @calcom/Consumer
/packages/ui/**/* @calcom/Consumer @calcom/Foundation
+37
View File
@@ -0,0 +1,37 @@
on:
workflow_call:
pull_request:
types: [closed]
permissions:
contents: write
issues: write
pull-requests: write
jobs:
cleanup-report:
runs-on: ubuntu-latest
steps:
- name: Checkout GitHub Pages Branch
uses: actions/checkout@v4
with:
repository: calcom/test-results
ref: gh-pages
token: ${{ secrets.GH_ACCESS_TOKEN }}
- name: Set Git User
run: |
git config --global user.name "github-actions[bot]"
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
- name: Cleanup HTML reports on PR close/merge
env:
BRANCH_REPORTS_DIR: reports/${{ github.event.pull_request.head.ref }}
run: |
if [ -d "$BRANCH_REPORTS_DIR" ]; then
rm -rf "$BRANCH_REPORTS_DIR"
git add .
git commit -m "workflow: remove all reports for branch ${{ github.event.pull_request.head.ref }} (PR closed/merged)"
git push
else
echo "No reports found for branch ${{ github.event.pull_request.head.ref }}. Nothing to clean up."
fi
+23
View File
@@ -0,0 +1,23 @@
name: Cron - checkSmsPrices
on:
# "Scheduled workflows run on the latest commit on the default or base branch."
# — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule
schedule:
# Runs “every minute” (see https://crontab.guru)
- cron: "* * * * *"
jobs:
cron-checkSmsPrices:
env:
APP_URL: ${{ secrets.APP_URL }}
CRON_API_KEY: ${{ secrets.CRON_API_KEY }}
runs-on: ubuntu-latest
steps:
- name: cURL request
if: ${{ env.APP_URL && env.CRON_API_KEY }}
run: |
curl ${{ secrets.APP_URL }}/api/cron/checkSmsPrices \
-X POST \
-H 'content-type: application/json' \
-H 'authorization: ${{ secrets.CRON_API_KEY }}' \
--fail
+11
View File
@@ -219,6 +219,17 @@ jobs:
uses: ./.github/workflows/publish-report.yml
secrets: inherit
cleanup-report:
name: Cleanup HTML report
if: ${{ !cancelled() && needs.check-label.outputs.run-e2e == 'true' && (github.event.pull_request.merged == true || github.event.pull_request.state == 'closed') }}
permissions:
contents: write
issues: write
pull-requests: write
needs: [changes, check-label, publish-report]
uses: ./.github/workflows/cleanup-report.yml
secrets: inherit
required:
needs:
[
+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
View File
@@ -0,0 +1,2 @@
undecided:
- calcom-monorepo
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@calcom/prisma"
View File
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@calcom/prisma"
+8
View File
@@ -0,0 +1,8 @@
undecided:
- calcom-monorepo
- "@calcom/app-store-cli"
- "@calcom/platform-constants"
- "@calcom/platform-enums"
- "@calcom/platform-types"
- "@calcom/platform-utils"
- "@calcom/prisma"
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@calcom/prisma"
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@calcom/prisma"
+8
View File
@@ -0,0 +1,8 @@
undecided:
- calcom-monorepo
- "@calcom/app-store-cli"
- "@calcom/platform-constants"
- "@calcom/platform-enums"
- "@calcom/platform-types"
- "@calcom/platform-utils"
- "@calcom/prisma"
+15
View File
@@ -0,0 +1,15 @@
Possible issue:
- We won't be able to use already built calendar-cache when Delegation Credential is enabled because existing CalendarCache entries don't have userId set.
## Approach of always using SelectedCalendar.credentialId for CalendarCache even for DelegationCredentials
- Delete Credentials when DelegationCredential is disabled
TO Test
- New members beyond the batch size are processed
@@ -0,0 +1,37 @@
/**
* Constructs the WHERE clause for Prisma booking findMany operation.
*
* @param userId - The ID of the user making the request. This is used to filter bookings where the user is either the host or an attendee.
* @param attendeeEmails - An array of emails provided in the request for filtering bookings by attendee emails, used in case of Admin calls.
* @param userIds - An array of user IDs to be included in the filter. Defaults to an empty array, and an array of user IDs in case of Admin call containing it.
* @param userEmails - An array of user emails to be included in the filter if it is an Admin call and contains userId in query parameter. Defaults to an empty array.
*
* @returns An object that represents the WHERE clause for the findMany/findUnique operation.
*/
export function buildWhereClause(userId: number | null, attendeeEmails: string[], userIds: number[] = []) {
const filterByAttendeeEmails = attendeeEmails.length > 0;
const userFilter = userIds.length > 0 ? { userId: { in: userIds } } : !!userId ? { userId } : {};
let whereClause = {};
if (filterByAttendeeEmails) {
whereClause = {
AND: [
userFilter,
{
attendees: {
some: {
email: { in: attendeeEmails },
},
},
},
],
};
} else {
whereClause = {
...userFilter,
};
}
return whereClause;
}
+1 -1
View File
@@ -30,7 +30,7 @@
"@calcom/lib": "*",
"@calcom/prisma": "*",
"@calcom/trpc": "*",
"@sentry/nextjs": "^8.8.0",
"@sentry/nextjs": "^9.15.0",
"bcryptjs": "^2.4.3",
"memory-cache": "^0.2.0",
"next": "^13.5.9",
+79 -57
View File
@@ -1,4 +1,4 @@
import type { Prisma } from "@prisma/client";
import type { Prisma, Booking } from "@prisma/client";
import type { NextApiRequest } from "next";
import { HttpError } from "@calcom/lib/http-error";
@@ -6,6 +6,7 @@ import { defaultResponder } from "@calcom/lib/server/defaultResponder";
import prisma from "@calcom/prisma";
import { withMiddleware } from "~/lib/helpers/withMiddleware";
import { buildWhereClause } from "~/lib/utils/bookings/get/buildWhereClause";
import {
getAccessibleUsers,
retrieveOrgScopedAccessibleUsers,
@@ -151,58 +152,6 @@ type GetAdminArgsType = {
userId: number;
};
/**
* Constructs the WHERE clause for Prisma booking findMany operation.
*
* @param userId - The ID of the user making the request. This is used to filter bookings where the user is either the host or an attendee.
* @param attendeeEmails - An array of emails provided in the request for filtering bookings by attendee emails, used in case of Admin calls.
* @param userIds - An array of user IDs to be included in the filter. Defaults to an empty array, and an array of user IDs in case of Admin call containing it.
* @param userEmails - An array of user emails to be included in the filter if it is an Admin call and contains userId in query parameter. Defaults to an empty array.
*
* @returns An object that represents the WHERE clause for the findMany/findUnique operation.
*/
function buildWhereClause(
userId: number | null,
attendeeEmails: string[],
userIds: number[] = [],
userEmails: string[] = []
) {
const filterByAttendeeEmails = attendeeEmails.length > 0;
const userFilter = userIds.length > 0 ? { userId: { in: userIds } } : !!userId ? { userId } : {};
let whereClause = {};
if (filterByAttendeeEmails) {
whereClause = {
AND: [
userFilter,
{
attendees: {
some: {
email: { in: attendeeEmails },
},
},
},
],
};
} else {
whereClause = {
OR: [
userFilter,
{
attendees: {
some: {
email: { in: userEmails },
},
},
},
],
};
}
return whereClause;
}
export async function handler(req: NextApiRequest) {
const {
userId,
@@ -238,6 +187,7 @@ export async function handler(req: NextApiRequest) {
? [queryFilterForAttendeeEmails.attendeeEmail]
: [];
const filterByAttendeeEmails = attendeeEmails.length > 0;
let userEmailsToFilterBy: string[] = [];
/** Only admins can query other users */
if (isSystemWideAdmin) {
@@ -251,7 +201,8 @@ export async function handler(req: NextApiRequest) {
userId,
};
const { userId: argUserId, userIds, userEmails } = await handleSystemWideAdminArgs(systemWideAdminArgs);
args.where = buildWhereClause(argUserId, attendeeEmails, userIds, userEmails);
userEmailsToFilterBy = userEmails;
args.where = buildWhereClause(argUserId, attendeeEmails, userIds);
}
} else if (isOrganizationOwnerOrAdmin) {
let requestedUserIds = [userId];
@@ -265,7 +216,8 @@ export async function handler(req: NextApiRequest) {
userId,
};
const { userId: argUserId, userIds, userEmails } = await handleOrgWideAdminArgs(orgWideAdminArgs);
args.where = buildWhereClause(argUserId, attendeeEmails, userIds, userEmails);
userEmailsToFilterBy = userEmails;
args.where = buildWhereClause(argUserId, attendeeEmails, userIds);
} else {
const user = await prisma.user.findUnique({
where: { id: userId },
@@ -276,7 +228,7 @@ export async function handler(req: NextApiRequest) {
if (!user) {
throw new HttpError({ message: "User not found", statusCode: 404 });
}
args.where = buildWhereClause(userId, attendeeEmails, [], []);
args.where = buildWhereClause(userId, attendeeEmails, []);
}
if (dateFrom) {
@@ -317,7 +269,77 @@ export async function handler(req: NextApiRequest) {
}
}
const data = await prisma.booking.findMany(args);
let data: Booking[] = [];
if (!filterByAttendeeEmails && userEmailsToFilterBy.length > 0) {
const queryOne = prisma.booking.findMany(args);
const whereClauseForQueryTwo: Prisma.BookingWhereInput = {
attendees: {
some: {
email: { in: userEmailsToFilterBy },
},
},
};
if (dateFrom) {
whereClauseForQueryTwo.startTime = { gte: dateFrom };
}
if (dateTo) {
whereClauseForQueryTwo.endTime = { lte: dateTo };
}
if (status === "upcoming") {
whereClauseForQueryTwo.startTime = { gte: new Date().toISOString() };
}
const argsForQueryTwo: Prisma.BookingFindManyArgs = {
...args,
where: whereClauseForQueryTwo,
};
const queryTwo = prisma.booking.findMany(argsForQueryTwo);
const [resultOne, resultTwo] = await Promise.all([queryOne, queryTwo]);
const bookingMap = new Map();
[...resultOne, ...resultTwo].forEach((booking) => {
bookingMap.set(booking.id, booking);
});
const dedupedResults = Array.from(bookingMap.values());
if (args.orderBy) {
let sortField: keyof Booking;
let sortDirection: "asc" | "desc" = "asc";
if (typeof args.orderBy === "object" && !Array.isArray(args.orderBy)) {
const orderByKey = Object.keys(args.orderBy)[0] as keyof typeof args.orderBy;
sortField = orderByKey as keyof Booking;
sortDirection = args.orderBy[orderByKey] as "asc" | "desc";
} else {
sortField = "id";
}
const sortOrder = sortDirection === "desc" ? -1 : 1;
dedupedResults.sort((a, b) => {
const aValue = a[sortField];
const bValue = b[sortField];
if (aValue < bValue) return -1 * sortOrder;
if (aValue > bValue) return 1 * sortOrder;
return 0;
});
}
if (args.take !== undefined && args.skip !== undefined) {
data = dedupedResults.slice(args.skip, args.skip + args.take);
} else {
data = dedupedResults;
}
} else {
data = await prisma.booking.findMany(args);
}
return { bookings: data.map((booking) => schemaBookingReadPublic.parse(booking)) };
}
@@ -185,7 +185,12 @@ async function verifyCredentialsAndGetId({
currentCredentialId: number | null;
}) {
if (parsedBody.integration && parsedBody.externalId) {
const calendarCredentials = getCalendarCredentialsWithoutDelegation(userCredentials);
const calendarCredentials = getCalendarCredentialsWithoutDelegation(
userCredentials.map((cred) => ({
...cred,
delegationCredentialId: null,
}))
);
const { connectedCalendars } = await getConnectedCalendars(
calendarCredentials,
+6 -1
View File
@@ -24,7 +24,12 @@ import { membershipCreateBodySchema, schemaMembershipPublic } from "~/lib/valida
*/
async function postHandler(req: NextApiRequest) {
const data = membershipCreateBodySchema.parse(req.body);
const args: Prisma.MembershipCreateArgs = { data };
const args: Prisma.MembershipCreateArgs = {
data: {
createdAt: new Date(),
...data,
},
};
await checkPermissions(req);
-1
View File
@@ -1 +0,0 @@
export {};
@@ -2,6 +2,7 @@ import type { Request, Response } from "express";
import type { NextApiRequest, NextApiResponse } from "next";
import { createMocks } from "node-mocks-http";
import { describe, expect, it } from "vitest";
import { ZodError } from "zod";
import prisma from "@calcom/prisma";
@@ -206,8 +207,155 @@ describe("GET /api/bookings", async () => {
console.log("bookings=>", responseData.bookings);
responseData.bookings.forEach((booking) => {
if (booking.id === 31) expect(booking.eventType?.team?.slug).toBe("team1");
if (booking.id === 19) expect(booking.eventType?.team).toBe(null);
if (booking.id === 19) {
// The team field can be either null or undefined due to nullish() in the schema
expect(booking.eventType?.team === null || booking.eventType?.team === undefined).toBe(true);
}
});
});
});
describe("Date filtering", () => {
it("filters bookings by dateFrom", async () => {
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
query: {
dateFrom: "2023-01-01T00:00:00Z",
},
pagination: DefaultPagination,
});
req.userId = proUser.id;
const responseData = await handler(req);
responseData.bookings.forEach((booking) => {
expect(new Date(booking.startTime).getTime()).toBeGreaterThanOrEqual(
new Date("2023-01-01T00:00:00Z").getTime()
);
});
});
it("filters bookings by dateTo", async () => {
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
query: {
dateTo: "2024-12-31T23:59:59Z",
},
pagination: DefaultPagination,
});
req.userId = proUser.id;
const responseData = await handler(req);
responseData.bookings.forEach((booking) => {
expect(new Date(booking.endTime).getTime()).toBeLessThanOrEqual(
new Date("2024-12-31T23:59:59Z").getTime()
);
});
});
});
describe("Sorting and ordering", () => {
it("sorts bookings by createdAt in descending order", async () => {
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
query: {
sortBy: "createdAt",
order: "desc",
},
pagination: DefaultPagination,
});
req.userId = proUser.id;
const responseData = await handler(req);
const timestamps = responseData.bookings.map((b) => new Date(b.createdAt).getTime());
expect(timestamps).toEqual([...timestamps].sort((a, b) => b - a));
});
});
describe("Multiple attendee email filtering", () => {
it("filters bookings by multiple attendee emails", async () => {
const attendeeEmails = ["test1@example.com", "test2@example.com"];
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
query: {
attendeeEmail: attendeeEmails,
},
pagination: DefaultPagination,
});
req.userId = proUser.id;
const responseData = await handler(req);
responseData.bookings.forEach((booking) => {
const bookingAttendeeEmails = booking.attendees?.map((a) => a.email);
expect(bookingAttendeeEmails?.some((email) => attendeeEmails.includes(email))).toBe(true);
});
});
});
describe("Error cases", () => {
it("throws error for invalid status parameter", async () => {
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
query: {
status: "invalid_status",
},
pagination: DefaultPagination,
});
req.userId = proUser.id;
await expect(handler(req)).rejects.toThrow(ZodError);
});
});
describe("Result merging", () => {
it("does not return duplicate bookings when merging results from multiple queries", async () => {
const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "owner1-acme@example.com" } });
const testUser = await prisma.user.findFirstOrThrow({ where: { email: "pro@example.com" } });
const testUserBooking = await prisma.booking.findFirstOrThrow({
where: { userId: testUser.id },
include: { attendees: true },
});
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
query: {
userId: testUser.id, // This will make userEmailsToFilterBy contain the test user's email
},
pagination: {
take: 100,
skip: 0,
},
});
req.isSystemWideAdmin = true;
req.userId = adminUser.id;
const responseData = await handler(req);
const bookingIds = responseData.bookings.map((booking) => booking.id);
const uniqueBookingIds = new Set(bookingIds);
expect(uniqueBookingIds.size).toBe(bookingIds.length);
if (uniqueBookingIds.size !== bookingIds.length) {
const counts = bookingIds.reduce((acc, id) => {
acc[id] = (acc[id] || 0) + 1;
return acc;
}, {} as Record<number, number>);
const duplicates = Object.entries(counts)
.filter(([_, count]) => count > 1)
.map(([id]) => id);
console.log(`Found duplicate booking IDs: ${duplicates.join(", ")}`);
}
});
});
});
@@ -0,0 +1,75 @@
import { describe, it, expect } from "vitest";
import { buildWhereClause } from "~/lib/utils/bookings/get/buildWhereClause";
describe("buildWhereClause", () => {
it("should return empty object when no filters are provided", () => {
const result = buildWhereClause(null, [], []);
expect(result).toEqual({});
});
it("should filter by userId when only userId is provided", () => {
const userId = 123;
const result = buildWhereClause(userId, [], []);
expect(result).toEqual({ userId: 123 });
});
it("should filter by userIds when userIds array is provided", () => {
const userIds = [1, 2, 3];
const result = buildWhereClause(null, [], userIds);
expect(result).toEqual({ userId: { in: [1, 2, 3] } });
});
it("should filter by attendee emails when provided", () => {
const attendeeEmails = ["test@example.com", "user@example.com"];
const result = buildWhereClause(null, attendeeEmails, []);
expect(result).toEqual({
AND: [
{},
{
attendees: {
some: {
email: { in: ["test@example.com", "user@example.com"] },
},
},
},
],
});
});
it("should combine userId and attendee email filters", () => {
const userId = 123;
const attendeeEmails = ["test@example.com"];
const result = buildWhereClause(userId, attendeeEmails, []);
expect(result).toEqual({
AND: [
{ userId: 123 },
{
attendees: {
some: {
email: { in: ["test@example.com"] },
},
},
},
],
});
});
it("should combine userIds and attendee email filters", () => {
const userIds = [1, 2, 3];
const attendeeEmails = ["test@example.com"];
const result = buildWhereClause(null, attendeeEmails, userIds);
expect(result).toEqual({
AND: [
{ userId: { in: [1, 2, 3] } },
{
attendees: {
some: {
email: { in: ["test@example.com"] },
},
},
},
],
});
});
});
+1
View File
@@ -11,6 +11,7 @@
"^.+\\.(t|j)s$": "ts-jest"
},
"setupFiles": ["<rootDir>/test/setEnvVars.ts", "jest-date-mock"],
"setupFilesAfterEnv": ["<rootDir>/test/jest.setup-e2e.ts"],
"reporters": ["default", "jest-summarizing-reporter"],
"workerIdleMemoryLimit": "512MB",
"maxWorkers": 8
+1 -1
View File
@@ -38,7 +38,7 @@
"@axiomhq/winston": "^1.2.0",
"@calcom/platform-constants": "*",
"@calcom/platform-enums": "*",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.176",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.197",
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
@@ -9,6 +9,7 @@ import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository";
import { AppsRepository } from "@/modules/apps/apps.repository";
import { BillingModule } from "@/modules/billing/billing.module";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { KyselyModule } from "@/modules/kysely/kysely.module";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
@@ -23,6 +24,7 @@ import { Module } from "@nestjs/common";
@Module({
imports: [
PrismaModule,
KyselyModule,
RedisModule,
TokensModule,
BillingModule,
@@ -13,6 +13,7 @@ import { Permissions } from "@/modules/auth/decorators/permissions/permissions.d
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { BillingService } from "@/modules/billing/services/billing.service";
import { KyselyReadService } from "@/modules/kysely/kysely-read.service";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
@@ -99,6 +100,7 @@ export class BookingsController_2024_04_15 {
constructor(
private readonly oAuthFlowService: OAuthFlowService,
private readonly prismaReadService: PrismaReadService,
private readonly kyselyReadService: KyselyReadService,
private readonly oAuthClientRepository: OAuthClientRepository,
private readonly billingService: BillingService,
private readonly config: ConfigService,
@@ -129,6 +131,7 @@ export class BookingsController_2024_04_15 {
ctx: {
user: { email: user.email, id: user.id, orgId: profile?.organizationId },
prisma: this.prismaReadService.prisma as unknown as PrismaClient,
kysely: this.kyselyReadService.kysely,
},
});
@@ -0,0 +1,27 @@
import { BookingReferencesFilterInput_2024_08_13 } from "@/ee/bookings/2024-08-13/inputs/booking-references-filter.input";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { Injectable } from "@nestjs/common";
import type { Prisma } from "@prisma/client";
@Injectable()
export class BookingReferencesRepository_2024_08_13 {
constructor(private readonly dbRead: PrismaReadService) {}
async getBookingReferences(bookingId: number, filter?: BookingReferencesFilterInput_2024_08_13) {
const whereClause: Prisma.BookingReferenceWhereInput = { bookingId };
if (filter?.type) {
whereClause.type = filter.type;
}
return this.dbRead.prisma.bookingReference.findMany({
where: whereClause,
select: {
type: true,
uid: true,
id: true,
externalCalendarId: true,
},
});
}
}
@@ -1,8 +1,11 @@
import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/booking-references.repository";
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
import { BookingsController_2024_08_13 } from "@/ee/bookings/2024-08-13/controllers/bookings.controller";
import { BookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-references.service";
import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service";
import { ErrorsBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/errors.service";
import { InputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/input.service";
import { OutputBookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output-booking-references.service";
import { OutputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output.service";
import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.service";
import { CalendarsRepository } from "@/ee/calendars/calendars.repository";
@@ -17,6 +20,7 @@ import { BillingModule } from "@/modules/billing/billing.module";
import { BookingSeatModule } from "@/modules/booking-seat/booking-seat.module";
import { BookingSeatRepository } from "@/modules/booking-seat/booking-seat.repository";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { KyselyModule } from "@/modules/kysely/kysely.module";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
@@ -36,6 +40,7 @@ import { Module } from "@nestjs/common";
@Module({
imports: [
PrismaModule,
KyselyModule,
RedisModule,
TokensModule,
BillingModule,
@@ -56,6 +61,7 @@ import { Module } from "@nestjs/common";
BookingsService_2024_08_13,
InputBookingsService_2024_08_13,
OutputBookingsService_2024_08_13,
OutputBookingReferencesService_2024_08_13,
BookingsRepository_2024_08_13,
EventTypesRepository_2024_06_14,
BookingSeatRepository,
@@ -69,6 +75,8 @@ import { Module } from "@nestjs/common";
OrganizationsTeamsRepository,
OrganizationsRepository,
ErrorsBookingsService_2024_08_13,
BookingReferencesService_2024_08_13,
BookingReferencesRepository_2024_08_13,
],
controllers: [BookingsController_2024_08_13],
exports: [InputBookingsService_2024_08_13, OutputBookingsService_2024_08_13, BookingsService_2024_08_13],
@@ -1,15 +1,20 @@
import { BookingUidGuard } from "@/ee/bookings/2024-08-13/guards/booking-uid.guard";
import { BookingReferencesFilterInput_2024_08_13 } from "@/ee/bookings/2024-08-13/inputs/booking-references-filter.input";
import { BookingReferencesOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/booking-references.output";
import { CalendarLinksOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/calendar-links.output";
import { CancelBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/cancel-booking.output";
import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output";
import { MarkAbsentBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/mark-absent.output";
import { ReassignBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reassign-booking.output";
import { RescheduleBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reschedule-booking.output";
import { BookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-references.service";
import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service";
import { VERSION_2024_08_13_VALUE, VERSION_2024_08_13 } from "@/lib/api-versions";
import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { UsersService } from "@/modules/users/services/users.service";
@@ -82,7 +87,8 @@ export class BookingsController_2024_08_13 {
constructor(
private readonly bookingsService: BookingsService_2024_08_13,
private readonly usersService: UsersService
private readonly usersService: UsersService,
private readonly bookingReferencesService: BookingReferencesService_2024_08_13
) {}
@Post("/")
@@ -176,7 +182,7 @@ export class BookingsController_2024_08_13 {
): Promise<GetBookingsOutput_2024_08_13> {
const profile = this.usersService.getUserMainProfile(user);
const bookings = await this.bookingsService.getBookings(queryParams, {
const { bookings, pagination } = await this.bookingsService.getBookings(queryParams, {
email: user.email,
id: user.id,
orgId: profile?.organizationId,
@@ -185,6 +191,7 @@ export class BookingsController_2024_08_13 {
return {
status: SUCCESS_STATUS,
data: bookings,
pagination,
};
}
@@ -386,4 +393,30 @@ export class BookingsController_2024_08_13 {
data: calendarLinks,
};
}
@Get("/:bookingUid/references")
@PlatformPlan("SCALE")
@UseGuards(ApiAuthGuard, BookingUidGuard)
@Permissions([BOOKING_READ])
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@ApiOperation({
summary: "Get 'Booking References' for a booking",
})
@HttpCode(HttpStatus.OK)
async getBookingReferences(
@Param("bookingUid") bookingUid: string,
@GetUser("id") userId: number,
@Query() filter: BookingReferencesFilterInput_2024_08_13
): Promise<BookingReferencesOutput_2024_08_13> {
const bookingReferences = await this.bookingReferencesService.getBookingReferences(
bookingUid,
userId,
filter
);
return {
status: SUCCESS_STATUS,
data: bookingReferences,
};
}
}
@@ -385,7 +385,7 @@ describe("Bookings Endpoints 2024-08-13 confirm emails", () => {
emailsEnabledSetup.createdBookingUid = responseBody.data.uid;
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibily recurring bookings"
"Invalid response data - expected booking but received array of possibly recurring bookings"
);
}
});
@@ -430,7 +430,7 @@ describe("Bookings Endpoints 2024-08-13 confirm emails", () => {
emailsEnabledSetup.rescheduledBookingUid = responseBody.data.uid;
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibily recurring bookings"
"Invalid response data - expected booking but received array of possibly recurring bookings"
);
}
});
@@ -459,7 +459,7 @@ describe("Bookings Endpoints 2024-08-13 confirm emails", () => {
emailsEnabledSetup.rescheduledBookingUid = responseBody.data.uid;
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibily recurring bookings"
"Invalid response data - expected booking but received array of possibly recurring bookings"
);
}
});
@@ -502,7 +502,7 @@ describe("Bookings Endpoints 2024-08-13 confirm emails", () => {
emailsEnabledSetup.createdBookingUid = responseBody.data.uid;
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibily recurring bookings"
"Invalid response data - expected booking but received array of possibly recurring bookings"
);
}
});
@@ -14,7 +14,7 @@ import { UsersModule } from "@/modules/users/users.module";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import { EventType, User } from "@prisma/client";
import { EventType, User, Workflow } from "@prisma/client";
import { advanceTo, clear } from "jest-date-mock";
import * as request from "supertest";
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
@@ -22,6 +22,8 @@ import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-type
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { WorkflowReminderRepositoryFixture } from "test/fixtures/repository/workflow-reminder.repository.fixture";
import { WorkflowRepositoryFixture } from "test/fixtures/repository/workflow.repository.fixture";
import { randomString } from "test/utils/randomString";
import { withApiAuth } from "test/utils/withApiAuth";
@@ -32,6 +34,7 @@ import {
VERSION_2024_08_13,
X_CAL_CLIENT_ID,
} from "@calcom/platform-constants";
import { AttendeeScheduledEmail, OrganizerScheduledEmail } from "@calcom/platform-libraries/emails";
import {
CreateEventTypeInput_2024_06_14,
FAILED_EVENT_TYPE_IDENTIFICATION_ERROR_MESSAGE,
@@ -60,6 +63,8 @@ describe("Bookings Endpoints 2024-08-13", () => {
let schedulesService: SchedulesService_2024_04_15;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
let workflowReminderRepositoryFixture: WorkflowReminderRepositoryFixture;
let workflowRepositoryFixture: WorkflowRepositoryFixture;
let oAuthClient: PlatformOAuthClient;
let teamRepositoryFixture: TeamRepositoryFixture;
@@ -68,6 +73,8 @@ describe("Bookings Endpoints 2024-08-13", () => {
let eventTypeId: number;
let eventType: EventType;
let eventTypeWithAttendeeSmsReminder: EventType;
let workflow: Workflow;
const eventTypeSlug = `user-bookings-event-type-${randomString()}`;
let recurringEventTypeId: number;
const recurringEventTypeSlug = `user-bookings-event-type-${randomString()}`;
@@ -97,6 +104,8 @@ describe("Bookings Endpoints 2024-08-13", () => {
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
workflowReminderRepositoryFixture = new WorkflowReminderRepositoryFixture(moduleRef);
workflowRepositoryFixture = new WorkflowRepositoryFixture(moduleRef);
organization = await teamRepositoryFixture.create({
name: `user-bookings-organization-${randomString()}`,
@@ -177,6 +186,85 @@ describe("Bookings Endpoints 2024-08-13", () => {
rating: 10,
});
workflow = await workflowRepositoryFixture.create({
user: {
connect: {
id: user.id,
},
},
trigger: "BEFORE_EVENT",
time: 24,
timeUnit: "HOUR",
position: 0,
isActiveOnAll: false,
name: "Attendee SMS Reminder",
steps: {
create: {
stepNumber: 1,
action: "SMS_ATTENDEE",
sendTo: null,
reminderBody:
"Hi {ATTENDEE}, this is a reminder that your meeting ({EVENT_NAME}) with {ORGANIZER} is on {EVENT_DATE_YYYY MMM D} at {EVENT_TIME_h:mma} {TIMEZONE}.",
emailSubject: "Reminder: {EVENT_NAME} - {EVENT_DATE_ddd, MMM D, YYYY h:mma}",
template: "REMINDER",
numberRequired: true,
sender: "Cal",
numberVerificationPending: false,
includeCalendarEvent: false,
verifiedAt: new Date(),
},
},
});
eventTypeWithAttendeeSmsReminder = await eventTypesRepositoryFixture.create(
{
title: "event with attendee sms reminder",
slug: "event-with-attendee-sms-reminder",
length: 15,
bookingFields: [
{
name: "smsReminderNumber",
type: "phone",
sources: [
{
id: String(workflow.id),
type: "workflow",
label: "Workflow",
editUrl: `/workflows/${workflow.id}`,
fieldRequired: true,
},
],
editable: "system",
required: true,
defaultLabel: "number_text_notifications",
defaultPlaceholder: "enter_phone_number",
},
],
metadata: {
disableStandardEmails: {
all: {
attendee: true,
host: true,
},
confirmation: {
host: true,
attendee: true,
},
},
},
workflows: {
create: {
workflow: {
connect: {
id: workflow.id,
},
},
},
},
},
user.id
);
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
@@ -583,7 +671,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
expect(data.rating).toEqual(bookingInThePast.rating);
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibily recurring bookings"
"Invalid response data - expected booking but received array of possibly recurring bookings"
);
}
});
@@ -642,7 +730,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
await bookingsRepositoryFixture.deleteById(mockBooking.id);
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibily recurring bookings"
"Invalid response data - expected booking but received array of possibly recurring bookings"
);
}
});
@@ -1668,7 +1756,6 @@ describe("Bookings Endpoints 2024-08-13", () => {
describe("book by username and event type slug", () => {
it("should not create a booking by if neither event type id nor username and event type slug provided", async () => {
const googleMeetUrl = "https://meet.google.com/abc-def-ghi";
const body = {
start: new Date(Date.UTC(2036, 0, 8, 15, 0, 0)).toISOString(),
attendee: {
@@ -1677,7 +1764,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
timeZone: "Europe/Rome",
language: "it",
},
location: googleMeetUrl,
location: "https://meet.google.com/abc-def-ghi",
bookingFieldsResponses: {
customField: "customValue",
},
@@ -2227,6 +2314,91 @@ describe("Bookings Endpoints 2024-08-13", () => {
});
});
describe("create booking with attendee sms reminder", () => {
it("should not be able create a booking if attendee sms reminder is missing", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 15, 0, 0)).toISOString(),
eventTypeId: eventTypeWithAttendeeSmsReminder.id,
attendee: {
name: "Mr Proper",
email: "mr_proper@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
};
const response = await request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(400);
expect(response.body.error.message).toEqual(
`Missing attendee phone number - it is required by the event type. Pass it as \"attendee.phoneNumber\" string in the request.`
);
});
it("should be able create a booking if attendee sms reminder is provided", async () => {
const phoneNumber = "+37122222222";
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 15, 0, 0)).toISOString(),
eventTypeId: eventTypeWithAttendeeSmsReminder.id,
attendee: {
name: "Mr Proper",
email: "mr_proper@gmail.com",
timeZone: "Europe/Rome",
language: "it",
phoneNumber,
},
};
const response = await request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201);
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsBooking(responseBody.data)).toBe(true);
if (responseDataIsBooking(responseBody.data)) {
const data: BookingOutput_2024_08_13 = responseBody.data;
expect(data.id).toBeDefined();
expect(data.uid).toBeDefined();
expect(data.hosts.length).toEqual(1);
expect(data.hosts[0].id).toEqual(user.id);
expect(data.status).toEqual("accepted");
expect(data.start).toEqual(body.start);
expect(data.end).toEqual(new Date(Date.UTC(2030, 0, 8, 15, 15, 0)).toISOString());
expect(data.duration).toEqual(15);
expect(data.eventTypeId).toEqual(eventTypeWithAttendeeSmsReminder.id);
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,
phoneNumber: body.attendee.phoneNumber,
absent: false,
});
expect(data.bookingFieldsResponses).toEqual({
name: body.attendee.name,
email: body.attendee.email,
attendeePhoneNumber: body.attendee.phoneNumber,
smsReminderNumber: body.attendee.phoneNumber,
});
const dbBooking = await bookingsRepositoryFixture.getById(data.id);
expect(dbBooking?.smsReminderNumber).toEqual(phoneNumber);
const dbWorkflowReminder = await workflowReminderRepositoryFixture.getByBookingUid(data.uid);
expect(dbWorkflowReminder?.method).toEqual("SMS");
} else {
throw new Error("Invalid response data");
}
});
});
function responseDataIsRecurranceBooking(data: any): data is RecurringBookingOutput_2024_08_13 {
return (
!Array.isArray(data) &&
@@ -0,0 +1,23 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsIn, IsOptional } from "class-validator";
export const BookingReferences = [
"google_calendar",
"office365_calendar",
"daily_video",
"google_video",
"office365_video",
"zoom_video",
] as const;
export class BookingReferencesFilterInput_2024_08_13 {
@ApiProperty({
description: "Filter booking references by type",
required: false,
enum: BookingReferences,
example: "google_calendar",
})
@IsOptional()
@IsIn(BookingReferences)
type?: (typeof BookingReferences)[number];
}
@@ -0,0 +1,46 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsEnum, IsNumber, IsString } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
export class BookingReference {
@IsString()
@ApiProperty({
description: "The type of the booking reference",
})
type!: string;
@IsString()
@ApiProperty({
description: "The event uid of the booking",
})
eventUid!: string;
@IsString()
@ApiProperty({
description: "The id of the calendar the event is created in",
nullable: true,
})
destinationCalendarId!: string | null;
@IsNumber()
@ApiProperty({
description: "The id of the booking reference",
})
id!: number;
}
export class BookingReferencesOutput_2024_08_13 {
@ApiProperty({
description: "The status of the request, always 'success' for successful responses",
example: SUCCESS_STATUS,
})
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
description: "Booking References",
type: [BookingReference],
})
data!: BookingReference[];
}
@@ -0,0 +1,46 @@
import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/booking-references.repository";
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
import { BookingReferencesFilterInput_2024_08_13 } from "@/ee/bookings/2024-08-13/inputs/booking-references-filter.input";
import { OutputBookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output-booking-references.service";
import { Injectable, NotFoundException, BadRequestException } from "@nestjs/common";
@Injectable()
export class BookingReferencesService_2024_08_13 {
constructor(
private readonly bookingsRepository: BookingsRepository_2024_08_13,
private readonly bookingReferencesRepository: BookingReferencesRepository_2024_08_13,
private readonly outputBookingReferencesService: OutputBookingReferencesService_2024_08_13
) {}
async getBookingReferences(
bookingUid: string,
userId: number,
filter?: BookingReferencesFilterInput_2024_08_13
) {
const booking = await this.bookingsRepository.getByUidWithUser(bookingUid);
if (!booking) {
throw new NotFoundException(`Booking with uid ${bookingUid} not found`);
}
if (booking.user?.id !== userId) {
throw new BadRequestException(`Booking with uid ${bookingUid} does not belong to user`);
}
const bookingReferences = await this.bookingReferencesRepository.getBookingReferences(booking.id, filter);
return this.outputBookingReferencesService.getOutputBookingReferences(bookingReferences);
}
async getOrgBookingReferences(bookingUid: string, filter?: BookingReferencesFilterInput_2024_08_13) {
const booking = await this.bookingsRepository.getByUidWithUser(bookingUid);
if (!booking) {
throw new NotFoundException(`Booking with uid ${bookingUid} not found`);
}
const bookingReferences = await this.bookingReferencesRepository.getBookingReferences(booking.id, filter);
return this.outputBookingReferencesService.getOutputBookingReferences(bookingReferences);
}
}
@@ -7,6 +7,7 @@ import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
import { BillingService } from "@/modules/billing/services/billing.service";
import { BookingSeatRepository } from "@/modules/booking-seat/booking-seat.repository";
import { KyselyReadService } from "@/modules/kysely/kysely-read.service";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
@@ -19,6 +20,7 @@ import { UsersRepository, UserWithProfile } from "@/modules/users/users.reposito
import { ConflictException, Injectable, Logger, NotFoundException } from "@nestjs/common";
import { BadRequestException } from "@nestjs/common";
import { Request } from "express";
import { DateTime } from "luxon";
import { z } from "zod";
import {
@@ -64,7 +66,7 @@ const eventTypeBookingFieldSchema = z.object({
editable: z.string(),
});
const eventTypeBookingFieldsSchema = z.array(eventTypeBookingFieldSchema);
export const eventTypeBookingFieldsSchema = z.array(eventTypeBookingFieldSchema);
export type EventTypeWithOwnerAndTeam = EventType & { owner: User | null; team: Team | null };
@@ -78,6 +80,7 @@ export class BookingsService_2024_08_13 {
private readonly bookingSeatRepository: BookingSeatRepository,
private readonly eventTypesRepository: EventTypesRepository_2024_06_14,
private readonly prismaReadService: PrismaReadService,
private readonly kyselyReadService: KyselyReadService,
private readonly billingService: BillingService,
private readonly usersService: UsersService,
private readonly usersRepository: UsersRepository,
@@ -169,25 +172,30 @@ export class BookingsService_2024_08_13 {
}
async hasRequiredBookingFieldsResponses(body: CreateBookingInput, eventType: EventType | null) {
const bookingFields = { ...body.bookingFieldsResponses, attendeePhoneNumber: body.attendee.phoneNumber };
const bookingFields: Record<string, unknown> = {
...body.bookingFieldsResponses,
attendeePhoneNumber: body.attendee.phoneNumber,
smsReminderNumber: body.attendee.phoneNumber,
};
if (!eventType?.bookingFields) {
return true;
}
// note(Lauris): we filter out system fields, because some of them are set by default and name and email are passed in the body.attendee
// note(Lauris): we filter out system fields, because some of them are set by default and name and email are passed in the body.attendee. Only exception
// is smsReminderNumber, because if it is required and not passed sms workflow won't work.
const eventTypeBookingFields = eventTypeBookingFieldsSchema
.parse(eventType.bookingFields)
.filter((field) => !field.editable.startsWith("system"));
.filter((field) => !field.editable.startsWith("system") || field.name === "smsReminderNumber");
if (!eventTypeBookingFields.length) {
return true;
}
for (const field of eventTypeBookingFields) {
if (field.required && !(field.name in bookingFields)) {
if (field.name === "attendeePhoneNumber") {
if (field.required && (bookingFields[field.name] === null || bookingFields[field.name] === undefined)) {
if (field.name === "attendeePhoneNumber" || field.name === "smsReminderNumber") {
throw new BadRequestException(
`Missing attendee phone number - it is required by the event type. Pass it as "attendee.phoneNumber" in the request.`
`Missing attendee phone number - it is required by the event type. Pass it as "attendee.phoneNumber" string in the request.`
);
}
throw new BadRequestException(
@@ -374,7 +382,7 @@ export class BookingsService_2024_08_13 {
queryParams.attendeeEmail = await this.getAttendeeEmail(queryParams.attendeeEmail, user);
}
const fetchedBookings: { bookings: { id: number }[] } = await getAllUserBookings({
const fetchedBookings: { bookings: { id: number }[]; totalCount: number } = await getAllUserBookings({
bookingListingByStatus: queryParams.status || [],
skip: queryParams.skip ?? 0,
take: queryParams.take ?? 100,
@@ -385,6 +393,7 @@ export class BookingsService_2024_08_13 {
ctx: {
user,
prisma: this.prismaReadService.prisma as unknown as PrismaClient,
kysely: this.kyselyReadService.kysely,
},
sort: this.inputService.transformGetBookingsSort(queryParams),
});
@@ -429,7 +438,29 @@ export class BookingsService_2024_08_13 {
}
}
return formattedBookings;
const skip = Math.abs(queryParams?.skip ?? 0);
const take = Math.abs(queryParams?.take ?? 100);
const itemsPerPage = take;
const totalPages = itemsPerPage !== 0 ? Math.ceil(fetchedBookings.totalCount / itemsPerPage) : 0;
const currentPage = Math.floor(skip / itemsPerPage) + 1;
const hasNextPage = skip + itemsPerPage < fetchedBookings.totalCount;
const hasPreviousPage = skip > 0;
return {
bookings: formattedBookings,
pagination: {
totalItems: fetchedBookings.totalCount,
// clamp remainingItems between 0 and totalCount
remainingItems: Math.min(
Math.max(fetchedBookings.totalCount - (skip + take), 0),
fetchedBookings.totalCount
),
itemsPerPage: itemsPerPage,
currentPage: currentPage,
totalPages: totalPages,
hasNextPage: hasNextPage,
hasPreviousPage: hasPreviousPage,
},
};
}
async getAttendeeEmail(queryParamsAttendeeEmail: string, user: { id: number }) {
@@ -569,6 +600,20 @@ export class BookingsService_2024_08_13 {
async markAbsent(bookingUid: string, bookingOwnerId: number, body: MarkAbsentBookingInput_2024_08_13) {
const bodyTransformed = this.inputService.transformInputMarkAbsentBooking(body);
const bookingBefore = await this.bookingsRepository.getByUid(bookingUid);
if (!bookingBefore) {
throw new NotFoundException(`Booking with uid=${bookingUid} not found.`);
}
const nowUtc = DateTime.utc();
const bookingStartTimeUtc = DateTime.fromJSDate(bookingBefore.startTime, { zone: "utc" });
if (nowUtc < bookingStartTimeUtc) {
throw new BadRequestException(
`Bookings can only be marked as absent after their scheduled start time. Current time in UTC+0: ${nowUtc.toISO()}, Booking start time in UTC+0: ${bookingStartTimeUtc.toISO()}`
);
}
const platformClientParams = bookingBefore?.eventTypeId
? await this.platformBookingsService.getOAuthClientParams(bookingBefore.eventTypeId)
: undefined;
@@ -1,5 +1,8 @@
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
import { EventTypeWithOwnerAndTeam } from "@/ee/bookings/2024-08-13/services/bookings.service";
import {
eventTypeBookingFieldsSchema,
EventTypeWithOwnerAndTeam,
} from "@/ee/bookings/2024-08-13/services/bookings.service";
import {
bookingResponsesSchema,
seatedBookingDataSchema,
@@ -159,6 +162,12 @@ export class InputBookingsService_2024_08_13 {
this.isBookingLocationWithEventTypeLocations(inputLocation, eventType);
const location = inputLocation ? this.transformLocation(inputLocation) : undefined;
const needsSmsReminderNumber = eventType.bookingFields
? eventTypeBookingFieldsSchema
.parse(eventType.bookingFields)
.find((field) => field.name === "smsReminderNumber")
: undefined;
return {
start: startTime.toISO(),
end: endTime.toISO(),
@@ -174,6 +183,7 @@ export class InputBookingsService_2024_08_13 {
name: inputBooking.attendee.name,
email: attendeeEmail ?? "",
attendeePhoneNumber: inputBooking.attendee.phoneNumber,
smsReminderNumber: needsSmsReminderNumber ? inputBooking.attendee.phoneNumber : undefined,
guests,
location,
},
@@ -0,0 +1,20 @@
import { Injectable } from "@nestjs/common";
interface IBookingReference {
type: string;
uid: string;
id: number;
externalCalendarId: string | null;
}
@Injectable()
export class OutputBookingReferencesService_2024_08_13 {
getOutputBookingReferences(bookingReferences: IBookingReference[]) {
return bookingReferences.map((bookingReference) => ({
type: bookingReference.type,
eventUid: bookingReference.uid,
id: bookingReference.id,
destinationCalendarId: bookingReference?.externalCalendarId,
}));
}
}
@@ -22,7 +22,7 @@ import {
RecurringBookingOutput_2024_08_13,
SeatedAttendee,
} from "@calcom/platform-types";
import { Booking, BookingSeat } from "@calcom/prisma/client";
import { Booking, BookingReference, BookingSeat } from "@calcom/prisma/client";
export const bookingResponsesSchema = z
.object({
@@ -119,6 +119,7 @@ export class AppleCalendarService implements CredentialSyncCalendarApp {
teamId: null,
appId: APPLE_CALENDAR_ID,
invalid: false,
delegationCredentialId: null,
};
const dav = new CalendarService({
@@ -35,6 +35,7 @@ export class IcsFeedService implements ICSFeedCalendarApp {
teamId: null,
appId: ICS_CALENDAR,
invalid: false,
delegationCredentialId: null,
};
try {
@@ -1,4 +1,4 @@
import { OrganizerIntegrationLocation } from "@calcom/lib";
import { OrganizerIntegrationLocation } from "@calcom/lib/event-types/transformers/internal/locations";
type BaseEventType = {
length: number;
@@ -490,6 +490,7 @@ describe("Event types Endpoints", () => {
requiresBookerEmailVerification: false,
hideCalendarNotes: false,
hideCalendarEventDetails: false,
hideOrganizerEmail: false,
lockTimeZoneToggleOnBookingPage: true,
color: {
darkThemeHex: "#292929",
@@ -529,6 +530,7 @@ describe("Event types Endpoints", () => {
expect(createdEventType.hideCalendarNotes).toEqual(body.hideCalendarNotes);
expect(createdEventType.hideCalendarEventDetails).toEqual(body.hideCalendarEventDetails);
expect(createdEventType.hideOrganizerEmail).toEqual(body.hideOrganizerEmail);
expect(createdEventType.lockTimeZoneToggleOnBookingPage).toEqual(
body.lockTimeZoneToggleOnBookingPage
);
@@ -610,6 +612,7 @@ describe("Event types Endpoints", () => {
);
expect(fetchedEventType.hideCalendarNotes).toEqual(eventType.hideCalendarNotes);
expect(fetchedEventType.hideCalendarEventDetails).toEqual(eventType.hideCalendarEventDetails);
expect(fetchedEventType.hideOrganizerEmail).toEqual(eventType.hideOrganizerEmail);
expect(fetchedEventType.lockTimeZoneToggleOnBookingPage).toEqual(
eventType.lockTimeZoneToggleOnBookingPage
);
@@ -1009,6 +1012,7 @@ describe("Event types Endpoints", () => {
requiresBookerEmailVerification: true,
hideCalendarNotes: true,
hideCalendarEventDetails: true,
hideOrganizerEmail: true,
lockTimeZoneToggleOnBookingPage: true,
color: {
darkThemeHex: "#292929",
@@ -1073,6 +1077,7 @@ describe("Event types Endpoints", () => {
);
expect(updatedEventType.hideCalendarNotes).toEqual(body.hideCalendarNotes);
expect(updatedEventType.hideCalendarEventDetails).toEqual(body.hideCalendarEventDetails);
expect(updatedEventType.hideOrganizerEmail).toEqual(body.hideOrganizerEmail);
expect(updatedEventType.lockTimeZoneToggleOnBookingPage).toEqual(
body.lockTimeZoneToggleOnBookingPage
);
@@ -1093,6 +1098,7 @@ describe("Event types Endpoints", () => {
eventType.requiresBookerEmailVerification = updatedEventType.requiresBookerEmailVerification;
eventType.hideCalendarNotes = updatedEventType.hideCalendarNotes;
eventType.hideCalendarEventDetails = updatedEventType.hideCalendarEventDetails;
eventType.hideOrganizerEmail = updatedEventType.hideOrganizerEmail;
eventType.lockTimeZoneToggleOnBookingPage = updatedEventType.lockTimeZoneToggleOnBookingPage;
eventType.color = updatedEventType.color;
eventType.bookingFields = updatedEventType.bookingFields;
@@ -1163,6 +1169,7 @@ describe("Event types Endpoints", () => {
);
expect(fetchedEventType.hideCalendarNotes).toEqual(eventType.hideCalendarNotes);
expect(fetchedEventType.hideCalendarEventDetails).toEqual(eventType.hideCalendarEventDetails);
expect(fetchedEventType.hideOrganizerEmail).toEqual(eventType.hideOrganizerEmail);
expect(fetchedEventType.lockTimeZoneToggleOnBookingPage).toEqual(
eventType.lockTimeZoneToggleOnBookingPage
);
@@ -1204,6 +1211,7 @@ describe("Event types Endpoints", () => {
);
expect(fetchedEventType.hideCalendarNotes).toEqual(eventType.hideCalendarNotes);
expect(fetchedEventType.hideCalendarEventDetails).toEqual(eventType.hideCalendarEventDetails);
expect(fetchedEventType.hideOrganizerEmail).toEqual(eventType.hideOrganizerEmail);
expect(fetchedEventType.lockTimeZoneToggleOnBookingPage).toEqual(
eventType.lockTimeZoneToggleOnBookingPage
);
@@ -8,7 +8,6 @@ import { InputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_
import { OutputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/output-event-types.service";
import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository";
import { AppsRepository } from "@/modules/apps/apps.repository";
import { ConferencingModule } from "@/modules/conferencing/conferencing.module";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { MembershipsModule } from "@/modules/memberships/memberships.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
@@ -19,7 +18,7 @@ import { UsersRepository } from "@/modules/users/users.repository";
import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule, MembershipsModule, TokensModule, SelectedCalendarsModule, ConferencingModule],
imports: [PrismaModule, MembershipsModule, TokensModule, SelectedCalendarsModule],
providers: [
EventTypesRepository_2024_06_14,
EventTypesService_2024_06_14,
@@ -5,6 +5,9 @@ import { ConferencingService } from "@/modules/conferencing/services/conferencin
import { UserWithProfile } from "@/modules/users/users.repository";
import { Injectable, BadRequestException } from "@nestjs/common";
import { CONFERENCING_APPS } from "@calcom/platform-constants";
import { getUsersCredentials } from "@calcom/platform-libraries";
import { getApps } from "@calcom/platform-libraries/app-store";
import {
transformBookingFieldsApiToInternal,
transformLocationsApiToInternal,
@@ -50,8 +53,7 @@ interface ValidationContext {
export class InputEventTypesService_2024_06_14 {
constructor(
private readonly eventTypesRepository: EventTypesRepository_2024_06_14,
private readonly calendarsService: CalendarsService,
private readonly conferencingService: ConferencingService
private readonly calendarsService: CalendarsService
) {}
async transformAndValidateCreateEventTypeInput(
@@ -104,13 +106,6 @@ export class InputEventTypesService_2024_06_14 {
}
transformInputCreateEventType(inputEventType: CreateEventTypeInput_2024_06_14) {
const defaultLocations: CreateEventTypeInput_2024_06_14["locations"] = [
{
type: "integration",
integration: "cal-video",
},
];
const {
lengthInMinutes,
lengthInMinutesOptions,
@@ -130,10 +125,11 @@ export class InputEventTypesService_2024_06_14 {
} = inputEventType;
const confirmationPolicyTransformed = this.transformInputConfirmationPolicy(confirmationPolicy);
const locationsTransformed = locations?.length ? this.transformInputLocations(locations) : undefined;
const eventType = {
...rest,
length: lengthInMinutes,
locations: this.transformInputLocations(locations || defaultLocations),
locations: locationsTransformed,
bookingFields: this.transformInputBookingFields(bookingFields),
bookingLimits: bookingLimitsCount ? this.transformInputIntervalLimits(bookingLimitsCount) : undefined,
durationLimits: bookingLimitsDuration
@@ -463,10 +459,32 @@ export class InputEventTypesService_2024_06_14 {
if (location.type === "integration") {
// cal-video is global, so we can skip this check
if (location.integration !== "cal-video") {
await this.conferencingService.checkAppIsValidAndConnected(user, location.integration);
await this.checkAppIsValidAndConnected(user, location.integration);
}
}
}) ?? []
);
}
async checkAppIsValidAndConnected(user: UserWithProfile, appSlug: string) {
const conferencingApps = ["google-meet", "office365-video", "zoom"];
if (!conferencingApps.includes(appSlug)) {
throw new BadRequestException("Invalid app, available apps are: ", conferencingApps.join(", "));
}
if (appSlug === "office365-video") {
appSlug = "msteams";
}
const credentials = await getUsersCredentials(user);
const foundApp = getApps(credentials, true).filter((app) => app.slug === appSlug)[0];
const appLocation = foundApp?.appData?.location;
if (!foundApp || !appLocation) {
throw new BadRequestException(`${appSlug} not connected.`);
}
return foundApp.credential;
}
}
@@ -87,6 +87,7 @@ type Input = Pick<
| "destinationCalendar"
| "useEventTypeDestinationCalendarEmail"
| "hideCalendarEventDetails"
| "hideOrganizerEmail"
>;
@Injectable()
@@ -123,6 +124,7 @@ export class OutputEventTypesService_2024_06_14 {
seatsShowAttendees,
useEventTypeDestinationCalendarEmail,
hideCalendarEventDetails,
hideOrganizerEmail,
} = databaseEventType;
const locations = this.transformLocations(databaseEventType.locations);
@@ -197,6 +199,7 @@ export class OutputEventTypesService_2024_06_14 {
destinationCalendar,
useDestinationCalendarEmail: useEventTypeDestinationCalendarEmail,
hideCalendarEventDetails,
hideOrganizerEmail,
};
}
+1 -1
View File
@@ -6,7 +6,7 @@ if (process.env.SENTRY_DSN) {
// Ensure to call this before requiring any other modules!
Sentry.init({
dsn: getEnv("SENTRY_DSN"),
integrations: [nodeProfilingIntegration()],
integrations: [nodeProfilingIntegration(), Sentry.prismaIntegration()],
// Performance Monitoring
tracesSampleRate: getEnv("SENTRY_TRACES_SAMPLE_RATE") ?? 1.0, // Capture 100% of the transactions
// Set sampling rate for profiling - this is relative to tracesSampleRate
@@ -0,0 +1,4 @@
import { RateLimitType } from "@/lib/throttler-guard";
import { Reflector } from "@nestjs/core";
export const Throttle = Reflector.createDecorator<RateLimitType>();
+16 -2
View File
@@ -1,5 +1,6 @@
import { getEnv } from "@/env";
import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key";
import { Throttle } from "@/lib/endpoint-throttler-decorator";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { ThrottlerStorageRedisService } from "@nest-lab/throttler-storage-redis";
import { Inject, Injectable, Logger, UnauthorizedException } from "@nestjs/common";
@@ -22,7 +23,7 @@ const rateLimitSchema = z.object({
ttl: z.number(),
blockDuration: z.number(),
});
type RateLimitType = z.infer<typeof rateLimitSchema>;
export type RateLimitType = z.infer<typeof rateLimitSchema>;
const rateLimitsSchema = z.array(rateLimitSchema);
const sixtySecondsMs = 60 * 1000;
@@ -52,7 +53,7 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
protected async handleRequest(requestProps: ThrottlerRequest): Promise<boolean> {
const { context } = requestProps;
const throttleOptions = this.reflector.get(Throttle, context.getHandler());
const request = context.switchToHttp().getRequest<Request>();
const IP = request?.headers?.["cf-connecting-ip"] ?? request?.headers?.["CF-Connecting-IP"] ?? request.ip;
const response = context.switchToHttp().getResponse<Response>();
@@ -63,6 +64,10 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
)}", OAuth client ID "${request.get(X_CAL_CLIENT_ID)}" and IP "${IP}"`
);
if (throttleOptions) {
return this.handleApiEndpointThrottle(tracker, throttleOptions, response);
}
if (tracker.startsWith("api_key_")) {
return this.handleApiKeyRequest(tracker, response);
} else {
@@ -70,6 +75,15 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
}
}
private async handleApiEndpointThrottle(tracker: string, options: RateLimitType, response: Response) {
const { isBlocked } = await this.incrementRateLimit(`${tracker}_${options.name}`, options, response);
if (isBlocked) {
throw new ThrottlerException("CustomThrottlerGuard - Too many requests. Please try again later.");
}
return true;
}
private async handleApiKeyRequest(tracker: string, response: Response): Promise<boolean> {
const rateLimits = await this.getRateLimitsForApiKeyTracker(tracker);
@@ -1,3 +1,4 @@
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
import { BillingProcessor } from "@/modules/billing/billing.processor";
import { BillingRepository } from "@/modules/billing/billing.repository";
import { BillingController } from "@/modules/billing/controllers/billing.controller";
@@ -5,6 +6,7 @@ import { BillingConfigService } from "@/modules/billing/services/billing.config.
import { BillingService } from "@/modules/billing/services/billing.service";
import { ManagedOrganizationsBillingService } from "@/modules/billing/services/managed-organizations.billing.service";
import { MembershipsModule } from "@/modules/memberships/memberships.module";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { StripeModule } from "@/modules/stripe/stripe.module";
@@ -33,6 +35,8 @@ import { Module } from "@nestjs/common";
BillingRepository,
BillingProcessor,
ManagedOrganizationsBillingService,
OAuthClientRepository,
BookingsRepository_2024_08_13,
],
exports: [BillingService, BillingRepository, ManagedOrganizationsBillingService],
controllers: [BillingController],
@@ -15,12 +15,21 @@ export class BillingRepository {
},
});
async getBillingForTeamBySubscriptionId(subscriptionId: string) {
return this.dbRead.prisma.platformBilling.findFirst({
where: {
subscriptionId,
},
});
}
async updateTeamBilling(
teamId: number,
billingStart: number,
billingEnd: number,
plan: PlatformPlan,
subscriptionId?: string
subscriptionId?: string,
priceId?: string
) {
return this.dbWrite.prisma.platformBilling.update({
where: {
@@ -32,6 +41,7 @@ export class BillingRepository {
subscriptionId,
plan: plan.toString(),
overdue: false,
priceId,
},
});
}
@@ -20,6 +20,7 @@ import {
HttpCode,
HttpStatus,
Logger,
Delete,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { ApiExcludeController } from "@nestjs/swagger";
@@ -96,6 +97,19 @@ export class BillingController {
};
}
@Delete("/:organizationId/unsubscribe")
@UseGuards(NextAuthGuard, OrganizationRolesGuard)
@MembershipRoles(["OWNER", "ADMIN"])
async cancelTeamSubscriptionInStripe(
@Param("organizationId") organizationId: number
): Promise<ApiResponse> {
await this.billingService.cancelTeamSubscription(organizationId);
return {
status: "success",
};
}
@Post("/webhook")
@HttpCode(HttpStatus.OK)
async stripeWebhook(
@@ -113,6 +127,9 @@ export class BillingController {
case "customer.subscription.deleted":
await this.billingService.handleStripeSubscriptionDeleted(event);
break;
case "invoice.created":
await this.billingService.handleStripeSubscriptionForActiveManagedUsers(event);
break;
case "invoice.payment_failed":
await this.billingService.handleStripePaymentFailed(event);
break;
@@ -1,12 +1,16 @@
import { AppConfig } from "@/config/type";
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
import { BILLING_QUEUE, INCREMENT_JOB, IncrementJobDataType } from "@/modules/billing/billing.processor";
import { BillingRepository } from "@/modules/billing/billing.repository";
import { BillingConfigService } from "@/modules/billing/services/billing.config.service";
import { PlatformPlan } from "@/modules/billing/types";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
import { StripeService } from "@/modules/stripe/stripe.service";
import { UsersRepository } from "@/modules/users/users.repository";
import { InjectQueue } from "@nestjs/bull";
import {
BadRequestException,
Injectable,
InternalServerErrorException,
Logger,
@@ -29,6 +33,9 @@ export class BillingService implements OnModuleDestroy {
private readonly billingRepository: BillingRepository,
private readonly configService: ConfigService<AppConfig>,
private readonly billingConfigService: BillingConfigService,
private readonly usersRepository: UsersRepository,
private readonly oAuthClientRepository: OAuthClientRepository,
private readonly bookingsRepository: BookingsRepository_2024_08_13,
@InjectQueue(BILLING_QUEUE) private readonly billingQueue: Queue
) {
this.webAppUrl = this.configService.get("app.baseUrl", { infer: true }) ?? "https://app.cal.com";
@@ -118,7 +125,7 @@ export class BillingService implements OnModuleDestroy {
return url;
}
async setSubscriptionForTeam(teamId: number, subscriptionId: string, plan: PlatformPlan) {
async setPerBookingSubscriptionForTeam(teamId: number, subscriptionId: string, plan: PlatformPlan) {
const billingCycleStart = DateTime.now().get("day");
const billingCycleEnd = DateTime.now().plus({ month: 1 }).get("day");
@@ -131,6 +138,25 @@ export class BillingService implements OnModuleDestroy {
);
}
async setPerActiveUserSubscriptionForTeam(
teamId: number,
subscriptionId: string,
plan: PlatformPlan,
priceId: string
) {
const billingCycleStart = DateTime.now().get("day");
const billingCycleEnd = DateTime.now().plus({ month: 1 }).get("day");
return this.billingRepository.updateTeamBilling(
teamId,
billingCycleStart,
billingCycleEnd,
plan,
subscriptionId,
priceId
);
}
async handleStripeSubscriptionDeleted(event: Stripe.Event) {
const subscription = event.data.object as Stripe.Subscription;
const teamId = subscription?.metadata?.teamId;
@@ -203,9 +229,19 @@ export class BillingService implements OnModuleDestroy {
this.logger.log("Webhook received but not pertaining to Platform, discarding.");
return;
}
const isPriceIdPresent = Boolean(checkoutSession.metadata?.priceId);
if (checkoutSession.mode === "subscription") {
await this.setSubscriptionForTeam(
if (checkoutSession.mode === "subscription" && isPriceIdPresent) {
await this.setPerActiveUserSubscriptionForTeam(
teamId,
checkoutSession.subscription as string,
PlatformPlan[plan.toUpperCase() as keyof typeof PlatformPlan],
checkoutSession.metadata?.priceId
);
}
if (checkoutSession.mode === "subscription" && !isPriceIdPresent) {
await this.setPerBookingSubscriptionForTeam(
teamId,
checkoutSession.subscription as string,
PlatformPlan[plan.toUpperCase() as keyof typeof PlatformPlan]
@@ -219,6 +255,67 @@ export class BillingService implements OnModuleDestroy {
return;
}
async handleStripeSubscriptionForActiveManagedUsers(event: Stripe.Event) {
const invoice = event.data.object as Stripe.Invoice;
const subscriptionId = this.getSubscriptionIdFromInvoice(invoice);
if (!subscriptionId) {
throw new NotFoundException("No subscription found for team");
}
const teamWithBilling = await this.billingRepository.getBillingForTeamBySubscriptionId(subscriptionId);
if (teamWithBilling?.plan === "PER_ACTIVE_USER") {
const activeManagedUsersCount = await this.getActiveManagedUsersCount(
subscriptionId,
new Date(invoice.period_start * 1000),
new Date(invoice.period_end * 1000)
);
await this.stripeService.getStripe().subscriptions.update(subscriptionId, {
items: [
{
quantity: activeManagedUsersCount > 0 ? activeManagedUsersCount : 1,
},
],
});
}
}
async getActiveManagedUsersCount(subscriptionId: string, invoiceStart: Date, invoiceEnd: Date) {
const managedUsersEmails = await this.usersRepository.getOrgsManagedUserEmailsBySubscriptionId(
subscriptionId
);
if (!managedUsersEmails) return 0;
if (!invoiceStart || !invoiceEnd) {
this.logger.log("Invoice period start or end date is null");
return 0;
}
const activeManagedUserEmailsAsHost = await this.usersRepository.getActiveManagedUsersAsHost(
subscriptionId,
invoiceStart,
invoiceEnd
);
const activeHostEmails = activeManagedUserEmailsAsHost.map((email) => email.email);
const notActiveHostEmails = managedUsersEmails
.filter((email) => !activeHostEmails.includes(email.email))
.map((email) => email.email);
if (notActiveHostEmails.length === 0) return activeManagedUserEmailsAsHost.length;
const activeManagedUserEmailsAsAttendee = await this.usersRepository.getActiveManagedUsersAsAttendee(
notActiveHostEmails,
invoiceStart,
invoiceEnd
);
return activeManagedUserEmailsAsAttendee.length + activeManagedUserEmailsAsHost.length;
}
async updateStripeSubscriptionForTeam(teamId: number, plan: PlatformPlan) {
const teamWithBilling = await this.teamsRepository.findByIdIncludeBilling(teamId);
@@ -262,7 +359,7 @@ export class BillingService implements OnModuleDestroy {
proration_behavior: "create_prorations",
});
await this.setSubscriptionForTeam(
await this.setPerBookingSubscriptionForTeam(
teamId,
teamWithBilling?.platformBilling?.subscriptionId,
PlatformPlan[plan.toUpperCase() as keyof typeof PlatformPlan]
@@ -313,6 +410,28 @@ export class BillingService implements OnModuleDestroy {
}
}
async cancelTeamSubscription(teamId: number) {
const teamWithBilling = await this.teamsRepository.findByIdIncludeBilling(teamId);
const customerId = teamWithBilling?.platformBilling?.customerId;
if (!customerId) {
throw new NotFoundException("No customer id found for team in Stripe");
}
if (!teamWithBilling?.platformBilling || !teamWithBilling?.platformBilling.subscriptionId) {
throw new NotFoundException("Team plan not found");
}
try {
await this.stripeService
.getStripe()
.subscriptions.cancel(teamWithBilling?.platformBilling?.subscriptionId);
} catch (error) {
this.logger.log(error, "error while cancelling team subscription in stripe");
throw new BadRequestException("Failed to cancel team subscription");
}
}
async onModuleDestroy() {
try {
await this.billingQueue.close();
+2 -1
View File
@@ -4,6 +4,7 @@ export enum PlatformPlan {
ESSENTIALS = "ESSENTIALS",
SCALE = "SCALE",
ENTERPRISE = "ENTERPRISE",
PER_ACTIVE_USER = "PER_ACTIVE_USER",
}
export type PlatformPlanType = "FREE" | "STARTER" | "ESSENTIALS" | "SCALE" | "ENTERPRISE";
export type PlatformPlanType = "FREE" | "STARTER" | "ESSENTIALS" | "SCALE" | "ENTERPRISE" | "PER_ACTIVE_USER";
@@ -11,6 +11,7 @@ import { OrganizationsUsersBookingsModule } from "@/modules/organizations/users/
import { RouterModule } from "@/modules/router/router.module";
import { StripeModule } from "@/modules/stripe/stripe.module";
import { TimezoneModule } from "@/modules/timezones/timezones.module";
import { VerifiedResourcesModule } from "@/modules/verified-resources/verified-resources.module";
import type { MiddlewareConsumer, NestModule } from "@nestjs/common";
import { Module } from "@nestjs/common";
@@ -33,6 +34,7 @@ import { WebhooksModule } from "./webhooks/webhooks.module";
OrganizationsUsersBookingsModule,
OrganizationsBookingsModule,
OrganizationsRoutingFormsModule,
VerifiedResourcesModule,
RouterModule,
],
})
@@ -0,0 +1,36 @@
import type { OnModuleDestroy } from "@nestjs/common";
import { Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { DeduplicateJoinsPlugin, Kysely, ParseJSONResultsPlugin, PostgresDialect } from "kysely";
import { Pool } from "pg";
import type { DB } from "@calcom/kysely/types";
@Injectable()
export class KyselyReadService implements OnModuleDestroy {
private logger = new Logger("KyselyReadService");
public kysely: Kysely<DB>;
constructor(readonly configService: ConfigService) {
const dbUrl = configService.get("db.readUrl", { infer: true });
const pool = new Pool({ connectionString: dbUrl });
// 3. Create the Dialect, passing the configured pool instance
const dialect = new PostgresDialect({
pool: pool, // Use the pool instance created above
});
this.kysely = new Kysely<DB>({
dialect,
plugins: [new ParseJSONResultsPlugin(), new DeduplicateJoinsPlugin()],
});
}
async onModuleDestroy() {
try {
await this.kysely.destroy();
} catch (error) {
this.logger.error(error);
}
}
}
@@ -0,0 +1,36 @@
import type { OnModuleDestroy } from "@nestjs/common";
import { Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Kysely, ParseJSONResultsPlugin, PostgresDialect, DeduplicateJoinsPlugin } from "kysely";
import { Pool } from "pg";
import type { DB } from "@calcom/kysely/types";
@Injectable()
export class KyselyWriteService implements OnModuleDestroy {
private logger = new Logger("KyselyWriteService");
public kysely: Kysely<DB>;
constructor(readonly configService: ConfigService) {
const dbUrl = configService.get("db.writeUrl", { infer: true });
const pool = new Pool({ connectionString: dbUrl });
// 3. Create the Dialect, passing the configured pool instance
const dialect = new PostgresDialect({
pool: pool, // Use the pool instance created above
});
this.kysely = new Kysely<DB>({
dialect,
plugins: [new ParseJSONResultsPlugin(), new DeduplicateJoinsPlugin()],
});
}
async onModuleDestroy() {
try {
await this.kysely.destroy();
} catch (error) {
this.logger.error(error);
}
}
}
@@ -0,0 +1,9 @@
import { KyselyReadService } from "@/modules/kysely/kysely-read.service";
import { KyselyWriteService } from "@/modules/kysely/kysely-write.service";
import { Module } from "@nestjs/common";
@Module({
providers: [KyselyReadService, KyselyWriteService],
exports: [KyselyReadService, KyselyWriteService],
})
export class KyselyModule {}
@@ -63,6 +63,7 @@ export class MembershipsRepository {
async createMembership(teamId: number, userId: number, role: MembershipRole, accepted: boolean) {
const membership = await this.dbRead.prisma.membership.create({
data: {
createdAt: new Date(),
role,
teamId,
userId,
@@ -45,7 +45,7 @@ export class OrganizationsBookingsController {
): Promise<GetBookingsOutput_2024_08_13> {
const { userIds, ...restParams } = queryParams;
const bookings = await this.bookingsService.getBookings(
const { bookings, pagination } = await this.bookingsService.getBookings(
{ ...restParams },
{ email: user.email, id: user.id, orgId },
userIds
@@ -54,6 +54,7 @@ export class OrganizationsBookingsController {
return {
status: SUCCESS_STATUS,
data: bookings,
pagination,
};
}
}
@@ -340,6 +340,7 @@ describe("Organizations Event Types Endpoints", () => {
requiresBookerEmailVerification: true,
hideCalendarNotes: true,
hideCalendarEventDetails: true,
hideOrganizerEmail: true,
lockTimeZoneToggleOnBookingPage: true,
color: {
darkThemeHex: "#292929",
@@ -371,6 +372,7 @@ describe("Organizations Event Types Endpoints", () => {
expect(data.requiresBookerEmailVerification).toEqual(body.requiresBookerEmailVerification);
expect(data.hideCalendarNotes).toEqual(body.hideCalendarNotes);
expect(data.hideCalendarEventDetails).toEqual(body.hideCalendarEventDetails);
expect(data.hideOrganizerEmail).toEqual(body.hideOrganizerEmail);
expect(data.lockTimeZoneToggleOnBookingPage).toEqual(body.lockTimeZoneToggleOnBookingPage);
expect(data.color).toEqual(body.color);
expect(data.successRedirectUrl).toEqual("https://masterchief.com/argentina/flan/video/1234");
@@ -1099,6 +1101,7 @@ describe("Organizations Event Types Endpoints", () => {
requiresBookerEmailVerification: true,
hideCalendarNotes: true,
hideCalendarEventDetails: true,
hideOrganizerEmail: true,
lockTimeZoneToggleOnBookingPage: true,
color: {
darkThemeHex: "#292929",
@@ -1130,6 +1133,7 @@ describe("Organizations Event Types Endpoints", () => {
expect(data.requiresBookerEmailVerification).toEqual(body.requiresBookerEmailVerification);
expect(data.hideCalendarNotes).toEqual(body.hideCalendarNotes);
expect(data.hideCalendarEventDetails).toEqual(body.hideCalendarEventDetails);
expect(data.hideOrganizerEmail).toEqual(body.hideOrganizerEmail);
expect(data.lockTimeZoneToggleOnBookingPage).toEqual(body.lockTimeZoneToggleOnBookingPage);
expect(data.color).toEqual(body.color);
expect(data.successRedirectUrl).toEqual("https://masterchief.com/argentina/flan/video/1234");
@@ -1,4 +1,5 @@
import { InputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/input-event-types.service";
import { ConferencingRepository } from "@/modules/conferencing/repositories/conferencing.repository";
import { OrganizationsConferencingService } from "@/modules/organizations/conferencing/services/organizations-conferencing.service";
import { TeamsEventTypesRepository } from "@/modules/teams/event-types/teams-event-types.repository";
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
@@ -27,7 +28,8 @@ export class InputOrganizationsEventTypesService {
private readonly teamsRepository: TeamsRepository,
private readonly usersRepository: UsersRepository,
private readonly teamsEventTypesRepository: TeamsEventTypesRepository,
private readonly conferencingService: OrganizationsConferencingService
private readonly conferencingService: OrganizationsConferencingService,
private readonly conferencingRepository: ConferencingRepository
) {}
async transformAndValidateCreateTeamEventTypeInput(
userId: number,
@@ -306,6 +308,22 @@ export class InputOrganizationsEventTypesService {
}) ?? []
);
}
async checkAppIsValidAndConnected(teamId: number, app: string) {
const conferencingApps = ["google-meet", "office365-video", "zoom"];
if (!conferencingApps.includes(app)) {
throw new BadRequestException("Invalid app, available apps are: ", conferencingApps.join(", "));
}
if (app === "office365-video") {
app = "msteams";
}
const credential = await this.conferencingRepository.findTeamConferencingApp(teamId, app);
if (!credential) {
throw new BadRequestException(`${app} not connected.`);
}
return credential;
}
}
function getPriorityValue(priority: keyof typeof HostPriority): number {
@@ -70,6 +70,7 @@ type Input = Pick<
| "eventName"
| "useEventTypeDestinationCalendarEmail"
| "hideCalendarEventDetails"
| "hideOrganizerEmail"
| "team"
>;
@@ -6,6 +6,7 @@ import { CreateOrgMembershipOutput } from "@/modules/organizations/memberships/o
import { DeleteOrgMembership } from "@/modules/organizations/memberships/outputs/delete-membership.output";
import { GetAllOrgMemberships } from "@/modules/organizations/memberships/outputs/get-all-memberships.output";
import { GetOrgMembership } from "@/modules/organizations/memberships/outputs/get-membership.output";
import { OrgUserAttribute } from "@/modules/organizations/memberships/outputs/organization-membership.output";
import { UpdateOrgMembership } from "@/modules/organizations/memberships/outputs/update-membership.output";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output";
@@ -14,17 +15,16 @@ import { UsersModule } from "@/modules/users/users.module";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import { User } from "@prisma/client";
import { Attribute, AttributeOption, User } from "@prisma/client";
import * as request from "supertest";
import { AttributeRepositoryFixture } from "test/fixtures/repository/attributes.repository.fixture";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { randomString } from "test/utils/randomString";
import { withApiAuth } from "test/utils/withApiAuth";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { ApiSuccessResponse } from "@calcom/platform-types";
import { Membership, Team } from "@calcom/prisma/client";
describe("Organizations Memberships Endpoints", () => {
@@ -33,7 +33,8 @@ describe("Organizations Memberships Endpoints", () => {
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let membershipsRepositoryFixture: MembershipRepositoryFixture;
let membershipRepositoryFixture: MembershipRepositoryFixture;
let attributesRepositoryFixture: AttributeRepositoryFixture;
let org: Team;
let membership: Membership;
@@ -49,6 +50,17 @@ describe("Organizations Memberships Endpoints", () => {
let userToInviteViaApi: User;
let textAttribute: Attribute;
let multiSelectAttribute: Attribute;
let numberAttribute: Attribute;
let singleSelectAttribute: Attribute;
let textAttributeOption: AttributeOption;
let multiSelectAttributeOption: AttributeOption;
let multiSelectAttributeOption2: AttributeOption;
let numberAttributeOption: AttributeOption;
let singleSelectAttributeOption: AttributeOption;
const metadata = {
some: "key",
};
@@ -64,7 +76,8 @@ describe("Organizations Memberships Endpoints", () => {
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
membershipRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
attributesRepositoryFixture = new AttributeRepositoryFixture(moduleRef);
user = await userRepositoryFixture.create({
email: userEmail,
@@ -91,16 +104,42 @@ describe("Organizations Memberships Endpoints", () => {
isOrganization: true,
});
membership = await membershipsRepositoryFixture.create({
await setupAttributes();
membership = await membershipRepositoryFixture.create({
role: "ADMIN",
user: { connect: { id: user.id } },
team: { connect: { id: org.id } },
});
membership2 = await membershipsRepositoryFixture.create({
membership2 = await membershipRepositoryFixture.create({
role: "MEMBER",
user: { connect: { id: user2.id } },
team: { connect: { id: org.id } },
AttributeToUser: {
create: [
{
attributeOption: { connect: { id: textAttributeOption.id } },
weight: 100,
},
{
attributeOption: { connect: { id: multiSelectAttributeOption.id } },
weight: 100,
},
{
attributeOption: { connect: { id: multiSelectAttributeOption2.id } },
weight: null,
},
{
attributeOption: { connect: { id: numberAttributeOption.id } },
weight: 100,
},
{
attributeOption: { connect: { id: singleSelectAttributeOption.id } },
weight: 100,
},
],
},
});
app = moduleRef.createNestApplication();
@@ -109,6 +148,87 @@ describe("Organizations Memberships Endpoints", () => {
await app.init();
});
async function setupAttributes() {
textAttribute = await attributesRepositoryFixture.create({
team: { connect: { id: org.id } },
type: "TEXT",
name: "team",
slug: `team-${randomString()}`,
enabled: true,
usersCanEditRelation: false,
isWeightsEnabled: false,
isLocked: false,
});
multiSelectAttribute = await attributesRepositoryFixture.create({
team: { connect: { id: org.id } },
type: "MULTI_SELECT",
name: "skills",
slug: `skills-${randomString()}`,
enabled: true,
usersCanEditRelation: false,
isWeightsEnabled: false,
isLocked: false,
});
numberAttribute = await attributesRepositoryFixture.create({
team: { connect: { id: org.id } },
type: "NUMBER",
name: "age",
slug: `age-${randomString()}`,
enabled: true,
usersCanEditRelation: false,
isWeightsEnabled: false,
isLocked: false,
});
singleSelectAttribute = await attributesRepositoryFixture.create({
team: { connect: { id: org.id } },
type: "SINGLE_SELECT",
name: "frontend",
slug: `frontend-${randomString()}`,
enabled: true,
usersCanEditRelation: false,
isWeightsEnabled: false,
isLocked: false,
});
textAttributeOption = await attributesRepositoryFixture.createOption({
attribute: { connect: { id: textAttribute.id } },
value: "coders",
slug: "coders",
isGroup: false,
});
multiSelectAttributeOption = await attributesRepositoryFixture.createOption({
attribute: { connect: { id: multiSelectAttribute.id } },
value: "javascript",
slug: "javascript",
isGroup: false,
});
multiSelectAttributeOption2 = await attributesRepositoryFixture.createOption({
attribute: { connect: { id: multiSelectAttribute.id } },
value: "typescript",
slug: "typescript",
isGroup: false,
});
numberAttributeOption = await attributesRepositoryFixture.createOption({
attribute: { connect: { id: numberAttribute.id } },
value: "18",
slug: "18",
isGroup: false,
});
singleSelectAttributeOption = await attributesRepositoryFixture.createOption({
attribute: { connect: { id: singleSelectAttribute.id } },
value: "yes",
slug: "yes",
isGroup: false,
});
}
it("should be defined", () => {
expect(userRepositoryFixture).toBeDefined();
expect(organizationsRepositoryFixture).toBeDefined();
@@ -139,9 +259,54 @@ describe("Organizations Memberships Endpoints", () => {
expect(responseBody.data[1].user.email).toEqual(user2.email);
expect(responseBody.data[1].user.username).toEqual(user2.username);
expect(responseBody.data[1].teamId).toEqual(org.id);
userHasCorrectAttributes(responseBody.data[1].attributes);
});
});
function userHasCorrectAttributes(attributes: OrgUserAttribute[]) {
expect(attributes.length).toEqual(4);
const responseNumberAttribute = attributes.find((attr) => attr.type === "number");
const responseSingleSelectAttribute = attributes.find((attr) => attr.type === "singleSelect");
const responseMultiSelectAttribute = attributes.find((attr) => attr.type === "multiSelect");
const responseTextAttribute = attributes.find((attr) => attr.type === "text");
expect(responseNumberAttribute).toEqual({
id: numberAttribute.id,
name: numberAttribute.name,
optionId: numberAttributeOption.id,
option: +numberAttributeOption.value,
type: "number",
});
expect(responseSingleSelectAttribute).toEqual({
id: singleSelectAttribute.id,
name: singleSelectAttribute.name,
optionId: singleSelectAttributeOption.id,
option: singleSelectAttributeOption.value,
type: "singleSelect",
});
expect(responseMultiSelectAttribute).toEqual({
id: multiSelectAttribute.id,
name: multiSelectAttribute.name,
options: [
{
optionId: multiSelectAttributeOption2.id,
option: multiSelectAttributeOption2.value,
},
{
optionId: multiSelectAttributeOption.id,
option: multiSelectAttributeOption.value,
},
],
type: "multiSelect",
});
expect(responseTextAttribute).toEqual({
id: textAttribute.id,
name: textAttribute.name,
optionId: textAttributeOption.id,
option: textAttributeOption.value,
type: "text",
});
}
it("should get all the memberships of the org paginated", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/memberships?skip=1&take=1`)
@@ -182,6 +347,25 @@ describe("Organizations Memberships Endpoints", () => {
});
});
it("should get the membership of the org", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/memberships/${membership2.id}`)
.expect(200)
.then((response) => {
const responseBody: GetOrgMembership = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data.id).toEqual(membership2.id);
expect(responseBody.data.userId).toEqual(user2.id);
expect(responseBody.data.role).toEqual("MEMBER");
expect(responseBody.data.user.bio).toEqual(bio);
expect(responseBody.data.user.metadata).toEqual(metadata);
expect(responseBody.data.user.email).toEqual(user2.email);
expect(responseBody.data.user.username).toEqual(user2.username);
expect(responseBody.data.teamId).toEqual(org.id);
userHasCorrectAttributes(responseBody.data.attributes);
});
});
it("should create the membership of the org", async () => {
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/memberships`)
@@ -259,7 +443,8 @@ describe("Organizations Memberships Endpoints", () => {
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let membershipsRepositoryFixture: MembershipRepositoryFixture;
let membershipRepositoryFixture: MembershipRepositoryFixture;
let attributesRepositoryFixture: AttributeRepositoryFixture;
let org: Team;
let membership: Membership;
@@ -277,7 +462,8 @@ describe("Organizations Memberships Endpoints", () => {
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
membershipRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
attributesRepositoryFixture = new AttributeRepositoryFixture(moduleRef);
user = await userRepositoryFixture.create({
email: userEmail,
@@ -289,7 +475,7 @@ describe("Organizations Memberships Endpoints", () => {
isOrganization: true,
});
membership = await membershipsRepositoryFixture.create({
membership = await membershipRepositoryFixture.create({
role: "MEMBER",
user: { connect: { id: user.id } },
team: { connect: { id: org.id } },
@@ -20,7 +20,6 @@ import { GetAllOrgMemberships } from "@/modules/organizations/memberships/output
import { GetOrgMembership } from "@/modules/organizations/memberships/outputs/get-membership.output";
import { UpdateOrgMembership } from "@/modules/organizations/memberships/outputs/update-membership.output";
import { OrganizationsMembershipService } from "@/modules/organizations/memberships/services/organizations-membership.service";
import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output";
import {
Controller,
UseGuards,
@@ -36,7 +35,6 @@ import {
HttpStatus,
} from "@nestjs/common";
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
import { plainToClass } from "class-transformer";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { SkipTakePagination } from "@calcom/platform-types";
@@ -70,9 +68,7 @@ export class OrganizationsMembershipsController {
);
return {
status: SUCCESS_STATUS,
data: memberships.map((membership) =>
plainToClass(TeamMembershipOutput, membership, { strategy: "excludeAll" })
),
data: memberships,
};
}
@@ -88,7 +84,7 @@ export class OrganizationsMembershipsController {
const membership = await this.organizationsMembershipService.createOrgMembership(orgId, body);
return {
status: SUCCESS_STATUS,
data: plainToClass(TeamMembershipOutput, membership, { strategy: "excludeAll" }),
data: membership,
};
}
@@ -105,7 +101,7 @@ export class OrganizationsMembershipsController {
const membership = await this.organizationsMembershipService.getOrgMembership(orgId, membershipId);
return {
status: SUCCESS_STATUS,
data: plainToClass(TeamMembershipOutput, membership, { strategy: "excludeAll" }),
data: membership,
};
}
@@ -122,7 +118,7 @@ export class OrganizationsMembershipsController {
const membership = await this.organizationsMembershipService.deleteOrgMembership(orgId, membershipId);
return {
status: SUCCESS_STATUS,
data: plainToClass(TeamMembershipOutput, membership, { strategy: "excludeAll" }),
data: membership,
};
}
@@ -144,7 +140,7 @@ export class OrganizationsMembershipsController {
);
return {
status: SUCCESS_STATUS,
data: plainToClass(TeamMembershipOutput, membership, { strategy: "excludeAll" }),
data: membership,
};
}
}
@@ -4,8 +4,31 @@ import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { MembershipUserSelect } from "@/modules/teams/memberships/teams-memberships.repository";
import { Injectable } from "@nestjs/common";
import { Prisma } from "@calcom/prisma/client";
import { UpdateOrgMembershipDto } from "./inputs/update-organization-membership.input";
export type DbOrgMembership = Awaited<ReturnType<OrganizationsMembershipRepository["findOrgMembership"]>>;
const attributeToUserSelect = {
createdByDSyncId: true,
weight: true,
attributeOption: {
select: {
id: true,
value: true,
slug: true,
attribute: {
select: {
id: true,
name: true,
type: true,
},
},
},
},
} as const satisfies Prisma.AttributeToUserSelect;
@Injectable()
export class OrganizationsMembershipRepository {
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
@@ -16,7 +39,12 @@ export class OrganizationsMembershipRepository {
id: membershipId,
teamId: organizationId,
},
include: { user: { select: MembershipUserSelect } },
include: {
user: { select: MembershipUserSelect },
AttributeToUser: {
select: attributeToUserSelect,
},
},
});
}
@@ -26,7 +54,12 @@ export class OrganizationsMembershipRepository {
teamId: organizationId,
userId,
},
include: { user: { select: MembershipUserSelect } },
include: {
user: { select: MembershipUserSelect },
AttributeToUser: {
select: attributeToUserSelect,
},
},
});
}
@@ -36,7 +69,12 @@ export class OrganizationsMembershipRepository {
id: membershipId,
teamId: organizationId,
},
include: { user: { select: MembershipUserSelect } },
include: {
user: { select: MembershipUserSelect },
AttributeToUser: {
select: attributeToUserSelect,
},
},
});
}
@@ -45,14 +83,24 @@ export class OrganizationsMembershipRepository {
create: { ...data, teamId: organizationId },
update: { role: data.role, accepted: data.accepted, disableImpersonation: data.disableImpersonation },
where: { userId_teamId: { userId: data.userId, teamId: organizationId } },
include: { user: { select: MembershipUserSelect } },
include: {
user: { select: MembershipUserSelect },
AttributeToUser: {
select: attributeToUserSelect,
},
},
});
}
async updateOrgMembership(organizationId: number, membershipId: number, data: UpdateOrgMembershipDto) {
return this.dbWrite.prisma.membership.update({
data: { ...data },
where: { id: membershipId, teamId: organizationId },
include: { user: { select: MembershipUserSelect } },
include: {
user: { select: MembershipUserSelect },
AttributeToUser: {
select: attributeToUserSelect,
},
},
});
}
@@ -61,7 +109,12 @@ export class OrganizationsMembershipRepository {
where: {
teamId: organizationId,
},
include: { user: { select: MembershipUserSelect } },
include: {
user: { select: MembershipUserSelect },
AttributeToUser: {
select: attributeToUserSelect,
},
},
skip,
take,
});
@@ -1,4 +1,4 @@
import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output";
import { OrganizationMembershipOutput } from "@/modules/organizations/memberships/outputs/organization-membership.output";
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
@@ -11,10 +11,10 @@ export class CreateOrgMembershipOutput {
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
type: TeamMembershipOutput,
type: OrganizationMembershipOutput,
})
@IsNotEmptyObject()
@ValidateNested()
@Type(() => TeamMembershipOutput)
data!: TeamMembershipOutput;
@Type(() => OrganizationMembershipOutput)
data!: OrganizationMembershipOutput;
}
@@ -1,4 +1,4 @@
import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output";
import { OrganizationMembershipOutput } from "@/modules/organizations/memberships/outputs/organization-membership.output";
import { ApiProperty } from "@nestjs/swagger";
import { IsEnum } from "class-validator";
@@ -9,5 +9,5 @@ export class DeleteOrgMembership {
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
data!: TeamMembershipOutput;
data!: OrganizationMembershipOutput;
}
@@ -1,4 +1,4 @@
import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output";
import { OrganizationMembershipOutput } from "@/modules/organizations/memberships/outputs/organization-membership.output";
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsEnum, IsNotEmptyObject, ValidateNested, IsArray } from "class-validator";
@@ -11,11 +11,11 @@ export class GetAllOrgMemberships {
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
type: TeamMembershipOutput,
type: OrganizationMembershipOutput,
})
@IsNotEmptyObject()
@ValidateNested({ each: true })
@Type(() => TeamMembershipOutput)
@Type(() => OrganizationMembershipOutput)
@IsArray()
data!: TeamMembershipOutput[];
data!: OrganizationMembershipOutput[];
}
@@ -1,4 +1,4 @@
import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output";
import { OrganizationMembershipOutput } from "@/modules/organizations/memberships/outputs/organization-membership.output";
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
@@ -11,10 +11,10 @@ export class GetOrgMembership {
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
type: TeamMembershipOutput,
type: OrganizationMembershipOutput,
})
@IsNotEmptyObject()
@ValidateNested()
@Type(() => TeamMembershipOutput)
data!: TeamMembershipOutput;
@Type(() => OrganizationMembershipOutput)
data!: OrganizationMembershipOutput;
}
@@ -0,0 +1,114 @@
import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output";
import { ApiExtraModels, ApiProperty, getSchemaPath } from "@nestjs/swagger";
import { Expose, Type } from "class-transformer";
import { IsArray, IsNumber, IsOptional, IsString, ValidateNested } from "class-validator";
class BaseAttribute {
@IsString()
@Expose()
@ApiProperty()
id!: string;
@IsString()
@Expose()
@ApiProperty()
name!: string;
}
export class TextAttribute extends BaseAttribute {
@IsString()
@Expose()
@ApiProperty()
type!: "text";
@IsString()
@Expose()
@ApiProperty()
option!: string;
@IsString()
@Expose()
@ApiProperty()
optionId!: string;
}
export class NumberAttribute extends BaseAttribute {
@IsString()
@Expose()
@ApiProperty()
type!: "number";
@IsNumber()
@Expose()
@ApiProperty()
option!: number;
@IsString()
@Expose()
@ApiProperty()
optionId!: string;
}
export class SingleSelectAttribute extends BaseAttribute {
@IsString()
@IsString()
@Expose()
@ApiProperty()
type!: "singleSelect";
@IsString()
@Expose()
@ApiProperty()
option!: string;
@IsString()
@Expose()
@ApiProperty()
optionId!: string;
}
class MultiSelectAttributeOption {
@IsString()
@Expose()
@ApiProperty()
optionId!: string;
@IsString()
@Expose()
@ApiProperty()
option!: string;
}
export class MultiSelectAttribute extends BaseAttribute {
@IsString()
@Expose()
@ApiProperty()
type!: "multiSelect";
@IsArray()
@ValidateNested({ each: true })
@Expose()
@ApiProperty()
options!: MultiSelectAttributeOption[];
}
export type OrgUserAttribute = TextAttribute | NumberAttribute | SingleSelectAttribute | MultiSelectAttribute;
@ApiExtraModels(BaseAttribute, TextAttribute, NumberAttribute, SingleSelectAttribute, MultiSelectAttribute)
export class OrganizationMembershipOutput extends TeamMembershipOutput {
@IsArray()
@ValidateNested({ each: true })
@Expose()
@ApiProperty({
required: true,
oneOf: [
{ $ref: getSchemaPath(TextAttribute) },
{ $ref: getSchemaPath(NumberAttribute) },
{ $ref: getSchemaPath(SingleSelectAttribute) },
{ $ref: getSchemaPath(MultiSelectAttribute) },
],
type: "array",
})
@Type(() => Object)
attributes!: OrgUserAttribute[];
}
@@ -1,4 +1,4 @@
import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output";
import { OrganizationMembershipOutput } from "@/modules/organizations/memberships/outputs/organization-membership.output";
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
@@ -11,10 +11,10 @@ export class UpdateOrgMembership {
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
type: TeamMembershipOutput,
type: OrganizationMembershipOutput,
})
@IsNotEmptyObject()
@ValidateNested()
@Type(() => TeamMembershipOutput)
data!: TeamMembershipOutput;
@Type(() => OrganizationMembershipOutput)
data!: OrganizationMembershipOutput;
}
@@ -0,0 +1,89 @@
import { DbOrgMembership } from "@/modules/organizations/memberships/organizations-membership.repository";
import {
MultiSelectAttribute,
NumberAttribute,
OrganizationMembershipOutput,
OrgUserAttribute,
SingleSelectAttribute,
TextAttribute,
} from "@/modules/organizations/memberships/outputs/organization-membership.output";
import { Injectable } from "@nestjs/common";
import { plainToClass } from "class-transformer";
import { groupMembershipAttributes } from "@calcom/platform-libraries";
import type { GroupedAttribute } from "@calcom/platform-libraries";
@Injectable()
export class OrganizationsMembershipOutputService {
getOrgMembershipsOutput(memberships: NonNullable<DbOrgMembership>[]) {
return memberships.map((membership) => this.getOrgMembershipOutput(membership));
}
getOrgMembershipOutput(organizationMembership: NonNullable<DbOrgMembership>) {
const { AttributeToUser, ...orgMembership } = organizationMembership;
const groupedMembershipAttributes: GroupedAttribute[] = groupMembershipAttributes(AttributeToUser);
const attributesOutput = this.getAttributesOutput(groupedMembershipAttributes);
return plainToClass(OrganizationMembershipOutput, { ...orgMembership, attributes: attributesOutput });
}
private getAttributesOutput(attributes: GroupedAttribute[]): OrgUserAttribute[] {
return attributes.map((attribute) => {
switch (attribute.type) {
case "TEXT":
return this.getTextAttributeOutput(attribute);
case "NUMBER":
return this.getNumberAttributeOutput(attribute);
case "SINGLE_SELECT":
return this.getSingleSelectAttributeOutput(attribute);
case "MULTI_SELECT":
return this.getMultiSelectAttributeOutput(attribute);
default:
return this.getTextAttributeOutput(attribute);
}
});
}
private getTextAttributeOutput(attribute: GroupedAttribute): TextAttribute {
return {
id: attribute.id,
name: attribute.name,
optionId: attribute.options[0].id,
option: attribute.options[0].value,
type: "text",
};
}
private getNumberAttributeOutput(attribute: GroupedAttribute): NumberAttribute {
return {
id: attribute.id,
name: attribute.name,
optionId: attribute.options[0].id,
option: +attribute.options[0].value,
type: "number",
};
}
private getSingleSelectAttributeOutput(attribute: GroupedAttribute): SingleSelectAttribute {
return {
id: attribute.id,
name: attribute.name,
optionId: attribute.options[0].id,
option: attribute.options[0].value,
type: "singleSelect",
};
}
private getMultiSelectAttributeOutput(attribute: GroupedAttribute): MultiSelectAttribute {
return {
id: attribute.id,
name: attribute.name,
options: attribute.options.map((option: GroupedAttribute["options"][number]) => ({
optionId: option.id,
option: option.value,
})),
type: "multiSelect",
};
}
}
@@ -1,19 +1,30 @@
import { CreateOrgMembershipDto } from "@/modules/organizations/memberships/inputs/create-organization-membership.input";
import { OrganizationsMembershipRepository } from "@/modules/organizations/memberships/organizations-membership.repository";
import { Injectable } from "@nestjs/common";
import { Injectable, NotFoundException } from "@nestjs/common";
import { UpdateOrgMembershipDto } from "../inputs/update-organization-membership.input";
import { OrganizationsMembershipOutputService } from "./organizations-membership-output.service";
@Injectable()
export class OrganizationsMembershipService {
constructor(private readonly organizationsMembershipRepository: OrganizationsMembershipRepository) {}
constructor(
private readonly organizationsMembershipRepository: OrganizationsMembershipRepository,
private readonly organizationsMembershipOutputService: OrganizationsMembershipOutputService
) {}
async getOrgMembership(organizationId: number, membershipId: number) {
const membership = await this.organizationsMembershipRepository.findOrgMembership(
organizationId,
membershipId
);
return membership;
if (!membership) {
throw new NotFoundException(
`Membership with id ${membershipId} within organization id ${organizationId} not found`
);
}
return this.organizationsMembershipOutputService.getOrgMembershipOutput(membership);
}
async getOrgMembershipByUserId(organizationId: number, userId: number) {
@@ -21,7 +32,13 @@ export class OrganizationsMembershipService {
organizationId,
userId
);
return membership;
if (!membership) {
throw new NotFoundException(
`Membership for user with id ${userId} within organization id ${organizationId} not found`
);
}
return this.organizationsMembershipOutputService.getOrgMembershipOutput(membership);
}
async getPaginatedOrgMemberships(organizationId: number, skip = 0, take = 250) {
@@ -30,7 +47,7 @@ export class OrganizationsMembershipService {
skip,
take
);
return memberships;
return this.organizationsMembershipOutputService.getOrgMembershipsOutput(memberships);
}
async deleteOrgMembership(organizationId: number, membershipId: number) {
@@ -38,7 +55,7 @@ export class OrganizationsMembershipService {
organizationId,
membershipId
);
return membership;
return this.organizationsMembershipOutputService.getOrgMembershipOutput(membership);
}
async updateOrgMembership(organizationId: number, membershipId: number, data: UpdateOrgMembershipDto) {
@@ -47,11 +64,11 @@ export class OrganizationsMembershipService {
membershipId,
data
);
return membership;
return this.organizationsMembershipOutputService.getOrgMembershipOutput(membership);
}
async createOrgMembership(organizationId: number, data: CreateOrgMembershipDto) {
const membership = await this.organizationsMembershipRepository.createOrgMembership(organizationId, data);
return membership;
return this.organizationsMembershipOutputService.getOrgMembershipOutput(membership);
}
}
@@ -32,6 +32,7 @@ import { OrganizationsRepository } from "@/modules/organizations/index/organizat
import { OrganizationsService } from "@/modules/organizations/index/organizations.service";
import { OrganizationsMembershipsController } from "@/modules/organizations/memberships/organizations-membership.controller";
import { OrganizationsMembershipRepository } from "@/modules/organizations/memberships/organizations-membership.repository";
import { OrganizationsMembershipOutputService } from "@/modules/organizations/memberships/services/organizations-membership-output.service";
import { OrganizationsMembershipService } from "@/modules/organizations/memberships/services/organizations-membership.service";
import { OrganizationsOrganizationsModule } from "@/modules/organizations/organizations/organizations-organizations.module";
import { OrganizationsSchedulesController } from "@/modules/organizations/schedules/organizations-schedules.controller";
@@ -98,6 +99,7 @@ import { Module } from "@nestjs/common";
EmailService,
OrganizationsMembershipRepository,
OrganizationsMembershipService,
OrganizationsMembershipOutputService,
OrganizationsEventTypesService,
InputOrganizationsEventTypesService,
OutputOrganizationsEventTypesService,
@@ -4,6 +4,7 @@ import { MembershipsModule } from "@/modules/memberships/memberships.module";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
import { OrganizationsMembershipRepository } from "@/modules/organizations/memberships/organizations-membership.repository";
import { OrganizationsMembershipOutputService } from "@/modules/organizations/memberships/services/organizations-membership-output.service";
import { OrganizationsMembershipService } from "@/modules/organizations/memberships/services/organizations-membership.service";
import { ManagedOrganizationsRepository } from "@/modules/organizations/organizations/managed-organizations.repository";
import { OrganizationsOrganizationsController } from "@/modules/organizations/organizations/organizations-organizations.controller";
@@ -23,6 +24,7 @@ import { Module } from "@nestjs/common";
ManagedOrganizationsBillingService,
OrganizationsRepository,
OrganizationsMembershipService,
OrganizationsMembershipOutputService,
OrganizationsMembershipRepository,
ManagedOrganizationsOutputService,
],
@@ -126,36 +126,6 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
await app.init();
});
afterAll(async () => {
await prismaWriteService.prisma.app_RoutingForms_FormResponse.deleteMany({
where: {
formId: routingForm.id,
},
});
await prismaWriteService.prisma.app_RoutingForms_Form.deleteMany({
where: {
teamId: org.id,
},
});
await prismaWriteService.prisma.apiKey.deleteMany({
where: {
teamId: org.id,
},
});
await prismaWriteService.prisma.team.delete({
where: {
id: team.id,
},
});
await prismaWriteService.prisma.team.delete({
where: {
id: org.id,
},
});
await app.close();
});
describe(`GET /v2/organizations/:orgId/routing-forms/:routingFormId/responses`, () => {
it("should not get routing form responses for non existing org", async () => {
return request(app.getHttpServer())
@@ -261,4 +231,44 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
});
});
});
afterAll(async () => {
await prismaWriteService.prisma.app_RoutingForms_FormResponse.delete({
where: {
id: routingFormResponse.id,
},
});
await prismaWriteService.prisma.app_RoutingForms_FormResponse.delete({
where: {
id: routingFormResponse2.id,
},
});
await prismaWriteService.prisma.app_RoutingForms_Form.deleteMany({
where: {
teamId: org.id,
},
});
await prismaWriteService.prisma.apiKey.deleteMany({
where: {
teamId: org.id,
},
});
await prismaWriteService.prisma.team.delete({
where: {
id: team.id,
},
});
await prismaWriteService.prisma.team.delete({
where: {
id: org.id,
},
});
await prismaWriteService.prisma.user.delete({
where: {
id: user.id,
},
});
await app.close();
});
});
@@ -1,13 +1,18 @@
import { BookingUidGuard } from "@/ee/bookings/2024-08-13/guards/booking-uid.guard";
import { BookingReferencesFilterInput_2024_08_13 } from "@/ee/bookings/2024-08-13/inputs/booking-references-filter.input";
import { BookingReferencesOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/booking-references.output";
import { BookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-references.service";
import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service";
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import {
OPTIONAL_X_CAL_CLIENT_ID_HEADER,
OPTIONAL_X_CAL_SECRET_KEY_HEADER,
OPTIONAL_API_KEY_HEADER,
API_KEY_OR_ACCESS_TOKEN_HEADER,
} from "@/lib/docs/headers";
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
@@ -20,7 +25,7 @@ import { UserWithProfile } from "@/modules/users/users.repository";
import { Controller, UseGuards, Get, Param, ParseIntPipe, Query, HttpStatus, HttpCode } from "@nestjs/common";
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { SUCCESS_STATUS, BOOKING_READ } from "@calcom/platform-constants";
import { GetBookingsOutput_2024_08_13 } from "@calcom/platform-types";
@Controller({
@@ -33,7 +38,10 @@ import { GetBookingsOutput_2024_08_13 } from "@calcom/platform-types";
@ApiHeader(OPTIONAL_X_CAL_SECRET_KEY_HEADER)
@ApiHeader(OPTIONAL_API_KEY_HEADER)
export class OrganizationsTeamsBookingsController {
constructor(private readonly bookingsService: BookingsService_2024_08_13) {}
constructor(
private readonly bookingsService: BookingsService_2024_08_13,
private readonly bookingReferencesService: BookingReferencesService_2024_08_13
) {}
@Get("/")
@ApiOperation({ summary: "Get organization team bookings" })
@@ -46,7 +54,7 @@ export class OrganizationsTeamsBookingsController {
@Param("orgId", ParseIntPipe) orgId: number,
@GetUser() user: UserWithProfile
): Promise<GetBookingsOutput_2024_08_13> {
const bookings = await this.bookingsService.getBookings(
const { bookings, pagination } = await this.bookingsService.getBookings(
{ ...queryParams, teamId },
{ email: user.email, id: user.id, orgId }
);
@@ -54,6 +62,36 @@ export class OrganizationsTeamsBookingsController {
return {
status: SUCCESS_STATUS,
data: bookings,
pagination,
};
}
@Get("/:bookingUid/references")
@PlatformPlan("SCALE")
@Roles("TEAM_ADMIN")
@Permissions([BOOKING_READ])
@UseGuards(
ApiAuthGuard,
BookingUidGuard,
IsOrgGuard,
RolesGuard,
IsTeamInOrg,
PlatformPlanGuard,
IsAdminAPIEnabledGuard
)
@ApiOperation({
summary: "Get 'Booking References' for a booking",
})
@HttpCode(HttpStatus.OK)
async getBookingReferences(
@Param("bookingUid") bookingUid: string,
@Query() filter: BookingReferencesFilterInput_2024_08_13
): Promise<BookingReferencesOutput_2024_08_13> {
const bookingReferences = await this.bookingReferencesService.getOrgBookingReferences(bookingUid, filter);
return {
status: SUCCESS_STATUS,
data: bookingReferences,
};
}
}
@@ -1,4 +1,8 @@
import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/booking-references.repository";
import { BookingsModule_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.module";
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
import { BookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-references.service";
import { OutputBookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output-booking-references.service";
import { MembershipsModule } from "@/modules/memberships/memberships.module";
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
import { OrganizationsTeamsBookingsController } from "@/modules/organizations/teams/bookings/organizations-teams-bookings.controller";
@@ -10,7 +14,14 @@ import { Module } from "@nestjs/common";
@Module({
imports: [BookingsModule_2024_08_13, PrismaModule, StripeModule, RedisModule, MembershipsModule],
providers: [OrganizationsRepository, OrganizationsTeamsRepository],
providers: [
OrganizationsRepository,
OrganizationsTeamsRepository,
BookingReferencesService_2024_08_13,
BookingReferencesRepository_2024_08_13,
BookingsRepository_2024_08_13,
OutputBookingReferencesService_2024_08_13,
],
controllers: [OrganizationsTeamsBookingsController],
})
export class OrganizationsTeamsBookingsModule {}
@@ -69,7 +69,11 @@ export class OrganizationsTeamsMembershipsRepository {
async createOrgTeamMembership(teamId: number, data: CreateOrgTeamMembershipDto) {
return this.dbWrite.prisma.membership.create({
data: { ...data, teamId: teamId },
data: {
createdAt: new Date(),
...data,
teamId: teamId,
},
include: { user: { select: MembershipUserSelect } },
});
}
@@ -35,7 +35,7 @@ describe("Organizations Teams Routing Forms Responses", () => {
let org: Team;
let orgTeam: Team;
let routingFormResponseId: string;
let routingFormResponseId: number;
const authEmail = `organizations-teams-routing-forms-responses-user-${randomString()}@api.com`;
let user: User;
let apiKeyString: string;
@@ -43,8 +43,7 @@ describe("Organizations Teams Routing Forms Responses", () => {
let routingFormId: string;
const routingFormResponses = [
{
id: 1,
formFillerId: "cm78tvkvd0001kh8jq0tu5iq9",
formFillerId: `${randomString()}`,
response: {
"participant-field": {
label: "participant",
@@ -184,7 +183,6 @@ describe("Organizations Teams Routing Forms Responses", () => {
const responseData = responseBody.data;
expect(responseData).toBeDefined();
expect(responseData.length).toEqual(1);
expect(responseData[0].id).toEqual(routingFormResponses[0].id);
expect(responseData[0].response).toEqual(routingFormResponses[0].response);
expect(responseData[0].formFillerId).toEqual(routingFormResponses[0].formFillerId);
expect(responseData[0].createdAt).toEqual(routingFormResponses[0].createdAt.toISOString());
@@ -248,6 +246,8 @@ describe("Organizations Teams Routing Forms Responses", () => {
});
afterAll(async () => {
await routingFormsRepositoryFixture.deleteResponse(routingFormResponseId);
await routingFormsRepositoryFixture.delete(routingFormId);
await userRepositoryFixture.deleteByEmail(user.email);
await organizationsRepositoryFixture.delete(org.id);
await app.close();
@@ -6,6 +6,7 @@ import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { IsRoutingFormInTeam } from "@/modules/auth/guards/routing-forms/is-routing-form-in-team.guard";
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
import { GetRoutingFormResponsesParams } from "@/modules/organizations/routing-forms/inputs/get-routing-form-responses-params.input";
@@ -29,6 +30,7 @@ import { SUCCESS_STATUS } from "@calcom/platform-constants";
IsTeamInOrg,
IsRoutingFormInTeam,
PlatformPlanGuard,
RolesGuard,
IsAdminAPIEnabledGuard
)
@ApiHeader(API_KEY_HEADER)
@@ -0,0 +1,236 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
import { RequestEmailVerificationInput } from "@/modules/verified-resources/inputs/request-email-verification.input";
import { VerifyEmailInput } from "@/modules/verified-resources/inputs/verify-email.input";
import {
TeamVerifiedEmailOutput,
TeamVerifiedEmailsOutput,
} from "@/modules/verified-resources/outputs/verified-email.output";
import {
TeamVerifiedPhoneOutput,
TeamVerifiedPhonesOutput,
} from "@/modules/verified-resources/outputs/verified-phone.output";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import { TOTP as TOTPtoMock } from "@otplib/core";
import { User } from "@prisma/client";
import { totp } from "otplib";
import * as request from "supertest";
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { VerifiedResourcesRepositoryFixtures } from "test/fixtures/repository/verified-resources.repository.fixture";
import { randomString } from "test/utils/randomString";
import { AttendeeVerifyEmail } from "@calcom/platform-libraries/emails";
import { Team } from "@calcom/prisma/client";
jest.spyOn(totp, "generate").mockImplementation(function () {
return "1234";
});
jest.spyOn(TOTPtoMock.prototype, "check").mockImplementation(function () {
return true;
});
jest
.spyOn(AttendeeVerifyEmail.prototype as any, "getNodeMailerPayload")
.mockImplementation(async function () {
return {
to: `testnotrealemail@notreal.com`,
from: `testnotrealemail@notreal.com`,
subject: "No Subject",
html: "<html><body>Mocked Email Content</body></html>",
text: "body",
};
});
describe("Organizations Teams Verified Resources", () => {
let app: INestApplication;
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let verifiedResourcesRepositoryFixtures: VerifiedResourcesRepositoryFixtures;
let teamsRepositoryFixture: TeamRepositoryFixture;
let profileRepositoryFixture: ProfileRepositoryFixture;
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
let membershipsRepositoryFixture: MembershipRepositoryFixture;
let org: Team;
let orgTeam: Team;
const emailToVerify = `org-team-e2e-verified-resources-${randomString()}@example.com`;
const phoneToVerify = "+37255556666";
const authEmail = `organizations-verified-resources-responses-user-${randomString()}@api.com`;
let user: User;
let apiKeyString: string;
let verifiedEmailId: number;
let verifiedPhoneId: number;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
}).compile();
verifiedResourcesRepositoryFixtures = new VerifiedResourcesRepositoryFixtures(moduleRef);
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
org = await organizationsRepositoryFixture.create({
name: `organizations-verified-resources-responses-organization-${randomString()}`,
isOrganization: true,
});
user = await userRepositoryFixture.create({
email: authEmail,
username: authEmail,
});
const { keyString } = await apiKeysRepositoryFixture.createApiKey(user.id, null);
apiKeyString = keyString;
orgTeam = await teamsRepositoryFixture.create({
name: `organizations-verified-resources-responses-team-${randomString()}`,
isOrganization: false,
parent: { connect: { id: org.id } },
});
await membershipsRepositoryFixture.create({
role: "ADMIN",
user: { connect: { id: user.id } },
team: { connect: { id: org.id } },
});
await membershipsRepositoryFixture.create({
role: "ADMIN",
user: { connect: { id: user.id } },
team: { connect: { id: orgTeam.id } },
});
await profileRepositoryFixture.create({
uid: `usr-${user.id}`,
username: authEmail,
organization: {
connect: {
id: org.id,
},
},
user: {
connect: {
id: user.id,
},
},
});
verifiedPhoneId = (
await verifiedResourcesRepositoryFixtures.createPhone({
phoneNumber: phoneToVerify,
user: { connect: { id: user.id } },
team: {
connect: {
id: orgTeam.id,
},
},
})
).id;
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
it("should trigger email verification code", async () => {
return request(app.getHttpServer())
.post(
`/v2/organizations/${org.id}/teams/${orgTeam.id}/verified-resources/emails/verification-code/request`
)
.send({ email: emailToVerify } satisfies RequestEmailVerificationInput)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.expect(200);
});
it("should verify email", async () => {
return request(app.getHttpServer())
.post(
`/v2/organizations/${org.id}/teams/${orgTeam.id}/verified-resources/emails/verification-code/verify`
)
.send({ email: emailToVerify, code: "1234" } satisfies VerifyEmailInput)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.expect(200)
.then((res) => {
const response = res.body as TeamVerifiedEmailOutput;
verifiedEmailId = response.data.id;
expect(response.data.email).toEqual(emailToVerify);
expect(response.data.teamId).toEqual(orgTeam.id);
});
});
it("should fetch verified email by id", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/teams/${orgTeam.id}/verified-resources/emails/${verifiedEmailId}`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.expect(200)
.then((res) => {
const response = res.body as TeamVerifiedEmailOutput;
expect(response.data.email).toEqual(emailToVerify);
expect(response.data.teamId).toEqual(orgTeam.id);
});
});
it("should fetch verified number by id", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/teams/${orgTeam.id}/verified-resources/phones/${verifiedPhoneId}`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.expect(200)
.then((res) => {
const response = res.body as TeamVerifiedPhoneOutput;
expect(response.data.phoneNumber).toEqual(phoneToVerify);
expect(response.data.teamId).toEqual(orgTeam.id);
});
});
it("should fetch verified emails", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/teams/${orgTeam.id}/verified-resources/emails`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.expect(200)
.then((res) => {
const response = res.body as TeamVerifiedEmailsOutput;
expect(response.data.find((verifiedEmail) => verifiedEmail.email === emailToVerify)).toBeDefined();
expect(response.data.find((verifiedEmail) => verifiedEmail.teamId === orgTeam.id)).toBeDefined();
});
});
it("should fetch verified phones", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/teams/${orgTeam.id}/verified-resources/phones`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.expect(200)
.then((res) => {
const response = res.body as TeamVerifiedPhonesOutput;
expect(
response.data.find((verifiedPhone) => verifiedPhone.phoneNumber === phoneToVerify)
).toBeDefined();
expect(response.data.find((verifiedPhone) => verifiedPhone.teamId === orgTeam.id)).toBeDefined();
});
});
afterAll(async () => {
await verifiedResourcesRepositoryFixtures.deleteEmailById(verifiedEmailId);
await verifiedResourcesRepositoryFixtures.deletePhoneById(verifiedPhoneId);
await userRepositoryFixture.deleteByEmail(user.email);
await organizationsRepositoryFixture.delete(org.id);
await app.close();
});
});
@@ -0,0 +1,256 @@
import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
import { Throttle } from "@/lib/endpoint-throttler-decorator";
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
import { RequestEmailVerificationInput } from "@/modules/verified-resources/inputs/request-email-verification.input";
import { RequestPhoneVerificationInput } from "@/modules/verified-resources/inputs/request-phone-verification.input";
import { VerifyEmailInput } from "@/modules/verified-resources/inputs/verify-email.input";
import { VerifyPhoneInput } from "@/modules/verified-resources/inputs/verify-phone.input";
import { RequestEmailVerificationOutput } from "@/modules/verified-resources/outputs/request-email-verification-output";
import { RequestPhoneVerificationOutput } from "@/modules/verified-resources/outputs/request-phone-verification-output";
import {
TeamVerifiedEmailOutput,
TeamVerifiedEmailOutputData,
TeamVerifiedEmailsOutput,
} from "@/modules/verified-resources/outputs/verified-email.output";
import {
TeamVerifiedPhoneOutput,
TeamVerifiedPhoneOutputData,
TeamVerifiedPhonesOutput,
} from "@/modules/verified-resources/outputs/verified-phone.output";
import { VerifiedResourcesService } from "@/modules/verified-resources/services/verified-resources.service";
import {
Body,
Controller,
Get,
HttpCode,
HttpStatus,
Param,
ParseIntPipe,
Post,
Query,
UseGuards,
} from "@nestjs/common";
import { ApiHeader, ApiOperation, ApiTags } from "@nestjs/swagger";
import { plainToClass } from "class-transformer";
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
import { SkipTakePagination } from "@calcom/platform-types";
@Controller({
path: "/v2/organizations/:orgId/teams/:teamId/verified-resources",
})
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
@ApiTags("Organization Team Verified Resources")
export class OrgTeamsVerifiedResourcesController {
constructor(private readonly verifiedResourcesService: VerifiedResourcesService) {}
@ApiOperation({
summary: "Request Email Verification Code",
description: `Sends a verification code to the email.`,
})
@Roles("TEAM_ADMIN")
@Throttle({
limit: 3,
ttl: 60000,
blockDuration: 60000,
name: "org_teams_verified_resources_emails_requests",
})
@PlatformPlan("ESSENTIALS")
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@Post("/emails/verification-code/request")
@HttpCode(HttpStatus.OK)
async requestEmailVerificationCode(
@Body() body: RequestEmailVerificationInput,
@GetUser("username") username: string,
@GetUser("locale") locale: string
): Promise<RequestEmailVerificationOutput> {
const verificationCodeRequest = await this.verifiedResourcesService.requestEmailVerificationCode(
{ username, locale },
body.email
);
return {
status: verificationCodeRequest ? SUCCESS_STATUS : ERROR_STATUS,
};
}
@ApiOperation({
summary: "Request Phone Number Verification Code",
description: `Sends a verification code to the phone number.`,
})
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@Roles("TEAM_ADMIN")
@Throttle({
limit: 3,
ttl: 60000,
blockDuration: 60000,
name: "org_teams_verified_resources_phones_requests",
})
@Post("/phones/verification-code/request")
@HttpCode(HttpStatus.OK)
async requestPhoneVerificationCode(
@Body() body: RequestPhoneVerificationInput
): Promise<RequestPhoneVerificationOutput> {
const verificationCodeRequest = await this.verifiedResourcesService.requestPhoneVerificationCode(
body.phone
);
return {
status: verificationCodeRequest ? SUCCESS_STATUS : ERROR_STATUS,
};
}
@ApiOperation({
summary: "Verify an email for an org team.",
description: `Use code to verify an email`,
})
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@Post("/emails/verification-code/verify")
@HttpCode(HttpStatus.OK)
async verifyEmail(
@Body() body: VerifyEmailInput,
@GetUser("id") userId: number,
@Param("teamId", ParseIntPipe) teamId: number
): Promise<TeamVerifiedEmailOutput> {
const verifiedEmail = await this.verifiedResourcesService.verifyEmail(
userId,
body.email,
body.code,
teamId
);
return {
status: SUCCESS_STATUS,
data: plainToClass(TeamVerifiedEmailOutputData, verifiedEmail),
};
}
@ApiOperation({
summary: "Verify a phone number for an org team.",
description: `Use code to verify a phone number`,
})
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@Post("/phones/verification-code/verify")
@Throttle({
limit: 3,
ttl: 60000,
blockDuration: 60000,
name: "org_teams_verified_resources_phones_verify",
})
@HttpCode(HttpStatus.OK)
async verifyPhoneNumber(
@Body() body: VerifyPhoneInput,
@GetUser("id") userId: number,
@Param("teamId", ParseIntPipe) teamId: number
): Promise<TeamVerifiedPhoneOutput> {
const verifiedPhone = await this.verifiedResourcesService.verifyPhone(
userId,
body.phone,
body.code,
teamId
);
return {
status: SUCCESS_STATUS,
data: plainToClass(TeamVerifiedPhoneOutputData, verifiedPhone),
};
}
@ApiOperation({
summary: "Get list of verified emails of an org team.",
})
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@Get("/emails")
@HttpCode(HttpStatus.OK)
async getVerifiedEmails(
@Param("teamId", ParseIntPipe) teamId: number,
@Query() pagination: SkipTakePagination
): Promise<TeamVerifiedEmailsOutput> {
const verifiedEmails = await this.verifiedResourcesService.getTeamVerifiedEmails(
teamId,
pagination?.skip ?? 0,
pagination?.take ?? 250
);
return {
status: SUCCESS_STATUS,
data: verifiedEmails.map((verifiedEmail) => plainToClass(TeamVerifiedEmailOutputData, verifiedEmail)),
};
}
@ApiOperation({
summary: "Get list of verified phone numbers of an org team.",
})
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@PlatformPlan("ESSENTIALS")
@Get("/phones")
@Roles("TEAM_ADMIN")
@HttpCode(HttpStatus.OK)
async getVerifiedPhoneNumbers(
@Param("teamId", ParseIntPipe) teamId: number,
@Query() pagination: SkipTakePagination
): Promise<TeamVerifiedPhonesOutput> {
const verifiedPhoneNumbers = await this.verifiedResourcesService.getTeamVerifiedPhoneNumbers(
teamId,
pagination?.skip ?? 0,
pagination?.take ?? 250
);
return {
status: SUCCESS_STATUS,
data: verifiedPhoneNumbers.map((verifiedPhoneNumber) =>
plainToClass(TeamVerifiedPhoneOutputData, verifiedPhoneNumber)
),
};
}
@ApiOperation({
summary: "Get verified email of an org team by id.",
})
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@Get("/emails/:id")
@HttpCode(HttpStatus.OK)
async getVerifiedEmailById(
@Param("id") id: number,
@Param("teamId", ParseIntPipe) teamId: number
): Promise<TeamVerifiedEmailOutput> {
const verifiedEmail = await this.verifiedResourcesService.getTeamVerifiedEmailById(teamId, id);
return {
status: SUCCESS_STATUS,
data: plainToClass(TeamVerifiedEmailOutputData, verifiedEmail),
};
}
@ApiOperation({
summary: "Get verified phone number of an org team by id.",
})
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@Get("/phones/:id")
@HttpCode(HttpStatus.OK)
async getVerifiedPhoneById(
@Param("teamId", ParseIntPipe) teamId: number,
@Param("id") id: number
): Promise<TeamVerifiedPhoneOutput> {
const verifiedPhoneNumber = await this.verifiedResourcesService.getTeamVerifiedPhoneNumberById(
teamId,
id
);
return {
status: SUCCESS_STATUS,
data: plainToClass(TeamVerifiedPhoneOutputData, verifiedPhoneNumber),
};
}
}
@@ -40,7 +40,7 @@ export class OrganizationsUsersBookingsController {
@Param("userId", ParseIntPipe) userId: number,
@Query() query: GetBookingsInput_2024_08_13
): Promise<GetBookingsOutput_2024_08_13> {
const bookings = await this.organizationUsersBookingsService.getOrganizationUserBookings(
const { bookings, pagination } = await this.organizationUsersBookingsService.getOrganizationUserBookings(
orgId,
userId,
query
@@ -49,6 +49,7 @@ export class OrganizationsUsersBookingsController {
return {
status: SUCCESS_STATUS,
data: bookings,
pagination,
};
}
}
@@ -29,6 +29,7 @@ import {
RecurringBookingOutput_2024_08_13,
GetBookingsOutput_2024_08_13,
GetSeatedBookingOutput_2024_08_13,
PaginationMetaDto,
} from "@calcom/platform-types";
import { PlatformOAuthClient, Team } from "@calcom/prisma/client";
@@ -373,17 +374,26 @@ describe("Organizations UsersBookings Endpoints 2024-08-13", () => {
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.pagination).toBeDefined();
const data: (
| BookingOutput_2024_08_13
| RecurringBookingOutput_2024_08_13
| GetSeatedBookingOutput_2024_08_13
)[] = responseBody.data;
const pagination: PaginationMetaDto = responseBody.pagination;
expect(data.length).toEqual(3);
expect(data.find((booking) => booking.id === createdPersonalBooking.id)).toBeDefined();
expect(data.find((booking) => booking.id === createdTeamBooking.id)).toBeDefined();
expect(
data.find((booking) => booking.id === createdPersonalBookingUsingUsername.id)
).toBeDefined();
expect(pagination.totalItems).toEqual(3);
expect(pagination.remainingItems).toEqual(0);
expect(pagination.hasNextPage).toEqual(false);
expect(pagination.hasPreviousPage).toEqual(false);
expect(pagination.itemsPerPage).toEqual(100);
expect(pagination.totalPages).toEqual(1);
expect(pagination.currentPage).toEqual(1);
});
});
});
@@ -38,7 +38,7 @@ export class OrganizationsUsersRepository {
member: {
teamId: orgId,
...(teamIds && { user: { teams: { some: { teamId: { in: teamIds } } } } }),
// Filter to only get users which have ALL of the assigned attribue options
// Filter to only get users which have ALL of the assigned attribute options
...(attributeQueryOperator === "AND" && {
AND: assignedOptionIds.map((optionId) => ({
AttributeToUser: { some: { attributeOptionId: optionId } },
@@ -46,11 +46,11 @@ export class OrganizationsUsersRepository {
}),
},
...(emailArray && emailArray.length ? { email: { in: emailArray } } : {}),
// Filter to get users which have AT LEAST ONE of the assigned attribue options
// Filter to get users which have AT LEAST ONE of the assigned attribute options
...(attributeQueryOperator === "OR" && {
attributeOption: { id: { in: assignedOptionIds } },
}),
// Filter to get users that have NONE the assigned attribue options
// Filter to get users that have NONE the assigned attribute options
...(attributeQueryOperator === "NONE" && {
NOT: {
attributeOption: { id: { in: assignedOptionIds } },
@@ -141,7 +141,16 @@ export class OrganizationsUsersRepository {
return await this.dbWrite.prisma.user.delete({
where: {
id: userId,
organizationId: orgId,
OR: [
{ organizationId: orgId },
{
profiles: {
some: {
organizationId: orgId,
},
},
},
],
},
include: {
profiles: {
@@ -4,6 +4,7 @@ import { AppsRepository } from "@/modules/apps/apps.repository";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { OrganizationsDelegationCredentialRepository } from "@/modules/organizations/delegation-credentials/organizations-delegation-credential.repository";
import { OrganizationsMembershipRepository } from "@/modules/organizations/memberships/organizations-membership.repository";
import { OrganizationsMembershipOutputService } from "@/modules/organizations/memberships/services/organizations-membership-output.service";
import { OrganizationsMembershipService } from "@/modules/organizations/memberships/services/organizations-membership.service";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { SelectedCalendarsController } from "@/modules/selected-calendars/controllers/selected-calendars.controller";
@@ -22,6 +23,7 @@ import { Module } from "@nestjs/common";
CredentialsRepository,
AppsRepository,
OrganizationsMembershipService,
OrganizationsMembershipOutputService,
OrganizationsDelegationCredentialRepository,
OrganizationsMembershipRepository,
SelectedCalendarsService,
@@ -1,7 +1,7 @@
import { SlotsOutputService_2024_04_15 } from "@/modules/slots/slots-2024-04-15/services/slots-output.service";
import type { RangeSlots, TimeSlots } from "@/modules/slots/slots-2024-04-15/services/slots-output.service";
import { SlotsService_2024_04_15 } from "@/modules/slots/slots-2024-04-15/services/slots.service";
import { Query, Body, Controller, Get, Delete, Post, Req, Res } from "@nestjs/common";
import { Query, Body, Controller, Get, Delete, Post, Req, Res, BadRequestException } from "@nestjs/common";
import { ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
import { ApiTags as DocsTags, ApiCreatedResponse, ApiOkResponse, ApiOperation } from "@nestjs/swagger";
import { Response as ExpressResponse, Request as ExpressRequest } from "express";
@@ -157,7 +157,11 @@ export class SlotsController_2024_04_15 {
@Query() query: GetAvailableSlotsInput_2024_04_15,
@Req() req: ExpressRequest
): Promise<ApiResponse<{ slots: TimeSlots["slots"] | RangeSlots["slots"] }>> {
const isTeamEvent = await this.slotsService.checkIfIsTeamEvent(query.eventTypeId);
try {
const isTeamEvent =
query.isTeamEvent === undefined
? await this.slotsService.checkIfIsTeamEvent(query.eventTypeId)
: query.isTeamEvent;
const availableSlots = await getAvailableSlots({
input: {
...query,
@@ -168,19 +172,29 @@ export class SlotsController_2024_04_15 {
},
});
const { slots } = await this.slotsOutputService.getOutputSlots(
availableSlots,
query.duration,
query.eventTypeId,
query.slotFormat,
query.timeZone
);
const { slots } = await this.slotsOutputService.getOutputSlots(
availableSlots,
query.duration,
query.eventTypeId,
query.slotFormat,
query.timeZone
);
return {
data: {
slots,
},
status: SUCCESS_STATUS,
};
return {
data: {
slots,
},
status: SUCCESS_STATUS,
};
} catch (error) {
if (error instanceof Error) {
if (error.message.includes("Invalid time range given")) {
throw new BadRequestException(
"Invalid time range given - check the 'startTime' and 'endTime' query parameters."
);
}
}
throw error;
}
}
}
@@ -181,7 +181,7 @@ describe("Slots 2024-09-04 Endpoints", () => {
title: "frisbee match",
slug: `slots-2024-09-04-variable-length-event-type-${randomString()}`,
length: 15,
metadata: { multipleDuration: [15, 30, 45, 60] },
metadata: { multipleDuration: [15, 30, 45, 60, 180] },
},
user.id
);
@@ -506,6 +506,65 @@ describe("Slots 2024-09-04 Endpoints", () => {
expect(reserveResponseBody.data).toEqual(rest);
});
describe("overlapping slot reservations", () => {
it("start of request slot overlaps already existing reserved slot", async () => {
// Try to reserve 10:15-11:15 when 10:00-11:00 is taken (60 min event)
const newSlotStart = DateTime.fromISO(reservedSlot.slotStart).plus({ minutes: 15 }).toISO();
await request(app.getHttpServer())
.post(`/v2/slots/reservations`)
.send({
eventTypeId,
slotStart: newSlotStart,
})
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
.expect(422)
.then((response) => {
expect(response.body.error.message).toEqual(
"This time slot is already reserved by another user. Please choose a different time."
);
});
});
it("end of request slot overlaps already existing reserved slot", async () => {
// Try to reserve 9:45-10:45 when 10:00-11:00 is taken (60 min event)
const newSlotStart = DateTime.fromISO(reservedSlot.slotStart).minus({ minutes: 15 }).toISO();
await request(app.getHttpServer())
.post(`/v2/slots/reservations`)
.send({
eventTypeId,
slotStart: newSlotStart,
})
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
.expect(422)
.then((response) => {
expect(response.body.error.message).toEqual(
"This time slot is already reserved by another user. Please choose a different time."
);
});
});
it("request slot is inside already existing reserved slot", async () => {
// Try to reserve 10:10-11:10 when 10:00-11:00 is taken (60 min event)
const newSlotStart = DateTime.fromISO(reservedSlot.slotStart).plus({ minutes: 10 }).toISO();
await request(app.getHttpServer())
.post(`/v2/slots/reservations`)
.send({
eventTypeId,
slotStart: newSlotStart,
})
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
.expect(422)
.then((response) => {
expect(response.body.error.message).toEqual(
"This time slot is already reserved by another user. Please choose a different time."
);
});
});
});
it("should update a reserved slot and it should not appear in available slots", async () => {
// note(Lauris): mock current date to test slots release time
const now = "2049-09-05T14:00:00.000Z";
@@ -1266,6 +1325,7 @@ describe("Slots 2024-09-04 Endpoints", () => {
});
describe("variable length", () => {
let responseReservedVariableSlot: ReserveSlotOutputData_2024_09_04;
it("should not be able to reserve a slot for variable length event type with invalid duration", async () => {
const slotStartTime = "2050-09-05T10:00:00.000Z";
const reserveResponse = await request(app.getHttpServer())
@@ -1279,7 +1339,7 @@ describe("Slots 2024-09-04 Endpoints", () => {
.expect(400);
expect(reserveResponse.body.error.message).toEqual(
"Provided 'slotDuration' is not one of the possible lengths for the event type. The possible lengths for this variable length event type are: 15, 30, 45, 60"
"Provided 'slotDuration' is not one of the possible lengths for the event type. The possible lengths for this variable length event type are: 15, 30, 45, 60, 180"
);
});
@@ -1303,17 +1363,17 @@ describe("Slots 2024-09-04 Endpoints", () => {
const reserveResponseBody: ReserveSlotOutputResponse_2024_09_04 = reserveResponse.body;
expect(reserveResponseBody.status).toEqual(SUCCESS_STATUS);
const responseReservedSlot: ReserveSlotOutputData_2024_09_04 = reserveResponseBody.data;
expect(responseReservedSlot.reservationUid).toBeDefined();
expect(responseReservedSlot.eventTypeId).toEqual(variableLengthEventType.id);
expect(responseReservedSlot.slotStart).toEqual(slotStartTime);
expect(responseReservedSlot.slotDuration).toEqual(slotDuration);
expect(responseReservedSlot.slotEnd).toEqual(
responseReservedVariableSlot = reserveResponseBody.data;
expect(responseReservedVariableSlot.reservationUid).toBeDefined();
expect(responseReservedVariableSlot.eventTypeId).toEqual(variableLengthEventType.id);
expect(responseReservedVariableSlot.slotStart).toEqual(slotStartTime);
expect(responseReservedVariableSlot.slotDuration).toEqual(slotDuration);
expect(responseReservedVariableSlot.slotEnd).toEqual(
DateTime.fromISO(slotStartTime, { zone: "UTC" }).plus({ minutes: slotDuration }).toISO()
);
expect(responseReservedSlot.reservationDuration).toEqual(5);
expect(responseReservedVariableSlot.reservationDuration).toEqual(5);
if (!responseReservedSlot.reservationUid) {
if (!responseReservedVariableSlot.reservationUid) {
throw new Error("Reserved slot uid is undefined");
}
@@ -1337,16 +1397,41 @@ describe("Slots 2024-09-04 Endpoints", () => {
);
expect(slots).toEqual({ ...expectedSlotsUTC, "2050-09-05": expectedSlotsUTC2050_09_05 });
const dbSlot = await selectedSlotsRepositoryFixture.getByUid(reservedSlot.reservationUid);
const dbSlot = await selectedSlotsRepositoryFixture.getByUid(
responseReservedVariableSlot.reservationUid
);
expect(dbSlot).toBeDefined();
if (dbSlot) {
const dbReleaseAt = DateTime.fromJSDate(dbSlot.releaseAt, { zone: "UTC" }).toISO();
const expectedReleaseAt = DateTime.fromISO(now, { zone: "UTC" }).plus({ minutes: 5 }).toISO();
expect(dbReleaseAt).toEqual(expectedReleaseAt);
expect(responseReservedSlot.reservationUntil).toEqual(expectedReleaseAt);
expect(responseReservedVariableSlot.reservationUntil).toEqual(expectedReleaseAt);
}
clear();
});
it("request slot contains already existing reserved slot", async () => {
// Try to reserve 9:45-12:45 when 10:00-11:00 is taken
const newSlotStart = DateTime.fromISO(responseReservedVariableSlot.slotStart)
.minus({ minutes: 15 })
.toISO();
await request(app.getHttpServer())
.post(`/v2/slots/reservations`)
.send({
eventTypeId: variableLengthEventType.id,
slotStart: newSlotStart,
slotDuration: 180,
})
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
.set("Authorization", `Bearer cal_test_${apiKeyString}`)
.expect(422)
.then((response) => {
expect(response.body.error.message).toEqual(
"This time slot is already reserved by another user. Please choose a different time."
);
});
});
});
describe("out of office", () => {
@@ -82,7 +82,7 @@ export class SlotsController_2024_09_04 {
Optional parameters are:
- timeZone: Time zone in which the available slots should be returned. Defaults to UTC.
- duration: Only use for event types that allow multiple durations or for dynamic event types. If not passed for multiple duration event types defaults to default duration. For dynamic event types defaults to 30 aka each returned slot is 30 minutes long. So duration=60 means that returned slots will be each 60 minutes long.
- slotFormat: Format of the slots. By default return is an object where each key is date and value is array of slots as string. If you want to get start and end of each slot use "range" as value.
- format: Format of the slots. By default return is an object where each key is date and value is array of slots as string. If you want to get start and end of each slot use "range" as value.
`,
})
@ApiQuery({
@@ -99,7 +99,7 @@ export class SlotsController_2024_09_04 {
example: "60",
})
@ApiQuery({
name: "slotFormat",
name: "format",
required: false,
description:
"Format of slot times in response. Use 'range' to get start and end times. Use 'time' or omit this query parameter to get only start time.",
@@ -42,24 +42,33 @@ export class SlotsService_2024_09_04 {
) {}
async getAvailableSlots(query: GetSlotsInput_2024_09_04) {
const queryTransformed = await this.slotsInputService.transformGetSlotsQuery(query);
try {
const queryTransformed = await this.slotsInputService.transformGetSlotsQuery(query);
const availableSlots: TimeSlots = await getAvailableSlots({
input: {
...queryTransformed,
},
ctx: {},
});
const formatted = await this.slotsOutputService.getAvailableSlots(
availableSlots,
queryTransformed.eventTypeId,
queryTransformed.duration,
query.format,
queryTransformed.timeZone
);
const availableSlots: TimeSlots = await getAvailableSlots({
input: {
...queryTransformed,
},
ctx: {},
});
const formatted = await this.slotsOutputService.getAvailableSlots(
availableSlots,
queryTransformed.eventTypeId,
queryTransformed.duration,
query.format,
queryTransformed.timeZone
);
return formatted;
return formatted;
} catch (error) {
if (error instanceof Error) {
if (error.message.includes("Invalid time range given")) {
throw new BadRequestException(
"Invalid time range given - check the 'start' and 'end' query parameters."
);
}
}
throw error;
}
}
async reserveSlot(input: ReserveSlotInput_2024_09_04, authUserId?: number) {
@@ -124,6 +133,8 @@ export class SlotsService_2024_09_04 {
const reservationDuration = input.reservationDuration ?? DEFAULT_RESERVATION_DURATION;
await this.checkSlotOverlap(input.eventTypeId, startDate.toISO(), endDate.toISO());
if (eventType.userId) {
const slot = await this.slotsRepository.createSlot(
eventType.userId,
@@ -153,6 +164,20 @@ export class SlotsService_2024_09_04 {
return this.slotsOutputService.getReservationSlotCreated(slot, reservationDuration);
}
private async checkSlotOverlap(eventTypeId: number, startDate: string, endDate: string) {
const overlappingReservation = await this.slotsRepository.getOverlappingSlotReservation(
eventTypeId,
startDate,
endDate
);
if (overlappingReservation) {
throw new UnprocessableEntityException(
`This time slot is already reserved by another user. Please choose a different time.`
);
}
}
validateSlotDuration(eventType: EventType, inputSlotDuration: number) {
const eventTypeMetadata = eventTypeMetadataSchema.parse(eventType.metadata);
if (!eventTypeMetadata?.multipleDuration) {
@@ -257,6 +282,8 @@ export class SlotsService_2024_09_04 {
const reservationDuration = input.reservationDuration ?? DEFAULT_RESERVATION_DURATION;
await this.checkSlotOverlap(input.eventTypeId, startDate.toISO(), endDate.toISO());
const slot = await this.slotsRepository.updateSlot(
eventType.id,
startDate.toISO(),
@@ -19,6 +19,30 @@ export class SlotsRepository_2024_09_04 {
});
}
async getOverlappingSlotReservation(eventTypeId: number, startDate: string, endDate: string) {
return this.dbRead.prisma.selectedSlots.findFirst({
where: {
eventTypeId,
AND: [
{
OR: [
// Case 1: New slot starts during an existing slot
{ slotUtcStartDate: { lte: startDate }, slotUtcEndDate: { gt: startDate } },
// Case 2: New slot ends during an existing slot
{ slotUtcStartDate: { lt: endDate }, slotUtcEndDate: { gte: endDate } },
// Case 3: New slot is completely inside an existing slot
{ slotUtcStartDate: { lte: startDate }, slotUtcEndDate: { gte: endDate } },
// Case 4: New slot completely overlaps an existing slot
{ slotUtcStartDate: { gte: startDate }, slotUtcEndDate: { lte: endDate } },
],
},
// Only consider non-expired reservations
{ releaseAt: { gt: DateTime.utc().toJSDate() } },
],
},
});
}
async createSlot(
userId: number,
eventTypeId: number,
@@ -312,6 +312,7 @@ describe("Organizations Event Types Endpoints", () => {
requiresBookerEmailVerification: true,
hideCalendarNotes: true,
hideCalendarEventDetails: true,
hideOrganizerEmail: true,
lockTimeZoneToggleOnBookingPage: true,
color: {
darkThemeHex: "#292929",
@@ -343,6 +344,7 @@ describe("Organizations Event Types Endpoints", () => {
expect(data.requiresBookerEmailVerification).toEqual(body.requiresBookerEmailVerification);
expect(data.hideCalendarNotes).toEqual(body.hideCalendarNotes);
expect(data.hideCalendarEventDetails).toEqual(body.hideCalendarEventDetails);
expect(data.hideOrganizerEmail).toEqual(body.hideOrganizerEmail);
expect(data.lockTimeZoneToggleOnBookingPage).toEqual(body.lockTimeZoneToggleOnBookingPage);
expect(data.color).toEqual(body.color);
@@ -1,4 +1,5 @@
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
import { ConferencingRepository } from "@/modules/conferencing/repositories/conferencing.repository";
import { MembershipsModule } from "@/modules/memberships/memberships.module";
import { OrganizationsConferencingModule } from "@/modules/organizations/conferencing/organizations-conferencing.module";
import { OutputTeamEventTypesResponsePipe } from "@/modules/organizations/event-types/pipes/team-event-types-response.transformer";
@@ -31,6 +32,7 @@ import { Module } from "@nestjs/common";
OrganizationsTeamsRepository,
OutputTeamEventTypesResponsePipe,
OutputOrganizationsEventTypesService,
ConferencingRepository,
],
exports: [TeamsEventTypesRepository, TeamsEventTypesService],
controllers: [TeamsEventTypesController],
@@ -112,7 +112,7 @@ export class TeamsMembershipsController {
@Roles("TEAM_ADMIN")
@Patch("/:membershipId")
@ApiOperation({ summary: "Create a membership" })
@ApiOperation({ summary: "Update membership" })
async updateTeamMembership(
@Param("teamId", ParseIntPipe) teamId: number,
@Param("membershipId", ParseIntPipe) membershipId: number,
@@ -21,7 +21,11 @@ export class TeamsMembershipsRepository {
async createTeamMembership(teamId: number, data: CreateTeamMembershipInput) {
return this.dbWrite.prisma.membership.create({
data: { ...data, teamId: teamId },
data: {
createdAt: new Date(),
...data,
teamId: teamId,
},
include: { user: { select: MembershipUserSelect } },
});
}
@@ -0,0 +1,229 @@
import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
import { Throttle } from "@/lib/endpoint-throttler-decorator";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { RequestEmailVerificationInput } from "@/modules/verified-resources/inputs/request-email-verification.input";
import { RequestPhoneVerificationInput } from "@/modules/verified-resources/inputs/request-phone-verification.input";
import { VerifyEmailInput } from "@/modules/verified-resources/inputs/verify-email.input";
import { VerifyPhoneInput } from "@/modules/verified-resources/inputs/verify-phone.input";
import { RequestEmailVerificationOutput } from "@/modules/verified-resources/outputs/request-email-verification-output";
import { RequestPhoneVerificationOutput } from "@/modules/verified-resources/outputs/request-phone-verification-output";
import {
TeamVerifiedEmailOutput,
TeamVerifiedEmailOutputData,
TeamVerifiedEmailsOutput,
} from "@/modules/verified-resources/outputs/verified-email.output";
import {
TeamVerifiedPhoneOutput,
TeamVerifiedPhoneOutputData,
TeamVerifiedPhonesOutput,
} from "@/modules/verified-resources/outputs/verified-phone.output";
import { VerifiedResourcesService } from "@/modules/verified-resources/services/verified-resources.service";
import {
Body,
Controller,
Get,
HttpCode,
HttpStatus,
Param,
ParseIntPipe,
Post,
Query,
UseGuards,
} from "@nestjs/common";
import { ApiHeader, ApiOperation, ApiTags } from "@nestjs/swagger";
import { plainToClass } from "class-transformer";
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
import { SkipTakePagination } from "@calcom/platform-types";
@Controller({
path: "/v2/teams/:teamId/verified-resources",
})
@UseGuards(ApiAuthGuard, RolesGuard)
@ApiTags("Teams Verified Resources")
export class TeamsVerifiedResourcesController {
constructor(private readonly verifiedResourcesService: VerifiedResourcesService) {}
@ApiOperation({
summary: "Request Email Verification Code",
description: `Sends a verification code to the Email.`,
})
@Roles("TEAM_ADMIN")
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@Post("/emails/verification-code/request")
@Throttle({ limit: 5, ttl: 60000, blockDuration: 60000, name: "teams_verified_resources_emails_requests" })
@HttpCode(HttpStatus.OK)
async requestEmailVerificationCode(
@Body() body: RequestEmailVerificationInput,
@GetUser("username") username: string,
@GetUser("locale") locale: string
): Promise<RequestEmailVerificationOutput> {
const verificationCodeRequest = await this.verifiedResourcesService.requestEmailVerificationCode(
{ username, locale },
body.email
);
return {
status: verificationCodeRequest ? SUCCESS_STATUS : ERROR_STATUS,
};
}
@ApiOperation({
summary: "Request Phone Number Verification Code",
description: `Sends a verification code to the phone number.`,
})
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@Roles("TEAM_ADMIN")
@Post("/phones/verification-code/request")
@Throttle({ limit: 3, ttl: 60000, blockDuration: 60000, name: "teams_verified_resources_phones_requests" })
@HttpCode(HttpStatus.OK)
async requestPhoneVerificationCode(
@Body() body: RequestPhoneVerificationInput
): Promise<RequestPhoneVerificationOutput> {
const verificationCodeRequest = await this.verifiedResourcesService.requestPhoneVerificationCode(
body.phone
);
return {
status: verificationCodeRequest ? SUCCESS_STATUS : ERROR_STATUS,
};
}
@ApiOperation({
summary: "Verify an email for a team.",
description: `Use code to verify an email`,
})
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@Roles("TEAM_ADMIN")
@Post("/emails/verification-code/verify")
@HttpCode(HttpStatus.OK)
async verifyEmail(
@Body() body: VerifyEmailInput,
@GetUser("id") userId: number,
@Param("teamId", ParseIntPipe) teamId: number
): Promise<TeamVerifiedEmailOutput> {
const verifiedEmail = await this.verifiedResourcesService.verifyEmail(
userId,
body.email,
body.code,
teamId
);
return {
status: SUCCESS_STATUS,
data: plainToClass(TeamVerifiedEmailOutputData, verifiedEmail),
};
}
@ApiOperation({
summary: "Verify a phone number for an org team.",
description: `Use code to verify a phone number`,
})
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@Roles("TEAM_ADMIN")
@Post("/phones/verification-code/verify")
@Throttle({ limit: 3, ttl: 60000, blockDuration: 60000, name: "teams_verified_resources_phones_verify" })
@HttpCode(HttpStatus.OK)
async verifyPhoneNumber(
@Body() body: VerifyPhoneInput,
@GetUser("id") userId: number,
@Param("teamId", ParseIntPipe) teamId: number
): Promise<TeamVerifiedPhoneOutput> {
const verifiedPhone = await this.verifiedResourcesService.verifyPhone(
userId,
body.phone,
body.code,
teamId
);
return {
status: SUCCESS_STATUS,
data: plainToClass(TeamVerifiedPhoneOutputData, verifiedPhone),
};
}
@ApiOperation({
summary: "Get list of verified emails of a team.",
})
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@Roles("TEAM_ADMIN")
@Get("/emails")
@HttpCode(HttpStatus.OK)
async getVerifiedEmails(
@Param("teamId", ParseIntPipe) teamId: number,
@Query() pagination: SkipTakePagination
): Promise<TeamVerifiedEmailsOutput> {
const verifiedEmails = await this.verifiedResourcesService.getTeamVerifiedEmails(
teamId,
pagination?.skip ?? 0,
pagination?.take ?? 250
);
return {
status: SUCCESS_STATUS,
data: verifiedEmails.map((verifiedEmail) => plainToClass(TeamVerifiedEmailOutputData, verifiedEmail)),
};
}
@ApiOperation({
summary: "Get list of verified phone numbers of a team.",
})
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@Get("/phones")
@Roles("TEAM_ADMIN")
@HttpCode(HttpStatus.OK)
async getVerifiedPhoneNumbers(
@Param("teamId", ParseIntPipe) teamId: number,
@Query() pagination: SkipTakePagination
): Promise<TeamVerifiedPhonesOutput> {
const verifiedPhoneNumbers = await this.verifiedResourcesService.getTeamVerifiedPhoneNumbers(
teamId,
pagination?.skip ?? 0,
pagination?.take ?? 250
);
return {
status: SUCCESS_STATUS,
data: verifiedPhoneNumbers.map((verifiedPhoneNumber) =>
plainToClass(TeamVerifiedPhoneOutputData, verifiedPhoneNumber)
),
};
}
@ApiOperation({
summary: "Get verified email of a team by id.",
})
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@Roles("TEAM_ADMIN")
@Get("/emails/:id")
@HttpCode(HttpStatus.OK)
async getVerifiedEmailById(
@Param("id") id: number,
@Param("teamId", ParseIntPipe) teamId: number
): Promise<TeamVerifiedEmailOutput> {
const verifiedEmail = await this.verifiedResourcesService.getTeamVerifiedEmailById(teamId, id);
return {
status: SUCCESS_STATUS,
data: plainToClass(TeamVerifiedEmailOutputData, verifiedEmail),
};
}
@ApiOperation({
summary: "Get verified phone number of a team by id.",
})
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@Roles("TEAM_ADMIN")
@Get("/phones/:id")
@HttpCode(HttpStatus.OK)
async getVerifiedPhoneById(
@Param("teamId", ParseIntPipe) teamId: number,
@Param("id") id: number
): Promise<TeamVerifiedPhoneOutput> {
const verifiedPhoneNumber = await this.verifiedResourcesService.getTeamVerifiedPhoneNumberById(
teamId,
id
);
return {
status: SUCCESS_STATUS,
data: plainToClass(TeamVerifiedPhoneOutputData, verifiedPhoneNumber),
};
}
}
@@ -149,7 +149,7 @@ export class TokensRepository {
this.dbWrite.prisma.refreshToken.create({
data: {
// note(Lauris): I am leaving userId because it was before adding ownerId to standardize payload like in the createOAuthTokens function for
// backwards compatability.
// backwards compatibility.
secret: this.jwtService.signRefreshToken({
clientId,
ownerId: tokenUserId,

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