Compare commits

...
Author SHA1 Message Date
Lauris Skraucis 947b05bf90 fix: v2 CI breaking changes check (#24445)
Post release / release (push) Canceled after 0s
* fix: v2 CI breaking changes check

* fix: ensure defaults

* fix
2025-10-14 13:46:36 +02:00
Hariom BalharaandDevin AI a0e5e03903 feat: Add async spam check and decoy booking response (#24326)
* feat: Add async spam check integration and decoy booking response

- Integrate SpamCheckService with handleNewBooking workflow
- Implement parallel spam check execution for minimal performance impact
- Add decoy booking response with localStorage-based success page
- Extract organization ID from event type for org-specific blocking
- Add comprehensive test coverage for spam detection scenarios
- Create reusable components for booking success cards
- Implement fail-open behavior to never block legitimate bookings

This builds on the spam blocker DI infrastructure from PR #24040 by
adding the actual integration into the booking flow and implementing
the decoy response mechanism to avoid revealing spam detection to
malicious actors.

Related: #24040
Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* Do checks in paralle

* Fix leaking host name in title

* Reduce expoiry time localstorage

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-10-14 11:36:51 +00:00
Rajiv Sahal 09ee39a3d8 feat: upcoming bookings atom (#24010)
* init: upcoming bookings atom

* add comments

* fix: better naming

* fixup

* fix: use `usePrefetch` hook

* add comment- for myself

* chore: add changesets

* fix: ignore isTeamEvent prop if we have teamId

* chore: update docs

* chore: implement PR feedback

* refactor: remove all unnecessary props

* update fetch bookings count
2025-10-14 12:37:37 +02:00
Anik Dhabal Babu 243012e82f fix: update resend inviation to follow auto accept (#24447) 2025-10-14 10:13:58 +00:00
emrysal d96c807505 chore: release v5.7.12 2025-10-14 09:57:19 +00:00
iqram magdon-ismailandAnik Dhabal Babu dac1069181 refactor: change the description of JellyPhone in Update config.json (#24340)
* Update config.json

modifying the description of jellyphone

* Update config.json

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-10-14 09:25:07 +00:00
Udit TakkarandCarinaWolli 06d6c180d1 feat: report booking (#24324)
* feat: report booking

* refactor: improvements

* test: add unit test

* test: add unit test

* chore

* refactor: feedback

* refactor: feedback

* refactor: feedback

* fix: use string

* fix: schema

* chore: improvements

* refactor: create service

* fix: udate test

* fix: feedback

* fix: schema

* fix: type

* fix: type

* refactor: address feedback

* refactor: move to new file

* refactor: move to new file

* chor: remove

* fix UserRepository import

* fix type of bookingUid

* fix: import path

* fix: remove cancellation

* chore: duplicate

* fix: tests

* refactor: feedback

* chore: remove table from here

* fix: types

* fix: types

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2025-10-14 09:23:31 +01:00
Syed Ali Shahbaz a2bee76da6 feat: Add spam blocker DI structure (#24040)
* --init

* --

* replace old structure with new DI

* fix type

* --

* moving stuff around

* moving stuff around again

* minor clean up

* --

* resolve conflict

* clea up

* old schema clean up

* further clean up

* removing unwanted merged responsibilities

* --

* improve DI and SOLID

* some type fixes

* --1

* clean up --cont

* fix DI in facade for test

* more fix

* fix

* checking

* o.o

* fix import

* fix failing test --2

* fix failing test --3

* fix failing test --4

* normalization use

* introduce facade container injection

* uniform async telemetry spans

* further improvements

* replace prismock with repo mocks

* ensure we don't pass prisma outside of repo

* fix import

* remove try catch from repo calls

* async await fixes

* using deps pattern

* more clean up and fixes

* more clean up

* address feedback --1

* separation of concern

* clean up

* test clean up

* feedback --2

* remove extra fetch

* remove await

* migrate _post test from prismock

* fix type

* --

* fix tokens path

* rename AuditRepo

* test --1

* update _post to integration test

* fix test

* fix test

* test fix maybe?

* --

* feedback

* feedback

* fixes

* more feedback

* NIT

* use sentry and logger imports as planned

* assertion in test

* add missing test case

* add tests for controllers and services

* NITs

* fix domain normalisation
2025-10-14 10:16:18 +03:00
52ecea964d fix: add tests for embed (#23561)
* fix: add tests for embed

* update

* fix test

* fix: use immediate interpolation for cancel button link

- Change from delayed evaluation (${...}) to immediate interpolation (${...})
- Remove unnecessary fallback value and complex expression
- Remove frame.goto() from cancelBookingThroughEmbed as iframe already at cancel page
- Remove unused page parameter from selectFirstAvailableTimeSlotNextMonth
- This aligns with working pattern from line 294 and avoids parser issues with complex expressions + query params

Co-Authored-By: anik@cal.com <adhabal2002@gmail.com>

* chore: fix formatting in testUtils

Co-Authored-By: anik@cal.com <adhabal2002@gmail.com>

* fix: read popupCancelUid from URL parameter instead of copying rescheduleUid

The cancel button was using the wrong booking UID because it was copying from
popupRescheduleUid instead of reading from the URL parameter. This caused the
embed iframe to never appear when clicking the cancel button, resulting in test
timeouts.

The test navigates to /?popupCancelUid=<booking_uid> which should be read by
the JavaScript, just like popupRescheduleUid is read from its URL parameter.

Co-Authored-By: anik@cal.com <adhabal2002@gmail.com>

* fix

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-10-13 15:33:30 -03:00
Anik Dhabal BabuandUdit Takkar f9b294cfc2 fix: email and phone field validation (#24432)
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2025-10-13 17:05:11 +00:00
e5f14c9316 feat: form submitted no event booked workflow trigger #2 (#23716)
* refactor: improve _scheduleWorkflowReminders readability and add missing booking trigger events

- Extract complex conditional logic into helper functions (isImmediateTrigger, isTimeBased, shouldProcessWorkflow)
- Add missing workflow trigger events with immediate execution logic
- Update test workflows to use different actions (EMAIL_ATTENDEE, SMS_ATTENDEE) for better differentiation
- Fix translation function mock in confirm.handler.test.ts using mockNoTranslations utility
- Maintain existing functionality while improving code maintainability

Co-Authored-By: amit@cal.com <samit91848@gmail.com>

* only show customt emplate for form triggers

* filter outside scheduleWorkflowReminder

* fix type check

* chore: add more tests

* test: add comprehensive unit tests for handleMarkNoShow with webhook and workflow coverage

- Create handleMarkNoShow.test.ts following confirm.handler.test.ts pattern
- Add expectBookingNoShowUpdatedWebhookToHaveBeenFired utility function
- Test both webhook and workflow triggers for BOOKING_NO_SHOW_UPDATED
- Cover attendee/host no-show scenarios, multiple attendees, and error cases
- All 6 unit tests pass with proper mocking of external dependencies

Co-Authored-By: amit@cal.com <samit91848@gmail.com>

* Revert "test: add comprehensive unit tests for handleMarkNoShow with webhook and workflow coverage"

This reverts commit 764299220279f0c012392dec24d3150246bfc4ad.

* fix: add new workflow triggers to api/v2

* update swagger docs

* fix: e2e

* fix type check

* fix tests, add test for before after events

* fix unit tests

* revert confirm.handler.test

* fix: unit tests

* dummy form variables

* add routing forms to active on dropdown

* add migration file

* Ui fixes for variables dropdown

* remove other translation keys

* review fixes

* allow routing forms for activeOn

* use repository function to get routing forms

* remove unnecessary code

* adjust logic in update handler

* add triggers to api v2

* remvoe unused file

* rename to getAcitveOnOptions handler

* remove routingFormOptions handler

* clean up getActiveOnOptions

* refactor WorkflowService

* remove logs

* remove unused

* fix: type check

* fix: missed before after events for recurring

* fix: calendarEvent handleMarkNoShow

* fix error message

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* don't query disabled routing forms

* create tasker function

* add tasker code

* move isFormTrigger function

* small adjustments + todo comments

* remove email to host action for form triggers

* throw trpc error if email to host is added as step

* fix dialog on how to use form responses as variables

* remove add variable dropdown for form triggers

* remove form workfows in event workflows tab

* improvements for workflow logic on form submission

* review fixes

* base setup for seperate schedule functions (evt and form)

* add missing BOOKING_PAID workflow trigger

* fix pathname

* fix: test for BOOKING_REQUESTED

* fix activeOn ids

* pass hideBranding and smsReminderNumber

* adjustments to reminderScheduler

* create empty scheduelForForm functions

* pass locale and timezone with form user

* pass formData instead of responses

* pass timeFormat and locale

* reusable function for email sending and reminder creation

* implement scheduleEmailReminderForForm

* remove added editor field from merge conflict

* don't support cal.ai action with form triggers

* throw bad request if form trigger and cal.ai is combined

* add tests for scheduleFormWorkflows

* add form submission tests

* remove form response varibe info

* clean up workflow actions

* fixes for getting template options

* pass triggerType to getAllWorkflows

* move reusable logic to scheduleSMSReminder

* add formdata to param type

* type fixes for text reminder managers

* implement scheduleSMSReminderForForm

* fix import

* fix isAuthorizedToAddActiveOnIds

* disble whatsapp action

* implement triggerFormSubmittedNoEventWorkflow

* code clean up

* Merge branch 'devin/1755107037-add-workflow-triggers' into feat/routing-form-workflow-triggers

* fix type errors

* remove async from getSubmitterEmail

* fix type errors

* revert cal.ai changes

* fix type error

* add sublogger

* code clean up

* fix type errors

* remove label for attendee whatsapp action

* code clean up

* fixes saving teams on org workflows

* fix type error

* code improvements for activeOn ids

* Revert "code improvements for activeOn ids"

This reverts commit 0a3590a4e2ce541b17d63483ad86ed458795a6a3.

* improve variable name

* fix unit tests

* small fixes

* type fixes

* remove unused translation keys

* fix merge conflict issues

* code clean up

* remove SMS action support

* remove more SMS code

* add missing imports

* set custom template for form action

* type fixes

* fix tasker endpoint

* fix duplicate check

* fix workfows.test.ts

* use repository funciton to getHideBranding

* code clean up

* fix hasDuplicateSubmission

* code clean up

* select only needed properties

* remove repository functions

* Revert "remove repository functions"

This reverts commit 7aa47b1c59c9abd7f964ebf26f746934c53a44f4.

* add scheduleWorkflows function

* Revert "add scheduleWorkflows function"

This reverts commit fe5db4fe3b65e2743c95475d585300cab98beed7.

* move type to /types

* Revert "move type to /types"

This reverts commit 91e0152154594b3772a801a426260068a8ccea54.

* revert changes causing type errors

* remove import

* remove unused import

* Revert "remove unused import"

This reverts commit 1916768c875ea5d0ac5598ccb8f9c796c5622dc9.

* revert changed from attempt to fix type errors

* pass object to gt all workflows

* fix isAuthorized check

* trigger filtering

* remove form submitted no event booked code

* remove form submitted no event from schema

* remove more code

* remove test

* fixes

* add getSubmitterEmail function

* add trigger

* small fixes

* add missing workflow DTOs

* small fixes

* use activeOnWithChildren

* fix active on when switching trigger type

* remove add variable dropdown

* add getAllWorkflowsFromRoutingForm to WorkflowService

* fix error caused by undefined evt

* fix type error

* fix type error

* fix tests

* code clean up

* final fixes and clean up

* remove console.log

* remove template text form from triggers

* add routing form repoditory function

* fix bug with key

* fix test

* add missing trigger in update-workflow.input.ts

* ForEvt and ForForm function for aiPhoneCallManager

* chore: add support for form workflows on api v2

* fixup! chore: add support for form workflows on api v2

* use only repository functions in update handler

* move all prisma queries from list.handler

* review suggestions

* chore: handle workflows api v2

* chore: handle workflows api v2, split in 2 endpoints

* fix workflow step creation

* remove connect agent and fixes types

* add type to workflow

* chore: use workflow type in apiv2 WorkflowsOutputService

* update worklfow type on update

* chore: use workflow type in apiv2 WorkflowsOutputService

* fix template body for torm trigger

* some UI fixes for email subject/body

* resetting email body when changing form triggers

* use type field to query workflows

* clean up all old active on values

* remove responseId from all funciton calls

* remove undefined from updateTemplate

* refactor: split routing form and event-type workflows code

* refactor: split routing form and event-type workflows code

* fix template  text when adding action

* chore: don't rename WorkflowActivationDto to avoid ci blocking

* refine update schedule to use only allowed actions

* fix type error

* don't allow whatsapp action with form trigger

* fix type error

* return early if activeOn array is empty

* fix: from step type in BaseFormWorkflowStepDto

* fixup! fix: from step type in BaseFormWorkflowStepDto

* api v2 updates

* move all prisma calls to repository (service/workflows.ts)

* use FORM_TRIGGER_WORKFLOW_EVENTS for form queries

* use userRepository

* use FORM_TRIGGER_WORKFLOW_EVENTS in isFormTrigger

* code clean up

* code clean up

* use repository functions in formSubmissionValidation.ts

* add back trpc  import

* fix agent repository functions

* remove unsued import

* fixes for offset api v2

* add missing responseId

* fix failing test

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: amit@cal.com <samit91848@gmail.com>
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Benny Joo <sldisek783@gmail.com>
Co-authored-by: cal.com <morgan@cal.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2025-10-13 16:13:56 +00:00
Hariom Balhara bed76beb2f fix: Layout shift in inline embed caused due to showing embed before it was really ready (#24427) 2025-10-13 12:29:31 -03:00
Benny Joo ff38d6c7db refactor: Remove circular deps between @calcom/lib and @calcom/features [1] (#24399)
* add eslint config

* migrate autoLock to features

* migrate teamService to features

* migrate userCreationService

* migrate insights services to features

* migrate ProfileRepository

* update imports

* migrate filter segmen tests

* migrate filter segment repository

* migrate getBusyTimes

* migrate getLocaleFromRequest

* refactor csvUtils

* make filename clearer

* migrate getLuckyUser integration test

* migrate autoLock test to features

* wip

* refactors

* migrate useBookerUrl

* migrate more

* wip

* Migrate eventTypeRepository

* membership repository

* update imports

* update imports

* migrate

* move organization repository

* update imports

* update imports

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix tests

* fix type checks

* fix

* fix

* migrate

* update imports

* fix tests

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix
2025-10-13 12:01:02 -03:00
1dc20e3aec refactor: usePrefetch hook (#24123)
* init: refactor usePrefetch hook to make it readable

* fix: move over logic to determine prefetch next month into its own function

* update parameters

* fix: better naming

* fix: type check

* test: add comprehensive test coverage for usePrefetch utility functions

- Add tests for areDifferentValidMonths: validates month comparison logic with edge cases
- Add tests for isLastWeekOfMonth: covers week boundaries and date edge cases
- Add tests for isMonthViewPrefetchEnabled: tests time-based prefetch logic with mocked time
- Add tests for getPrefetchMonthCount: validates conditional month count logic for different layouts
- Add tests for isPrefetchNextMonthEnabled: integration tests for orchestration function

All tests use vitest framework with 51 test cases covering key scenarios and edge cases.
Tests follow existing patterns from Cal.com codebase and use integration approach.

Co-Authored-By: rajiv@cal.com <sahalrajiv6900@gmail.com>

* fix: implement coderabbit feedback

* fix: tests

* fix: use isoWeek plugin from day to maintain consistency

* fix: type errors

* fix: dont use isoWeek from dayjs

* revert isoWeek import for dayjs

* add helper function

* fix: bring back the orginal logic we had in usePrefetch but in a simplified way

* remove unused logic

* test: update tests for usePrefetch refactoring

- Add tests for new isMonthChange helper function
- Update isPrefetchNextMonthEnabled tests to match new signature
- Tests now use pre-calculated month values instead of date strings

All 39 tests passing locally

Co-Authored-By: rajiv@cal.com <sahalrajiv6900@gmail.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-10-13 22:51:50 +09:00
44a3a9eabb feat: form submitted workflow triggers #1 (#23704)
* feat: add 5 new workflow triggers for booking events

- Add BOOKING_REJECTED, BOOKING_REQUESTED, BOOKING_PAYMENT_INITIATED, BOOKING_PAID, BOOKING_NO_SHOW_UPDATED to WorkflowTriggerEvents enum
- Update workflow constants to include new trigger options
- Implement workflow trigger logic for booking rejected and requested events
- Add translations for new workflow triggers following {enum}_trigger format
- Generate updated Prisma types for new schema changes

Co-Authored-By: amit@cal.com <samit91848@gmail.com>

* fix: type check, remove as any

* feat: add workflow trigger for BOOKING_REQUESTED in handleNewBooking.ts

- Add WorkflowTriggerEvents import to handleNewBooking.ts
- Implement workflow trigger logic for BOOKING_REQUESTED in else block
- Filter workflows by BOOKING_REQUESTED trigger and call scheduleWorkflowReminders
- Use proper calendar event object construction without type casting
- Add error handling for workflow reminder scheduling

Co-Authored-By: amit@cal.com <samit91848@gmail.com>

* fix: resolve type errors in workflow trigger implementations

- Add proper database includes for user information in handleConfirmation.ts
- Fix ExtendedCalendarEvent type structure with correct hosts mapping
- Add missing properties to calendar event objects in handleMarkNoShow.ts
- Ensure all workflow triggers follow proper type patterns

Co-Authored-By: amit@cal.com <samit91848@gmail.com>

* feat: add workflow test configurations for new booking triggers

- Add workflow configurations for BOOKING_REQUESTED and BOOKING_PAYMENT_INITIATED in fresh-booking.test.ts
- Add workflow configuration for BOOKING_REJECTED in confirm.handler.test.ts
- Enable previously skipped confirm.handler.test.ts
- Remove workflow test assertions temporarily until triggers are fully functional
- Maintain webhook test coverage while adding workflow test infrastructure

Co-Authored-By: amit@cal.com <samit91848@gmail.com>

* fix: add missing mockSuccessfulVideoMeetingCreation import to confirm.handler.test.ts

- Import mockSuccessfulVideoMeetingCreation from bookingScenario utils
- Add mock call to BOOKING_REJECTED workflow test case
- Resolves ReferenceError that was causing unit test CI failure

Co-Authored-By: amit@cal.com <samit91848@gmail.com>

* add new triggers

* refactor: improve _scheduleWorkflowReminders readability and add missing booking trigger events

- Extract complex conditional logic into helper functions (isImmediateTrigger, isTimeBased, shouldProcessWorkflow)
- Add missing workflow trigger events with immediate execution logic
- Update test workflows to use different actions (EMAIL_ATTENDEE, SMS_ATTENDEE) for better differentiation
- Fix translation function mock in confirm.handler.test.ts using mockNoTranslations utility
- Maintain existing functionality while improving code maintainability

Co-Authored-By: amit@cal.com <samit91848@gmail.com>

* only show customt emplate for form triggers

* filter outside scheduleWorkflowReminder

* fix type check

* chore: add more tests

* test: add comprehensive unit tests for handleMarkNoShow with webhook and workflow coverage

- Create handleMarkNoShow.test.ts following confirm.handler.test.ts pattern
- Add expectBookingNoShowUpdatedWebhookToHaveBeenFired utility function
- Test both webhook and workflow triggers for BOOKING_NO_SHOW_UPDATED
- Cover attendee/host no-show scenarios, multiple attendees, and error cases
- All 6 unit tests pass with proper mocking of external dependencies

Co-Authored-By: amit@cal.com <samit91848@gmail.com>

* Revert "test: add comprehensive unit tests for handleMarkNoShow with webhook and workflow coverage"

This reverts commit 764299220279f0c012392dec24d3150246bfc4ad.

* fix: add new workflow triggers to api/v2

* update swagger docs

* fix: e2e

* fix type check

* fix tests, add test for before after events

* fix unit tests

* revert confirm.handler.test

* fix: unit tests

* dummy form variables

* add routing forms to active on dropdown

* add migration file

* Ui fixes for variables dropdown

* remove other translation keys

* review fixes

* allow routing forms for activeOn

* use repository function to get routing forms

* remove unnecessary code

* adjust logic in update handler

* add triggers to api v2

* remvoe unused file

* rename to getAcitveOnOptions handler

* remove routingFormOptions handler

* clean up getActiveOnOptions

* refactor WorkflowService

* remove logs

* remove unused

* fix: type check

* fix: missed before after events for recurring

* fix: calendarEvent handleMarkNoShow

* fix error message

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* don't query disabled routing forms

* create tasker function

* add tasker code

* move isFormTrigger function

* small adjustments + todo comments

* remove email to host action for form triggers

* throw trpc error if email to host is added as step

* fix dialog on how to use form responses as variables

* remove add variable dropdown for form triggers

* remove form workfows in event workflows tab

* improvements for workflow logic on form submission

* review fixes

* base setup for seperate schedule functions (evt and form)

* add missing BOOKING_PAID workflow trigger

* fix pathname

* fix: test for BOOKING_REQUESTED

* fix activeOn ids

* pass hideBranding and smsReminderNumber

* adjustments to reminderScheduler

* create empty scheduelForForm functions

* pass locale and timezone with form user

* pass formData instead of responses

* pass timeFormat and locale

* reusable function for email sending and reminder creation

* implement scheduleEmailReminderForForm

* remove added editor field from merge conflict

* don't support cal.ai action with form triggers

* throw bad request if form trigger and cal.ai is combined

* add tests for scheduleFormWorkflows

* add form submission tests

* remove form response varibe info

* clean up workflow actions

* fixes for getting template options

* pass triggerType to getAllWorkflows

* move reusable logic to scheduleSMSReminder

* add formdata to param type

* type fixes for text reminder managers

* implement scheduleSMSReminderForForm

* fix import

* fix isAuthorizedToAddActiveOnIds

* disble whatsapp action

* implement triggerFormSubmittedNoEventWorkflow

* code clean up

* Merge branch 'devin/1755107037-add-workflow-triggers' into feat/routing-form-workflow-triggers

* fix type errors

* remove async from getSubmitterEmail

* fix type errors

* revert cal.ai changes

* fix type error

* add sublogger

* code clean up

* fix type errors

* remove label for attendee whatsapp action

* code clean up

* fixes saving teams on org workflows

* fix type error

* code improvements for activeOn ids

* Revert "code improvements for activeOn ids"

This reverts commit 0a3590a4e2ce541b17d63483ad86ed458795a6a3.

* improve variable name

* fix unit tests

* small fixes

* type fixes

* remove unused translation keys

* fix merge conflict issues

* code clean up

* remove SMS action support

* remove more SMS code

* add missing imports

* set custom template for form action

* type fixes

* fix tasker endpoint

* fix duplicate check

* fix workfows.test.ts

* use repository funciton to getHideBranding

* code clean up

* fix hasDuplicateSubmission

* code clean up

* select only needed properties

* remove repository functions

* Revert "remove repository functions"

This reverts commit 7aa47b1c59c9abd7f964ebf26f746934c53a44f4.

* add scheduleWorkflows function

* Revert "add scheduleWorkflows function"

This reverts commit fe5db4fe3b65e2743c95475d585300cab98beed7.

* move type to /types

* Revert "move type to /types"

This reverts commit 91e0152154594b3772a801a426260068a8ccea54.

* revert changes causing type errors

* remove import

* remove unused import

* Revert "remove unused import"

This reverts commit 1916768c875ea5d0ac5598ccb8f9c796c5622dc9.

* revert changed from attempt to fix type errors

* pass object to gt all workflows

* fix isAuthorized check

* trigger filtering

* remove form submitted no event booked code

* remove form submitted no event from schema

* remove more code

* remove test

* fixes

* add getSubmitterEmail function

* add missing workflow DTOs

* small fixes

* use activeOnWithChildren

* fix active on when switching trigger type

* remove add variable dropdown

* add getAllWorkflowsFromRoutingForm to WorkflowService

* fix error caused by undefined evt

* fix type error

* fix type error

* fix tests

* code clean up

* remove console.log

* remove template text form from triggers

* add routing form repoditory function

* fix bug with key

* add missing trigger in update-workflow.input.ts

* ForEvt and ForForm function for aiPhoneCallManager

* chore: add support for form workflows on api v2

* fixup! chore: add support for form workflows on api v2

* use only repository functions in update handler

* move all prisma queries from list.handler

* review suggestions

* chore: handle workflows api v2

* chore: handle workflows api v2, split in 2 endpoints

* fix workflow step creation

* remove connect agent and fixes types

* add type to workflow

* chore: use workflow type in apiv2 WorkflowsOutputService

* update worklfow type on update

* chore: use workflow type in apiv2 WorkflowsOutputService

* fix template body for torm trigger

* some UI fixes for email subject/body

* resetting email body when changing form triggers

* use type field to query workflows

* clean up all old active on values

* remove responseId from all funciton calls

* remove undefined from updateTemplate

* refactor: split routing form and event-type workflows code

* refactor: split routing form and event-type workflows code

* fix template  text when adding action

* chore: don't rename WorkflowActivationDto to avoid ci blocking

* refine update schedule to use only allowed actions

* fix type error

* don't allow whatsapp action with form trigger

* fix type error

* return early if activeOn array is empty

* fix: from step type in BaseFormWorkflowStepDto

* fixup! fix: from step type in BaseFormWorkflowStepDto

* move all prisma calls to repository (service/workflows.ts)

* use FORM_TRIGGER_WORKFLOW_EVENTS for form queries

* use userRepository

* use FORM_TRIGGER_WORKFLOW_EVENTS in isFormTrigger

* code clean up

* code clean up

* add back trpc  import

* fix agent repository functions

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: amit@cal.com <samit91848@gmail.com>
Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Benny Joo <sldisek783@gmail.com>
Co-authored-by: cal.com <morgan@cal.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2025-10-13 12:20:47 +02:00
chauhan_s c0c52750b6 docs: add example and improve tabs prop documentation in event type settings (#24393) 2025-10-13 10:18:46 +00:00
3bcaf0ec22 fix: move team credits to org on upgarde (#24386)
* move credits from team to org

* add test

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
2025-10-13 09:30:22 +00:00
Anik Dhabal BabuandDevin AI b881e025f6 fix: Standardize email field editable to system-but-optional (#24416)
Change email booking field's editable property from 'system' to
'system-but-optional' across all code paths. This allows email field
to be optional when phone field is required instead.

- Updated systemBeforeFieldEmail in API v2024_06_14 transformers
- Fixed explicit override in API v2024_04_15 service
- Updated test utility to match production behavior
- Ensures consistency with validation logic that requires either
  email OR phone to be visible and required

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-10-13 09:19:59 +00:00
sean-brydon 35d140e0a7 feat: PBAC - opt-in for organizations. (#24424)
* WIP UI for opt in pbac

* Remove header

* Opt in plus route invaldiation

* Use dialog upgrade tip approach

* Add i18n + animation

* Format

* Address @designer man feedback

* Fix type errors

* Add i18n to opt in view settings header + add comments for svg

* remvoe comments
2025-10-13 09:31:53 +01:00
Anik Dhabal Babu 5fd55cbfc3 fix: unable to cancel a seated event as an host (#24426) 2025-10-13 07:38:19 +00:00
18c546a171 fix: pass cancellation reason for seated event cancellations in API v2 (#24409)
* fix: pass cancellation reason for seated event cancellations in API v2

- Add cancellationReason field to CancelSeatedBookingInput_2024_08_13 type
- Update transformInputCancelSeatedBooking to use provided cancellationReason
- Fix pre-existing ESLint warnings (no-prototype-builtins) in isRescheduleSeatedBody and isCancelSeatedBody
- Fixes issue where API v2 throws 'cancellation reason required' error even when reason is provided
- Resolves validation error for hosts canceling seated events via API v2

Co-Authored-By: anik@cal.com <adhabal2002@gmail.com>

* Update input.service.ts

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-10-13 07:19:34 +00:00
Carina WollendorferandCarinaWolli b01ceb427d add videoCallUrl to zapier payload (#24403)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2025-10-13 06:02:03 +00:00
Rajiv Sahal eae2711f21 chore: update docs to make sure to inform users to pass in cal-api-version header (#24364)
Post release / release (push) Canceled after 0s
2025-10-10 11:00:27 +00:00
emrysal fac956b761 chore: release v5.7.11 2025-10-10 10:54:23 +00:00
b286244aab fix: api v2 logic for slots reservation endpoint (#23222)
* init logic for handling reservation for round robin event

* remove unused comments

* add function to get already reserved slots

* fixup: add correct logic for round robin slots reservation

* fixup: implement PR feedback

* fixup: update logic to handle fixed and non fixed round robin hosts

* update slots repository

* fixup: implement PR feedback

* fixup: implement PR feedback

* add tests for round robin slot reservation with non fixed hosts

* add tests for round robin slot reservation with fixed and non fixed hosts

* fix: implement PR feedback

* fix: e2e test

* fix: move validateRoundRobinSlotAvailability to core libraries

* fix: merge conflicts

* fix: merge conflicts

* update slots.ts for platform libraries

* fix: import logic from platform libraries

* cleanup

* fix: import path

* fix: missing import

* fix: test code and handle error thrown

---------

Co-authored-by: supalarry <laurisskraucis@gmail.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-10-10 12:35:21 +02:00
emrysal c46f824e3f chore: release vv5.7.11 2025-10-10 10:31:11 +00:00
Benny Joo dbe55f0804 refactor: Break videoClient into two separate files in app-store and features (#23992)
* getVideoAdapters

* fix
2025-10-10 07:11:46 -03:00
Hariom BalharaandVolnei Munhoz 3bcce02b92 chore: [Booking flow refactor - 2] Integrate Booking services (#23156)
* Integrate booking services

* Fix imports

---------

Co-authored-by: Volnei Munhoz <volnei.munhoz@gmail.com>
2025-10-09 18:21:55 -03:00
Anik Dhabal Babu 2a23911a5b fix: extra 30-minute buffer was added to 60-minute slots without it being set (#24087)
* fix: 30 min buffer added on 60 min slots

* update

* fix test

* Update restrictionSchedule.test.ts

* addressed test

* Update getSchedule.test.ts
2025-10-09 18:37:52 +01:00
sean-brydonandcoderabbitai[bot] 5aa24c8472 fix: event-type permissions (#24318)
* New permission function

* Update permission check service to have fall backs

Update event type create permission procedure to also have org heigharchy

* Update get eventType handler to use new procedure

* Type check

* Type fix

* Update packages/features/pbac/lib/event-type-permissions.ts

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Fix type errorr

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-10-09 12:30:12 -03:00
Benny Joo 8277ce934b fix: ESLint config in app-store and features (#24387)
* make eslint config in app-store

* remove the one in features
2025-10-09 14:09:23 +00:00
Benny Joo bb68cd73ef refactor: circular deps between app store and lib [6] (#23971)
* move delegation credential repository to features

* mv credential repository to features

* update imports

* mv

* fix

* fix

* fix

* fix

* fix

* update imports

* update imports

* update eslint rule

* fix

* fix

* mv getConnectedDestinationCalendars

* fix import errors

* mv getCalendarsEvents

* remove getUsersCredentials

* wip

* revert eslint rule change for now

* fix type checks

* fix

* format

* cleanup

* fix

* fix

* fix

* fix

* fix tests

* migrate getUserAvailability

* migrate

* fix tests

* fix type checks

* fix

* fix

* migrate crmManager

* update imports

* migrate raqbUtils to appstore

* migrate getLuckyUser to features

* migrate findTeamMembersMatchingAttributeLogic to appstore

* update imports

* fix

* fix

* fix test

* fix unit tests

* fix

* fix

* add eslint config
2025-10-09 14:02:12 +00:00
Rajiv Sahal 445b307972 fix: week view and column view slots not fetching at end of month bug (#24374)
* fix: dont pick date value from url, use selectedDate value from booker store instead

* fix: replace month count logic with usePrefectch hook

* intialise booker layout as week view
2025-10-09 13:56:59 +01:00
Calcom BotandLingo.dev d2d86a4079 feat: update translations via @LingoDotDev (#24383)
Co-authored-by: Lingo.dev <support@lingo.dev>
2025-10-09 21:43:34 +09:00
chauhan_s 7601cc2194 fix: PER_ACTIVE_USER Plan should be able to access everything above SCALE (#24352)
* Update managed-organizations.billing.service.ts

* test: add plan restriction check for managed organization creation

* test: add pay-per-user plan validation for managed organization creation

* refactor: extract test setup to helpers

* refactor: move helper functions below beforeAll for better test organization
2025-10-09 09:26:22 -03:00
Udit TakkarandCarina Wollendorfer aa48e72c12 feat: inbound calls in cal ai (#23890)
* feat: lang support

* fix: type errors

* feat: select voice agent

* refactor: address feedback

* refactor: address feedback

* refactor: missing import

* fix: types

* feat: add inbound calls

* chore: formatting

* chore

* feat: finish inbound call

* chore: formatting

* fix: update bug

* fix: types

* refactor: Agent Configuration Sheet (#23930)

* refactor: agent configuration sheet

* chore: use default phone numbre

* refactor: improvements

* refactor: improvements

* fix: types

* fix: feedback

* chore:

* fix: feedback

* fix: prompt

* fix: review

* fix: review

* refactor: class

* refactor: class

* refactor: rename

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

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

* chore: update set value

* fix: remove index

* fix: type error

* fix: update tetss

* fix: use logger

* refactor: don't use static

* fix: type

* fix: schema

* refactor:

---------

Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
2025-10-09 11:40:37 +01:00
Hariom BalharaandDevin AI bec49ef417 fix: provide meetingStartTime for test preview to prevent 'Meeting start time is required' crash (#24379)
Post release / release (push) Canceled after 0s
* fix: provide meetingStartTime for test preview to prevent RR timestamp error

When Round Robin timestamp basis is set to START_TIME, the system requires
a meeting start time to calculate the interval for load balancing. Test
Preview doesn't have a real meeting time since it's just simulating the
routing logic.

This fix provides the current time (new Date()) as the meetingStartTime
when calling getOrderedListOfLuckyUsers in test preview mode. This allows
the Round Robin calculation to proceed using the current interval for
realistic ordering while still being in preview mode.

Fixes the 'Meeting start time is required' error that occurs when users
click Test Preview on routing forms with Round Robin timestamp basis set
to START_TIME.

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

* add comment

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-10-09 08:58:57 +00:00
Kartik LabhshetwarandDevanshu Sharma c13aa69565 fix: improve cross button alignment and spacing in FormBuilder Options (#24247)
* fix: improve cross button alignment and spacing in FormBuilder Options

* fix: revert eslint errors

---------

Co-authored-by: Devanshu Sharma <devanshusharma658@gmail.com>
2025-10-09 08:18:19 +00:00
emrysal 1470adb159 chore: release v5.7.10 2025-10-09 08:17:36 +00:00
emrysal bf89991eef chore: release v5.9.10 2025-10-09 00:22:21 +00:00
iqram magdon-ismail 4dfab874e0 refactor: update JellyPhone icon (#24341)
change the icon for jellyphone
2025-10-08 14:38:54 +00:00
Joe Au-Yeung 4303fb8726 feat: Salesforce - add urql/retry-exchange (#24302)
* Add .env variables

* Add `urql/exchange-retry`

* Add retry mechanic to graphql client

* Only add retries if .env variables are set

* Remove console.log
2025-10-08 14:03:40 +00:00
64f811ed0b feat: show organization username in impersonation banner (#24313)
- Update ImpersonatingBanner to use orgAwareUsername field from session
- Falls back to regular username for non-organization users
- Uses existing session data without additional queries

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-10-08 11:31:59 +00:00
4d51873140 feat: remove Cal.ai tab from navigation while preserving URL access (#23894)
Co-authored-by: peer@cal.com <peer@cal.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-10-07 20:38:05 +00:00
3c0fc2e88b fix: reschedule issue when limited booking is enabled in the event settings. (#23717)
* fix

* fix fix

* cleanup

* Update useBookings.ts

* Update store.ts

---------

Co-authored-by: Kartik Saini <41051387+kart1ka@users.noreply.github.com>
Co-authored-by: Devanshu Sharma <devanshusharma658@gmail.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-10-07 19:23:43 +00:00
c702da0334 feat: Add team billing tables (#24148)
* Init billing tables

* Create `IBillingRepository` types

* Create billing repositories

* Create billingRepositoryFactory

* Eslint fix - remove unused organizationOnboarding

* internal-team-billing create saveTeamBilling method using repositories

* On new teams write to team billing table

* On new org write to org billing table

* Change fields to organizationId

* Add todo comment

* Revert "Change fields to organizationId"

This reverts commit bbb2e5dfa6b4c20a8a395f5730848a492cd70d68.

* test: add comprehensive tests for team billing tables

- Fix credit-service.test.ts Prisma mock to export prisma object
- Replace any types with proper TypeScript types in credit-service.test.ts
- Add unit tests for PrismaTeamBillingRepository covering record creation, enum casting, and error handling
- Add unit tests for PrismaOrganizationBillingRepository with same coverage
- Add unit tests for BillingRepositoryFactory to verify correct repository selection
- Add unit tests for InternalTeamBilling.saveTeamBilling() method testing delegation to correct repositories
- All 53 tests pass with TZ=UTC yarn test
- Type checking passes with yarn type-check:ci --force

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* refactor: update saveTeamBilling tests to mock repository interface

- Replace prismaMock usage with BillingRepositoryFactory mock
- Mock IBillingRepository interface instead of Prisma directly
- Follow repository mocking pattern from handleResponse.test.ts
- All tests passing (53 total)

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* Remove log statement

* Remove repository tests

* Address feedback

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-10-07 14:25:31 -04:00
Bhavya Bhardwaj 92169eb0d2 fix: resolve AI assistant button overlap and mobile padding on Teams page (#24286)
* Fix padding in UpgradeTip component

Adjusted padding in UpgradeTip component.

* Adjust bottom positioning of main navigation component

* Adjust bottom positioning for main navigation
2025-10-07 23:11:39 +05:30
kusiewicz 3d74357e96 fix: polish sign-in translation (#24263) 2025-10-07 23:06:07 +05:30
Parship Chowdhury 6593291d00 Proper error handling in PrismaAppRepository.findAppStore with Sentry logging (#24212)
Signed-off-by: Parship Chowdhury <i.am.parship@gmail.com>
2025-10-07 21:23:27 +05:30
Kartik LabhshetwarandKartik Saini c48fb44a8d fix: make empty screen template grid mobile responsive (#24259)
* fix: make empty screen template grid mobile responsive

* refactor: adjust EmptyScreen layout classes for responsive grid

---------

Co-authored-by: Kartik Saini <41051387+kart1ka@users.noreply.github.com>
2025-10-07 21:19:58 +05:30
Peer Richelsen e7c908bc80 feat: framer app (#24204)
* added framer app

* new 1.jpeg

* Update packages/app-store/framer/config.json
2025-10-07 15:12:22 +00:00
Peer RichelsenandRodrigo Ehlers ad35e19f8e feat: fonio.ai app (#23874)
* added fonio

* small hitpay update

* added fonio screenshots

* added description

* fix: rename 1.jpg -> 1.jpeg in fonio-ai app

---------

Co-authored-by: Rodrigo Ehlers <rodrigoehlers@outlook.com>
2025-10-07 14:42:29 +00:00
Keith Williams 3fc8c6c84e chore: Remove unused next dependency in @calcom/ui (#24311)
* chore: Remove unused Next.js package

* update yarn.lock
2025-10-07 01:34:53 +00:00
emrysal da7aeb4a21 chore: release v5.7.9
Post release / release (push) Canceled after 0s
2025-10-06 23:57:19 +00:00
Keith Williams 6197a2a5c5 chore: Upgrade Next.js to 15.5.4 (#24307) 2025-10-06 21:39:26 +00:00
Syed Ali ShahbazandAlex van Andel de186b95c1 fix: create missing migration for dropping obsolete indexes (#24304)
* Create migration.sql

* Drop index if exists, otherwise do nada

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-10-06 21:33:00 +00:00
Syed Ali Shahbaz 6b24513073 Update how-to-setup-api-in-a-local-instance.mdx (#24300) 2025-10-06 17:39:55 +00:00
Kinbaum 55ea18128a fix: timeZone variable name does not match webhook payload (#24279) 2025-10-06 22:49:28 +05:30
Calcom BotandLingo.dev d5557fa512 feat: update translations via @LingoDotDev (#24301)
Co-authored-by: Lingo.dev <support@lingo.dev>
2025-10-06 12:59:10 -03:00
Hariom BalharaandDevin AI 9c97b2aa6d feat: add usernameInOrg field to webhook organizer payload for organization users (#23246)
* feat: add usernameInOrg field to webhook organizer payload for organization users

- Add usernameInOrg field to CalendarEventBuilder organizer interface
- Update handleNewBooking to pass organizerOrganizationProfile.username as usernameInOrg
- Include usernameInOrg in webhook payload generation (sendPayload.ts)
- Add webhook form variable for usernameInOrg with translation
- Update Person type to include usernameInOrg field
- Add test case for organization user webhook verification
- Maintain backward compatibility by keeping existing username field unchanged

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

* feat: add usernameInOrg to additional webhook sending locations

- Update handleCancelBooking.ts to include usernameInOrg in organizer payload
- Update confirm.handler.ts to include usernameInOrg for booking confirmations
- Update getBooking.ts to include usernameInOrg in payment-related webhooks
- Maintain backward compatibility with existing username field
- All webhook sending locations now include organization profile username

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

* fixes

* Add subteam event test for usernameInOrg

* fix eslitn issues

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-10-06 19:51:04 +05:30
sean-brydonandClaude 1e226a40d1 Add next-auth-options.ts to Foundation code owners (#24295)
Added packages/features/auth/lib/next-auth-options.ts to CODEOWNERS for the Foundation team to ensure proper review and oversight of authentication configuration changes.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude <noreply@anthropic.com>
2025-10-06 11:55:58 +00:00
Hariom Balhara bbe46eaa7d Restrict the length of emails as per RFC (#24269) 2025-10-06 10:22:58 +00:00
sean-brydon 7799b191ec feat: botid enabled on api/book/event api route (#24207)
* wip

* WIP

* restore event

* testing without instrument client

* Add conditional for botID init

* Bump BotID version + pass in header

* botID yarn lock changes

* feat: Add feature flag checks + tidy up into service

* rely on env var also

* use eventType repo instead of passing in prisma

* remove slug from audit

* rename botId feature to botid

* add unit tests for bot service
2025-10-06 11:16:13 +01:00
Anik Dhabal Babu f3269a3ddf fix: contentType is added in worng format (#24284) 2025-10-06 06:29:54 +00:00
emrysal a4be822636 chore: release v5.7.8
Post release / release (push) Canceled after 0s
2025-10-04 14:20:17 +00:00
Syed Ali Shahbaz 7cd8dbf7d8 chore: Watchlist schema update (#24246)
* watchlist schema update

* changes addressed

* no backfill audit

* fix type

* fix flow of migration

* fix err

* type err fix

* add if exists check in migration
2025-10-04 01:44:37 +01:00
654a865007 refactor: Salesforce getContacts (#24235)
* lint fixes

* Create getContactOrLeadFromEmail method

* Use getContactOrLeadFromEmail in getContacts

* Move normal getContact query

* fix: correct return type in getContactOrLeadFromEmail and add search mock

- Fix getContactOrLeadFromEmail to return single ContactRecord instead of array
- Add conn.search() implementation to salesforceMock for integration tests
- Ensure id field is always string with proper fallbacks for AccountId and Id
- Fix Owner.Email to use undefined instead of null in mock

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* test: add comprehensive tests for getContactOrLeadFromEmail method

- Add 6 new unit tests covering all key scenarios
- Test contact lookup, lead fallback, and preference logic
- Test both roundRobinSkipFallbackToLeadOwner and createEventOnLeadCheckForContact code paths
- Add search mock support to mockConnection in beforeEach
- All 34 tests passing, type-check clean

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* Implement change to error from #24090

* Address feedback

* Type fix

* test: update SOQL query expectations to include Account.OwnerId

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-10-03 14:53:00 -04:00
Alex van AndelandDevin AI 409a0277fb fix: Resolve BillingPlan tree-shaking issue causing runtime error (#24229)
* fix: Convert BillingPlan enum to const object to prevent webpack tree-shaking

TypeScript enums compile to IIFEs which can be incorrectly tree-shaken by webpack
when 'sideEffects: false' is set in package.json. Converting to a const object
with 'as const' avoids the IIFE pattern while maintaining full type safety.

This fixes the 'TRPCError: BillingPlan is not defined' error that occurred in
the hasTeamPlan tRPC handler.

Follows the existing CHECKOUT_SESSION_TYPES pattern in the same file.

Co-Authored-By: alex@cal.com <me@alexvanandel.com>

* fix: Re-export BillingPlan from billing-plans to prevent tree-shaking

The issue was a module initialization order problem. When BillingPlan was
imported from constants.ts into billing-plans.ts and used inside the
BillingPlanService class, webpack's tree-shaker (with sideEffects: false)
couldn't properly track the value dependency across the package boundary.

By re-exporting BillingPlan from the same module that exports
BillingPlanService, we ensure the constant is bundled together with the
class that uses it, preventing tree-shaking.

Co-Authored-By: alex@cal.com <me@alexvanandel.com>

* revert: Restore BillingPlan enum format in constants.ts

The const object conversion didn't fix the tree-shaking issue. The real
fix is re-exporting BillingPlan from billing-plans.ts to ensure it's
part of the same module as BillingPlanService.

Co-Authored-By: alex@cal.com <me@alexvanandel.com>

* fix: Add BillingPlan as private static member to prevent tree-shaking

By making BillingPlan a private static member of BillingPlanService, webpack
now sees it as part of the class definition rather than just used inside
methods. This creates a strong reference that prevents webpack's tree-shaker
from removing the enum initialization IIFE when 'sideEffects: false' is set.

With the previous approach where BillingPlan was only imported and used inside
methods, webpack's static analysis couldn't properly track the enum usage
across package boundaries (@calcom/features -> @calcom/trpc), causing it to
incorrectly determine the enum initialization was unused code.

Fixes the TRPCError: BillingPlan is not defined runtime error.

Thread: https://calendso.slack.com/archives/C08LT9BLEET/p1759420015428149
Co-Authored-By: alex@cal.com <me@alexvanandel.com>

* fix: Clean up BillingPlan references and add explanatory comment

- Remove unnecessary re-export statement
- Use direct BillingPlan references instead of BillingPlanService.BillingPlan
- Add detailed comment explaining webpack tree-shaking workaround
- Keep import from constants.ts and private static member for webpack reference

Co-Authored-By: alex@cal.com <me@alexvanandel.com>

* fix: Remove Slack reference from comment and restore static usage

- Remove Slack URL from explanatory comment
- Restore BillingPlanService.BillingPlan usage throughout class methods
- This static member usage is essential for webpack to track the enum reference

Co-Authored-By: alex@cal.com <me@alexvanandel.com>

* Fix BillingPlan issue by finding root cause

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-10-03 16:37:45 +01:00
Anik Dhabal Babu 7adab1f65f fix: event not showing up on outlook through ics file (#24249) 2025-10-03 13:52:25 +00:00
Calcom BotandLingo.dev 3b92c86582 feat: update translations via @LingoDotDev (#24248)
Co-authored-by: Lingo.dev <support@lingo.dev>
2025-10-03 12:42:14 +00:00
Devanshu Sharma de195a1c0f feat: add email filtering to team memberships endpoint (#23923)
* Controller Layer updates.

* adding email filtering and pagination to team memberships endpoint..

* Minor enhancements.

* Improve addressed.

* refactor: update team memberships input to handle comma-separated emails

- Replace array format with comma-separated string handling
- Add proper email validation with BadRequestException
- Remove ArrayMaxSize constraint for better flexibility
- Update API documentation and examples
- Align with codebase patterns from get-managed-users.input.ts

* Morgan suggestions addressed.

* More improvements......
2025-10-03 18:09:47 +05:30
an_ifrah24andDevanshu Sharma d7d2487f6d fix: hydration error warning in scheduleListItem component (#24240)
Co-authored-by: Devanshu Sharma <devanshusharma658@gmail.com>
2025-10-03 17:52:32 +05:30
sean-brydon 3e31945157 Fix nits (#24244) 2025-10-03 11:03:07 +00:00
Lauris Skraucis c11814c342 fix: managed user timeZone validation (#24226)
* fix: managed user timeZone validation

* fix: managed user timeZone validation

* fix: managed user timeZone validation
2025-10-03 08:23:54 +00:00
Syed Ali Shahbaz 60e3244088 Migrate watchlist severity to action(#24195) 2025-10-03 11:18:14 +04:00
Rajiv Sahal a1ac5f36f0 feat: add atoms section inside developing guides (#24220) 2025-10-02 21:59:43 +05:30
Lauris SkraucisandMorgan 6f38d40613 refactor: dont allow scheduling team event type without hosts (#24206)
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2025-10-02 15:58:42 +00:00
Lauris Skraucis 6b7d22de3f refactor: fail breaking change check on error only (#24221) 2025-10-02 14:42:49 +00:00
Anik Dhabal Babu 2f763dceda fix: Getting an unauthorized error when accessing the workflow tab for a child-managed event (#24219)
* Update get.handler.ts

* fix: Ignore userIds form filter segment if no permission

* add tests

* Update booking-filters.e2e.ts

* Update booking-filters.e2e.ts

* fix test

* fix: got unautorized error when viewing workflows

* fix
2025-10-02 13:38:21 +00:00
Benny Joo 96468c4083 refactor: move @calcom/lib/di folder to @calcom/features (#24199)
* mv di folder

* update imports

* fix

* fix

* fix test
2025-10-02 08:12:06 -03:00
sean-brydonandBenny Joo 858d52563f feat: pbac - org pages check update permissions (#24164)
* pbac - org pages check update permissions

* restore permision service

* fix type check

* more type fixes

---------

Co-authored-by: Benny Joo <sldisek783@gmail.com>
2025-10-02 20:04:21 +09:00
7505b3b271 feat: add missing sensitive fields to redactSensitiveData (#24121)
- Add private_key, encrypted_credentials, tenant_id, client_email, serviceAccountKey to SENSITIVE_FIELDS array
- Add comprehensive test cases for delegation credential sensitive fields
- Prevents logging of sensitive delegation credential data including private keys

Fixes security issue where delegation credential sensitive fields were not being redacted from logs

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: morgan@cal.com <morgan@cal.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2025-10-01 20:26:58 -03:00
Calcom BotandLingo.dev 758dc88b55 feat: update translations via @LingoDotDev (#24203)
Co-authored-by: Lingo.dev <support@lingo.dev>
2025-10-01 23:24:17 +00:00
Udit Takkar 9be418dc65 chore: update description (#24189) 2025-10-01 23:35:41 +05:30
Volnei Munhoz 2ac310456e fix: Selected calendar delegation credentials (#24190)
* Fix selected calendar delegation credentials
2025-10-01 14:20:36 +00:00
ff264d6f7a fix: allow team with same slug for diff cases (#24029)
* fix: aalow team with slug for diff cases

* addressed review

* fix type error

* update test

* addressed review

* fix test

* Update team.ts

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-10-01 14:04:20 +00:00
8019c414e1 chore: CSRF protect forgot-password functionality (#22361)
* chore: CSRF protect forgot-password functionality

* feat: implement conditional sameSite cookie setting for CSRF tokens

- Use WEBAPP_URL to determine secure cookie settings
- Set sameSite to 'none' for HTTPS environments to support cross-origin scenarios
- Fall back to 'lax' for HTTP development environments
- Follows patterns from PRs #23439 and #23556

Co-Authored-By: alex@cal.com <me@alexvanandel.com>

* update

* change

* NIT

* minor

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: unknown <adhabal2002@gmail.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-10-01 13:38:29 +00:00
chauhan_s a3fbe3731e feat: add host validation for team event types (#24170)
* feat: add host validation for team event types

* refactor: remove hosts validator in favor of documentation-based validation approach

* fix: remove HostsOrAssignAllValidator from event type update inputs

* docs: improve clarity of hosts and assignAllTeamMembers field descriptions in event type inputs
2025-10-01 16:15:40 +05:30
sean-brydonandcoderabbitai[bot] e3742dbc66 fix: Always enforce one owner with pbac (#24144)
* Always enforce one owner

* updates from merge

* remove redudant eslint rules

* Update packages/features/pbac/services/pbac-role-manager.service.ts

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-10-01 11:06:18 +01:00
Rajiv SahalandMorgan 83bf717d6d fix: customReplyEmailTo feedback (#23738)
* fix: move validateRoundRobinSlotAvailability to core libraries

* fix: implement PR feedback

* fix: merge conflicts

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2025-10-01 15:32:32 +05:30
Peer RichelsenandDevin AI 7fcdaa1c46 feat: add created_at field display to admin user edit page (#23805)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-10-01 09:58:20 +00:00
Kartik LabhshetwarandKartik Saini a8154f9405 feat(ui/api-keys): add confirmation dialog for API key deletion (#24118)
* feat(api-keys): add confirmation dialog for API key deletion

* fix: changed text color to subtle

---------

Co-authored-by: Kartik Saini <41051387+kart1ka@users.noreply.github.com>
2025-10-01 09:56:55 +00:00
chauhan_sandLauris Skraucis 52a5afeba5 fix: different calendar hosts (#24000)
* fis: pass the missing `platformClientId` to `handleNewBooking` in api/v2

* Update formatCalendarEvent.ts

* only add the externald in case of same calendar, otherwise add if from the .ics file sent in email

* Revert "fis: pass the missing `platformClientId` to `handleNewBooking` in api/v2"

This reverts commit 030ac0f0981c1135d4973fbdafa62d58e8985831.

* fix: alow the host change logic to run for collective envets as well

* fix: run the changedOrganizer logic for both collective and round-robin events

* Update handleNewBooking.ts

* fix: failing unit test

---------

Co-authored-by: Lauris Skraucis <lauris.skraucis@gmail.com>
2025-10-01 12:15:34 +03:00
Anik Dhabal Babu 43cc40d7fe fix: add permissions to workflows (#24192) 2025-10-01 06:53:08 +00:00
Hariom Balhara 42a98f000e fix: 405 method not allowed error for queuedFormResponseCleanup (#24191) 2025-10-01 04:55:48 +00:00
Joe Au-Yeung f003ef935b Remove safeStringify from graphql errors (#24188) 2025-10-01 09:50:15 +05:30
Volnei Munhoz e2b2a184c8 fix: Calendar Cache sync page (#24182)
* fix

* fix calendar sync page
2025-09-30 17:10:51 +01:00
Calcom BotandLingo.dev 094595c435 feat: update translations via @LingoDotDev (#24178)
Co-authored-by: Lingo.dev <support@lingo.dev>
2025-09-30 13:51:13 +00:00
Benny Joo 4118a86c0d refactor: do a permission check in removeHostsFromEventTypes trpc handler (#24176) 2025-09-30 13:30:12 +00:00
sean-brydon 928d6f3821 chore: PBAC routingform entity permissions to favour pbac (#24130)
* Remove routingform entity permissions to favour pbac

* push typo

* fix types

* update returns

* Remove unused function

* nits

* fix hariom feedback

* fix correct write permissions
2025-09-30 18:55:43 +05:30
Benny Joo b2ade568c8 refactor: more pbac replacements (#24135)
Post release / release (push) Canceled after 0s
* getUserConnectedApps

* create teams

* use permission check in team deletion

* team read

* revert

* publish handler

* wip

* revert

* wip

* wip
2025-09-30 13:11:02 +00:00
27820ce897 feat: auto-accept team invitations for existing users (#24091)
* feat: auto-accept team invitations for existing users

- Change email button text from 'View Invitation' to 'Accept Invite'
- Implement auto-accept flow when clicking email CTA
- Update TeamService.inviteMemberByToken to support auto-acceptance
- Add new autoAcceptInvite tRPC endpoint for handling auto-acceptance
- Update invitation link generation to include autoAccept parameter
- Handle both team and organization invitation scenarios
- Maintain payment/billing flow integration with TeamBilling.updateQuantity
- Preserve backward compatibility with existing manual flow
- Update all locale files with new 'Accept Invite' button text

Co-Authored-By: anik@cal.com <adhabal2002@gmail.com>

* revert: locale changes except English

- Keep only English 'Accept Invite' translation
- Revert all other locale files to original 'View Invitation' translations
- Maintain core auto-accept invitation functionality

Co-Authored-By: anik@cal.com <adhabal2002@gmail.com>

* simplify: remove autoAccept parameter and make auto-acceptance default

- Remove autoAccept parameter from TeamService.inviteMemberByToken
- Always auto-accept invitations for existing users clicking email links
- Remove autoAccept logic from teams server-page.tsx
- Remove autoAccept=true from invitation URLs
- Delete autoAcceptInvite handler and schema files
- Remove autoAcceptInvite endpoint from tRPC router
- Simplify invitation flow to match new user pattern

Co-Authored-By: anik@cal.com <adhabal2002@gmail.com>

* test: update teamService test to expect auto-accepted memberships

- Change expectation from accepted: false to accepted: true
- Update test description to reflect auto-accept behavior
- Fix TypeScript type casting to use Pick<TeamRepository, 'deleteById'>
- Aligns with new default auto-acceptance for team invitations

Co-Authored-By: anik@cal.com <adhabal2002@gmail.com>

* update

* Update utils.ts

* fix type error

* delete token

* add prisma transaction

* update

* update param

* test: fix mock objects in teamService tests with realistic data

- Fix duplicate property assignments in mock user objects
- Use proper email format (user@example.com) for email fields
- Use proper username format (testuser) for username fields
- Fix logic error in acceptInvitationByToken (|| to &&)
- Add autoAccept parameter to resendInvitation.handler.ts
- All 16 tests passing with proper TypeScript types

Co-Authored-By: anik@cal.com <adhabal2002@gmail.com>

* test: add e2e tests for team invitation auto-accept flow

- Add test for existing user auto-accepting team invitation via email link
- Add test for error handling when wrong user tries to use invitation link
- Verify proper user identity validation and database state changes
- Follow existing e2e test patterns with browser context isolation
- Fix ESLint warnings: replace conditional with assertion and remove unused browser parameter

Co-Authored-By: anik@cal.com <adhabal2002@gmail.com>

* fix

* fix

* fix: update team owner creation in e2e tests to include proper names

- Fix email subject mismatch in auto-accept invitation tests
- Team owners now created with explicit names instead of undefined
- Matches pattern used in other working team invitation tests

Co-Authored-By: anik@cal.com <adhabal2002@gmail.com>

* fix: update organization invitation test helper to match new auto-accept link format

- Change expectExistingUserToBeInvitedToOrganization to look for 'teams?token' instead of 'settings/team'
- Fixes 'Invite link not found' error in organization booking e2e test
- Aligns with auto-accept invitation URL changes that use /teams?token= format
- Fix eslint disable comment for playwright rule

Co-Authored-By: anik@cal.com <adhabal2002@gmail.com>

* address coderrabit review

* fix failing test

* addressed review

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-09-30 13:07:21 +00:00
emrysal fea7e66996 chore: release v5.7.7 2025-09-30 12:57:27 +00:00
sean-brydon 474d1571ae Remove checkAdminOrOwner from settings layout client (#24162) 2025-09-30 12:08:05 +01:00
Rajiv Sahal 2ef627b87e fix: css issue for 12/24 styles (#24141) 2025-09-30 08:38:17 +00:00
sean-brydon 2dfb82b623 Bring back correct routing form teams (#24158) 2025-09-30 08:37:16 +00:00
sean-brydon ccd7fdfd52 refactor role manage (#24146) 2025-09-30 08:35:26 +01:00
Keith Williams f92e59ed34 chore: Update yarn.lock (#24153) 2025-09-30 02:19:30 +00:00
Keith WilliamsandDevin AI d29477349a test: replace prismaMock with BookingRepository mock in checkBookingLimits tests (#24152)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-09-29 23:34:20 +00:00
Alex van Andel 259e480baa chore: Handle stripe refunds slightly differently to reduce hard errors (#24150)
* chore: Handle stripe refunds slightly differently to reduce hard errors

* Remove 'Received and discarded' error when a credential is not found for a triggered subscription

* Fix handling of no credential found (do show in stripe debugger)

* Minimal overhaul of delete handling

* Fix doc typo
2025-09-29 23:07:08 +00:00
Benny Joo 0055dd1142 perf: dynamically import sentry in Next.js api routes' wrapper (#23710)
* dynamically import sentry in nextjs api routes wrapper

* refactor

* same for pages router

* fixes

* address feedback

* refactor

* address feedback
2025-09-30 02:13:50 +05:30
Volnei Munhoz 61c44780f0 chore: Optional skip warnings for lint-staged (#24102)
* add optional skip warnings

* remove test changes

* allow underscore variables pattern being ignored by eslint (#24103)
2025-09-29 19:51:18 +00:00
Calcom BotandLingo.dev 9710d3b15c feat: update translations via @LingoDotDev (#23991)
Co-authored-by: Lingo.dev <support@lingo.dev>
2025-09-29 19:23:59 +00:00
sean-brydon 04cefebd75 add permissions.canUpdateOrganization to show/hide items in org navbar (#24142) 2025-09-29 15:11:39 +00:00
Amit Sharma dbd927c002 fix: intercom zindex (#24096) 2025-09-29 11:43:11 -03:00
e6b2116a2b feat: Calendar Cache and Sync (#23876)
* feat: calendar cache and sync - wip

* Add env.example

* refactor on CalendarCacheEventService

* remove test console.log

* Fix type checks errors

* chore: remove pt comment

* add route.ts

* chore: fix tests

* Improve cache impl

* chore: update recurring event id

* chore: small improvements

* calendar cache improvements

* Fix remove dynamic imports

* Add cleanup stale cache

* Fix tests

* add event update

* type fixes

* feat: add comprehensive tests for new calendar subscription API routes

- Add tests for /api/cron/calendar-subscriptions-cleanup route (9 tests)
- Add tests for /api/cron/calendar-subscriptions route (10 tests)
- Add tests for /api/webhooks/calendar-subscription/[provider] route (11 tests)
- Add missing feature flags for calendar-subscription-cache and calendar-subscription-sync
- All 30 tests pass with comprehensive coverage of authentication, feature flags, error handling, and service instantiation

Tests cover:
- Authentication scenarios (API key validation, Bearer tokens, query parameters)
- Feature flag combinations (cache/sync enabled/disabled states)
- Success and error handling (including non-Error exceptions)
- Service instantiation with proper dependency injection
- Provider validation for webhook endpoints

Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com>

* feat: add comprehensive tests for calendar subscription services, repositories, and adapters

- Add unit tests for CalendarSubscriptionService with subscription, webhook, and event processing
- Add unit tests for CalendarCacheEventService with cache operations and cleanup
- Add unit tests for CalendarSyncService with Cal.com event filtering and booking operations
- Add unit tests for CalendarCacheEventRepository with CRUD operations
- Add unit tests for SelectedCalendarRepository with calendar selection management
- Add unit tests for GoogleCalendarSubscriptionAdapter with subscription and event fetching
- Add unit tests for Office365CalendarSubscriptionAdapter with placeholder implementation
- Add unit tests for AdaptersFactory with provider management and adapter creation
- Fix lint issues by removing explicit 'any' type casting and unused variables
- All tests follow Cal.com conventions using Vitest framework with proper mocking

Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com>

* fix: improve calendar-subscriptions-cleanup test performance by adding missing mocks

- Add comprehensive mocks for defaultResponderForAppDir, logger, performance monitoring, and Sentry
- Fix slow test execution (933ms -> <100ms) caused by missing dependency mocks
- Ensure consistent test performance across different environments

Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com>

* Fix tests

* Fix tests

* type fix

* Fix coderabbit comments

* Fix types

* Fix test

* Update apps/web/app/api/cron/calendar-subscriptions/route.ts

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

* Fixes by first review

* feat: add database migrations for calendar cache and sync fields

- Add CalendarCacheEventStatus enum with confirmed, tentative, cancelled values
- Add new fields to SelectedCalendar: channelId, channelKind, channelResourceId, channelResourceUri, channelExpiration, syncSubscribedAt, syncToken, syncedAt, syncErrorAt, syncErrorCount
- Create CalendarCacheEvent table with foreign key to SelectedCalendar
- Add necessary indexes and constraints for performance and data integrity

Fixes database schema issues causing e2e test failures with 'column does not exist' errors.

Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com>

* only google-calendar for now

* docs: add Calendar Cache and Sync feature documentation

- Add comprehensive feature overview and motivation
- Document feature flags with SQL examples
- Include SQL examples for enabling features for users and teams
- Reference technical documentation files

Addresses PR #23876 documentation requirements

Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com>

* docs: update calendar subscription README with comprehensive documentation

- Undo incorrect changes to main README.md
- Update packages/features/calendar-subscription/README.md with:
  - Feature overview and motivation
  - Environment variables section
  - Complete feature flags documentation with SQL examples
  - SQL examples for enabling features for users and teams
  - Detailed architecture documentation

Addresses PR #23876 documentation requirements

Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com>

* fix docs

* Fix test to available calendars

* Fix test to available calendars

* add migration and sync boilerplate

* fix typo

* remove double log

* sync boilerplate

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2025-09-29 14:26:14 +00:00
Udit Takkar 855a02091f chore: update tooltip text (#24143) 2025-09-29 11:20:28 -03:00
Volnei MunhozandDevin AI 358434429e feat: add booking flags for skipAvailabilityCheck, skipEventLimitsCheck, skipCalendarSyncTaskCreation (#24122)
- Add three optional boolean flags to BookingHandlerInput interface
- Implement conditional logic to skip availability checks when skipAvailabilityCheck is true
- Implement conditional logic to skip event limits checks when skipEventLimitsCheck is true
- Implement conditional logic to skip calendar sync when skipCalendarSyncTaskCreation is true
- Add comprehensive tests for all three flags and combined scenarios
- Maintain backward compatibility with default false values
- Fix linting issues (unused variables and expressions)

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-09-29 14:02:31 +00:00
sean-brydonandhbjORbj b00061d85e chore: Add addMembersToTeams logic from 24099 (#24140)
* Add addMembersToTeams logic from 24099

* remove import

---------

Co-authored-by: hbjORbj <sldisek783@gmail.com>
2025-09-29 12:54:35 +00:00
Udit Takkarandsean-brydon 022f08c05e feat: add 10 minute cooldown in instant meetings (#24119)
* feat: add 10 minute

* feat: add 10 minute

* chore: change name

* chore: change name

---------

Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
2025-09-29 16:03:03 +04:00
emrysal 6ff5c8ce5f chore: release v5.7.6
Post release / release (push) Canceled after 0s
2025-09-29 11:22:43 +00:00
Lauris Skraucis 3be7fb9dd8 fix: v2 create org user with time zone (#24133)
* fix: tzdata undefined import

* refactor: capitalize org user timezone

* test: org user creation with time zone
2025-09-29 11:12:18 +00:00
Benny Joo 72dd970b6e refactor: use permission check service in /organizations endpoints (#24099)
* getUserConnectedApps

* create teams

* use permission check in team deletion

* team read

* revert

* publish handler

* wip
2025-09-29 11:07:19 +00:00
sean-brydon f4d09ece43 chore: (PBAC) readonly eventTypes (#24131)
* Chore(PBAC): readonly eventTypes

* Set readonly value
2025-09-29 11:06:55 +00:00
sean-brydonandEunjae Lee 239e6085ba feat: pbac - private teams (#23998)
* Add permission + migration for listMembersPrivate + depends on lsitMember

* Add legacy list members PBAC private logic

* Add checks in team pages + handler

* add list memeber checks for private orgs

* Add permision visibility scope to loading permissions

* getMembers handler

* fix fallback permission

* Verify private teams work with private org

* Apply suggestion from @eunjae-lee

Co-authored-by: Eunjae Lee <hey@eunjae.dev>

* Remove and private scope function and add param to old

---------

Co-authored-by: Eunjae Lee <hey@eunjae.dev>
2025-09-29 11:30:54 +01:00
sean-brydon b17eab3aeb chore: Use enums instead of hardcoded strings (#24132)
* Use enums instead of hardcoded strings

* use membership role
2025-09-29 10:57:36 +01:00
752f7fcde0 refactor: replace checkAdminOrOwner with team.create permission in teams page (#24116)
* refactor: replace checkAdminOrOwner with team.create permission in teams page

- Replace role-based access control with PBAC using team.create permission
- Follow PBAC refactoring guide pattern for server-side permission checking
- Use permission-specific variable naming (canCreateTeam)
- Maintain existing TeamsListing component interface

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

* refactor: implement permissions object pattern for TeamsListing

- Replace isOrgAdmin prop with permissions object containing canCreateTeam
- Follow PBAC refactoring guide pattern for UI components
- Update TeamsListing component to use permissions.canCreateTeam
- Maintain existing functionality while improving code structure
- All type checking and linting verification passed

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

* use different method

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
2025-09-29 14:11:42 +05:30
f6b061f787 fix: update handler to not delete banner (#24106)
* Fix update handler to not delete banner

* code cleanup

* add banner-upload.e2e

* fix: add slug fallback in banner-upload e2e test

- Use org.slug as fallback when requestedSlug is undefined
- Addresses GitHub comment about guarding against undefined slug
- Prevents test failures when requestedSlug is not set
- Fix ESLint warnings: remove unnecessary escape characters and unused variable

Co-Authored-By: sean@cal.com <Sean@brydon.io>

* test: add banner preservation test for profile updates

- Verify that updating organization profile without banner changes preserves existing banner
- Ensures update handler doesn't overwrite banner when undefined is passed
- Addresses user request to test banner preservation during profile saves

Co-Authored-By: sean@cal.com <Sean@brydon.io>

* remove banner e2e wip

---------

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-09-29 09:31:49 +01:00
Eunjae LeeandDevin AI cc8a1290ee docs: add comprehensive DataTable usage guide (#24107)
* docs: add comprehensive DataTable usage guide

- Document DataTableProvider, DataTableWrapper, and DataTable components
- Cover filter system with all filter types and operators
- Explain segment system (system vs user segments)
- Document pagination modes (infinite vs standard)
- Include real-world usage examples from UserListTable, BookingsListingView, MemberList
- Add TypeScript types reference and best practices

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

* docs: update DataTable guide to recommend standard pagination

- De-emphasize virtualized rendering and infinite mode
- Position standard pagination as the recommended approach
- Document known issues with infinite loading mode
- Reorder pagination section to prioritize standard mode
- Update best practices to recommend standard pagination

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

* docs: add missing DataTable guide topics

- Document getFacetedUniqueValues for faceted filters with real examples
- Add useSegments prop requirement for filter segment enablement
- Enhance server-side filtering documentation with comprehensive patterns
- Include examples from useFacetedUniqueValues and useInsightsRoutingParameters

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

* docs: add missing DataTable guide topics

- Document getFacetedUniqueValues for faceted filters with real examples
- Add useSegments prop requirement for filter segment enablement
- Enhance server-side filtering documentation with comprehensive patterns
- Include examples from useFacetedUniqueValues and useInsightsRoutingParameters
- Correct server-side vs client-side filtering rationale

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

* docs: enhance DataTable guide with troubleshooting and testing

- Remove duplicate Key Hooks section (lines 783-803)
- Add visual ASCII diagrams for architecture and component hierarchy
- Add comprehensive troubleshooting section with 7 common issues and solutions
- Add testing patterns section with Vitest examples and best practices
- Update table of contents to include new sections
- Maintain existing high-quality documentation standards

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

* remove some sections

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-09-29 07:59:55 +00:00
Eunjae LeeandDevin AI b7cf893169 refactor: replace checkAdminOrOwner with PBAC availability.read permission (#24115)
- Replace role-based check with PermissionCheckService
- Use availability.read permission with OWNER/ADMIN fallback roles
- Maintain existing privacy logic for organizations
- Follow established PBAC pattern from bookings implementation

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-09-29 07:59:23 +00:00
Lauris Skraucis 1458cacdbb fix: v2 api event type splitName booking field (#24111)
* fix: v2 api event type splitName booking field

* fix: test
2025-09-29 09:19:24 +02:00
1036 changed files with 35072 additions and 8596 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@calcom/atoms": minor
---
This PR adds ability to display bookings of a user event for calendar view atom
+13
View File
@@ -144,6 +144,12 @@ GOOGLE_WEBHOOK_TOKEN=
# Optional URL to override for tunelling webhooks. Defaults to NEXT_PUBLIC_WEBAPP_URL.
GOOGLE_WEBHOOK_URL=
# Token to verify incoming webhooks from Microsoft Calendar
MICROSOFT_WEBHOOK_TOKEN=
# Optional URL to override for tunelling webhooks. Defaults to NEXT_PUBLIC_WEBAPP_URL.
MICROSOFT_WEBHOOK_URL=
# Inbox to send user feedback
SEND_FEEDBACK_EMAIL=
@@ -261,6 +267,9 @@ NEXT_PUBLIC_CLOUDFLARE_SITEKEY=
NEXT_PUBLIC_CLOUDFLARE_USE_TURNSTILE_IN_BOOKER=
CLOUDFLARE_TURNSTILE_SECRET=
# 0 = false, 1=true
NEXT_PUBLIC_VERCEL_USE_BOTID_IN_BOOKER=
# Set the following value to true if you wish to enable Team Impersonation
NEXT_PUBLIC_TEAM_IMPERSONATION=false
@@ -473,3 +482,7 @@ DATABASE_CHUNK_SIZE=
# Service Account Encryption Key for encrypting/decrypting service account keys
# You can use: `openssl rand -base64 24` to generate one
CALCOM_SERVICE_ACCOUNT_ENCRYPTION_KEY=
SALESFORCE_GRAPHQL_DELAY_MS=500
SALESFORCE_GRAPHQL_MAX_DELAY_MS=2000
SALESFORCE_GRAPHQL_MAX_RETRIES=3
+1
View File
@@ -23,6 +23,7 @@
/packages/app-store/office365calendar/**/* @calcom/Foundation
/packages/app-store/zohocalendar/**/* @calcom/Foundation
/packages/embeds/**/* @calcom/Foundation
/packages/features/auth/lib/next-auth-options.ts @calcom/Foundation
/packages/features/bookings/lib/**/* @calcom/Foundation
/packages/lib/getAggregatedAvailability.ts @calcom/Foundation
/packages/lib/getUserAvailability.ts @calcom/Foundation
+1 -1
View File
@@ -82,7 +82,7 @@ jobs:
with:
base: https://raw.githubusercontent.com/calcom/cal.com/refs/heads/main/docs/api-reference/v2/openapi.json
revision: docs/api-reference/v2/openapi.json
fail-on: WARN
fail-on: ERR
- name: Run Tests
working-directory: apps/api/v2
View File
View File
View File
View File
View File
View File
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@calcom/prisma"
+2
View File
@@ -0,0 +1,2 @@
undecided:
- calcom-monorepo
View File
@@ -4,7 +4,7 @@ import type { NextApiResponse, NextApiRequest } from "next";
import { createMocks } from "node-mocks-http";
import { describe, it, expect, vi } from "vitest";
import { handleAutoLock } from "@calcom/lib/autoLock";
import { handleAutoLock } from "@calcom/features/ee/api-keys/lib/autoLock";
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import { HttpError } from "@calcom/lib/http-error";
@@ -19,7 +19,7 @@ vi.mock("@calcom/lib/checkRateLimitAndThrowError", () => ({
checkRateLimitAndThrowError: vi.fn(),
}));
vi.mock("@calcom/lib/autoLock", () => ({
vi.mock("@calcom/features/ee/api-keys/lib/autoLock", () => ({
handleAutoLock: vi.fn(),
}));
+1 -1
View File
@@ -1,6 +1,6 @@
import type { NextMiddleware } from "next-api-middleware";
import { handleAutoLock } from "@calcom/lib/autoLock";
import { handleAutoLock } from "@calcom/features/ee/api-keys/lib/autoLock";
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import { HttpError } from "@calcom/lib/http-error";
+89 -63
View File
@@ -1,19 +1,42 @@
import prismock from "../../../../../tests/libs/__mocks__/prisma";
/**
* Unit Tests for verifyApiKey middleware
*
* These tests verify the middleware logic without touching the database.
* All dependencies (repositories, utilities) are mocked.
*/
import type { Request, Response } from "express";
import type { NextApiRequest, NextApiResponse } from "next";
import { createMocks } from "node-mocks-http";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ILicenseKeyService } from "@calcom/ee/common/server/LicenseKeyService";
import LicenseKeyService from "@calcom/ee/common/server/LicenseKeyService";
import { hashAPIKey } from "@calcom/features/ee/api-keys/lib/apiKeys";
import LicenseKeyService, { LicenseKeySingleton } from "@calcom/ee/common/server/LicenseKeyService";
import { PrismaApiKeyRepository } from "@calcom/lib/server/repository/PrismaApiKeyRepository";
import type { IDeploymentRepository } from "@calcom/lib/server/repository/deployment.interface";
import prisma from "@calcom/prisma";
import { MembershipRole, UserPermissionRole } from "@calcom/prisma/enums";
import { ApiKeyService } from "@calcom/lib/server/service/ApiKeyService";
import { UserPermissionRole } from "@calcom/prisma/enums";
import { isAdminGuard } from "../utils/isAdmin";
import { isLockedOrBlocked } from "../utils/isLockedOrBlocked";
import { ScopeOfAdmin } from "../utils/scopeOfAdmin";
import { verifyApiKey } from "./verifyApiKey";
vi.mock("@calcom/lib/server/service/ApiKeyService", () => ({
ApiKeyService: vi.fn(),
}));
vi.mock("@calcom/lib/server/repository/PrismaApiKeyRepository", () => ({
PrismaApiKeyRepository: vi.fn(),
}));
vi.mock("../utils/isAdmin", () => ({
isAdminGuard: vi.fn(),
}));
vi.mock("../utils/isLockedOrBlocked", () => ({
isLockedOrBlocked: vi.fn(),
}));
vi.mock("@calcom/lib/crypto", () => ({
symmetricDecrypt: vi.fn().mockReturnValue("mocked-decrypted-value"),
symmetricEncrypt: vi.fn().mockReturnValue("mocked-encrypted-value"),
@@ -32,17 +55,29 @@ afterEach(() => {
});
const mockDeploymentRepository: IDeploymentRepository = {
getLicenseKeyWithId: vi.fn().mockResolvedValue("mockLicenseKey"), // Mocked return value
getLicenseKeyWithId: vi.fn().mockResolvedValue("mockLicenseKey"),
getSignatureToken: vi.fn().mockResolvedValue("mockSignatureToken"),
};
describe("Verify API key", () => {
describe("Verify API key - Unit Tests", () => {
let service: ILicenseKeyService;
let mockApiKeyService: ApiKeyService;
beforeEach(async () => {
service = await LicenseKeyService.create(mockDeploymentRepository);
vi.spyOn(service, "checkLicense");
vi.spyOn(LicenseKeySingleton, "getInstance").mockResolvedValue(service as LicenseKeyService);
mockApiKeyService = {
verifyKeyByHashedKey: vi.fn(),
} as unknown as ApiKeyService;
vi.mocked(ApiKeyService).mockImplementation(() => mockApiKeyService);
vi.mocked(PrismaApiKeyRepository).mockImplementation(() => ({} as unknown as PrismaApiKeyRepository));
vi.mocked(isAdminGuard).mockReset();
vi.mocked(isLockedOrBlocked).mockReset();
});
it("should throw an error if the api key is not valid", async () => {
@@ -98,22 +133,25 @@ describe("Verify API key", () => {
query: {
apiKey: "cal_test_key",
},
prisma,
});
const hashedKey = hashAPIKey("test_key");
await prismock.apiKey.create({
data: {
hashedKey,
user: {
create: {
email: "admin@example.com",
role: UserPermissionRole.ADMIN,
locked: false,
},
},
vi.mocked(mockApiKeyService.verifyKeyByHashedKey).mockResolvedValue({
valid: true,
userId: 1,
user: {
role: UserPermissionRole.ADMIN,
locked: false,
email: "admin@example.com",
},
});
vi.mocked(isAdminGuard).mockResolvedValue({
isAdmin: true,
scope: ScopeOfAdmin.SystemWide,
});
vi.mocked(isLockedOrBlocked).mockResolvedValue(false);
const middleware = {
fn: verifyApiKey,
};
@@ -139,40 +177,25 @@ describe("Verify API key", () => {
query: {
apiKey: "cal_test_key",
},
prisma,
});
const hashedKey = hashAPIKey("test_key");
await prismock.apiKey.create({
data: {
hashedKey,
user: {
create: {
email: "org-admin@acme.com",
role: UserPermissionRole.USER,
locked: false,
teams: {
create: {
accepted: true,
role: MembershipRole.OWNER,
team: {
create: {
name: "ACME",
isOrganization: true,
organizationSettings: {
create: {
isAdminAPIEnabled: true,
orgAutoAcceptEmail: "acme.com",
},
},
},
},
},
},
},
},
vi.mocked(mockApiKeyService.verifyKeyByHashedKey).mockResolvedValue({
valid: true,
userId: 2,
user: {
role: UserPermissionRole.USER,
locked: false,
email: "org-admin@acme.com",
},
});
vi.mocked(isAdminGuard).mockResolvedValue({
isAdmin: true,
scope: ScopeOfAdmin.OrgOwnerOrAdmin,
});
vi.mocked(isLockedOrBlocked).mockResolvedValue(false);
const middleware = {
fn: verifyApiKey,
};
@@ -198,22 +221,25 @@ describe("Verify API key", () => {
query: {
apiKey: "cal_test_key",
},
prisma,
});
const hashedKey = hashAPIKey("test_key");
await prismock.apiKey.create({
data: {
hashedKey,
user: {
create: {
email: "locked@example.com",
role: UserPermissionRole.USER,
locked: true,
},
},
vi.mocked(mockApiKeyService.verifyKeyByHashedKey).mockResolvedValue({
valid: true,
userId: 3,
user: {
role: UserPermissionRole.USER,
locked: true,
email: "locked@example.com",
},
});
vi.mocked(isAdminGuard).mockResolvedValue({
isAdmin: false,
scope: ScopeOfAdmin.SystemWide,
});
vi.mocked(isLockedOrBlocked).mockResolvedValue(true);
const middleware = {
fn: verifyApiKey,
};
+13 -24
View File
@@ -3,21 +3,15 @@ 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 { PrismaApiKeyRepository } from "@calcom/lib/server/repository/PrismaApiKeyRepository";
import { DeploymentRepository } from "@calcom/lib/server/repository/deployment";
import prisma from "@calcom/prisma";
import { ApiKeyService } from "@calcom/lib/server/service/ApiKeyService";
import { prisma } from "@calcom/prisma";
import { isAdminGuard } from "../utils/isAdmin";
import { isLockedOrBlocked } from "../utils/isLockedOrBlocked";
import { ScopeOfAdmin } from "../utils/scopeOfAdmin";
// Used to check if the apiKey is not expired, could be extracted if reused. but not for now.
export const dateNotInPast = function (date: Date) {
const now = new Date();
if (now.setHours(0, 0, 0, 0) > date.setHours(0, 0, 0, 0)) {
return true;
}
};
// This verifies the apiKey and sets the user if it is valid.
export const verifyApiKey: NextMiddleware = async (req, res, next) => {
const deploymentRepo = new DeploymentRepository(prisma);
@@ -32,24 +26,19 @@ export const verifyApiKey: NextMiddleware = async (req, res, next) => {
const strippedApiKey = `${req.query.apiKey}`.replace(process.env.API_KEY_PREFIX || "cal_", "");
const hashedKey = hashAPIKey(strippedApiKey);
const apiKey = await prisma.apiKey.findUnique({
where: { hashedKey },
include: {
user: {
select: { role: true, locked: true, email: true },
},
},
});
if (!apiKey) return res.status(401).json({ error: "Your API key is not valid." });
if (apiKey.expiresAt && dateNotInPast(apiKey.expiresAt)) {
return res.status(401).json({ error: "This API key is expired." });
// Use service layer for API key verification
const apiKeyRepo = new PrismaApiKeyRepository(prisma);
const apiKeyService = new ApiKeyService({ apiKeyRepo });
const result = await apiKeyService.verifyKeyByHashedKey(hashedKey);
if (!result.valid) {
return res.status(401).json({ error: result.error });
}
if (!apiKey.userId || !apiKey.user)
return res.status(404).json({ error: "No user found for this API key." });
// save the user id in the request for later use
req.userId = apiKey.userId;
req.user = apiKey.user;
req.userId = result.userId!;
req.user = result.user!;
const { isAdmin, scope } = await isAdminGuard(req);
const userIsLockedOrBlocked = await isLockedOrBlocked(req);
+9 -1
View File
@@ -1,9 +1,17 @@
import type { NextApiRequest } from "next";
import { sentrySpan } from "@calcom/features/watchlist/lib/telemetry";
import { checkIfEmailIsBlockedInWatchlistController } from "@calcom/features/watchlist/operations/check-if-email-in-watchlist.controller";
export async function isLockedOrBlocked(req: NextApiRequest) {
const user = req.user;
if (!user?.email) return false;
return user.locked || (await checkIfEmailIsBlockedInWatchlistController(user.email));
return (
user.locked ||
(await checkIfEmailIsBlockedInWatchlistController({
email: user.email,
organizationId: null,
span: sentrySpan,
}))
);
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { z } from "zod";
import { checkUsername } from "@calcom/features/profile/lib/checkUsername";
import { emailSchema } from "@calcom/lib/emailSchema";
import { checkUsername } from "@calcom/lib/server/checkUsername";
import { iso8601 } from "@calcom/prisma/zod-utils";
import { UserSchema } from "@calcom/prisma/zod/modelSchema/UserSchema";
+1 -1
View File
@@ -1,7 +1,7 @@
import type { NextApiRequest } from "next";
import { z } from "zod";
import { getUserAvailabilityService } from "@calcom/lib/di/containers/GetUserAvailability";
import { getUserAvailabilityService } from "@calcom/features/di/containers/GetUserAvailability";
import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
import prisma from "@calcom/prisma";
@@ -3,7 +3,7 @@ import type { NextApiRequest } from "next";
import {
getRecordingsOfCalVideoByRoomName,
getDownloadLinkOfCalVideoByRecordingId,
} from "@calcom/app-store/videoClient";
} from "@calcom/features/conferencing/lib/videoClient";
import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
import prisma from "@calcom/prisma";
@@ -3,7 +3,7 @@ import type { NextApiRequest } from "next";
import {
getTranscriptsAccessLinkFromRecordingId,
checkIfRoomNameMatchesInRecording,
} from "@calcom/app-store/videoClient";
} from "@calcom/features/conferencing/lib/videoClient";
import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
import prisma from "@calcom/prisma";
@@ -1,6 +1,6 @@
import type { NextApiRequest } from "next";
import { getAllTranscriptsAccessLinkFromRoomName } from "@calcom/app-store/videoClient";
import { getAllTranscriptsAccessLinkFromRoomName } from "@calcom/features/conferencing/lib/videoClient";
import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
import prisma from "@calcom/prisma";
+8 -6
View File
@@ -1,7 +1,7 @@
import type { NextApiRequest } from "next";
import { getRegularBookingService } from "@calcom/features/bookings/di/RegularBookingService.container";
import getBookingDataSchemaForApi from "@calcom/features/bookings/lib/getBookingDataSchemaForApi";
import handleNewBooking from "@calcom/features/bookings/lib/handleNewBooking";
import { ErrorCode } from "@calcom/lib/errorCodes";
import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
@@ -239,15 +239,17 @@ async function handler(req: NextApiRequest) {
}
try {
return await handleNewBooking(
{
bookingData: req.body,
const regularBookingService = getRegularBookingService();
return await regularBookingService.createBookingForApiV1({
bookingData: req.body,
bookingMeta: {
userId,
hostname: req.headers.host || "",
forcedSlug: req.headers["x-cal-force-slug"] as string | undefined,
},
getBookingDataSchemaForApi
);
bookingDataSchemaGetter: getBookingDataSchemaForApi,
});
} catch (error: unknown) {
const knownError = error as Error;
if (knownError?.message === ErrorCode.NoAvailableUsersFound) {
@@ -1,10 +1,10 @@
import type { NextApiRequest } from "next";
import type { UserWithCalendars } from "@calcom/lib/getConnectedDestinationCalendars";
import { getConnectedDestinationCalendarsAndEnsureDefaultsInDb } from "@calcom/lib/getConnectedDestinationCalendars";
import type { UserWithCalendars } from "@calcom/features/calendars/lib/getConnectedDestinationCalendars";
import { getConnectedDestinationCalendarsAndEnsureDefaultsInDb } from "@calcom/features/calendars/lib/getConnectedDestinationCalendars";
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
import { UserRepository } from "@calcom/lib/server/repository/user";
import prisma from "@calcom/prisma";
import { extractUserIdsFromQuery } from "~/lib/utils/extractUserIdsFromQuery";
+1 -1
View File
@@ -4,7 +4,7 @@ import type { NextApiRequest, NextApiResponse } from "next";
import dayjs from "@calcom/dayjs";
import { isSupportedTimeZone } from "@calcom/lib/dayjs";
import { getAvailableSlotsService } from "@calcom/lib/di/containers/AvailableSlots";
import { getAvailableSlotsService } from "@calcom/features/di/containers/AvailableSlots";
import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
import { createContext } from "@calcom/trpc/server/createContext";
+20 -19
View File
@@ -4,6 +4,7 @@ import { purchaseTeamOrOrgSubscription } from "@calcom/features/ee/teams/lib/pay
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
import { TeamRepository } from "@calcom/lib/server/repository/team";
import prisma from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
@@ -61,24 +62,24 @@ export async function patchHandler(req: NextApiRequest) {
const { teamId } = schemaQueryTeamId.parse(req.query);
/** Only OWNERS and ADMINS can edit teams */
const _team = await prisma.team.findFirst({
const team = await prisma.team.findFirst({
// eslint-disable-next-line @calcom/eslint/no-prisma-include-true
include: { members: true },
where: { id: teamId, members: { some: { userId, role: { in: ["OWNER", "ADMIN"] } } } },
});
if (!_team) throw new HttpError({ statusCode: 401, message: "Unauthorized: OWNER or ADMIN required" });
if (!team) throw new HttpError({ statusCode: 401, message: "Unauthorized: OWNER or ADMIN required" });
const slugAlreadyExists = await prisma.team.findFirst({
where: {
slug: {
mode: "insensitive",
equals: data.slug,
},
},
});
if (slugAlreadyExists && data.slug !== _team.slug)
throw new HttpError({ statusCode: 409, message: "Team slug already exists" });
if (data.slug) {
const teamRepository = new TeamRepository(prisma);
const isSlugAvailable = await teamRepository.isSlugAvailableForUpdate({
slug: data.slug,
teamId: team.id,
parentId: team.parentId,
});
if (!isSlugAvailable) {
throw new HttpError({ statusCode: 409, message: "Team slug already exists" });
}
}
// Check if parentId is related to this user
if (data.parentId && data.parentId === teamId) {
@@ -99,16 +100,16 @@ export async function patchHandler(req: NextApiRequest) {
}
let paymentUrl;
if (_team.slug === null && data.slug) {
if (team.slug === null && data.slug) {
data.metadata = {
...(_team.metadata as Prisma.JsonObject),
...(team.metadata as Prisma.JsonObject),
requestedSlug: data.slug,
};
delete data.slug;
if (IS_TEAM_BILLING_ENABLED) {
const checkoutSession = await purchaseTeamOrOrgSubscription({
teamId: _team.id,
seatsUsed: _team.members.length,
teamId: team.id,
seatsUsed: team.members.length,
userId,
pricePerSeat: null,
});
@@ -131,9 +132,9 @@ export async function patchHandler(req: NextApiRequest) {
bookingLimits: data.bookingLimits === null ? {} : data.bookingLimits,
metadata: data.metadata === null ? {} : data.metadata || undefined,
};
const team = await prisma.team.update({ where: { id: teamId }, data: cloneData });
const updatedTeam = await prisma.team.update({ where: { id: teamId }, data: cloneData });
const result = {
team: schemaTeamReadPublic.parse(team),
team: schemaTeamReadPublic.parse(updatedTeam),
paymentUrl,
};
if (!paymentUrl) {
@@ -1,6 +1,6 @@
import type { NextApiRequest } from "next";
import { deleteUser } from "@calcom/features/users/lib/userDeletionService";
import { deleteUser } from "@calcom/features/users/lib/deleteUser";
import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
import prisma from "@calcom/prisma";
+1 -1
View File
@@ -1,8 +1,8 @@
import type { NextApiRequest } from "next";
import { UserCreationService } from "@calcom/features/users/services/userCreationService";
import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
import { UserCreationService } from "@calcom/lib/server/service/userCreationService";
import { CreationSource } from "@calcom/prisma/enums";
import { schemaUserCreateBodyParams } from "~/lib/validations/user";
@@ -9,7 +9,7 @@ import { buildBooking } from "@calcom/lib/test/builder";
import {
getRecordingsOfCalVideoByRoomName,
getDownloadLinkOfCalVideoByRecordingId,
} from "@calcom/app-store/videoClient";
} from "@calcom/features/conferencing/lib/videoClient";
import { getAccessibleUsers } from "~/lib/utils/retrieveScopedAccessibleUsers";
@@ -22,7 +22,7 @@ type CustomNextApiResponse = NextApiResponse & Response;
const adminUserId = 1;
const memberUserId = 10;
vi.mock("@calcom/app-store/videoClient", () => {
vi.mock("@calcom/features/conferencing/lib/videoClient", () => {
return {
getRecordingsOfCalVideoByRoomName: vi.fn(),
getDownloadLinkOfCalVideoByRecordingId: vi.fn(),
@@ -8,7 +8,7 @@ import { describe, expect, test, vi, afterEach } from "vitest";
import {
getTranscriptsAccessLinkFromRecordingId,
checkIfRoomNameMatchesInRecording,
} from "@calcom/app-store/videoClient";
} from "@calcom/features/conferencing/lib/videoClient";
import { buildBooking } from "@calcom/lib/test/builder";
import { getAccessibleUsers } from "~/lib/utils/retrieveScopedAccessibleUsers";
@@ -19,7 +19,7 @@ import handler from "../../../../../../pages/api/bookings/[id]/transcripts/[reco
type CustomNextApiRequest = NextApiRequest & Request;
type CustomNextApiResponse = NextApiResponse & Response;
vi.mock("@calcom/app-store/videoClient", () => {
vi.mock("@calcom/features/conferencing/lib/videoClient", () => {
return {
getTranscriptsAccessLinkFromRecordingId: vi.fn(),
checkIfRoomNameMatchesInRecording: vi.fn(),
@@ -6,7 +6,7 @@ import { createMocks } from "node-mocks-http";
import { describe, expect, test, vi, afterEach } from "vitest";
import { buildBooking } from "@calcom/lib/test/builder";
import { getAllTranscriptsAccessLinkFromRoomName } from "@calcom/app-store/videoClient";
import { getAllTranscriptsAccessLinkFromRoomName } from "@calcom/features/conferencing/lib/videoClient";
import { getAccessibleUsers } from "~/lib/utils/retrieveScopedAccessibleUsers";
@@ -16,7 +16,7 @@ import handler from "../../../../../pages/api/bookings/[id]/transcripts/_get";
type CustomNextApiRequest = NextApiRequest & Request;
type CustomNextApiResponse = NextApiResponse & Response;
vi.mock("@calcom/app-store/videoClient", () => {
vi.mock("@calcom/features/conferencing/lib/videoClient", () => {
return {
getAllTranscriptsAccessLinkFromRoomName: vi.fn(),
};
+3 -3
View File
@@ -66,7 +66,7 @@ vi.mock("@calcom/features/webhooks/lib/sendOrSchedulePayload", () => ({
}));
const mockFindOriginalRescheduledBooking = vi.fn();
vi.mock("@calcom/lib/server/repository/booking", () => ({
vi.mock("@calcom/features/bookings/repositories/BookingRepository", () => ({
BookingRepository: vi.fn().mockImplementation(() => ({
findOriginalRescheduledBooking: mockFindOriginalRescheduledBooking,
})),
@@ -76,7 +76,7 @@ vi.mock("@calcom/features/watchlist/operations/check-if-users-are-blocked.contro
checkIfUsersAreBlocked: vi.fn().mockResolvedValue(false),
}));
vi.mock("@calcom/lib/di/containers/QualifiedHosts", () => ({
vi.mock("@calcom/features/di/containers/QualifiedHosts", () => ({
getQualifiedHostsService: vi.fn().mockReturnValue({
findQualifiedHostsWithDelegationCredentials: vi.fn().mockResolvedValue({
qualifiedRRHosts: [],
@@ -133,7 +133,7 @@ vi.mock("@calcom/features/bookings/lib/handleNewBooking/ensureAvailableUsers", (
}),
}));
vi.mock("@calcom/lib/server/repository/profile", () => ({
vi.mock("@calcom/features/profile/repositories/ProfileRepository", () => ({
ProfileRepository: {
findManyForUser: vi.fn().mockResolvedValue([]),
buildPersonalProfileFromUser: vi.fn().mockReturnValue({
@@ -6,6 +6,7 @@ import { createMocks } from "node-mocks-http";
import { describe, expect, test } from "vitest";
import { HttpError } from "@calcom/lib/http-error";
import type { User } from "@calcom/prisma/client";
import handler from "../../../pages/api/selected-calendars/_post";
@@ -72,7 +73,7 @@ describe("POST /api/selected-calendars", () => {
prismaMock.user.findFirstOrThrow.mockResolvedValue({
id: 444444,
} as any);
} as User);
prismaMock.selectedCalendar.create.mockResolvedValue({
credentialId: 1,
@@ -95,6 +96,16 @@ describe("POST /api/selected-calendars", () => {
unwatchAttempts: 0,
createdAt: new Date(),
updatedAt: new Date(),
channelId: null,
channelKind: null,
channelResourceId: null,
channelResourceUri: null,
channelExpiration: null,
syncSubscribedAt: null,
syncToken: null,
syncedAt: null,
syncErrorAt: null,
syncErrorCount: null,
});
await handler(req, res);
@@ -140,6 +151,16 @@ describe("POST /api/selected-calendars", () => {
unwatchAttempts: 0,
createdAt: new Date(),
updatedAt: new Date(),
channelId: null,
channelKind: null,
channelResourceId: null,
channelResourceUri: null,
channelExpiration: null,
syncSubscribedAt: null,
syncToken: null,
syncedAt: null,
syncErrorAt: null,
syncErrorCount: null,
});
await handler(req, res);
+60 -49
View File
@@ -1,9 +1,13 @@
import prismock from "../../../../../../tests/libs/__mocks__/prisma";
/**
* Unit Tests for POST /api/users
*
* These tests verify the API endpoint logic without touching the database.
* All dependencies (UserCreationService) are mocked.
*/
import type { Request, Response } from "express";
import type { NextApiRequest, NextApiResponse } from "next";
import { createMocks } from "node-mocks-http";
import { describe, test, expect, vi } from "vitest";
import { describe, test, expect, vi, beforeEach } from "vitest";
import handler from "../../../pages/api/users/_post";
@@ -18,9 +22,41 @@ vi.mock("@calcom/lib/server/i18n", () => {
};
});
vi.mock("@calcom/features/profile/lib/checkUsername", () => ({
checkUsername: vi.fn().mockResolvedValue({
available: true,
premium: false,
}),
}));
vi.mock("@calcom/features/watchlist/operations/check-if-email-in-watchlist.controller", () => ({
checkIfEmailIsBlockedInWatchlistController: vi.fn().mockResolvedValue(false),
}));
const mockCreate = vi.fn();
vi.mock("@calcom/features/users/repositories/UserRepository", () => ({
UserRepository: vi.fn().mockImplementation(() => ({
create: mockCreate,
})),
}));
vi.mock("@calcom/lib/auth/hashPassword", () => ({
hashPassword: vi.fn().mockResolvedValue("hashed-password"),
}));
vi.stubEnv("CALCOM_LICENSE_KEY", undefined);
describe("POST /api/users", () => {
describe("POST /api/users - Unit Tests", () => {
beforeEach(() => {
vi.clearAllMocks();
mockCreate.mockResolvedValue({
id: 1,
email: "test@example.com",
username: "test",
locked: false,
});
});
test("should throw 401 if not system-wide admin", async () => {
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "POST",
@@ -34,7 +70,9 @@ describe("POST /api/users", () => {
await handler(req, res);
expect(res.statusCode).toBe(401);
expect(mockCreate).not.toHaveBeenCalled();
});
test("should throw a 400 if no email is provided", async () => {
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "POST",
@@ -47,7 +85,9 @@ describe("POST /api/users", () => {
await handler(req, res);
expect(res.statusCode).toBe(400);
expect(mockCreate).not.toHaveBeenCalled();
});
test("should throw a 400 if no username is provided", async () => {
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "POST",
@@ -60,15 +100,24 @@ describe("POST /api/users", () => {
await handler(req, res);
expect(res.statusCode).toBe(400);
expect(mockCreate).not.toHaveBeenCalled();
});
test("should create user successfully", async () => {
mockCreate.mockResolvedValue({
id: 1,
email: "test@example.com",
username: "testuser123",
locked: false,
organizationId: null,
});
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "POST",
body: {
email: "test@example.com",
username: "test",
username: "testuser123",
},
prisma: prismock,
});
req.isSystemWideAdmin = true;
@@ -76,57 +125,19 @@ describe("POST /api/users", () => {
expect(res.statusCode).toBe(200);
const userQuery = await prismock.user.findFirst({
where: {
email: "test@example.com",
},
});
expect(userQuery).toEqual(
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
email: "test@example.com",
username: "test",
username: "testuser123",
locked: false,
organizationId: null,
})
);
});
test("should auto lock user if email is in watchlist", async () => {
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "POST",
body: {
email: "test@example.com",
username: "test",
},
prisma: prismock,
});
req.isSystemWideAdmin = true;
await prismock.watchlist.create({
data: {
type: "EMAIL",
value: "test@example.com",
severity: "CRITICAL",
createdById: 1,
},
});
await handler(req, res);
expect(res.statusCode).toBe(200);
const userQuery = await prismock.user.findFirst({
where: {
email: "test@example.com",
},
});
expect(userQuery).toEqual(
const responseData = JSON.parse(res._getData());
expect(responseData.user).toEqual(
expect.objectContaining({
email: "test@example.com",
username: "test",
locked: true,
username: "testuser123",
organizationId: null,
})
);
@@ -1,40 +1,52 @@
import prismock from "../../../../../../tests/libs/__mocks__/prisma";
import type { NextApiRequest } from "next";
import { describe, expect, it, beforeEach, vi, afterEach } from "vitest";
import { describe, expect, it, beforeEach } from "vitest";
import { WatchlistSeverity } from "@calcom/prisma/enums";
import { getWatchlistFeature } from "@calcom/features/di/watchlist/containers/watchlist";
import type { WatchlistFeature } from "@calcom/features/watchlist/lib/facade/WatchlistFeature";
import { isLockedOrBlocked } from "../../../lib/utils/isLockedOrBlocked";
vi.mock("@calcom/features/di/watchlist/containers/watchlist", () => ({
getWatchlistFeature: vi.fn(),
}));
describe("isLockedOrBlocked", () => {
beforeEach(async () => {
await prismock.watchlist.createMany({
data: [
{
type: "DOMAIN",
value: "spam.com",
createdById: 1,
},
{
type: "DOMAIN",
value: "blocked.com",
severity: WatchlistSeverity.CRITICAL,
createdById: 1,
},
],
});
let mockWatchlistFeature: WatchlistFeature;
beforeEach(() => {
const mockGlobalBlocking = {
isBlocked: vi.fn(),
};
const mockOrgBlocking = {
isBlocked: vi.fn(),
};
mockWatchlistFeature = {
globalBlocking: mockGlobalBlocking,
orgBlocking: mockOrgBlocking,
} as unknown as WatchlistFeature;
vi.mocked(getWatchlistFeature).mockResolvedValue(mockWatchlistFeature);
});
afterEach(() => {
vi.resetAllMocks();
});
it("should return false if no user in request", async () => {
const req = { userId: null, user: null } as any;
const req = { userId: null, user: null } as unknown as NextApiRequest;
const result = await isLockedOrBlocked(req);
expect(result).toBe(false);
expect(vi.mocked(mockWatchlistFeature.globalBlocking.isBlocked)).not.toHaveBeenCalled();
});
it("should return false if user has no email", async () => {
const req = { userId: 123, user: { email: null } } as any;
const req = { userId: 123, user: { email: null } } as unknown as NextApiRequest;
const result = await isLockedOrBlocked(req);
expect(result).toBe(false);
expect(vi.mocked(mockWatchlistFeature.globalBlocking.isBlocked)).not.toHaveBeenCalled();
});
it("should return true if user is locked", async () => {
@@ -44,10 +56,12 @@ describe("isLockedOrBlocked", () => {
locked: true,
email: "test@example.com",
},
} as any;
} as unknown as NextApiRequest;
const result = await isLockedOrBlocked(req);
expect(result).toBe(true);
expect(vi.mocked(mockWatchlistFeature.globalBlocking.isBlocked)).not.toHaveBeenCalled();
});
it("should return true if user email domain is watchlisted", async () => {
@@ -57,10 +71,14 @@ describe("isLockedOrBlocked", () => {
locked: false,
email: "test@blocked.com",
},
} as any;
} as unknown as NextApiRequest;
vi.mocked(mockWatchlistFeature.globalBlocking.isBlocked).mockResolvedValue({ isBlocked: true });
const result = await isLockedOrBlocked(req);
expect(result).toBe(true);
expect(vi.mocked(mockWatchlistFeature.globalBlocking.isBlocked)).toHaveBeenCalledWith("test@blocked.com");
});
it("should return false if user is not locked and email domain is not watchlisted", async () => {
@@ -70,10 +88,14 @@ describe("isLockedOrBlocked", () => {
locked: false,
email: "test@example.com",
},
} as any;
} as unknown as NextApiRequest;
vi.mocked(mockWatchlistFeature.globalBlocking.isBlocked).mockResolvedValue({ isBlocked: false });
const result = await isLockedOrBlocked(req);
expect(result).toBe(false);
expect(vi.mocked(mockWatchlistFeature.globalBlocking.isBlocked)).toHaveBeenCalledWith("test@example.com");
});
it("should handle email domains case-insensitively", async () => {
@@ -83,9 +105,13 @@ describe("isLockedOrBlocked", () => {
locked: false,
email: "test@BLOCKED.COM",
},
} as any;
} as unknown as NextApiRequest;
vi.mocked(mockWatchlistFeature.globalBlocking.isBlocked).mockResolvedValue({ isBlocked: true });
const result = await isLockedOrBlocked(req);
expect(result).toBe(true);
expect(vi.mocked(mockWatchlistFeature.globalBlocking.isBlocked)).toHaveBeenCalledWith("test@blocked.com");
});
});
@@ -6,6 +6,9 @@ import { CalendarsService } from "@/ee/calendars/services/calendars.service";
import { EventTypesModule_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/event-types.module";
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
import { InstantBookingModule } from "@/lib/modules/instant-booking.module";
import { RecurringBookingModule } from "@/lib/modules/recurring-booking.module";
import { RegularBookingModule } from "@/lib/modules/regular-booking.module";
import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository";
import { AppsRepository } from "@/modules/apps/apps.repository";
import { BillingModule } from "@/modules/billing/billing.module";
@@ -35,6 +38,9 @@ import { Module } from "@nestjs/common";
SchedulesModule_2024_04_15,
EventTypesModule_2024_06_14,
ProfilesModule,
RegularBookingModule,
RecurringBookingModule,
InstantBookingModule,
],
providers: [
TokensRepository,
@@ -21,7 +21,7 @@ import { randomString } from "test/utils/randomString";
import { withApiAuth } from "test/utils/withApiAuth";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { handleNewBooking } from "@calcom/platform-libraries";
import { type RegularBookingCreateResult } from "@calcom/platform-libraries/bookings";
import type { ApiSuccessResponse, ApiErrorResponse } from "@calcom/platform-types";
import type { User } from "@calcom/prisma/client";
@@ -41,7 +41,7 @@ describe("Bookings Endpoints 2024-04-15", () => {
let eventTypeId: number;
let createdBooking: Awaited<ReturnType<typeof handleNewBooking>>;
let createdBooking: RegularBookingCreateResult;
beforeAll(async () => {
const moduleRef = await withApiAuth(
@@ -131,8 +131,7 @@ describe("Bookings Endpoints 2024-04-15", () => {
.send(body)
.expect(201)
.then(async (response) => {
const responseBody: ApiSuccessResponse<Awaited<ReturnType<typeof handleNewBooking>>> =
response.body;
const responseBody: ApiSuccessResponse<RegularBookingCreateResult> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.userPrimaryEmail).toBeDefined();
@@ -231,8 +230,7 @@ describe("Bookings Endpoints 2024-04-15", () => {
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.expect(201)
.then(async (response) => {
const responseBody: ApiSuccessResponse<Awaited<ReturnType<typeof handleNewBooking>>> =
response.body;
const responseBody: ApiSuccessResponse<RegularBookingCreateResult> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.userPrimaryEmail).toBeDefined();
@@ -289,8 +287,7 @@ describe("Bookings Endpoints 2024-04-15", () => {
.send(body)
.expect(201)
.then(async (response) => {
const responseBody: ApiSuccessResponse<Awaited<ReturnType<typeof handleNewBooking>>> =
response.body;
const responseBody: ApiSuccessResponse<RegularBookingCreateResult> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.userPrimaryEmail).toBeDefined();
@@ -387,8 +384,7 @@ describe("Bookings Endpoints 2024-04-15", () => {
.send(body)
.expect(201)
.then(async (response) => {
const responseBody: ApiSuccessResponse<Awaited<ReturnType<typeof handleNewBooking>>> =
response.body;
const responseBody: ApiSuccessResponse<RegularBookingCreateResult> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.userPrimaryEmail).toBeDefined();
@@ -7,6 +7,9 @@ import { MarkNoShowOutput_2024_04_15 } from "@/ee/bookings/2024-04-15/outputs/ma
import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.service";
import { sha256Hash, isApiKey, stripApiKey } from "@/lib/api-key";
import { VERSION_2024_04_15, VERSION_2024_06_11, VERSION_2024_06_14 } from "@/lib/api-versions";
import { InstantBookingCreateService } from "@/lib/services/instant-booking-create.service";
import { RecurringBookingService } from "@/lib/services/recurring-booking.service";
import { RegularBookingService } from "@/lib/services/regular-booking.service";
import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
@@ -45,11 +48,8 @@ import { v4 as uuidv4 } from "uuid";
import { X_CAL_CLIENT_ID, X_CAL_PLATFORM_EMBED } from "@calcom/platform-constants";
import { BOOKING_READ, SUCCESS_STATUS, BOOKING_WRITE } from "@calcom/platform-constants";
import {
handleNewRecurringBooking,
handleNewBooking,
BookingResponse,
HttpError,
handleInstantMeeting,
handleMarkNoShow,
getAllUserBookings,
getBookingInfo,
@@ -58,6 +58,7 @@ import {
ErrorCode,
} from "@calcom/platform-libraries";
import { CreationSource } from "@calcom/platform-libraries";
import { type InstantBookingCreateResult } from "@calcom/platform-libraries/bookings";
import {
GetBookingsInput_2024_04_15,
CancelBookingInput_2024_04_15,
@@ -109,7 +110,10 @@ export class BookingsController_2024_04_15 {
private readonly apiKeyRepository: ApiKeysRepository,
private readonly platformBookingsService: PlatformBookingsService,
private readonly usersRepository: UsersRepository,
private readonly usersService: UsersService
private readonly usersService: UsersService,
private readonly regularBookingService: RegularBookingService,
private readonly recurringBookingService: RecurringBookingService,
private readonly instantBookingCreateService: InstantBookingCreateService
) {}
@Get("/")
@@ -187,17 +191,19 @@ export class BookingsController_2024_04_15 {
const { orgSlug, locationUrl } = body;
try {
const bookingRequest = await this.createNextApiBookingRequest(req, oAuthClientId, locationUrl, isEmbed);
const booking = await handleNewBooking({
const booking = await this.regularBookingService.createBooking({
bookingData: bookingRequest.body,
userId: bookingRequest.userId,
hostname: bookingRequest.headers?.host || "",
forcedSlug: orgSlug,
platformClientId: bookingRequest.platformClientId,
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
platformCancelUrl: bookingRequest.platformCancelUrl,
platformBookingUrl: bookingRequest.platformBookingUrl,
platformBookingLocation: bookingRequest.platformBookingLocation,
areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled,
bookingMeta: {
userId: bookingRequest.userId,
hostname: bookingRequest.headers?.host || "",
forcedSlug: orgSlug,
platformClientId: bookingRequest.platformClientId,
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
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, {
@@ -315,15 +321,17 @@ export class BookingsController_2024_04_15 {
const bookingRequest = await this.createNextApiBookingRequest(req, oAuthClientId, undefined, isEmbed);
const createdBookings: BookingResponse[] = await handleNewRecurringBooking({
const createdBookings: BookingResponse[] = await this.recurringBookingService.createBooking({
bookingData: bookingRequest.body,
userId: bookingRequest.userId,
hostname: bookingRequest.headers?.host || "",
platformClientId: bookingRequest.platformClientId,
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
platformCancelUrl: bookingRequest.platformCancelUrl,
platformBookingUrl: bookingRequest.platformBookingUrl,
platformBookingLocation: bookingRequest.platformBookingLocation,
bookingMeta: {
userId: bookingRequest.userId,
hostname: bookingRequest.headers?.host || "",
platformClientId: bookingRequest.platformClientId,
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
platformCancelUrl: bookingRequest.platformCancelUrl,
platformBookingUrl: bookingRequest.platformBookingUrl,
platformBookingLocation: bookingRequest.platformBookingLocation,
},
});
createdBookings.forEach(async (booking) => {
@@ -351,14 +359,15 @@ export class BookingsController_2024_04_15 {
@Body() body: CreateBookingInput_2024_04_15,
@Headers(X_CAL_CLIENT_ID) clientId?: string,
@Headers(X_CAL_PLATFORM_EMBED) isEmbed?: string
): Promise<ApiResponse<Awaited<ReturnType<typeof handleInstantMeeting>>>> {
): Promise<ApiResponse<InstantBookingCreateResult>> {
const oAuthClientId =
clientId?.toString() || (await this.getOAuthClientIdFromEventType(body.eventTypeId));
req.userId = (await this.getOwnerId(req)) ?? -1;
try {
const instantMeeting = await handleInstantMeeting(
await this.createNextApiBookingRequest(req, oAuthClientId, undefined, isEmbed)
);
const bookingReq = await this.createNextApiBookingRequest(req, oAuthClientId, undefined, isEmbed);
const instantMeeting = await this.instantBookingCreateService.createBooking({
bookingData: bookingReq.body,
});
if (instantMeeting.userId && instantMeeting.bookingUid) {
const now = new Date();
@@ -16,6 +16,9 @@ import { EventTypesModule_2024_04_15 } from "@/ee/event-types/event-types_2024_0
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
import { InstantBookingModule } from "@/lib/modules/instant-booking.module";
import { RecurringBookingModule } from "@/lib/modules/recurring-booking.module";
import { RegularBookingModule } from "@/lib/modules/regular-booking.module";
import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository";
import { AppsRepository } from "@/modules/apps/apps.repository";
import { BillingModule } from "@/modules/billing/billing.module";
@@ -58,6 +61,9 @@ import { Module } from "@nestjs/common";
TeamsEventTypesModule,
MembershipsModule,
ProfilesModule,
RegularBookingModule,
RecurringBookingModule,
InstantBookingModule,
],
providers: [
TokensRepository,
@@ -87,7 +87,7 @@ import {
@DocsTags("Bookings")
@ApiHeader({
name: "cal-api-version",
description: `Must be set to ${VERSION_2024_08_13}`,
description: `Must be set to ${VERSION_2024_08_13}. If not set to this value, the endpoint will default to an older version.`,
example: VERSION_2024_08_13,
required: true,
schema: {
@@ -133,6 +133,8 @@ export class BookingsController_2024_08_13 {
If you are creating a seated booking for an event type with 'show attendees' disabled, then to retrieve attendees in the response either set 'show attendees' to true on event type level or
you have to provide an authentication method of event type owner, host, team admin or owner or org admin or owner.
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
`,
})
@ApiBody({
@@ -188,6 +190,8 @@ export class BookingsController_2024_08_13 {
If you are fetching a seated booking for an event type with 'show attendees' disabled, then to retrieve attendees in the response either set 'show attendees' to true on event type level or
you have to provide an authentication method of event type owner, host, team admin or owner or org admin or owner.
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
`,
})
async getBooking(
@@ -206,7 +210,10 @@ export class BookingsController_2024_08_13 {
@UseGuards(BookingUidGuard)
@ApiOperation({
summary: "Get all the recordings for the booking",
description: `Fetches all the recordings for the booking \`:bookingUid\``,
description: `Fetches all the recordings for the booking \`:bookingUid\`
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
`,
})
async getBookingRecordings(@Param("bookingUid") bookingUid: string): Promise<GetBookingRecordingsOutput> {
const recordings = await this.calVideoService.getRecordings(bookingUid);
@@ -221,7 +228,10 @@ export class BookingsController_2024_08_13 {
@UseGuards(BookingUidGuard)
@ApiOperation({
summary: "Get all the transcripts download links for the booking",
description: `Fetches all the transcripts download links for the booking \`:bookingUid\``,
description: `Fetches all the transcripts download links for the booking \`:bookingUid\`
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
`,
})
async getBookingTranscripts(@Param("bookingUid") bookingUid: string): Promise<GetBookingTranscriptsOutput> {
const transcripts = await this.calVideoService.getTranscripts(bookingUid);
@@ -236,7 +246,10 @@ export class BookingsController_2024_08_13 {
@UseGuards(ApiAuthGuard)
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@Permissions([BOOKING_READ])
@ApiOperation({ summary: "Get all bookings" })
@ApiOperation({
summary: "Get all bookings",
description: `<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>`,
})
async getBookings(
@Query() queryParams: GetBookingsInput_2024_08_13,
@GetUser() user: ApiAuthGuardUser
@@ -263,7 +276,10 @@ export class BookingsController_2024_08_13 {
@ApiHeader(OPTIONAL_API_KEY_OR_ACCESS_TOKEN_HEADER)
@ApiOperation({
summary: "Reschedule a booking",
description: "Reschedule a booking or seated booking",
description: `Reschedule a booking or seated booking
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
`,
})
@ApiBody({
schema: {
@@ -315,6 +331,8 @@ export class BookingsController_2024_08_13 {
If you are cancelling a seated booking for an event type with 'show attendees' disabled, then to retrieve attendees in the response either set 'show attendees' to true on event type level or
you have to provide an authentication method of event type owner, host, team admin or owner or org admin or owner.
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
`,
})
@ApiBody({
@@ -350,7 +368,10 @@ export class BookingsController_2024_08_13 {
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@ApiOperation({
summary: "Mark a booking absence",
description: "The provided authorization header refers to the owner of the booking.",
description: `The provided authorization header refers to the owner of the booking.
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
`,
})
async markNoShow(
@Param("bookingUid") bookingUid: string,
@@ -372,8 +393,10 @@ export class BookingsController_2024_08_13 {
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@ApiOperation({
summary: "Reassign a booking to auto-selected host",
description:
"Currently only supports reassigning host for round robin bookings. The provided authorization header refers to the owner of the booking.",
description: `Currently only supports reassigning host for round robin bookings. The provided authorization header refers to the owner of the booking.
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
`,
})
async reassignBooking(
@Param("bookingUid") bookingUid: string,
@@ -394,8 +417,10 @@ export class BookingsController_2024_08_13 {
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@ApiOperation({
summary: "Reassign a booking to a specific host",
description:
"Currently only supports reassigning host for round robin bookings. The provided authorization header refers to the owner of the booking.",
description: `Currently only supports reassigning host for round robin bookings. The provided authorization header refers to the owner of the booking.
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
`,
})
async reassignBookingToUser(
@Param("bookingUid") bookingUid: string,
@@ -423,7 +448,10 @@ export class BookingsController_2024_08_13 {
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@ApiOperation({
summary: "Confirm a booking",
description: "The provided authorization header refers to the owner of the booking.",
description: `The provided authorization header refers to the owner of the booking.
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
`,
})
async confirmBooking(
@Param("bookingUid") bookingUid: string,
@@ -444,7 +472,10 @@ export class BookingsController_2024_08_13 {
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@ApiOperation({
summary: "Decline a booking",
description: "The provided authorization header refers to the owner of the booking.",
description: `The provided authorization header refers to the owner of the booking.
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
`,
})
async declineBooking(
@Param("bookingUid") bookingUid: string,
@@ -465,8 +496,10 @@ export class BookingsController_2024_08_13 {
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@ApiOperation({
summary: "Get 'Add to Calendar' links for a booking",
description:
"Retrieve calendar links for a booking that can be used to add the event to various calendar services. Returns links for Google Calendar, Microsoft Office, Microsoft Outlook, and a downloadable ICS file.",
description: `Retrieve calendar links for a booking that can be used to add the event to various calendar services. Returns links for Google Calendar, Microsoft Office, Microsoft Outlook, and a downloadable ICS file.
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
`,
})
@HttpCode(HttpStatus.OK)
async getCalendarLinks(@Param("bookingUid") bookingUid: string): Promise<CalendarLinksOutput_2024_08_13> {
@@ -485,6 +518,7 @@ export class BookingsController_2024_08_13 {
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@ApiOperation({
summary: "Get booking references",
description: `<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>`,
})
@HttpCode(HttpStatus.OK)
async getBookingReferences(
@@ -65,6 +65,8 @@ describe("Bookings Endpoints 2024-08-13", () => {
let team1EventTypeId: number;
let team2EventTypeId: number;
let phoneOnlyEventTypeId: number;
let collectiveEventWithoutHostsId: number;
let roundRobinEventWithoutHostsId: number;
const team1EventTypeSlug = `team-bookings-event-type-${randomString()}`;
const team2EventTypeSlug = `team-bookings-event-type-${randomString()}`;
@@ -223,6 +225,28 @@ describe("Bookings Endpoints 2024-08-13", () => {
team1EventTypeId = team1EventType.id;
const team1CollectiveEventTypeWithoutHosts = await eventTypesRepositoryFixture.createTeamEventType({
schedulingType: "COLLECTIVE",
team: {
connect: { id: team1.id },
},
title: `team-bookings-2024-08-13-collective-event-type-without-hosts-${randomString()}`,
slug: `team-bookings-2024-08-13-collective-event-type-without-hosts-${randomString()}`,
length: 60,
});
collectiveEventWithoutHostsId = team1CollectiveEventTypeWithoutHosts.id;
const team1RoundRobinEventTypeWithoutHosts = await eventTypesRepositoryFixture.createTeamEventType({
schedulingType: "ROUND_ROBIN",
team: {
connect: { id: team1.id },
},
title: `team-bookings-2024-08-13-round-robin-event-type-without-hosts-${randomString()}`,
slug: `team-bookings-2024-08-13-round-robin-event-type-without-hosts-${randomString()}`,
length: 60,
});
roundRobinEventWithoutHostsId = team1RoundRobinEventTypeWithoutHosts.id;
const phoneOnlyEventType = await eventTypesRepositoryFixture.createTeamEventType({
schedulingType: "ROUND_ROBIN",
team: {
@@ -363,6 +387,52 @@ describe("Bookings Endpoints 2024-08-13", () => {
await app.init();
});
describe("cant book event types without hosts", () => {
it("should fail to book a collective event type without hosts", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(),
eventTypeId: collectiveEventWithoutHostsId,
attendee: {
name: "alice",
timeZone: "Europe/Madrid",
email: "alice@gmail.com",
},
};
const response = await request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
expect(response.status).toBe(422);
expect(response.body.error.message).toBe(
`Can't book this team event type because it has no hosts. Please, add at least 1 host to event type with id=${collectiveEventWithoutHostsId} belonging to team with id=${team1.id} and try again.`
);
});
it("should fail to book a round robin event type without hosts", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(),
eventTypeId: roundRobinEventWithoutHostsId,
attendee: {
name: "alice",
timeZone: "Europe/Madrid",
email: "alice@gmail.com",
},
};
const response = await request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
expect(response.status).toBe(422);
expect(response.body.error.message).toBe(
`Can't book this team event type because it has no hosts. Please, add at least 1 host to event type with id=${roundRobinEventWithoutHostsId} belonging to team with id=${team1.id} and try again.`
);
});
});
describe("create team bookings", () => {
it("should create a team 1 booking", async () => {
const body: CreateBookingInput_2024_08_13 = {
@@ -765,8 +835,8 @@ describe("Bookings Endpoints 2024-08-13", () => {
return client;
}
function responseDataIsBooking(data: any): data is BookingOutput_2024_08_13 {
return !Array.isArray(data) && typeof data === "object" && data && "id" in data;
function responseDataIsBooking(data: unknown): data is BookingOutput_2024_08_13 {
return !Array.isArray(data) && typeof data === "object" && data !== null && data && "id" in data;
}
afterAll(async () => {
@@ -6,6 +6,9 @@ import { OutputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/servi
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 { getPagination } from "@/lib/pagination/pagination";
import { InstantBookingCreateService } from "@/lib/services/instant-booking-create.service";
import { RecurringBookingService } from "@/lib/services/recurring-booking.service";
import { RegularBookingService } from "@/lib/services/regular-booking.service";
import { AuthOptionalUser } from "@/modules/auth/decorators/get-optional-user/get-optional-user.decorator";
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
import { BillingService } from "@/modules/billing/services/billing.service";
@@ -29,6 +32,7 @@ import {
Logger,
NotFoundException,
UnauthorizedException,
UnprocessableEntityException,
} from "@nestjs/common";
import { BadRequestException } from "@nestjs/common";
import { Request } from "express";
@@ -36,10 +40,8 @@ import { DateTime } from "luxon";
import { z } from "zod";
import {
handleNewRecurringBooking,
getTranslation,
getAllUserBookings,
handleInstantMeeting,
handleCancelBooking,
roundRobinReassignment,
roundRobinManualReassignment,
@@ -47,7 +49,6 @@ import {
confirmBookingHandler,
getCalendarLinks,
} from "@calcom/platform-libraries";
import { handleNewBooking } from "@calcom/platform-libraries";
import {
CreateBookingInput_2024_08_13,
CreateBookingInput,
@@ -108,7 +109,10 @@ export class BookingsService_2024_08_13 {
private readonly teamsEventTypesRepository: TeamsEventTypesRepository,
private readonly membershipsRepository: MembershipsRepository,
private readonly membershipsService: MembershipsService,
private readonly errorsBookingsService: ErrorsBookingsService_2024_08_13
private readonly errorsBookingsService: ErrorsBookingsService_2024_08_13,
private readonly regularBookingService: RegularBookingService,
private readonly recurringBookingService: RecurringBookingService,
private readonly instantBookingCreateService: InstantBookingCreateService
) {}
async createBooking(request: Request, body: CreateBookingInput, authUser: AuthOptionalUser) {
@@ -132,6 +136,10 @@ export class BookingsService_2024_08_13 {
);
}
if (eventType.schedulingType === "COLLECTIVE" || eventType.schedulingType === "ROUND_ROBIN") {
await this.checkEventTypeHasHosts(eventType.id);
}
body.eventTypeId = eventType.id;
if ("instant" in body && body.instant) {
@@ -159,6 +167,15 @@ export class BookingsService_2024_08_13 {
}
}
async checkEventTypeHasHosts(eventTypeId: number) {
const eventType = await this.eventTypesRepository.getEventTypeWithHosts(eventTypeId);
if (!eventType?.hosts?.length) {
throw new UnprocessableEntityException(
`Can't book this team event type because it has no hosts. Please, add at least 1 host to event type with id=${eventTypeId} belonging to team with id=${eventType?.teamId} and try again.`
);
}
}
async checkBookingRequiresAuthenticationSetting(
eventType: EventTypeWithOwnerAndTeam,
authUser: AuthOptionalUser,
@@ -450,7 +467,9 @@ export class BookingsService_2024_08_13 {
}
const bookingRequest = await this.inputService.createBookingRequest(request, body, eventType);
const booking = await handleInstantMeeting(bookingRequest);
const booking = await this.instantBookingCreateService.createBooking({
bookingData: bookingRequest.body,
});
const databaseBooking = await this.bookingsRepository.getByIdWithAttendeesAndUserAndEvent(
booking.bookingId
@@ -468,17 +487,19 @@ export class BookingsService_2024_08_13 {
eventType: EventTypeWithOwnerAndTeam
) {
const bookingRequest = await this.inputService.createRecurringBookingRequest(request, body, eventType);
const bookings = await handleNewRecurringBooking({
const bookings = await this.recurringBookingService.createBooking({
bookingData: bookingRequest.body,
userId: bookingRequest.userId,
hostname: bookingRequest.headers?.host || "",
platformClientId: bookingRequest.platformClientId,
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
platformCancelUrl: bookingRequest.platformCancelUrl,
platformBookingUrl: bookingRequest.platformBookingUrl,
platformBookingLocation: bookingRequest.platformBookingLocation,
noEmail: bookingRequest.noEmail,
areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled,
bookingMeta: {
userId: bookingRequest.userId,
hostname: bookingRequest.headers?.host || "",
platformClientId: bookingRequest.platformClientId,
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
platformCancelUrl: bookingRequest.platformCancelUrl,
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);
@@ -491,16 +512,18 @@ export class BookingsService_2024_08_13 {
userIsEventTypeAdminOrOwner: boolean
) {
const bookingRequest = await this.inputService.createRecurringBookingRequest(request, body, eventType);
const bookings = await handleNewRecurringBooking({
const bookings = await this.recurringBookingService.createBooking({
bookingData: bookingRequest.body,
userId: bookingRequest.userId,
hostname: bookingRequest.headers?.host || "",
platformClientId: bookingRequest.platformClientId,
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
platformCancelUrl: bookingRequest.platformCancelUrl,
platformBookingUrl: bookingRequest.platformBookingUrl,
platformBookingLocation: bookingRequest.platformBookingLocation,
areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled,
bookingMeta: {
userId: bookingRequest.userId,
hostname: bookingRequest.headers?.host || "",
platformClientId: bookingRequest.platformClientId,
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
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 || "" })),
@@ -514,16 +537,18 @@ export class BookingsService_2024_08_13 {
eventType: EventTypeWithOwnerAndTeam
) {
const bookingRequest = await this.inputService.createBookingRequest(request, body, eventType);
const booking = await handleNewBooking({
const booking = await this.regularBookingService.createBooking({
bookingData: bookingRequest.body,
userId: bookingRequest.userId,
hostname: bookingRequest.headers?.host || "",
platformClientId: bookingRequest.platformClientId,
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
platformCancelUrl: bookingRequest.platformCancelUrl,
platformBookingUrl: bookingRequest.platformBookingUrl,
platformBookingLocation: bookingRequest.platformBookingLocation,
areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled,
bookingMeta: {
userId: bookingRequest.userId,
hostname: bookingRequest.headers?.host || "",
platformClientId: bookingRequest.platformClientId,
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
platformCancelUrl: bookingRequest.platformCancelUrl,
platformBookingUrl: bookingRequest.platformBookingUrl,
platformBookingLocation: bookingRequest.platformBookingLocation,
areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled,
},
});
if (!booking.uid) {
@@ -546,16 +571,18 @@ export class BookingsService_2024_08_13 {
) {
const bookingRequest = await this.inputService.createBookingRequest(request, body, eventType);
try {
const booking = await handleNewBooking({
const booking = await this.regularBookingService.createBooking({
bookingData: bookingRequest.body,
userId: bookingRequest.userId,
hostname: bookingRequest.headers?.host || "",
platformClientId: bookingRequest.platformClientId,
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
platformCancelUrl: bookingRequest.platformCancelUrl,
platformBookingUrl: bookingRequest.platformBookingUrl,
platformBookingLocation: bookingRequest.platformBookingLocation,
areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled,
bookingMeta: {
userId: bookingRequest.userId,
hostname: bookingRequest.headers?.host || "",
platformClientId: bookingRequest.platformClientId,
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
platformCancelUrl: bookingRequest.platformCancelUrl,
platformBookingUrl: bookingRequest.platformBookingUrl,
platformBookingLocation: bookingRequest.platformBookingLocation,
areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled,
},
});
if (!booking.uid) {
@@ -741,16 +768,18 @@ export class BookingsService_2024_08_13 {
bookingUid,
body
);
const booking = await handleNewBooking({
const booking = await this.regularBookingService.createBooking({
bookingData: bookingRequest.body,
userId: bookingRequest.userId,
hostname: bookingRequest.headers?.host || "",
platformClientId: bookingRequest.platformClientId,
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
platformCancelUrl: bookingRequest.platformCancelUrl,
platformBookingUrl: bookingRequest.platformBookingUrl,
platformBookingLocation: bookingRequest.platformBookingLocation,
areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled,
bookingMeta: {
userId: bookingRequest.userId,
hostname: bookingRequest.headers?.host || "",
platformClientId: bookingRequest.platformClientId,
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
platformCancelUrl: bookingRequest.platformCancelUrl,
platformBookingUrl: bookingRequest.platformBookingUrl,
platformBookingLocation: bookingRequest.platformBookingLocation,
areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled,
},
});
if (!booking.uid) {
throw new Error("Booking missing uid");
@@ -817,7 +817,7 @@ export class InputBookingsService_2024_08_13 {
// for an individual person, so api users need to call booking by booking using uid + seatUid to cancel it.
return {
uid: bookingUid,
cancellationReason: "",
cancellationReason: inputBooking.cancellationReason || "",
allRemainingBookings: false,
seatReferenceUid: inputBooking.seatUid,
};
@@ -137,7 +137,11 @@ export class EventTypesService_2024_04_15 {
!bookingFields.find((field) => field.type === "email") &&
!bookingFields.find((field) => field.type === "phone")
) {
bookingFields.push({ ...systemBeforeFieldEmail, type: BaseField.email, editable: Editable.system });
bookingFields.push({
...systemBeforeFieldEmail,
type: BaseField.email,
editable: Editable.systemButOptional,
});
}
await updateEventType({
@@ -38,6 +38,7 @@ import type {
GuestsDefaultFieldOutput_2024_06_14,
NameDefaultFieldInput_2024_06_14,
NotesDefaultFieldInput_2024_06_14,
SplitNameDefaultFieldOutput_2024_06_14,
UpdateEventTypeInput_2024_06_14,
} from "@calcom/platform-types";
import type { PlatformOAuthClient, Team, User, Schedule, EventType } from "@calcom/prisma/client";
@@ -1447,23 +1448,23 @@ describe("Event types Endpoints", () => {
try {
await eventTypesRepositoryFixture.delete(eventType.id);
} catch (e) {
// Event type might have been deleted by the test
console.log(e);
}
try {
await userRepositoryFixture.delete(user.id);
} catch (e) {
// User might have been deleted by the test
console.log(e);
}
try {
await userRepositoryFixture.delete(falseTestUser.id);
} catch (e) {
// User might have been deleted by the test
console.log(e);
}
try {
await userRepositoryFixture.delete(orgUser.id);
} catch (e) {
// User might have been deleted by the test
console.log(e);
}
await app.close();
});
@@ -2187,6 +2188,47 @@ describe("Event types Endpoints", () => {
});
});
describe("split name booking field", () => {
it("should create an event type with split name booking field", async () => {
const splitNameBookingField: SplitNameDefaultFieldOutput_2024_06_14 = {
isDefault: true,
type: "splitName",
slug: "splitName",
firstNameLabel: "First name",
firstNamePlaceholder: "",
lastNameLabel: "last name",
lastNamePlaceholder: "",
lastNameRequired: false,
disableOnPrefill: false,
};
const body: CreateEventTypeInput_2024_06_14 = {
title: "Coding class 9",
slug: "coding-class-9",
description: "Let's learn how to code like a pro.",
lengthInMinutes: 60,
bookingFields: [splitNameBookingField],
};
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;
const splitNameBookingFieldResponse = createdEventType.bookingFields.find(
(field) => field.type === "splitName"
) as SplitNameDefaultFieldOutput_2024_06_14 | undefined;
expect(splitNameBookingFieldResponse).toEqual(splitNameBookingField);
await eventTypesRepositoryFixture.delete(createdEventType.id);
});
});
});
afterAll(async () => {
await oauthClientRepositoryFixture.delete(oAuthClient.id);
await teamRepositoryFixture.delete(organization.id);
@@ -2194,12 +2236,12 @@ describe("Event types Endpoints", () => {
await eventTypesRepositoryFixture.delete(legacyEventTypeId1);
await eventTypesRepositoryFixture.delete(legacyEventTypeId2);
} catch (e) {
// Event type might have been deleted by the test
console.log(e);
}
try {
await userRepositoryFixture.delete(user.id);
} catch (e) {
// User might have been deleted by the test
console.log(e);
}
await app.close();
});
@@ -2379,7 +2421,7 @@ describe("Event types Endpoints", () => {
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
const updatedEventType = responseBody.data;
+expect(updatedEventType.hidden).toBe(false);
expect(updatedEventType.hidden).toBe(false);
});
});
@@ -2389,12 +2431,12 @@ describe("Event types Endpoints", () => {
try {
await eventTypesRepositoryFixture.delete(legacyEventTypeId1);
} catch (e) {
// Event type might have been deleted by the test
console.log(e);
}
try {
await userRepositoryFixture.delete(user.id);
} catch (e) {
// User might have been deleted by the test
console.log(e);
}
await app.close();
});
@@ -61,7 +61,7 @@ import {
@DocsTags("Event Types")
@ApiHeader({
name: "cal-api-version",
description: `Must be set to ${VERSION_2024_06_14}`,
description: `Must be set to ${VERSION_2024_06_14}. If not set to this value, the endpoint will default to an older version.`,
example: VERSION_2024_06_14,
required: true,
schema: {
@@ -80,7 +80,10 @@ export class EventTypesController_2024_06_14 {
@Permissions([EVENT_TYPE_WRITE])
@UseGuards(ApiAuthGuard)
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@ApiOperation({ summary: "Create an event type" })
@ApiOperation({
summary: "Create an event type",
description: `<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>`,
})
async createEventType(
@Body() body: CreateEventTypeInput_2024_06_14,
@GetUser() user: UserWithProfile
@@ -102,7 +105,10 @@ export class EventTypesController_2024_06_14 {
@Permissions([EVENT_TYPE_READ])
@UseGuards(ApiAuthGuard)
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@ApiOperation({ summary: "Get an event type" })
@ApiOperation({
summary: "Get an event type",
description: `<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>`,
})
async getEventTypeById(
@Param("eventTypeId") eventTypeId: string,
@GetUser() user: UserWithProfile
@@ -122,8 +128,10 @@ export class EventTypesController_2024_06_14 {
@Get("/")
@ApiOperation({
summary: "Get all event types",
description:
"Hidden event types are returned only if authentication is provided and it belongs to the event type owner.",
description: `Hidden event types are returned only if authentication is provided and it belongs to the event type owner.
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
`,
})
@UseGuards(OptionalApiAuthGuard)
@ApiHeader(OPTIONAL_X_CAL_CLIENT_ID_HEADER)
@@ -149,7 +157,10 @@ export class EventTypesController_2024_06_14 {
@UseGuards(ApiAuthGuard)
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Update an event type" })
@ApiOperation({
summary: "Update an event type",
description: `<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>`,
})
async updateEventType(
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
@Body() body: UpdateEventTypeInput_2024_06_14,
@@ -173,7 +184,10 @@ export class EventTypesController_2024_06_14 {
@Permissions([EVENT_TYPE_WRITE])
@UseGuards(ApiAuthGuard)
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@ApiOperation({ summary: "Delete an event type" })
@ApiOperation({
summary: "Delete an event type",
description: `<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>`,
})
async deleteEventType(
@Param("eventTypeId") eventTypeId: number,
@GetUser("id") userId: number
@@ -556,7 +556,7 @@ export const systemBeforeFieldEmail: EmailSystemField = {
type: "email",
name: "email",
required: true,
editable: "system",
editable: "system-but-optional",
sources: [
{
label: "Default",
@@ -40,7 +40,7 @@ import {
@DocsTags("Schedules")
@ApiHeader({
name: "cal-api-version",
description: `Must be set to ${VERSION_2024_06_11}`,
description: `Must be set to ${VERSION_2024_06_11}. If not set to this value, the endpoint will default to an older version.`,
example: VERSION_2024_06_11,
required: true,
schema: {
@@ -69,6 +69,8 @@ export class SchedulesController_2024_06_11 {
After creating a non-default schedule, you can update an event type to point to that schedule via the PATCH \`event-types/{eventTypeId}\` endpoint.
When specifying start time and end time for each day use the 24 hour format e.g. 08:00, 15:00 etc.
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
`,
})
async createSchedule(
@@ -91,7 +93,10 @@ export class SchedulesController_2024_06_11 {
})
@ApiOperation({
summary: "Get default schedule",
description: "Get the default schedule of the authenticated user.",
description: `Get the default schedule of the authenticated user.
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
`,
})
async getDefaultSchedule(@GetUser() user: UserWithProfile): Promise<GetScheduleOutput_2024_06_11> {
const schedule = await this.schedulesService.getUserScheduleDefault(user.id);
@@ -104,7 +109,10 @@ export class SchedulesController_2024_06_11 {
@Get("/:scheduleId")
@Permissions([SCHEDULE_READ])
@ApiOperation({ summary: "Get a schedule" })
@ApiOperation({
summary: "Get a schedule",
description: `<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>`,
})
async getSchedule(
@GetUser() user: UserWithProfile,
@Param("scheduleId") scheduleId: number
@@ -121,7 +129,10 @@ export class SchedulesController_2024_06_11 {
@Permissions([SCHEDULE_READ])
@ApiOperation({
summary: "Get all schedules",
description: "Get all schedules of the authenticated user.",
description: `Get all schedules of the authenticated user.
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
`,
})
async getSchedules(@GetUser() user: UserWithProfile): Promise<GetSchedulesOutput_2024_06_11> {
const schedules = await this.schedulesService.getUserSchedules(user.id);
@@ -134,7 +145,10 @@ export class SchedulesController_2024_06_11 {
@Patch("/:scheduleId")
@Permissions([SCHEDULE_WRITE])
@ApiOperation({ summary: "Update a schedule" })
@ApiOperation({
summary: "Update a schedule",
description: `<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>`,
})
async updateSchedule(
@GetUser() user: UserWithProfile,
@Body() bodySchedule: UpdateScheduleInput_2024_06_11,
@@ -155,7 +169,10 @@ export class SchedulesController_2024_06_11 {
@Delete("/:scheduleId")
@HttpCode(HttpStatus.OK)
@Permissions([SCHEDULE_WRITE])
@ApiOperation({ summary: "Delete a schedule" })
@ApiOperation({
summary: "Delete a schedule",
description: `<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>`,
})
async deleteSchedule(
@GetUser("id") userId: number,
@Param("scheduleId") scheduleId: number
@@ -0,0 +1,8 @@
import { InstantBookingCreateService } from "@/lib/services/instant-booking-create.service";
import { Module } from "@nestjs/common";
@Module({
providers: [InstantBookingCreateService],
exports: [InstantBookingCreateService],
})
export class InstantBookingModule {}
@@ -0,0 +1,10 @@
import { RegularBookingModule } from "@/lib/modules/regular-booking.module";
import { RecurringBookingService } from "@/lib/services/recurring-booking.service";
import { Module } from "@nestjs/common";
@Module({
imports: [RegularBookingModule],
providers: [RecurringBookingService],
exports: [RecurringBookingService],
})
export class RecurringBookingModule {}
@@ -0,0 +1,32 @@
import { PrismaAttributeRepository } from "@/lib/repositories/prisma-attribute.repository";
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
import { PrismaFeaturesRepository } from "@/lib/repositories/prisma-features.repository";
import { PrismaHostRepository } from "@/lib/repositories/prisma-host.repository";
import { PrismaOOORepository } from "@/lib/repositories/prisma-ooo.repository";
import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository";
import { CacheService } from "@/lib/services/cache.service";
import { CheckBookingAndDurationLimitsService } from "@/lib/services/check-booking-and-duration-limits.service";
import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service";
import { LuckyUserService } from "@/lib/services/lucky-user.service";
import { RegularBookingService } from "@/lib/services/regular-booking.service";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule],
providers: [
PrismaAttributeRepository,
PrismaBookingRepository,
PrismaFeaturesRepository,
PrismaHostRepository,
PrismaOOORepository,
PrismaUserRepository,
CacheService,
CheckBookingAndDurationLimitsService,
CheckBookingLimitsService,
LuckyUserService,
RegularBookingService,
],
exports: [RegularBookingService],
})
export class RegularBookingModule {}
@@ -0,0 +1,6 @@
import { Injectable } from "@nestjs/common";
import { InstantBookingCreateService as BaseInstantBookingCreateService } from "@calcom/platform-libraries/bookings";
@Injectable()
export class InstantBookingCreateService extends BaseInstantBookingCreateService {}
@@ -0,0 +1,13 @@
import { RegularBookingService } from "@/lib/services/regular-booking.service";
import { Injectable } from "@nestjs/common";
import { RecurringBookingService as BaseRecurringBookingService } from "@calcom/platform-libraries/bookings";
@Injectable()
export class RecurringBookingService extends BaseRecurringBookingService {
constructor(regularBookingService: RegularBookingService) {
super({
regularBookingService,
});
}
}
@@ -0,0 +1,46 @@
import { PrismaAttributeRepository } from "@/lib/repositories/prisma-attribute.repository";
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
import { PrismaFeaturesRepository } from "@/lib/repositories/prisma-features.repository";
import { PrismaHostRepository } from "@/lib/repositories/prisma-host.repository";
import { PrismaOOORepository } from "@/lib/repositories/prisma-ooo.repository";
import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository";
import { CacheService } from "@/lib/services/cache.service";
import { CheckBookingAndDurationLimitsService } from "@/lib/services/check-booking-and-duration-limits.service";
import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service";
import { LuckyUserService } from "@/lib/services/lucky-user.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
import { RegularBookingService as BaseRegularBookingService } from "@calcom/platform-libraries/bookings";
import type { PrismaClient } from "@calcom/prisma";
@Injectable()
export class RegularBookingService extends BaseRegularBookingService {
constructor(
cacheService: CacheService,
checkBookingAndDurationLimitsService: CheckBookingAndDurationLimitsService,
prismaWriteService: PrismaWriteService,
bookingRepository: PrismaBookingRepository,
featuresRepository: PrismaFeaturesRepository,
checkBookingLimitsService: CheckBookingLimitsService,
luckyUserService: LuckyUserService,
hostRepository: PrismaHostRepository,
oooRepository: PrismaOOORepository,
userRepository: PrismaUserRepository,
attributeRepository: PrismaAttributeRepository
) {
super({
cacheService,
checkBookingAndDurationLimitsService,
prismaClient: prismaWriteService.prisma as unknown as PrismaClient,
bookingRepository,
featuresRepository,
checkBookingLimitsService,
luckyUserService,
hostRepository,
oooRepository,
userRepository,
attributeRepository,
});
}
}
@@ -0,0 +1,51 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
@Injectable()
export class AtomsSecondaryEmailsRepository {
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
async getSecondaryEmailsVerified(userId: number) {
return await this.dbRead.prisma.secondaryEmail.findMany({
where: {
userId,
emailVerified: {
not: null,
},
},
});
}
async getExistingSecondaryEmailByUserAndEmail(userId: number, email: string) {
const existingSecondaryEmailRecord = await this.dbRead.prisma.secondaryEmail.findUnique({
where: {
userId_email: { userId, email },
},
});
return existingSecondaryEmailRecord?.email;
}
async getExistingSecondaryEmail(email: string) {
const existingSecondaryEmailRecord = await this.dbRead.prisma.secondaryEmail.findUnique({
where: {
email,
},
});
return existingSecondaryEmailRecord?.email;
}
async addSecondaryEmailVerified(userId: number, email: string) {
const existingSecondaryEmailRecord = await this.dbWrite.prisma.secondaryEmail.create({
data: {
userId,
email,
emailVerified: new Date(),
},
});
return existingSecondaryEmailRecord?.email;
}
}
@@ -1,4 +1,5 @@
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
import { AtomsSecondaryEmailsRepository } from "@/modules/atoms/atoms-secondary-emails.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";
@@ -33,6 +34,7 @@ import { Module } from "@nestjs/common";
CredentialsRepository,
UsersRepository,
AtomsRepository,
AtomsSecondaryEmailsRepository,
UsersService,
SchedulesAtomsService,
VerificationAtomsService,
@@ -64,47 +64,4 @@ export class AtomsRepository {
return userTeams;
}
async getSecondaryEmails(userId: number) {
return await this.dbRead.prisma.secondaryEmail.findMany({
where: {
userId,
emailVerified: {
not: null,
},
},
});
}
async getExistingSecondaryEmailByUserAndEmail(userId: number, email: string) {
const existingSecondaryEmailRecord = await this.dbRead.prisma.secondaryEmail.findUnique({
where: {
userId_email: { userId, email },
},
});
return existingSecondaryEmailRecord?.email;
}
async getExistingSecondaryEmail(email: string) {
const existingSecondaryEmailRecord = await this.dbRead.prisma.secondaryEmail.findUnique({
where: {
email,
},
});
return existingSecondaryEmailRecord?.email;
}
async addSecondaryEmail(userId: number, email: string) {
const existingSecondaryEmailRecord = await this.dbWrite.prisma.secondaryEmail.create({
data: {
userId,
email,
emailVerified: new Date(),
},
});
return existingSecondaryEmailRecord?.email;
}
}
@@ -1,10 +1,10 @@
import { AtomsRepository } from "@/modules/atoms/atoms.repository";
import { AtomsSecondaryEmailsRepository } from "@/modules/atoms/atoms-secondary-emails.repository";
import { CheckEmailVerificationRequiredParams } from "@/modules/atoms/inputs/check-email-verification-required-params";
import { GetVerifiedEmailsInput } from "@/modules/atoms/inputs/get-verified-emails-params";
import { SendVerificationEmailInput } from "@/modules/atoms/inputs/send-verification-email.input";
import { VerifyEmailCodeInput } from "@/modules/atoms/inputs/verify-email-code.input";
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
import { UserWithProfile } from "@/modules/users/users.repository";
import { UsersRepository } from "@/modules/users/users.repository";
import { Injectable, BadRequestException, UnauthorizedException } from "@nestjs/common";
import {
@@ -17,8 +17,8 @@ import {
@Injectable()
export class VerificationAtomsService {
constructor(
private readonly atomsRepository: AtomsRepository,
private readonly teamsRepository: TeamsRepository
private readonly atomsSecondaryEmailsRepository: AtomsSecondaryEmailsRepository,
private readonly usersRepository: UsersRepository
) {}
async checkEmailVerificationRequired(input: CheckEmailVerificationRequiredParams) {
@@ -76,7 +76,8 @@ export class VerificationAtomsService {
if (teamId) {
const verifiedEmails: string[] = [];
const teamMembers = await this.teamsRepository.getTeamMemberEmails(teamId);
const teamMembers = await this.usersRepository.getUserEmailsVerifiedForTeam(teamId);
if (teamMembers.length === 0) {
return verifiedEmails;
@@ -96,7 +97,7 @@ export class VerificationAtomsService {
let verifiedEmails = [userEmailWithoutOauthClientId];
const secondaryEmails = await this.atomsRepository.getSecondaryEmails(userId);
const secondaryEmails = await this.atomsSecondaryEmailsRepository.getSecondaryEmailsVerified(userId);
verifiedEmails = verifiedEmails.concat(
secondaryEmails.map((secondaryEmail) => this.removeClientIdFromEmail(secondaryEmail.email))
);
@@ -113,11 +114,9 @@ export class VerificationAtomsService {
existingPrimaryEmail: string;
email: string;
}): Promise<boolean> {
const existingSecondaryEmail = await this.atomsRepository.getExistingSecondaryEmailByUserAndEmail(
userId,
email
);
const alreadyExistingEmail = await this.atomsRepository.getExistingSecondaryEmail(email);
const existingSecondaryEmail =
await this.atomsSecondaryEmailsRepository.getExistingSecondaryEmailByUserAndEmail(userId, email);
const alreadyExistingEmail = await this.atomsSecondaryEmailsRepository.getExistingSecondaryEmail(email);
if (alreadyExistingEmail) {
throw new BadRequestException("Email already exists");
@@ -127,7 +126,7 @@ export class VerificationAtomsService {
return true;
}
await this.atomsRepository.addSecondaryEmail(userId, email);
await this.atomsSecondaryEmailsRepository.addSecondaryEmailVerified(userId, email);
return true;
}
@@ -9,7 +9,7 @@ import {
import { Request } from "express";
@Injectable()
export class IsWorkflowInTeam implements CanActivate {
export class IsEventTypeWorkflowInTeam implements CanActivate {
constructor(private workflowsRepository: WorkflowsRepository) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
@@ -41,10 +41,13 @@ export class IsWorkflowInTeam implements CanActivate {
teamId: string,
workflowId: string
): Promise<{ canAccess: boolean; workflow?: WorkflowType }> {
const workflow = await this.workflowsRepository.getTeamWorkflowById(Number(teamId), Number(workflowId));
const workflow = await this.workflowsRepository.getEventTypeTeamWorkflowById(
Number(teamId),
Number(workflowId)
);
if (!workflow) {
throw new NotFoundException(`IsWorkflowInTeam - workflow (${workflowId}) not found.`);
throw new NotFoundException(`IsWorkflowInTeam - event-type workflow (${workflowId}) not found.`);
}
if (workflow.teamId === Number(teamId)) {
@@ -0,0 +1,59 @@
import { WorkflowsRepository, WorkflowType } from "@/modules/workflows/workflows.repository";
import {
Injectable,
CanActivate,
ExecutionContext,
ForbiddenException,
NotFoundException,
} from "@nestjs/common";
import { Request } from "express";
@Injectable()
export class IsRoutingFormWorkflowInTeam 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.getRoutingFormTeamWorkflowById(
Number(teamId),
Number(workflowId)
);
if (!workflow) {
throw new NotFoundException(`IsWorkflowInTeam - routing form workflow (${workflowId}) not found.`);
}
if (workflow.teamId === Number(teamId)) {
return { canAccess: true, workflow };
}
return { canAccess: false };
}
}
@@ -1,7 +1,8 @@
import { PlatformPlan } from "@/modules/billing/types";
import { hasMinimumPlan } from "@/modules/auth/guards/billing/platform-plan.guard";
import { orderedPlans, PlatformPlan } from "@/modules/billing/types";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable, NotFoundException } from "@nestjs/common";
import { ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
@Injectable()
export class ManagedOrganizationsBillingService {
@@ -14,8 +15,18 @@ export class ManagedOrganizationsBillingService {
if (!managerOrgBilling) {
throw new NotFoundException("Manager organization billing not found.");
}
if (managerOrgBilling.plan !== PlatformPlan.SCALE) {
throw new NotFoundException("Manager organization billing plan is not SCALE.");
if (
!hasMinimumPlan({
currentPlan: managerOrgBilling.plan as PlatformPlan,
minimumPlan: PlatformPlan.SCALE,
plans: orderedPlans,
})
) {
throw new ForbiddenException(
`organization with id=${managerOrgId} does not have required plan for this operation. Minimum plan is ${
PlatformPlan.SCALE
} while the organization has ${managerOrgBilling.plan || "undefined"}.`
);
}
return this.dbWrite.prisma.platformBilling.create({
@@ -705,6 +705,111 @@ describe("OAuth Client Users Endpoints", () => {
.expect(200);
});
describe("managed user time zone", () => {
describe("negative tests", () => {
it("should not allow '' time zone", async () => {
const requestBody = {
email: "whatever2@gmail.com",
timeZone: "",
name: "Bob Smithson",
};
await request(app.getHttpServer())
.post(`/api/v2/oauth-clients/${oAuthClient.id}/users`)
.set("x-cal-secret-key", oAuthClient.secret)
.send(requestBody)
.expect(400);
});
it("should not allow 'invalid-timezone' time zone", async () => {
const requestBody = {
email: "whatever2@gmail.com",
timeZone: "invalid-timezone",
name: "Bob Smithson",
};
await request(app.getHttpServer())
.post(`/api/v2/oauth-clients/${oAuthClient.id}/users`)
.set("x-cal-secret-key", oAuthClient.secret)
.send(requestBody)
.expect(400);
});
});
describe("positive tests", () => {
it("should allow null timezone", async () => {
const requestBody = {
email: "whatever1@gmail.com",
timeZone: null,
name: "Bob Smithson",
};
const response = await request(app.getHttpServer())
.post(`/api/v2/oauth-clients/${oAuthClient.id}/users`)
.set("x-cal-secret-key", oAuthClient.secret)
.send(requestBody)
.expect(201);
const responseBody: CreateManagedUserOutput = response.body;
expect(responseBody.data.user.timeZone).toEqual("Europe/London");
await userRepositoryFixture.delete(responseBody.data.user.id);
});
it("should allow undefined time zone", async () => {
const requestBody = {
email: "whatever3@gmail.com",
timeZone: undefined,
name: "Bob Smithson",
};
const response = await request(app.getHttpServer())
.post(`/api/v2/oauth-clients/${oAuthClient.id}/users`)
.set("x-cal-secret-key", oAuthClient.secret)
.send(requestBody)
.expect(201);
const responseBody: CreateManagedUserOutput = response.body;
expect(responseBody.data.user.timeZone).toEqual("Europe/London");
await userRepositoryFixture.delete(responseBody.data.user.id);
});
it("should allow valid time zone", async () => {
const requestBody = {
email: "whatever4@gmail.com",
timeZone: "Europe/Rome",
name: "Bob Smithson",
};
const response = await request(app.getHttpServer())
.post(`/api/v2/oauth-clients/${oAuthClient.id}/users`)
.set("x-cal-secret-key", oAuthClient.secret)
.send(requestBody)
.expect(201);
const responseBody: CreateManagedUserOutput = response.body;
expect(responseBody.data.user.timeZone).toBe("Europe/Rome");
await userRepositoryFixture.delete(responseBody.data.user.id);
});
it("should allow without any time zone", async () => {
const requestBody = {
email: "whatever5@gmail.com",
name: "Bob Smithson",
};
const response = await request(app.getHttpServer())
.post(`/api/v2/oauth-clients/${oAuthClient.id}/users`)
.set("x-cal-secret-key", oAuthClient.secret)
.send(requestBody)
.expect(201);
const responseBody: CreateManagedUserOutput = response.body;
expect(responseBody.data.user.timeZone).toEqual("Europe/London");
await userRepositoryFixture.delete(responseBody.data.user.id);
});
});
});
afterAll(async () => {
await oauthClientRepositoryFixture.delete(oAuthClient.id);
await oauthClientRepositoryFixture.delete(oAuthClientEventTypesDisabled.id);
@@ -712,17 +817,17 @@ describe("OAuth Client Users Endpoints", () => {
try {
await userRepositoryFixture.delete(postResponseData.user.id);
} catch (e) {
// User might have been deleted by the test
console.log(e);
}
try {
await userRepositoryFixture.delete(postResponseData2.user.id);
} catch (e) {
// User might have been deleted by the test
console.log(e);
}
try {
await userRepositoryFixture.delete(platformAdmin.id);
} catch (e) {
// User might have been deleted by the test
console.log(e);
}
await app.close();
});
@@ -931,7 +1036,7 @@ describe("OAuth Client Users Endpoints", () => {
try {
await userRepositoryFixture.delete(postResponseData.user.id);
} catch (e) {
// User might have been deleted by the test
console.log(e);
}
await app.close();
});
@@ -46,8 +46,8 @@ import { OrganizationsStripeService } from "@/modules/organizations/stripe/servi
import { OrganizationsTeamsController } from "@/modules/organizations/teams/index/organizations-teams.controller";
import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/index/organizations-teams.repository";
import { OrganizationsTeamsService } from "@/modules/organizations/teams/index/services/organizations-teams.service";
import { OrganizationsTeamsMembershipsController } from "@/modules/organizations/teams/memberships/organizations-teams-memberships.controller";
import { OrganizationsTeamsInviteController } from "@/modules/organizations/teams/invite/organizations-teams-invite.controller";
import { OrganizationsTeamsMembershipsController } from "@/modules/organizations/teams/memberships/organizations-teams-memberships.controller";
import { OrganizationsTeamsMembershipsRepository } from "@/modules/organizations/teams/memberships/organizations-teams-memberships.repository";
import { OrganizationsTeamsMembershipsService } from "@/modules/organizations/teams/memberships/services/organizations-teams-memberships.service";
import { OrganizationsTeamsRoutingFormsModule } from "@/modules/organizations/teams/routing-forms/organizations-teams-routing-forms.module";
@@ -76,7 +76,8 @@ import { UsersModule } from "@/modules/users/users.module";
import { TeamsVerifiedResourcesRepository } from "@/modules/verified-resources/teams-verified-resources.repository";
import { WebhooksService } from "@/modules/webhooks/services/webhooks.service";
import { WebhooksRepository } from "@/modules/webhooks/webhooks.repository";
import { TeamWorkflowsService } from "@/modules/workflows/services/team-workflows.service";
import { TeamEventTypeWorkflowsService } from "@/modules/workflows/services/team-event-type-workflows.service";
import { TeamRoutingFormWorkflowsService } from "@/modules/workflows/services/team-routing-form-workflows.service";
import { WorkflowsInputService } from "@/modules/workflows/services/workflows.input.service";
import { WorkflowsOutputService } from "@/modules/workflows/services/workflows.output.service";
import { WorkflowsRepository } from "@/modules/workflows/workflows.repository";
@@ -145,7 +146,8 @@ import { Module } from "@nestjs/common";
TokensRepository,
TeamsVerifiedResourcesRepository,
WorkflowsRepository,
TeamWorkflowsService,
TeamEventTypeWorkflowsService,
TeamRoutingFormWorkflowsService,
WorkflowsInputService,
WorkflowsOutputService,
TeamsSchedulesService,
@@ -61,11 +61,24 @@ describe("Organizations Organizations Endpoints", () => {
let profilesRepositoryFixture: ProfileRepositoryFixture;
let managerOrg: Team;
let payPerUserPlanManagerOrg: Team;
let essentialsPlanManagerOrg: Team;
let managedOrg: ManagedOrganizationWithApiKeyOutput;
let managedOrg2: ManagedOrganizationWithApiKeyOutput;
const managerOrgAdminEmail = `organizations-organizations-admin-${randomString()}@api.com`;
const payPerUserPlanManagerOrgAdminEmail = `organizations-organizations-admin-${randomString()}@api.com`;
const essentialsPlanManagerOrgAdminEmail = `organizations-organizations-admin-${randomString()}@api.com`;
let managerOrgAdmin: User;
let payPerUserPlanManagerOrgAdmin: User;
let essentialsPlanManagerOrgAdmin: User;
let managerOrgAdminApiKey: string;
let payPerUserPlanManagerOrgAdminApiKey: string;
let essentialsPlanManagerOrgAdminApiKey: string;
let managerOrgBilling: PlatformBilling;
let managedOrgApiKey: string;
@@ -93,40 +106,36 @@ describe("Organizations Organizations Endpoints", () => {
oAuthClientsRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
profilesRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
managerOrgAdmin = await userRepositoryFixture.create({
email: managerOrgAdminEmail,
username: managerOrgAdminEmail,
});
managerOrg = await organizationsRepositoryFixture.create({
name: `organizations-organizations-organization-${randomString()}`,
isOrganization: true,
isPlatform: true,
});
await profilesRepositoryFixture.create({
uid: "asd-asd",
username: managerOrgAdminEmail,
user: { connect: { id: managerOrgAdmin.id } },
organization: { connect: { id: managerOrg.id } },
movedFromUser: { connect: { id: managerOrgAdmin.id } },
});
managerOrgBilling = await platformBillingRepositoryFixture.create(managerOrg.id, "SCALE");
await membershipsRepositoryFixture.create({
role: "ADMIN",
user: { connect: { id: managerOrgAdmin.id } },
team: { connect: { id: managerOrg.id } },
accepted: true,
});
const { keyString } = await apiKeysRepositoryFixture.createApiKey(
managerOrgAdmin.id,
null,
managerOrg.id
// Setup manager organization with SCALE plan
const managerSetup = await setupTestOrganization(
managerOrgAdminEmail,
`organizations-organizations-organization-${randomString()}`,
"SCALE"
);
managerOrgAdminApiKey = `cal_test_${keyString}`;
managerOrgAdmin = managerSetup.admin;
managerOrg = managerSetup.org;
managerOrgBilling = managerSetup.billing;
managerOrgAdminApiKey = managerSetup.apiKey;
// Setup pay-per-user organization
const payPerUserSetup = await setupTestOrganization(
payPerUserPlanManagerOrgAdminEmail,
`pay-per-user-plan-organization-${randomString()}`,
"PER_ACTIVE_USER"
);
payPerUserPlanManagerOrgAdmin = payPerUserSetup.admin;
payPerUserPlanManagerOrg = payPerUserSetup.org;
payPerUserPlanManagerOrgAdminApiKey = payPerUserSetup.apiKey;
// Setup essentials organization
const essentialsSetup = await setupTestOrganization(
essentialsPlanManagerOrgAdminEmail,
`essentials-per-user-plan-organization-${randomString()}`,
"ESSENTIALS"
);
essentialsPlanManagerOrgAdmin = essentialsSetup.admin;
essentialsPlanManagerOrg = essentialsSetup.org;
essentialsPlanManagerOrgAdminApiKey = essentialsSetup.apiKey;
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
@@ -136,6 +145,57 @@ describe("Organizations Organizations Endpoints", () => {
await app.init();
});
async function setupTestOrganization(
adminEmail: string,
orgName: string,
plan: "SCALE" | "PER_ACTIVE_USER" | "ESSENTIALS"
) {
const admin = await userRepositoryFixture.create({
email: adminEmail,
username: adminEmail,
});
const org = await organizationsRepositoryFixture.create({
name: orgName,
isOrganization: true,
isPlatform: true,
});
await profilesRepositoryFixture.create({
uid: `${randomString()}-uid`,
username: adminEmail,
user: { connect: { id: admin.id } },
organization: { connect: { id: org.id } },
movedFromUser: { connect: { id: admin.id } },
});
const billing = await platformBillingRepositoryFixture.create(org.id, plan);
await membershipsRepositoryFixture.create({
role: "ADMIN",
user: { connect: { id: admin.id } },
team: { connect: { id: org.id } },
accepted: true,
});
const { keyString } = await apiKeysRepositoryFixture.createApiKey(admin.id, null, org.id);
const apiKey = `cal_test_${keyString}`;
return { admin, org, billing, apiKey };
}
function createManagedOrgInput(
namePrefix: string,
metadata?: Record<string, string | number | boolean>
): CreateOrganizationInput {
const suffix = randomString(5);
return {
name: `${namePrefix} ${suffix}`,
slug: `${namePrefix.toLowerCase().replace(/\s+/g, "-")}-${suffix}`,
metadata: metadata || { key: "value" },
};
}
afterAll(() => {
clear();
});
@@ -155,21 +215,44 @@ describe("Organizations Organizations Endpoints", () => {
.expect(400);
});
const suffix = randomString(5);
const metadataKey = "first-org-metadata-key";
const metadataValue = "first-org-metadata-value";
const createManagedOrganizationBody: CreateOrganizationInput = {
name: `org ${suffix}`,
slug: `org-${suffix}`,
metadata: { [metadataKey]: metadataValue },
};
const createManagedOrganizationBody: CreateOrganizationInput = createManagedOrgInput("org", {
[metadataKey]: metadataValue,
});
const suffix2 = randomString(5);
const createManagedOrganizationBodySecond: CreateOrganizationInput = {
name: `org2 ${suffix2}`,
slug: `org2-${suffix2}`,
metadata: { key: "value" },
};
const createManagedOrganizationBodySecond: CreateOrganizationInput = createManagedOrgInput("org2");
const createManagedOrganizationBodyThird: CreateOrganizationInput = createManagedOrgInput("org3");
it("should not allow to create managed organization if plan is below SCALE", async () => {
const response = await request(app.getHttpServer())
.post(`/v2/organizations/${essentialsPlanManagerOrg.id}/organizations`)
.set("Authorization", `Bearer ${essentialsPlanManagerOrgAdminApiKey}`)
.send(createManagedOrganizationBody)
.expect(403);
expect(response.body.error.message).toBe(
`PlatformPlanGuard - organization with id=${essentialsPlanManagerOrg.id} does not have required plan for this operation. Minimum plan is SCALE while the organization has ESSENTIALS.`
);
});
it("should allow to create managed organization if plan is SCALE or above", async () => {
return request(app.getHttpServer())
.post(`/v2/organizations/${payPerUserPlanManagerOrg.id}/organizations`)
.set("Authorization", `Bearer ${payPerUserPlanManagerOrgAdminApiKey}`)
.send(createManagedOrganizationBodyThird)
.expect(201)
.then(async (response) => {
const responseBody: ApiSuccessResponse<ManagedOrganizationWithApiKeyOutput> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const managedOrg3 = responseBody.data;
expect(managedOrg3?.id).toBeDefined();
expect(managedOrg3?.name).toEqual(createManagedOrganizationBodyThird.name);
expect(managedOrg3?.slug).toEqual(createManagedOrganizationBodyThird.slug);
expect(managedOrg3?.metadata).toEqual(createManagedOrganizationBodyThird.metadata);
expect(managedOrg3?.apiKey).toBeDefined();
});
});
it("should create managed organization", async () => {
return request(app.getHttpServer())
@@ -625,7 +708,11 @@ describe("Organizations Organizations Endpoints", () => {
afterAll(async () => {
await userRepositoryFixture.deleteByEmail(managerOrgAdmin.email);
await userRepositoryFixture.deleteByEmail(payPerUserPlanManagerOrgAdmin.email);
await userRepositoryFixture.deleteByEmail(essentialsPlanManagerOrgAdmin.email);
await organizationsRepositoryFixture.delete(managerOrg.id);
await organizationsRepositoryFixture.delete(payPerUserPlanManagerOrg.id);
await organizationsRepositoryFixture.delete(essentialsPlanManagerOrg.id);
await app.close();
});
});
@@ -4,22 +4,44 @@ import { AppModule } from "@/app.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
import { CreateWorkflowDto } from "@/modules/workflows/inputs/create-workflow.input";
import {
CreateEventTypeWorkflowDto,
WorkflowActivationDto,
} from "@/modules/workflows/inputs/create-event-type-workflow.input";
import {
CreateFormWorkflowDto,
WorkflowFormActivationDto,
} from "@/modules/workflows/inputs/create-form-workflow";
import {
ATTENDEE,
REMINDER,
PHONE_NUMBER,
EMAIL,
WorkflowEmailAttendeeStepDto,
WorkflowEmailAddressStepDto,
UpdateEmailAddressWorkflowStepDto,
UpdatePhoneWhatsAppNumberWorkflowStepDto,
} from "@/modules/workflows/inputs/workflow-step.input";
import {
AFTER_EVENT,
BEFORE_EVENT,
DAY,
FORM_SUBMITTED,
FORM_SUBMITTED_NO_EVENT,
OnAfterEventTriggerDto,
OnBeforeEventTriggerDto,
OnFormSubmittedNoEventTriggerDto,
OnFormSubmittedTriggerDto,
} from "@/modules/workflows/inputs/workflow-trigger.input";
import {
GetEventTypeWorkflowOutput,
GetEventTypeWorkflowsOutput,
} from "@/modules/workflows/outputs/event-type-workflow.output";
// Adjust path if needed
import { GetWorkflowOutput, GetWorkflowsOutput } from "@/modules/workflows/outputs/workflow.output";
import {
GetRoutingFormWorkflowOutput,
GetRoutingFormWorkflowsOutput,
} from "@/modules/workflows/outputs/routing-form-workflow.output";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
@@ -51,14 +73,21 @@ describe("OrganizationsTeamsWorkflowsController (E2E)", () => {
let org: Team;
let orgTeam: Team;
let createdWorkflowId: number;
let createdFormWorkflowId: number;
const authEmail = `org-teams-workflows-user-${randomString()}@example.com`;
let user: User;
let apiKeyString: string;
let verifiedPhoneId: number;
let verifiedPhoneId2: number;
let verifiedEmailId: number;
let createdWorkflow: GetWorkflowOutput["data"];
let verifiedEmailId2: number;
let createdWorkflow: GetEventTypeWorkflowOutput["data"];
let createdFormWorkflow: GetRoutingFormWorkflowOutput["data"];
let sampleCreateWorkflowDto: CreateWorkflowDto = {
const emailToVerify = `org-teams-workflows-team-${randomString()}@example.com`;
const phoneToVerify = `+37255556666`;
let sampleCreateEventTypeWorkflowDto = {
name: `E2E Test Workflow ${randomString()}`,
activation: {
isActiveOnAllEventTypes: true,
@@ -72,6 +101,18 @@ describe("OrganizationsTeamsWorkflowsController (E2E)", () => {
},
},
steps: [],
} as unknown as CreateEventTypeWorkflowDto;
let sampleCreateWorkflowRoutingFormDto: CreateFormWorkflowDto = {
name: `E2E Test Workflow ${randomString()}`,
activation: {
activeOnRoutingFormIds: [],
isActiveOnAllRoutingForms: true,
},
trigger: {
type: FORM_SUBMITTED,
},
steps: [],
};
beforeAll(async () => {
@@ -149,15 +190,29 @@ describe("OrganizationsTeamsWorkflowsController (E2E)", () => {
phoneNumber: "+37255555555",
team: { connect: { id: orgTeam.id } },
});
const verifiedPhone2 = await verifiedResourcesRepositoryFixtures.createPhone({
user: { connect: { id: user.id } },
phoneNumber: phoneToVerify,
team: { connect: { id: orgTeam.id } },
});
const verifiedEmail = await verifiedResourcesRepositoryFixtures.createEmail({
user: { connect: { id: user.id } },
email: authEmail,
team: { connect: { id: orgTeam.id } },
});
verifiedEmailId = verifiedEmail.id;
verifiedPhoneId = verifiedPhone.id;
sampleCreateWorkflowDto = {
const verifiedEmail2 = await verifiedResourcesRepositoryFixtures.createEmail({
user: { connect: { id: user.id } },
email: emailToVerify,
team: { connect: { id: orgTeam.id } },
});
verifiedEmailId = verifiedEmail.id;
verifiedEmailId2 = verifiedEmail2.id;
verifiedPhoneId = verifiedPhone.id;
verifiedPhoneId2 = verifiedPhone2.id;
sampleCreateEventTypeWorkflowDto = {
name: `E2E Test Workflow ${randomString()}`,
activation: {
isActiveOnAllEventTypes: true,
@@ -208,6 +263,43 @@ describe("OrganizationsTeamsWorkflowsController (E2E)", () => {
html: "<p>Reminder for your event {EVENT_NAME}.</p>",
},
},
{
stepNumber: 4,
action: "sms_attendee",
recipient: PHONE_NUMBER,
template: REMINDER,
phoneRequired: true,
sender: "CalcomE2EStep4",
message: {
subject: "Upcoming: {EVENT_NAME}",
text: "Reminder for your event {EVENT_NAME}.",
},
},
],
};
sampleCreateWorkflowRoutingFormDto = {
name: `E2E Test Form Workflow ${randomString()}`,
activation: {
isActiveOnAllRoutingForms: true,
activeOnRoutingFormIds: [],
},
trigger: {
type: FORM_SUBMITTED,
},
steps: [
{
stepNumber: 1,
action: "email_attendee",
recipient: ATTENDEE,
template: REMINDER,
sender: "CalcomE2EStep1",
includeCalendarEvent: true,
message: {
subject: "Upcoming: {EVENT_NAME}",
html: "<p>Reminder for your event {EVENT_NAME}.</p>",
},
},
],
};
basePath = `/v2/organizations/${org.id}/teams/${orgTeam.id}/workflows`;
@@ -220,7 +312,9 @@ describe("OrganizationsTeamsWorkflowsController (E2E)", () => {
if (createdWorkflowId) {
try {
await workflowsRepositoryFixture.delete(createdWorkflowId);
} catch (error) {}
} catch {
/* empty */
}
}
await userRepositoryFixture.deleteByEmail(user.email);
@@ -235,18 +329,227 @@ describe("OrganizationsTeamsWorkflowsController (E2E)", () => {
return request(app.getHttpServer())
.post(basePath)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send(sampleCreateWorkflowDto)
.send(sampleCreateEventTypeWorkflowDto)
.expect(201)
.then((response) => {
const responseBody: GetWorkflowOutput = response.body;
const responseBody: GetEventTypeWorkflowOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.name).toEqual(sampleCreateWorkflowDto.name);
expect(responseBody.data.activation.isActiveOnAllEventTypes).toEqual(
sampleCreateWorkflowDto.activation.isActiveOnAllEventTypes
expect(responseBody.data.activation).toBeDefined();
expect(responseBody.data.name).toEqual(sampleCreateEventTypeWorkflowDto.name);
if (responseBody.data.activation instanceof WorkflowActivationDto) {
expect(responseBody.data.activation.isActiveOnAllEventTypes).toEqual(
sampleCreateEventTypeWorkflowDto.activation.isActiveOnAllEventTypes
);
}
if (
responseBody.data.activation instanceof WorkflowFormActivationDto &&
sampleCreateEventTypeWorkflowDto.activation instanceof WorkflowFormActivationDto
) {
expect(responseBody.data.activation.isActiveOnAllRoutingForms).toEqual(
sampleCreateEventTypeWorkflowDto.activation.isActiveOnAllRoutingForms
);
}
expect(responseBody.data.trigger.type).toEqual(sampleCreateEventTypeWorkflowDto.trigger.type);
expect(responseBody.data.steps).toHaveLength(sampleCreateEventTypeWorkflowDto.steps.length);
expect(responseBody.data.steps.find((step) => step.stepNumber === 1)?.id).toBeDefined();
expect(responseBody.data.steps.find((step) => step.stepNumber === 2)?.id).toBeDefined();
expect(responseBody.data.steps.find((step) => step.stepNumber === 3)?.id).toBeDefined();
expect(responseBody.data.steps.find((step) => step.stepNumber === 4)?.id).toBeDefined();
expect(responseBody.data.steps.find((step) => step.stepNumber === 1)?.sender).toEqual(
"CalcomE2EStep1"
);
expect(responseBody.data.trigger.type).toEqual(sampleCreateWorkflowDto.trigger.type);
expect(responseBody.data.steps).toHaveLength(sampleCreateWorkflowDto.steps.length);
expect(responseBody.data.steps.find((step) => step.stepNumber === 1)?.includeCalendarEvent).toEqual(
true
);
expect(responseBody.data.steps.find((step) => step.stepNumber === 2)?.sender).toEqual(
"CalcomE2EStep2"
);
expect(responseBody.data.steps.find((step) => step.stepNumber === 2)?.phone).toEqual(
"+37255555555"
);
expect(responseBody.data.steps.find((step) => step.stepNumber === 4)?.phoneRequired).toEqual(true);
expect(responseBody.data.steps.find((step) => step.stepNumber === 3)?.email).toEqual(authEmail);
const trigger = sampleCreateEventTypeWorkflowDto.trigger as OnBeforeEventTriggerDto;
expect(responseBody.data.trigger?.offset?.value).toEqual(trigger.offset.value);
expect(responseBody.data.trigger?.offset?.unit).toEqual(trigger.offset.unit);
createdWorkflowId = responseBody.data.id;
createdWorkflow = responseBody.data;
expect(responseBody.data.type).toEqual("event-type");
});
});
it("should not create a new routing form workflow with trigger not FORM_SUBMITTED", async () => {
const invalidWorkflow = structuredClone(
sampleCreateWorkflowRoutingFormDto
) as unknown as CreateEventTypeWorkflowDto;
invalidWorkflow.trigger.type = AFTER_EVENT;
return request(app.getHttpServer())
.post(`${basePath}/routing-form`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send(invalidWorkflow)
.expect(400);
});
it("should not create a new routing form workflow with not allowed actions", async () => {
// force impossible step to test validation, should fail with 400
const invalidWorkflow = structuredClone(
sampleCreateWorkflowRoutingFormDto
) as unknown as CreateEventTypeWorkflowDto;
invalidWorkflow.steps = [
{
stepNumber: 1,
action: "sms_number",
recipient: PHONE_NUMBER,
template: REMINDER,
verifiedPhoneId: verifiedPhoneId,
sender: "CalcomE2EStep2",
message: {
subject: "Upcoming: {EVENT_NAME}",
text: "Reminder for your event {EVENT_NAME}.",
},
} as unknown as WorkflowEmailAddressStepDto,
];
return request(app.getHttpServer())
.post(`${basePath}/routing-form`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send(invalidWorkflow)
.expect(400);
});
it("should create a new routing form workflow with allowed actions", async () => {
const validWorkflow = structuredClone(
sampleCreateWorkflowRoutingFormDto
) as unknown as CreateEventTypeWorkflowDto;
validWorkflow.steps = [
{
stepNumber: 1,
action: "email_attendee",
recipient: ATTENDEE,
template: REMINDER,
sender: "CalcomE2EStep1",
includeCalendarEvent: true,
message: {
subject: "Upcoming: {EVENT_NAME}",
html: "<p>Reminder for your event {EVENT_NAME}.</p>",
},
},
];
return request(app.getHttpServer())
.post(`${basePath}/routing-form`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send(validWorkflow)
.expect(201)
.then((response) => {
const responseBody: GetRoutingFormWorkflowOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.name).toEqual(sampleCreateWorkflowRoutingFormDto.name);
expect(responseBody.data.type).toEqual("routing-form");
if (responseBody.data.activation instanceof WorkflowFormActivationDto) {
expect(responseBody.data.activation.isActiveOnAllRoutingForms).toEqual(
sampleCreateWorkflowRoutingFormDto.activation.isActiveOnAllRoutingForms
);
}
expect(responseBody.data.trigger.type).toEqual(sampleCreateWorkflowRoutingFormDto.trigger.type);
expect(responseBody.data.steps).toHaveLength(sampleCreateWorkflowRoutingFormDto.steps.length);
expect(responseBody.data.steps.find((step) => step.stepNumber === 1)?.id).toBeDefined();
expect(responseBody.data.steps.find((step) => step.stepNumber === 1)?.sender).toEqual(
"CalcomE2EStep1"
);
const trigger = sampleCreateWorkflowRoutingFormDto.trigger as OnFormSubmittedTriggerDto;
expect(responseBody.data.trigger?.type).toEqual(trigger.type);
createdFormWorkflowId = responseBody.data.id;
createdFormWorkflow = responseBody.data;
});
});
it("should create a new routing form workflow with allowed actions and offset trigger", async () => {
const validWorkflow = structuredClone(
sampleCreateWorkflowRoutingFormDto
) as unknown as CreateFormWorkflowDto;
validWorkflow.steps = [
{
stepNumber: 1,
action: "email_attendee",
recipient: ATTENDEE,
template: REMINDER,
sender: "CalcomE2EStep1",
includeCalendarEvent: true,
message: {
subject: "Upcoming: {EVENT_NAME}",
html: "<p>Reminder for your event {EVENT_NAME}.</p>",
},
},
];
validWorkflow.trigger = {
type: FORM_SUBMITTED_NO_EVENT,
offset: {
value: 1,
unit: DAY,
},
};
return request(app.getHttpServer())
.post(`${basePath}/routing-form`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send(validWorkflow)
.expect(201)
.then((response) => {
const responseBody: GetRoutingFormWorkflowOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.name).toEqual(sampleCreateWorkflowRoutingFormDto.name);
expect(responseBody.data.type).toEqual("routing-form");
if (responseBody.data.activation instanceof WorkflowFormActivationDto) {
expect(responseBody.data.activation.isActiveOnAllRoutingForms).toEqual(
sampleCreateWorkflowRoutingFormDto.activation.isActiveOnAllRoutingForms
);
}
expect(responseBody.data.trigger.type).toEqual(validWorkflow.trigger.type);
expect(responseBody.data.steps).toHaveLength(sampleCreateWorkflowRoutingFormDto.steps.length);
expect(responseBody.data.steps.find((step) => step.stepNumber === 1)?.id).toBeDefined();
expect(responseBody.data.steps.find((step) => step.stepNumber === 1)?.sender).toEqual(
"CalcomE2EStep1"
);
const trigger = validWorkflow.trigger as OnFormSubmittedNoEventTriggerDto;
expect(responseBody.data.trigger?.type).toEqual(trigger.type);
expect(responseBody.data.trigger?.offset?.unit).toEqual(trigger.offset.unit);
expect(responseBody.data.trigger?.offset?.value).toEqual(trigger.offset.value);
});
});
it("should create a new workflow", async () => {
return request(app.getHttpServer())
.post(basePath)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send({ ...sampleCreateEventTypeWorkflowDto, type: undefined })
.expect(201)
.then((response) => {
const responseBody: GetEventTypeWorkflowOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.name).toEqual(sampleCreateEventTypeWorkflowDto.name);
if (responseBody.data.activation instanceof WorkflowActivationDto) {
expect(responseBody.data.activation.isActiveOnAllEventTypes).toEqual(
sampleCreateEventTypeWorkflowDto.activation.isActiveOnAllEventTypes
);
}
expect(responseBody.data.trigger.type).toEqual(sampleCreateEventTypeWorkflowDto.trigger.type);
expect(responseBody.data.steps).toHaveLength(sampleCreateEventTypeWorkflowDto.steps.length);
expect(responseBody.data.steps.find((step) => step.stepNumber === 1)?.id).toBeDefined();
expect(responseBody.data.steps.find((step) => step.stepNumber === 2)?.id).toBeDefined();
expect(responseBody.data.steps.find((step) => step.stepNumber === 3)?.id).toBeDefined();
@@ -260,7 +563,7 @@ describe("OrganizationsTeamsWorkflowsController (E2E)", () => {
"+37255555555"
);
expect(responseBody.data.steps.find((step) => step.stepNumber === 3)?.email).toEqual(authEmail);
const trigger = sampleCreateWorkflowDto.trigger as OnBeforeEventTriggerDto;
const trigger = sampleCreateEventTypeWorkflowDto.trigger as OnBeforeEventTriggerDto;
expect(responseBody.data.trigger?.offset?.value).toEqual(trigger.offset.value);
expect(responseBody.data.trigger?.offset?.unit).toEqual(trigger.offset.unit);
@@ -269,12 +572,23 @@ describe("OrganizationsTeamsWorkflowsController (E2E)", () => {
});
});
it("should not create a new workflow", async () => {
return request(app.getHttpServer())
.post(basePath)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send({
...sampleCreateEventTypeWorkflowDto,
trigger: { ...sampleCreateEventTypeWorkflowDto, type: "formSubmitted" },
})
.expect(400);
});
it("should return 401 if not authenticated", async () => {
return request(app.getHttpServer()).post(basePath).send(sampleCreateWorkflowDto).expect(401);
return request(app.getHttpServer()).post(basePath).send(sampleCreateEventTypeWorkflowDto).expect(401);
});
it("should return 400 for invalid data (e.g. missing name)", async () => {
const invalidDto = { ...sampleCreateWorkflowDto, name: undefined };
const invalidDto = { ...sampleCreateEventTypeWorkflowDto, name: undefined };
return request(app.getHttpServer())
.post(basePath)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
@@ -284,17 +598,33 @@ describe("OrganizationsTeamsWorkflowsController (E2E)", () => {
});
describe(`GET ${basePath}`, () => {
it("should get a list of workflows for the team", async () => {
it("should get a list of event-type workflows for the team", async () => {
return request(app.getHttpServer())
.get(`${basePath}?skip=0&take=10`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.expect(200)
.then((response) => {
const responseBody: GetWorkflowsOutput = response.body;
const responseBody: GetEventTypeWorkflowsOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeInstanceOf(Array);
expect(responseBody.data.length).toBeGreaterThanOrEqual(1);
expect(responseBody.data.some((wf) => wf.id === createdWorkflowId)).toBe(true);
expect(responseBody.data.every((wf) => wf.type === "event-type")).toBe(true);
});
});
it("should get a list of routing-form workflows for the team", async () => {
return request(app.getHttpServer())
.get(`${basePath}/routing-form?skip=0&take=10`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.expect(200)
.then((response) => {
const responseBody: GetRoutingFormWorkflowsOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeInstanceOf(Array);
expect(responseBody.data.length).toBeGreaterThanOrEqual(1);
expect(responseBody.data.some((wf) => wf.id === createdFormWorkflowId)).toBe(true);
expect(responseBody.data.every((wf) => wf.type === "routing-form")).toBe(true);
});
});
@@ -311,10 +641,26 @@ describe("OrganizationsTeamsWorkflowsController (E2E)", () => {
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.expect(200)
.then((response) => {
const responseBody: GetWorkflowOutput = response.body;
const responseBody: GetEventTypeWorkflowOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.id).toEqual(createdWorkflowId);
expect(responseBody.data.type).toEqual("event-type");
});
});
it("should get a specific routing-form workflow by ID", async () => {
expect(createdWorkflowId).toBeDefined();
return request(app.getHttpServer())
.get(`${basePath}/${createdFormWorkflowId}/routing-form`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.expect(200)
.then((response) => {
const responseBody: GetEventTypeWorkflowOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.id).toEqual(createdFormWorkflowId);
expect(responseBody.data.type).toEqual("routing-form");
});
});
@@ -334,10 +680,12 @@ describe("OrganizationsTeamsWorkflowsController (E2E)", () => {
describe(`PATCH ${basePath}/:workflowId`, () => {
const updatedName = `Updated Workflow Name ${randomString()}`;
it("should update an existing workflow, update the first step and discard other steps", async () => {
const step1 = createdWorkflow.steps.find((step) => step.stepNumber === 1);
expect(step1).toBeDefined();
const partialUpdateDto: Partial<CreateWorkflowDto> = {
it("should update an existing workflow, update the first and second step and discard other steps", async () => {
const step2 = createdWorkflow.steps.find((step) => step.stepNumber === 2);
expect(step2).toBeDefined();
const step3 = createdWorkflow.steps.find((step) => step.stepNumber === 3);
expect(step3).toBeDefined();
const partialUpdateDto: Partial<CreateEventTypeWorkflowDto> = {
name: updatedName,
trigger: {
type: "afterEvent",
@@ -346,7 +694,38 @@ describe("OrganizationsTeamsWorkflowsController (E2E)", () => {
value: 10,
},
},
steps: step1 ? [{ ...step1, sender: "updatedSender" } as WorkflowEmailAttendeeStepDto] : [],
steps:
step3 && step2
? [
{
stepNumber: 1,
id: step3.id,
action: "email_address",
recipient: EMAIL,
template: REMINDER,
verifiedEmailId: verifiedEmailId2,
sender: "updatedSender",
includeCalendarEvent: false,
message: {
subject: "Update Upcoming: {EVENT_NAME}",
html: "<p>Update Reminder for your event {EVENT_NAME}.</p>",
},
} as UpdateEmailAddressWorkflowStepDto,
{
stepNumber: 2,
id: step2.id,
action: "whatsapp_number",
recipient: PHONE_NUMBER,
template: REMINDER,
verifiedPhoneId: verifiedPhoneId2,
sender: "updatedSender",
message: {
subject: "Update Upcoming: {EVENT_NAME}",
text: "Update Reminder for your event {EVENT_NAME}.",
},
} as UpdatePhoneWhatsAppNumberWorkflowStepDto,
]
: [],
};
expect(createdWorkflowId).toBeDefined();
expect(createdWorkflow).toBeDefined();
@@ -356,14 +735,32 @@ describe("OrganizationsTeamsWorkflowsController (E2E)", () => {
.send(partialUpdateDto)
.expect(200)
.then((response) => {
const responseBody: GetWorkflowOutput = response.body;
const responseBody: GetEventTypeWorkflowOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.id).toEqual(createdWorkflowId);
expect(responseBody.data.name).toEqual(updatedName);
step1 && expect(responseBody.data.steps[0].id).toEqual(step1.id);
expect(responseBody.data.steps[0].sender).toEqual("updatedSender");
expect(responseBody.data.steps[1]?.id).toBeUndefined();
if (step3) {
const newStep3 = responseBody.data.steps.find((step) => step.id === step3.id);
expect(newStep3).toBeDefined();
if (newStep3) {
expect(newStep3.sender).toEqual("updatedSender");
expect(newStep3.email).toEqual(emailToVerify);
expect(newStep3.includeCalendarEvent).toEqual(false);
}
}
if (step2) {
const newStep2 = responseBody.data.steps.find((step) => step.id === step2.id);
expect(newStep2).toBeDefined();
if (newStep2) {
expect(responseBody.data.steps[1].sender).toEqual("updatedSender");
expect(responseBody.data.steps[1].phone).toEqual(phoneToVerify);
}
}
// we updated 2 steps, third one should have been discarded
expect(responseBody.data.steps[2]?.id).toBeUndefined();
const trigger = partialUpdateDto.trigger as OnAfterEventTriggerDto;
expect(responseBody.data.trigger?.type).toEqual(trigger.type);
expect(responseBody.data.trigger?.offset?.value).toEqual(trigger.offset.value);
@@ -371,8 +768,107 @@ describe("OrganizationsTeamsWorkflowsController (E2E)", () => {
});
});
it("should not update an existing event-type workflow, trying to use form workflow trigger", async () => {
const step2 = createdWorkflow.steps.find((step) => step.stepNumber === 2);
expect(step2).toBeDefined();
const step3 = createdWorkflow.steps.find((step) => step.stepNumber === 3);
expect(step3).toBeDefined();
const partialUpdateDto = {
name: updatedName,
trigger: {
type: "formSubmitted",
offset: {
unit: "minute",
value: 10,
},
},
};
expect(createdWorkflowId).toBeDefined();
expect(createdWorkflow).toBeDefined();
return request(app.getHttpServer())
.patch(`${basePath}/${createdWorkflowId}`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send(partialUpdateDto)
.expect(400);
});
it("should not update an existing event-type workflow, trying to use routing-form workflow endpoint", async () => {
const partialUpdateDto = {
name: updatedName,
};
expect(createdWorkflowId).toBeDefined();
expect(createdWorkflow).toBeDefined();
return request(app.getHttpServer())
.patch(`${basePath}/${createdWorkflowId}/routing-form`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send(partialUpdateDto)
.expect(404);
});
it("should update an existing routing form workflow, update the first step and discard any other steps", async () => {
const step1 = createdFormWorkflow.steps.find((step) => step.stepNumber === 1);
expect(step1).toBeDefined();
const partialUpdateDto: Partial<CreateFormWorkflowDto> = {
name: updatedName,
trigger: {
type: "formSubmitted",
},
steps: step1
? [
{
stepNumber: 1,
id: step1.id,
action: "email_address",
recipient: EMAIL,
template: REMINDER,
verifiedEmailId: verifiedEmailId2,
sender: "updatedSender",
includeCalendarEvent: true,
message: {
subject: "Update Upcoming: {EVENT_NAME}",
html: "<p>Update Reminder for your event {EVENT_NAME}.</p>",
},
} as UpdateEmailAddressWorkflowStepDto,
]
: [],
};
expect(createdFormWorkflowId).toBeDefined();
expect(createdFormWorkflow).toBeDefined();
return request(app.getHttpServer())
.patch(`${basePath}/${createdFormWorkflowId}/routing-form`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send(partialUpdateDto)
.expect(200)
.then((response) => {
const responseBody: GetRoutingFormWorkflowOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.id).toEqual(createdFormWorkflowId);
expect(responseBody.data.name).toEqual(updatedName);
expect(responseBody.data.activation).toBeDefined();
if (step1) {
const newStep1 = responseBody.data.steps.find((step) => step.id === step1.id);
expect(newStep1).toBeDefined();
if (newStep1) {
expect(newStep1.sender).toEqual("updatedSender");
expect(newStep1.email).toEqual(emailToVerify);
expect(newStep1.includeCalendarEvent).toEqual(true);
}
}
// we updated 1 steps, no more steps should be defined
expect(responseBody.data.steps[1]?.id).toBeUndefined();
const trigger = partialUpdateDto.trigger as OnFormSubmittedTriggerDto;
expect(responseBody.data.trigger?.type).toEqual(trigger.type);
expect(responseBody.data.type).toEqual("routing-form");
});
});
it("should return 404 for updating a non-existent workflow ID", async () => {
const partialUpdateDto: Partial<CreateWorkflowDto> = {
const partialUpdateDto: Partial<CreateEventTypeWorkflowDto> = {
name: updatedName,
steps: [{ ...createdWorkflow.steps[0], sender: "updatedSender" } as WorkflowEmailAttendeeStepDto],
};
@@ -384,7 +880,7 @@ describe("OrganizationsTeamsWorkflowsController (E2E)", () => {
});
it("should return 401 if not authenticated", async () => {
const partialUpdateDto: Partial<CreateWorkflowDto> = {
const partialUpdateDto: Partial<CreateEventTypeWorkflowDto> = {
name: updatedName,
steps: [{ ...createdWorkflow.steps[0], sender: "updatedSender" } as WorkflowEmailAttendeeStepDto],
};
@@ -403,11 +899,11 @@ describe("OrganizationsTeamsWorkflowsController (E2E)", () => {
const res = await request(app.getHttpServer())
.post(basePath)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send({ ...sampleCreateWorkflowDto, name: `Workflow To Delete ${randomString()}` });
.send({ ...sampleCreateEventTypeWorkflowDto, name: `Workflow To Delete ${randomString()}` });
workflowToDeleteId = res.body.data.id;
});
it("should delete an existing workflow", async () => {
it("should delete an existing event-type workflow", async () => {
return request(app.getHttpServer())
.delete(`${basePath}/${workflowToDeleteId}`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
@@ -417,6 +913,16 @@ describe("OrganizationsTeamsWorkflowsController (E2E)", () => {
});
});
it("should delete an existing routing-form workflow", async () => {
return request(app.getHttpServer())
.delete(`${basePath}/${createdFormWorkflowId}/routing-form`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.expect(200)
.then((response) => {
expect(response.body.status).toEqual(SUCCESS_STATUS);
});
});
it("should return 404 when trying to delete a non-existent workflow ID", async () => {
return request(app.getHttpServer())
.delete(`${basePath}/999999`)
@@ -13,12 +13,23 @@ import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-a
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
import { IsWorkflowInTeam } from "@/modules/auth/guards/workflows/is-workflow-in-team";
import { IsEventTypeWorkflowInTeam } from "@/modules/auth/guards/workflows/is-event-type-workflow-in-team";
import { IsRoutingFormWorkflowInTeam } from "@/modules/auth/guards/workflows/is-routing-form-workflow-in-team";
import { UserWithProfile } from "@/modules/users/users.repository";
import { CreateWorkflowDto } from "@/modules/workflows/inputs/create-workflow.input";
import { UpdateWorkflowDto } from "@/modules/workflows/inputs/update-workflow.input";
import { GetWorkflowOutput, GetWorkflowsOutput } from "@/modules/workflows/outputs/workflow.output";
import { TeamWorkflowsService } from "@/modules/workflows/services/team-workflows.service";
import { CreateEventTypeWorkflowDto } from "@/modules/workflows/inputs/create-event-type-workflow.input";
import { CreateFormWorkflowDto } from "@/modules/workflows/inputs/create-form-workflow";
import { UpdateEventTypeWorkflowDto } from "@/modules/workflows/inputs/update-event-type-workflow.input";
import { UpdateFormWorkflowDto } from "@/modules/workflows/inputs/update-form-workflow.input";
import {
GetEventTypeWorkflowsOutput,
GetEventTypeWorkflowOutput,
} from "@/modules/workflows/outputs/event-type-workflow.output";
import {
GetRoutingFormWorkflowOutput,
GetRoutingFormWorkflowsOutput,
} from "@/modules/workflows/outputs/routing-form-workflow.output";
import { TeamEventTypeWorkflowsService } from "@/modules/workflows/services/team-event-type-workflows.service";
import { TeamRoutingFormWorkflowsService } from "@/modules/workflows/services/team-routing-form-workflows.service";
import {
Controller,
Get,
@@ -46,7 +57,10 @@ import { SkipTakePagination } from "@calcom/platform-types";
@ApiHeader(OPTIONAL_X_CAL_SECRET_KEY_HEADER)
@ApiHeader(OPTIONAL_API_KEY_HEADER)
export class OrganizationTeamWorkflowsController {
constructor(private readonly workflowsService: TeamWorkflowsService) {}
constructor(
private readonly eventTypeWorkflowsService: TeamEventTypeWorkflowsService,
private readonly routingFormWorkflowsService: TeamRoutingFormWorkflowsService
) {}
@Get("/")
@ApiOperation({ summary: "Get organization team workflows" })
@@ -56,43 +70,89 @@ export class OrganizationTeamWorkflowsController {
@Param("orgId", ParseIntPipe) orgId: number,
@Param("teamId", ParseIntPipe) teamId: number,
@Query() queryParams: SkipTakePagination
): Promise<GetWorkflowsOutput> {
): Promise<GetEventTypeWorkflowsOutput> {
const { skip, take } = queryParams;
const workflows = await this.workflowsService.getTeamWorkflows(teamId, skip, take);
const workflows = await this.eventTypeWorkflowsService.getEventTypeTeamWorkflows(teamId, skip, take);
return { data: workflows, status: SUCCESS_STATUS };
}
@Get("/routing-form")
@ApiOperation({ summary: "Get organization team workflows" })
@Roles("TEAM_ADMIN")
@PlatformPlan("SCALE")
async getRoutingFormWorkflows(
@Param("orgId", ParseIntPipe) orgId: number,
@Param("teamId", ParseIntPipe) teamId: number,
@Query() queryParams: SkipTakePagination
): Promise<GetRoutingFormWorkflowsOutput> {
const { skip, take } = queryParams;
const workflows = await this.routingFormWorkflowsService.getRoutingFormTeamWorkflows(teamId, skip, take);
return { data: workflows, status: SUCCESS_STATUS };
}
@Get("/:workflowId")
@UseGuards(IsWorkflowInTeam)
@UseGuards(IsEventTypeWorkflowInTeam)
@ApiOperation({ summary: "Get organization team workflow" })
@Roles("TEAM_ADMIN")
@PlatformPlan("SCALE")
async getWorkflowById(
@Param("teamId", ParseIntPipe) teamId: number,
@Param("workflowId", ParseIntPipe) workflowId: number
): Promise<GetWorkflowOutput> {
const workflow = await this.workflowsService.getTeamWorkflowById(teamId, workflowId);
): Promise<GetEventTypeWorkflowOutput> {
const workflow = await this.eventTypeWorkflowsService.getEventTypeTeamWorkflowById(teamId, workflowId);
return { data: workflow, status: SUCCESS_STATUS };
}
@Get("/:workflowId/routing-form")
@UseGuards(IsRoutingFormWorkflowInTeam)
@ApiOperation({ summary: "Get organization team workflow" })
@Roles("TEAM_ADMIN")
@PlatformPlan("SCALE")
async getRoutingFormWorkflowById(
@Param("teamId", ParseIntPipe) teamId: number,
@Param("workflowId", ParseIntPipe) workflowId: number
): Promise<GetRoutingFormWorkflowOutput> {
const workflow = await this.routingFormWorkflowsService.getRoutingFormTeamWorkflowById(
teamId,
workflowId
);
return { data: workflow, status: SUCCESS_STATUS };
}
@Post("/")
@ApiOperation({ summary: "Create organization team workflow" })
@ApiOperation({ summary: "Create organization team workflow for event-types" })
@Roles("TEAM_ADMIN")
@PlatformPlan("SCALE")
async createWorkflow(
async createEventTypeWorkflow(
@GetUser() user: UserWithProfile,
@Param("teamId", ParseIntPipe) teamId: number,
@Body() data: CreateWorkflowDto
): Promise<GetWorkflowOutput> {
const workflow = await this.workflowsService.createTeamWorkflow(user, teamId, data);
@Body() data: CreateEventTypeWorkflowDto
): Promise<GetEventTypeWorkflowOutput> {
const workflow = await this.eventTypeWorkflowsService.createEventTypeTeamWorkflow(user, teamId, data);
return { data: workflow, status: SUCCESS_STATUS };
}
@Post("/routing-form")
@ApiOperation({ summary: "Create organization team workflow for routing-forms" })
@Roles("TEAM_ADMIN")
@PlatformPlan("SCALE")
async createFormWorkflow(
@GetUser() user: UserWithProfile,
@Param("teamId", ParseIntPipe) teamId: number,
@Body() data: CreateFormWorkflowDto
): Promise<GetRoutingFormWorkflowOutput> {
const workflow = await this.routingFormWorkflowsService.createFormTeamWorkflow(user, teamId, data);
return { data: workflow, status: SUCCESS_STATUS };
}
@Patch("/:workflowId")
@UseGuards(IsWorkflowInTeam)
@UseGuards(IsEventTypeWorkflowInTeam)
@ApiOperation({ summary: "Update organization team workflow" })
@Roles("TEAM_ADMIN")
@PlatformPlan("SCALE")
@@ -100,14 +160,39 @@ export class OrganizationTeamWorkflowsController {
@Param("teamId", ParseIntPipe) teamId: number,
@Param("workflowId", ParseIntPipe) workflowId: number,
@GetUser() user: UserWithProfile,
@Body() data: UpdateWorkflowDto
): Promise<GetWorkflowOutput> {
const workflow = await this.workflowsService.updateTeamWorkflow(user, teamId, workflowId, data);
@Body() data: UpdateEventTypeWorkflowDto
): Promise<GetEventTypeWorkflowOutput> {
const workflow = await this.eventTypeWorkflowsService.updateEventTypeTeamWorkflow(
user,
teamId,
workflowId,
data
);
return { data: workflow, status: SUCCESS_STATUS };
}
@Patch("/:workflowId/routing-form")
@UseGuards(IsRoutingFormWorkflowInTeam)
@ApiOperation({ summary: "Update organization routing form team workflow" })
@Roles("TEAM_ADMIN")
@PlatformPlan("SCALE")
async updateRoutingFormWorkflow(
@Param("teamId", ParseIntPipe) teamId: number,
@Param("workflowId", ParseIntPipe) workflowId: number,
@GetUser() user: UserWithProfile,
@Body() data: UpdateFormWorkflowDto
): Promise<GetRoutingFormWorkflowOutput> {
const workflow = await this.routingFormWorkflowsService.updateFormTeamWorkflow(
user,
teamId,
workflowId,
data
);
return { data: workflow, status: SUCCESS_STATUS };
}
@Delete("/:workflowId")
@UseGuards(IsWorkflowInTeam)
@UseGuards(IsEventTypeWorkflowInTeam)
@ApiOperation({ summary: "Delete organization team workflow" })
@Roles("TEAM_ADMIN")
@PlatformPlan("SCALE")
@@ -115,7 +200,20 @@ export class OrganizationTeamWorkflowsController {
@Param("teamId", ParseIntPipe) teamId: number,
@Param("workflowId") workflowId: number
): Promise<{ status: typeof SUCCESS_STATUS }> {
await this.workflowsService.deleteTeamWorkflow(teamId, workflowId);
await this.eventTypeWorkflowsService.deleteTeamEventTypeWorkflow(teamId, workflowId);
return { status: SUCCESS_STATUS };
}
@Delete("/:workflowId/routing-form")
@UseGuards(IsRoutingFormWorkflowInTeam)
@ApiOperation({ summary: "Delete organization team routing-form workflow" })
@Roles("TEAM_ADMIN")
@PlatformPlan("SCALE")
async deleteRoutingFormWorkflow(
@Param("teamId", ParseIntPipe) teamId: number,
@Param("workflowId") workflowId: number
): Promise<{ status: typeof SUCCESS_STATUS }> {
await this.routingFormWorkflowsService.deleteTeamRoutingFormWorkflow(teamId, workflowId);
return { status: SUCCESS_STATUS };
}
}
@@ -351,6 +351,7 @@ describe("Organizations Users Endpoints", () => {
email: `organizations-users-new-member-${randomString()}@api.com`,
bio,
metadata,
timeZone: "Europe/Rome",
};
const emailSpy = jest
@@ -367,6 +368,7 @@ describe("Organizations Users Endpoints", () => {
expect(userData.email).toBe(newOrgUser.email);
expect(userData.bio).toBe(newOrgUser.bio);
expect(userData.metadata).toEqual(newOrgUser.metadata);
expect(userData.timeZone).toBe(newOrgUser.timeZone);
expect(emailSpy).toHaveBeenCalledWith({
usernameOrEmail: newOrgUser.email,
orgName: org.name,
@@ -25,7 +25,12 @@ import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { randomString } from "test/utils/randomString";
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_09_04 } from "@calcom/platform-constants";
import {
CAL_API_VERSION_HEADER,
SUCCESS_STATUS,
VERSION_2024_09_04,
ERROR_STATUS,
} from "@calcom/platform-constants";
import type {
CreateScheduleInput_2024_06_11,
ReserveSlotOutput_2024_09_04 as ReserveSlotOutputData_2024_09_04,
@@ -48,6 +53,7 @@ describe("Slots 2024-09-04 Endpoints", () => {
const teammateEmailOne = `slots-2024-09-04-user-1-team-slots-${randomString()}`;
let teammateApiKeyString: string;
const teammateEmailTwo = `slots-2024-09-04-user-2-team-slots-${randomString()}`;
let teammateTwoApiKeyString: string;
const outsiderEmail = `slots-2024-09-04-unrelated-team-slots-${randomString()}`;
let outsider: User;
@@ -61,6 +67,8 @@ describe("Slots 2024-09-04 Endpoints", () => {
let collectiveEventTypeSlug: string;
let collectiveEventTypeWithoutHostsId: number;
let roundRobinEventTypeId: number;
let roundRobinEventTypeWithoutFixedHostsId: number;
let roundRobinEventTypeWithFixedAndNonFixedHostsId: number;
let collectiveBookingId: number;
let roundRobinBookingId: number;
let fullyBookedRoundRobinBookingIdOne: number;
@@ -115,6 +123,12 @@ describe("Slots 2024-09-04 Endpoints", () => {
const { keyString } = await apiKeysRepositoryFixture.createApiKey(teammateOne.id, null);
teammateApiKeyString = keyString;
const { keyString: keyStringForTeammateTwo } = await apiKeysRepositoryFixture.createApiKey(
teammateTwo.id,
null
);
teammateTwoApiKeyString = keyStringForTeammateTwo;
const { keyString: unrelatedUserKeyString } = await apiKeysRepositoryFixture.createApiKey(
outsider.id,
null
@@ -217,6 +231,67 @@ describe("Slots 2024-09-04 Endpoints", () => {
});
roundRobinEventTypeId = roundRobinEventType.id;
const roundRobinEventTypeWithoutFixedHosts = await eventTypesRepositoryFixture.createTeamEventType({
schedulingType: "ROUND_ROBIN",
team: {
connect: { id: team.id },
},
title: "RR Event Type Without Fixed Hosts",
slug: `slots-2024-09-04-round-robin-event-type-${randomString()}`,
length: 60,
assignAllTeamMembers: true,
bookingFields: [],
locations: [],
users: {
connect: [{ id: teammateOne.id }, { id: teammateTwo.id }],
},
hosts: {
create: [
{
userId: teammateOne.id,
isFixed: false,
},
{
userId: teammateTwo.id,
isFixed: false,
},
],
},
});
roundRobinEventTypeWithoutFixedHostsId = roundRobinEventTypeWithoutFixedHosts.id;
const roundRobinEventTypeWithFixedAndNonFixedHosts =
await eventTypesRepositoryFixture.createTeamEventType({
schedulingType: "ROUND_ROBIN",
team: {
connect: { id: team.id },
},
title: "RR Event Type With Fixed and Non-Fixed Hosts",
slug: `slots-2024-09-04-round-robin-event-type-${randomString()}`,
length: 60,
assignAllTeamMembers: true,
bookingFields: [],
locations: [],
users: {
connect: [{ id: teammateOne.id }, { id: teammateTwo.id }],
},
hosts: {
create: [
{
userId: teammateOne.id,
isFixed: true,
},
{
userId: teammateTwo.id,
isFixed: false,
},
],
},
});
roundRobinEventTypeWithFixedAndNonFixedHostsId = roundRobinEventTypeWithFixedAndNonFixedHosts.id;
const userSchedule: CreateScheduleInput_2024_06_11 = {
name: "working time",
timeZone: "Europe/Rome",
@@ -541,6 +616,135 @@ describe("Slots 2024-09-04 Endpoints", () => {
bookingsRepositoryFixture.deleteById(bookingTwo.id);
});
it("should reserve all available slots for round robin event type with non-fixed hosts", async () => {
const now = "2049-09-05T12:00:00.000Z";
const newDate = DateTime.fromISO(now, { zone: "UTC" }).toJSDate();
advanceTo(newDate);
const slotStartTime = "2050-09-05T10:00:00.000Z";
const reserveResponseOne = await request(app.getHttpServer())
.post(`/v2/slots/reservations`)
.set({ Authorization: `Bearer cal_test_${teammateApiKeyString}` })
.send({
eventTypeId: roundRobinEventTypeWithoutFixedHostsId,
slotStart: slotStartTime,
reservationDuration: 10,
})
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
.expect(201);
const reserveResponseOneBody: ReserveSlotOutputResponse_2024_09_04 = reserveResponseOne.body;
expect(reserveResponseOneBody.status).toEqual(SUCCESS_STATUS);
const responseReservedSlotOne: ReserveSlotOutputData_2024_09_04 = reserveResponseOneBody.data;
expect(responseReservedSlotOne.reservationUid).toBeDefined();
if (!responseReservedSlotOne.reservationUid) {
throw new Error("Reserved slot one uid is undefined");
}
const dbSlotOne = await selectedSlotRepositoryFixture.getByUid(responseReservedSlotOne.reservationUid);
expect(dbSlotOne).toBeDefined();
if (dbSlotOne) {
const dbReleaseAt = DateTime.fromJSDate(dbSlotOne.releaseAt, { zone: "UTC" }).toISO();
const expectedReleaseAt = DateTime.fromISO(now, { zone: "UTC" }).plus({ minutes: 10 }).toISO();
expect(dbReleaseAt).toEqual(expectedReleaseAt);
}
const reserveResponseTwo = await request(app.getHttpServer())
.post(`/v2/slots/reservations`)
.set({ Authorization: `Bearer cal_test_${teammateTwoApiKeyString}` })
.send({
eventTypeId: roundRobinEventTypeWithoutFixedHostsId,
slotStart: slotStartTime,
reservationDuration: 10,
})
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
.expect(201);
const reserveResponseTwoBody: ReserveSlotOutputResponse_2024_09_04 = reserveResponseTwo.body;
expect(reserveResponseTwoBody.status).toEqual(SUCCESS_STATUS);
const responseReservedSlotTwo: ReserveSlotOutputData_2024_09_04 = reserveResponseTwoBody.data;
expect(responseReservedSlotTwo.reservationUid).toBeDefined();
if (!responseReservedSlotTwo.reservationUid) {
throw new Error("Reserved slot two uid is undefined");
}
const dbSlotTwo = await selectedSlotRepositoryFixture.getByUid(responseReservedSlotTwo.reservationUid);
expect(dbSlotTwo).toBeDefined();
if (dbSlotTwo) {
const dbReleaseAt = DateTime.fromJSDate(dbSlotTwo.releaseAt, { zone: "UTC" }).toISO();
const expectedReleaseAt = DateTime.fromISO(now, { zone: "UTC" }).plus({ minutes: 10 }).toISO();
expect(dbReleaseAt).toEqual(expectedReleaseAt);
}
const reserveResponseThree = await request(app.getHttpServer())
.post(`/v2/slots/reservations`)
.set({ Authorization: `Bearer cal_test_${outsiderApiKeyString}` })
.send({
eventTypeId: roundRobinEventTypeWithoutFixedHostsId,
slotStart: slotStartTime,
reservationDuration: 10,
})
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04);
expect(reserveResponseThree.status).toEqual(403);
expect(reserveResponseThree.body.status).toEqual(ERROR_STATUS);
await selectedSlotRepositoryFixture.deleteByUId(responseReservedSlotOne.reservationUid);
await selectedSlotRepositoryFixture.deleteByUId(responseReservedSlotTwo.reservationUid);
clear();
});
it("should reserve available slot for round robin event type with fixed and non-fixed hosts and should not be able to reserve another slot", async () => {
const now = "2049-09-05T12:00:00.000Z";
const newDate = DateTime.fromISO(now, { zone: "UTC" }).toJSDate();
advanceTo(newDate);
const slotStartTime = "2050-09-05T10:00:00.000Z";
const reserveResponseOne = await request(app.getHttpServer())
.post(`/v2/slots/reservations`)
.set({ Authorization: `Bearer cal_test_${teammateApiKeyString}` })
.send({
eventTypeId: roundRobinEventTypeWithFixedAndNonFixedHostsId,
slotStart: slotStartTime,
reservationDuration: 10,
})
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
.expect(201);
const reserveResponseBodyOne: ReserveSlotOutputResponse_2024_09_04 = reserveResponseOne.body;
expect(reserveResponseBodyOne.status).toEqual(SUCCESS_STATUS);
const responseReservedSlotOne: ReserveSlotOutputData_2024_09_04 = reserveResponseBodyOne.data;
expect(responseReservedSlotOne.reservationUid).toBeDefined();
if (!responseReservedSlotOne.reservationUid) {
throw new Error("Reserved slot uid is undefined");
}
const dbSlotOne = await selectedSlotRepositoryFixture.getByUid(responseReservedSlotOne.reservationUid);
expect(dbSlotOne).toBeDefined();
if (dbSlotOne) {
const dbReleaseAt = DateTime.fromJSDate(dbSlotOne.releaseAt, { zone: "UTC" }).toISO();
const expectedReleaseAt = DateTime.fromISO(now, { zone: "UTC" }).plus({ minutes: 10 }).toISO();
expect(dbReleaseAt).toEqual(expectedReleaseAt);
}
const reserveResponseTwo = await request(app.getHttpServer())
.post(`/v2/slots/reservations`)
.send({
eventTypeId: roundRobinEventTypeWithFixedAndNonFixedHostsId,
slotStart: slotStartTime,
})
.set({ Authorization: `Bearer cal_test_${teammateTwoApiKeyString}` })
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04);
expect(reserveResponseTwo.status).toEqual(422);
expect(reserveResponseTwo.body.status).toEqual(ERROR_STATUS);
await selectedSlotRepositoryFixture.deleteByUId(responseReservedSlotOne.reservationUid);
clear();
});
afterAll(async () => {
await userRepositoryFixture.deleteByEmail(teammateOne.email);
await userRepositoryFixture.deleteByEmail(teammateTwo.email);
@@ -48,7 +48,7 @@ import { ApiResponse } from "@calcom/platform-types";
@DocsTags("Slots")
@ApiHeader({
name: "cal-api-version",
description: `Must be set to ${VERSION_2024_09_04}`,
description: `Must be set to ${VERSION_2024_09_04}. If not set to this value, the endpoint will default to an older version.`,
example: VERSION_2024_09_04,
required: true,
schema: {
@@ -86,6 +86,8 @@ export class SlotsController_2024_09_04 {
- duration: Only use for event types that allow multiple durations or for dynamic event types. If not passed for multiple duration event types defaults to default duration. For dynamic event types defaults to 30 aka each returned slot is 30 minutes long. So duration=60 means that returned slots will be each 60 minutes long.
- format: Format of the slots. By default return is an object where each key is date and value is array of slots as string. If you want to get start and end of each slot use "range" as value.
- bookingUidToReschedule: When rescheduling an existing booking, provide the booking's unique identifier to exclude its time slot from busy time calculations. This ensures the original booking time appears as available for rescheduling.
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
`,
})
@ApiQuery({
@@ -256,7 +258,10 @@ export class SlotsController_2024_09_04 {
@ApiOperation({
summary: "Reserve a slot",
description: `Make a slot not available for others to book for a certain period of time. If you authenticate using oAuth credentials, api key or access token
then you can also specify custom duration for how long the slot should be reserved for (defaults to 5 minutes).`,
then you can also specify custom duration for how long the slot should be reserved for (defaults to 5 minutes).
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
`,
})
@ApiHeader(OPTIONAL_X_CAL_CLIENT_ID_HEADER)
@ApiHeader(OPTIONAL_X_CAL_CLIENT_ID_HEADER)
@@ -278,6 +283,7 @@ export class SlotsController_2024_09_04 {
@Get("/reservations/:uid")
@ApiOperation({
summary: "Get reserved slot",
description: `<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>`,
})
async getReservedSlot(@Param("uid") uid: string): Promise<GetReservedSlotOutput_2024_09_04> {
const reservedSlot = await this.slotsService.getReservedSlot(uid);
@@ -293,6 +299,7 @@ export class SlotsController_2024_09_04 {
@Patch("/reservations/:uid")
@ApiOperation({
summary: "Update a reserved slot",
description: `<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>`,
})
@HttpCode(HttpStatus.OK)
async updateReservedSlot(
@@ -312,6 +319,7 @@ export class SlotsController_2024_09_04 {
@Delete("/reservations/:uid")
@ApiOperation({
summary: "Delete a reserved slot",
description: `<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>`,
})
@HttpCode(HttpStatus.OK)
@DocsResponse({
@@ -23,6 +23,8 @@ import { DateTime } from "luxon";
import { z } from "zod";
import { SlotFormat } from "@calcom/platform-enums";
import { SchedulingType } from "@calcom/platform-libraries";
import { validateRoundRobinSlotAvailability } from "@calcom/platform-libraries/slots";
import type {
GetSlotsInput_2024_09_04,
GetSlotsInputWithRouting_2024_09_04,
@@ -147,13 +149,26 @@ export class SlotsService_2024_09_04 {
}
const nonSeatedEventAlreadyBooked = !eventType.seatsPerTimeSlot && booking;
if (nonSeatedEventAlreadyBooked) {
const isRoundRobinEvent = eventType.schedulingType === SchedulingType.ROUND_ROBIN;
if (nonSeatedEventAlreadyBooked && !isRoundRobinEvent) {
throw new UnprocessableEntityException(`Can't reserve a slot if the event is already booked.`);
}
const reservationDuration = input.reservationDuration ?? DEFAULT_RESERVATION_DURATION;
if (isRoundRobinEvent) {
try {
await validateRoundRobinSlotAvailability(input.eventTypeId, startDate, endDate, eventType.hosts);
} catch (error) {
if (error instanceof Error) {
throw new UnprocessableEntityException(error?.message);
}
throw error;
}
} else {
await this.checkSlotOverlap(input.eventTypeId, startDate.toISO(), endDate.toISO());
}
await this.checkSlotOverlap(input.eventTypeId, startDate.toISO(), endDate.toISO());
const reservationDuration = input.reservationDuration ?? DEFAULT_RESERVATION_DURATION;
if (eventType.userId) {
const slot = await this.slotsRepository.createSlot(
@@ -328,6 +328,131 @@ describe("Teams Memberships Endpoints", () => {
return request(app.getHttpServer()).get(`/v2/teams/${team.id}/memberships/123132145`).expect(404);
});
it("should filter memberships by single email", async () => {
return request(app.getHttpServer())
.get(`/v2/teams/${team.id}/memberships?emails=${teamAdminEmail}`)
.expect(200)
.then((response) => {
const responseBody: GetTeamMembershipsOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data.length).toEqual(1);
expect(responseBody.data[0].user.email).toEqual(teamAdminEmail);
expect(responseBody.data[0].userId).toEqual(teamAdmin.id);
expect(responseBody.data[0].role).toEqual("ADMIN");
});
});
it("should filter memberships by multiple emails", async () => {
return request(app.getHttpServer())
.get(`/v2/teams/${team.id}/memberships?emails=${teamAdminEmail},${teamMemberEmail}`)
.expect(200)
.then((response) => {
const responseBody: GetTeamMembershipsOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data.length).toEqual(2);
const emails = responseBody.data.map((membership) => membership.user.email);
expect(emails).toContain(teamAdminEmail);
expect(emails).toContain(teamMemberEmail);
const adminMembership = responseBody.data.find((m) => m.user.email === teamAdminEmail);
const memberMembership = responseBody.data.find((m) => m.user.email === teamMemberEmail);
expect(adminMembership).toBeDefined();
expect(memberMembership).toBeDefined();
expect(adminMembership?.role).toEqual("ADMIN");
expect(memberMembership?.role).toEqual("MEMBER");
});
});
it("should return empty array when filtering by non-existent email", async () => {
const nonExistentEmail = `nonexistent-${randomString()}@test.com`;
return request(app.getHttpServer())
.get(`/v2/teams/${team.id}/memberships?emails=${nonExistentEmail}`)
.expect(200)
.then((response) => {
const responseBody: GetTeamMembershipsOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data.length).toEqual(0);
});
});
it("should return partial results when filtering by mix of existing and non-existent emails", async () => {
const nonExistentEmail = `nonexistent-${randomString()}@test.com`;
return request(app.getHttpServer())
.get(`/v2/teams/${team.id}/memberships?emails=${teamAdminEmail},${nonExistentEmail}`)
.expect(200)
.then((response) => {
const responseBody: GetTeamMembershipsOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data.length).toEqual(1);
expect(responseBody.data[0].user.email).toEqual(teamAdminEmail);
});
});
it("should work with pagination and email filtering combined", async () => {
return request(app.getHttpServer())
.get(`/v2/teams/${team.id}/memberships?emails=${teamAdminEmail},${teamMemberEmail}&skip=1&take=1`)
.expect(200)
.then((response) => {
const responseBody: GetTeamMembershipsOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data.length).toEqual(1);
const returnedEmail = responseBody.data[0].user.email;
expect([teamAdminEmail, teamMemberEmail]).toContain(returnedEmail);
});
});
it("should handle empty emails array gracefully", async () => {
return request(app.getHttpServer())
.get(`/v2/teams/${team.id}/memberships?emails=`)
.expect(200)
.then((response) => {
const responseBody: GetTeamMembershipsOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data.length).toEqual(2);
});
});
it("should handle URL encoded email addresses in filter", async () => {
const encodedEmail = encodeURIComponent(teamAdminEmail);
return request(app.getHttpServer())
.get(`/v2/teams/${team.id}/memberships?emails=${encodedEmail}`)
.expect(200)
.then((response) => {
const responseBody: GetTeamMembershipsOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data.length).toEqual(1);
expect(responseBody.data[0].user.email).toEqual(teamAdminEmail);
});
});
it("should filter by email and maintain all user properties", async () => {
return request(app.getHttpServer())
.get(`/v2/teams/${team.id}/memberships?emails=${teamMemberEmail}`)
.expect(200)
.then((response) => {
const responseBody: GetTeamMembershipsOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data.length).toEqual(1);
const membership = responseBody.data[0];
expect(membership.user.email).toEqual(teamMemberEmail);
expect(membership.user.bio).toEqual(teamMember.bio);
expect(membership.user.metadata).toEqual(teamMember.metadata);
expect(membership.user.username).toEqual(teamMember.username);
expect(membership.teamId).toEqual(team.id);
expect(membership.userId).toEqual(teamMember.id);
expect(membership.role).toEqual("MEMBER");
});
});
it("should validate email array size limits", async () => {
const tooManyEmails = Array.from({ length: 21 }, (_, i) => `test${i}@example.com`).join(",");
return request(app.getHttpServer())
.get(`/v2/teams/${team.id}/memberships?emails=${tooManyEmails}`)
.expect(400);
});
afterAll(async () => {
await userRepositoryFixture.deleteByEmail(teamAdmin.email);
await userRepositoryFixture.deleteByEmail(teammateInvitedViaApi.email);
@@ -4,6 +4,7 @@ import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { CreateTeamMembershipInput } from "@/modules/teams/memberships/inputs/create-team-membership.input";
import { GetTeamMembershipsInput } from "@/modules/teams/memberships/inputs/get-team-memberships.input";
import { UpdateTeamMembershipInput } from "@/modules/teams/memberships/inputs/update-team-membership.input";
import { CreateTeamMembershipOutput } from "@/modules/teams/memberships/outputs/create-team-membership.output";
import { DeleteTeamMembershipOutput } from "@/modules/teams/memberships/outputs/delete-team-membership.output";
@@ -32,7 +33,6 @@ import { plainToClass } from "class-transformer";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { updateNewTeamMemberEventTypes } from "@calcom/platform-libraries/event-types";
import { SkipTakePagination } from "@calcom/platform-types";
@Controller({
path: "/v2/teams/:teamId/memberships",
@@ -85,16 +85,20 @@ export class TeamsMembershipsController {
}
@Get("/")
@ApiOperation({ summary: "Get all memberships" })
@ApiOperation({
summary: "Get all memberships",
description: "Retrieve team memberships with optional filtering by email addresses. Supports pagination.",
})
@Roles("TEAM_ADMIN")
@HttpCode(HttpStatus.OK)
async getTeamMemberships(
@Param("teamId", ParseIntPipe) teamId: number,
@Query() queryParams: SkipTakePagination
@Query() queryParams: GetTeamMembershipsInput
): Promise<GetTeamMembershipsOutput> {
const { skip, take } = queryParams;
const { skip, take, emails } = queryParams;
const orgTeamMemberships = await this.teamsMembershipsService.getPaginatedTeamMemberships(
teamId,
emails,
skip ?? 0,
take ?? 250
);
@@ -0,0 +1,33 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { Transform } from "class-transformer";
import { ArrayMaxSize, ArrayNotEmpty, IsEmail, IsOptional } from "class-validator";
import { SkipTakePagination } from "@calcom/platform-types";
export class GetTeamMembershipsInput extends SkipTakePagination {
@IsOptional()
@Transform(({ value }) => {
if (value == null) return undefined;
const rawValues = (Array.isArray(value) ? value : [value]).flatMap((entry) =>
typeof entry === "string" ? entry.split(",") : []
);
const normalized = rawValues
.map((email) => email.trim())
.filter((email) => email.length > 0)
.map((email) => email.toLowerCase());
const deduplicated = [...new Set(normalized)];
return deduplicated.length > 0 ? deduplicated : undefined;
})
@ArrayNotEmpty({ message: "emails cannot be empty." })
@ArrayMaxSize(20, {
message: "emails array cannot contain more than 20 email addresses for team membership filtering",
})
@IsEmail({}, { each: true, message: "Each email must be a valid email address" })
@ApiPropertyOptional({
type: [String],
description:
"Filter team memberships by email addresses. If you want to filter by multiple emails, separate them with a comma (max 20 emails for performance).",
example: "?emails=user1@example.com,user2@example.com",
})
emails?: string[];
}
@@ -23,13 +23,15 @@ export class TeamsMembershipsService {
return teamMembership;
}
async getPaginatedTeamMemberships(teamId: number, skip = 0, take = 250) {
const teamMemberships = await this.teamsMembershipsRepository.findTeamMembershipsPaginated(
async getPaginatedTeamMemberships(teamId: number, emails?: string[], skip = 0, take = 250) {
const emailArray = !emails ? [] : emails;
return await this.teamsMembershipsRepository.findTeamMembershipsPaginatedWithFilters(
teamId,
{ emails: emailArray },
skip,
take
);
return teamMemberships;
}
async getTeamMembership(teamId: number, membershipId: number) {
@@ -6,6 +6,10 @@ import { Injectable } from "@nestjs/common";
import type { Prisma } from "@calcom/prisma/client";
export interface TeamMembershipFilters {
emails?: string[];
}
export const MembershipUserSelect: Prisma.UserSelect = {
username: true,
email: true,
@@ -41,6 +45,30 @@ export class TeamsMembershipsRepository {
});
}
async findTeamMembershipsPaginatedWithFilters(
teamId: number,
filters: TeamMembershipFilters,
skip: number,
take: number
) {
const whereClause: Prisma.MembershipWhereInput = {
teamId: teamId,
};
if (filters.emails && filters.emails.length > 0) {
whereClause.user = {
email: { in: filters.emails },
};
}
return await this.dbRead.prisma.membership.findMany({
where: whereClause,
include: { user: { select: MembershipUserSelect } },
skip,
take,
});
}
async findTeamMembership(teamId: number, membershipId: number) {
return this.dbRead.prisma.membership.findUnique({
where: {
@@ -120,30 +120,4 @@ export class TeamsRepository {
},
});
}
async getTeamMemberEmails(teamId: number) {
return this.dbRead.prisma.user.findMany({
where: {
teams: {
some: {
teamId,
},
},
},
select: {
id: true,
email: true,
secondaryEmails: {
where: {
emailVerified: {
not: null,
},
},
select: {
email: true,
},
},
},
});
}
}
@@ -1,6 +1,7 @@
import { Locales } from "@/lib/enums/locales";
import { CapitalizeTimeZone } from "@/lib/inputs/capitalize-timezone";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Transform } from "class-transformer";
import { IsOptional, IsTimeZone, IsString, IsEnum, IsIn, IsUrl, IsObject } from "class-validator";
import { ValidateMetadata } from "@calcom/platform-types";
@@ -29,6 +30,7 @@ export class CreateManagedUserInput {
})
weekStart?: WeekDay;
@Transform(({ value }) => (value === null ? undefined : value))
@IsTimeZone()
@IsOptional()
@CapitalizeTimeZone()
@@ -1,3 +1,4 @@
import { CapitalizeTimeZone } from "@/lib/inputs/capitalize-timezone";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Expose, Transform } from "class-transformer";
import {
@@ -112,6 +113,7 @@ export class CreateUserInput {
@ApiProperty({ type: String, required: false, description: "Time zone", example: "America/New_York" })
@IsOptional()
@IsString()
@CapitalizeTimeZone()
@Validate(TimeZoneValidator)
@Expose()
timeZone?: string;
@@ -28,6 +28,7 @@ export class GetUsersInput {
@ApiPropertyOptional({
type: [String],
description: "The email address or an array of email addresses to filter by",
example: ["user1@example.com", "user2@example.com"],
})
emails?: string[];
}
@@ -408,4 +408,30 @@ export class UsersRepository {
},
});
}
async getUserEmailsVerifiedForTeam(teamId: number) {
return this.dbRead.prisma.user.findMany({
where: {
teams: {
some: {
teamId,
},
},
},
select: {
id: true,
email: true,
secondaryEmails: {
where: {
emailVerified: {
not: null,
},
},
select: {
email: true,
},
},
},
});
}
}
@@ -1,6 +1,6 @@
import type { ValidatorConstraintInterface } from "class-validator";
import { ValidatorConstraint } from "class-validator";
import tzdata from "tzdata";
import * as tzdata from "tzdata";
@ValidatorConstraint({ name: "timezoneValidator", async: false })
export class TimeZoneValidator implements ValidatorConstraintInterface {
@@ -17,6 +17,7 @@ import {
EMAIL_HOST,
SMS_ATTENDEE,
SMS_NUMBER,
STEP_ACTIONS,
WHATSAPP_ATTENDEE,
WHATSAPP_NUMBER,
WorkflowEmailAddressStepDto,
@@ -31,7 +32,7 @@ import {
AFTER_EVENT,
AFTER_GUESTS_CAL_VIDEO_NO_SHOW,
AFTER_HOSTS_CAL_VIDEO_NO_SHOW,
BaseWorkflowTriggerDto,
EventTypeWorkflowTriggerDto,
BEFORE_EVENT,
BOOKING_NO_SHOW_UPDATED,
BOOKING_PAID,
@@ -39,6 +40,7 @@ import {
BOOKING_REJECTED,
BOOKING_REQUESTED,
EVENT_CANCELLED,
EVENT_TYPE_WORKFLOW_TRIGGER_TYPES,
NEW_EVENT,
OnAfterCalVideoGuestsNoShowTriggerDto,
OnAfterCalVideoHostsNoShowTriggerDto,
@@ -70,26 +72,12 @@ export class WorkflowActivationDto {
example: [698191],
type: [Number],
})
@ValidateIf((o) => !Boolean(o.isActiveOnAllEventTypes))
@ValidateIf((o) => !o.isActiveOnAllEventTypes)
@IsOptional()
@IsNumber({}, { each: true })
activeOnEventTypeIds: number[] = [];
}
export type TriggerDtoType =
| OnAfterEventTriggerDto
| OnBeforeEventTriggerDto
| OnCreationTriggerDto
| OnRescheduleTriggerDto
| OnCancelTriggerDto
| OnAfterCalVideoGuestsNoShowTriggerDto
| OnRejectedTriggerDto
| OnRequestedTriggerDto
| OnPaymentInitiatedTriggerDto
| OnPaidTriggerDto
| OnNoShowUpdateTriggerDto
| OnAfterCalVideoHostsNoShowTriggerDto;
@ApiExtraModels(
OnBeforeEventTriggerDto,
OnAfterEventTriggerDto,
@@ -110,20 +98,24 @@ export type TriggerDtoType =
WorkflowPhoneWhatsAppNumberStepDto,
WorkflowPhoneNumberStepDto,
WorkflowPhoneAttendeeStepDto,
BaseWorkflowTriggerDto
EventTypeWorkflowTriggerDto,
WorkflowActivationDto
)
export class CreateWorkflowDto {
export class CreateEventTypeWorkflowDto {
@ApiProperty({ description: "Name of the workflow", example: "Platform Test Workflow" })
@IsString()
name!: string;
@ApiProperty({ description: "Activation settings for the workflow", type: WorkflowActivationDto })
@ApiProperty({
description: "Activation settings for the workflow",
type: WorkflowActivationDto,
})
@ValidateNested()
@Type(() => WorkflowActivationDto)
activation!: WorkflowActivationDto;
@ApiProperty({
description: "Trigger configuration for the workflow",
description: `Trigger configuration for the event-type workflow, allowed triggers are ${EVENT_TYPE_WORKFLOW_TRIGGER_TYPES.toString()}`,
oneOf: [
{ $ref: getSchemaPath(OnBeforeEventTriggerDto) },
{ $ref: getSchemaPath(OnAfterEventTriggerDto) },
@@ -140,7 +132,8 @@ export class CreateWorkflowDto {
],
})
@ValidateNested()
@Type(() => BaseWorkflowTriggerDto, {
@Type(() => EventTypeWorkflowTriggerDto, {
keepDiscriminatorProperty: true,
discriminator: {
property: "type",
subTypes: [
@@ -170,11 +163,10 @@ export class CreateWorkflowDto {
| OnPaidTriggerDto
| OnPaymentInitiatedTriggerDto
| OnNoShowUpdateTriggerDto
| OnAfterCalVideoGuestsNoShowTriggerDto
| OnAfterCalVideoHostsNoShowTriggerDto;
| OnAfterCalVideoGuestsNoShowTriggerDto;
@ApiProperty({
description: "Steps to execute as part of the workflow",
description: `Steps to execute as part of the event-type workflow, allowed steps are ${STEP_ACTIONS.toString()}`,
oneOf: [
{ $ref: getSchemaPath(WorkflowEmailAddressStepDto) },
{ $ref: getSchemaPath(WorkflowEmailAttendeeStepDto) },
@@ -187,8 +179,11 @@ export class CreateWorkflowDto {
type: "array",
})
@ValidateNested({ each: true })
@ArrayMinSize(1, { message: "Your workflow must contain at least one step." })
@ArrayMinSize(1, {
message: `Your workflow must contain at least one allowed step. allowed steps are ${STEP_ACTIONS.toString()}`,
})
@Type(() => BaseWorkflowStepDto, {
keepDiscriminatorProperty: true,
discriminator: {
property: "action",
subTypes: [
@@ -0,0 +1,106 @@
import { ApiExtraModels, ApiProperty, ApiPropertyOptional, getSchemaPath } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsBoolean, ArrayMinSize, IsOptional, IsString, ValidateNested, ValidateIf } from "class-validator";
import {
BaseFormWorkflowStepDto,
EMAIL_ADDRESS,
EMAIL_ATTENDEE,
FORM_ALLOWED_STEP_ACTIONS,
WorkflowEmailAddressStepDto,
WorkflowEmailAttendeeStepDto,
} from "./workflow-step.input";
import {
RoutingFormWorkflowTriggerDto,
FORM_SUBMITTED,
FORM_SUBMITTED_NO_EVENT,
FORM_WORKFLOW_TRIGGER_TYPES,
OnFormSubmittedNoEventTriggerDto,
OnFormSubmittedTriggerDto,
} from "./workflow-trigger.input";
export class WorkflowFormActivationDto {
@ApiProperty({
description: "Whether the workflow is active for all the routing forms",
example: false,
type: Boolean,
})
@IsBoolean()
isActiveOnAllRoutingForms = false;
@ApiPropertyOptional({
description: "List of routing form IDs the workflow applies to",
example: ["abd1-123edf-a213d-123dfwf"],
type: [Number],
})
@ValidateIf((o) => !o.isActiveOnAllEventTypes)
@IsOptional()
@IsString({ each: true })
activeOnRoutingFormIds: string[] = [];
}
@ApiExtraModels(
OnFormSubmittedTriggerDto,
OnFormSubmittedNoEventTriggerDto,
WorkflowEmailAddressStepDto,
WorkflowEmailAttendeeStepDto,
RoutingFormWorkflowTriggerDto,
WorkflowFormActivationDto
)
export class CreateFormWorkflowDto {
@ApiProperty({ description: "Name of the workflow", example: "Platform Test Workflow" })
@IsString()
name!: string;
@ApiProperty({
description: "Activation settings for the workflow",
type: WorkflowFormActivationDto,
})
@ValidateNested()
@Type(() => WorkflowFormActivationDto)
activation!: WorkflowFormActivationDto;
@ApiProperty({
description: `Trigger configuration for the routing-form workflow, allowed triggers are ${FORM_WORKFLOW_TRIGGER_TYPES.toString()}`,
oneOf: [
{ $ref: getSchemaPath(OnFormSubmittedTriggerDto) },
{ $ref: getSchemaPath(OnFormSubmittedNoEventTriggerDto) },
],
})
@ValidateNested()
@Type(() => RoutingFormWorkflowTriggerDto, {
keepDiscriminatorProperty: true,
discriminator: {
property: "type",
subTypes: [
{ value: OnFormSubmittedTriggerDto, name: FORM_SUBMITTED },
{ value: OnFormSubmittedNoEventTriggerDto, name: FORM_SUBMITTED_NO_EVENT },
],
},
})
trigger!: OnFormSubmittedTriggerDto | OnFormSubmittedNoEventTriggerDto;
@ApiProperty({
description: `Steps to execute as part of the routing-form workflow, allowed steps are ${FORM_ALLOWED_STEP_ACTIONS.toString()}`,
oneOf: [
{ $ref: getSchemaPath(WorkflowEmailAddressStepDto) },
{ $ref: getSchemaPath(WorkflowEmailAttendeeStepDto) },
],
type: "array",
})
@ValidateNested({ each: true })
@ArrayMinSize(1, {
message: `Your workflow must contain at least one allowed step. allowed steps are ${FORM_ALLOWED_STEP_ACTIONS.toString()}`,
})
@Type(() => BaseFormWorkflowStepDto, {
keepDiscriminatorProperty: true,
discriminator: {
property: "action",
subTypes: [
{ value: WorkflowEmailAddressStepDto, name: EMAIL_ADDRESS },
{ value: WorkflowEmailAttendeeStepDto, name: EMAIL_ATTENDEE },
],
},
})
steps!: (WorkflowEmailAddressStepDto | WorkflowEmailAttendeeStepDto)[];
}
@@ -1,16 +1,9 @@
import { ApiExtraModels, ApiProperty, ApiPropertyOptional, getSchemaPath } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsNumber, IsString, IsOptional, ValidateNested, ArrayMinSize } from "class-validator";
import { IsString, IsOptional, ValidateNested, ArrayMinSize } from "class-validator";
import { WorkflowActivationDto } from "./create-workflow.input";
import { WorkflowActivationDto } from "./create-event-type-workflow.input";
import {
WorkflowEmailAttendeeStepDto,
WorkflowEmailAddressStepDto,
WorkflowEmailHostStepDto,
WorkflowPhoneWhatsAppNumberStepDto,
WorkflowPhoneAttendeeStepDto,
WorkflowPhoneNumberStepDto,
WorkflowPhoneWhatsAppAttendeeStepDto,
BaseWorkflowStepDto,
EMAIL_ADDRESS,
EMAIL_ATTENDEE,
@@ -19,9 +12,17 @@ import {
WHATSAPP_NUMBER,
SMS_NUMBER,
SMS_ATTENDEE,
UpdateEmailAddressWorkflowStepDto,
UpdateEmailAttendeeWorkflowStepDto,
UpdateEmailHostWorkflowStepDto,
UpdatePhoneAttendeeWorkflowStepDto,
UpdatePhoneNumberWorkflowStepDto,
UpdatePhoneWhatsAppNumberWorkflowStepDto,
UpdateWhatsAppAttendeePhoneWorkflowStepDto,
STEP_ACTIONS,
} from "./workflow-step.input";
import {
BaseWorkflowTriggerDto,
EventTypeWorkflowTriggerDto,
OnBeforeEventTriggerDto,
BEFORE_EVENT,
OnAfterEventTriggerDto,
@@ -46,83 +47,9 @@ import {
BOOKING_PAYMENT_INITIATED,
BOOKING_PAID,
BOOKING_NO_SHOW_UPDATED,
EVENT_TYPE_WORKFLOW_TRIGGER_TYPES,
} from "./workflow-trigger.input";
export type UpdateWorkflowStepDto =
| UpdateEmailAttendeeWorkflowStepDto
| UpdateEmailAddressWorkflowStepDto
| UpdateEmailHostWorkflowStepDto
| UpdateWhatsAppAttendeePhoneWorkflowStepDto
| UpdatePhoneWhatsAppNumberWorkflowStepDto
| UpdatePhoneAttendeeWorkflowStepDto
| UpdatePhoneNumberWorkflowStepDto;
export class UpdateEmailAttendeeWorkflowStepDto extends WorkflowEmailAttendeeStepDto {
@ApiProperty({
description:
"Unique identifier of the step you want to update, if adding a new step do not provide this id",
example: 67244,
})
@IsNumber()
id?: number;
}
export class UpdateEmailAddressWorkflowStepDto extends WorkflowEmailAddressStepDto {
@ApiProperty({
description:
"Unique identifier of the step you want to update, if adding a new step do not provide this id",
example: 67244,
})
@IsNumber()
id?: number;
}
export class UpdateEmailHostWorkflowStepDto extends WorkflowEmailHostStepDto {
@ApiProperty({
description:
"Unique identifier of the step you want to update, if adding a new step do not provide this id",
example: 67244,
})
@IsNumber()
id?: number;
}
export class UpdatePhoneWhatsAppNumberWorkflowStepDto extends WorkflowPhoneWhatsAppNumberStepDto {
@ApiProperty({
description:
"Unique identifier of the step you want to update, if adding a new step do not provide this id",
example: 67244,
})
@IsNumber()
id?: number;
}
export class UpdatePhoneAttendeeWorkflowStepDto extends WorkflowPhoneAttendeeStepDto {
@ApiProperty({
description:
"Unique identifier of the step you want to update, if adding a new step do not provide this id",
example: 67244,
})
@IsNumber()
id?: number;
}
export class UpdatePhoneNumberWorkflowStepDto extends WorkflowPhoneNumberStepDto {
@ApiProperty({
description:
"Unique identifier of the step you want to update, if adding a new step do not provide this id",
example: 67244,
})
@IsNumber()
id?: number;
}
export class UpdateWhatsAppAttendeePhoneWorkflowStepDto extends WorkflowPhoneWhatsAppAttendeeStepDto {
@ApiProperty({
description:
"Unique identifier of the step you want to update, if adding a new step do not provide this id",
example: 67244,
})
@IsNumber()
id?: number;
}
@ApiExtraModels(
OnBeforeEventTriggerDto,
OnAfterEventTriggerDto,
@@ -143,16 +70,17 @@ export class UpdateWhatsAppAttendeePhoneWorkflowStepDto extends WorkflowPhoneWha
UpdatePhoneWhatsAppNumberWorkflowStepDto,
UpdateWhatsAppAttendeePhoneWorkflowStepDto,
UpdatePhoneNumberWorkflowStepDto,
BaseWorkflowTriggerDto
EventTypeWorkflowTriggerDto,
WorkflowActivationDto
)
export class UpdateWorkflowDto {
export class UpdateEventTypeWorkflowDto {
@ApiPropertyOptional({ description: "Name of the workflow", example: "Platform Test Workflow" })
@IsString()
@IsOptional()
name?: string;
@ApiPropertyOptional({
description: "Activation settings for the workflow, the action that will trigger the workflow.",
@ApiProperty({
description: "Activation settings for the workflow",
type: WorkflowActivationDto,
})
@ValidateNested()
@@ -161,7 +89,7 @@ export class UpdateWorkflowDto {
activation?: WorkflowActivationDto;
@ApiPropertyOptional({
description: "Trigger configuration for the workflow",
description: `Trigger configuration for the event-type workflow, allowed triggers are ${EVENT_TYPE_WORKFLOW_TRIGGER_TYPES.toString()}`,
oneOf: [
{ $ref: getSchemaPath(OnBeforeEventTriggerDto) },
{ $ref: getSchemaPath(OnAfterEventTriggerDto) },
@@ -179,7 +107,8 @@ export class UpdateWorkflowDto {
})
@IsOptional()
@ValidateNested()
@Type(() => BaseWorkflowTriggerDto, {
@Type(() => EventTypeWorkflowTriggerDto, {
keepDiscriminatorProperty: true,
discriminator: {
property: "type",
subTypes: [
@@ -213,7 +142,7 @@ export class UpdateWorkflowDto {
| OnAfterCalVideoHostsNoShowTriggerDto;
@ApiPropertyOptional({
description: "Steps to execute as part of the workflow",
description: `Steps to execute as part of the event-type workflow, allowed steps are ${STEP_ACTIONS.toString()}`,
oneOf: [
{ $ref: getSchemaPath(UpdateEmailAddressWorkflowStepDto) },
{ $ref: getSchemaPath(UpdateEmailAttendeeWorkflowStepDto) },
@@ -226,9 +155,12 @@ export class UpdateWorkflowDto {
type: "array",
})
@ValidateNested({ each: true })
@ArrayMinSize(1, { message: "Your workflow must contain at least one step." })
@ArrayMinSize(1, {
message: `Your workflow must contain at least one allowed step. allowed steps are ${STEP_ACTIONS.toString()}`,
})
@IsOptional()
@Type(() => BaseWorkflowStepDto, {
keepDiscriminatorProperty: true,
discriminator: {
property: "action",
subTypes: [
@@ -0,0 +1,97 @@
import { WorkflowFormActivationDto } from "@/modules/workflows/inputs/create-form-workflow";
import { ApiExtraModels, ApiPropertyOptional, getSchemaPath } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsString, IsOptional, ValidateNested, ArrayMinSize } from "class-validator";
import {
BaseFormWorkflowStepDto,
EMAIL_ADDRESS,
EMAIL_ATTENDEE,
FORM_ALLOWED_STEP_ACTIONS,
UpdateEmailAddressWorkflowStepDto,
UpdateEmailAttendeeWorkflowStepDto,
UpdateEmailHostWorkflowStepDto,
UpdatePhoneAttendeeWorkflowStepDto,
UpdatePhoneNumberWorkflowStepDto,
UpdatePhoneWhatsAppNumberWorkflowStepDto,
UpdateWhatsAppAttendeePhoneWorkflowStepDto,
} from "./workflow-step.input";
import {
OnFormSubmittedTriggerDto,
OnFormSubmittedNoEventTriggerDto,
FORM_SUBMITTED,
FORM_SUBMITTED_NO_EVENT,
FORM_WORKFLOW_TRIGGER_TYPES,
RoutingFormWorkflowTriggerDto,
} from "./workflow-trigger.input";
@ApiExtraModels(
OnFormSubmittedTriggerDto,
OnFormSubmittedNoEventTriggerDto,
UpdateEmailAddressWorkflowStepDto,
UpdateEmailAttendeeWorkflowStepDto,
UpdateEmailHostWorkflowStepDto,
UpdatePhoneAttendeeWorkflowStepDto,
UpdatePhoneWhatsAppNumberWorkflowStepDto,
UpdateWhatsAppAttendeePhoneWorkflowStepDto,
UpdatePhoneNumberWorkflowStepDto,
RoutingFormWorkflowTriggerDto,
WorkflowFormActivationDto
)
export class UpdateFormWorkflowDto {
@ApiPropertyOptional({ description: "Name of the workflow", example: "Rounting-form Test Workflow" })
@IsString()
@IsOptional()
name?: string;
@ValidateNested()
@Type(() => WorkflowFormActivationDto)
@IsOptional()
activation?: WorkflowFormActivationDto;
@ApiPropertyOptional({
description: `Trigger configuration for the routing-form workflow, allowed triggers are ${FORM_WORKFLOW_TRIGGER_TYPES}`,
oneOf: [
{ $ref: getSchemaPath(OnFormSubmittedTriggerDto) },
{ $ref: getSchemaPath(OnFormSubmittedNoEventTriggerDto) },
],
})
@IsOptional()
@ValidateNested()
@Type(() => RoutingFormWorkflowTriggerDto, {
keepDiscriminatorProperty: true,
discriminator: {
property: "type",
subTypes: [
{ value: OnFormSubmittedTriggerDto, name: FORM_SUBMITTED },
{ value: OnFormSubmittedNoEventTriggerDto, name: FORM_SUBMITTED_NO_EVENT },
],
},
})
trigger?: OnFormSubmittedTriggerDto | OnFormSubmittedNoEventTriggerDto;
@ApiPropertyOptional({
description: `Steps to execute as part of the routing-form workflow, allowed steps are ${FORM_ALLOWED_STEP_ACTIONS.toString()}`,
oneOf: [
{ $ref: getSchemaPath(UpdateEmailAddressWorkflowStepDto) },
{ $ref: getSchemaPath(UpdateEmailAttendeeWorkflowStepDto) },
],
type: "array",
})
@ValidateNested({ each: true })
@ArrayMinSize(1, {
message: `Your workflow must contain at least one allowed step. allowed steps are ${FORM_ALLOWED_STEP_ACTIONS.toString()}`,
})
@IsOptional()
@Type(() => BaseFormWorkflowStepDto, {
keepDiscriminatorProperty: true,
discriminator: {
property: "action",
subTypes: [
{ value: UpdateEmailAddressWorkflowStepDto, name: EMAIL_ADDRESS },
{ value: UpdateEmailAttendeeWorkflowStepDto, name: EMAIL_ATTENDEE },
],
},
})
steps?: (UpdateEmailAddressWorkflowStepDto | UpdateEmailAttendeeWorkflowStepDto)[];
}
@@ -1,6 +1,6 @@
import { ApiProperty } from "@nestjs/swagger";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsNumber, IsBoolean, IsString, ValidateNested, IsIn } from "class-validator";
import { IsNumber, IsBoolean, IsString, ValidateNested, IsIn, IsOptional } from "class-validator";
import { WorkflowActions, WorkflowTemplates } from "@calcom/platform-libraries";
@@ -24,6 +24,8 @@ export const STEP_ACTIONS = [
CAL_AI_PHONE_CALL,
] as const;
export const FORM_ALLOWED_STEP_ACTIONS = [EMAIL_ATTENDEE, EMAIL_ADDRESS] as const;
export const STEP_ACTIONS_TO_ENUM = {
[EMAIL_HOST]: WorkflowActions.EMAIL_HOST,
[EMAIL_ATTENDEE]: WorkflowActions.EMAIL_ATTENDEE,
@@ -47,7 +49,7 @@ export const ENUM_TO_STEP_ACTIONS = {
} as const;
export type StepAction = (typeof STEP_ACTIONS)[number];
export type StepActionsType = (typeof STEP_ACTIONS)[number];
export type FormAllowedStepAction = (typeof FORM_ALLOWED_STEP_ACTIONS)[number];
export const REMINDER = "reminder";
export const CUSTOM = "custom";
@@ -134,6 +136,13 @@ export class BaseWorkflowStepDto {
sender!: string;
}
export class BaseFormWorkflowStepDto extends BaseWorkflowStepDto {
@ApiProperty({ description: "Action to perform", example: EMAIL_HOST, enum: STEP_ACTIONS })
@IsString()
@IsIn(FORM_ALLOWED_STEP_ACTIONS)
action!: FormAllowedStepAction;
}
export class WorkflowEmailHostStepDto extends BaseWorkflowStepDto {
@ApiProperty({
description: "Action to perform, send an email to the host of the event",
@@ -260,6 +269,15 @@ export class WorkflowPhoneAttendeeStepDto extends BaseWorkflowStepDto {
@ValidateNested()
@Type(() => TextWorkflowMessageDto)
message!: TextWorkflowMessageDto;
@ApiPropertyOptional({
description: "whether or not the attendees are required to provide their phone numbers when booking",
example: true,
default: false,
})
@IsBoolean()
@IsOptional()
phoneRequired: boolean = false;
}
export class WorkflowPhoneNumberStepDto extends BaseWorkflowStepDto {
@@ -302,4 +320,88 @@ export class WorkflowPhoneWhatsAppAttendeeStepDto extends BaseWorkflowStepDto {
@ValidateNested()
@Type(() => TextWorkflowMessageDto)
message!: TextWorkflowMessageDto;
@ApiPropertyOptional({
description: "whether or not the attendees are required to provide their phone numbers when booking",
example: true,
default: false,
})
@IsBoolean()
@IsOptional()
phoneRequired: boolean = false;
}
export type UpdateWorkflowStepDto =
| UpdateEmailAttendeeWorkflowStepDto
| UpdateEmailAddressWorkflowStepDto
| UpdateEmailHostWorkflowStepDto
| UpdateWhatsAppAttendeePhoneWorkflowStepDto
| UpdatePhoneWhatsAppNumberWorkflowStepDto
| UpdatePhoneAttendeeWorkflowStepDto
| UpdatePhoneNumberWorkflowStepDto;
export class UpdateEmailAttendeeWorkflowStepDto extends WorkflowEmailAttendeeStepDto {
@ApiProperty({
description:
"Unique identifier of the step you want to update, if adding a new step do not provide this id",
example: 67244,
})
@IsNumber()
id?: number;
}
export class UpdateEmailAddressWorkflowStepDto extends WorkflowEmailAddressStepDto {
@ApiProperty({
description:
"Unique identifier of the step you want to update, if adding a new step do not provide this id",
example: 67244,
})
@IsNumber()
id?: number;
}
export class UpdateEmailHostWorkflowStepDto extends WorkflowEmailHostStepDto {
@ApiProperty({
description:
"Unique identifier of the step you want to update, if adding a new step do not provide this id",
example: 67244,
})
@IsNumber()
id?: number;
}
export class UpdatePhoneWhatsAppNumberWorkflowStepDto extends WorkflowPhoneWhatsAppNumberStepDto {
@ApiProperty({
description:
"Unique identifier of the step you want to update, if adding a new step do not provide this id",
example: 67244,
})
@IsNumber()
id?: number;
}
export class UpdatePhoneAttendeeWorkflowStepDto extends WorkflowPhoneAttendeeStepDto {
@ApiProperty({
description:
"Unique identifier of the step you want to update, if adding a new step do not provide this id",
example: 67244,
})
@IsNumber()
id?: number;
}
export class UpdatePhoneNumberWorkflowStepDto extends WorkflowPhoneNumberStepDto {
@ApiProperty({
description:
"Unique identifier of the step you want to update, if adding a new step do not provide this id",
example: 67244,
})
@IsNumber()
id?: number;
}
export class UpdateWhatsAppAttendeePhoneWorkflowStepDto extends WorkflowPhoneWhatsAppAttendeeStepDto {
@ApiProperty({
description:
"Unique identifier of the step you want to update, if adding a new step do not provide this id",
example: 67244,
})
@IsNumber()
id?: number;
}
@@ -11,11 +11,31 @@ export const AFTER_EVENT = "afterEvent";
export const RESCHEDULE_EVENT = "rescheduleEvent";
export const AFTER_HOSTS_CAL_VIDEO_NO_SHOW = "afterHostsCalVideoNoShow";
export const AFTER_GUESTS_CAL_VIDEO_NO_SHOW = "afterGuestsCalVideoNoShow";
export const FORM_SUBMITTED = "formSubmitted";
export const FORM_SUBMITTED_NO_EVENT = "formSubmittedNoEvent";
export const BOOKING_REJECTED = "bookingRejected";
export const BOOKING_REQUESTED = "bookingRequested";
export const BOOKING_PAYMENT_INITIATED = "bookingPaymentInitiated";
export const BOOKING_PAID = "bookingPaid";
export const BOOKING_NO_SHOW_UPDATED = "bookingNoShowUpdated";
export const FORM_WORKFLOW_TRIGGER_TYPES = [FORM_SUBMITTED, FORM_SUBMITTED_NO_EVENT] as const;
export const EVENT_TYPE_WORKFLOW_TRIGGER_TYPES = [
BEFORE_EVENT,
EVENT_CANCELLED,
NEW_EVENT,
AFTER_EVENT,
RESCHEDULE_EVENT,
AFTER_HOSTS_CAL_VIDEO_NO_SHOW,
AFTER_GUESTS_CAL_VIDEO_NO_SHOW,
BOOKING_REJECTED,
BOOKING_REQUESTED,
BOOKING_PAYMENT_INITIATED,
BOOKING_PAID,
BOOKING_NO_SHOW_UPDATED,
] as const;
export const WORKFLOW_TRIGGER_TYPES = [
BEFORE_EVENT,
EVENT_CANCELLED,
@@ -24,6 +44,8 @@ export const WORKFLOW_TRIGGER_TYPES = [
RESCHEDULE_EVENT,
AFTER_HOSTS_CAL_VIDEO_NO_SHOW,
AFTER_GUESTS_CAL_VIDEO_NO_SHOW,
FORM_SUBMITTED,
FORM_SUBMITTED_NO_EVENT,
BOOKING_REJECTED,
BOOKING_REQUESTED,
BOOKING_PAYMENT_INITIATED,
@@ -39,6 +61,8 @@ export const WORKFLOW_TRIGGER_TO_ENUM = {
[RESCHEDULE_EVENT]: WorkflowTriggerEvents.RESCHEDULE_EVENT,
[AFTER_HOSTS_CAL_VIDEO_NO_SHOW]: WorkflowTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW,
[AFTER_GUESTS_CAL_VIDEO_NO_SHOW]: WorkflowTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW,
[FORM_SUBMITTED]: WorkflowTriggerEvents.FORM_SUBMITTED,
[FORM_SUBMITTED_NO_EVENT]: WorkflowTriggerEvents.FORM_SUBMITTED_NO_EVENT,
[BOOKING_REJECTED]: WorkflowTriggerEvents.BOOKING_REJECTED,
[BOOKING_REQUESTED]: WorkflowTriggerEvents.BOOKING_REQUESTED,
[BOOKING_PAYMENT_INITIATED]: WorkflowTriggerEvents.BOOKING_PAYMENT_INITIATED,
@@ -46,6 +70,19 @@ export const WORKFLOW_TRIGGER_TO_ENUM = {
[BOOKING_PAID]: WorkflowTriggerEvents.BOOKING_PAID,
} as const;
export const ENUM_ROUTING_FORM_WORFLOW_TRIGGERS = [
WORKFLOW_TRIGGER_TO_ENUM[FORM_SUBMITTED_NO_EVENT],
WORKFLOW_TRIGGER_TO_ENUM[FORM_SUBMITTED],
];
export const ENUM_OFFSET_WORFLOW_TRIGGERS = [
WORKFLOW_TRIGGER_TO_ENUM[FORM_SUBMITTED_NO_EVENT],
WORKFLOW_TRIGGER_TO_ENUM[BEFORE_EVENT],
WORKFLOW_TRIGGER_TO_ENUM[AFTER_EVENT],
WORKFLOW_TRIGGER_TO_ENUM[AFTER_GUESTS_CAL_VIDEO_NO_SHOW],
WORKFLOW_TRIGGER_TO_ENUM[AFTER_HOSTS_CAL_VIDEO_NO_SHOW],
];
export const ENUM_TO_WORKFLOW_TRIGGER = {
[WorkflowTriggerEvents.BEFORE_EVENT]: BEFORE_EVENT,
[WorkflowTriggerEvents.EVENT_CANCELLED]: EVENT_CANCELLED,
@@ -54,6 +91,8 @@ export const ENUM_TO_WORKFLOW_TRIGGER = {
[WorkflowTriggerEvents.RESCHEDULE_EVENT]: RESCHEDULE_EVENT,
[WorkflowTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW]: AFTER_HOSTS_CAL_VIDEO_NO_SHOW,
[WorkflowTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW]: AFTER_GUESTS_CAL_VIDEO_NO_SHOW,
[WorkflowTriggerEvents.FORM_SUBMITTED]: FORM_SUBMITTED,
[WorkflowTriggerEvents.FORM_SUBMITTED_NO_EVENT]: FORM_SUBMITTED_NO_EVENT,
[WorkflowTriggerEvents.BOOKING_REJECTED]: BOOKING_REJECTED,
[WorkflowTriggerEvents.BOOKING_REQUESTED]: BOOKING_REQUESTED,
[WorkflowTriggerEvents.BOOKING_PAYMENT_INITIATED]: BOOKING_PAYMENT_INITIATED,
@@ -61,6 +100,10 @@ export const ENUM_TO_WORKFLOW_TRIGGER = {
[WorkflowTriggerEvents.BOOKING_NO_SHOW_UPDATED]: BOOKING_NO_SHOW_UPDATED,
} as const;
export const ENUM_TO_ROUNTING_FORM_WORKFLOW_TRIGGER = {
[WorkflowTriggerEvents.FORM_SUBMITTED]: FORM_SUBMITTED,
} as const;
export const HOUR = "hour";
export const MINUTE = "minute";
export const DAY = "day";
@@ -82,6 +125,8 @@ export const ENUM_TO_TIME_UNIT = {
} as const;
export type WorkflowTriggerType = (typeof WORKFLOW_TRIGGER_TYPES)[number];
export type WorkflowEventTypeTriggerType = (typeof EVENT_TYPE_WORKFLOW_TRIGGER_TYPES)[number];
export type WorkflowFormTriggerType = (typeof FORM_WORKFLOW_TRIGGER_TYPES)[number];
export class WorkflowTriggerOffsetDto {
@ApiProperty({ description: "Time value for offset before/after event trigger", example: 24, type: Number })
@@ -94,13 +139,24 @@ export class WorkflowTriggerOffsetDto {
unit!: TimeUnitType;
}
export class BaseWorkflowTriggerDto {
export class EventTypeWorkflowTriggerDto {
@ApiProperty({
description: "Trigger type for the workflow",
description: "Trigger type for the event-type workflow",
example: "beforeEvent",
})
@IsString()
@IsIn([WORKFLOW_TRIGGER_TYPES])
type!: WorkflowTriggerType;
@IsIn(EVENT_TYPE_WORKFLOW_TRIGGER_TYPES)
type!: WorkflowEventTypeTriggerType;
}
export class RoutingFormWorkflowTriggerDto {
@ApiProperty({
description: "Trigger type for the routing-form workflow",
example: "formSubmitted",
})
@IsString()
@IsIn(FORM_WORKFLOW_TRIGGER_TYPES)
type!: WorkflowFormTriggerType;
}
export class OnCreationTriggerDto {
@@ -166,7 +222,8 @@ export class OnNoShowUpdateTriggerDto {
export class TriggerOffsetDTO {
@ApiProperty({
description: "Offset before/after the trigger time; required for BEFORE_EVENT and AFTER_EVENT only",
description:
"Offset before/after the trigger time; required for BEFORE_EVENT, AFTER_EVENT, and FORM_SUBMITTED_NO_EVENT",
type: WorkflowTriggerOffsetDto,
})
@ValidateNested()
@@ -213,3 +270,31 @@ export class OnAfterCalVideoHostsNoShowTriggerDto extends TriggerOffsetDTO {
@IsIn([AFTER_HOSTS_CAL_VIDEO_NO_SHOW])
type: typeof AFTER_HOSTS_CAL_VIDEO_NO_SHOW = AFTER_HOSTS_CAL_VIDEO_NO_SHOW;
}
export class OnFormSubmittedTriggerDto {
@ApiProperty({
description: "Trigger type for the workflow",
example: FORM_SUBMITTED,
})
@IsString()
@IsIn([FORM_SUBMITTED])
type: typeof FORM_SUBMITTED = FORM_SUBMITTED;
}
export class OnFormSubmittedNoEventTriggerDto extends TriggerOffsetDTO {
@ApiProperty({
description: "Trigger type for the workflow",
example: FORM_SUBMITTED_NO_EVENT,
})
@IsString()
@IsIn([FORM_SUBMITTED_NO_EVENT])
type: typeof FORM_SUBMITTED_NO_EVENT = FORM_SUBMITTED_NO_EVENT;
}
export const OffsetTriggerDTOInstances = [
OnFormSubmittedNoEventTriggerDto,
OnBeforeEventTriggerDto,
OnAfterEventTriggerDto,
OnAfterCalVideoGuestsNoShowTriggerDto,
OnAfterEventTriggerDto,
];
export type OffsetTriggerDTOInstancesType = InstanceType<(typeof OffsetTriggerDTOInstances)[number]>;
@@ -1,28 +1,15 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Expose, Type } from "class-transformer";
import { IsArray, IsEnum, ValidateNested } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
import {
EMAIL_HOST,
HOST,
RECIPIENT_TYPES,
RecipientType,
REMINDER,
STEP_ACTIONS,
StepAction,
TEMPLATES,
TemplateType,
} from "../inputs/workflow-step.input";
import {
BEFORE_EVENT,
HOUR,
TIME_UNITS,
TimeUnitType,
WORKFLOW_TRIGGER_TYPES,
WorkflowTriggerType,
} from "../inputs/workflow-trigger.input";
} from "@/modules/workflows/inputs/workflow-step.input";
import { HOUR, TIME_UNITS, TimeUnitType } from "@/modules/workflows/inputs/workflow-trigger.input";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Expose, Type } from "class-transformer";
import { IsBoolean, IsOptional, ValidateNested } from "class-validator";
export class WorkflowMessageOutputDto {
@ApiProperty({
@@ -47,7 +34,7 @@ export class WorkflowMessageOutputDto {
text?: string;
}
export class WorkflowStepOutputDto {
export class BaseWorkflowStepOutputDto {
@ApiProperty({ description: "Unique identifier of the step", example: 67244 })
@Expose()
id!: number;
@@ -56,10 +43,6 @@ export class WorkflowStepOutputDto {
@Expose()
stepNumber!: number;
@ApiProperty({ description: "Action to perform", example: EMAIL_HOST, enum: STEP_ACTIONS })
@Expose()
action!: StepAction;
@ApiProperty({ description: "Intended recipient type", example: HOST, enum: RECIPIENT_TYPES })
@Expose()
recipient!: RecipientType;
@@ -74,6 +57,16 @@ export class WorkflowStepOutputDto {
@Expose()
phone?: string;
@ApiPropertyOptional({
description: "whether or not the attendees are required to provide their phone numbers when booking",
example: true,
default: false,
})
@IsBoolean()
@Expose()
@IsOptional()
phoneRequired?: boolean;
@ApiProperty({ description: "Template type used", example: REMINDER, enum: TEMPLATES })
@Expose()
template!: TemplateType;
@@ -110,45 +103,7 @@ export class WorkflowTriggerOffsetOutputDto {
unit!: TimeUnitType;
}
export class WorkflowTriggerOutputDto {
@ApiProperty({
description: "Trigger type for the workflow",
example: BEFORE_EVENT,
enum: WORKFLOW_TRIGGER_TYPES,
})
@Expose()
type!: WorkflowTriggerType;
@ApiPropertyOptional({
description: "Offset details (present for BEFORE_EVENT/AFTER_EVENT)",
type: WorkflowTriggerOffsetOutputDto,
})
@Expose()
@ValidateNested()
@Type(() => WorkflowTriggerOffsetOutputDto)
offset?: WorkflowTriggerOffsetOutputDto;
}
export class WorkflowActivationOutputDto {
@ApiProperty({
description: "Whether the workflow is active for all event types associated with the team/user",
example: false,
})
@Expose()
isActiveOnAllEventTypes?: boolean = false;
@ApiPropertyOptional({
description: "List of Event Type IDs the workflow is specifically active on (if not active on all)",
example: [698191, 698192],
})
@Expose()
@IsArray()
activeOnEventTypeIds?: number[];
}
// --- Main Workflow Output DTO ---
export class WorkflowOutput {
export class BaseWorkflowOutput {
@ApiProperty({ description: "Unique identifier of the workflow", example: 101 })
@Expose()
id!: number;
@@ -168,25 +123,6 @@ export class WorkflowOutput {
@Expose()
teamId?: number;
@ApiProperty({ description: "Activation settings (scope)", type: WorkflowActivationOutputDto })
@Expose()
@ValidateNested()
@Type(() => WorkflowActivationOutputDto)
activation!: WorkflowActivationOutputDto;
@ApiProperty({ description: "Trigger configuration", type: WorkflowTriggerOutputDto })
@Expose()
@ValidateNested()
@Type(() => WorkflowTriggerOutputDto)
trigger!: WorkflowTriggerOutputDto;
@ApiProperty({ description: "Steps comprising the workflow", type: [WorkflowStepOutputDto] })
@Expose()
@IsArray()
@ValidateNested({ each: true })
@Type(() => WorkflowStepOutputDto)
steps!: WorkflowStepOutputDto[];
@ApiPropertyOptional({ description: "Timestamp of creation", example: "2024-05-12T10:00:00.000Z" })
@Expose()
createdAt?: Date | string;
@@ -195,47 +131,3 @@ export class WorkflowOutput {
@Expose()
updatedAt?: Date | string;
}
// --- List Response Output DTO ---
export class GetWorkflowsOutput {
@ApiProperty({
description: "Indicates the status of the response",
example: SUCCESS_STATUS,
enum: [SUCCESS_STATUS, ERROR_STATUS],
})
@Expose()
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
description: "List of workflows",
type: [WorkflowOutput],
})
@Expose()
@IsArray()
@ValidateNested({ each: true })
@Type(() => WorkflowOutput)
data!: WorkflowOutput[];
}
export class GetWorkflowOutput {
@ApiProperty({
description: "Indicates the status of the response",
example: SUCCESS_STATUS,
enum: [SUCCESS_STATUS, ERROR_STATUS],
})
@Expose()
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
description: "workflow",
type: [WorkflowOutput],
})
@Expose()
@IsArray()
@ValidateNested({ each: true })
@Type(() => WorkflowOutput)
data!: WorkflowOutput;
}
@@ -0,0 +1,140 @@
import {
BaseWorkflowStepOutputDto,
WorkflowTriggerOffsetOutputDto,
BaseWorkflowOutput,
} from "@/modules/workflows/outputs/base-workflow.output";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Expose, Type } from "class-transformer";
import { IsArray, IsEnum, IsIn, IsString, ValidateNested } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
import { EMAIL_HOST, STEP_ACTIONS, StepAction } from "../inputs/workflow-step.input";
import {
BEFORE_EVENT,
EVENT_TYPE_WORKFLOW_TRIGGER_TYPES,
WorkflowEventTypeTriggerType,
} from "../inputs/workflow-trigger.input";
export const WORKFLOW_TYPE_FORM = "routing-form";
export const WORKFLOW_TYPE_EVENT_TYPE = "event-type";
export class EventTypeWorkflowStepOutputDto extends BaseWorkflowStepOutputDto {
@ApiProperty({ description: "Action to perform", example: EMAIL_HOST, enum: STEP_ACTIONS })
@Expose()
action!: StepAction;
}
export class EventTypeWorkflowTriggerOutputDto {
@ApiProperty({
description: "Trigger type for the workflow",
example: BEFORE_EVENT,
enum: EVENT_TYPE_WORKFLOW_TRIGGER_TYPES,
})
@Expose()
type!: WorkflowEventTypeTriggerType;
@ApiPropertyOptional({
description: "Offset details (present for BEFORE_EVENT/AFTER_EVENT)",
type: WorkflowTriggerOffsetOutputDto,
})
@Expose()
@ValidateNested()
@Type(() => WorkflowTriggerOffsetOutputDto)
offset?: WorkflowTriggerOffsetOutputDto;
}
export class EventTypeWorkflowActivationOutputDto {
@ApiProperty({
description: "Whether the workflow is active for all event types associated with the team/user",
example: false,
})
@Expose()
isActiveOnAllEventTypes?: boolean = false;
@ApiPropertyOptional({
description: "List of Event Type IDs the workflow is specifically active on (if not active on all)",
example: [698191, 698192],
})
@Expose()
@IsArray()
activeOnEventTypeIds?: number[];
}
// --- Main Workflow Output DTO ---
export class EventTypeWorkflowOutput extends BaseWorkflowOutput {
@ApiProperty({
description: "type of the workflow",
example: WORKFLOW_TYPE_EVENT_TYPE,
default: WORKFLOW_TYPE_EVENT_TYPE,
})
@IsString()
@IsIn([WORKFLOW_TYPE_EVENT_TYPE])
type!: typeof WORKFLOW_TYPE_EVENT_TYPE;
@ApiProperty({
description: "Activation settings for the workflow",
})
@Expose()
@ValidateNested()
@Type(() => EventTypeWorkflowActivationOutputDto)
activation!: EventTypeWorkflowActivationOutputDto;
@ApiProperty({ description: "Trigger configuration", type: EventTypeWorkflowTriggerOutputDto })
@Expose()
@ValidateNested()
@Type(() => EventTypeWorkflowTriggerOutputDto)
trigger!: EventTypeWorkflowTriggerOutputDto;
@ApiProperty({ description: "Steps comprising the workflow", type: [EventTypeWorkflowStepOutputDto] })
@Expose()
@IsArray()
@ValidateNested({ each: true })
@Type(() => EventTypeWorkflowStepOutputDto)
steps!: EventTypeWorkflowStepOutputDto[];
}
// --- List Response Output DTO ---
export class GetEventTypeWorkflowsOutput {
@ApiProperty({
description: "Indicates the status of the response",
example: SUCCESS_STATUS,
enum: [SUCCESS_STATUS, ERROR_STATUS],
})
@Expose()
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
description: "List of workflows",
type: [EventTypeWorkflowOutput],
})
@Expose()
@IsArray()
@ValidateNested({ each: true })
@Type(() => EventTypeWorkflowOutput)
data!: EventTypeWorkflowOutput[];
}
export class GetEventTypeWorkflowOutput {
@ApiProperty({
description: "Indicates the status of the response",
example: SUCCESS_STATUS,
enum: [SUCCESS_STATUS, ERROR_STATUS],
})
@Expose()
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
description: "workflow",
type: [EventTypeWorkflowOutput],
})
@Expose()
@IsArray()
@ValidateNested({ each: true })
@Type(() => EventTypeWorkflowOutput)
data!: EventTypeWorkflowOutput;
}
@@ -0,0 +1,138 @@
import {
BaseWorkflowOutput,
BaseWorkflowStepOutputDto,
WorkflowTriggerOffsetOutputDto,
} from "@/modules/workflows/outputs/base-workflow.output";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Expose, Type } from "class-transformer";
import { IsArray, IsEnum, IsIn, IsString, ValidateNested } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
import { EMAIL_HOST, FORM_ALLOWED_STEP_ACTIONS, FormAllowedStepAction } from "../inputs/workflow-step.input";
import {
FORM_SUBMITTED,
FORM_WORKFLOW_TRIGGER_TYPES,
WorkflowFormTriggerType,
} from "../inputs/workflow-trigger.input";
export const WORKFLOW_TYPE_FORM = "routing-form";
export const WORKFLOW_TYPE_EVENT_TYPE = "event-type";
export class RoutingFormWorkflowStepOutputDto extends BaseWorkflowStepOutputDto {
@ApiProperty({ description: "Action to perform", example: EMAIL_HOST, enum: FORM_ALLOWED_STEP_ACTIONS })
@Expose()
action!: FormAllowedStepAction;
}
export class RoutingFormWorkflowTriggerOutputDto {
@ApiProperty({
description: "Trigger type for the workflow",
example: FORM_SUBMITTED,
enum: FORM_WORKFLOW_TRIGGER_TYPES,
})
@Expose()
type!: WorkflowFormTriggerType;
@ApiPropertyOptional({
description: "Offset details (present for BEFORE_EVENT/AFTER_EVENT/FORM_SUBMITTED_NO_EVENT)",
type: WorkflowTriggerOffsetOutputDto,
})
@Expose()
@ValidateNested()
@Type(() => WorkflowTriggerOffsetOutputDto)
offset?: WorkflowTriggerOffsetOutputDto;
}
export class RoutingFormWorkflowActivationOutputDto {
@ApiProperty({
description: "Whether the workflow is active for all routing forms associated with the team/user",
example: false,
})
@Expose()
isActiveOnAllRoutingForms?: boolean = false;
@ApiPropertyOptional({
description: "List of Event Type IDs the workflow is specifically active on (if not active on all)",
example: ["5cacdec7-1234-6e1b-78d9-7bcda8a1b332"],
})
@Expose()
@IsArray()
activeOnRoutingFormIds?: string[];
}
// --- Main Workflow Output DTO ---
export class RoutingFormWorkflowOutput extends BaseWorkflowOutput {
@ApiProperty({
description: "type of the workflow",
example: WORKFLOW_TYPE_FORM,
default: WORKFLOW_TYPE_FORM,
})
@IsString()
@IsIn([WORKFLOW_TYPE_FORM])
type!: typeof WORKFLOW_TYPE_FORM;
@ApiProperty({
description: "Activation settings for the workflow",
})
@Expose()
@Type(() => RoutingFormWorkflowActivationOutputDto)
@ValidateNested()
activation!: RoutingFormWorkflowActivationOutputDto;
@ApiProperty({ description: "Trigger configuration", type: RoutingFormWorkflowTriggerOutputDto })
@Expose()
@ValidateNested()
@Type(() => RoutingFormWorkflowTriggerOutputDto)
trigger!: RoutingFormWorkflowTriggerOutputDto;
@ApiProperty({ description: "Steps comprising the workflow", type: [RoutingFormWorkflowStepOutputDto] })
@Expose()
@IsArray()
@ValidateNested({ each: true })
@Type(() => RoutingFormWorkflowStepOutputDto)
steps!: RoutingFormWorkflowStepOutputDto[];
}
export class GetRoutingFormWorkflowsOutput {
@ApiProperty({
description: "Indicates the status of the response",
example: SUCCESS_STATUS,
enum: [SUCCESS_STATUS, ERROR_STATUS],
})
@Expose()
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
description: "List of workflows",
type: [RoutingFormWorkflowOutput],
})
@Expose()
@IsArray()
@ValidateNested({ each: true })
@Type(() => RoutingFormWorkflowOutput)
data!: RoutingFormWorkflowOutput[];
}
export class GetRoutingFormWorkflowOutput {
@ApiProperty({
description: "Indicates the status of the response",
example: SUCCESS_STATUS,
enum: [SUCCESS_STATUS, ERROR_STATUS],
})
@Expose()
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
description: "workflow",
type: [RoutingFormWorkflowOutput],
})
@Expose()
@IsArray()
@ValidateNested({ each: true })
@Type(() => RoutingFormWorkflowOutput)
data!: RoutingFormWorkflowOutput;
}
@@ -0,0 +1,113 @@
import { UserWithProfile } from "@/modules/users/users.repository";
import { TeamsVerifiedResourcesRepository } from "@/modules/verified-resources/teams-verified-resources.repository";
import { CreateEventTypeWorkflowDto } from "@/modules/workflows/inputs/create-event-type-workflow.input";
import { UpdateEventTypeWorkflowDto } from "@/modules/workflows/inputs/update-event-type-workflow.input";
import { WorkflowsInputService } from "@/modules/workflows/services/workflows.input.service";
import { WorkflowsOutputService } from "@/modules/workflows/services/workflows.output.service";
import { WorkflowsRepository } from "@/modules/workflows/workflows.repository";
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
@Injectable()
export class TeamEventTypeWorkflowsService {
constructor(
private readonly workflowsRepository: WorkflowsRepository,
private readonly teamsVerifiedResourcesRepository: TeamsVerifiedResourcesRepository,
private readonly workflowInputService: WorkflowsInputService,
private readonly workflowOutputService: WorkflowsOutputService
) {}
async getEventTypeTeamWorkflows(teamId: number, skip: number, take: number) {
const workflows = await this.workflowsRepository.getEventTypeTeamWorkflows(teamId, skip, take);
return workflows.map((workflow) => {
const output = this.workflowOutputService.toEventTypeOutputDto(workflow);
if (!output) {
throw new BadRequestException(`Could not format workflow for response.`);
}
return output;
});
}
async getEventTypeTeamWorkflowById(teamId: number, workflowId: number) {
const workflow = await this.workflowsRepository.getEventTypeTeamWorkflowById(teamId, workflowId);
if (!workflow) {
throw new NotFoundException(`Workflow with ID ${workflowId} not found for team ${teamId}`);
}
const output = this.workflowOutputService.toEventTypeOutputDto(workflow);
if (!output) {
throw new BadRequestException(`Could not format workflow for response.`);
}
return output;
}
async createEventTypeTeamWorkflow(user: UserWithProfile, teamId: number, data: CreateEventTypeWorkflowDto) {
const workflowHusk = await this.workflowsRepository.createTeamWorkflowHusk(teamId);
const mappedData = await this.workflowInputService.mapEventTypeUpdateDtoToZodSchema(
data,
workflowHusk.id,
teamId,
workflowHusk
);
const createdWorkflow = await this.workflowsRepository.updateEventTypeTeamWorkflow(
user,
teamId,
workflowHusk.id,
mappedData
);
if (!createdWorkflow) {
throw new BadRequestException(`Could not create Workflow in team ${teamId}`);
}
const output = this.workflowOutputService.toEventTypeOutputDto(createdWorkflow);
if (!output) {
throw new BadRequestException(`Could not format workflow for response.`);
}
return output;
}
async updateEventTypeTeamWorkflow(
user: UserWithProfile,
teamId: number,
workflowId: number,
data: UpdateEventTypeWorkflowDto
) {
const currentWorkflow = await this.workflowsRepository.getEventTypeTeamWorkflowById(teamId, workflowId);
if (!currentWorkflow) {
throw new NotFoundException(`Workflow with ID ${workflowId} not found for team ${teamId}`);
}
const mappedData = await this.workflowInputService.mapEventTypeUpdateDtoToZodSchema(
data,
workflowId,
teamId,
currentWorkflow
);
const updatedWorkflow = await this.workflowsRepository.updateEventTypeTeamWorkflow(
user,
teamId,
workflowId,
mappedData
);
if (!updatedWorkflow) {
throw new BadRequestException(`Could not update Workflow with ID ${workflowId} in team ${teamId}`);
}
const output = this.workflowOutputService.toEventTypeOutputDto(updatedWorkflow);
if (!output) {
throw new BadRequestException(`Could not format workflow for response.`);
}
return output;
}
async deleteTeamEventTypeWorkflow(teamId: number, workflowId: number) {
return await this.workflowsRepository.deleteTeamWorkflowById(teamId, workflowId);
}
}
@@ -1,14 +1,14 @@
import { UserWithProfile } from "@/modules/users/users.repository";
import { TeamsVerifiedResourcesRepository } from "@/modules/verified-resources/teams-verified-resources.repository";
import { CreateWorkflowDto } from "@/modules/workflows/inputs/create-workflow.input";
import { UpdateWorkflowDto } from "@/modules/workflows/inputs/update-workflow.input";
import { CreateFormWorkflowDto } from "@/modules/workflows/inputs/create-form-workflow";
import { UpdateFormWorkflowDto } from "@/modules/workflows/inputs/update-form-workflow.input";
import { WorkflowsInputService } from "@/modules/workflows/services/workflows.input.service";
import { WorkflowsOutputService } from "@/modules/workflows/services/workflows.output.service";
import { WorkflowsRepository } from "@/modules/workflows/workflows.repository";
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
@Injectable()
export class TeamWorkflowsService {
export class TeamRoutingFormWorkflowsService {
constructor(
private readonly workflowsRepository: WorkflowsRepository,
private readonly teamsVerifiedResourcesRepository: TeamsVerifiedResourcesRepository,
@@ -16,32 +16,44 @@ export class TeamWorkflowsService {
private readonly workflowOutputService: WorkflowsOutputService
) {}
async getTeamWorkflows(teamId: number, skip: number, take: number) {
const workflows = await this.workflowsRepository.getTeamWorkflows(teamId, skip, take);
async getRoutingFormTeamWorkflows(teamId: number, skip: number, take: number) {
const workflows = await this.workflowsRepository.getRoutingFormTeamWorkflows(teamId, skip, take);
return workflows.map((workflow) => this.workflowOutputService.toOutputDto(workflow));
return workflows.map((workflow) => {
const output = this.workflowOutputService.toRoutingFormOutputDto(workflow);
if (!output) {
throw new BadRequestException(`Could not format workflow for response.`);
}
return output;
});
}
async getTeamWorkflowById(teamId: number, workflowId: number) {
const workflow = await this.workflowsRepository.getTeamWorkflowById(teamId, workflowId);
async getRoutingFormTeamWorkflowById(teamId: number, workflowId: number) {
const workflow = await this.workflowsRepository.getRoutingFormTeamWorkflowById(teamId, workflowId);
if (!workflow) {
throw new NotFoundException(`Workflow with ID ${workflowId} not found for team ${teamId}`);
}
return this.workflowOutputService.toOutputDto(workflow);
const output = this.workflowOutputService.toRoutingFormOutputDto(workflow);
if (!output) {
throw new BadRequestException(`Could not format workflow for response.`);
}
return output;
}
async createTeamWorkflow(user: UserWithProfile, teamId: number, data: CreateWorkflowDto) {
async createFormTeamWorkflow(user: UserWithProfile, teamId: number, data: CreateFormWorkflowDto) {
const workflowHusk = await this.workflowsRepository.createTeamWorkflowHusk(teamId);
const mappedData = await this.workflowInputService.mapUpdateDtoToZodUpdateSchema(
const mappedData = await this.workflowInputService.mapFormUpdateDtoToZodSchema(
data,
workflowHusk.id,
teamId,
workflowHusk
);
const createdWorkflow = await this.workflowsRepository.updateTeamWorkflow(
const createdWorkflow = await this.workflowsRepository.updateRoutingFormTeamWorkflow(
user,
teamId,
workflowHusk.id,
@@ -51,28 +63,33 @@ export class TeamWorkflowsService {
throw new BadRequestException(`Could not create Workflow in team ${teamId}`);
}
return this.workflowOutputService.toOutputDto(createdWorkflow);
const output = this.workflowOutputService.toRoutingFormOutputDto(createdWorkflow);
if (!output) {
throw new BadRequestException(`Could not format workflow for response.`);
}
return output;
}
async updateTeamWorkflow(
async updateFormTeamWorkflow(
user: UserWithProfile,
teamId: number,
workflowId: number,
data: UpdateWorkflowDto
data: UpdateFormWorkflowDto
) {
const currentWorkflow = await this.workflowsRepository.getTeamWorkflowById(teamId, workflowId);
const currentWorkflow = await this.workflowsRepository.getRoutingFormTeamWorkflowById(teamId, workflowId);
if (!currentWorkflow) {
throw new NotFoundException(`Workflow with ID ${workflowId} not found for team ${teamId}`);
}
const mappedData = await this.workflowInputService.mapUpdateDtoToZodUpdateSchema(
const mappedData = await this.workflowInputService.mapFormUpdateDtoToZodSchema(
data,
workflowId,
teamId,
currentWorkflow
);
const updatedWorkflow = await this.workflowsRepository.updateTeamWorkflow(
const updatedWorkflow = await this.workflowsRepository.updateRoutingFormTeamWorkflow(
user,
teamId,
workflowId,
@@ -83,10 +100,15 @@ export class TeamWorkflowsService {
throw new BadRequestException(`Could not update Workflow with ID ${workflowId} in team ${teamId}`);
}
return this.workflowOutputService.toOutputDto(updatedWorkflow);
const output = this.workflowOutputService.toRoutingFormOutputDto(updatedWorkflow);
if (!output) {
throw new BadRequestException(`Could not format workflow for response.`);
}
return output;
}
async deleteTeamWorkflow(teamId: number, workflowId: number) {
async deleteTeamRoutingFormWorkflow(teamId: number, workflowId: number) {
return await this.workflowsRepository.deleteTeamWorkflowById(teamId, workflowId);
}
}
@@ -1,11 +1,6 @@
import { TeamsVerifiedResourcesRepository } from "@/modules/verified-resources/teams-verified-resources.repository";
import {
UpdateWorkflowDto,
UpdateWorkflowStepDto,
UpdateEmailAttendeeWorkflowStepDto,
UpdateEmailAddressWorkflowStepDto,
UpdateEmailHostWorkflowStepDto,
} from "@/modules/workflows/inputs/update-workflow.input";
import { UpdateEventTypeWorkflowDto } from "@/modules/workflows/inputs/update-event-type-workflow.input";
import { UpdateFormWorkflowDto } from "@/modules/workflows/inputs/update-form-workflow.input";
import { WorkflowType } from "@/modules/workflows/workflows.repository";
import { BadRequestException, Injectable } from "@nestjs/common";
@@ -21,14 +16,13 @@ import {
STEP_ACTIONS_TO_ENUM,
TemplateType,
TextWorkflowMessageDto,
UpdateWorkflowStepDto,
WHATSAPP_ATTENDEE,
WHATSAPP_NUMBER,
WorkflowPhoneNumberStepDto,
WorkflowPhoneWhatsAppNumberStepDto,
} from "../inputs/workflow-step.input";
import {
OnAfterEventTriggerDto,
OnBeforeEventTriggerDto,
OffsetTriggerDTOInstances,
OffsetTriggerDTOInstancesType,
TIME_UNIT_TO_ENUM,
WORKFLOW_TRIGGER_TO_ENUM,
} from "../inputs/workflow-trigger.input";
@@ -37,6 +31,37 @@ import {
export class WorkflowsInputService {
constructor(private readonly teamsVerifiedResourcesRepository: TeamsVerifiedResourcesRepository) {}
private _isOffsetTrigger(
trigger: UpdateEventTypeWorkflowDto["trigger"] | UpdateFormWorkflowDto["trigger"]
): trigger is OffsetTriggerDTOInstancesType {
return OffsetTriggerDTOInstances.some((Instance) => trigger instanceof Instance);
}
private async _getTeamPhoneNumberFromVerifiedId(teamId: number, verifiedPhoneId: number) {
const phoneResource = await this.teamsVerifiedResourcesRepository.getTeamVerifiedPhoneNumberById(
verifiedPhoneId,
teamId
);
if (!phoneResource?.phoneNumber) {
throw new BadRequestException("Invalid Verified Phone Id.");
}
return phoneResource.phoneNumber;
}
private async _getTeamEmailFromVerifiedId(teamId: number, verifiedEmailId: number) {
const emailResource = await this.teamsVerifiedResourcesRepository.getTeamVerifiedEmailById(
verifiedEmailId,
teamId
);
if (!emailResource?.email) {
throw new BadRequestException("Invalid Verified Email Id.");
}
return emailResource.email;
}
private async mapUpdateWorkflowStepToZodUpdateSchema(
stepDto: UpdateWorkflowStepDto,
index: number,
@@ -45,61 +70,42 @@ export class WorkflowsInputService {
) {
let reminderBody: string | null = null;
let sendTo: string | null = null;
let phoneRequired: boolean | null = null;
const html = stepDto.message instanceof HtmlWorkflowMessageDto ? stepDto.message.html : null;
const text = stepDto.message instanceof TextWorkflowMessageDto ? stepDto.message.text : null;
const includeCalendarEvent =
stepDto instanceof UpdateEmailAddressWorkflowStepDto ||
stepDto instanceof UpdateEmailAttendeeWorkflowStepDto ||
stepDto instanceof UpdateEmailHostWorkflowStepDto
? stepDto.includeCalendarEvent
: false;
let includeCalendarEvent = false;
switch (stepDto.action) {
case EMAIL_HOST:
case EMAIL_ATTENDEE:
case EMAIL_ADDRESS:
reminderBody = html ?? null;
includeCalendarEvent = stepDto.includeCalendarEvent;
break;
case SMS_ATTENDEE:
phoneRequired = stepDto.phoneRequired ?? false;
break;
case SMS_NUMBER:
break;
case WHATSAPP_ATTENDEE:
phoneRequired = stepDto.phoneRequired ?? false;
break;
case WHATSAPP_NUMBER:
reminderBody = text ?? null;
break;
}
if (stepDto.action === EMAIL_ADDRESS) {
if (stepDto.verifiedEmailId) {
const emailResource = await this.teamsVerifiedResourcesRepository.getTeamVerifiedEmailById(
stepDto.verifiedEmailId,
teamId
);
if (!emailResource?.email) {
throw new BadRequestException("Invalid Verified Email Id.");
}
sendTo = emailResource.email;
sendTo = await this._getTeamEmailFromVerifiedId(teamId, stepDto.verifiedEmailId);
}
} else if (stepDto.action === SMS_NUMBER || stepDto.action === WHATSAPP_NUMBER) {
if (
stepDto instanceof WorkflowPhoneNumberStepDto ||
stepDto instanceof WorkflowPhoneWhatsAppNumberStepDto
) {
if (stepDto.verifiedPhoneId) {
const phoneResource = await this.teamsVerifiedResourcesRepository.getTeamVerifiedPhoneNumberById(
stepDto.verifiedPhoneId,
teamId
);
if (!phoneResource?.phoneNumber) {
throw new BadRequestException("Invalid Verified Phone Id.");
}
sendTo = phoneResource.phoneNumber;
}
if (stepDto.verifiedPhoneId) {
sendTo = await this._getTeamPhoneNumberFromVerifiedId(teamId, stepDto.verifiedPhoneId);
}
}
const actionForZod = STEP_ACTIONS_TO_ENUM[stepDto.action];
const templateForZod = stepDto.template as unknown as Uppercase<TemplateType>;
const templateForZod = stepDto?.template?.toUpperCase() as unknown as Uppercase<TemplateType>;
return {
id: stepDto.id ?? -(index + 1),
@@ -110,19 +116,19 @@ export class WorkflowsInputService {
reminderBody: reminderBody,
emailSubject: stepDto.message.subject ?? null,
template: templateForZod,
numberRequired: null,
numberRequired: phoneRequired,
sender: stepDto.sender ?? null,
senderName: stepDto.sender ?? null,
includeCalendarEvent: includeCalendarEvent,
};
}
async mapUpdateDtoToZodUpdateSchema(
updateDto: UpdateWorkflowDto,
workflowIdToUse: number,
private async _mapCommonWorkflowProperties(
updateDto: UpdateEventTypeWorkflowDto | UpdateFormWorkflowDto,
currentData: WorkflowType,
teamId: number,
currentData: WorkflowType
): Promise<TUpdateInputSchema> {
workflowIdToUse: number
) {
const mappedSteps = updateDto?.steps
? await Promise.all(
updateDto.steps.map(async (stepDto: UpdateWorkflowStepDto, index: number) =>
@@ -134,28 +140,85 @@ export class WorkflowsInputService {
const triggerForZod = updateDto?.trigger?.type
? WORKFLOW_TRIGGER_TO_ENUM[updateDto?.trigger?.type]
: currentData.trigger;
const timeUnitForZod =
updateDto.trigger instanceof OnBeforeEventTriggerDto ||
updateDto.trigger instanceof OnAfterEventTriggerDto
? updateDto?.trigger?.offset?.unit ?? currentData.timeUnit ?? null
: undefined;
const timeUnitForZod = this._isOffsetTrigger(updateDto.trigger)
? updateDto?.trigger?.offset?.unit ?? currentData.timeUnit ?? null
: undefined;
const time = this._isOffsetTrigger(updateDto.trigger)
? updateDto?.trigger?.offset?.value ?? currentData?.time ?? null
: null;
const timeUnit = timeUnitForZod ? TIME_UNIT_TO_ENUM[timeUnitForZod] : null;
return { mappedSteps, triggerForZod, time, timeUnit };
}
async mapEventTypeUpdateDtoToZodSchema(
updateDto: UpdateEventTypeWorkflowDto,
workflowIdToUse: number,
teamId: number,
currentData: WorkflowType
): Promise<TUpdateInputSchema> {
const { mappedSteps, triggerForZod, time, timeUnit } = await this._mapCommonWorkflowProperties(
updateDto,
currentData,
teamId,
workflowIdToUse
);
const updateData: TUpdateInputSchema = {
id: workflowIdToUse,
name: updateDto.name ?? currentData.name,
activeOn:
steps: mappedSteps,
trigger: triggerForZod,
time: time,
timeUnit: timeUnit,
// Event-type specific logic
activeOnEventTypeIds:
updateDto?.activation?.activeOnEventTypeIds ??
currentData?.activeOn.map((active) => active.eventTypeId) ??
[],
isActiveOnAll: updateDto?.activation?.isActiveOnAllEventTypes ?? currentData.isActiveOnAll ?? false,
// Explicitly set form-related fields to their default/empty state
activeOnRoutingFormIds: [],
} as const satisfies TUpdateInputSchema;
return updateData;
}
async mapFormUpdateDtoToZodSchema(
updateDto: UpdateFormWorkflowDto,
workflowIdToUse: number,
teamId: number,
currentData: WorkflowType
): Promise<TUpdateInputSchema> {
const { mappedSteps, triggerForZod, time, timeUnit } = await this._mapCommonWorkflowProperties(
updateDto,
currentData,
teamId,
workflowIdToUse
);
const updateData: TUpdateInputSchema = {
id: workflowIdToUse,
name: updateDto.name ?? currentData.name,
steps: mappedSteps,
trigger: triggerForZod,
time:
updateDto.trigger instanceof OnBeforeEventTriggerDto ||
updateDto.trigger instanceof OnAfterEventTriggerDto
? updateDto?.trigger?.offset?.value ?? currentData?.time ?? null
: null,
timeUnit: timeUnitForZod ? TIME_UNIT_TO_ENUM[timeUnitForZod] : null,
isActiveOnAll: updateDto?.activation?.isActiveOnAllEventTypes ?? currentData.isActiveOnAll ?? false,
time: time,
timeUnit: timeUnit,
// Form-specific logic
activeOnRoutingFormIds:
updateDto?.activation?.activeOnRoutingFormIds ??
currentData?.activeOnRoutingForms.map((active) => active.routingFormId) ??
[],
isActiveOnAll: updateDto?.activation?.isActiveOnAllRoutingForms ?? currentData.isActiveOnAll ?? false,
// Explicitly set event-type-related fields to their default/empty state
activeOnEventTypeIds: [],
} as const satisfies TUpdateInputSchema;
return updateData;
@@ -1,48 +1,223 @@
import { WorkflowActivationDto, TriggerDtoType } from "@/modules/workflows/inputs/create-workflow.input";
import { WorkflowOutput, WorkflowStepOutputDto } from "@/modules/workflows/outputs/workflow.output";
import { WorkflowActivationDto } from "@/modules/workflows/inputs/create-event-type-workflow.input";
import { WorkflowFormActivationDto } from "@/modules/workflows/inputs/create-form-workflow";
import {
EventTypeWorkflowStepOutputDto,
EventTypeWorkflowOutput,
} from "@/modules/workflows/outputs/event-type-workflow.output";
import {
RoutingFormWorkflowOutput,
RoutingFormWorkflowStepOutputDto,
} from "@/modules/workflows/outputs/routing-form-workflow.output";
import { WorkflowType } from "@/modules/workflows/workflows.repository";
import { Injectable } from "@nestjs/common";
import {
ATTENDEE,
CAL_AI_PHONE_CALL,
EMAIL,
EMAIL_ADDRESS,
EMAIL_ATTENDEE,
EMAIL_HOST,
ENUM_TO_STEP_ACTIONS,
ENUM_TO_TEMPLATES,
FORM_ALLOWED_STEP_ACTIONS,
FormAllowedStepAction,
HOST,
PHONE_NUMBER,
RecipientType,
SMS_ATTENDEE,
SMS_NUMBER,
StepAction,
WHATSAPP_ATTENDEE,
WHATSAPP_NUMBER,
} from "../inputs/workflow-step.input";
import {
AFTER_EVENT,
AFTER_GUESTS_CAL_VIDEO_NO_SHOW,
AFTER_HOSTS_CAL_VIDEO_NO_SHOW,
BEFORE_EVENT,
ENUM_TO_TIME_UNIT,
ENUM_TO_WORKFLOW_TRIGGER,
HOUR,
OnAfterCalVideoGuestsNoShowTriggerDto,
OnAfterCalVideoHostsNoShowTriggerDto,
OnAfterEventTriggerDto,
OnBeforeEventTriggerDto,
OnCancelTriggerDto,
OnCreationTriggerDto,
OnFormSubmittedTriggerDto,
OnFormSubmittedNoEventTriggerDto,
OnNoShowUpdateTriggerDto,
OnPaidTriggerDto,
OnPaymentInitiatedTriggerDto,
OnRejectedTriggerDto,
OnRequestedTriggerDto,
OnRescheduleTriggerDto,
WORKFLOW_TRIGGER_TO_ENUM,
FORM_WORKFLOW_TRIGGER_TYPES,
ENUM_ROUTING_FORM_WORFLOW_TRIGGERS,
ENUM_OFFSET_WORFLOW_TRIGGERS,
} from "../inputs/workflow-trigger.input";
export type TriggerDtoType =
| OnAfterEventTriggerDto
| OnBeforeEventTriggerDto
| OnCreationTriggerDto
| OnRescheduleTriggerDto
| OnCancelTriggerDto
| OnAfterCalVideoGuestsNoShowTriggerDto
| OnFormSubmittedTriggerDto
| OnFormSubmittedNoEventTriggerDto
| OnRejectedTriggerDto
| OnRequestedTriggerDto
| OnPaymentInitiatedTriggerDto
| OnPaidTriggerDto
| OnNoShowUpdateTriggerDto
| OnAfterCalVideoHostsNoShowTriggerDto;
export type TriggerEventTypeDtoType =
| OnAfterEventTriggerDto
| OnBeforeEventTriggerDto
| OnCreationTriggerDto
| OnRescheduleTriggerDto
| OnCancelTriggerDto
| OnAfterCalVideoGuestsNoShowTriggerDto
| OnRejectedTriggerDto
| OnRequestedTriggerDto
| OnPaymentInitiatedTriggerDto
| OnPaidTriggerDto
| OnNoShowUpdateTriggerDto
| OnAfterCalVideoHostsNoShowTriggerDto;
type StepConfig = {
recipient: RecipientType;
messageKey: "html" | "text";
setsCustomRecipient: boolean;
requiresPhone: boolean;
};
const ACTION_CONFIG_MAP = {
[EMAIL_HOST]: {
recipient: HOST,
messageKey: "html",
setsCustomRecipient: false,
requiresPhone: false,
} satisfies StepConfig,
[EMAIL_ATTENDEE]: {
recipient: ATTENDEE,
messageKey: "html",
setsCustomRecipient: false,
requiresPhone: false,
},
[SMS_ATTENDEE]: {
recipient: ATTENDEE,
messageKey: "text",
setsCustomRecipient: false,
requiresPhone: true,
},
[WHATSAPP_ATTENDEE]: {
recipient: ATTENDEE,
messageKey: "text",
setsCustomRecipient: false,
requiresPhone: true,
},
[EMAIL_ADDRESS]: { recipient: EMAIL, messageKey: "html", setsCustomRecipient: true, requiresPhone: false },
[SMS_NUMBER]: {
recipient: PHONE_NUMBER,
messageKey: "text",
setsCustomRecipient: true,
requiresPhone: false,
},
[WHATSAPP_NUMBER]: {
recipient: PHONE_NUMBER,
messageKey: "text",
setsCustomRecipient: true,
requiresPhone: false,
},
[CAL_AI_PHONE_CALL]: {
recipient: PHONE_NUMBER,
messageKey: "text",
setsCustomRecipient: true,
requiresPhone: false,
},
} satisfies Record<StepAction, StepConfig>;
@Injectable()
export class WorkflowsOutputService {
toOutputDto(workflow: WorkflowType): WorkflowOutput {
const activation: WorkflowActivationDto = {
isActiveOnAllEventTypes: workflow.isActiveOnAll,
activeOnEventTypeIds: workflow.activeOn?.map((relation) => relation.eventTypeId) ?? [],
_isFormAllowedStepAction(action: StepAction): action is FormAllowedStepAction {
return FORM_ALLOWED_STEP_ACTIONS.some((formAction) => formAction === action);
}
_isFormAllowedTrigger(
trigger: WorkflowType["trigger"]
): trigger is (typeof ENUM_ROUTING_FORM_WORFLOW_TRIGGERS)[number] {
return FORM_WORKFLOW_TRIGGER_TYPES.some(
(formTrigger) => WORKFLOW_TRIGGER_TO_ENUM[formTrigger] === trigger
);
}
private _isOffsetTrigger(
trigger: WorkflowType["trigger"]
): trigger is (typeof ENUM_OFFSET_WORFLOW_TRIGGERS)[number] {
return ENUM_OFFSET_WORFLOW_TRIGGERS.some((offsetTrigger) => offsetTrigger === trigger);
}
/**
* Maps a single workflow step from the database entity to its DTO representation.
* @param step The workflow step object from the database.
* @returns An EventTypeWorkflowStepOutputDto.
*/
mapStep(step: WorkflowType["steps"][number], _discriminator: "event-type"): EventTypeWorkflowStepOutputDto;
mapStep(
step: WorkflowType["steps"][number],
_discriminator: "routing-form"
): RoutingFormWorkflowStepOutputDto;
mapStep(
step: WorkflowType["steps"][number],
_discriminator: "event-type" | "routing-form"
): EventTypeWorkflowStepOutputDto | RoutingFormWorkflowStepOutputDto {
const action = ENUM_TO_STEP_ACTIONS[step.action];
const config = ACTION_CONFIG_MAP[action] || {
recipient: ATTENDEE,
setsCustomRecipient: false,
requiresPhone: false,
};
const trigger: TriggerDtoType =
workflow.trigger === WORKFLOW_TRIGGER_TO_ENUM[BEFORE_EVENT] ||
workflow.trigger === WORKFLOW_TRIGGER_TO_ENUM[AFTER_EVENT] ||
workflow.trigger === WORKFLOW_TRIGGER_TO_ENUM[AFTER_GUESTS_CAL_VIDEO_NO_SHOW] ||
workflow.trigger === WORKFLOW_TRIGGER_TO_ENUM[AFTER_HOSTS_CAL_VIDEO_NO_SHOW]
const customRecipient = step.sendTo ?? "";
const reminderBody = step.reminderBody ?? "";
const baseAction = {
id: step.id,
stepNumber: step.stepNumber,
template: ENUM_TO_TEMPLATES[step.template],
recipient: config.recipient,
sender: step.sender ?? "Default Sender",
includeCalendarEvent: step.includeCalendarEvent,
phoneRequired: config.requiresPhone ? step.numberRequired ?? false : undefined,
email: config.recipient === EMAIL ? customRecipient : "",
phone: config.recipient === PHONE_NUMBER ? customRecipient : "",
message: {
subject: step.emailSubject ?? "",
html: config.messageKey === "html" ? reminderBody : undefined,
text: config.messageKey === "text" ? reminderBody : undefined,
},
};
return this._isFormAllowedStepAction(action)
? ({
...baseAction,
action: action,
} satisfies RoutingFormWorkflowStepOutputDto)
: ({
...baseAction,
action: action,
} satisfies EventTypeWorkflowStepOutputDto);
}
toRoutingFormOutputDto(workflow: WorkflowType): RoutingFormWorkflowOutput | void {
if (workflow.type === "ROUTING_FORM" && this._isFormAllowedTrigger(workflow.trigger)) {
const activation: WorkflowFormActivationDto = {
isActiveOnAllRoutingForms: workflow.isActiveOnAll,
activeOnRoutingFormIds:
workflow.activeOnRoutingForms?.map((relation) => relation.routingFormId) ?? [],
};
const trigger: TriggerDtoType = this._isOffsetTrigger(workflow.trigger)
? {
type: ENUM_TO_WORKFLOW_TRIGGER[workflow.trigger],
offset: {
@@ -52,68 +227,50 @@ export class WorkflowsOutputService {
}
: { type: ENUM_TO_WORKFLOW_TRIGGER[workflow.trigger] };
const steps: WorkflowStepOutputDto[] = workflow.steps.map((step) => {
let recipient: RecipientType;
let email = "";
let phone = "";
let text;
let html;
switch (ENUM_TO_STEP_ACTIONS[step.action]) {
case EMAIL_HOST:
recipient = HOST;
html = step.reminderBody ?? "";
break;
case EMAIL_ATTENDEE:
html = step.reminderBody ?? "";
recipient = ATTENDEE;
break;
case SMS_ATTENDEE:
text = step.reminderBody ?? "";
recipient = ATTENDEE;
break;
case WHATSAPP_ATTENDEE:
text = step.reminderBody ?? "";
recipient = ATTENDEE;
break;
case EMAIL_ADDRESS:
html = step.reminderBody ?? "";
recipient = EMAIL;
email = step.sendTo ?? "";
break;
case SMS_NUMBER:
case WHATSAPP_NUMBER:
text = step.reminderBody ?? "";
recipient = PHONE_NUMBER;
phone = step.sendTo ?? "";
break;
default:
recipient = ATTENDEE;
}
const steps: RoutingFormWorkflowStepOutputDto[] = workflow.steps.map((step) => {
return this.mapStep(step, "routing-form");
});
return {
id: step.id,
stepNumber: step.stepNumber,
action: ENUM_TO_STEP_ACTIONS[step.action],
recipient: recipient,
email,
phone,
template: ENUM_TO_TEMPLATES[step.template],
includeCalendarEvent: step.includeCalendarEvent,
sender: step.sender ?? "Default Sender",
message: {
subject: step.emailSubject ?? "",
text,
html,
},
id: workflow.id,
name: workflow.name,
activation: activation,
trigger: trigger,
steps: steps.sort((stepA, stepB) => stepA.stepNumber - stepB.stepNumber),
type: "routing-form",
};
});
}
}
return {
id: workflow.id,
name: workflow.name,
activation: activation,
trigger: trigger,
steps: steps.sort((stepA, stepB) => stepA.stepNumber - stepB.stepNumber),
};
toEventTypeOutputDto(workflow: WorkflowType): EventTypeWorkflowOutput | void {
if (workflow.type === "EVENT_TYPE" && !this._isFormAllowedTrigger(workflow.trigger)) {
const activation: WorkflowActivationDto = {
isActiveOnAllEventTypes: workflow.isActiveOnAll,
activeOnEventTypeIds: workflow.activeOn?.map((relation) => relation.eventTypeId) ?? [],
};
const trigger: TriggerEventTypeDtoType = this._isOffsetTrigger(workflow.trigger)
? {
type: ENUM_TO_WORKFLOW_TRIGGER[workflow.trigger],
offset: {
value: workflow.time ?? 1,
unit: workflow.timeUnit ? ENUM_TO_TIME_UNIT[workflow.timeUnit] : HOUR,
},
}
: { type: ENUM_TO_WORKFLOW_TRIGGER[workflow.trigger] };
const steps: EventTypeWorkflowStepOutputDto[] = workflow.steps.map((step) => {
return this.mapStep(step, "event-type");
});
return {
id: workflow.id,
name: workflow.name,
activation: activation,
trigger: trigger,
steps: steps.sort((stepA, stepB) => stepA.stepNumber - stepB.stepNumber),
type: "event-type",
};
}
}
}
@@ -9,7 +9,11 @@ import { updateWorkflow } from "@calcom/platform-libraries/workflows";
import type { PrismaClient } from "@calcom/prisma";
import type { Workflow, WorkflowStep } from "@calcom/prisma/client";
export type WorkflowType = Workflow & { activeOn: { eventTypeId: number }[]; steps: WorkflowStep[] };
export type WorkflowType = Workflow & {
activeOn: { eventTypeId: number }[];
steps: WorkflowStep[];
activeOnRoutingForms: { routingFormId: string }[];
};
@Injectable()
export class WorkflowsRepository {
@@ -19,29 +23,68 @@ export class WorkflowsRepository {
return await this.dbWrite.prisma.workflow.delete({ where: { id: workflowId, teamId } });
}
async getTeamWorkflowById(teamId: number, id: number): Promise<WorkflowType | null> {
async getEventTypeTeamWorkflowById(teamId: number, id: number): Promise<WorkflowType | null> {
const workflow = await this.dbRead.prisma.workflow.findUnique({
where: {
id: id,
teamId: teamId,
type: "EVENT_TYPE",
},
include: {
steps: true,
activeOn: { select: { eventTypeId: true } },
activeOnRoutingForms: { select: { routingFormId: true } },
},
});
return workflow;
}
async getTeamWorkflows(teamId: number, skip: number, take: number): Promise<WorkflowType[]> {
const workflows = await this.dbRead.prisma.workflow.findMany({
async getRoutingFormTeamWorkflowById(teamId: number, id: number): Promise<WorkflowType | null> {
const workflow = await this.dbRead.prisma.workflow.findUnique({
where: {
id: id,
teamId: teamId,
type: "ROUTING_FORM",
},
include: {
steps: true,
activeOn: { select: { eventTypeId: true } },
activeOnRoutingForms: { select: { routingFormId: true } },
},
});
return workflow;
}
async getEventTypeTeamWorkflows(teamId: number, skip: number, take: number): Promise<WorkflowType[]> {
const workflows = await this.dbRead.prisma.workflow.findMany({
where: {
teamId: teamId,
type: "EVENT_TYPE",
},
include: {
steps: true,
activeOn: { select: { eventTypeId: true } },
activeOnRoutingForms: { select: { routingFormId: true } },
},
skip,
take,
});
return workflows;
}
async getRoutingFormTeamWorkflows(teamId: number, skip: number, take: number): Promise<WorkflowType[]> {
const workflows = await this.dbRead.prisma.workflow.findMany({
where: {
teamId: teamId,
type: "ROUTING_FORM",
},
include: {
steps: true,
activeOn: { select: { eventTypeId: true } },
activeOnRoutingForms: { select: { routingFormId: true } },
},
skip,
take,
@@ -59,11 +102,11 @@ export class WorkflowsRepository {
timeUnit: TimeUnit.HOUR,
teamId,
},
include: { activeOn: true, steps: true },
include: { activeOn: true, steps: true, activeOnRoutingForms: true },
});
}
async updateTeamWorkflow(
async updateRoutingFormTeamWorkflow(
user: UserWithProfile,
teamId: number,
workflowId: number,
@@ -77,7 +120,25 @@ export class WorkflowsRepository {
input: data,
});
const workflow = await this.getTeamWorkflowById(teamId, workflowId);
const workflow = await this.getRoutingFormTeamWorkflowById(teamId, workflowId);
return workflow;
}
async updateEventTypeTeamWorkflow(
user: UserWithProfile,
teamId: number,
workflowId: number,
data: TUpdateInputSchema
) {
await updateWorkflow({
ctx: {
user: { ...user, locale: user?.locale ?? "en" },
prisma: this.dbWrite.prisma as unknown as PrismaClient,
},
input: data,
});
const workflow = await this.getEventTypeTeamWorkflowById(teamId, workflowId);
return workflow;
}
}
-8
View File
@@ -636,10 +636,6 @@ select:focus {
@apply !border-r-default left-0.5 !border-y-0 !border-l-0;
}
.intercom-lightweight-app {
@apply z-40 !important;
}
@import "../../web/styles/tokens.css";
@layer base {
@@ -1027,10 +1023,6 @@ select:focus {
@apply !border-r-default left-0.5 !border-y-0 !border-l-0;
}
.intercom-lightweight-app {
@apply z-40 !important;
}
.prose :where(a:not([data-card])):not(:where([class~="not-prose"] *)) {
text-decoration: none;
}
@@ -0,0 +1,50 @@
"use client";
import { useParams } from "next/navigation";
import dayjs from "@calcom/dayjs";
import { DecoyBookingSuccessCard } from "@calcom/features/bookings/Booker/components/DecoyBookingSuccessCard";
import { useDecoyBooking } from "@calcom/features/bookings/Booker/components/hooks/useDecoyBooking";
export default function BookingSuccessful() {
const params = useParams();
const uid = params?.uid as string;
const bookingData = useDecoyBooking(uid);
if (!bookingData) {
return null;
}
const { booking } = bookingData;
// Format the data for the BookingSuccessCard
const startTime = booking.startTime ? dayjs(booking.startTime) : null;
const endTime = booking.endTime ? dayjs(booking.endTime) : null;
const timeZone = booking.booker?.timeZone || booking.host?.timeZone || dayjs.tz.guess();
const formattedDate = startTime ? startTime.tz(timeZone).format("dddd, MMMM D, YYYY") : "";
const formattedTime = startTime ? startTime.tz(timeZone).format("h:mm A") : "";
const formattedEndTime = endTime ? endTime.tz(timeZone).format("h:mm A") : "";
const formattedTimeZone = timeZone;
const hostName = booking.host?.name || null;
const hostEmail = null; // Email not stored for spam decoy bookings
const attendeeName = booking.booker?.name || null;
const attendeeEmail = booking.booker?.email || null;
return (
<DecoyBookingSuccessCard
title={booking.title || "Booking"}
formattedDate={formattedDate}
formattedTime={formattedTime}
endTime={formattedEndTime}
formattedTimeZone={formattedTimeZone}
hostName={hostName}
hostEmail={hostEmail}
attendeeName={attendeeName}
attendeeEmail={attendeeEmail}
location={booking.location || null}
/>
);
}
@@ -10,7 +10,7 @@ import { getBookingForReschedule, type GetBookingType } from "@calcom/features/b
import { getOrgFullOrigin, orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import { getOrganizationSEOSettings } from "@calcom/features/ee/organizations/lib/orgSettings";
import type { TeamData } from "@calcom/features/ee/teams/lib/getTeamData";
import { shouldHideBrandingForTeamEvent } from "@calcom/lib/hideBranding";
import { shouldHideBrandingForTeamEvent } from "@calcom/features/profile/lib/hideBranding";
import { loadTranslations } from "@calcom/lib/server/i18n";
import slugify from "@calcom/lib/slugify";
import { BookingStatus, RedirectType } from "@calcom/prisma/enums";
@@ -14,7 +14,7 @@ import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { NEXTJS_CACHE_TTL } from "@calcom/lib/constants";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { TeamRepository } from "@calcom/lib/server/repository/team";
import { UserRepository } from "@calcom/lib/server/repository/user";
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import { prisma } from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
import type { SchedulingType } from "@calcom/prisma/enums";

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