Compare commits

...
119 Commits
Author SHA1 Message Date
GitHub Actions 3bea03f4b1 chore: release v5.4.3
Post release / release (push) Canceled after 0s
2025-06-19 07:57:24 +00:00
Hariom Balhara 19dbde8a3c Add comment (#21920) 2025-06-19 05:55:42 +00:00
Syed Ali Shahbaz a5fe10189f fix: Banner uploader fix error handling and reduce image size jump (#21903)
* improve uploader

* better error handling

* unnecessary check

* more adjustments

* remove logs
2025-06-19 03:35:41 +00:00
Adarsh Tiwari 7075384313 fix:hover on delete action on event-types (#21884) 2025-06-18 21:47:47 +00:00
Udit Takkar 25bf43cddd perf: improve invalid app credential query (#21909) 2025-06-19 02:29:33 +05:30
Udit Takkar 2880a681a8 feat: disable transcription setting in cal video (#21755)
* feat: disable transcription setting in cal video

* fix: type err
2025-06-18 14:36:36 +00:00
bbcd0b7e78 test: add unit tests to various important fns in routing flow (#21902)
* Add unit tests to routing flow

* fix: resolve TypeScript errors in routing forms test files

- Fix mock form structure to match TargetRoutingFormForResponse type
- Add missing properties like selectText, deleted, updatedById to form fields
- Correct route action types to use valid RouteActionType enum values
- Fix response objects to include required label property
- Update mock return values for findTeamMembersMatchingAttributeLogic
- Use proper type assertions for complex route objects

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: hariom@cal.com <hariom@cal.com>
2025-06-18 14:14:14 +00:00
Adarsh Tiwari 916ed13bea fix the height of buttons in override list (#21898) 2025-06-18 12:41:28 +00:00
Alex van Andel 11a06324be fix: 'handles OOO correctly' test fail in non-UTC servertime (#21899) 2025-06-18 12:28:32 +01:00
Hariom Balhara bcb340d97e fix: Dry Run for non-pre-render scenario (#21893)
* Fix dry-run non-prerender scenario

* Add test

* Enable logging through cal.embed.logging=1
2025-06-18 10:27:44 +01:00
Somay Chauhanandcubic-dev-ai[bot] aabef47ff1 fix: microsoft oAuth in api/v2 (#21890)
* fix: microsoft oAuth in api/v2

* Update apps/api/v2/src/ee/calendars/services/gcal.service.ts

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* Update gcal.service.ts

---------

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2025-06-18 07:34:42 +00:00
Benny Joo e27de47d9f perf: leverage DB index for user profile fetching (booking page & payment page) (#21814)
* leverage DB index for findByUserIdAndOrgSlug method

* booking view

* payment view

* fix

* fix

* address comment
2025-06-18 09:53:12 +03:00
Benny Joo 7900be3a30 perf: deduplicating DB fetches + leverage unique DB index for team type booking page (#21811)
* wip

* refactor

* refactor

* fix

* optimize

* better

* comment

* better code

* do not use cache for now

* simplify
2025-06-18 09:53:04 +03:00
Joe Au-YeungandOmar López ddc5838c7b Change to OR operator (#21881)
Co-authored-by: Omar López <zomars@me.com>
2025-06-17 23:04:38 +00:00
6a13d7c5c6 fix: update trigger function for RoutingFormResponseField (#21868)
* fix: update trigger function for RoutingFormResponseField

* Update packages/prisma/migrations/20250617123959_update_handle_routing_form_response_fields/migration.sql

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* add reusable function

* handle number type more flexibly

* handle multiselect value flexibly

---------

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
Co-authored-by: Benny Joo <sldisek783@gmail.com>
2025-06-17 20:47:43 +02:00
GitHub Actions 4f0b35d9e2 chore: release v5.4.2
Post release / release (push) Canceled after 0s
2025-06-17 16:11:09 +00:00
Joe Au-Yeung 3b19ede6a3 Add logging (#21874) 2025-06-17 15:58:58 +00:00
Hariom Balhara 1a0ce47404 fix: Dry run in prerender flow (#21876)
* fix dry run in queued flow

* All zeros uuid
2025-06-17 15:53:23 +00:00
22f136d19b feat: Headless router - queue recording booking response (#21805)
* Add queued booking response table

* Create `RoutingFormResponseRepository`

* Pass `queueFormResponse` param

* Queue up form response if param is passed

* Forward queued form response parma to booker

* Pass `queuedFormResponse` from booker to `handleNewBooking`

* Write queued routing form response

* Type fixes

* Clean up

* Allow dry run to work which wont have any QueuedFormResponse or FormResponse

* Support passing the time when the modal was actually shown to the user and consider that time as the time of form submission

* fix ts error

* Queue -> Response through separate endpoint that would be triggered by
embed

* Make queueResponseId a non-guessable uid

* Change queueFormResponse query param

* fix ts error

* Support useQueuedResponse to record new response data

* revert handleNewBooking

* Remove dead code formResponse

* Refactor use repository

* Unify migration files

* refactor: moved api endpoint to app dir

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

* Update formResponse.ts

* Refactor use-queued-response for test

* Add tests

* Fix ts error and unit test. recordFormResponse cant return nullish response

* fix schema

* feat: Support full reuse of preloaded iframe (#21803)

* feat: support updating cal video settings in API v2 (#21784)

* feat: support updating cal video settings in API v2

* chore: update descriptio

* feat: support create event type

* test: add test for updating event type

* test: add test for create event type

* chore: undo openapi

* chore: bump libraries

* Revert "chore: bump libraries"

This reverts commit bdf36d09b021fc531497a7b7ea66ab9c52b7d136.

* chore: bump libraries

---------

Co-authored-by: Lauris Skraucis <lauris.skraucis@gmail.com>
Co-authored-by: supalarry <laurisskraucis@gmail.com>

* fix tests and ts

* Fix tests

* wip-useQueuedResponseEndpoint

* Add one more test

* Change queueFormResponse query param

* wip

* Support useQueuedResponse to record new response data

* Use the update useQueuedResponse endpoint

* self-review addressed

* Use queuedResponse if available in slots/utils

* Add documentation

* Remove use-queued-response from critical-path

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Lauris Skraucis <lauris.skraucis@gmail.com>
Co-authored-by: supalarry <laurisskraucis@gmail.com>

* Update schema.prisma

* refactor: renamed to avoid react hooks confusion

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

---------

Signed-off-by: Omar López <zomars@me.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: Omar López <zomars@me.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Lauris Skraucis <lauris.skraucis@gmail.com>
Co-authored-by: supalarry <laurisskraucis@gmail.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2025-06-17 12:11:49 -03:00
Lauris Skraucis 9a40e54159 refactor: seated booking reschedule error message (#21841) 2025-06-17 14:11:50 +00:00
229f2627c8 fix: Add Guests dialog for Bookings (#21669)
* fixedAddUsers

* Update packages/ui/components/address/MultiEmailLazy.tsx

---------

Co-authored-by: layand12 <d.layan@unque.me>
Co-authored-by: Eunjae Lee <hey@eunjae.dev>
2025-06-17 13:32:23 +00:00
GitHub Actions 6f6454df40 chore: release v5.4.1
Post release / release (push) Canceled after 0s
2025-06-17 13:27:30 +00:00
Morgan 3da9fc5500 chore: add global prefix to api v2 via env variable for selfhost (#21860) 2025-06-17 12:55:15 +00:00
Lauris Skraucis 540bf868b5 fix: event type settings atom not saving (#21864)
* chore: specify atoms version

* fix: saving event type settings
2025-06-17 12:09:37 +00:00
Anik Dhabal Babu 1674ecc585 fix: replyTo includes the correct email (#21843)
* fix: delete dilog button not visible

* fix: replyTo includes the correct email

* Update getReplyToHeader.ts
2025-06-17 10:45:44 +00:00
4920abdaf7 feat: optimize Prisma queries by replacing findFirst with findUnique where applicable (#21826)
* feat: optimize Prisma queries by replacing findFirst with findUnique where applicable

- Replace findFirst/findFirstOrThrow with findUnique/findUniqueOrThrow for queries using unique constraints
- Maintain existing functionality and error handling behavior
- Focus on queries using primary keys and unique index fields from schema
- Revert problematic changes that caused test failures to maintain stability

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

* revert: exclude API files from Prisma query optimizations per user request

- Reverted all 55 API-related files to their original state
- Kept all non-API Prisma query optimizations intact
- API files include apps/api/v1, apps/api/v2, apps/web/app/api, and packages/app-store/*/api
- Non-API optimizations remain for packages/lib, packages/features, apps/web (non-api), etc.

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

* feat: optimize membership query in attributeUtils to use findUnique with userId_teamId constraint

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

* revert: exclude test files from Prisma query optimizations per user request

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

* revert: revert attributeUtils.ts to use findFirst for test compatibility

Co-Authored-By: benny@cal.com <benny@cal.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: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-06-17 09:52:02 +03:00
Somay Chauhan a6b98bd35d docs: expand Google Calendar event ID description with API endpoint references (#21857) 2025-06-17 07:02:50 +02:00
Lauris Skraucis 34d4f394fc fix: v2 event type disable guests (#21753)
* fix: v2 event type disable guests

* fix: implement cubic feedback
2025-06-17 10:15:46 +05:30
Joe Au-Yeung a4ef8d4200 Auto review seeded orgs (#21856) 2025-06-17 03:40:56 +00:00
Lauris Skraucis 9d9662687e docs: v2 cancelling seated booking as a host (#21744)
* docs: cancel seated bookings as attendee or host

* test: cancelling seated booking as host
2025-06-17 09:06:17 +05:30
Anik Dhabal Babu 2d41c719c4 fix: reschedule and cancel unconfirmed event from email (#21844) 2025-06-16 17:13:38 +00:00
b69bd966a6 fix: duplicate workflow reminders caused by scanWorkflowBody (#21767)
* don't scan if there is newer task

* create seperate tasks per workflowStepId

* revert changed constant

* fix comment

* add back missing import

* code clean up

* fix PR feedback

* change function to hasNewerScanTaskForStepId

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-06-16 14:25:41 +00:00
GitHub Actions 09a49dd0fd chore: release v5.4.0
Post release / release (push) Canceled after 0s
2025-06-16 13:00:34 +00:00
MorganandAnik Dhabal Babu 3dd3372c35 fix: getBookings for team admins within orgs (#21833)
* fix: getBookings for team admins within orgs

* fixup! fix: getBookings for team admins within orgs

* chore: update getUserIdsAndEmailsWhereUserIsAdminOrOwner and remove unused orgId

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-06-16 09:34:02 -03:00
Anik Dhabal Babu 0a60864310 fix: Few flaky e2e test (#21818)
* fix: delete dilog button not visible

* fix: flaky e2e tests

* Update booking.e2e.ts

* update

* Update booking.e2e.ts

* update

* Update booking.e2e.ts

* Update booking.e2e.ts
2025-06-16 09:51:37 +00:00
Benny Joo cb24e5d0aa perf: remove 2 DB fetches by removing getFacetedValues (#21808)
* perf: remove 2 server fetches by removing getFacetedValues

* fix

* fix

* use set

* fix
2025-06-15 11:31:11 +00:00
Romit. 8c5aebdca1 ux improve (#21815) 2025-06-15 11:30:34 +00:00
GitHub Actions 4e2cff0fd5 chore: release v5.3.13
Post release / release (push) Canceled after 0s
2025-06-14 00:13:45 +00:00
Joe Au-Yeungandromit 54e518efad feat: Allow reschedule when max booker bookings have been reached (#21778)
* feat - Restrict same email to create more than 'n' active bookings at a time

* updated checkbookerbookinglimit function

* type fix

* minor change

* import fix

* minor fixes

* back to null on disable

* back

* type check

* managed edge cases

* chore: name changes

* name changes

* fix

* minor change

* changed name

* use default value for maxactivebookingsperbooker, and some minor changes

* disabling bookerbooking limit for recurring event

* disabling bookerbooking limit for recurring event

* type fix

* ui fix and backend eventtype update check

* Add `maxActiveBookingPerBookerOfferReschedule` to schema

* Create `MaxActiveBookingsPerBookerController` and offer reschedule option

* Add offer reschedule to event type form data

* Pass data through to HttpError

* When checking max bookings, return last booking info if applicable

* removed unused code

* minor changes

* update validation

* chore

* Do not check booking limits if rescheduling

* Add data for reschedule

* Add reschedule specific error code

* On maximum booking error, write to booker store reschedule params

* Add translations for error codes

* Write to error message previous booking time

* minor fix

* Write to error message previous booking time

* Type fixes

* Clean up comment

* Refactor eventType update errors

* Typo fix

* Type fix

* Type fix

* Type fix

* Fix test

* Fix test

* Add migration

* Addressed feedback and missed merges

---------

Co-authored-by: romit <romitgabani@icloud.com>
2025-06-13 23:54:33 +00:00
Joe Au-Yeung ad93099171 refactor: Do not consider event type id in idempotency key (#21681)
* Update idempotency key

* Remove console.log
2025-06-13 21:54:49 +01:00
Romit.andJoe Au-Yeung 630065ca89 feat: Restrict same email to create more than 'n' active bookings at… (#21745)
* feat - Restrict same email to create more than 'n' active bookings at a time

* updated checkbookerbookinglimit function

* type fix

* minor change

* import fix

* minor fixes

* back to null on disable

* back

* type check

* managed edge cases

* chore: name changes

* name changes

* fix

* minor change

* changed name

* use default value for maxactivebookingsperbooker, and some minor changes

* disabling bookerbooking limit for recurring event

* disabling bookerbooking limit for recurring event

* type fix

* ui fix and backend eventtype update check

* removed unused code

* minor changes

* update validation

* chore

* Do not check booking limits if rescheduling

* minor fix

* Refactor eventType update errors

* Typo fix

---------

Co-authored-by: Joe Au-Yeung <j.auyeung419@gmail.com>
2025-06-13 20:52:22 +00:00
Sarthak Kapila ddf5717992 fix: slots rescheduleUid (#21626) 2025-06-13 17:36:20 +00:00
Lauris Skraucis 4a53ec0b74 feat: platform toggle calendar event creation (#21788)
* feat: add areCalendarEventsEnabled to PlatformOAuthClient table

* feat: areCalendarEvents enabled in handleNewBooking and v2

* feat: v2 handle areCalendarEventsEnabled

* feat: toggle areCalendarEventsEnabled on frontend

* feat: enable areCalendarEventsEnabled for recurring,confirm,paid

* refactor: use placeholder calendar event

* remove merge conflicts

* chore: bump libraries

* chore: bump libraries
2025-06-13 14:15:47 +00:00
Morgan d7fd055171 chore: Org Team workflows endpoints [v2] (#21329)
* chore: workflow endpoints init

* chore: org teams workflow endpoints

* chore: org teams workflow endpoints

* chore: org teams workflow endpoints and tests

* chore: refactor and improvements

* chore: bump libraries

* remove empty module

* update docs and remove AI comments

* chore: only enable workflows for SCALE orgs atm

* bump libraries

* improve dtos

* add hashmaps of enums

* remove unnecessary isOptional

* fix docs

* code review fix controller

* improve docs

* sort steps and improve docs

* fix: add services and rename triggers

* chore: platform libraries bump

* chore: platform libraries bump
2025-06-13 15:10:44 +02:00
874973d78f feat: add api v2 endpoints for cal video (#21775)
* feat: add api v2 endpoints for cal video

* feat: add endpoint for fetching transcripts

* chore: fetch only references

* feat: add cal video and transcript

* chore: undp open api

* chore: move it to service

* chore: bump libraries

---------

Co-authored-by: Lauris Skraucis <lauris.skraucis@gmail.com>
Co-authored-by: supalarry <laurisskraucis@gmail.com>
2025-06-13 12:02:58 +00:00
Eunjae Leeandsean-brydon 8078c5657b chore: move logics to FilterSegmentRepository (#21648)
* chore: move logics to FilterSegmentRepository

* add explicit select

* replace TRPCError with Error

* convert to non static methods and implement the interface

* apply feedback

---------

Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
2025-06-13 13:49:26 +02:00
Alex van Andel b39404860f chore: Remove cascade on profile deletion for EventType (#21792) 2025-06-13 10:24:01 +00:00
Morgan 860eae8dcb fix: ensure failed apiv2 slots workers are terminated (#21797)
* fix: ensure failed apiv2 slots workers are terminated

* fixup! fix: ensure failed apiv2 slots workers are terminated
2025-06-13 07:05:00 -03:00
Benny Joo b931d3ee13 feat: restructure org pages based on permission levels (#21611) 2025-06-13 10:42:04 +01:00
ba8a71bf3a feat: support updating cal video settings in API v2 (#21784)
* feat: support updating cal video settings in API v2

* chore: update descriptio

* feat: support create event type

* test: add test for updating event type

* test: add test for create event type

* chore: undo openapi

* chore: bump libraries

* Revert "chore: bump libraries"

This reverts commit bdf36d09b021fc531497a7b7ea66ab9c52b7d136.

* chore: bump libraries

---------

Co-authored-by: Lauris Skraucis <lauris.skraucis@gmail.com>
Co-authored-by: supalarry <laurisskraucis@gmail.com>
2025-06-13 12:05:29 +03:00
8de4897042 feat: enable transcription automatically (#21691)
* fest: Add enableAutomaticTranscription field to CalVideoSettings schema

* fest: Update backend logic for enableAutomaticTranscription in event types

* fest: Add frontend support for enableAutomaticTranscription in video meetings and event types

* fest: Add localization strings for enableAutomaticTranscription feature

* fix: enable transcription feature

* Update apps/web/lib/video/[uid]/getServerSideProps.ts

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: akarsh-jain-790 <akarsh.jain.790@gmail.com>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2025-06-13 07:51:57 +00:00
Somay Chauhan 6de6f8e697 feat: api v2 get google-calendar event details (#21336)
* feat: add API endpoint to fetch Google Calendar meeting details and metrics

* refactor: rename eventId to eventUid and update calendar event details endpoint

* feat: implement Google Calendar service account delegation for event details

* refactor: simplify Google Calendar event response

* refactor: simplify calendar event details endpoint

* feat: add calendar event details endpoint with schema and controller implementation

* chore: reset platform libraries package version to 0.0.0

* refactor: migrate calendar event details to unified calendars module

* Update gcal.service.ts

* refactor: update calendar event schema with title and locations fields replacing summary and conferenceData

* feat: update calendar event source to use enum type for better validation

* update api description

* refactor: make Google Calendar service methods private

* refactor: remove location field from unified calendar event output

* fix: update Google Calendar event location type property to match unified schema

* feat: add CalendarEventStatus enum and map Google Calendar statuses to standardized values

* docs: add property descriptions to calendar event schema and remove organizer field

* fix: change calendar event locations type from null to empty array in unified calendar API

* feat: make calendar source field required

* refactor: replace organizer field with hosts array in Google calendar event mapping

* feat: enhance calendar event location API documentation with detailed property descriptions

* feat: add calendar event details endpoint documentation

* fix: typo

* docs: update calendar event details API description to remove reschedule history reference

* refactor: rename uri to url in calendar event location interfaces and mappings

* refactor: improve calendar event response status handling and code organization

* Update get-unified-calendar-event.ts

* update docs

* chore: update @calcom/platform-libraries to version 0.0.215
2025-06-13 13:08:54 +05:30
Kartik Saini e55ecff899 fix: hovering over insights tab does not show pointer (#21772) 2025-06-13 07:13:43 +00:00
Muhammad Aiman Sulaiman 3895ef002e fix: Updates to HitPay app (#21175)
* reapplied my changes after syncing with upstream

* fix: changed raw error to sanitized error

* fix: added try/catch to create catch
2025-06-13 12:10:28 +05:30
Eunjae Lee afc28503a5 fix: apply user timeZone for Event Trends on /insights (#21787) 2025-06-13 08:47:47 +04:00
Amit Sharma e6497c87ca perf: use repository for apiKeys list query & caching in /settings/developer/api-keys RSC (#21776) 2025-06-12 21:11:06 +00:00
GitHub Actions cbb020911d chore: release v5.3.12
Post release / release (push) Canceled after 0s
2025-06-12 20:58:13 +00:00
Udit Takkar 200b9d037c fix: remove deprecated fields from retell ai (#21790) 2025-06-12 20:45:55 +00:00
GitHub Actions 69667a7528 chore: release v5.3.12 2025-06-12 18:30:15 +00:00
9f655ed819 feat: added validation to limit event duration to 1440 minutes (24 ho… (#21379)
* feat: added validation to limit event duration to 1440 minutes (24 hours)

* Update English locale strings in common.json

* feat: introduce minimum event duration constant and update validations and fixed some suggested changes

* fix: remove static error message for maximum event duration validation

* fix: update minimum event duration to 1 minute and adjust validations accordingly

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: amrit <iamamrit27@gmail.com>
2025-06-12 17:09:21 +00:00
Carina WollendorferandCarinaWolli 5a1e1e231b add credit purchase log (#21768)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2025-06-12 11:29:12 -04:00
Eunjae Lee 6cccb1725f fix: provide timeZone to /insights from the server side (#21580) 2025-06-12 15:26:44 +00:00
5bb2a904ce refactor: Deprecate Legacy v2 Booker Atom Endpoints (#20939)
* feat: split atoms endpoints and add public event type endpoint

* Update ConnectedDestinationCalendars import path across platform/atoms components

* refactor: update BookingResponse import path from platform-libraries to features/bookings

* Move AvailableSlotsType to util.ts and update imports to use GetAvailableSlotsResponse

* Refactor import path for RecurringBookingCreateBody type from libraries to types

* Fix import path for getBookingForReschedule type from platform-libraries to features

* Refactor PublicEventType export location and update import references

* Remove @calcom/platform-libraries dependency from atoms package.json

* chore(deps): update yarn.lock dependencies

* Remove console.log statements and fix indentation in event type hooks

* Migrate event type transformers from platform/libraries to api/v2 directory

* Remove console.log

* Update BookerPlatformWrapper.tsx

* Add script to populate empty team slugs with slugified team names

* Remove vitest imports

* reset platform libraries version

* Remove unused orgId comment from useAtomGetPublicEvent hook params

* Update useApiV2AvailableSlots.ts

* refactor: remove unused exports from lib package index file

* Undo: @SomayChauhan
Add script to populate empty team slugs with slugified team names

* Update booking.tsx

* chore: upgrade @calcom/platform-libraries from 0.0.202 to 0.0.205

* chore: bump @calcom/platform-libraries from 0.0.205 to 0.0.206

* chore: configure babel and jest for node module transpilation in api v2

* fix: type errors

* Revert "chore: configure babel and jest for node module transpilation in api v2"

This reverts commit b2cf172a84fe8953f9497bf6e43874f476fbc04b.

* Update calendars.service.ts

* chore: bump @calcom/platform-libraries from 0.0.208 to 0.0.209

* fix: add proper type definition for calendar busy times to resolve ts-expect-error

* refactor: deprecate v2 old availability endpoints (#21075)

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

* chore: publish platform librareis

* feat: add delegation credential fields to calendar service mock

* chore: update @calcom/platform-libraries from 0.0.211 to 0.0.213

* fix: skip failing calendar integration test

* chore: bump @calcom/platform-libraries from 0.0.213 to 0.0.214

* fix: api/v2 build error

* fix: e2e tests

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: supalarry <laurisskraucis@gmail.com>
Co-authored-by: Lauris Skraucis <lauris.skraucis@gmail.com>
2025-06-12 20:20:52 +05:30
Chai 5e2f06cdea fix: multi-line formatted correctly for sms (#21779)
* fix: multi-line formatted correctly for sms

* fix: tests iteration2
2025-06-12 14:26:34 +00:00
Parteek malik cf83038a7a fix: cal atoms schedule container class in availability settings custom class names not being applied (#21733)
* fix: classname not used

* fix types

* added type
2025-06-12 11:02:00 -03:00
Udit Takkar c7662cad61 feat: override name in daily video (#21758)
* feat: override name in daily vide

* refactor: simplify operation
2025-06-12 09:54:24 -04:00
Alex van Andel 86912e0cbc refactor: async constructor is an anti-pattern, use init instead (#21785) 2025-06-12 13:13:44 +00:00
Syed Ali Shahbaz 74234b4ed1 docs: Cleanup false info (#21741)
* Delete docs/developing/guides/appstore-and-integration/oauth.mdx

* Update mint.json
2025-06-12 12:21:34 +00:00
devin-ai-integration[bot]andHariom Balhara 2fcb8a750d fix: cascade organization hideBranding setting to user and team events (#21703)
- Add utility function for cascading hideBranding logic
- Update all getServerSideProps files to check organization hideBranding as fallback
- Ensure team queries include parent hideBranding data
- Fix hideBranding not working for organization members and teams

Fixes the issue where organization-level branding settings were not
being applied to user and team event pages when the direct entity
didn't have hideBranding enabled.

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2025-06-12 09:14:55 -03:00
2aaf672b10 perf: Implement worker threads for getAvailableSlots to prevent CPU blocking (#21479)
* Implement worker threads for getAvailableSlots to prevent CPU blocking

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

* fix: resolve TypeScript errors in worker implementation

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

* chore: fix slot-worker-service.ts

* fix: do not use workers in E2E

* fix: try to fix memory leak

* chore: remove unecessary comments

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: keith@cal.com <keithwillcode@gmail.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: cal.com <morgan@cal.com>
2025-06-12 11:56:13 +00:00
nav 34beadeef6 chore(docs): update 'Calendso' to 'Cal.com' in documentation (#21661) 2025-06-12 11:29:15 +00:00
Carina WollendorferandCarinaWolli 6de715b884 feat: Round Robin timestamp basis (#21337)
* add rrTimeStampBasis to prisma schema

* add rr time stamp basis setting

* update migration

* add getLuckyUser logic

* add rrTimestampBasis to tests

* add load balancing warning message

* disable load balancing logic

* disable load balancing in event type settings

* add missing translations

* fix UI

* disable load balancing on all team event types

* don't show routing forms in router position

* use correct interval times

* fix variable naming

* fix event type update handler

* add test to booking.test.ts

* add test for getting interval times

* remove not needed prop

* fix label

* improve warning message

* fix removing maxLeadTreshold

* fix typo

* fix disabling maxLeadThreshold

* add back missing translation

* improve rr reset interval label

* fix rr_load_balancing_disabled text

* improve test

* fix description

* only use rrTimestampBasis in weights round robin

* fix dropdown width

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2025-06-12 11:37:38 +01:00
GitHub Actions 84717ca73e chore: release v5.3.11
Post release / release (push) Canceled after 0s
2025-06-11 23:50:19 +00:00
86b1db57d5 fix: Resending team invites now updates token expiry date (#21774)
* Create `VerificationTokenRepository`

* Update the verification token expiry when resending the invite

* Handle error

* Fix import

* Get past unique constraint

* Update packages/trpc/server/routers/viewer/teams/resendInvitation.handler.ts

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

* Update packages/lib/server/repository/verificationToken.ts

Update set expiry time

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* Type fix

* Update how we're fetching the token

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2025-06-11 22:21:58 +00:00
Amit Sharmaandcubic-dev-ai[bot] 50c15f0d51 fix: dub installation on teams (#21654)
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2025-06-11 18:17:55 +02:00
Romit.andEunjae Lee 86d5c43307 fix: select component icons misaligned (#21688)
Co-authored-by: Eunjae Lee <hey@eunjae.dev>
2025-06-11 13:22:00 +00:00
Anik Dhabal Babuandcubic-dev-ai[bot] e82bd45b55 fix: Incorrect RR host is shown in the email and calendar event for a seated RR event (#21684)
* fix: seated rr event

* Update packages/features/bookings/lib/handleNewBooking.ts

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* update

* Update handleNewBooking.ts

---------

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2025-06-11 11:21:21 +00:00
13966e0ec8 perf: use repository for queries & caching in /teams RSC (#21399)
* cache trpc queries in /teams

* fix

* revalidate

* refactor: migrate listHandler to TeamRepository.findTeamsByUserId

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

* add comment

* refactor

* refactor

* make it even more performant

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: benny@cal.com <benny@cal.com>
2025-06-10 20:44:51 -04:00
GitHub Actions 14b9e0c032 chore: release v5.3.10
Post release / release (push) Canceled after 0s
2025-06-10 22:43:07 +00:00
Anik Dhabal BabuandSyed Ali Shahbaz f0f168e920 fix: improve updatehostineventname function (#21734)
Co-authored-by: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com>
2025-06-10 21:47:30 +00:00
devin-ai-integration[bot] dd37caf32c feat: move team privacy settings to dedicated settings page (#21590) 2025-06-10 22:23:28 +02:00
Hariom Balhara 856d223535 fix: Use setTimeout instead of requestAnimationFrame which is more reliable and support calOrigin in prerender #21205 (#21730) 2025-06-10 11:00:57 -04:00
Sarthak KapilaandMorgan ad697365ce fix: Add onFormStateChange callback on EventTypeSettings atom [CAL-5856] (#21615)
* Adds onFormStateChange callback on EventTypeSettings atom

* updated docs for event-type

* added watch all form values

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2025-06-10 12:36:34 +00:00
Morgan 84171928cb chore: enable disable rewrite for apiv2 selfhost (#21742) 2025-06-10 11:04:25 +00:00
Sebastian BrunowandSebastian Brunow e781a78eea fix: Prevent Timing Attacks by Removing await in Password Reset Flow (#21747)
* fix: correct password reset request handling logic

* fix: handle password reset request errors more gracefully

---------

Co-authored-by: Sebastian Brunow <90863358+Bratpfanne-4000@users.noreply.github.com>
2025-06-09 22:44:24 +00:00
Keith Williams 7d08c6724b chore: @Foundation required for embeds changes (#21748) 2025-06-09 23:13:04 +01:00
GitHub Actions 899da51f06 chore: release v5.3.9
Post release / release (push) Canceled after 0s
2025-06-09 21:56:25 +00:00
Joe Au-Yeung 4e085e404a Downgrade Next to 15.3.0 (#21746) 2025-06-09 17:21:02 +00:00
Morgan ff3aaadbaa fix: update delegation credentials api-v2 (#21739)
* fix: update delegation credentials api-v2

* fixup! fix: update delegation credentials api-v2
2025-06-09 11:46:38 +00:00
11bddbb71d feat: add setting to allow booking through a reschedule link (#21652)
* feat: add setting to disable rescheduling cancelled bookings

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

* fix: resolve type errors for disableReschedulingCancelledBookings field

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

* fix: update test expectations and builder to include disableReschedulingCancelledBookings field

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

* fix: add disableReschedulingCancelledBookings field to managed event types

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

* fix: remove duplicate disableReschedulingCancelledBookings property in test

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

* fix: change default value to true for disableReschedulingCancelledBookings

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

* fix: update managed event types to use true as default for disableReschedulingCancelledBookings

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

* test: add comprehensive tests for disableReschedulingCancelledBookings feature

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

* update and remove unnecesarry test

* update e2e test

* Update reschedule.e2e.ts

* update

* fix

* Reverse the meaning of column

* Simpify logic of rescheduling redirects

* fix test

* revert

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: hariom@cal.com <hariom@cal.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: unknown <adhabal2002@gmail.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2025-06-07 22:30:54 +02:00
devklepacki 55a80ba253 Fixed pl translation typo (#21723) 2025-06-06 13:59:58 +00:00
Hariom Balhara 32a89c30de fix: [object object] for name when rescheduling a past booking (#21695) 2025-06-05 09:42:48 +01:00
Parteek malikandAnik Dhabal Babu 5404eeedc2 fix: no show not updating in sync (#21687)
* fix: no show not updating in sync

* fix footer in no show dialog

* using await

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-06-04 10:14:08 +00:00
Hariom Balhara d54ba90304 fix-wrong-url-on-auth-error (#21693) 2025-06-04 09:55:19 +05:30
Omar López ca206915bf fix: self hosted feature check (#21690) 2025-06-03 17:00:48 +00:00
Anik Dhabal Babu 8461b82f0d fix: Update handlers and refactor code (#21686)
* fix: update safeCredentialSelect

* Update credential.ts

* Update

* update
2025-06-03 15:32:40 +00:00
Alex van Andel 07ffc3fade fix: Remove hard dependency on NEXT_PUBLIC_API_V2_URL (#21682)
* fix: Remove hard dependency on NEXT_PUBLIC_API_V2_URL

* turn spread into a push, simpler
2025-06-02 19:21:38 +01:00
sean-brydon c2240f0bad only use event api v2 call if route is in env (#21680) 2025-06-02 17:31:35 +00:00
Zenrac 36a712f979 fix: Correct typo 'siège' to 'place' in French translation (#21671) 2025-06-02 08:54:24 +05:30
Lauris Skraucis 842ba09896 refactor: v2 reschedule booking error messages (#21647) 2025-05-30 13:24:06 +00:00
devin-ai-integration[bot] b3c822abf7 feat: move filter segment selection from localStorage to database (#21523) 2025-05-30 12:56:49 +00:00
Akarsh JainandMorgan 1f52ed7290 feat: Add loadingStateChildren prop to AvailabilitySettingsPlatformWrapper (#21602)
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2025-05-30 10:39:02 +00:00
Lauris Skraucis a2c13e576e refactor: v2 dont allow rescheduling cancelled,rescheduled bookings (#21643) 2025-05-30 10:23:51 +00:00
Morgan 24bb52d2e9 fix: platformPlanGuard per active users plan (#21642)
* fix: platformPlanGuard per active users plan

* fix: refactor
2025-05-30 09:42:23 +00:00
Joe Au-Yeung d8145bbc78 Remove error if team members and hosts do not match (#21598) 2025-05-30 08:31:34 +00:00
Hariom BalharaandAlex van Andel 8753a6e4d6 Function return type is illegal, not sure how this passed eslint (#21567) (#21622)
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-05-30 13:18:04 +05:30
Benny Joo b91607b5b4 fix: missing places for revalidations (#21632) 2025-05-29 22:45:41 +01:00
Sarthak Kapila af0e839f27 feat: Enable Adding Custom children components at the bottom of EventMeta within Booker using children component props (#21618)
* feat: Enable Adding Custom children components at the bottom of EventMeta

* removed default style

* added prop in BookerPlatformWrapper
2025-05-29 21:26:30 +00:00
Udit Takkar b6de03de96 Revert "perf: use app dir to fetch team feature flags in settings (#21433)" (#21628)
This reverts commit 1dca160db0.
2025-05-29 20:10:12 +00:00
e115095198 perf: migrate listHandler to AttributeRepository + cache attributes fetching in RSCs (#21552)
* refactor: migrate listHandler to AttributeRepository

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

* cache attributes list

---------

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-29 15:01:44 -04:00
Lauris Skraucis c3add4bae8 fix: v2 slots reservation (#21623)
* feat: return team event type hosts username

* docs: team event types hosts username in return

* fix: ignore cancelled and rejected bookings from slot reservation

* fix: dont allow slot to overlap existing active booking

* refactor: remove log
2025-05-29 16:25:33 +00:00
Alex van Andel 3040e14624 refactor: Use a deployment repository to interact with license keys (#21613) 2025-05-29 10:58:33 -04:00
Calcom BotandLingo.dev 34993e3f36 feat: update translations via @LingoDotDev (#21260)
Co-authored-by: Lingo.dev <support@lingo.dev>
2025-05-29 14:58:17 +00:00
Sarthak Kapilaandcubic-dev-ai[bot] 8d5c6b8877 feat: OnformChange on Availability Settings Atom (#21560)
* feat: OnformChange on Availability Settings Atom

* docs: update availability-settings

* Update docs/platform/atoms/availability-settings.mdx

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2025-05-29 20:05:01 +05:30
Devanshu SharmaandMorgan c9eac73fec feat: add support for custom no schedule children in AvailabilitySettingsPlatformWrapper (#21601)
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2025-05-29 20:02:56 +05:30
Udit Takkar ab475b80f3 fix: recording button bug (#21609)
* fix: recording btn visible bug

* fix: check for showRecordingButton

* refactor: create a reusable function
2025-05-29 14:23:51 +00:00
Benny Joo 2b79039b70 perf: upgrade to next 15.3.2 (#21610)
* perf: upgrade nextjs to 15.3.2

* update yarn lock
2025-05-29 14:50:42 +01:00
Lauris Skraucis 044df04d3f feat: v2 return team event type hosts username (#21617)
* feat: return team event type hosts username

* docs: team event types hosts username in return
2025-05-29 14:58:40 +03:00
Layan Jyoti Duttaandlayand12 2cbf805ffb Fixed generator enums in prisma db (#21595)
Co-authored-by: layand12 <d.layan@unque.me>
2025-05-28 22:49:20 +00:00
619 changed files with 17551 additions and 4275 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@calcom/atoms": patch
---
fix saving event type settings
+1
View File
@@ -17,6 +17,7 @@
/packages/app-store/larkcalendar/**/* @calcom/Foundation
/packages/app-store/office365calendar/**/* @calcom/Foundation
/packages/app-store/zohocalendar/**/* @calcom/Foundation
/packages/embeds/**/* @calcom/Foundation
/packages/features/bookings/lib/**/* @calcom/Foundation
/packages/lib/getAggregatedAvailability.ts @calcom/Foundation
/packages/lib/getUserAvailability.ts @calcom/Foundation
View File
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@calcom/prisma"
View File
View File
View File
+3
View File
@@ -0,0 +1,3 @@
undecided:
- calcom-monorepo
- "@calcom/prisma"
+8
View File
@@ -0,0 +1,8 @@
undecided:
- calcom-monorepo
- "@calcom/app-store-cli"
- "@calcom/platform-constants"
- "@calcom/platform-enums"
- "@calcom/platform-types"
- "@calcom/platform-utils"
- "@calcom/prisma"
View File
View File
+3
View File
@@ -0,0 +1,3 @@
undecided:
- calcom-monorepo
- "@calcom/prisma"
@@ -1,4 +1,4 @@
import prismock from "../../../../../../tests/libs/__mocks__/prisma";
import prismock from "../../../../../tests/libs/__mocks__/prisma";
import type { Request, Response } from "express";
import type { NextApiRequest, NextApiResponse } from "next";
@@ -7,12 +7,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ILicenseKeyService } from "@calcom/ee/common/server/LicenseKeyService";
import LicenseKeyService from "@calcom/ee/common/server/LicenseKeyService";
import { hashAPIKey } from "@calcom/features/ee/api-keys/lib/apiKeys";
import type { IDeploymentRepository } from "@calcom/lib/server/repository/deployment.interface";
import prisma from "@calcom/prisma";
import { MembershipRole, UserPermissionRole } from "@calcom/prisma/enums";
import { hashAPIKey } from "~/../../../packages/features/ee/api-keys/lib/apiKeys";
import { verifyApiKey } from "../../../lib/helpers/verifyApiKey";
import { verifyApiKey } from "./verifyApiKey";
type CustomNextApiRequest = NextApiRequest & Request;
type CustomNextApiResponse = NextApiResponse & Response;
@@ -21,11 +21,16 @@ afterEach(() => {
vi.resetAllMocks();
});
describe("Verify API key", () => {
const mockDeploymentRepository: IDeploymentRepository = {
getLicenseKeyWithId: vi.fn().mockResolvedValue("mockLicenseKey"), // Mocked return value
};
// TODO: Fix the skip condition for this test suite
describe.skip("Verify API key", () => {
let service: ILicenseKeyService;
beforeEach(async () => {
service = await LicenseKeyService.create();
service = await LicenseKeyService.create(mockDeploymentRepository);
vi.spyOn(service, "checkLicense");
});
+4 -2
View File
@@ -3,6 +3,7 @@ import type { NextMiddleware } from "next-api-middleware";
import { LicenseKeySingleton } from "@calcom/ee/common/server/LicenseKeyService";
import { hashAPIKey } from "@calcom/features/ee/api-keys/lib/apiKeys";
import { IS_PRODUCTION } from "@calcom/lib/constants";
import { DeploymentRepository } from "@calcom/lib/server/repository/deployment";
import prisma from "@calcom/prisma";
import { isAdminGuard } from "../utils/isAdmin";
@@ -19,11 +20,12 @@ export const dateNotInPast = function (date: Date) {
// This verifies the apiKey and sets the user if it is valid.
export const verifyApiKey: NextMiddleware = async (req, res, next) => {
const licenseKeyService = await LicenseKeySingleton.getInstance();
const deploymentRepo = new DeploymentRepository(prisma);
const licenseKeyService = await LicenseKeySingleton.getInstance(deploymentRepo);
const hasValidLicense = await licenseKeyService.checkLicense();
if (!hasValidLicense && IS_PRODUCTION) {
return res.status(401).json({ error: "Invalid or missing CALCOM_LICENSE_KEY environment variable" });
return res.status(401).json({ message: "Invalid or missing CALCOM_LICENSE_KEY environment variable" });
}
if (!req.query.apiKey) return res.status(401).json({ message: "No apiKey provided" });
+6 -2
View File
@@ -1,6 +1,10 @@
import { z } from "zod";
import { MAX_SEATS_PER_TIME_SLOT } from "@calcom/lib/constants";
import {
MAX_SEATS_PER_TIME_SLOT,
MAX_EVENT_DURATION_MINUTES,
MIN_EVENT_DURATION_MINUTES,
} from "@calcom/lib/constants";
import slugify from "@calcom/lib/slugify";
import { _EventTypeModel as EventType, _HostModel } from "@calcom/prisma/zod";
import { customInputSchema, eventTypeBookingFields } from "@calcom/prisma/zod-utils";
@@ -80,7 +84,7 @@ const schemaEventTypeCreateParams = z
title: z.string(),
slug: z.string().transform((s) => slugify(s)),
description: z.string().optional().nullable(),
length: z.number().int(),
length: z.number().int().min(MIN_EVENT_DURATION_MINUTES).max(MAX_EVENT_DURATION_MINUTES),
metadata: z.any().optional(),
recurringEvent: recurringEventInputSchema.optional(),
seatsPerTimeSlot: z.number().optional(),
-5
View File
@@ -42,11 +42,6 @@ describe("GET /api/slots", () => {
await handler(req, res);
expect(res.statusCode).toBe(400);
expect(JSON.parse(res._getData())).toMatchInlineSnapshot(`
{
"message": "invalid_type in 'startTime': Required; invalid_type in 'endTime': Required",
}
`);
});
});
+5 -1
View File
@@ -37,4 +37,8 @@ AXIOM_TOKEN=
# INFO: 1
# WARN: 2
# ERROR: 3
LOGGER_BRIDGE_LOG_LEVEL="1"
LOGGER_BRIDGE_LOG_LEVEL="1"
# 1: Rewrite /api/v2 to /v2
# 0: Don't rewrite
REWRITE_API_V2_PREFIX="1"
+1 -1
View File
@@ -37,7 +37,7 @@
"@axiomhq/winston": "^1.2.0",
"@calcom/platform-constants": "*",
"@calcom/platform-enums": "*",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.208",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.223",
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
+4
View File
@@ -81,5 +81,9 @@ export const bootstrap = (app: NestExpressApplication): NestExpressApplication =
app.use(cookieParser());
if (process?.env?.API_GLOBAL_PREFIX) {
app.setGlobalPrefix(process?.env?.API_GLOBAL_PREFIX);
}
return app;
};
+1 -1
View File
@@ -36,7 +36,7 @@ const loadConfig = (): AppConfig => {
app: {
baseUrl: getEnv("WEB_APP_URL", "https://app.cal.com"),
},
e2e: getEnv("IS_E2E", false),
e2e: getEnv("IS_E2E", "false") === "true" ? true : false,
};
};
@@ -77,6 +77,7 @@ type OAuthRequestParams = {
platformBookingUrl: string;
platformBookingLocation?: string;
arePlatformEmailsEnabled: boolean;
areCalendarEventsEnabled: boolean;
};
const DEFAULT_PLATFORM_PARAMS = {
@@ -86,6 +87,7 @@ const DEFAULT_PLATFORM_PARAMS = {
platformBookingUrl: "",
arePlatformEmailsEnabled: false,
platformBookingLocation: undefined,
areCalendarEventsEnabled: false,
};
@Controller({
@@ -135,9 +137,13 @@ export class BookingsController_2024_04_15 {
},
});
let nextCursor = null;
if (bookings.totalCount > (cursor ?? 0) + (limit ?? 10)) {
nextCursor = (cursor ?? 0) + (limit ?? 10);
}
return {
status: SUCCESS_STATUS,
data: bookings,
data: { ...bookings, nextCursor },
};
}
@@ -191,6 +197,7 @@ export class BookingsController_2024_04_15 {
platformCancelUrl: bookingRequest.platformCancelUrl,
platformBookingUrl: bookingRequest.platformBookingUrl,
platformBookingLocation: bookingRequest.platformBookingLocation,
areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled,
});
if (booking.userId && booking.uid && booking.startTime) {
void (await this.billingService.increaseUsageByUserId(booking.userId, {
@@ -424,7 +431,7 @@ export class BookingsController_2024_04_15 {
if (isEmbed) {
// embed should ignore oauth client settings and enable emails by default
return { ...res, arePlatformEmailsEnabled: true };
return { ...res, arePlatformEmailsEnabled: true, areCalendarEventsEnabled: true };
}
try {
@@ -436,6 +443,7 @@ export class BookingsController_2024_04_15 {
res.platformRescheduleUrl = client.bookingRescheduleRedirectUri ?? "";
res.platformBookingUrl = client.bookingRedirectUri ?? "";
res.arePlatformEmailsEnabled = client.areEmailsEnabled ?? false;
res.areCalendarEventsEnabled = client.areCalendarEventsEnabled;
}
return res;
} catch (err) {
@@ -16,13 +16,15 @@ import {
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
enum Status {
CANCELLED = "CANCELLED",
REJECTED = "REJECTED",
ACCEPTED = "ACCEPTED",
PENDING = "PENDING",
AWAITING_HOST = "AWAITING_HOST",
}
const Status = {
CANCELLED: "CANCELLED",
REJECTED: "REJECTED",
ACCEPTED: "ACCEPTED",
PENDING: "PENDING",
AWAITING_HOST: "AWAITING_HOST",
} as const;
export type Status = (typeof Status)[keyof typeof Status];
class Attendee {
@IsInt()
@@ -24,4 +24,26 @@ export class BookingReferencesRepository_2024_08_13 {
},
});
}
async getBookingReferencesIncludeSensitiveCredentials(eventUid: string) {
return this.dbRead.prisma.bookingReference.findFirst({
where: {
uid: eventUid,
},
include: {
credential: true,
delegationCredential: true,
booking: {
select: {
user: {
select: {
id: true,
email: true,
},
},
},
},
},
});
}
}
@@ -3,6 +3,7 @@ import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings
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 { CalVideoService } from "@/ee/bookings/2024-08-13/services/cal-video.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";
@@ -77,6 +78,7 @@ import { Module } from "@nestjs/common";
ErrorsBookingsService_2024_08_13,
BookingReferencesService_2024_08_13,
BookingReferencesRepository_2024_08_13,
CalVideoService,
],
controllers: [BookingsController_2024_08_13],
exports: [InputBookingsService_2024_08_13, OutputBookingsService_2024_08_13, BookingsService_2024_08_13],
@@ -169,4 +169,15 @@ export class BookingsRepository_2024_08_13 {
},
});
}
async getByUidWithBookingReference(uid: string) {
return this.dbRead.prisma.booking.findUnique({
where: {
uid,
},
select: {
references: true,
},
});
}
}
@@ -9,6 +9,7 @@ import { ReassignBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outpu
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 { CalVideoService } from "@/ee/bookings/2024-08-13/services/cal-video.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";
@@ -54,6 +55,8 @@ import {
RescheduleBookingInput_2024_08_13,
RescheduleBookingInputPipe,
RescheduleSeatedBookingInput_2024_08_13,
GetBookingRecordingsOutput,
GetBookingTranscriptsOutput,
} from "@calcom/platform-types";
import {
CreateBookingInputPipe,
@@ -88,7 +91,8 @@ export class BookingsController_2024_08_13 {
constructor(
private readonly bookingsService: BookingsService_2024_08_13,
private readonly usersService: UsersService,
private readonly bookingReferencesService: BookingReferencesService_2024_08_13
private readonly bookingReferencesService: BookingReferencesService_2024_08_13,
private readonly calVideoService: CalVideoService
) {}
@Post("/")
@@ -171,6 +175,36 @@ export class BookingsController_2024_08_13 {
};
}
@Get("/:bookingUid/recordings")
@UseGuards(BookingUidGuard)
@ApiOperation({
summary: "Get all the recordings for the booking",
description: `Fetches all the recordings for the booking \`:bookingUid\``,
})
async getBookingRecordings(@Param("bookingUid") bookingUid: string): Promise<GetBookingRecordingsOutput> {
const recordings = await this.calVideoService.getRecordings(bookingUid);
return {
status: SUCCESS_STATUS,
data: recordings,
};
}
@Get("/:bookingUid/transcripts")
@UseGuards(BookingUidGuard)
@ApiOperation({
summary: "Get all the transcripts download links for the booking",
description: `Fetches all the transcripts download links for the booking \`:bookingUid\``,
})
async getBookingTranscripts(@Param("bookingUid") bookingUid: string): Promise<GetBookingTranscriptsOutput> {
const transcripts = await this.calVideoService.getTranscripts(bookingUid);
return {
status: SUCCESS_STATUS,
data: transcripts ?? [],
};
}
@Get("/")
@UseGuards(ApiAuthGuard)
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@@ -233,7 +267,14 @@ export class BookingsController_2024_08_13 {
@ApiOperation({
summary: "Cancel a booking",
description: `:bookingUid can be :bookingUid of an usual booking, individual recurrence or recurring booking to cancel all recurrences.
For seated bookings to cancel one individual booking provide :bookingUid and :seatUid in the request body. For recurring seated bookings it is not possible to cancel all of them with 1 call
\nCancelling seated bookings:
It is possible to cancel specific seat within a booking as an attendee or all of the seats as the host.
\n1. As an attendee - provide :bookingUid in the request URL \`/bookings/:bookingUid/cancel\` and seatUid in the request body \`{"seatUid": "123-123-123"}\` . This will remove this particular attendance from the booking.
\n2. As the host - host can cancel booking for all attendees aka for every seat. Provide :bookingUid in the request URL \`/bookings/:bookingUid/cancel\` and cancellationReason in the request body \`{"cancellationReason": "Will travel"}\` and \`Authorization: Bearer token\` request header where token is event type owner (host) credential. This will cancel the booking for all attendees.
\nCancelling recurring seated bookings:
For recurring seated bookings it is not possible to cancel all of them with 1 call
like with non-seated recurring bookings by providing recurring bookind uid - you have to cancel each recurrence booking by its bookingUid + seatUid.`,
})
@ApiBody({
@@ -874,9 +874,8 @@ describe("Bookings Endpoints 2024-08-13 team emails", () => {
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(AttendeeScheduledEmail.prototype.getHtml).not.toHaveBeenCalled();
expect(AttendeeRescheduledEmail.prototype.getHtml).not.toHaveBeenCalled();
expect(AttendeeRescheduledEmail.prototype.getHtml).toHaveBeenCalled();
expect(OrganizerRescheduledEmail.prototype.getHtml).toHaveBeenCalled();
expect(OrganizerScheduledEmail.prototype.getHtml).toHaveBeenCalled();
expect(OrganizerCancelledEmail.prototype.getHtml).toHaveBeenCalled();
emailsEnabledSetup.roundRobinEventType.rescheduledBookingUid = responseBody.data.uid;
@@ -14,29 +14,27 @@ import { Test } from "@nestjs/testing";
import { User } from "@prisma/client";
import { DateTime } from "luxon";
import * as request from "supertest";
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
import { BookingSeatRepositoryFixture } from "test/fixtures/repository/booking-seat.repository.fixture";
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { randomString } from "test/utils/randomString";
import { withApiAuth } from "test/utils/withApiAuth";
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants";
import {
CancelBookingInput_2024_08_13,
CancelSeatedBookingInput_2024_08_13,
CreateRecurringSeatedBookingOutput_2024_08_13,
CreateSeatedBookingOutput_2024_08_13,
GetBookingOutput_2024_08_13,
GetBookingsOutput_2024_08_13,
GetRecurringSeatedBookingOutput_2024_08_13,
GetSeatedBookingOutput_2024_08_13,
RescheduleSeatedBookingInput_2024_08_13,
} from "@calcom/platform-types";
import {
CreateBookingInput_2024_08_13,
CreateRecurringBookingInput_2024_08_13,
} from "@calcom/platform-types";
import { CreateBookingInput_2024_08_13 } from "@calcom/platform-types";
import { PlatformOAuthClient, Team } from "@calcom/prisma/client";
describe("Bookings Endpoints 2024-08-13", () => {
@@ -51,11 +49,13 @@ describe("Bookings Endpoints 2024-08-13", () => {
let schedulesService: SchedulesService_2024_04_15;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
let oAuthClient: PlatformOAuthClient;
let teamRepositoryFixture: TeamRepositoryFixture;
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
let bookingSeatRepositoryFixture: BookingSeatRepositoryFixture;
const userEmail = `seated-bookings-user-${randomString()}@api.com`;
let user: User;
let apiKeyString: string;
let seatedEventTypeId: number;
const maxRecurrenceCount = 3;
@@ -63,6 +63,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
const seatedEventSlug = `seated-bookings-event-type-${randomString()}`;
let createdSeatedBooking: CreateSeatedBookingOutput_2024_08_13;
let createdSeatedBooking2: CreateSeatedBookingOutput_2024_08_13;
const emailAttendeeOne = `seated-bookings-attendee1-${randomString()}@api.com`;
const nameAttendeeOne = `Attendee One ${randomString()}`;
@@ -70,12 +71,9 @@ describe("Bookings Endpoints 2024-08-13", () => {
const nameAttendeeTwo = `Attendee Two ${randomString()}`;
beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
})
)
const moduleRef = await Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
})
.overrideGuard(PermissionsGuard)
.useValue({
canActivate: () => true,
@@ -87,22 +85,21 @@ describe("Bookings Endpoints 2024-08-13", () => {
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
bookingSeatRepositoryFixture = new BookingSeatRepositoryFixture(moduleRef);
organization = await teamRepositoryFixture.create({
name: `seated-bookings-organization-${randomString()}`,
});
oAuthClient = await createOAuthClient(organization.id);
user = await userRepositoryFixture.create({
email: userEmail,
platformOAuthClients: {
connect: {
id: oAuthClient.id,
},
},
});
const { keyString } = await apiKeysRepositoryFixture.createApiKey(user.id, null);
apiKeyString = `cal_test_${keyString}`;
const userSchedule: CreateScheduleInput_2024_04_15 = {
name: `seated-bookings-2024-08-13-schedule-${randomString()}`,
timeZone: "Europe/Rome",
@@ -118,6 +115,14 @@ describe("Bookings Endpoints 2024-08-13", () => {
seatsShowAttendees: true,
seatsShowAvailabilityCount: true,
locations: [{ type: "inPerson", address: "via 10, rome, italy" }],
metadata: {
disableStandardEmails: {
all: {
attendee: true,
host: true,
},
},
},
},
user.id
);
@@ -129,19 +134,6 @@ describe("Bookings Endpoints 2024-08-13", () => {
await app.init();
});
async function createOAuthClient(organizationId: number) {
const data = {
logo: "logo-url",
name: "name",
redirectUris: ["http://localhost:5555"],
permissions: 32,
};
const secret = "secret";
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
return client;
}
it("should book an event type with seats for the first time", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(),
@@ -333,6 +325,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
return request(app.getHttpServer())
.get("/v2/bookings?sortCreated=asc")
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.set("Authorization", `Bearer ${apiKeyString}`)
.expect(200)
.then(async (response) => {
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
@@ -350,6 +343,22 @@ describe("Bookings Endpoints 2024-08-13", () => {
});
});
it("should not be able to reschedule seated booking if seatUid is not provided", async () => {
const body = {
start: new Date(Date.UTC(2030, 0, 8, 15, 0, 0)).toISOString(),
};
const response = await request(app.getHttpServer())
.post(`/v2/bookings/${createdSeatedBooking.uid}/reschedule`)
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(400);
expect(response.body.error.message).toEqual(
`Booking with uid=${createdSeatedBooking.uid} is a seated booking which means you have to provide seatUid in the request body to specify which seat specifically you want to reschedule. First, fetch the booking using https://cal.com/docs/api-reference/v2/bookings/get-a-booking and then within the attendees array you will find the seatUid of the booking you want to reschedule. Second, provide the seatUid in the request body to specify which seat specifically you want to reschedule using the reschedule endpoint https://cal.com/docs/api-reference/v2/bookings/reschedule-a-booking#option-2`
);
});
it("should reschedule seated booking", async () => {
const body: RescheduleSeatedBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 15, 0, 0)).toISOString(),
@@ -409,67 +418,186 @@ describe("Bookings Endpoints 2024-08-13", () => {
});
});
it("should cancel seated booking", async () => {
const body: CancelSeatedBookingInput_2024_08_13 = {
seatUid: createdSeatedBooking.seatUid,
};
describe("cancel seated booking", () => {
describe("cancel seated booking as attendee", () => {
it("should cancel seated booking", async () => {
const body: CancelSeatedBookingInput_2024_08_13 = {
seatUid: createdSeatedBooking.seatUid,
};
return request(app.getHttpServer())
.post(`/v2/bookings/${createdSeatedBooking.uid}/cancel`)
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: RescheduleBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsGetSeatedBooking(responseBody.data)).toBe(true);
return request(app.getHttpServer())
.post(`/v2/bookings/${createdSeatedBooking.uid}/cancel`)
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: RescheduleBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsGetSeatedBooking(responseBody.data)).toBe(true);
if (responseDataIsGetSeatedBooking(responseBody.data)) {
const data: GetSeatedBookingOutput_2024_08_13 = responseBody.data;
expect(data.id).toBeDefined();
expect(data.uid).toBeDefined();
expect(data.hosts[0].id).toEqual(user.id);
expect(data.status).toEqual("cancelled");
expect(data.start).toEqual(createdSeatedBooking.start);
expect(data.end).toEqual(createdSeatedBooking.end);
expect(data.duration).toEqual(60);
expect(data.eventTypeId).toEqual(seatedEventTypeId);
expect(data.eventType).toEqual({
id: seatedEventTypeId,
slug: seatedEventSlug,
if (responseDataIsGetSeatedBooking(responseBody.data)) {
const data: GetSeatedBookingOutput_2024_08_13 = responseBody.data;
expect(data.id).toBeDefined();
expect(data.uid).toBeDefined();
expect(data.hosts[0].id).toEqual(user.id);
expect(data.status).toEqual("cancelled");
expect(data.start).toEqual(createdSeatedBooking.start);
expect(data.end).toEqual(createdSeatedBooking.end);
expect(data.duration).toEqual(60);
expect(data.eventTypeId).toEqual(seatedEventTypeId);
expect(data.eventType).toEqual({
id: seatedEventTypeId,
slug: seatedEventSlug,
});
expect(data.attendees.length).toEqual(0);
expect(data.location).toBeDefined();
expect(data.absentHost).toEqual(false);
} else {
throw new Error("Invalid response data - expected booking but received array response");
}
});
expect(data.attendees.length).toEqual(0);
expect(data.location).toBeDefined();
expect(data.absentHost).toEqual(false);
} else {
throw new Error("Invalid response data - expected booking but received array response");
}
});
});
describe("cancel seated booking as host", () => {
it("should book an event type with seats", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 14, 0, 0)).toISOString(),
eventTypeId: seatedEventTypeId,
attendee: {
name: nameAttendeeOne,
email: emailAttendeeOne,
timeZone: "Europe/Rome",
language: "it",
},
bookingFieldsResponses: {
codingLanguage: "TypeScript",
},
metadata: {
userId: "100",
},
};
return request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201)
.then(async (response) => {
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsCreateSeatedBooking(responseBody.data)).toBe(true);
if (responseDataIsCreateSeatedBooking(responseBody.data)) {
const data: CreateSeatedBookingOutput_2024_08_13 = responseBody.data;
expect(data.seatUid).toBeDefined();
const seatUid = data.seatUid;
expect(data.id).toBeDefined();
expect(data.uid).toBeDefined();
expect(data.hosts[0].id).toEqual(user.id);
expect(data.status).toEqual("accepted");
expect(data.start).toEqual(body.start);
expect(data.end).toEqual(
DateTime.fromISO(body.start, { zone: "utc" }).plus({ hours: 1 }).toISO()
);
expect(data.duration).toEqual(60);
expect(data.eventTypeId).toEqual(seatedEventTypeId);
expect(data.eventType).toEqual({
id: seatedEventTypeId,
slug: seatedEventSlug,
});
expect(data.attendees.length).toEqual(1);
expect(data.attendees[0]).toEqual({
name: body.attendee.name,
email: body.attendee.email,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
seatUid,
bookingFieldsResponses: {
name: body.attendee.name,
...body.bookingFieldsResponses,
},
metadata: body.metadata,
});
expect(data.location).toBeDefined();
expect(data.absentHost).toEqual(false);
createdSeatedBooking2 = data;
} else {
throw new Error(
"Invalid response data - expected recurring booking but received non array response"
);
}
});
});
it("should not be able to cancel without cancellation reason", async () => {
const response = await request(app.getHttpServer())
.post(`/v2/bookings/${createdSeatedBooking2.uid}/cancel`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.set("Authorization", `Bearer ${apiKeyString}`)
.expect(400);
expect(response.body.message).toEqual("Cancellation reason is required when you are the host");
});
it("should cancel seated booking", async () => {
const body: CancelBookingInput_2024_08_13 = {
cancellationReason: "I will be travelling without internet",
};
return request(app.getHttpServer())
.post(`/v2/bookings/${createdSeatedBooking2.uid}/cancel`)
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.set("Authorization", `Bearer ${apiKeyString}`)
.expect(200)
.then(async (response) => {
const responseBody: RescheduleBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsGetSeatedBooking(responseBody.data)).toBe(true);
if (responseDataIsGetSeatedBooking(responseBody.data)) {
const data: GetSeatedBookingOutput_2024_08_13 = responseBody.data;
expect(data.id).toBeDefined();
expect(data.uid).toBeDefined();
expect(data.hosts[0].id).toEqual(user.id);
expect(data.status).toEqual("cancelled");
expect(data.start).toEqual(createdSeatedBooking2.start);
expect(data.end).toEqual(createdSeatedBooking2.end);
expect(data.duration).toEqual(60);
expect(data.eventTypeId).toEqual(seatedEventTypeId);
expect(data.eventType).toEqual({
id: seatedEventTypeId,
slug: seatedEventSlug,
});
expect(data.attendees.length).toEqual(0);
expect(data.location).toBeDefined();
expect(data.absentHost).toEqual(false);
expect(data.cancellationReason).toEqual("I will be travelling without internet");
const seats = await bookingSeatRepositoryFixture.findAllByBookingId(data.id);
expect(seats.length).toEqual(0);
} else {
throw new Error("Invalid response data - expected booking but received array response");
}
});
});
});
});
function responseDataIsCreateSeatedBooking(data: any): data is CreateSeatedBookingOutput_2024_08_13 {
return data.hasOwnProperty("seatUid");
}
function responseDataIsCreateRecurringSeatedBooking(
data: any
): data is CreateRecurringSeatedBookingOutput_2024_08_13[] {
return Array.isArray(data);
}
function responseDataIsGetSeatedBooking(data: any): data is GetSeatedBookingOutput_2024_08_13 {
return data?.attendees?.every((attendee: any) => attendee?.hasOwnProperty("seatUid"));
}
function responseDataIsGetRecurringSeatedBooking(
data: any
): data is GetRecurringSeatedBookingOutput_2024_08_13[] {
return Array.isArray(data);
}
afterAll(async () => {
await oauthClientRepositoryFixture.delete(oAuthClient.id);
await teamRepositoryFixture.delete(organization.id);
await userRepositoryFixture.deleteByEmail(user.email);
await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email);
@@ -35,6 +35,7 @@ import {
X_CAL_CLIENT_ID,
} from "@calcom/platform-constants";
import { AttendeeScheduledEmail, OrganizerScheduledEmail } from "@calcom/platform-libraries/emails";
import { EventManager } from "@calcom/platform-libraries/event-types";
import {
CreateEventTypeInput_2024_06_14,
FAILED_EVENT_TYPE_IDENTIFICATION_ERROR_MESSAGE,
@@ -78,6 +79,8 @@ describe("Bookings Endpoints 2024-08-13", () => {
const eventTypeSlug = `user-bookings-event-type-${randomString()}`;
let recurringEventTypeId: number;
const recurringEventTypeSlug = `user-bookings-event-type-${randomString()}`;
let eventTypeRequiresConfirmationId: number;
const eventTypeRequiresConfirmationSlug = `user-bookings-event-type-requires-confirmation-${randomString()}`;
let createdBooking: BookingOutput_2024_08_13;
let rescheduledBooking: BookingOutput_2024_08_13;
@@ -153,6 +156,17 @@ describe("Bookings Endpoints 2024-08-13", () => {
);
recurringEventTypeId = recurringEvent.id;
const eventTypeRequiresConfirmation = await eventTypesRepositoryFixture.create(
{
title: `user-bookings-2024-08-13-event-type-requires-confirmation-${randomString()}`,
slug: eventTypeRequiresConfirmationSlug,
length: 60,
requiresConfirmation: true,
},
user.id
);
eventTypeRequiresConfirmationId = eventTypeRequiresConfirmation.id;
bookingInThePast = await bookingsRepositoryFixture.create({
user: {
connect: {
@@ -2399,6 +2413,425 @@ describe("Bookings Endpoints 2024-08-13", () => {
});
});
describe("cant't reschedule cancelled booking", () => {
it("should not be able to reschedule cancelled booking", async () => {
const cancelledBooking = await bookingsRepositoryFixture.create({
status: "CANCELLED",
user: {
connect: {
id: user.id,
},
},
startTime: new Date(Date.UTC(2050, 0, 8, 13, 0, 0)),
endTime: new Date(Date.UTC(2050, 0, 8, 14, 0, 0)),
title: "peer coding lets goo",
uid: `cancelled-booking-${randomString()}`,
eventType: {
connect: {
id: eventTypeId,
},
},
location: "integrations:daily",
customInputs: {},
metadata: {},
responses: {
name: "Oldie",
email: "oldie@gmail.com",
},
attendees: {
create: {
email: "oldie@gmail.com",
name: "Oldie",
locale: "lv",
timeZone: "Europe/Rome",
},
},
});
const body: RescheduleBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2040, 0, 9, 14, 0, 0)).toISOString(),
reschedulingReason: "Flying to mars that day",
};
const response = await request(app.getHttpServer())
.post(`/v2/bookings/${cancelledBooking.uid}/reschedule`)
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(400);
expect(response.body.error.message).toEqual(
`Can't reschedule booking with uid=${cancelledBooking.uid} because it has been cancelled. Please provide uid of a booking that is not cancelled.`
);
await bookingsRepositoryFixture.deleteById(cancelledBooking.id);
});
it("should not be able to reschedule rescheduled booking", async () => {
const rescheduledBooking = await bookingsRepositoryFixture.create({
status: "CANCELLED",
rescheduled: true,
user: {
connect: {
id: user.id,
},
},
startTime: new Date(Date.UTC(2050, 0, 8, 13, 0, 0)),
endTime: new Date(Date.UTC(2050, 0, 8, 14, 0, 0)),
title: "peer coding lets goo",
uid: `cancelled-booking-${randomString()}`,
eventType: {
connect: {
id: eventTypeId,
},
},
location: "integrations:daily",
customInputs: {},
metadata: {},
responses: {
name: "Oldie",
email: "oldie@gmail.com",
},
attendees: {
create: {
email: "oldie@gmail.com",
name: "Oldie",
locale: "lv",
timeZone: "Europe/Rome",
},
},
});
const newBooking = await bookingsRepositoryFixture.create({
status: "ACCEPTED",
user: {
connect: {
id: user.id,
},
},
fromReschedule: rescheduledBooking.uid,
startTime: new Date(Date.UTC(2050, 0, 8, 13, 0, 0)),
endTime: new Date(Date.UTC(2050, 0, 8, 14, 0, 0)),
title: "peer coding lets goo",
uid: `new-booking-${randomString()}`,
eventType: {
connect: {
id: eventTypeId,
},
},
location: "integrations:daily",
customInputs: {},
metadata: {},
responses: {
name: "Oldie",
email: "oldie@gmail.com",
},
attendees: {
create: {
email: "oldie@gmail.com",
name: "Oldie",
locale: "lv",
timeZone: "Europe/Rome",
},
},
});
const body: RescheduleBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2040, 0, 9, 14, 0, 0)).toISOString(),
reschedulingReason: "Flying to mars that day",
};
const response = await request(app.getHttpServer())
.post(`/v2/bookings/${rescheduledBooking.uid}/reschedule`)
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(400);
expect(response.body.error.message).toEqual(
`Can't reschedule booking with uid=${rescheduledBooking.uid} because it has been cancelled and rescheduled already to booking with uid=${newBooking.uid}. You probably want to reschedule ${newBooking.uid} instead by passing it within the request URL.`
);
await bookingsRepositoryFixture.deleteById(rescheduledBooking.id);
await bookingsRepositoryFixture.deleteById(newBooking.id);
});
});
describe("calendar events", () => {
beforeEach(() => {
jest.restoreAllMocks();
jest
.spyOn(EventManager.prototype, "create")
.mockImplementation(() => Promise.resolve({ results: [], referencesToCreate: [] }));
});
describe("platform oAuth client has calendar events enabled", () => {
beforeAll(async () => {
await oauthClientRepositoryFixture.update(oAuthClient.id, {
areCalendarEventsEnabled: true,
});
});
it("should create calendar event when booking normal event", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2040, 0, 9, 9, 0, 0)).toISOString(),
eventTypeId,
attendee: {
name: "Mr Proper",
email: "mr_proper@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
};
return request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201)
.then(async (response) => {
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsBooking(responseBody.data)).toBe(true);
if (responseDataIsBooking(responseBody.data)) {
const data: BookingOutput_2024_08_13 = responseBody.data;
expect(data.id).toBeDefined();
expect(data.uid).toBeDefined();
expect(EventManager.prototype.create).toHaveBeenCalledTimes(1);
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibly recurring bookings"
);
}
});
});
it("should create calendar events when booking recurring event", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2040, 0, 10, 9, 0, 0)).toISOString(),
eventTypeId: recurringEventTypeId,
attendee: {
name: "Mr Proper",
email: "mr_proper@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
};
return request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201)
.then(async (response) => {
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(EventManager.prototype.create).toHaveBeenCalledTimes(3);
});
});
describe("event type requires confirmation", () => {
let bookingThatRequiresConfirmationUid: string;
it("should not create calendar event when booking event that requires confirmation", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2040, 0, 12, 9, 0, 0)).toISOString(),
eventTypeId: eventTypeRequiresConfirmationId,
attendee: {
name: "Mr Proper",
email: "mr_proper@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
};
return request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201)
.then(async (response) => {
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsBooking(responseBody.data)).toBe(true);
if (responseDataIsBooking(responseBody.data)) {
const data: BookingOutput_2024_08_13 = responseBody.data;
expect(data.id).toBeDefined();
expect(data.uid).toBeDefined();
expect(EventManager.prototype.create).toHaveBeenCalledTimes(0);
bookingThatRequiresConfirmationUid = data.uid;
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibly recurring bookings"
);
}
});
});
it("should create calendar event when confirming event that requires confirmation", async () => {
return request(app.getHttpServer())
.post(`/v2/bookings/${bookingThatRequiresConfirmationUid}/confirm`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsBooking(responseBody.data)).toBe(true);
if (responseDataIsBooking(responseBody.data)) {
const data: BookingOutput_2024_08_13 = responseBody.data;
expect(data.id).toBeDefined();
expect(data.uid).toBeDefined();
expect(EventManager.prototype.create).toHaveBeenCalledTimes(1);
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibly recurring bookings"
);
}
});
});
});
});
describe("platform oAuth client has calendar events disabled", () => {
beforeAll(async () => {
await oauthClientRepositoryFixture.update(oAuthClient.id, {
areCalendarEventsEnabled: false,
});
});
it("should not create calendar event when booking", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2040, 0, 9, 10, 0, 0)).toISOString(),
eventTypeId,
attendee: {
name: "Mr Proper",
email: "mr_proper@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
};
const beforeCreate = new Date();
return request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201)
.then(async (response) => {
const afterCreate = new Date();
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(EventManager.prototype.create).toHaveBeenCalledTimes(0);
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibly recurring bookings"
);
}
});
});
it("should not create calendar events when booking recurring event", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2040, 0, 11, 10, 0, 0)).toISOString(),
eventTypeId: recurringEventTypeId,
attendee: {
name: "Mr Proper",
email: "mr_proper@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
};
return request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201)
.then(async (response) => {
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(EventManager.prototype.create).toHaveBeenCalledTimes(0);
});
});
describe("event type requires confirmation", () => {
let bookingThatRequiresConfirmationUid: string;
it("should not create calendar event when booking event that requires confirmation", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2040, 0, 12, 10, 0, 0)).toISOString(),
eventTypeId: eventTypeRequiresConfirmationId,
attendee: {
name: "Mr Proper",
email: "mr_proper@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
};
return request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201)
.then(async (response) => {
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsBooking(responseBody.data)).toBe(true);
if (responseDataIsBooking(responseBody.data)) {
const data: BookingOutput_2024_08_13 = responseBody.data;
expect(data.id).toBeDefined();
expect(data.uid).toBeDefined();
expect(EventManager.prototype.create).toHaveBeenCalledTimes(0);
bookingThatRequiresConfirmationUid = data.uid;
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibly recurring bookings"
);
}
});
});
it("should not create calendar event when confirming event that requires confirmation", async () => {
return request(app.getHttpServer())
.post(`/v2/bookings/${bookingThatRequiresConfirmationUid}/confirm`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsBooking(responseBody.data)).toBe(true);
if (responseDataIsBooking(responseBody.data)) {
const data: BookingOutput_2024_08_13 = responseBody.data;
expect(data.id).toBeDefined();
expect(data.uid).toBeDefined();
expect(EventManager.prototype.create).toHaveBeenCalledTimes(0);
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibly recurring bookings"
);
}
});
});
});
});
});
function responseDataIsRecurranceBooking(data: any): data is RecurringBookingOutput_2024_08_13 {
return (
!Array.isArray(data) &&
@@ -404,6 +404,7 @@ export class BookingsService_2024_08_13 {
platformBookingUrl: bookingRequest.platformBookingUrl,
platformBookingLocation: bookingRequest.platformBookingLocation,
noEmail: bookingRequest.noEmail,
areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled,
});
const ids = bookings.map((booking) => booking.id || 0);
return this.outputService.getOutputRecurringBookings(ids);
@@ -424,6 +425,7 @@ export class BookingsService_2024_08_13 {
platformCancelUrl: bookingRequest.platformCancelUrl,
platformBookingUrl: bookingRequest.platformBookingUrl,
platformBookingLocation: bookingRequest.platformBookingLocation,
areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled,
});
return this.outputService.getOutputCreateRecurringSeatedBookings(
bookings.map((booking) => ({ uid: booking.uid || "", seatUid: booking.seatReferenceUid || "" }))
@@ -445,6 +447,7 @@ export class BookingsService_2024_08_13 {
platformCancelUrl: bookingRequest.platformCancelUrl,
platformBookingUrl: bookingRequest.platformBookingUrl,
platformBookingLocation: bookingRequest.platformBookingLocation,
areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled,
});
if (!booking.uid) {
@@ -475,6 +478,7 @@ export class BookingsService_2024_08_13 {
platformCancelUrl: bookingRequest.platformCancelUrl,
platformBookingUrl: bookingRequest.platformBookingUrl,
platformBookingLocation: bookingRequest.platformBookingLocation,
areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled,
});
if (!booking.uid) {
@@ -637,6 +641,7 @@ export class BookingsService_2024_08_13 {
async rescheduleBooking(request: Request, bookingUid: string, body: RescheduleBookingInput) {
try {
await this.canRescheduleBooking(bookingUid);
const bookingRequest = await this.inputService.createRescheduleBookingRequest(
request,
bookingUid,
@@ -651,6 +656,7 @@ export class BookingsService_2024_08_13 {
platformCancelUrl: bookingRequest.platformCancelUrl,
platformBookingUrl: bookingRequest.platformBookingUrl,
platformBookingLocation: bookingRequest.platformBookingLocation,
areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled,
});
if (!booking.uid) {
throw new Error("Booking missing uid");
@@ -682,15 +688,29 @@ export class BookingsService_2024_08_13 {
}
return this.outputService.getOutputBooking(databaseBooking);
} catch (error) {
if (error instanceof Error) {
if (error.message === "no_available_users_found_error") {
throw new BadRequestException("User either already has booking at this time or is not available");
}
}
throw error;
this.errorsBookingsService.handleBookingError(error, false);
}
}
async canRescheduleBooking(bookingUid: string) {
const booking = await this.bookingsRepository.getByUid(bookingUid);
if (!booking) {
throw new Error(`Booking with uid=${bookingUid} was not found in the database`);
}
if (booking.status === "CANCELLED" && !booking.rescheduled) {
throw new BadRequestException(
`Can't reschedule booking with uid=${bookingUid} because it has been cancelled. Please provide uid of a booking that is not cancelled.`
);
}
if (booking.status === "CANCELLED" && booking.rescheduled) {
const rescheduledTo = await this.bookingsRepository.getByFromReschedule(bookingUid);
throw new BadRequestException(
`Can't reschedule booking with uid=${bookingUid} because it has been cancelled and rescheduled already to booking with uid=${rescheduledTo?.uid}. You probably want to reschedule ${rescheduledTo?.uid} instead by passing it within the request URL.`
);
}
return booking;
}
async cancelBooking(request: Request, bookingUid: string, body: CancelBookingInput) {
if (this.inputService.isCancelSeatedBody(body)) {
const seat = await this.bookingSeatRepository.getByReferenceUid(body.seatUid);
@@ -915,15 +935,19 @@ export class BookingsService_2024_08_13 {
: undefined;
const emailsEnabled = platformClientParams ? platformClientParams.arePlatformEmailsEnabled : true;
const userCalendars = await this.usersRepository.findByIdWithCalendars(requestUser.id);
await confirmBookingHandler({
ctx: {
user: requestUser,
user: {
...requestUser,
destinationCalendar: userCalendars?.destinationCalendar ?? null,
},
},
input: {
bookingId: booking.id,
confirmed: true,
recurringEventId: booking.recurringEventId,
recurringEventId: booking.recurringEventId ?? undefined,
emailsEnabled,
platformClientParams,
},
@@ -943,15 +967,19 @@ export class BookingsService_2024_08_13 {
: undefined;
const emailsEnabled = platformClientParams ? platformClientParams.arePlatformEmailsEnabled : true;
const userCalendars = await this.usersRepository.findByIdWithCalendars(requestUser.id);
await confirmBookingHandler({
ctx: {
user: requestUser,
user: {
...requestUser,
destinationCalendar: userCalendars?.destinationCalendar ?? null,
},
},
input: {
bookingId: booking.id,
confirmed: false,
recurringEventId: booking.recurringEventId,
recurringEventId: booking.recurringEventId ?? undefined,
reason,
emailsEnabled,
platformClientParams,
@@ -0,0 +1,83 @@
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
import { OutputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output.service";
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
import {
getRecordingsOfCalVideoByRoomName,
getAllTranscriptsAccessLinkFromRoomName,
getDownloadLinkOfCalVideoByRecordingId,
} from "@calcom/platform-libraries/conferencing";
@Injectable()
export class CalVideoService {
private readonly logger = new Logger("CalVideoService");
constructor(
private readonly bookingsRepository: BookingsRepository_2024_08_13,
private readonly outputService: OutputBookingsService_2024_08_13
) {}
async getRecordings(bookingUid: string) {
const booking = await this.bookingsRepository.getByUidWithBookingReference(bookingUid);
if (!booking) {
throw new NotFoundException(`Booking with uid=${bookingUid} was not found in the database`);
}
const roomName =
booking?.references?.filter((reference) => reference.type === "daily_video")?.pop()?.meetingId ??
undefined;
if (!roomName) {
throw new NotFoundException(`No Cal Video reference found with booking uid ${bookingUid}`);
}
const recordings = await getRecordingsOfCalVideoByRoomName(roomName);
if (!recordings || !("data" in recordings)) return [];
const recordingWithDownloadLink = recordings.data.map((recording) => {
return getDownloadLinkOfCalVideoByRecordingId(recording.id)
.then((res: { download_link: string } | undefined) => ({
id: recording.id,
roomName: recording.room_name,
startTs: recording.start_ts,
status: recording.status,
maxParticipants: recording.max_participants,
duration: recording.duration,
shareToken: recording.share_token,
downloadLink: res?.download_link,
}))
.catch((err: Error) => ({
id: recording.id,
roomName: recording.room_name,
startTs: recording.start_ts,
status: recording.status,
maxParticipants: recording.max_participants,
duration: recording.duration,
shareToken: recording.share_token,
downloadLink: null,
error: err.message,
}));
});
const allRecordingsWithDownloadLink = await Promise.all(recordingWithDownloadLink);
return allRecordingsWithDownloadLink;
}
async getTranscripts(bookingUid: string) {
const booking = await this.bookingsRepository.getByUidWithBookingReference(bookingUid);
if (!booking) {
throw new NotFoundException(`Booking with uid=${bookingUid} was not found in the database`);
}
const roomName =
booking?.references?.filter((reference) => reference.type === "daily_video")?.pop()?.meetingId ??
undefined;
if (!roomName) {
throw new NotFoundException(`No Cal Video reference found with booking uid ${bookingUid}`);
}
const transcripts = await getAllTranscriptsAccessLinkFromRoomName(roomName);
return transcripts;
}
}
@@ -10,6 +10,7 @@ import {
import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.service";
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
import { OutputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/output-event-types.service";
import { apiToInternalintegrationsMapping } from "@/ee/event-types/event-types_2024_06_14/transformers";
import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key";
import { defaultBookingResponses } from "@/lib/safe-parse/default-responses-booking";
import { safeParse } from "@/lib/safe-parse/safe-parse";
@@ -29,10 +30,7 @@ import { NextApiRequest } from "next/types";
import { v4 as uuidv4 } from "uuid";
import { z } from "zod";
import {
EventTypeMetaDataSchema,
apiToInternalintegrationsMapping,
} from "@calcom/platform-libraries/event-types";
import { EventTypeMetaDataSchema } from "@calcom/platform-libraries/event-types";
import {
CancelBookingInput,
CancelBookingInput_2024_08_13,
@@ -61,6 +59,7 @@ type OAuthRequestParams = {
platformBookingUrl: string;
platformBookingLocation?: string;
arePlatformEmailsEnabled: boolean;
areCalendarEventsEnabled: boolean;
};
export enum Frequency {
@@ -587,6 +586,11 @@ export class InputBookingsService_2024_08_13 {
if (!eventType) {
throw new NotFoundException(`Event type with id=${booking.eventTypeId} not found`);
}
if (eventType.seatsPerTimeSlot) {
throw new BadRequestException(
`Booking with uid=${bookingUid} is a seated booking which means you have to provide seatUid in the request body to specify which seat specifically you want to reschedule. First, fetch the booking using https://cal.com/docs/api-reference/v2/bookings/get-a-booking and then within the attendees array you will find the seatUid of the booking you want to reschedule. Second, provide the seatUid in the request body to specify which seat specifically you want to reschedule using the reschedule endpoint https://cal.com/docs/api-reference/v2/bookings/reschedule-a-booking#option-2`
);
}
const bookingResponses = safeParse(
bookingResponsesSchema,
@@ -22,7 +22,7 @@ import {
RecurringBookingOutput_2024_08_13,
SeatedAttendee,
} from "@calcom/platform-types";
import { Booking, BookingReference, BookingSeat } from "@calcom/prisma/client";
import { Booking, BookingSeat } from "@calcom/prisma/client";
export const bookingResponsesSchema = z
.object({
@@ -266,6 +266,7 @@ export class OutputBookingsService_2024_08_13 {
description: databaseBooking.description,
hosts: [this.getHost(databaseBooking.user)],
status: databaseBooking.status.toLowerCase(),
cancellationReason: databaseBooking.cancellationReason || undefined,
rescheduledFromUid: databaseBooking.fromReschedule || undefined,
start: databaseBooking.startTime,
end: databaseBooking.endTime,
@@ -54,6 +54,7 @@ export class PlatformBookingsService {
platformRescheduleUrl: oAuthClient.bookingRescheduleRedirectUri,
platformBookingUrl: oAuthClient.bookingRedirectUri,
arePlatformEmailsEnabled: oAuthClient.areEmailsEnabled,
areCalendarEventsEnabled: oAuthClient.areCalendarEventsEnabled,
};
}
@@ -78,6 +79,7 @@ export class PlatformBookingsService {
platformRescheduleUrl: oAuthClient.bookingRescheduleRedirectUri,
platformBookingUrl: oAuthClient.bookingRedirectUri,
arePlatformEmailsEnabled: oAuthClient.areEmailsEnabled,
areCalendarEventsEnabled: oAuthClient.areCalendarEventsEnabled,
};
}
@@ -1,3 +1,5 @@
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 { CalendarsRepository } from "@/ee/calendars/calendars.repository";
import { CalendarsController } from "@/ee/calendars/controllers/calendars.controller";
import { AppleCalendarService } from "@/ee/calendars/services/apple-calendar.service";
@@ -25,8 +27,10 @@ import { Module } from "@nestjs/common";
SelectedCalendarsRepository,
AppsRepository,
CalendarsRepository,
BookingsRepository_2024_08_13,
BookingReferencesRepository_2024_08_13,
],
controllers: [CalendarsController],
exports: [CalendarsService],
exports: [CalendarsService, GoogleCalendarService],
})
export class CalendarsModule {}
@@ -54,6 +54,23 @@ import {
} from "@calcom/platform-constants";
import { ApiResponse, CalendarBusyTimesInput, CreateCalendarCredentialsInput } from "@calcom/platform-types";
export interface CalendarState {
accessToken: string;
origin: string;
redir?: string;
isDryRun?: boolean;
}
const calendarStateSchema = z.object({
accessToken: z.string(),
origin: z.string(),
redir: z.string().optional(),
isDryRun: z
.string()
.optional()
.transform((val) => val === "true"),
});
@Controller({
path: "/v2/calendars",
version: API_VERSIONS_VALUES,
@@ -181,32 +198,31 @@ export class CalendarsController {
@Query("code") code: string,
@Param("calendar") calendar: string
): Promise<{ url: string }> {
// state params contains our user access token
const stateParams = new URLSearchParams(state);
const { accessToken, origin, redir, isDryRun } = z
.object({
accessToken: z.string(),
origin: z.string(),
redir: z.string().nullish().optional(),
isDryRun: z.string().nullish().optional(),
})
.parse({
let stateObj: CalendarState;
try {
// First try to parse as JSON
stateObj = JSON.parse(state) as CalendarState;
} catch (e) {
// If JSON parsing fails, try URL params
const stateParams = new URLSearchParams(state);
const parsedState = calendarStateSchema.parse({
accessToken: stateParams.get("accessToken"),
origin: stateParams.get("origin"),
redir: stateParams.get("redir"),
redir: stateParams.get("redir") || undefined,
isDryRun: stateParams.get("isDryRun"),
});
stateObj = parsedState;
}
const { accessToken, origin, redir, isDryRun } = stateObj;
switch (calendar) {
case OFFICE_365_CALENDAR:
return await this.outlookService.save(code, accessToken, origin, redir ?? "", isDryRun === "true");
return await this.outlookService.save(code, accessToken, origin, redir ?? "", !!isDryRun);
case GOOGLE_CALENDAR:
return await this.googleCalendarService.save(
code,
accessToken,
origin,
redir ?? "",
isDryRun === "true"
);
return await this.googleCalendarService.save(code, accessToken, origin, redir ?? "", !!isDryRun);
default:
throw new BadRequestException(
"Invalid calendar type, available calendars are: ",
@@ -22,6 +22,7 @@ import { APPS_TYPE_ID_MAPPING } from "@calcom/platform-constants";
import {
getConnectedDestinationCalendarsAndEnsureDefaultsInDb,
getBusyCalendarTimes,
type EventBusyDate,
} from "@calcom/platform-libraries";
import { Calendar } from "@calcom/platform-types";
import { PrismaClient } from "@calcom/prisma";
@@ -45,6 +46,7 @@ export class CalendarsService {
...credential,
delegatedTo: null,
delegatedToId: null,
delegationCredentialId: null,
}))
.filter((credential) => !!credential);
}
@@ -88,18 +90,19 @@ export class CalendarsService {
dateTo,
composedSelectedCalendars
);
// @ts-expect-error Element implicitly has any type
const calendarBusyTimesConverted = calendarBusyTimes.map((busyTime) => {
const busyTimeStart = DateTime.fromJSDate(new Date(busyTime.start)).setZone(timezone);
const busyTimeEnd = DateTime.fromJSDate(new Date(busyTime.end)).setZone(timezone);
const busyTimeStartDate = busyTimeStart.toJSDate();
const busyTimeEndDate = busyTimeEnd.toJSDate();
return {
...busyTime,
start: busyTimeStartDate,
end: busyTimeEndDate,
};
});
const calendarBusyTimesConverted = calendarBusyTimes.map(
(busyTime: EventBusyDate & { timeZone?: string }) => {
const busyTimeStart = DateTime.fromJSDate(new Date(busyTime.start)).setZone(timezone);
const busyTimeEnd = DateTime.fromJSDate(new Date(busyTime.end)).setZone(timezone);
const busyTimeStartDate = busyTimeStart.toJSDate();
const busyTimeEndDate = busyTimeEnd.toJSDate();
return {
...busyTime,
start: busyTimeStartDate,
end: busyTimeEndDate,
};
}
);
return calendarBusyTimesConverted;
} catch (error) {
throw new InternalServerErrorException(
@@ -1,4 +1,5 @@
import { OAuthCalendarApp } from "@/ee/calendars/calendars.interface";
import type { CalendarState } from "@/ee/calendars/controllers/calendars.controller";
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
import { AppsRepository } from "@/modules/apps/apps.repository";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
@@ -23,7 +24,7 @@ const CALENDAR_SCOPES = [
@Injectable()
export class GoogleCalendarService implements OAuthCalendarApp {
private redirectUri = `${this.config.get("api.url")}/gcal/oauth/save`;
public readonly redirectUri = `${this.config.get("api.url")}/gcal/oauth/save`;
private gcalResponseSchema = z.object({ client_id: z.string(), client_secret: z.string() });
private logger = new Logger("GcalService");
@@ -65,12 +66,18 @@ export class GoogleCalendarService implements OAuthCalendarApp {
async getCalendarRedirectUrl(accessToken: string, origin: string, redir?: string, isDryRun?: boolean) {
const oAuth2Client = await this.getOAuthClient(this.redirectUri);
const state: CalendarState = {
accessToken,
origin,
redir,
isDryRun,
};
const authUrl = oAuth2Client.generateAuthUrl({
access_type: "offline",
scope: CALENDAR_SCOPES,
prompt: "consent",
state: `accessToken=${accessToken}&origin=${origin}&redir=${redir ?? ""}&isDryRun=${isDryRun}`,
state: JSON.stringify(state),
});
return authUrl;
@@ -1,4 +1,5 @@
import { OAuthCalendarApp } from "@/ee/calendars/calendars.interface";
import { CalendarState } from "@/ee/calendars/controllers/calendars.controller";
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
@@ -60,6 +61,13 @@ export class OutlookService implements OAuthCalendarApp {
async getCalendarRedirectUrl(accessToken: string, origin: string, redir?: string, isDryRun?: boolean) {
const { client_id } = await this.calendarsService.getAppKeys(OFFICE_365_CALENDAR_ID);
const state: CalendarState = {
accessToken,
origin,
redir,
isDryRun,
};
const scopes = ["User.Read", "Calendars.Read", "Calendars.ReadWrite", "offline_access"];
const params = {
response_type: "code",
@@ -67,7 +75,7 @@ export class OutlookService implements OAuthCalendarApp {
client_id,
prompt: "select_account",
redirect_uri: this.redirectUri,
state: `accessToken=${accessToken}&origin=${origin}&redir=${redir ?? ""}&isDryRun=${isDryRun}`,
state: JSON.stringify(state),
};
const query = stringify(params);
@@ -5,6 +5,7 @@ import { Editable } from "@/ee/event-types/event-types_2024_04_15/inputs/enums/e
import { BaseField } from "@/ee/event-types/event-types_2024_04_15/inputs/enums/field-type";
import { UpdateEventTypeInput_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/inputs/update-event-type.input";
import { EventTypeOutput } from "@/ee/event-types/event-types_2024_04_15/outputs/event-type.output";
import { systemBeforeFieldEmail } from "@/ee/event-types/event-types_2024_06_14/transformers";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
@@ -17,7 +18,6 @@ import {
updateEventType,
EventTypesPublic,
getEventTypesPublic,
systemBeforeFieldEmail,
} from "@calcom/platform-libraries/event-types";
import { EventType } from "@calcom/prisma/client";
@@ -1,4 +1,4 @@
import { OrganizerIntegrationLocation } from "@calcom/lib/event-types/transformers/internal/locations";
import { OrganizerIntegrationLocation } from "@/ee/event-types/event-types_2024_06_14/transformers";
type BaseEventType = {
length: number;
@@ -34,6 +34,7 @@ import {
ApiSuccessResponse,
CreateEventTypeInput_2024_06_14,
EventTypeOutput_2024_06_14,
GuestsDefaultFieldOutput_2024_06_14,
NameDefaultFieldInput_2024_06_14,
NotesDefaultFieldInput_2024_06_14,
UpdateEventTypeInput_2024_06_14,
@@ -535,6 +536,7 @@ describe("Event types Endpoints", () => {
body.lockTimeZoneToggleOnBookingPage
);
expect(createdEventType.color).toEqual(body.color);
expect(createdEventType.disableGuests).toEqual(false);
const expectedBookingFields = [
{ ...defaultResponseBookingFieldName, ...nameBookingField },
@@ -962,6 +964,10 @@ describe("Event types Endpoints", () => {
title: newTitle,
scheduleId: secondSchedule.id,
lengthInMinutesOptions: [15, 30],
calVideoSettings: {
disableRecordingForGuests: true,
disableRecordingForOrganizer: true,
},
bookingFields: [
nameBookingField,
{
@@ -1037,6 +1043,7 @@ describe("Event types Endpoints", () => {
expect(updatedEventType.description).toEqual(eventType.description);
expect(updatedEventType.lengthInMinutes).toEqual(eventType.lengthInMinutes);
expect(updatedEventType.locations).toEqual(eventType.locations);
expect(updatedEventType.disableGuests).toEqual(false);
const expectedBookingFields = [
{ ...defaultResponseBookingFieldName, ...nameBookingField },
@@ -1082,6 +1089,12 @@ describe("Event types Endpoints", () => {
body.lockTimeZoneToggleOnBookingPage
);
expect(updatedEventType.color).toEqual(body.color);
expect(updatedEventType.calVideoSettings?.disableRecordingForGuests).toEqual(
body.calVideoSettings?.disableRecordingForGuests
);
expect(updatedEventType.calVideoSettings?.disableRecordingForOrganizer).toEqual(
body.calVideoSettings?.disableRecordingForOrganizer
);
eventType.title = newTitle;
eventType.scheduleId = secondSchedule.id;
@@ -1841,6 +1854,130 @@ describe("Event types Endpoints", () => {
expect(slug2).not.toEqual(slug);
});
});
it("should create event type with cal video settings", async () => {
const body: CreateEventTypeInput_2024_06_14 = {
title: "event type with cal video settings",
slug: "event-type-with-cal-video-settings",
lengthInMinutes: 60,
calVideoSettings: {
disableRecordingForGuests: true,
disableRecordingForOrganizer: true,
},
locations: [
{
type: "integration",
integration: "cal-video",
},
],
};
return request(app.getHttpServer())
.post("/api/v2/event-types")
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.send(body)
.expect(201)
.then(async (response) => {
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
const createdEventType = responseBody.data;
expect(createdEventType).toHaveProperty("id");
expect(createdEventType.title).toEqual(body.title);
expect(createdEventType.locations).toEqual(body.locations);
expect(createdEventType.calVideoSettings?.disableRecordingForGuests).toEqual(true);
expect(createdEventType.calVideoSettings?.disableRecordingForOrganizer).toEqual(true);
firstCreatedEventType = responseBody.data;
});
});
});
describe("toggle disable guests", () => {
let eventTypeWithGuestsDisabledId: number;
it("should create an event type with guests disabled", async () => {
const body: CreateEventTypeInput_2024_06_14 = {
title: "Coding class guests disabled",
slug: "coding-class-guests-disabled",
description: "Let's learn how to code like a pro.",
lengthInMinutes: 60,
disableGuests: true,
};
return request(app.getHttpServer())
.post("/api/v2/event-types")
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.send(body)
.expect(201)
.then(async (response) => {
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
const createdEventType = responseBody.data;
expect(createdEventType).toHaveProperty("id");
expect(createdEventType.title).toEqual(body.title);
expect(createdEventType.description).toEqual(body.description);
expect(createdEventType.lengthInMinutes).toEqual(body.lengthInMinutes);
expect(createdEventType.disableGuests).toEqual(true);
const guestsBookingField = createdEventType.bookingFields.find(
(field) => field.slug === "guests"
) as GuestsDefaultFieldOutput_2024_06_14 | undefined;
expect(guestsBookingField?.hidden).toEqual(true);
eventTypeWithGuestsDisabledId = createdEventType.id;
});
});
it("should update an event type with guests enabled", async () => {
const body: UpdateEventTypeInput_2024_06_14 = {
disableGuests: false,
};
return request(app.getHttpServer())
.patch(`/api/v2/event-types/${eventTypeWithGuestsDisabledId}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.send(body)
.expect(200)
.then(async (response) => {
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
const updatedEventType = responseBody.data;
expect(updatedEventType.disableGuests).toEqual(false);
const guestsBookingField = updatedEventType.bookingFields.find(
(field) => field.slug === "guests"
) as GuestsDefaultFieldOutput_2024_06_14 | undefined;
expect(guestsBookingField?.hidden).toEqual(false);
eventTypeWithGuestsDisabledId = updatedEventType.id;
});
});
it("should update an event type with guests disabled", async () => {
const body: UpdateEventTypeInput_2024_06_14 = {
disableGuests: true,
};
return request(app.getHttpServer())
.patch(`/api/v2/event-types/${eventTypeWithGuestsDisabledId}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.send(body)
.expect(200)
.then(async (response) => {
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
const updatedEventType = responseBody.data;
expect(updatedEventType.disableGuests).toEqual(true);
const guestsBookingField = updatedEventType.bookingFields.find(
(field) => field.slug === "guests"
) as GuestsDefaultFieldOutput_2024_06_14 | undefined;
expect(guestsBookingField?.hidden).toEqual(true);
eventTypeWithGuestsDisabledId = updatedEventType.id;
});
});
afterAll(async () => {
if (eventTypeWithGuestsDisabledId) {
await eventTypesRepositoryFixture.delete(eventTypeWithGuestsDisabledId);
}
});
});
afterAll(async () => {
@@ -114,6 +114,9 @@ export class EventTypesController_2024_06_14 {
@Query() queryParams: GetEventTypesQuery_2024_06_14
): Promise<GetEventTypesOutput_2024_06_14> {
const eventTypes = await this.eventTypesService.getEventTypes(queryParams);
if (!eventTypes || eventTypes.length === 0) {
throw new NotFoundException(`Event types not found`);
}
const eventTypesFormatted = this.eventTypeResponseTransformPipe.transform(eventTypes);
const eventTypesWithoutHiddenFields = eventTypesFormatted.map((eventType) => {
return {
@@ -1,10 +1,9 @@
import { InputEventTransformed_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/transformed";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
import type { Prisma } from "@prisma/client";
import { InputEventTransformed_2024_06_14 } from "@calcom/platform-types";
@Injectable()
export class EventTypesRepository_2024_06_14 {
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
@@ -13,13 +12,20 @@ export class EventTypesRepository_2024_06_14 {
userId: number,
body: Omit<InputEventTransformed_2024_06_14, "destinationCalendar">
) {
const { calVideoSettings, ...restBody } = body;
return this.dbWrite.prisma.eventType.create({
data: {
...body,
...restBody,
userId,
locations: body.locations,
bookingFields: body.bookingFields,
users: { connect: { id: userId } },
...(calVideoSettings && {
calVideoSettings: {
create: calVideoSettings,
},
}),
},
});
}
@@ -72,7 +78,7 @@ export class EventTypesRepository_2024_06_14 {
async getEventTypeById(eventTypeId: number) {
return this.dbRead.prisma.eventType.findUnique({
where: { id: eventTypeId },
include: { users: true, schedule: true, destinationCalendar: true },
include: { users: true, schedule: true, destinationCalendar: true, calVideoSettings: true },
});
}
@@ -1,5 +1,7 @@
import { DEFAULT_EVENT_TYPES } from "@/ee/event-types/event-types_2024_06_14/constants/constants";
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
import { InputEventTransformed_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/transformed";
import { SystemField, CustomField } from "@/ee/event-types/event-types_2024_06_14/transformers";
import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
@@ -14,10 +16,8 @@ import {
updateEventType,
getEventTypesPublic,
EventTypesPublic,
SystemField,
CustomField,
} from "@calcom/platform-libraries/event-types";
import { GetEventTypesQuery_2024_06_14, InputEventTransformed_2024_06_14 } from "@calcom/platform-types";
import { GetEventTypesQuery_2024_06_14 } from "@calcom/platform-types";
import { EventType } from "@calcom/prisma/client";
@Injectable()
@@ -1,13 +1,7 @@
import { ConnectedCalendarsData } from "@/ee/calendars/outputs/connected-calendars.output";
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
import { ConferencingService } from "@/modules/conferencing/services/conferencing.service";
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 { InputEventTransformed_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/transformed";
import {
transformBookingFieldsApiToInternal,
transformLocationsApiToInternal,
@@ -21,21 +15,24 @@ import {
systemAfterFieldNotes,
systemAfterFieldGuests,
systemAfterFieldRescheduleReason,
EventTypeMetaDataSchema,
transformBookerLayoutsApiToInternal,
transformConfirmationPolicyApiToInternal,
transformEventColorsApiToInternal,
validateCustomEventName,
transformSeatsApiToInternal,
SystemField,
CustomField,
InternalLocation,
InternalLocationSchema,
} from "@calcom/platform-libraries/event-types";
} from "@/ee/event-types/event-types_2024_06_14/transformers";
import { UserWithProfile } from "@/modules/users/users.repository";
import { Injectable, BadRequestException } from "@nestjs/common";
import { getApps, getUsersCredentialsIncludeServiceAccountKey } from "@calcom/platform-libraries/app-store";
import { validateCustomEventName, EventTypeMetaDataSchema } from "@calcom/platform-libraries/event-types";
import {
CreateEventTypeInput_2024_06_14,
DestinationCalendar_2024_06_14,
InputEventTransformed_2024_06_14,
InputBookingField_2024_06_14,
OutputUnknownLocation_2024_06_14,
UpdateEventTypeInput_2024_06_14,
} from "@calcom/platform-types";
@@ -121,16 +118,23 @@ export class InputEventTypesService_2024_06_14 {
seats,
customName,
useDestinationCalendarEmail,
disableGuests,
...rest
} = inputEventType;
const confirmationPolicyTransformed = this.transformInputConfirmationPolicy(confirmationPolicy);
const locationsTransformed = locations?.length ? this.transformInputLocations(locations) : undefined;
const effectiveBookingFields =
disableGuests !== undefined
? this.getBookingFieldsWithGuestsToggled(bookingFields, disableGuests)
: bookingFields;
const eventType = {
...rest,
length: lengthInMinutes,
locations: locationsTransformed,
bookingFields: this.transformInputBookingFields(bookingFields),
bookingFields: this.transformInputBookingFields(effectiveBookingFields),
bookingLimits: bookingLimitsCount ? this.transformInputIntervalLimits(bookingLimitsCount) : undefined,
durationLimits: bookingLimitsDuration
? this.transformInputIntervalLimits(bookingLimitsDuration)
@@ -171,6 +175,7 @@ export class InputEventTypesService_2024_06_14 {
seats,
customName,
useDestinationCalendarEmail,
disableGuests,
...rest
} = inputEventType;
const eventTypeDb = await this.eventTypesRepository.getEventTypeWithMetaData(eventTypeId);
@@ -180,11 +185,18 @@ export class InputEventTypesService_2024_06_14 {
const confirmationPolicyTransformed = this.transformInputConfirmationPolicy(confirmationPolicy);
const effectiveBookingFields =
disableGuests !== undefined
? this.getBookingFieldsWithGuestsToggled(bookingFields, disableGuests)
: bookingFields;
const eventType = {
...rest,
length: lengthInMinutes,
locations: locations ? this.transformInputLocations(locations) : undefined,
bookingFields: bookingFields ? this.transformInputBookingFields(bookingFields) : undefined,
bookingFields: effectiveBookingFields
? this.transformInputBookingFields(effectiveBookingFields)
: undefined,
bookingLimits: bookingLimitsCount ? this.transformInputIntervalLimits(bookingLimitsCount) : undefined,
durationLimits: bookingLimitsDuration
? this.transformInputIntervalLimits(bookingLimitsDuration)
@@ -210,6 +222,27 @@ export class InputEventTypesService_2024_06_14 {
return eventType;
}
getBookingFieldsWithGuestsToggled(
bookingFields: InputBookingField_2024_06_14[] | undefined,
hideGuests: boolean
) {
const toggledGuestsBookingField: InputBookingField_2024_06_14 = { slug: "guests", hidden: hideGuests };
if (!bookingFields) {
return [toggledGuestsBookingField];
}
const bookingFieldsCopy = [...bookingFields];
const guestsBookingField = bookingFieldsCopy.find((field) => "slug" in field && field.slug === "guests");
if (guestsBookingField) {
Object.assign(guestsBookingField, { hidden: hideGuests });
return bookingFieldsCopy;
}
bookingFieldsCopy.push(toggledGuestsBookingField);
return bookingFieldsCopy;
}
transformInputLocations(inputLocations: CreateEventTypeInput_2024_06_14["locations"]) {
return transformLocationsApiToInternal(inputLocations);
}
@@ -478,7 +511,7 @@ export class InputEventTypesService_2024_06_14 {
appSlug = "msteams";
}
const credentials = await getUsersCredentials(user);
const credentials = await getUsersCredentialsIncludeServiceAccountKey(user);
const foundApp = getApps(credentials, true).filter((app) => app.slug === appSlug)[0];
@@ -1,14 +1,4 @@
import { Injectable } from "@nestjs/common";
import type { EventType, User, Schedule, DestinationCalendar } from "@prisma/client";
import {
userMetadata,
parseBookingLimit,
parseRecurringEvent,
getBookingFieldsWithSystemFields,
} from "@calcom/platform-libraries";
import {
EventTypeMetaDataSchema,
transformLocationsInternalToApi,
transformBookingFieldsInternalToApi,
InternalLocationSchema,
@@ -20,11 +10,20 @@ import {
transformBookerLayoutsInternalToApi,
transformRequiresConfirmationInternalToApi,
transformEventTypeColorsInternalToApi,
parseEventTypeColor,
transformSeatsInternalToApi,
InternalLocation,
BookingFieldSchema,
} from "@calcom/platform-libraries/event-types";
} from "@/ee/event-types/event-types_2024_06_14/transformers";
import { Injectable } from "@nestjs/common";
import type { EventType, User, Schedule, DestinationCalendar, CalVideoSettings } from "@prisma/client";
import {
userMetadata,
parseBookingLimit,
parseRecurringEvent,
getBookingFieldsWithSystemFields,
} from "@calcom/platform-libraries";
import { EventTypeMetaDataSchema, parseEventTypeColor } from "@calcom/platform-libraries/event-types";
import {
TransformFutureBookingsLimitSchema_2024_06_14,
BookerLayoutsTransformedSchema,
@@ -38,8 +37,9 @@ type EventTypeRelations = {
users: User[];
schedule: Schedule | null;
destinationCalendar?: DestinationCalendar | null;
calVideoSettings?: CalVideoSettings | null;
};
export type DatabaseEventType = EventType & EventTypeRelations;
export type DatabaseEventType = Omit<EventType, "allowReschedulingCancelledBookings"> & EventTypeRelations;
type Input = Pick<
DatabaseEventType,
@@ -88,6 +88,7 @@ type Input = Pick<
| "useEventTypeDestinationCalendarEmail"
| "hideCalendarEventDetails"
| "hideOrganizerEmail"
| "calVideoSettings"
>;
@Injectable()
@@ -125,6 +126,7 @@ export class OutputEventTypesService_2024_06_14 {
useEventTypeDestinationCalendarEmail,
hideCalendarEventDetails,
hideOrganizerEmail,
calVideoSettings,
} = databaseEventType;
const locations = this.transformLocations(databaseEventType.locations);
@@ -200,6 +202,7 @@ export class OutputEventTypesService_2024_06_14 {
useDestinationCalendarEmail: useEventTypeDestinationCalendarEmail,
hideCalendarEventDetails,
hideOrganizerEmail,
calVideoSettings,
};
}
@@ -1,5 +1,3 @@
import type { z } from "zod";
import type {
transformEventColorsApiToInternal,
transformFutureBookingLimitsApiToInternal,
@@ -8,9 +6,13 @@ import type {
transformSeatsApiToInternal,
transformBookingFieldsApiToInternal,
InternalLocationsSchema,
} from "@calcom/lib/event-types/transformers";
} from "@/ee/event-types/event-types_2024_06_14/transformers";
import type { z } from "zod";
import type { CreateEventTypeInput_2024_06_14, ConfirmationPolicyTransformedSchema } from "../inputs";
import type {
CreateEventTypeInput_2024_06_14,
ConfirmationPolicyTransformedSchema,
} from "@calcom/platform-types";
export type InputEventTransformed_2024_06_14 = Omit<
CreateEventTypeInput_2024_06_14,
@@ -1,12 +1,10 @@
import { describe, expect, it } from "vitest";
import {
BookerLayoutsInputEnum_2024_06_14,
BookerLayoutsOutputEnum_2024_06_14,
ConfirmationPolicyEnum,
NoticeThresholdUnitEnum,
FrequencyInput,
} from "@calcom/platform-enums/monorepo";
} from "@calcom/platform-enums";
import type {
InputBookingField_2024_06_14,
InputLocation_2024_06_14,
@@ -1,7 +1,7 @@
import {
BookerLayoutsInputEnum_2024_06_14,
BookerLayoutsOutputEnum_2024_06_14,
} from "@calcom/platform-enums/monorepo";
} from "@calcom/platform-enums";
import { type CreateEventTypeInput_2024_06_14 } from "@calcom/platform-types";
export function transformBookerLayoutsApiToInternal(
@@ -1,4 +1,4 @@
import { ConfirmationPolicyEnum } from "@calcom/platform-enums/monorepo";
import { ConfirmationPolicyEnum } from "@calcom/platform-enums";
import type {
ConfirmationPolicyTransformedSchema,
NoticeThresholdTransformedSchema,
@@ -1,7 +1,7 @@
import {
BookingWindowPeriodInputTypeEnum_2024_06_14,
BookingWindowPeriodOutputTypeEnum_2024_06_14,
} from "@calcom/platform-enums/monorepo";
} from "@calcom/platform-enums";
import {
type CreateEventTypeInput_2024_06_14,
type BusinessDaysWindow_2024_06_14,
@@ -1,4 +1,4 @@
import { BookingLimitsEnum_2024_06_14 } from "@calcom/platform-enums/monorepo";
import { BookingLimitsEnum_2024_06_14 } from "@calcom/platform-enums";
import {
type CreateEventTypeInput_2024_06_14,
type BookingLimitsKeyOutputType_2024_06_14,
@@ -1,4 +1,4 @@
import { Frequency } from "@calcom/platform-enums/monorepo";
import { Frequency } from "@calcom/platform-enums";
import type { Recurrence_2024_06_14 } from "@calcom/platform-types";
import { type TransformRecurringEventSchema_2024_06_14 } from "@calcom/platform-types";
@@ -1,7 +1,7 @@
import {
BookerLayoutsInputEnum_2024_06_14,
BookerLayoutsOutputEnum_2024_06_14,
} from "@calcom/platform-enums/monorepo";
} from "@calcom/platform-enums";
import type { BookerLayoutsTransformedSchema } from "@calcom/platform-types";
export function transformBookerLayoutsInternalToApi(
@@ -1,7 +1,7 @@
import {
BookingWindowPeriodInputTypeEnum_2024_06_14,
BookingWindowPeriodOutputTypeEnum_2024_06_14,
} from "@calcom/platform-enums/monorepo";
} from "@calcom/platform-enums";
import type {
TransformFutureBookingsLimitSchema_2024_06_14,
BookingWindow_2024_06_14,
@@ -1,11 +1,9 @@
import { describe, expect, it } from "vitest";
import {
BookerLayoutsInputEnum_2024_06_14,
BookerLayoutsOutputEnum_2024_06_14,
ConfirmationPolicyEnum,
NoticeThresholdUnitEnum,
} from "@calcom/platform-enums/monorepo";
} from "@calcom/platform-enums";
import type {
TransformBookingLimitsSchema_2024_06_14,
TransformFutureBookingsLimitSchema_2024_06_14,
@@ -1,4 +1,4 @@
import { BookingLimitsEnum_2024_06_14 } from "@calcom/platform-enums/monorepo";
import { BookingLimitsEnum_2024_06_14 } from "@calcom/platform-enums";
import type {
BookingLimitsKeysInputType,
TransformBookingLimitsSchema_2024_06_14,
@@ -1,4 +1,4 @@
import { Frequency, FrequencyInput } from "@calcom/platform-enums/monorepo";
import { Frequency, FrequencyInput } from "@calcom/platform-enums";
import type { Recurrence_2024_06_14, TransformRecurringEventSchema_2024_06_14 } from "@calcom/platform-types";
export function transformRecurrenceInternalToApi(
@@ -1,4 +1,4 @@
import { ConfirmationPolicyEnum } from "@calcom/platform-enums/monorepo";
import { ConfirmationPolicyEnum } from "@calcom/platform-enums";
import type { NoticeThresholdTransformedSchema, ConfirmationPolicy_2024_06_14 } from "@calcom/platform-types";
export function transformRequiresConfirmationInternalToApi(
@@ -1,11 +1,7 @@
import { Injectable } from "@nestjs/common";
import {
transformApiScheduleOverrides,
transformApiScheduleAvailability,
} from "@calcom/platform-libraries/schedules";
import { CreateScheduleInput_2024_06_11, ScheduleAvailabilityInput_2024_06_11 } from "@calcom/platform-types";
import { ScheduleOverrideInput_2024_06_11 } from "@calcom/platform-types";
import { ScheduleOverrideInput_2024_06_11, WeekDay } from "@calcom/platform-types";
@Injectable()
export class InputSchedulesService_2024_06_11 {
@@ -34,10 +30,52 @@ export class InputSchedulesService_2024_06_11 {
}
transformInputScheduleAvailability(inputAvailability: ScheduleAvailabilityInput_2024_06_11[]) {
return transformApiScheduleAvailability(inputAvailability);
return inputAvailability.map((availability) => ({
days: availability.days.map(this.transformDayToNumber),
startTime: this.createDateFromHoursMinutes(availability.startTime),
endTime: this.createDateFromHoursMinutes(availability.endTime),
}));
}
transformInputOverrides(inputOverrides: ScheduleOverrideInput_2024_06_11[]) {
return transformApiScheduleOverrides(inputOverrides);
return inputOverrides.map((override) => ({
date: new Date(override.date),
startTime: this.createDateFromHoursMinutes(override.startTime),
endTime: this.createDateFromHoursMinutes(override.endTime),
}));
}
createDateFromHoursMinutes(hoursMinutes: string): Date {
const parts = hoursMinutes.split(":");
if (parts.length < 2) {
throw new Error("Invalid time format. Please use 'hh:mm'.");
}
const hours = parseInt(parts[0], 10);
const minutes = parseInt(parts[1], 10);
if (isNaN(hours) || hours < 0 || hours > 23) {
throw new Error("Hours must be a number between 0 and 23.");
}
if (isNaN(minutes) || minutes < 0 || minutes > 59) {
throw new Error("Minutes must be a number between 0 and 59.");
}
const utcDate = new Date(Date.UTC(1970, 0, 1, hours, minutes));
return utcDate;
}
transformDayToNumber(day: WeekDay): number {
const weekMap: { [key in WeekDay]: number } = {
Sunday: 0,
Monday: 1,
Tuesday: 2,
Wednesday: 3,
Thursday: 4,
Friday: 5,
Saturday: 6,
};
return weekMap[day];
}
}
+3 -1
View File
@@ -17,7 +17,7 @@ export type Environment = {
STRIPE_API_KEY: string;
STRIPE_WEBHOOK_SECRET: string;
WEB_APP_URL: string;
IS_E2E: boolean;
IS_E2E: string;
CALCOM_LICENSE_KEY: string;
GET_LICENSE_KEY_URL: string;
API_KEY_PREFIX: string;
@@ -32,6 +32,8 @@ export type Environment = {
AXIOM_TOKEN: string;
STRIPE_TEAM_MONTHLY_PRICE_ID: string;
IS_TEAM_BILLING_ENABLED: boolean;
// Used to enable/disable the rewrite of /api/v2 to /v2, active by default.
REWRITE_API_V2_PREFIX: string;
};
export const getEnv = <K extends keyof Environment>(key: K, fallback?: Environment[K]): Environment[K] => {
@@ -1,10 +1,11 @@
import { getEnv } from "@/env";
import { Injectable, NestMiddleware } from "@nestjs/common";
import { Request, Response } from "express";
@Injectable()
export class RewriterMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: () => void) {
if (req.url.startsWith("/api/v2")) {
if (shouldRewriteApiV2Prefix() && req.url.startsWith("/api/v2")) {
req.url = req.url.replace("/api/v2", "/v2");
}
if (req.url.startsWith("/v2/ee")) {
@@ -16,3 +17,12 @@ export class RewriterMiddleware implements NestMiddleware {
next();
}
}
const shouldRewriteApiV2Prefix = () => {
try {
// rewrite is active even if REWRITE_API_V2_PREFIX is not set
return getEnv("REWRITE_API_V2_PREFIX", "1") === "1";
} catch (error) {
return true;
}
};
+11 -3
View File
@@ -1,10 +1,13 @@
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository";
import { AtomsRepository } from "@/modules/atoms/atoms.repository";
import { AtomsConferencingAppsController } from "@/modules/atoms/controllers/atoms.conferencing-apps.controller";
import { AtomsController } from "@/modules/atoms/controllers/atoms.controller";
import { AtomsEventTypesController } from "@/modules/atoms/controllers/atoms.event-types.controller";
import { AtomsSchedulesController } from "@/modules/atoms/controllers/atoms.schedules.controller";
import { AttributesAtomsService } from "@/modules/atoms/services/attributes-atom.service";
import { ConferencingAtomsService } from "@/modules/atoms/services/conferencing-atom.service";
import { EventTypesAtomService } from "@/modules/atoms/services/event-types-atom.service";
import { SchedulesAtomsService } from "@/modules/atoms/services/schedules-atom.service";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
@@ -26,10 +29,15 @@ import { Module } from "@nestjs/common";
UsersRepository,
AtomsRepository,
UsersService,
SchedulesRepository_2024_06_11,
SchedulesAtomsService,
RedisService,
],
exports: [EventTypesAtomService],
controllers: [AtomsController],
controllers: [
AtomsController,
AtomsEventTypesController,
AtomsConferencingAppsController,
AtomsSchedulesController,
],
})
export class AtomsModule {}
@@ -0,0 +1,62 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { ConferencingAtomsService } from "@/modules/atoms/services/conferencing-atom.service";
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 { UserWithProfile } from "@/modules/users/users.repository";
import { Controller, Get, Param, ParseIntPipe, UseGuards, Version, VERSION_NEUTRAL } from "@nestjs/common";
import { ApiTags as DocsTags, ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { ConnectedApps } from "@calcom/platform-libraries/app-store";
import { ApiResponse } from "@calcom/platform-types";
/*
Conferencing endpoints for atoms, split from AtomsController for clarity and maintainability.
These endpoints should not be recommended for use by third party and are excluded from docs.
*/
@Controller({
path: "/v2/atoms",
version: API_VERSIONS_VALUES,
})
@DocsTags("Atoms - conferencing endpoints for atoms")
@DocsExcludeController(true)
export class AtomsConferencingAppsController {
constructor(private readonly conferencingService: ConferencingAtomsService) {}
@Get("/organizations/:orgId/teams/:teamId/conferencing")
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
@Version(VERSION_NEUTRAL)
async listTeamInstalledConferencingApps(
@GetUser() user: UserWithProfile,
@Param("teamId", ParseIntPipe) teamId: number
): Promise<ApiResponse<ConnectedApps>> {
const conferencingApps = await this.conferencingService.getTeamConferencingApps(user, teamId);
return {
status: SUCCESS_STATUS,
data: conferencingApps,
};
}
@Get("/conferencing")
@Version(VERSION_NEUTRAL)
@UseGuards(ApiAuthGuard)
async listUserInstalledConferencingApps(
@GetUser() user: UserWithProfile
): Promise<ApiResponse<ConnectedApps>> {
const conferencingApps = await this.conferencingService.getUserConferencingApps(user);
return {
status: SUCCESS_STATUS,
data: conferencingApps,
};
}
}
@@ -1,21 +1,9 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import {
BulkUpdateEventTypeToDefaultLocationDto,
EventTypesAppInput,
} from "@/modules/atoms/inputs/event-types-app.input";
import { FindTeamMembersMatchingAttributeQueryDto } from "@/modules/atoms/inputs/find-team-members-matching-attribute.input";
import { AttributesAtomsService } from "@/modules/atoms/services/attributes-atom.service";
import { ConferencingAtomsService } from "@/modules/atoms/services/conferencing-atom.service";
import { EventTypesAtomService } from "@/modules/atoms/services/event-types-atom.service";
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 { UserWithProfile } from "@/modules/users/users.repository";
import {
Controller,
@@ -25,24 +13,17 @@ import {
UseGuards,
Version,
VERSION_NEUTRAL,
Patch,
Body,
Query,
} from "@nestjs/common";
import { ApiTags as DocsTags, ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
import { ConnectedApps } from "@calcom/platform-libraries/app-store";
import type { UpdateEventTypeReturn } from "@calcom/platform-libraries/event-types";
import { ApiResponse } from "@calcom/platform-types";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { FindTeamMembersMatchingAttributeResponseDto } from "../outputs/find-team-members-matching-attribute.output";
/*
Endpoints used only by platform atoms, reusing code from other modules, data is already formatted and ready to be used by frontend atoms
these endpoints should not be recommended for use by third party and are excluded from docs
*/
@Controller({
@@ -53,180 +34,10 @@ these endpoints should not be recommended for use by third party and are exclude
@DocsExcludeController(true)
export class AtomsController {
constructor(
private readonly eventTypesService: EventTypesAtomService,
private readonly conferencingService: ConferencingAtomsService,
private readonly attributesService: AttributesAtomsService
) {}
@Get("event-types/:eventTypeId")
@Version(VERSION_NEUTRAL)
@UseGuards(ApiAuthGuard)
async getAtomEventType(
@GetUser() user: UserWithProfile,
@Param("eventTypeId", ParseIntPipe) eventTypeId: number
): Promise<ApiResponse<unknown>> {
const eventType = await this.eventTypesService.getUserEventType(user, eventTypeId);
return {
status: SUCCESS_STATUS,
data: eventType,
};
}
@Get("/organizations/:orgId/teams/:teamId/event-types")
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
@Version(VERSION_NEUTRAL)
async listTeamEventTypes(@Param("teamId", ParseIntPipe) teamId: number): Promise<ApiResponse<unknown>> {
const eventTypes = await this.eventTypesService.getTeamEventTypes(teamId);
return {
status: SUCCESS_STATUS,
data: eventTypes,
};
}
@Get("/event-types")
@Version(VERSION_NEUTRAL)
@UseGuards(ApiAuthGuard)
async listUserEventTypes(@GetUser("id") userId: number): Promise<ApiResponse<unknown>> {
const eventTypes = await this.eventTypesService.getUserEventTypes(userId);
return {
status: SUCCESS_STATUS,
data: eventTypes,
};
}
@Get("event-types-app/:appSlug")
@Version(VERSION_NEUTRAL)
@UseGuards(ApiAuthGuard)
async getAtomEventTypeApp(
@GetUser() user: UserWithProfile,
@Param("appSlug") appSlug: string,
@Query() queryParams: EventTypesAppInput
): Promise<ApiResponse<unknown>> {
const { teamId } = queryParams;
const app = await this.eventTypesService.getEventTypesAppIntegration(appSlug, user, teamId);
return {
status: SUCCESS_STATUS,
data: {
app,
},
};
}
@Get("payment/:uid")
@Version(VERSION_NEUTRAL)
@UseGuards(ApiAuthGuard)
async getUserPaymentInfoById(@Param("uid") uid: string): Promise<ApiResponse<unknown>> {
const data = await this.eventTypesService.getUserPaymentInfo(uid);
return {
status: SUCCESS_STATUS,
data,
};
}
@Patch("/event-types/bulk-update-to-default-location")
@Version(VERSION_NEUTRAL)
@UseGuards(ApiAuthGuard)
async bulkUpdateAtomEventTypes(
@GetUser() user: UserWithProfile,
@Body() body: BulkUpdateEventTypeToDefaultLocationDto
): Promise<{ status: typeof SUCCESS_STATUS | typeof ERROR_STATUS }> {
await this.eventTypesService.bulkUpdateEventTypesDefaultLocation(user, body.eventTypeIds);
return {
status: SUCCESS_STATUS,
};
}
@Patch("/organizations/:orgId/teams/:teamId/event-types/bulk-update-to-default-location")
@Version(VERSION_NEUTRAL)
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
async bulkUpdateAtomTeamEventTypes(
@GetUser() user: UserWithProfile,
@Body() body: BulkUpdateEventTypeToDefaultLocationDto,
@Param("teamId", ParseIntPipe) teamId: number
): Promise<{ status: typeof SUCCESS_STATUS | typeof ERROR_STATUS }> {
await this.eventTypesService.bulkUpdateTeamEventTypesDefaultLocation(body.eventTypeIds, teamId);
return {
status: SUCCESS_STATUS,
};
}
@Patch("event-types/:eventTypeId")
@Version(VERSION_NEUTRAL)
@UseGuards(ApiAuthGuard)
async updateAtomEventType(
@GetUser() user: UserWithProfile,
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
@Body() body: UpdateEventTypeReturn
): Promise<ApiResponse<UpdateEventTypeReturn>> {
const eventType = await this.eventTypesService.updateEventType(
eventTypeId,
{ ...body, id: eventTypeId },
user
);
return {
status: SUCCESS_STATUS,
data: eventType,
};
}
@Patch("/organizations/:orgId/teams/:teamId/event-types/:eventTypeId")
@Version(VERSION_NEUTRAL)
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
async updateAtomTeamEventType(
@GetUser() user: UserWithProfile,
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
@Param("teamId", ParseIntPipe) teamId: number,
@Body() body: UpdateEventTypeReturn
): Promise<ApiResponse<UpdateEventTypeReturn>> {
const eventType = await this.eventTypesService.updateTeamEventType(
eventTypeId,
{ ...body, id: eventTypeId },
user,
teamId
);
return {
status: SUCCESS_STATUS,
data: eventType,
};
}
@Get("/organizations/:orgId/teams/:teamId/conferencing")
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
@Version(VERSION_NEUTRAL)
async listTeamInstalledConferencingApps(
@GetUser() user: UserWithProfile,
@Param("teamId", ParseIntPipe) teamId: number
): Promise<ApiResponse<ConnectedApps>> {
const conferencingApps = await this.conferencingService.getTeamConferencingApps(user, teamId);
return {
status: SUCCESS_STATUS,
data: conferencingApps,
};
}
@Get("/conferencing")
@Version(VERSION_NEUTRAL)
@UseGuards(ApiAuthGuard)
async listUserInstalledConferencingApps(
@GetUser() user: UserWithProfile
): Promise<ApiResponse<ConnectedApps>> {
const conferencingApps = await this.conferencingService.getUserConferencingApps(user);
return {
status: SUCCESS_STATUS,
data: conferencingApps,
};
}
@Get("/organizations/:orgId/teams/:teamId/members-matching-attribute")
@Version(VERSION_NEUTRAL)
@UseGuards(ApiAuthGuard)
@@ -0,0 +1,210 @@
import { GetEventTypePublicOutput } from "@/ee/event-types/event-types_2024_04_15/outputs/get-event-type-public.output";
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import {
BulkUpdateEventTypeToDefaultLocationDto,
EventTypesAppInput,
} from "@/modules/atoms/inputs/event-types-app.input";
import { GetAtomPublicEventTypeQueryParams } from "@/modules/atoms/inputs/get-atom-public-event-type-query-params.input";
import { EventTypesAtomService } from "@/modules/atoms/services/event-types-atom.service";
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 { UserWithProfile } from "@/modules/users/users.repository";
import {
Controller,
Get,
Param,
ParseIntPipe,
UseGuards,
Version,
VERSION_NEUTRAL,
Patch,
Body,
Query,
} from "@nestjs/common";
import { ApiTags as DocsTags, ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
import type { UpdateEventTypeReturn } from "@calcom/platform-libraries/event-types";
import { PublicEventType } from "@calcom/platform-libraries/event-types";
import { ApiResponse } from "@calcom/platform-types";
/*
Event-types endpoints for atoms, split from AtomsController for clarity and maintainability.
These endpoints should not be recommended for use by third party and are excluded from docs.
*/
@Controller({
path: "/v2/atoms",
version: API_VERSIONS_VALUES,
})
@DocsTags("Atoms - event-types endpoints for atoms")
@DocsExcludeController(true)
export class AtomsEventTypesController {
constructor(private readonly eventTypesService: EventTypesAtomService) {}
@Get("/event-types/:eventSlug/public")
async getPublicEventType(
@Param("eventSlug") eventSlug: string,
@Query() queryParams: GetAtomPublicEventTypeQueryParams
): Promise<ApiResponse<PublicEventType>> {
const { username, teamId, orgId, isTeamEvent } = queryParams;
const event = await this.eventTypesService.getPublicEventTypeForAtoms({
username,
eventSlug,
isTeamEvent: isTeamEvent ?? false,
teamId,
orgId,
});
return {
data: event,
status: SUCCESS_STATUS,
};
}
@Get("event-types/:eventTypeId")
@Version(VERSION_NEUTRAL)
@UseGuards(ApiAuthGuard)
async getAtomEventType(
@GetUser() user: UserWithProfile,
@Param("eventTypeId", ParseIntPipe) eventTypeId: number
): Promise<ApiResponse<unknown>> {
const eventType = await this.eventTypesService.getUserEventType(user, eventTypeId);
return {
status: SUCCESS_STATUS,
data: eventType,
};
}
@Get("/organizations/:orgId/teams/:teamId/event-types")
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
@Version(VERSION_NEUTRAL)
async listTeamEventTypes(@Param("teamId", ParseIntPipe) teamId: number): Promise<ApiResponse<unknown>> {
const eventTypes = await this.eventTypesService.getTeamEventTypes(teamId);
return {
status: SUCCESS_STATUS,
data: eventTypes,
};
}
@Get("/event-types")
@Version(VERSION_NEUTRAL)
@UseGuards(ApiAuthGuard)
async listUserEventTypes(@GetUser("id") userId: number): Promise<ApiResponse<unknown>> {
const eventTypes = await this.eventTypesService.getUserEventTypes(userId);
return {
status: SUCCESS_STATUS,
data: eventTypes,
};
}
@Get("event-types-app/:appSlug")
@Version(VERSION_NEUTRAL)
@UseGuards(ApiAuthGuard)
async getAtomEventTypeApp(
@GetUser() user: UserWithProfile,
@Param("appSlug") appSlug: string,
@Query() queryParams: EventTypesAppInput
): Promise<ApiResponse<unknown>> {
const { teamId } = queryParams;
const app = await this.eventTypesService.getEventTypesAppIntegration(appSlug, user, teamId);
return {
status: SUCCESS_STATUS,
data: {
app,
},
};
}
@Get("payment/:uid")
@Version(VERSION_NEUTRAL)
@UseGuards(ApiAuthGuard)
async getUserPaymentInfoById(@Param("uid") uid: string): Promise<ApiResponse<unknown>> {
const data = await this.eventTypesService.getUserPaymentInfo(uid);
return {
status: SUCCESS_STATUS,
data,
};
}
@Patch("/event-types/bulk-update-to-default-location")
@Version(VERSION_NEUTRAL)
@UseGuards(ApiAuthGuard)
async bulkUpdateAtomEventTypes(
@GetUser() user: UserWithProfile,
@Body() body: BulkUpdateEventTypeToDefaultLocationDto
): Promise<{ status: typeof SUCCESS_STATUS | typeof ERROR_STATUS }> {
await this.eventTypesService.bulkUpdateEventTypesDefaultLocation(user, body.eventTypeIds);
return {
status: SUCCESS_STATUS,
};
}
@Patch("/organizations/:orgId/teams/:teamId/event-types/bulk-update-to-default-location")
@Version(VERSION_NEUTRAL)
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
async bulkUpdateAtomTeamEventTypes(
@GetUser() user: UserWithProfile,
@Body() body: BulkUpdateEventTypeToDefaultLocationDto,
@Param("teamId", ParseIntPipe) teamId: number
): Promise<{ status: typeof SUCCESS_STATUS | typeof ERROR_STATUS }> {
await this.eventTypesService.bulkUpdateTeamEventTypesDefaultLocation(body.eventTypeIds, teamId);
return {
status: SUCCESS_STATUS,
};
}
@Patch("event-types/:eventTypeId")
@Version(VERSION_NEUTRAL)
@UseGuards(ApiAuthGuard)
async updateAtomEventType(
@GetUser() user: UserWithProfile,
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
@Body() body: UpdateEventTypeReturn
): Promise<ApiResponse<UpdateEventTypeReturn>> {
const eventType = await this.eventTypesService.updateEventType(
eventTypeId,
{ ...body, id: eventTypeId },
user
);
return {
status: SUCCESS_STATUS,
data: eventType,
};
}
@Patch("/organizations/:orgId/teams/:teamId/event-types/:eventTypeId")
@Version(VERSION_NEUTRAL)
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
async updateAtomTeamEventType(
@GetUser() user: UserWithProfile,
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
@Param("teamId", ParseIntPipe) teamId: number,
@Body() body: UpdateEventTypeReturn
): Promise<ApiResponse<UpdateEventTypeReturn>> {
const eventType = await this.eventTypesService.updateTeamEventType(
eventTypeId,
{ ...body, id: eventTypeId },
user,
teamId
);
return {
status: SUCCESS_STATUS,
data: eventType,
};
}
}
@@ -0,0 +1,86 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { GetAtomSchedulesQueryParams } from "@/modules/atoms/inputs/get-atom-schedules-query-params.input";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { UserWithProfile } from "@/modules/users/users.repository";
import {
Body,
Controller,
Get,
Param,
ParseIntPipe,
Patch,
Query,
UseGuards,
Version,
VERSION_NEUTRAL,
} from "@nestjs/common";
import {
ApiTags as DocsTags,
ApiExcludeController as DocsExcludeController,
ApiOperation,
} from "@nestjs/swagger";
import { SCHEDULE_READ, SCHEDULE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
import { FindDetailedScheduleByIdReturnType } from "@calcom/platform-libraries/schedules";
import { ApiResponse, UpdateAtomScheduleDto } from "@calcom/platform-types";
import { SchedulesAtomsService } from "../services/schedules-atom.service";
/*
Endpoints used only by platform atoms, reusing code from other modules, data is already formatted and ready to be used by frontend atoms
these endpoints should not be recommended for use by third party and are excluded from docs
*/
@Controller({
path: "/v2/atoms",
version: API_VERSIONS_VALUES,
})
@DocsTags("Atoms - endpoints for atoms")
@DocsExcludeController(true)
export class AtomsSchedulesController {
constructor(private readonly schedulesService: SchedulesAtomsService) {}
@Get("/schedules")
@Version(VERSION_NEUTRAL)
@UseGuards(ApiAuthGuard)
@Permissions([SCHEDULE_READ])
async getSchedule(
@GetUser() user: UserWithProfile,
@Query() queryParams: GetAtomSchedulesQueryParams
): Promise<ApiResponse<FindDetailedScheduleByIdReturnType | null>> {
const { isManagedEventType, scheduleId } = queryParams;
const schedule = await this.schedulesService.getSchedule({
scheduleId,
userId: user.id,
timeZone: user.timeZone,
isManagedEventType,
});
return {
status: SUCCESS_STATUS,
data: schedule,
};
}
@Patch("schedules/:scheduleId")
@Permissions([SCHEDULE_WRITE])
@UseGuards(ApiAuthGuard)
@ApiOperation({ summary: "Update atom schedule" })
async updateSchedule(
@GetUser() user: UserWithProfile,
@Body() bodySchedule: UpdateAtomScheduleDto,
@Param("scheduleId", ParseIntPipe) scheduleId: number
): Promise<ApiResponse<any>> {
const updatedSchedule = await this.schedulesService.updateUserSchedule({
user,
input: bodySchedule,
scheduleId,
});
return {
status: SUCCESS_STATUS,
data: updatedSchedule,
};
}
}
@@ -1,12 +1,12 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import type { TFindTeamMembersMatchingAttributeLogicInputSchema } from "@calcom/platform-libraries";
export class FindTeamMembersMatchingAttributeQueryDto {
@ApiPropertyOptional({
@ApiProperty({
nullable: true,
})
attributesQueryValue: TFindTeamMembersMatchingAttributeLogicInputSchema["attributesQueryValue"];
attributesQueryValue!: TFindTeamMembersMatchingAttributeLogicInputSchema["attributesQueryValue"] | null;
@ApiPropertyOptional({
type: Boolean,
@@ -0,0 +1,28 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { Transform, Type } from "class-transformer";
import { IsBoolean, IsOptional, IsString, IsNumber } from "class-validator";
export class GetAtomPublicEventTypeQueryParams {
@Transform(({ value }: { value: string }) => value === "true")
@IsBoolean()
@IsOptional()
@ApiPropertyOptional()
isTeamEvent?: boolean;
@IsOptional()
@Transform(({ value }: { value: string }) => value && parseInt(value))
@IsNumber()
@ApiPropertyOptional({ type: Number })
teamId?: number;
@IsOptional()
@Transform(({ value }: { value: string }) => value && parseInt(value))
@IsNumber()
@ApiPropertyOptional({ type: Number })
orgId?: number;
@IsOptional()
@IsString()
@ApiPropertyOptional({ type: String })
username?: string;
}
@@ -0,0 +1,15 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { Transform } from "class-transformer";
import { IsBoolean, IsOptional } from "class-validator";
export class GetAtomSchedulesQueryParams {
@IsOptional()
@Transform(({ value }: { value: string }) => value && parseInt(value))
@ApiPropertyOptional({ type: Number })
scheduleId?: number;
@IsOptional()
@IsBoolean()
@ApiPropertyOptional({ type: Boolean })
isManagedEventType?: boolean;
}
@@ -1,4 +1,5 @@
import { EventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/event-types.service";
import { systemBeforeFieldEmail } from "@/ee/event-types/event-types_2024_06_14/transformers";
import { AtomsRepository } from "@/modules/atoms/atoms.repository";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
@@ -7,7 +8,7 @@ import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { TeamsEventTypesService } from "@/modules/teams/event-types/services/teams-event-types.service";
import { UsersService } from "@/modules/users/services/users.service";
import { UserWithProfile } from "@/modules/users/users.repository";
import { Injectable, NotFoundException, ForbiddenException } from "@nestjs/common";
import { Injectable, NotFoundException, ForbiddenException, BadRequestException } from "@nestjs/common";
import { checkAdminOrOwner, getClientSecretFromPayment } from "@calcom/platform-libraries";
import type { TeamQuery } from "@calcom/platform-libraries";
@@ -21,6 +22,7 @@ import type {
CredentialDataWithTeamName,
LocationOption,
} from "@calcom/platform-libraries/app-store";
import { type PublicEventType, getPublicEvent } from "@calcom/platform-libraries/event-types";
import {
getEventTypeById,
bulkUpdateEventsToDefaultLocation,
@@ -31,7 +33,6 @@ import {
import {
updateEventType,
TUpdateEventTypeInputSchema,
systemBeforeFieldEmail,
EventTypeMetaDataSchema,
} from "@calcom/platform-libraries/event-types";
import { PrismaClient } from "@calcom/prisma";
@@ -55,6 +56,18 @@ export class EventTypesAtomService {
private readonly teamEventTypeService: TeamsEventTypesService
) {}
private async getTeamSlug(teamId: number): Promise<string> {
const team = await this.dbRead.prisma.team.findUnique({
where: { id: teamId },
select: { slug: true },
});
if (!team?.slug) {
throw new NotFoundException(`Team with id ${teamId} not found`);
}
return team.slug;
}
async getUserEventType(user: UserWithProfile, eventTypeId: number) {
const organizationId = this.usersService.getUserMainOrgId(user);
@@ -369,4 +382,61 @@ export class EventTypesAtomService {
teamId,
});
}
/**
* Returns the public event type for atoms, handling both team and user events.
*/
async getPublicEventTypeForAtoms({
username,
eventSlug,
isTeamEvent,
orgId,
teamId,
}: {
username?: string;
eventSlug: string;
isTeamEvent?: boolean;
orgId?: number;
teamId?: number;
}): Promise<PublicEventType> {
const orgSlug = orgId ? await this.getTeamSlug(orgId) : null;
let slug: string | null = null;
if (isTeamEvent) {
if (!teamId) {
throw new BadRequestException("teamId is required for team events, please provide a valid teamId");
}
slug = await this.getTeamSlug(teamId);
} else {
if (!username) {
throw new BadRequestException(
"username is required for non-team events, please provide a valid username"
);
}
slug = username;
}
const slugLower = slug.toLowerCase();
try {
const event = await getPublicEvent(
slugLower,
eventSlug,
isTeamEvent,
orgSlug,
this.dbRead.prisma as unknown as PrismaClient,
true
);
if (!event) {
throw new NotFoundException(`Event type with slug ${eventSlug} not found`);
}
return event;
} catch (err) {
if (err instanceof Error) {
throw new NotFoundException(err.message);
}
throw new NotFoundException(`Event type with slug ${eventSlug} not found`);
}
}
}
@@ -0,0 +1,63 @@
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { UsersRepository } from "@/modules/users/users.repository";
import { UserWithProfile } from "@/modules/users/users.repository";
import { Logger } from "@nestjs/common";
import { Injectable } from "@nestjs/common";
import { ScheduleRepository, UpdateScheduleResponse } from "@calcom/platform-libraries/schedules";
import { updateSchedule } from "@calcom/platform-libraries/schedules";
import { UpdateAtomScheduleDto } from "@calcom/platform-types";
import { PrismaClient } from "@calcom/prisma";
@Injectable()
export class SchedulesAtomsService {
private logger = new Logger("SchedulesAtomService");
constructor(
private readonly usersRepository: UsersRepository,
private readonly dbWrite: PrismaWriteService
) {}
async getSchedule({
timeZone,
userId,
scheduleId,
isManagedEventType,
}: {
timeZone: string;
userId: number;
scheduleId?: number;
isManagedEventType?: boolean;
}) {
const user = await this.usersRepository.findById(userId);
if (!user?.defaultScheduleId) return null;
return await ScheduleRepository.findDetailedScheduleById({
scheduleId: scheduleId ?? user.defaultScheduleId,
isManagedEventType,
userId,
timeZone,
defaultScheduleId: user.defaultScheduleId,
});
}
async updateUserSchedule({
input,
user,
scheduleId,
}: {
input: UpdateAtomScheduleDto;
user: UserWithProfile;
scheduleId: number;
}): Promise<UpdateScheduleResponse> {
return updateSchedule({
input: {
scheduleId,
...input,
},
user,
prisma: this.dbWrite.prisma as unknown as PrismaClient,
});
}
}
@@ -1,6 +1,6 @@
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
import { PlatformPlanType } from "@/modules/billing/types";
import { PlatformPlanType, orderedPlans } from "@/modules/billing/types";
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
import { RedisService } from "@/modules/redis/redis.service";
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common";
@@ -61,7 +61,7 @@ export class PlatformPlanGuard implements CanActivate {
!hasMinimumPlan({
currentPlan: organization.platformBilling?.plan as PlatformPlanType,
minimumPlan: minimumPlan,
plans: ["FREE", "STARTER", "ESSENTIALS", "SCALE", "ENTERPRISE"],
plans: orderedPlans,
})
) {
throw new ForbiddenException(
@@ -79,7 +79,7 @@ export class PlatformPlanGuard implements CanActivate {
type HasMinimumPlanProp = {
currentPlan: PlatformPlanType;
minimumPlan: PlatformPlanType;
plans: PlatformPlanType[];
plans: readonly PlatformPlanType[];
};
export function hasMinimumPlan(props: HasMinimumPlanProp): boolean {
@@ -87,7 +87,7 @@ export function hasMinimumPlan(props: HasMinimumPlanProp): boolean {
const minimumPlanIndex = props.plans.indexOf(props.minimumPlan);
if (currentPlanIndex === -1 || minimumPlanIndex === -1) {
throw new Error(
throw new ForbiddenException(
`PlatformPlanGuard - Invalid platform billing plan provided. Current plan: ${props.currentPlan}, Minimum plan: ${props.minimumPlan}`
);
}
@@ -0,0 +1,56 @@
import { WorkflowsRepository, WorkflowType } from "@/modules/workflows/workflows.repository";
import {
Injectable,
CanActivate,
ExecutionContext,
ForbiddenException,
NotFoundException,
} from "@nestjs/common";
import { Request } from "express";
@Injectable()
export class IsWorkflowInTeam implements CanActivate {
constructor(private workflowsRepository: WorkflowsRepository) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request & { workflow?: WorkflowType }>();
const teamId: string = request.params.teamId;
const workflowId: string = request.params.workflowId;
if (!workflowId) {
throw new ForbiddenException("IsWorkflowInTeam - No workflow found in request params.");
}
if (!teamId) {
throw new ForbiddenException("IsWorkflowInTeam - No team id found in request params.");
}
const { canAccess, workflow } = await this.checkIfWorkflowIsInTeam(teamId, workflowId);
if (!canAccess) {
throw new ForbiddenException(
`IsTeamInOrg - Workflow with id=${workflowId} is not part of the team with id=${teamId}.`
);
}
request.workflow = workflow;
return true;
}
async checkIfWorkflowIsInTeam(
teamId: string,
workflowId: string
): Promise<{ canAccess: boolean; workflow?: WorkflowType }> {
const workflow = await this.workflowsRepository.getTeamWorkflowById(Number(teamId), Number(workflowId));
if (!workflow) {
throw new NotFoundException(`IsWorkflowInTeam - workflow (${workflowId}) not found.`);
}
if (workflow.teamId === Number(teamId)) {
return { canAccess: true, workflow };
}
return { canAccess: false };
}
}
+10 -1
View File
@@ -7,4 +7,13 @@ export enum PlatformPlan {
PER_ACTIVE_USER = "PER_ACTIVE_USER",
}
export type PlatformPlanType = "FREE" | "STARTER" | "ESSENTIALS" | "SCALE" | "ENTERPRISE" | "PER_ACTIVE_USER";
export const orderedPlans = [
"FREE",
"STARTER",
"ESSENTIALS",
"SCALE",
"PER_ACTIVE_USER",
"ENTERPRISE",
] as const;
export type PlatformPlanType = (typeof orderedPlans)[number];
@@ -0,0 +1,35 @@
import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/booking-references.repository";
import { CalendarsRepository } from "@/ee/calendars/calendars.repository";
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
import { GoogleCalendarService as GCalService } from "@/ee/calendars/services/gcal.service";
import { AppsRepository } from "@/modules/apps/apps.repository";
import { CalUnifiedCalendarsController } from "@/modules/cal-unified-calendars/controllers/cal-unified-calendars.controller";
import { GoogleCalendarService } from "@/modules/cal-unified-calendars/services/google-calendar.service";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { UsersRepository } from "@/modules/users/users.repository";
import { Module } from "@nestjs/common";
@Module({
imports: [],
providers: [
GCalService,
GoogleCalendarService,
AppsRepository,
BookingReferencesRepository_2024_08_13,
CredentialsRepository,
CalendarsService,
TokensRepository,
SelectedCalendarsRepository,
PrismaReadService,
PrismaWriteService,
UsersRepository,
CalendarsRepository,
],
controllers: [CalUnifiedCalendarsController],
exports: [GoogleCalendarService],
})
export class CalUnifiedCalendarsModule {}
@@ -0,0 +1,57 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { GetUnifiedCalendarEventOutput } from "@/modules/cal-unified-calendars/outputs/get-unified-calendar-event";
import { GoogleCalendarEventOutputPipe } from "@/modules/cal-unified-calendars/pipes/get-calendar-event-details-output-pipe";
import { GoogleCalendarService } from "@/modules/cal-unified-calendars/services/google-calendar.service";
import { Controller, Get, Param, UseGuards, HttpCode, HttpStatus, BadRequestException } from "@nestjs/common";
import { ApiTags as DocsTags, ApiParam, ApiHeader, ApiOperation } from "@nestjs/swagger";
import { GOOGLE_CALENDAR, SUCCESS_STATUS } from "@calcom/platform-constants";
@Controller({
path: "/v2/calendars",
version: API_VERSIONS_VALUES,
})
@DocsTags("Cal Unified Calendars")
export class CalUnifiedCalendarsController {
constructor(private readonly googleCalendarService: GoogleCalendarService) {}
@ApiParam({
name: "calendar",
enum: [GOOGLE_CALENDAR],
type: String,
})
@ApiParam({
name: "eventUid",
description:
"The Google Calendar event ID. You can retrieve this by getting booking references from the following endpoints:\n\n- For team events: https://cal.com/docs/api-reference/v2/orgs-teams-bookings/get-booking-references-for-a-booking\n\n- For user events: https://cal.com/docs/api-reference/v2/bookings/get-booking-references-for-a-booking",
type: String,
})
@Get("/:calendar/event/:eventUid")
@HttpCode(HttpStatus.OK)
@UseGuards(ApiAuthGuard, PermissionsGuard)
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@ApiOperation({
summary: "Get meeting Details from calendar",
description: "Returns detailed information about a meeting including attendance metrics",
})
async getCalendarEventDetails(
@Param("calendar") calendar: string,
@Param("eventUid") eventUid: string
): Promise<GetUnifiedCalendarEventOutput> {
if (calendar !== GOOGLE_CALENDAR) {
throw new BadRequestException("Meeting details are currently only available for Google Calendar");
}
const eventDetails = await this.googleCalendarService.getEventDetails(eventUid);
const transformedEvent = new GoogleCalendarEventOutputPipe().transform(eventDetails);
return {
status: SUCCESS_STATUS,
data: transformedEvent,
};
}
}
@@ -0,0 +1,395 @@
import { ApiProperty, ApiPropertyOptional, getSchemaPath } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsEnum, IsISO8601, IsOptional, IsString, ValidateNested } from "class-validator";
import { CALENDARS, SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
export enum CalendarEventStatus {
ACCEPTED = "accepted",
PENDING = "pending",
DECLINED = "declined",
CANCELLED = "cancelled",
}
export enum CalendarEventResponseStatus {
ACCEPTED = "accepted",
PENDING = "pending",
DECLINED = "declined",
NEEDS_ACTION = "needsAction",
}
/**
* Base interface for all calendar event locations
*/
export interface ICalendarEventLocation {
type: string;
url: string;
label?: string;
}
export class CalendarEventVideoLocation implements ICalendarEventLocation {
@IsString()
@ApiProperty({
enum: ["video"],
description: "Indicates this is a video conference location",
})
type = "video";
@IsString()
@ApiProperty({
description: "URL for joining the video conference",
})
url!: string;
@IsString()
@IsOptional()
@ApiPropertyOptional({
nullable: true,
description: "Display name for the video conference",
})
label?: string;
@IsString()
@IsOptional()
@ApiPropertyOptional({
nullable: true,
description: "Password required to join the video conference",
})
password?: string;
@IsString()
@IsOptional()
@ApiPropertyOptional({
nullable: true,
description: "Meeting code or ID required to join the conference",
})
meetingCode?: string;
@IsString()
@IsOptional()
@ApiPropertyOptional({
nullable: true,
description: "Access code required to join the conference",
})
accessCode?: string;
}
export class CalendarEventPhoneLocation implements ICalendarEventLocation {
@IsString()
@ApiProperty({
enum: ["phone"],
description: "Indicates this is a phone conference location",
})
type = "phone";
@IsString()
@ApiProperty({
description: "Phone number or URL for dialing into the conference",
})
url!: string;
@IsString()
@IsOptional()
@ApiPropertyOptional({
nullable: true,
description: "Display name for the phone conference",
})
label?: string;
@IsString()
@IsOptional()
@ApiPropertyOptional({
nullable: true,
description: "PIN number required for the phone conference",
})
pin?: string;
@IsString()
@IsOptional()
@ApiPropertyOptional({
nullable: true,
description: "Password required for the phone conference",
})
password?: string;
@IsString()
@IsOptional()
@ApiPropertyOptional({
nullable: true,
description: "Access code required for the phone conference",
})
accessCode?: string;
@IsString()
@IsOptional()
@ApiPropertyOptional({
nullable: true,
description: "Country/region code for the phone number",
})
regionCode?: string;
}
export class CalendarEventSipLocation implements ICalendarEventLocation {
@IsString()
@ApiProperty({
enum: ["sip"],
description: "Indicates this is a SIP (Session Initiation Protocol) conference location",
})
type = "sip";
@IsString()
@ApiProperty({
description: "SIP URL for joining the conference",
})
url!: string;
@IsString()
@IsOptional()
@ApiPropertyOptional({
nullable: true,
description: "Display name for the SIP conference",
})
label?: string;
@IsString()
@IsOptional()
@ApiPropertyOptional({
nullable: true,
description: "PIN number required for the SIP conference",
})
pin?: string;
@IsString()
@IsOptional()
@ApiPropertyOptional({
nullable: true,
description: "Password required for the SIP conference",
})
password?: string;
}
export class CalendarEventMoreLocation implements ICalendarEventLocation {
@IsString()
@ApiProperty({
enum: ["more"],
description: "Indicates this is an additional conference location type",
})
type = "more";
@IsString()
@ApiProperty({
description: "URL for accessing this location",
})
url!: string;
@IsString()
@IsOptional()
@ApiPropertyOptional({
nullable: true,
description: "Display name for this location",
})
label?: string;
}
export type CalendarEventLocation =
| CalendarEventVideoLocation
| CalendarEventPhoneLocation
| CalendarEventSipLocation
| CalendarEventMoreLocation;
export class CalendarEventHost {
@IsString()
@ApiProperty({
description: "Email address of the event host",
})
email!: string;
@IsString()
@IsOptional()
@ApiPropertyOptional({
nullable: true,
description: "Display name of the event host",
})
name?: string;
}
export class CalendarEventAttendee {
@IsString()
@ApiProperty({
description: "Email address of the attendee",
})
email!: string;
@IsString()
@IsOptional()
@ApiPropertyOptional({
description: "Display name of the attendee",
})
name?: string;
@IsString()
@IsOptional()
@IsEnum(CalendarEventResponseStatus)
@ApiPropertyOptional({
enum: CalendarEventResponseStatus,
nullable: true,
enumName: "CalendarEventResponseStatus",
description: "Attendee's response to the invitation",
example: CalendarEventResponseStatus.ACCEPTED,
})
responseStatus!: CalendarEventResponseStatus | null;
@IsOptional()
@ApiPropertyOptional({
nullable: true,
description: "Indicates if this attendee is the current user",
})
self?: boolean;
@IsOptional()
@ApiPropertyOptional({
nullable: true,
description: "Indicates if this attendee's attendance is optional",
})
optional?: boolean;
}
export class DateTimeWithZone {
@IsISO8601()
time!: string;
@IsString()
timeZone!: string;
}
export class UnifiedCalendarEventOutput {
@ValidateNested()
@Type(() => DateTimeWithZone)
@ApiProperty({
type: "object",
properties: {
time: { type: "string", format: "date-time" },
timeZone: { type: "string" },
},
description: "Start date and time of the calendar event with timezone information",
})
start!: DateTimeWithZone;
@ValidateNested()
@Type(() => DateTimeWithZone)
@ApiProperty({
type: "object",
properties: {
time: { type: "string", format: "date-time" },
timeZone: { type: "string" },
},
description: "End date and time of the calendar event with timezone information",
})
end!: DateTimeWithZone;
@IsString()
@ApiProperty({
type: String,
description: "Unique identifier of the calendar event",
})
id!: string;
@IsString()
@ApiProperty({
type: String,
description: "Title of the calendar event",
})
title!: string;
@IsString()
@IsOptional()
@ApiPropertyOptional({
type: String,
nullable: true,
description: "Detailed description of the calendar event",
})
description?: string | null;
@IsOptional()
@ValidateNested({ each: true })
@Type(() => Object, {
discriminator: {
property: "type",
subTypes: [
{ value: CalendarEventVideoLocation, name: "video" },
{ value: CalendarEventPhoneLocation, name: "phone" },
{ value: CalendarEventSipLocation, name: "sip" },
{ value: CalendarEventMoreLocation, name: "more" },
],
},
})
@ApiPropertyOptional({
type: "array",
items: {
oneOf: [
{ $ref: getSchemaPath(CalendarEventVideoLocation) },
{ $ref: getSchemaPath(CalendarEventPhoneLocation) },
{ $ref: getSchemaPath(CalendarEventSipLocation) },
{ $ref: getSchemaPath(CalendarEventMoreLocation) },
],
discriminator: {
propertyName: "type",
},
},
nullable: true,
description: "Conference locations with entry points (video, phone, sip, more)",
})
locations?: CalendarEventLocation[];
@IsOptional()
@ValidateNested({ each: true })
@Type(() => CalendarEventAttendee)
@ApiPropertyOptional({
type: [CalendarEventAttendee],
nullable: true,
description: "List of attendees with their response status",
})
attendees?: CalendarEventAttendee[];
@IsEnum(CalendarEventStatus)
@IsOptional()
@ApiPropertyOptional({
enum: CalendarEventStatus,
enumName: "CalendarEventStatus",
nullable: true,
description: "Status of the event (accepted, pending, declined, cancelled)",
example: CalendarEventStatus.ACCEPTED,
})
status?: CalendarEventStatus | null;
@IsOptional()
@ValidateNested({ each: true })
@Type(() => CalendarEventHost)
@ApiPropertyOptional({
type: [CalendarEventHost],
nullable: true,
description: "Information about the event hosts (organizers)",
})
hosts?: CalendarEventHost[];
@IsEnum(CALENDARS)
@ApiProperty({
enum: CALENDARS,
enumName: "CalendarSource",
description:
"Calendar integration source (e.g., Google Calendar, Office 365, Apple Calendar). Currently only Google Calendar is supported.",
example: "google",
})
source!: (typeof CALENDARS)[number];
}
export class GetUnifiedCalendarEventOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ValidateNested()
@Type(() => UnifiedCalendarEventOutput)
@ApiProperty({ type: UnifiedCalendarEventOutput })
data!: UnifiedCalendarEventOutput;
}
@@ -0,0 +1,205 @@
import {
CalendarEventResponseStatus,
CalendarEventStatus,
DateTimeWithZone,
UnifiedCalendarEventOutput,
} from "@/modules/cal-unified-calendars/outputs/get-unified-calendar-event";
import { PipeTransform, Injectable } from "@nestjs/common";
export interface GoogleCalendarEventResponse {
kind: string;
etag: string;
id: string;
status: string;
htmlLink: string;
created: string;
updated: string;
summary: string;
description?: string;
location?: string;
creator: {
email: string;
displayName?: string;
};
organizer: {
email: string;
displayName?: string;
self?: boolean;
};
start: {
dateTime: string;
timeZone: string;
};
end: {
dateTime: string;
timeZone: string;
};
iCalUID: string;
sequence: number;
attendees?: Array<{
email: string;
displayName?: string;
organizer?: boolean;
self?: boolean;
responseStatus?: string;
optional?: boolean;
}>;
hangoutLink?: string;
conferenceData?: {
createRequest?: {
requestId: string;
conferenceSolutionKey: {
type: string;
};
status: {
statusCode: string;
};
};
entryPoints?: Array<{
entryPointType: string;
uri: string;
label?: string;
pin?: string;
regionCode?: string;
}>;
conferenceSolution?: {
key: {
type: string;
};
name: string;
iconUri: string;
};
conferenceId: string;
};
reminders?: {
useDefault: boolean;
};
eventType?: string;
}
@Injectable()
export class GoogleCalendarEventOutputPipe
implements PipeTransform<GoogleCalendarEventResponse, UnifiedCalendarEventOutput>
{
transform(googleEvent: GoogleCalendarEventResponse): UnifiedCalendarEventOutput {
const calendarEvent = new UnifiedCalendarEventOutput();
calendarEvent.id = googleEvent.id;
calendarEvent.title = googleEvent.summary;
calendarEvent.description = googleEvent.description || null;
calendarEvent.start = this.transformDateTimeWithZone(googleEvent.start);
calendarEvent.end = this.transformDateTimeWithZone(googleEvent.end);
calendarEvent.locations = this.transformLocations(googleEvent);
if (googleEvent.attendees && googleEvent.attendees.length > 0) {
calendarEvent.attendees = googleEvent.attendees
.filter((attendee) => !attendee.organizer)
.map((attendee) => {
return {
email: attendee.email,
name: attendee.displayName,
responseStatus: this.transformAttendeeResponseStatus(attendee.responseStatus),
organizer: attendee.organizer,
self: attendee.self,
optional: attendee.optional,
};
});
}
calendarEvent.status = this.transformEventStatus(googleEvent.status);
calendarEvent.hosts = this.transformHosts(googleEvent);
calendarEvent.source = "google";
return calendarEvent;
}
private transformDateTimeWithZone(googleDateTime: {
dateTime: string;
timeZone: string;
}): DateTimeWithZone {
const dateTimeWithZone = new DateTimeWithZone();
dateTimeWithZone.time = googleDateTime.dateTime;
dateTimeWithZone.timeZone = googleDateTime.timeZone;
return dateTimeWithZone;
}
private transformAttendeeResponseStatus(responseStatus?: string): CalendarEventResponseStatus | null {
if (!responseStatus) return null;
switch (responseStatus.toLowerCase()) {
case "accepted":
return CalendarEventResponseStatus.ACCEPTED;
case "tentative":
return CalendarEventResponseStatus.PENDING;
case "declined":
return CalendarEventResponseStatus.DECLINED;
case "needsaction":
return CalendarEventResponseStatus.NEEDS_ACTION;
default:
return null;
}
}
private transformEventStatus(eventStatus?: string): CalendarEventStatus | null {
if (!eventStatus) return null;
switch (eventStatus.toLowerCase()) {
case "confirmed":
return CalendarEventStatus.ACCEPTED;
case "tentative":
return CalendarEventStatus.PENDING;
case "cancelled":
return CalendarEventStatus.CANCELLED;
default:
return null;
}
}
private transformHosts(googleEvent: GoogleCalendarEventResponse): Array<{ email: string; name?: string }> {
if (googleEvent.organizer) {
return [
{
email: googleEvent.organizer.email,
name: googleEvent.organizer.displayName,
},
];
} else if (googleEvent.attendees) {
// If no explicit organizer, find the first attendee with organizer flag
const organizerAttendee = googleEvent.attendees.find((attendee) => attendee.organizer);
if (organizerAttendee) {
return [
{
email: organizerAttendee.email,
name: organizerAttendee.displayName,
},
];
}
}
return [];
}
private transformLocations(
googleEvent: GoogleCalendarEventResponse
): Array<{ type: string; url: string; label?: string; pin?: string; regionCode?: string }> {
if (googleEvent?.conferenceData?.entryPoints) {
return googleEvent.conferenceData.entryPoints.map((entryPoint) => {
return {
type: entryPoint.entryPointType,
url: entryPoint.uri,
label: entryPoint.label,
pin: entryPoint.pin,
regionCode: entryPoint.regionCode,
};
});
} else if (googleEvent.hangoutLink) {
return [{ type: "video", url: googleEvent.hangoutLink }];
}
return [];
}
}
@@ -0,0 +1,119 @@
import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/booking-references.repository";
import { GoogleCalendarService as GCalService } from "@/ee/calendars/services/gcal.service";
import { GoogleCalendarEventResponse } from "@/modules/cal-unified-calendars/pipes/get-calendar-event-details-output-pipe";
import { calendar_v3 } from "@googleapis/calendar";
import { Injectable, UnauthorizedException } from "@nestjs/common";
import { NotFoundException } from "@nestjs/common";
import { Logger } from "@nestjs/common";
import { Prisma } from "@prisma/client";
import { JWT } from "googleapis-common";
import { DelegationCredentialRepository, OAuth2UniversalSchema } from "@calcom/platform-libraries/app-store";
import { UnifiedCalendarEventOutput } from "../outputs/get-unified-calendar-event";
@Injectable()
export class GoogleCalendarService {
private logger = new Logger("GoogleCalendarService");
constructor(
private readonly bookingReferencesRepository: BookingReferencesRepository_2024_08_13,
private readonly gCalService: GCalService
) {}
async getEventDetails(eventUid: string): Promise<GoogleCalendarEventResponse> {
const bookingReference =
await this.bookingReferencesRepository.getBookingReferencesIncludeSensitiveCredentials(eventUid);
if (!bookingReference) {
throw new NotFoundException("Booking reference not found");
}
const ownerUserEmail = bookingReference?.booking?.user?.email;
// Get authenticated calendar instance
const calendar = await this.getAuthorizedCalendarInstance(
ownerUserEmail,
bookingReference.credential?.key,
bookingReference.delegationCredential
);
try {
const event = await calendar.events.get({
calendarId: bookingReference?.externalCalendarId ?? "primary",
eventId: bookingReference?.uid,
// fields: "id,status,created,updated,summary,description,location,creator,organizer,start,end,attendees,conferenceData"
});
if (!event.data) {
throw new NotFoundException("Meeting not found");
}
return event.data as GoogleCalendarEventResponse;
} catch (error) {
throw new NotFoundException("Failed to retrieve meeting details");
}
}
/**
* Gets an authorized Google Calendar instance
* Tries delegation credentials first, falls back to direct OAuth
*/
private async getAuthorizedCalendarInstance(
userEmail?: string,
oAuthCredentials?: Prisma.JsonValue | undefined,
delegationCredential?: { id: string } | null
): Promise<calendar_v3.Calendar> {
if (userEmail && delegationCredential?.id) {
const delegatedCalendar = await this.getDelegatedCalendarInstance(delegationCredential, userEmail);
if (delegatedCalendar) {
return delegatedCalendar;
}
}
// Fall back to direct OAuth authentication
if (!oAuthCredentials) {
throw new UnauthorizedException("No valid credentials available for Google Calendar");
}
const parsedOAuthCredentials = OAuth2UniversalSchema.parse(oAuthCredentials);
const oAuth2Client = await this.gCalService.getOAuthClient(this.gCalService.redirectUri);
oAuth2Client.setCredentials(parsedOAuthCredentials);
return new calendar_v3.Calendar({ auth: oAuth2Client });
}
private async getDelegatedCalendarInstance(
delegationCredential: { id: string },
emailToImpersonate: string
): Promise<calendar_v3.Calendar | null> {
try {
const oauthClientIdAliasRegex = /\+[a-zA-Z0-9]{25}/;
const cleanEmail = emailToImpersonate.replace(oauthClientIdAliasRegex, "");
const serviceAccountCreds =
await DelegationCredentialRepository.findByIdIncludeSensitiveServiceAccountKey({
id: delegationCredential.id,
});
if (
!serviceAccountCreds?.serviceAccountKey?.client_email ||
!serviceAccountCreds?.serviceAccountKey?.private_key
) {
this.logger.error("Missing service account credentials for delegation", {
delegationCredentialId: delegationCredential.id,
});
return null;
}
const authClient = new JWT({
email: serviceAccountCreds.serviceAccountKey.client_email,
key: serviceAccountCreds.serviceAccountKey.private_key,
scopes: ["https://www.googleapis.com/auth/calendar"],
subject: cleanEmail,
});
await authClient.authorize();
return new calendar_v3.Calendar({ auth: authClient });
} catch (error) {
return null;
}
}
}
@@ -21,7 +21,8 @@ import {
ZOOM,
OFFICE_365_VIDEO,
} from "@calcom/platform-constants";
import { userMetadata, getUsersCredentials } from "@calcom/platform-libraries";
import { userMetadata } from "@calcom/platform-libraries";
import { getUsersCredentialsIncludeServiceAccountKey } from "@calcom/platform-libraries/app-store";
import { getApps, handleDeleteCredential } from "@calcom/platform-libraries/app-store";
@Injectable()
@@ -90,7 +91,7 @@ export class ConferencingService {
if (!CONFERENCING_APPS.includes(appSlug)) {
throw new BadRequestException("Invalid app, available apps are: ", CONFERENCING_APPS.join(", "));
}
const credentials = await getUsersCredentials(user);
const credentials = await getUsersCredentialsIncludeServiceAccountKey(user);
const foundApp = getApps(credentials, true).filter((app) => app.slug === appSlug)[0];
@@ -73,6 +73,7 @@ describe("Platform Destination Calendar Endpoints", () => {
user.id,
APPLE_CALENDAR_ID
);
jest.spyOn(CalendarsService.prototype, "getCalendars").mockReturnValue(
Promise.resolve({
connectedCalendars: [
@@ -92,19 +93,32 @@ describe("Platform Destination Calendar Endpoints", () => {
url: "",
email: "",
},
calendars: {
externalId:
"https://caldav.icloud.com/20961146906/calendars/83C4F9A1-F1D0-41C7-8FC3-0B$9AE22E813/",
readOnly: false,
integration: "apple_calendar",
credentialId: appleCalendarCredentials.id,
primary: true,
email: user.email,
},
// calendars: {
// externalId:
// "https://caldav.icloud.com/20961146906/calendars/83C4F9A1-F1D0-41C7-8FC3-0B$9AE22E813/",
// readOnly: false,
// integration: "apple_calendar",
// credentialId: appleCalendarCredentials.id,
// primary: true,
// email: user.email,
// },
error: { message: "" },
delegationCredentialId: null,
credentialId: appleCalendarCredentials.id,
},
],
destinationCalendar: null,
destinationCalendar: {
name: "destinationCalendar",
eventTypeId: 1,
credentialId: appleCalendarCredentials.id,
primaryEmail: "primaryEmail",
integration: "apple_calendar",
externalId: "externalId",
userId: null,
id: 0,
delegationCredentialId: null,
domainWideDelegationCredentialId: null,
},
})
);
app = moduleRef.createNestApplication();
@@ -134,7 +148,7 @@ describe("Platform Destination Calendar Endpoints", () => {
expect(user).toBeDefined();
});
it(`POST /v2/destination-calendars: it should respond with a 200 returning back the user updated destination calendar`, async () => {
it.skip(`POST /v2/destination-calendars: it should respond with a 200 returning back the user updated destination calendar`, async () => {
const body = {
integration: appleCalendarCredentials.type,
externalId: "https://caldav.icloud.com/20961146906/calendars/83C4F9A1-F1D0-41C7-8FC3-0B$9AE22E813/",
@@ -1,6 +1,7 @@
import { PlatformEndpointsModule } from "@/ee/platform-endpoints-module";
import { AtomsModule } from "@/modules/atoms/atoms.module";
import { BillingModule } from "@/modules/billing/billing.module";
import { CalUnifiedCalendarsModule } from "@/modules/cal-unified-calendars/cal-unified-calendars.module";
import { ConferencingModule } from "@/modules/conferencing/conferencing.module";
import { DestinationCalendarsModule } from "@/modules/destination-calendars/destination-calendars.module";
import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module";
@@ -30,6 +31,7 @@ import { WebhooksModule } from "./webhooks/webhooks.module";
AtomsModule,
StripeModule,
ConferencingModule,
CalUnifiedCalendarsModule,
OrganizationsTeamsBookingsModule,
OrganizationsUsersBookingsModule,
OrganizationsBookingsModule,
@@ -25,6 +25,7 @@ export class OAuthClientsOutputService {
bookingRescheduleRedirectUri: client.bookingRescheduleRedirectUri ?? undefined,
areEmailsEnabled: client.areEmailsEnabled,
areDefaultEventTypesEnabled: client.areDefaultEventTypesEnabled,
areCalendarEventsEnabled: client.areCalendarEventsEnabled,
};
}
@@ -1,11 +1,12 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
import { Prisma } from "@calcom/prisma/client";
@Injectable()
export class OrganizationsDelegationCredentialRepository {
constructor(private readonly dbRead: PrismaReadService) {}
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
async findById(delegationCredentialId: string) {
return this.dbRead.prisma.delegationCredential.findUnique({ where: { id: delegationCredentialId } });
@@ -22,7 +23,7 @@ export class OrganizationsDelegationCredentialRepository {
delegationCredentialId: string,
data: Prisma.DelegationCredentialUncheckedUpdateInput
) {
return this.dbRead.prisma.delegationCredential.update({
return this.dbWrite.prisma.delegationCredential.update({
where: { id: delegationCredentialId },
data,
include: { workspacePlatform: true },
@@ -19,6 +19,7 @@ import { encryptServiceAccountKey } from "@calcom/platform-libraries";
import {
addDelegationCredential,
toggleDelegationCredentialEnabled,
type TServiceAccountKeySchema,
} from "@calcom/platform-libraries/app-store";
@Injectable()
@@ -47,7 +48,7 @@ export class OrganizationsDelegationCredentialService {
delegatedServiceAccountUser: User,
body: UpdateDelegationCredentialInput
) {
const delegationCredential =
let delegationCredential =
await this.organizationsDelegationCredentialRepository.findByIdWithWorkspacePlatform(
delegationCredentialId
);
@@ -56,6 +57,14 @@ export class OrganizationsDelegationCredentialService {
throw new NotFoundException(`DelegationCredential with id ${delegationCredentialId} not found`);
}
if (body.serviceAccountKey !== undefined) {
const updatedDelegationCredential = await this.updateDelegationCredentialServiceAccountKey(
delegationCredential.id,
body.serviceAccountKey
);
delegationCredential = updatedDelegationCredential ?? delegationCredential;
}
if (body.enabled !== undefined) {
await this.updateDelegationCredentialEnabled(
orgId,
@@ -64,16 +73,13 @@ export class OrganizationsDelegationCredentialService {
body.enabled
);
}
if (body.serviceAccountKey !== undefined) {
await this.updateDelegationCredentialServiceAccountKey(delegationCredentialId, body.serviceAccountKey);
}
// once delegation credentials are enabled, slowly set all the destination calendars of delegated users
if (body.enabled === true && delegationCredential.enabled === false) {
await this.ensureDefaultCalendars(orgId, delegationCredential.domain);
}
return { ...delegationCredential, enabled: body?.enabled ?? delegationCredential?.enabled };
return { ...delegationCredential, enabled: body?.enabled ?? delegationCredential.enabled };
}
async ensureDefaultCalendars(orgId: number, domain: string) {
@@ -126,12 +132,18 @@ export class OrganizationsDelegationCredentialService {
delegationCredentialId: string,
serviceAccountKey: GoogleServiceAccountKeyInput | MicrosoftServiceAccountKeyInput
) {
const encryptedServiceAccountKey = encryptServiceAccountKey(serviceAccountKey);
// First encrypt the service account key
const encryptedServiceAccountKey = encryptServiceAccountKey(
serviceAccountKey as TServiceAccountKeySchema
);
const prismaJsonValue = JSON.parse(JSON.stringify(encryptedServiceAccountKey));
const delegationCredential =
await this.organizationsDelegationCredentialRepository.updateIncludeWorkspacePlatform(
delegationCredentialId,
{
serviceAccountKey: encryptedServiceAccountKey,
serviceAccountKey: prismaJsonValue,
enabled: false,
}
);
return delegationCredential;

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