Compare commits

...
Author SHA1 Message Date
github-actions[bot] 8e481f2921 chore: version packages (#24451)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-10-19 08:33:07 -03:00
Dhairyashil ShindeandAnik Dhabal Babu 0e6203ac3c fix: install paypal button team level (#24442)
* fix: install paypal button at team level

* fix: install paypal button at team level

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-10-19 01:18:31 +05:30
Hariom BalharaandDevin AI 624d6d7001 Add embed-iframe import to error pages (#24475)
Import @calcom/embed-core/src/embed-iframe in both the error page component
and _error.tsx to ensure embed functionality is initialized when users
encounter 500 errors. This allows proper communication with parent frames
when the error page is displayed in an embedded context.

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-10-18 15:39:32 -03:00
an_ifrah24andKeith Williams ec03ab632e fix: make vapid optional to prevent api failure at initialisation (#24469)
* fix: added optional check for VAPID keys

* fix: added extra check for vapid to prevent runtime error

* fix: added consistent logger across the file

* Apply suggestion from @keithwillcode

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2025-10-18 18:30:12 +00:00
spandev c81b8145b5 fixed spacing between bold and italic buttons in description of new event type (#24542) 2025-10-18 18:20:57 +00:00
fa35cc5210 chore: organization onboarding refactor (#24381)
* feat: redirect to new onboarding flow

* Getting started

* Brand details

* Preview organization brands

* Orgs team pages

* Invite team steps

* Move to global zustand store

* Few darkmdoe fixes

* Wip onboarding + stripe flow

* Default plan state

Server Action for gettting slug satus of org

* Remove onboardingId

* Confirmation prompt

* Update old onboarding flow handlers to handle new fields

* update onboarding hook

* Filter out organization section for none -company emails

* Match placeholders to users domain

* Drop migration

* Wip new onboarding intent

* WIP flow for self-hosted. Same service call just split logic

* WIP

* Add TODO

* Use onboarding user type instead of trpc session

* WIP

* WIP

* pass role and team name from onboarding to save in schema

* Add test to ensure role + name + team are persisted into onboarding table

* migrate roles to enum values

* Update ENUM

* Fix type error

* Redirect if flag is disabled

* Remove web

* WIP

* WIP

* Fix migration

* Fix calls

* User onboarding User types instead of trpc session

* Fix factory tests

* Fix flow for self hoste

* Type error

* More type fixes

* Fix handler tests

* Fix enum return type being different

* Use consistant types across the oganization stuff

* Fix

* Use TEAM_BILLING for e2e test

* Refactor is not company email and add tests

* Fix

* Fix

* Refactor flow to submit after form complete

* Fix flow with billing disabled

* Fix tests

* Apply suggestion from @coderabbitai[bot]

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

* Rename and move test files

* WIP

* Fix types

* Update repo paths + tests

* Move to service folder

* Fix tests

* Fix types

* Remove old test files

* Restore lock

* Fix path

* Fix tests with new paths and factory logic

* Fix updaetdAt

* WIP onboardingID isolation

* Fix e2e test

* verify test

* Code rabbit

* Rename SelfHostedOnboardongService -> SelfHostedOrganizationOnboardingService

* Fix stores

* Fix type error

* Fix types

* remove tsignore

* Apply suggestion from @coderabbitai[bot]

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

* NITS

* Add the logic to auto complete admin org when billing enabled

* Fix store being weird

* We need to return the parsed value

* fixes

* sync from db always

* Add onboardingSgtore tests

* fix test

* remove step and status

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2025-10-18 14:13:30 +00:00
Volnei MunhozandDevin AI 1eb6ff3e65 chore: calendar cache optmize (#24533)
* wip

* fix: regenerate Prisma types and fix GoogleCalendarSubscriptionAdapter tests

- Run yarn prisma generate to regenerate Prisma client with missing types (WorkflowType enum)
- Fix addMonthsFromNow helper to properly return the calculated date
- Update GoogleCalendarSubscriptionAdapter to use startOf/endOf day for time ranges
- Update test expectations to use Date objects instead of dayjs objects
- Fix all-day event test to use future date instead of today
- All tests now passing (3599 passed)

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

* fix tests

* parametrize months ahead

* improve names

* Improve based on reviews

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-10-17 17:20:08 +00:00
fc4ecf500a fix: Apply organization brand colors and theme to member personal events (#24456)
* fix: Apply organization brand colors and theme to member personal events

- Fetch organization brand colors and theme in getEventTypesFromDB
- Override user brand colors/theme with org values for personal events
- Apply to booking confirmation pages, user booking pages, and routing forms
- Follow same pattern as hideBranding for consistency
- Fixes issue where org brand colors only applied to team events

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

* fix: Resolve type errors for organization brand color and theme overrides

- Added brandColor, darkBrandColor, theme to ProfileRepository findById organization select
- Updated test mock to include new organization fields
- Ensures consistent organization branding types across all queries
- All type errors related to brand color changes now resolved

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

* fix: Remove redundant profile assignment causing type errors

The spread operator already includes profile when it exists.
Explicit assignment causes type conflicts with union types where
profile is not guaranteed to exist in all branches.

Resolves TypeScript error at bookings-single-view.getServerSideProps.tsx:134

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

* fix: Use eventTypeRaw.profile for organization brand color access

Changed eventType.profile to eventTypeRaw.profile in profile object construction
to fix TypeScript errors where profile property doesn't exist on transformed
eventType object. The eventTypeRaw object maintains the original profile field
from the database query.

Resolves TypeScript errors at lines 155, 158, 161 in
bookings-single-view.getServerSideProps.tsx

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

* fix type error

* refactor: Extract branding logic into getBrandingForEventType utility

- Create getBrandingForEventType utility function that handles organization
  brand color overrides for both team and personal events
- For team events: org branding → team branding → null
- For personal events: org branding → user branding → null
- Refactor bookings-single-view to use utility function
- Update team booking page to apply organization brand overrides
- Add organization branding fields to ProfileRepository and team queries

Addresses review feedback from @hariombalhara on PR #24456

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

* Update getBranding.ts

* Change

Set themeBasis to null instead of branding.theme.

* refactor: Simplify branding fallback logic to prevent mixing org/team settings

- Changed getBrandingForEventType to use either parent or team branding entirely
- Simplified team parent branding structure in getServerSideProps
- Prevents inconsistent branding when some properties exist in parent but not others
- Addresses PR review feedback from hariombalhara

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

* addressed review

* test: Add comprehensive unit tests for branding utility functions

- Add 20 unit tests covering getBrandingForEventType, getBrandingForUser, and getBrandingForTeam
- Test organization branding override scenarios for both team and personal events
- Test fallback behavior when organization branding is not set
- Test handling of null and optional branding properties
- Verify theme and color inheritance from parent organizations

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

* test: Simplify branding tests to focus on core scenarios

- Reduce from 20 to 8 essential test cases
- Focus on org override and fallback behavior for each utility
- Remove redundant edge case tests while maintaining full coverage

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: hariom@cal.com <hariombalhara@gmail.com>
2025-10-17 14:03:50 -03:00
Dhairyashil Shinde 9d4522825b chore: fix-vertical-space-between-name-toggle (#24527) 2025-10-17 18:22:46 +05:30
Benny Joo e91bf53d80 refactor: Remove circular deps between @calcom/lib and @calcom/features [2] (#24438)
* move SystemField to features

* migrate workflow service

* merge two tests for team repository

* update imports and migrate team repository

* migrate delegation credential repository

* migrate credential repository

* migrate entityPermissionUtils

* migrate hashedLink service and repository

* migrate membership service

* update imports

* remove file

* migrate buildEventUrlFromBooking

* migrate getAllUserBookings to features

* update imports

* update organizationMock

* migrate slots

* migrate date-ranges to schedules dir

* migrate getAggregatedAvailability

* fix

* refactor

* migrate useCreateEventType hook to features

* migrate assignValueToUser

* migrate validateUsername to auth features

* migrate system field back to lib

* migrate getLabelValueMapFromResponses back to lib

* update imports

* use relative path

* fix type checks

* fix

* fix

* fix tests

* update gh codeowners

* fix

* fix
2025-10-17 06:48:08 -03:00
emrysal 79b319c1dd chore: release v5.8.1
Post release / release (push) Canceled after 0s
2025-10-17 09:36:29 +00:00
Eunjae Lee 9197a73b31 chore: update style of Routed to per period chart on /insights/routing (#24501)
* chore: update style of Routed to per period chart on /insights/routing

* w to max-w

* remove unused variables
2025-10-17 10:44:52 +02:00
Rajiv Sahal ec3656e646 fix: add isDryRun behaviour for date overrides (#24464)
* chore: add `isDryRun` prop for date overrides

* chore: add changesets

* chore: implement PR feedback

* fix: merge conflicts
2025-10-17 08:42:25 +00:00
sean-brydon ae4b807500 PBAC - README guide (#24517) 2025-10-16 22:34:39 +00:00
Hariom BalharaandDevin AI 34a9872df1 fix: enable format on save with Prettier in VS Code (#24502)
- Set editor.formatOnSave to true
- Set Prettier as the default formatter
- ESLint continues to handle code quality via codeActionsOnSave
- Prettier handles all formatting with eslint-config-prettier disabling conflicting rules

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-10-16 12:56:02 +00:00
kusiewiczandchauhan_s 91a480b83a fix: managed user api playground (time format transform) (#24255)
* fix: managed user time format transform

* feat: add transform decorator to handle null/undefined timeFormat values in UpdateManagedUserInput

---------

Co-authored-by: chauhan_s <somaychauhan98@gmail.com>
2025-10-16 08:42:34 +00:00
Dhairyashil ShindeandAnik Dhabal Babu db19bb3d51 feat: add booking uid to zapier payload (#24489)
* feat: add booking uid to zapier payload

* resolve type check error

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-10-16 10:07:49 +02:00
chauhan_sandAnik Dhabal Babu 9afdb1ad7e fix: clear videoCallData and iCalUID when organizer changes in round-robin reassignment (#24474)
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-10-16 09:18:47 +02:00
Joe Au-Yeung 78c2a69389 Script to reassign attributes (#24496) 2025-10-16 07:06:53 +00:00
Yadong (Adam) Zhang 5a483e75c9 fix(apps/web): update description formatting in bookings single view to preserve whitespace (#24440)
- Changed the class of the description paragraph from `break-words` to `break-words whitespace-pre-line` to ensure that whitespace is preserved in the booking description display.
2025-10-16 06:53:47 +00:00
bb9c4bb8f3 feat: Update HubSpot meetings to CANCELLED status instead of archiving on booking cancellation (#24478)
* feat: Update HubSpot meetings to CANCELLED status instead of archiving

When a booking is cancelled in Cal.com, preserve the meeting record
in HubSpot by updating it to a CANCELLED status rather than archiving
it. This maintains the meeting history for better tracking and reporting.

Changes:
- Renamed hubspotDeleteMeeting to hubspotCancelMeeting
- Updated the method to set hs_meeting_outcome to CANCELLED
- Changed from archive() to update() API call
- Removed unused CustomPublicObjectInput interface

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

* fix: Remove return statement to match void return type in deleteEvent

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

* refactor: Remove hs_timestamp from cancel meeting to keep it minimal

Only update the meeting outcome status when cancelling,
without modifying the timestamp field.

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

* fix: Use correct HubSpot API value for canceled meetings

Changed hs_meeting_outcome from "CANCELLED" to "CANCELED" to match
HubSpot API specification. HubSpot uses lowercase American spelling
"CANCELED" as the valid enum value for canceled meeting outcomes.

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-16 03:38:10 +00:00
Anik Dhabal Babu 02fdc1361c perf: redundant query in eventsAppsTab (#24491) 2025-10-16 03:12:31 +00:00
sean-brydon 573132198e Fix pbac being enabled when a team has ANY features (#24494) 2025-10-15 21:04:43 +00:00
Anik Dhabal Babu 9133bc5b16 test: fixed flaky e2e tests (#24490)
* test: fixed flaky e2e tests

* fix
2025-10-15 16:19:57 -03:00
Syed Ali Shahbaz 94ae3a5a73 init] (#24487) 2025-10-15 16:09:21 +00:00
Rajiv Sahal 0757b00db7 feat: list schedules atom (#24205)
* feat: add api v2 endpoint to fetch all user schedules

* chore: update typing

* feat: custom hook to fetch all user schedules

* refactor: shift logic to transform schedules into function of its own

* init: list schedules atom

* feat: api endpoints for list schedules atom

* refactor: accept redirect url as prop

* fix: pass redirect url prop

* integrate list schedules atom with availability settings

* refactor: extract types to be reused in api v2 endpoints

* skip availability settings page for the time being until we have api v2 endpoints in prod

* feat: add docs for list schedules atom

* fixup

* export Schedule type

* make sure we always have a default schedule if user deletes his default schedule

* chore: implement code rabbit feedback

* chore: implement PR feedback

* fix: resolve merge conflicts

* fix: import path

* update platform libraries

* fix: type check

* resolve  merge conflicts

* update atoms export

* update platform libraries schedule

* chore: arrange atoms in alphabetical order

* update atoms controller to include endpoints for list schedules atom

* add create atom scheule atom

* fix: invalidate schedules on new schedule creation

* chore: add changesets
2025-10-15 17:57:22 +02:00
emrysal 3402f79912 chore: release v5.8.0
Post release / release (push) Canceled after 0s
2025-10-15 14:43:51 +00:00
Alex van Andel 56ef5d667c fix: Hotfix in change from @spammer.com to spammer.com (#24479)
* fix: Hotfix in change from @spammer.com to spammer.com

* Test fixes + string

* Removed redundant test, fixed error message

* Comment fixup
2025-10-15 15:38:14 +01:00
Lauris Skraucis bb9d99c3e1 feat: api v2 event type settings - booker bookings limit, emails, round robin reschedule (#24169)
* feat: event type bookerActiveBookingsLimit

* test: booking with bookerActiveBookingsLimit

* fix: typecheck

* feat: org team event type emailSettings & rescheduleWithSameRoundRobinHost

* docs: regenerate swagger

* chore: default booker name and email in examples app

* refactor: email settings description

* refactor: rename email setting properties

* refactor: function to add email settings to metadata

* fix: try to fix e2e

* fix: dont return input bookerActiveBookingsLimit in transformed object

* fix: dont allow recurrence and bookerActiveBookingsLimit

* fix: output service return bookerActiveBookingsLimit
2025-10-15 17:31:35 +03:00
Alex van Andel 1aac14056e fix: Hotfix generated migration for user booker email verfication shouldn't be NOT NULL 2025-10-15 14:51:36 +01:00
Hariom BalharaandDevin AI 7af46cc7ab docs: add repository method conventions to knowledge base (#24443)
- Add comprehensive repository method naming conventions
- Include examples for method naming patterns
- Document separation of concerns between repositories and services
- Emphasize reusability and generic method naming

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-10-15 13:44:06 +00:00
Hariom Balhara 37cec89bf6 fix: Support domains without @ prefix in watchlist (#24476)
* fix: Support domains without @ prefix in watchlist

* fix: free email domain check
2025-10-15 13:42:02 +00:00
sean-brydon 67843adab9 feat: admin feature flags v3 + assign/unassign (#24428)
* Feature grouping

* Add assign/unassign logic for teams feature flags

* Fix types
2025-10-15 12:12:39 +00:00
64297f027c feat: add user-specific email verification setting (#24298)
* feat: add user-specific email verification setting

Add requiresBookerEmailVerification boolean field to User model that allows
users to protect their email from impersonation during bookings.

When enabled, anyone attempting to book using the protected user's email
address (as booker or guest) must complete email verification and be logged
in as that email owner.

Key changes:
- Add requiresBookerEmailVerification field to User schema
- Create settings toggle in /settings/my-account/general
- Update checkIfBookerEmailIsBlocked to check booker's account setting
- Update guest filtering in handleNewBooking and addGuests handlers
- Add i18n translations for new setting
- Check both primary and verified secondary emails

Additional fixes:
- Replace 'any' types with proper Prisma and zod types in user.ts
- Fix member role type in sessionMiddleware.ts
- Fix avatar URL generation bug in sessionMiddleware.ts

These type fixes were necessary to resolve pre-commit lint warnings that
were blocking the commit.

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

* fix: address PR review comments

- Remove unrelated Watchlist index drops from migration
- Add missing Watchlist indexes to schema.prisma to fix drift
- Refactor checkIfBookerEmailIsBlocked to throw ErrorWithCode
- Move HttpError handling to handleNewBooking caller layer

Addresses review comments on PR #24298

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

* refactor: move Prisma queries to UserRepository and remove unrelated Watchlist changes

- Add findByEmailWithEmailVerificationSetting method to UserRepository
- Add findManyByEmailsWithEmailVerificationSettings method to UserRepository
- Refactor checkIfUserEmailVerificationRequired handler to use UserRepository
- Refactor addGuests handler to use UserRepository
- Remove unrelated Watchlist schema indices (organizationId/isGlobal, source)
- Remove unrelated WatchlistAudit unique constraint on id

Addresses review comments on PR #24298

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

* fix: better error codes + use repo

* Updated db query with manully written one using UNION (#24430)

* fix: resolve usage of deprecated secondary email in return value

* fix: type errors from refactors

* fix: address CodeRabbit PR review comments

- Add NOT NULL constraint to requiresBookerEmailVerification migration
- Dedupe guest input by base email to handle plus-addressing correctly
- Compare attendees by base email instead of raw strings
- Send emails only to filtered uniqueGuests (not all guests)
- Improve error logging with actual error details

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

* fix: indices added by mistake

Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>

* chore: update label of setting

* fix: return matched email for guests

* chore: remove whitespace

* test: add comprehensive email verification tests

- Add 9 test scenarios covering user email verification setting
- Test main booker verification (logged in/out, with/without code)
- Test secondary email verification as main booker and guest
- Test guest filtering when verification is required
- Test plus-addressed email handling
- Test multiple guests with mixed verification requirements
- Test invalid verification code error handling
- Update bookingScenario helper to support requiresBookerEmailVerification and secondaryEmails

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

* fix: correct guest placement in test mock data

Move guests array from top-level booking data into responses object
to match expected structure in getBookingData.ts which looks for
responses.guests (line 74).

Fixes three failing tests:
- should filter out guest that requires verification
- should filter out secondary email with verification when added as guest
- should filter only guests requiring verification from multiple guests

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Rodrigo Ehlers <rodrigoehlers@outlook.com>
Co-authored-by: Dhairyashil Shinde <93669429+dhairyashiil@users.noreply.github.com>
Co-authored-by: Rodrigo Ehlers <rodrigo@chatbyte.ai>
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
2025-10-15 11:36:03 +00:00
Anik Dhabal BabuandAlex van Andel 9fe5ff7721 test: Fix duplicate API calls test to handle date-based conditional logic (#24472)
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-10-15 11:35:38 +00:00
Hariom BalharaandDevin AI 3076aca457 fix: Organization User Events' Spam (#24468)
* fix: Organization User Events' Spam

* fix: derive organizationId from hostname for spam check

Instead of only checking the team's parentId or eventType.profile.organizationId,
the spam check now first attempts to derive the organization ID from the hostname.

This ensures that organization-level spam blocking works correctly based on the
domain/subdomain the booker is visiting, which is especially important for
multi-tenant deployments where the same event type might be accessible via
different organization domains.

Changes:
- Extract org slug from hostname using getOrgSlug()
- Fetch organization by slug using OrganizationRepository
- Use hostname-derived orgId for spam check, falling back to team/profile orgId
- Maintains backward compatibility when hostname is not available

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-10-15 11:22:07 +00:00
Udit TakkarandCarinaWolli 5a59bb86cd feat: blocklist table (#24459)
* feat: blocklist table

* feat: blocklist table

* refactor: feedback

* chore: add select

* UI improvements

* chore: remove un unsed

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2025-10-15 11:20:49 +00:00
17ab088af8 fix: Faulty logic around shouldServeCache (#24465)
* Should return `false` if no `teamId` is passed to `getShouldServeCache`

* Change  to falsey check

* test: Add comprehensive tests for shouldServeCache fix

- Add tests for CacheService.getShouldServeCache to verify it returns false when no teamId is provided (instead of undefined)
- Add tests for GoogleCalendarService.getFreeBusyResult to verify the falsey check properly handles undefined, null, 0, empty string, and false values
- All tests verify the fix in PR #24465 which changes undefined return to false and changes === false check to !shouldServeCache

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

* fix: Resolve type errors in test files

- Fix private credential property access by using type assertion
- Add missing IFeaturesRepository methods to mock

Co-Authored-By: joe@cal.com <j.auyeung419@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-15 11:20:25 +00:00
Udit Takkar b81a307bd9 fix: duplicate get schedule (#24471)
* fix: duplciate get schedule

* fix: duplciate get schedule

* chore: remvoe comment

* chore: update comment
2025-10-15 10:45:50 +00:00
b312955838 feat: Add subscription start, trial end, and dates to billing tables (#24408)
* Add subscription start, trial end, and end dates to db

* Add subscription start, trial end, and end date to db

* Write subscription start date on new team subscriptions

* Write subscription start date for new orgs

* Fix typo in stripe billing service file (billling -> billing)

* Use `StripeBillingService.extractSubscriptionDates`

* Remove comments

* Address comment

* Fix typo in file import

* Fix typo in file import

* Add missing SubscriptionStatus enum values

- Add INCOMPLETE, INCOMPLETE_EXPIRED, UNPAID, PAUSED enum values
- These values are referenced in stripe-billing-service.ts status mapping
- Fixes type errors in billing-related code

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-14 21:12:31 -04:00
Udit Takkar 003aaaa18b fix: disble streaming (#24460) 2025-10-14 22:09:50 +00:00
Keith Williams 3b19c064fd chore: Remove /infra folder (#24462) 2025-10-14 19:23:02 +00:00
sean-brydonandcoderabbitai[bot] e26a57bf25 fix: prisma serialisation errors (#24452)
* Prisma serilaization errors

* Rename integration tests

* Update packages/features/pbac/infrastructure/repositories/__tests__/PermissionRepository.integration-test.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-14 15:46:20 -03:00
Rajiv Sahal b5585b3974 feat: create new schedule atom (#24404)
* init: create schedule atom

* feat: api v2 endpoints for create schedule atom

* fixup

* fix: merge conflicts

* feat: update platform libraries

* chore: add disableToasts prop

* fixup: remove classname, not needed

* fix: reset form after schedule is created

* docs for create schedule atom

* better names inside classnames prop

* chore: implement PR feedback

* implement coderabbit feedback
2025-10-14 14:36:25 +00:00
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
1083 changed files with 35718 additions and 9874 deletions
+218
View File
@@ -306,6 +306,224 @@ import { Button } from "@calcom/ui/components/button";
- **Tests**: Same as source file + `.test.ts` or `.spec.ts`
- **Avoid**: Dot-suffixes like `.service.ts`, `.repository.ts` (except for tests, types, specs)
## Repository Method Conventions
Repositories should follow consistent naming and design patterns to promote reusability and maintainability.
### Method Naming Rules
**1. Don't include the repository's entity name in method names**
Method names should be concise and avoid redundancy since the repository class name already indicates the entity type.
```typescript
// ✅ Good - Concise method names
class BookingRepository {
findById(id: string) { ... }
findByUserId(userId: string) { ... }
create(data: BookingCreateInput) { ... }
delete(id: string) { ... }
}
// ❌ Bad - Redundant entity name in methods
class BookingRepository {
findBookingById(id: string) { ... }
findBookingByUserId(userId: string) { ... }
createBooking(data: BookingCreateInput) { ... }
deleteBooking(id: string) { ... }
}
```
**2. Use `include` or similar keywords for methods that fetch relational data**
When a method retrieves additional related entities, make this explicit in the method name using keywords like `include`, `with`, or `andRelations`.
```typescript
// ✅ Good - Clear indication of included relations
class EventTypeRepository {
findById(id: string) {
return prisma.eventType.findUnique({
where: { id },
});
}
findByIdIncludeHosts(id: string) {
return prisma.eventType.findUnique({
where: { id },
include: {
hosts: true,
},
});
}
findByIdIncludeHostsAndSchedule(id: string) {
return prisma.eventType.findUnique({
where: { id },
include: {
hosts: true,
schedule: true,
},
});
}
}
// ❌ Bad - Unclear what data is included
class EventTypeRepository {
findById(id: string) {
return prisma.eventType.findUnique({
where: { id },
include: {
hosts: true,
schedule: true,
},
});
}
findByIdForReporting(id: string) {
return prisma.eventType.findUnique({
where: { id },
include: {
hosts: true,
},
});
}
}
```
**3. Keep methods generic and reusable - avoid use-case-specific names**
Repository methods should be general-purpose and describe what data they return, not how or where it's used. This promotes code reuse across different features.
```typescript
// ✅ Good - Generic, reusable methods
class BookingRepository {
findByUserIdIncludeAttendees(userId: string) {
return prisma.booking.findMany({
where: { userId },
include: {
attendees: true,
},
});
}
findByDateRangeIncludeEventType(startDate: Date, endDate: Date) {
return prisma.booking.findMany({
where: {
startTime: { gte: startDate },
endTime: { lte: endDate },
},
include: {
eventType: true,
},
});
}
}
// ❌ Bad - Use-case-specific method names
class BookingRepository {
findBookingsForReporting(userId: string) {
return prisma.booking.findMany({
where: { userId },
include: {
attendees: true,
},
});
}
findBookingsForDashboard(startDate: Date, endDate: Date) {
return prisma.booking.findMany({
where: {
startTime: { gte: startDate },
endTime: { lte: endDate },
},
include: {
eventType: true,
},
});
}
}
```
**4. No business logic in repositories**
Repositories should only handle data access. Business logic, validations, and complex transformations belong in the Service layer.
```typescript
// ✅ Good - Repository only handles data access
class BookingRepository {
findByIdIncludeAttendees(id: string) {
return prisma.booking.findUnique({
where: { id },
include: {
attendees: true,
},
});
}
updateStatus(id: string, status: BookingStatus) {
return prisma.booking.update({
where: { id },
data: { status },
});
}
}
class BookingService {
async confirmBooking(bookingId: string) {
const booking = await this.bookingRepository.findByIdIncludeAttendees(bookingId);
if (!booking) {
throw new Error("Booking not found");
}
if (booking.status !== "PENDING") {
throw new Error("Only pending bookings can be confirmed");
}
// Business logic: send confirmation emails
await this.emailService.sendConfirmationToAttendees(booking.attendees);
// Update status through repository
return this.bookingRepository.updateStatus(bookingId, "CONFIRMED");
}
}
// ❌ Bad - Business logic in repository
class BookingRepository {
async confirmBooking(bookingId: string) {
const booking = await prisma.booking.findUnique({
where: { id: bookingId },
include: {
attendees: true,
},
});
if (!booking) {
throw new Error("Booking not found");
}
if (booking.status !== "PENDING") {
throw new Error("Only pending bookings can be confirmed");
}
// ❌ Business logic shouldn't be here
await sendEmailToAttendees(booking.attendees);
return prisma.booking.update({
where: { id: bookingId },
data: { status: "CONFIRMED" },
});
}
}
```
### Summary
- Method names should be concise: `findById` not `findBookingById`
- Use `include`/`with` keywords when fetching relations: `findByIdIncludeHosts`
- Keep methods generic and reusable: `findByUserIdIncludeAttendees` not `findBookingsForReporting`
- No business logic in repositories - that belongs in Services
## When using Day.js
```typescript
+4
View File
@@ -482,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
+4 -5
View File
@@ -10,7 +10,6 @@
/apps/web/lib/handleOrgRedirect.ts @calcom/Foundation
/apps/web/middleware.ts @calcom/Foundation
/deploy/**/* @calcom/Foundation
/infra/**/* @calcom/Foundation
/packages/app-store/applecalendar/**/* @calcom/Foundation
/packages/app-store/caldavcalendar/**/* @calcom/Foundation
/packages/app-store/exchange2013calendar/**/* @calcom/Foundation
@@ -25,10 +24,10 @@
/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
/packages/lib/server/getLuckyUser.ts @calcom/Foundation
/packages/lib/slots.ts @calcom/Foundation
/packages/features/availability/lib/getAggregatedAvailability/getAggregatedAvailability.ts @calcom/Foundation
/packages/features/availability/lib/getUserAvailability.ts @calcom/Foundation
/packages/features/bookings/lib/getLuckyUser.ts @calcom/Foundation
/packages/features/schedules/lib/slots.ts @calcom/Foundation
/packages/platform/atoms/**/*WebWrapper.tsx @calcom/Consumer
/packages/platform/**/* @calcom/Platform @calcom/Foundation
/packages/prisma/**/* @calcom/Foundation
+2 -1
View File
@@ -1,6 +1,7 @@
{
"typescript.tsdk": "node_modules/typescript/lib",
"editor.formatOnSave": false,
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
},
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";
@@ -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,10 +1,10 @@
import type { NextApiRequest } from "next";
import { purchaseTeamOrOrgSubscription } from "@calcom/features/ee/teams/lib/payments";
import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository";
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";
@@ -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(),
};
+2 -2
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,
})),
@@ -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({
+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,38 +1,52 @@
import prismock from "../../../../../../tests/libs/__mocks__/prisma";
import type { NextApiRequest } from "next";
import { describe, expect, it, beforeEach } from "vitest";
import { describe, expect, it, beforeEach, vi, afterEach } from "vitest";
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",
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 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 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 () => {
@@ -46,6 +60,8 @@ describe("isLockedOrBlocked", () => {
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,8 +73,12 @@ describe("isLockedOrBlocked", () => {
},
} 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,8 +90,12 @@ describe("isLockedOrBlocked", () => {
},
} 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,7 +107,11 @@ describe("isLockedOrBlocked", () => {
},
} 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(
@@ -2494,7 +2494,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
.expect(400);
expect(response.body.error.message).toEqual(
`Missing attendee phone number - it is required by the event type. Pass it as \"attendee.phoneNumber\" string in the request.`
`Missing attendee phone number - it is required by the event type. Pass it as "attendee.phoneNumber" string in the request.`
);
});
@@ -3026,13 +3026,154 @@ describe("Bookings Endpoints 2024-08-13", () => {
});
});
});
describe("event type with max bookings count per booker", () => {
it("should not allow booking more than maximumActiveBookings per attendee", async () => {
const eventTypeIdWithMaxBookerBookings = await eventTypesRepositoryFixture.create(
{
slug: `max-bookings-count-per-booker-${randomString(10)}`,
length: 60,
title: "Event Type with max bookings count per booker",
maxActiveBookingsPerBooker: 1,
},
user.id
);
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2040, 0, 13, 10, 0, 0)).toISOString(),
eventTypeId: eventTypeIdWithMaxBookerBookings.id,
attendee: {
name: "alice",
email: "alice@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
};
const response = await request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201);
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsBooking(responseBody.data)).toBe(true);
if (responseDataIsBooking(responseBody.data)) {
const data: BookingOutput_2024_08_13 = responseBody.data;
expect(data.id).toBeDefined();
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibly recurring bookings"
);
}
const response2 = await request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(400);
expect(response2.body.error.message).toBe(
"Attendee with this email can't book because the maximum number of active bookings has been reached."
);
const body2: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2040, 0, 13, 12, 0, 0)).toISOString(),
eventTypeId: eventTypeIdWithMaxBookerBookings.id,
attendee: {
name: "bob",
email: "bob@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
};
const response3 = await request(app.getHttpServer())
.post("/v2/bookings")
.send(body2)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201);
const responseBody2: CreateBookingOutput_2024_08_13 = response3.body;
expect(responseBody2.status).toEqual(SUCCESS_STATUS);
expect(responseBody2.data).toBeDefined();
expect(responseDataIsBooking(responseBody2.data)).toBe(true);
if (responseDataIsBooking(responseBody2.data)) {
const data: BookingOutput_2024_08_13 = responseBody2.data;
expect(data.id).toBeDefined();
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibly recurring bookings"
);
}
});
it("should not allow booking more than maximumActiveBookings per attendee and offer rescheduling", async () => {
const eventTypeIdWithMaxBookerBookings = await eventTypesRepositoryFixture.create(
{
slug: `max-bookings-count-per-booker-with-reschedule-${randomString(10)}`,
length: 60,
title: "Event Type with max bookings count per booker with reschedule",
maxActiveBookingsPerBooker: 1,
maxActiveBookingPerBookerOfferReschedule: true,
},
user.id
);
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2040, 0, 13, 14, 0, 0)).toISOString(),
eventTypeId: eventTypeIdWithMaxBookerBookings.id,
attendee: {
name: "charlie",
email: "charlie@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
};
const response = await request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201);
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsBooking(responseBody.data)).toBe(true);
if (responseDataIsBooking(responseBody.data)) {
const data: BookingOutput_2024_08_13 = responseBody.data;
expect(data.id).toBeDefined();
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibly recurring bookings"
);
}
const response2 = await request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(400);
expect(response2.body.error.message).toBe(
`Attendee with this email can't book because the maximum number of active bookings has been reached. You can reschedule your existing booking (${responseBody.data.uid}) to a new timeslot instead.`
);
});
});
});
});
function responseDataIsRecurranceBooking(data: any): data is RecurringBookingOutput_2024_08_13 {
function responseDataIsRecurranceBooking(data: unknown): data is RecurringBookingOutput_2024_08_13 {
return (
!Array.isArray(data) &&
typeof data === "object" &&
data !== null &&
data &&
"id" in data &&
"recurringBookingUid" in data
@@ -3048,11 +3189,11 @@ describe("Bookings Endpoints 2024-08-13", () => {
});
});
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;
}
function responseDataIsRecurringBooking(data: any): data is RecurringBookingOutput_2024_08_13[] {
function responseDataIsRecurringBooking(data: unknown): data is RecurringBookingOutput_2024_08_13[] {
return Array.isArray(data);
}
});
@@ -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";
@@ -37,10 +40,8 @@ import { DateTime } from "luxon";
import { z } from "zod";
import {
handleNewRecurringBooking,
getTranslation,
getAllUserBookings,
handleInstantMeeting,
handleCancelBooking,
roundRobinReassignment,
roundRobinManualReassignment,
@@ -48,7 +49,6 @@ import {
confirmBookingHandler,
getCalendarLinks,
} from "@calcom/platform-libraries";
import { handleNewBooking } from "@calcom/platform-libraries";
import {
CreateBookingInput_2024_08_13,
CreateBookingInput,
@@ -109,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) {
@@ -464,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
@@ -482,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);
@@ -505,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 || "" })),
@@ -528,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) {
@@ -560,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) {
@@ -755,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");
@@ -48,6 +48,19 @@ export class ErrorsBookingsService_2024_08_13 {
throw new BadRequestException("Attempting to book a meeting in the past.");
} else if (error.message === "hosts_unavailable_for_booking") {
throw new BadRequestException(hostsUnavaile);
} else if (error.message === "booker_limit_exceeded_error") {
throw new BadRequestException(
"Attendee with this email can't book because the maximum number of active bookings has been reached."
);
} else if (error.message === "booker_limit_exceeded_error_reschedule") {
const errorData =
"data" in error ? (error.data as { rescheduleUid: string }) : { rescheduleUid: undefined };
let message =
"Attendee with this email can't book because the maximum number of active bookings has been reached.";
if (errorData?.rescheduleUid) {
message += ` You can reschedule your existing booking (${errorData.rescheduleUid}) to a new timeslot instead.`;
}
throw new BadRequestException(message);
}
}
throw error;
@@ -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({
@@ -31,16 +31,18 @@ import {
FrequencyInput,
} from "@calcom/platform-enums";
import { SchedulingType } from "@calcom/platform-libraries";
import type {
ApiSuccessResponse,
CreateEventTypeInput_2024_06_14,
EventTypeOutput_2024_06_14,
GuestsDefaultFieldOutput_2024_06_14,
NameDefaultFieldInput_2024_06_14,
NotesDefaultFieldInput_2024_06_14,
SplitNameDefaultFieldOutput_2024_06_14,
UpdateEventTypeInput_2024_06_14,
import {
type ApiSuccessResponse,
type CreateEventTypeInput_2024_06_14,
type EventTypeOutput_2024_06_14,
type GuestsDefaultFieldOutput_2024_06_14,
type NameDefaultFieldInput_2024_06_14,
type NotesDefaultFieldInput_2024_06_14,
type SplitNameDefaultFieldOutput_2024_06_14,
type UpdateEventTypeInput_2024_06_14,
} from "@calcom/platform-types";
import { FAILED_RECURRING_EVENT_TYPE_WITH_BOOKER_LIMITS_ERROR_MESSAGE } from "@calcom/platform-types/event-types/event-types_2024_06_14/inputs/validators/CantHaveRecurrenceAndBookerActiveBookingsLimit";
import { REQUIRES_AT_LEAST_ONE_PROPERTY_ERROR } from "@calcom/platform-types/utils/RequiresOneOfPropertiesWhenNotDisabled";
import type { PlatformOAuthClient, Team, User, Schedule, EventType } from "@calcom/prisma/client";
const orderBySlug = (a: { slug: string }, b: { slug: string }) => {
@@ -1442,6 +1444,177 @@ describe("Event types Endpoints", () => {
.expect(200);
});
describe("bookerActiveBookingsLimit", () => {
describe("negative tests", () => {
it("should not create an event type with bookerActiveBookingsLimit and recurrence", async () => {
const body: CreateEventTypeInput_2024_06_14 = {
title: "Coding class with bookerActiveBookingsLimit",
slug: "coding-class-booker-active-bookings-limit",
description: "Let's learn how to code like a pro.",
lengthInMinutes: 60,
bookerActiveBookingsLimit: {
maximumActiveBookings: 2,
offerReschedule: true,
},
recurrence: {
frequency: FrequencyInput.weekly,
interval: 2,
occurrences: 10,
},
};
const response = await request(app.getHttpServer())
.post("/api/v2/event-types")
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.set("Authorization", `Bearer ${apiKeyString}`)
.send(body)
.expect(400);
expect(
response.body.error.message.includes(FAILED_RECURRING_EVENT_TYPE_WITH_BOOKER_LIMITS_ERROR_MESSAGE)
).toBe(true);
});
it("should not allow creating an event type with bookerActiveBookingsLimit disabled:false", async () => {
const body: CreateEventTypeInput_2024_06_14 = {
title: "Coding class with bookerActiveBookingsLimit disabled:false",
slug: "coding-class-booker-active-bookings-limit-disabled",
description: "Let's learn how to code like a pro.",
lengthInMinutes: 60,
// note(Lauris): disabled false means that it is enabled so it should have maximumActiveBookings and / or offerReschedule provided
bookerActiveBookingsLimit: {
disabled: false,
},
};
const response = await request(app.getHttpServer())
.post("/api/v2/event-types")
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.set("Authorization", `Bearer ${apiKeyString}`)
.send(body)
.expect(400);
expect(response.body.error.message.includes(REQUIRES_AT_LEAST_ONE_PROPERTY_ERROR)).toBe(true);
});
});
describe("positive tests", () => {
let eventTypeWithBookerActiveBookingsLimitId: number;
it("should create an event type with bookerActiveBookingsLimit", async () => {
const body: CreateEventTypeInput_2024_06_14 = {
title: "Coding class with bookerActiveBookingsLimit",
slug: "coding-class-booker-active-bookings-limit",
description: "Let's learn how to code like a pro.",
lengthInMinutes: 60,
bookerActiveBookingsLimit: {
maximumActiveBookings: 2,
offerReschedule: true,
},
};
return request(app.getHttpServer())
.post("/api/v2/event-types")
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.set("Authorization", `Bearer ${apiKeyString}`)
.send(body)
.expect(201)
.then(async (response) => {
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
const createdEventType = responseBody.data;
expect(createdEventType).toHaveProperty("id");
expect(createdEventType.title).toEqual(body.title);
expect(createdEventType.bookerActiveBookingsLimit).toEqual(body.bookerActiveBookingsLimit);
eventTypeWithBookerActiveBookingsLimitId = createdEventType.id;
});
});
it("should update an event type with bookerActiveBookingsLimit", async () => {
const body: UpdateEventTypeInput_2024_06_14 = {
bookerActiveBookingsLimit: {
disabled: true,
},
};
return request(app.getHttpServer())
.patch(`/api/v2/event-types/${eventTypeWithBookerActiveBookingsLimitId}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.set("Authorization", `Bearer ${apiKeyString}`)
.send(body)
.expect(200)
.then(async (response) => {
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
const updatedEventType = responseBody.data;
expect(updatedEventType.bookerActiveBookingsLimit).toEqual(body.bookerActiveBookingsLimit);
eventTypeWithBookerActiveBookingsLimitId = updatedEventType.id;
});
});
it("should create an event type with bookerActiveBookingsLimit and recurrence disabled", async () => {
const body: CreateEventTypeInput_2024_06_14 = {
title: "Coding class with bookerActiveBookingsLimit and recurrence disabled",
slug: "coding-class-booker-active-bookings-limit-recurrence-disabled",
description: "Let's learn how to code like a pro.",
lengthInMinutes: 60,
bookerActiveBookingsLimit: {
maximumActiveBookings: 2,
offerReschedule: true,
},
recurrence: {
disabled: true,
},
};
return request(app.getHttpServer())
.post("/api/v2/event-types")
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.set("Authorization", `Bearer ${apiKeyString}`)
.send(body)
.expect(201)
.then(async (response) => {
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
const createdEventType = responseBody.data;
expect(createdEventType).toHaveProperty("id");
expect(createdEventType.title).toEqual(body.title);
expect(createdEventType.bookerActiveBookingsLimit).toEqual(body.bookerActiveBookingsLimit);
eventTypeWithBookerActiveBookingsLimitId = createdEventType.id;
});
});
it("should create an event type with recurrence and bookerActiveBookingsLimit disabled", async () => {
const body: CreateEventTypeInput_2024_06_14 = {
title: "Coding class with bookerActiveBookingsLimit disabled and recurrence",
slug: "coding-class-booker-active-bookings-limit-disabled-and-recurrence",
description: "Let's learn how to code like a pro.",
lengthInMinutes: 60,
bookerActiveBookingsLimit: {
disabled: true,
},
recurrence: {
frequency: FrequencyInput.weekly,
interval: 2,
occurrences: 10,
},
};
return request(app.getHttpServer())
.post("/api/v2/event-types")
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.set("Authorization", `Bearer ${apiKeyString}`)
.send(body)
.expect(201)
.then(async (response) => {
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
const createdEventType = responseBody.data;
expect(createdEventType).toHaveProperty("id");
expect(createdEventType.title).toEqual(body.title);
expect(createdEventType.bookerActiveBookingsLimit).toEqual(body.bookerActiveBookingsLimit);
eventTypeWithBookerActiveBookingsLimitId = createdEventType.id;
});
});
});
});
afterAll(async () => {
await oauthClientRepositoryFixture.delete(oAuthClient.id);
await teamRepositoryFixture.delete(organization.id);
@@ -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
@@ -28,7 +28,11 @@ import { UserWithProfile } from "@/modules/users/users.repository";
import { Injectable, BadRequestException } from "@nestjs/common";
import { getApps, getUsersCredentialsIncludeServiceAccountKey } from "@calcom/platform-libraries/app-store";
import { validateCustomEventName, EventTypeMetaDataSchema } from "@calcom/platform-libraries/event-types";
import {
validateCustomEventName,
EventTypeMetaDataSchema,
EventTypeMetadata,
} from "@calcom/platform-libraries/event-types";
import {
CreateEventTypeInput_2024_06_14,
DestinationCalendar_2024_06_14,
@@ -67,11 +71,13 @@ export class InputEventTypesService_2024_06_14 {
eventName: transformedBody.eventName,
});
transformedBody.destinationCalendar &&
(await this.validateInputDestinationCalendar(user.id, transformedBody.destinationCalendar));
if (transformedBody.destinationCalendar) {
await this.validateInputDestinationCalendar(user.id, transformedBody.destinationCalendar);
}
transformedBody.useEventTypeDestinationCalendarEmail &&
(await this.validateInputUseDestinationCalendarEmail(user.id));
if (transformedBody.useEventTypeDestinationCalendarEmail) {
await this.validateInputUseDestinationCalendarEmail(user.id);
}
return transformedBody;
}
@@ -93,11 +99,13 @@ export class InputEventTypesService_2024_06_14 {
eventName: transformedBody.eventName,
});
transformedBody.destinationCalendar &&
(await this.validateInputDestinationCalendar(user.id, transformedBody.destinationCalendar));
if (transformedBody.destinationCalendar) {
await this.validateInputDestinationCalendar(user.id, transformedBody.destinationCalendar);
}
transformedBody.useEventTypeDestinationCalendarEmail &&
(await this.validateInputUseDestinationCalendarEmail(user.id));
if (transformedBody.useEventTypeDestinationCalendarEmail) {
await this.validateInputUseDestinationCalendarEmail(user.id);
}
return transformedBody;
}
@@ -119,6 +127,7 @@ export class InputEventTypesService_2024_06_14 {
customName,
useDestinationCalendarEmail,
disableGuests,
bookerActiveBookingsLimit,
...rest
} = inputEventType;
const confirmationPolicyTransformed = this.transformInputConfirmationPolicy(confirmationPolicy);
@@ -130,6 +139,17 @@ export class InputEventTypesService_2024_06_14 {
? this.getBookingFieldsWithGuestsToggled(bookingFields, disableGuests)
: bookingFields;
const maxActiveBookingsPerBooker = bookerActiveBookingsLimit
? this.transformInputBookerActiveBookingsLimit(bookerActiveBookingsLimit)
: {};
const metadata: EventTypeMetadata = {
bookerLayouts: this.transformInputBookerLayouts(bookerLayouts),
requiresConfirmationThreshold:
confirmationPolicyTransformed?.requiresConfirmationThreshold ?? undefined,
multipleDuration: lengthInMinutesOptions,
};
const eventType = {
...rest,
length: lengthInMinutes,
@@ -140,12 +160,7 @@ export class InputEventTypesService_2024_06_14 {
? this.transformInputIntervalLimits(bookingLimitsDuration)
: undefined,
...this.transformInputBookingWindow(bookingWindow),
metadata: {
bookerLayouts: this.transformInputBookerLayouts(bookerLayouts),
requiresConfirmationThreshold:
confirmationPolicyTransformed?.requiresConfirmationThreshold ?? undefined,
multipleDuration: lengthInMinutesOptions,
},
metadata,
requiresConfirmation: confirmationPolicyTransformed?.requiresConfirmation ?? undefined,
requiresConfirmationWillBlockSlot:
confirmationPolicyTransformed?.requiresConfirmationWillBlockSlot ?? undefined,
@@ -154,11 +169,27 @@ export class InputEventTypesService_2024_06_14 {
...this.transformInputSeatOptions(seats),
eventName: customName,
useEventTypeDestinationCalendarEmail: useDestinationCalendarEmail,
...maxActiveBookingsPerBooker,
};
return eventType;
}
transformInputBookerActiveBookingsLimit(
bookerActiveBookingsLimit: CreateEventTypeInput_2024_06_14["bookerActiveBookingsLimit"]
) {
if (!bookerActiveBookingsLimit || bookerActiveBookingsLimit.disabled) {
return {
maxActiveBookingsPerBooker: null,
maxActiveBookingPerBookerOfferReschedule: false,
};
}
return {
maxActiveBookingsPerBooker: bookerActiveBookingsLimit?.maximumActiveBookings,
maxActiveBookingPerBookerOfferReschedule: bookerActiveBookingsLimit?.offerReschedule,
};
}
async transformInputUpdateEventType(inputEventType: UpdateEventTypeInput_2024_06_14, eventTypeId: number) {
const {
lengthInMinutes,
@@ -176,10 +207,11 @@ export class InputEventTypesService_2024_06_14 {
customName,
useDestinationCalendarEmail,
disableGuests,
bookerActiveBookingsLimit,
...rest
} = inputEventType;
const eventTypeDb = await this.eventTypesRepository.getEventTypeWithMetaData(eventTypeId);
const metadataTransformed = !!eventTypeDb?.metadata
const metadataTransformed = eventTypeDb?.metadata
? EventTypeMetaDataSchema.parse(eventTypeDb.metadata)
: {};
@@ -190,6 +222,18 @@ export class InputEventTypesService_2024_06_14 {
? this.getBookingFieldsWithGuestsToggled(bookingFields, disableGuests)
: bookingFields;
const maxActiveBookingsPerBooker = bookerActiveBookingsLimit
? this.transformInputBookerActiveBookingsLimit(bookerActiveBookingsLimit)
: {};
const metadata: EventTypeMetadata = {
...metadataTransformed,
bookerLayouts: this.transformInputBookerLayouts(bookerLayouts),
requiresConfirmationThreshold:
confirmationPolicyTransformed?.requiresConfirmationThreshold ?? undefined,
multipleDuration: lengthInMinutesOptions,
};
const eventType = {
...rest,
length: lengthInMinutes,
@@ -202,13 +246,7 @@ export class InputEventTypesService_2024_06_14 {
? this.transformInputIntervalLimits(bookingLimitsDuration)
: undefined,
...this.transformInputBookingWindow(bookingWindow),
metadata: {
...metadataTransformed,
bookerLayouts: this.transformInputBookerLayouts(bookerLayouts),
requiresConfirmationThreshold:
confirmationPolicyTransformed?.requiresConfirmationThreshold ?? undefined,
multipleDuration: lengthInMinutesOptions,
},
metadata,
recurringEvent: recurrence ? this.transformInputRecurrignEvent(recurrence) : undefined,
requiresConfirmation: confirmationPolicyTransformed?.requiresConfirmation ?? undefined,
requiresConfirmationWillBlockSlot:
@@ -217,6 +255,7 @@ export class InputEventTypesService_2024_06_14 {
...this.transformInputSeatOptions(seats),
eventName: customName,
useEventTypeDestinationCalendarEmail: useDestinationCalendarEmail,
...maxActiveBookingsPerBooker,
};
return eventType;
@@ -319,7 +358,7 @@ export class InputEventTypesService_2024_06_14 {
transformInputBookingWindow(inputBookingWindow: CreateEventTypeInput_2024_06_14["bookingWindow"]) {
const res = transformFutureBookingLimitsApiToInternal(inputBookingWindow);
return !!res ? res : {};
return res ? res : {};
}
transformInputBookerLayouts(inputBookerLayouts: CreateEventTypeInput_2024_06_14["bookerLayouts"]) {
@@ -369,7 +408,7 @@ export class InputEventTypesService_2024_06_14 {
requiresConfirmationDb = eventTypeDb?.requiresConfirmation ?? false;
}
const seatsPerTimeSlotFinal = !!seatsPerTimeSlot ? seatsPerTimeSlot : seatsPerTimeSlotDb;
const seatsPerTimeSlotFinal = seatsPerTimeSlot ? seatsPerTimeSlot : seatsPerTimeSlotDb;
const seatsEnabledFinal = !!seatsPerTimeSlotFinal && seatsPerTimeSlotFinal > 0;
const locationsFinal = locations !== undefined ? locations : locationsDb;
@@ -394,6 +433,7 @@ export class InputEventTypesService_2024_06_14 {
);
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
transformLocations(locations: any) {
if (!locations) return [];
@@ -92,6 +92,8 @@ type Input = Pick<
| "calVideoSettings"
| "hidden"
| "bookingRequiresAuthentication"
| "maxActiveBookingsPerBooker"
| "maxActiveBookingPerBookerOfferReschedule"
>;
@Injectable()
@@ -165,6 +167,7 @@ export class OutputEventTypesService_2024_06_14 {
periodEndDate: databaseEventType.periodEndDate,
} as TransformFutureBookingsLimitSchema_2024_06_14);
const destinationCalendar = this.transformDestinationCalendar(databaseEventType.destinationCalendar);
const bookerActiveBookingsLimit = this.transformBookerActiveBookingsLimit(databaseEventType);
return {
id,
@@ -210,9 +213,29 @@ export class OutputEventTypesService_2024_06_14 {
calVideoSettings,
hidden,
bookingRequiresAuthentication,
bookerActiveBookingsLimit,
};
}
transformBookerActiveBookingsLimit(databaseEventType: Input) {
const noMaxActiveBookingsPerBooker =
!databaseEventType.maxActiveBookingsPerBooker && databaseEventType.maxActiveBookingsPerBooker !== 0;
const noMaxActiveBookingPerBookerOfferReschedule =
!databaseEventType.maxActiveBookingPerBookerOfferReschedule;
if (noMaxActiveBookingsPerBooker && noMaxActiveBookingPerBookerOfferReschedule) {
return {
disabled: true,
};
}
return {
maximumActiveBookings: databaseEventType.maxActiveBookingsPerBooker ?? undefined,
offerReschedule: databaseEventType.maxActiveBookingPerBookerOfferReschedule ?? undefined,
};
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
transformLocations(locationDb: any) {
if (!locationDb) return [];
@@ -239,6 +262,7 @@ export class OutputEventTypesService_2024_06_14 {
};
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
transformBookingFields(bookingFields: any) {
if (!bookingFields) return [];
@@ -273,6 +297,7 @@ export class OutputEventTypesService_2024_06_14 {
return this.transformBookingFields(defaultBookingFields);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
transformRecurringEvent(recurringEvent: any) {
if (!recurringEvent) return null;
const recurringEventParsed = parseRecurringEvent(recurringEvent);
@@ -280,6 +305,7 @@ export class OutputEventTypesService_2024_06_14 {
return transformRecurrenceInternalToApi(recurringEventParsed);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
transformMetadata(metadata: any) {
if (!metadata) return {};
return EventTypeMetaDataSchema.parse(metadata);
@@ -301,6 +327,7 @@ export class OutputEventTypesService_2024_06_14 {
});
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
transformIntervalLimits(bookingLimits: any) {
const bookingLimitsParsed = parseBookingLimit(bookingLimits);
return transformIntervalLimitsInternalToApi(bookingLimitsParsed);
@@ -327,6 +354,7 @@ export class OutputEventTypesService_2024_06_14 {
);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
transformEventTypeColor(eventTypeColor: any) {
if (!eventTypeColor) return undefined;
const parsedeventTypeColor = parseEventTypeColor(eventTypeColor);
@@ -38,6 +38,8 @@ export type InputEventTransformed_2024_06_14 = Omit<
bookingFields?: ReturnType<typeof transformBookingFieldsApiToInternal>;
durationLimits?: ReturnType<typeof transformIntervalLimitsApiToInternal>;
recurringEvent?: ReturnType<typeof transformRecurrenceApiToInternal>;
maxActiveBookingsPerBooker?: number;
maxActiveBookingPerBookerOfferReschedule?: boolean;
eventTypeColor?: ReturnType<typeof transformEventColorsApiToInternal>;
useEventTypeDestinationCalendarEmail?: boolean;
} & Partial<
@@ -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,
});
}
}
@@ -11,6 +11,7 @@ import {
Param,
ParseIntPipe,
Patch,
Post,
Query,
UseGuards,
Version,
@@ -21,8 +22,16 @@ import {
ApiExcludeController as DocsExcludeController,
ApiOperation,
} from "@nestjs/swagger";
import { z } from "zod";
import { SCHEDULE_READ, SCHEDULE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
import type {
GetAvailabilityListHandlerReturn,
DuplicateScheduleHandlerReturn,
} from "@calcom/platform-libraries/schedules";
import { getAvailabilityListHandler, duplicateScheduleHandler } from "@calcom/platform-libraries/schedules";
import type { CreateScheduleHandlerReturn, CreateScheduleSchema } from "@calcom/platform-libraries/schedules";
import { createScheduleHandler } from "@calcom/platform-libraries/schedules";
import { FindDetailedScheduleByIdReturnType } from "@calcom/platform-libraries/schedules";
import { ApiResponse, UpdateAtomScheduleDto } from "@calcom/platform-types";
@@ -63,6 +72,22 @@ export class AtomsSchedulesController {
data: schedule,
};
}
@Get("/schedules/all")
@Version(VERSION_NEUTRAL)
@UseGuards(ApiAuthGuard)
@Permissions([SCHEDULE_READ])
async getAllUserSchedules(
@GetUser() user: UserWithProfile
): Promise<ApiResponse<Awaited<GetAvailabilityListHandlerReturn>>> {
const userSchedules = await getAvailabilityListHandler({ ctx: { user } });
return {
status: SUCCESS_STATUS,
data: userSchedules,
};
}
@Patch("schedules/:scheduleId")
@Permissions([SCHEDULE_WRITE])
@UseGuards(ApiAuthGuard)
@@ -83,4 +108,36 @@ export class AtomsSchedulesController {
data: updatedSchedule,
};
}
@Post("schedules/create")
@Permissions([SCHEDULE_WRITE])
@UseGuards(ApiAuthGuard)
@ApiOperation({ summary: "Create atom schedule" })
async createSchedule(
@GetUser() user: UserWithProfile,
@Body() bodySchedule: z.infer<typeof CreateScheduleSchema>
): Promise<ApiResponse<CreateScheduleHandlerReturn>> {
const createdSchedule = await createScheduleHandler({ input: bodySchedule, ctx: { user } });
return {
status: SUCCESS_STATUS,
data: createdSchedule,
};
}
@Post("schedules/:scheduleId/duplicate")
@Permissions([SCHEDULE_WRITE])
@UseGuards(ApiAuthGuard)
@ApiOperation({ summary: "Duplicate existing schedule" })
async duplicateExistingSchedule(
@GetUser() user: UserWithProfile,
@Param("scheduleId", ParseIntPipe) scheduleId: number
): Promise<ApiResponse<Awaited<DuplicateScheduleHandlerReturn>>> {
const duplicatedSchedule = await duplicateScheduleHandler({ ctx: { user }, input: { scheduleId } });
return {
status: SUCCESS_STATUS,
data: duplicatedSchedule,
};
}
}
@@ -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({
@@ -355,6 +355,11 @@ describe("Organizations Event Types Endpoints", () => {
darkThemeHex: "#292929",
lightThemeHex: "#fafafa",
},
emailSettings: {
disableEmailsToAttendees: true,
disableEmailsToHosts: true,
},
rescheduleWithSameRoundRobinHost: true,
};
return request(app.getHttpServer())
@@ -385,6 +390,7 @@ describe("Organizations Event Types Endpoints", () => {
expect(data.lockTimeZoneToggleOnBookingPage).toEqual(body.lockTimeZoneToggleOnBookingPage);
expect(data.color).toEqual(body.color);
expect(data.successRedirectUrl).toEqual("https://masterchief.com/argentina/flan/video/1234");
expect(data.emailSettings).toEqual(body.emailSettings);
collectiveEventType = responseBody.data;
});
});
@@ -600,6 +606,11 @@ describe("Organizations Event Types Endpoints", () => {
const body: UpdateTeamEventTypeInput_2024_06_14 = {
hosts: newHosts,
successRedirectUrl: "https://new-url-success.com",
emailSettings: {
disableEmailsToAttendees: false,
disableEmailsToHosts: false,
},
rescheduleWithSameRoundRobinHost: false,
};
return request(app.getHttpServer())
@@ -614,6 +625,8 @@ describe("Organizations Event Types Endpoints", () => {
expect(eventType.successRedirectUrl).toEqual("https://new-url-success.com");
expect(eventType.title).toEqual(collectiveEventType.title);
expect(eventType.hosts.length).toEqual(1);
expect(eventType.emailSettings).toEqual(body.emailSettings);
expect(eventType.rescheduleWithSameRoundRobinHost).toEqual(body.rescheduleWithSameRoundRobinHost);
evaluateHost(eventType.hosts[0], newHosts[0]);
});
});
@@ -8,10 +8,12 @@ import { UsersRepository } from "@/modules/users/users.repository";
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
import { SchedulingType } from "@calcom/platform-libraries";
import { EventTypeMetadata } from "@calcom/platform-libraries/event-types";
import {
CreateTeamEventTypeInput_2024_06_14,
UpdateTeamEventTypeInput_2024_06_14,
HostPriority,
EmailSettings_2024_06_14,
} from "@calcom/platform-types";
export type TransformedCreateTeamEventTypeInput = Awaited<
@@ -49,14 +51,16 @@ export class InputOrganizationsEventTypesService {
eventName: transformedBody.eventName,
});
transformedBody.destinationCalendar &&
(await this.inputEventTypesService.validateInputDestinationCalendar(
if (transformedBody.destinationCalendar) {
await this.inputEventTypesService.validateInputDestinationCalendar(
userId,
transformedBody.destinationCalendar
));
);
}
transformedBody.useEventTypeDestinationCalendarEmail &&
(await this.inputEventTypesService.validateInputUseDestinationCalendarEmail(userId));
if (transformedBody.useEventTypeDestinationCalendarEmail) {
await this.inputEventTypesService.validateInputUseDestinationCalendarEmail(userId);
}
return transformedBody;
}
@@ -83,14 +87,16 @@ export class InputOrganizationsEventTypesService {
eventName: transformedBody.eventName,
});
transformedBody.destinationCalendar &&
(await this.inputEventTypesService.validateInputDestinationCalendar(
if (transformedBody.destinationCalendar) {
await this.inputEventTypesService.validateInputDestinationCalendar(
userId,
transformedBody.destinationCalendar
));
);
}
transformedBody.useEventTypeDestinationCalendarEmail &&
(await this.inputEventTypesService.validateInputUseDestinationCalendarEmail(userId));
if (transformedBody.useEventTypeDestinationCalendarEmail) {
await this.inputEventTypesService.validateInputUseDestinationCalendarEmail(userId);
}
return transformedBody;
}
@@ -110,7 +116,7 @@ export class InputOrganizationsEventTypesService {
teamId: number,
inputEventType: CreateTeamEventTypeInput_2024_06_14
) {
const { hosts, assignAllTeamMembers, locations, ...rest } = inputEventType;
const { hosts, assignAllTeamMembers, locations, emailSettings, ...rest } = inputEventType;
const eventType = this.inputEventTypesService.transformInputCreateEventType(rest);
@@ -123,11 +129,15 @@ export class InputOrganizationsEventTypesService {
const children = await this.getChildEventTypesForManagedEventTypeCreate(inputEventType, teamId);
const metadata =
let metadata =
rest.schedulingType === "MANAGED"
? { managedEventConfig: {}, ...eventType.metadata }
: eventType.metadata;
if (emailSettings) {
metadata = this.addEmailSettingsToMetadata(emailSettings, metadata);
}
const teamEventType = {
...eventType,
// note(Lauris): we don't populate hosts for managed event-types because they are handled by the children
@@ -145,12 +155,42 @@ export class InputOrganizationsEventTypesService {
return teamEventType;
}
private addEmailSettingsToMetadata(
emailSettings: EmailSettings_2024_06_14,
metadata: NonNullable<EventTypeMetadata>
) {
if (
emailSettings?.disableEmailsToAttendees === undefined &&
emailSettings?.disableEmailsToHosts === undefined
) {
return metadata;
}
const clonedMetadata = structuredClone(metadata);
if (!clonedMetadata.disableStandardEmails) {
clonedMetadata.disableStandardEmails = {};
}
if (!clonedMetadata.disableStandardEmails.all) {
clonedMetadata.disableStandardEmails.all = {};
}
if (emailSettings?.disableEmailsToAttendees !== undefined) {
clonedMetadata.disableStandardEmails.all.attendee = emailSettings.disableEmailsToAttendees;
}
if (emailSettings?.disableEmailsToHosts !== undefined) {
clonedMetadata.disableStandardEmails.all.host = emailSettings.disableEmailsToHosts;
}
return clonedMetadata;
}
async transformInputUpdateTeamEventType(
eventTypeId: number,
teamId: number,
inputEventType: UpdateTeamEventTypeInput_2024_06_14
) {
const { hosts, assignAllTeamMembers, locations, ...rest } = inputEventType;
const { hosts, assignAllTeamMembers, locations, emailSettings, ...rest } = inputEventType;
const eventType = await this.inputEventTypesService.transformInputUpdateEventType(rest, eventTypeId);
const dbEventType = await this.teamsEventTypesRepository.getTeamEventType(teamId, eventTypeId);
@@ -164,6 +204,12 @@ export class InputOrganizationsEventTypesService {
? await this.getChildEventTypesForManagedEventTypeUpdate(eventTypeId, inputEventType, teamId)
: undefined;
let metadata = eventType.metadata;
if (emailSettings) {
metadata = this.addEmailSettingsToMetadata(emailSettings, metadata);
}
const teamEventType = {
...eventType,
// note(Lauris): we don't populate hosts for managed event-types because they are handled by the children
@@ -175,6 +221,7 @@ export class InputOrganizationsEventTypesService {
assignAllTeamMembers,
children,
locations: locations ? this.transformInputTeamLocations(locations) : undefined,
metadata,
};
return teamEventType;
@@ -4,6 +4,7 @@ import { UsersRepository } from "@/modules/users/users.repository";
import { Injectable } from "@nestjs/common";
import { SchedulingType } from "@calcom/platform-libraries";
import { EventTypeMetadata } from "@calcom/platform-libraries/event-types";
import type { HostPriority, TeamEventTypeResponseHost } from "@calcom/platform-types";
import type {
Team,
@@ -84,6 +85,9 @@ type Input = Pick<
| "calVideoSettings"
| "hidden"
| "bookingRequiresAuthentication"
| "rescheduleWithSameRoundRobinHost"
| "maxActiveBookingPerBookerOfferReschedule"
| "maxActiveBookingsPerBooker"
>;
@Injectable()
@@ -95,7 +99,12 @@ export class OutputOrganizationsEventTypesService {
) {}
async getResponseTeamEventType(databaseEventType: Input, isOrgTeamEvent: boolean) {
const { teamId, userId, parentId, assignAllTeamMembers } = databaseEventType;
const metadata = this.outputEventTypesService.transformMetadata(databaseEventType.metadata);
const emailSettings = this.transformEmailSettings(metadata);
const { teamId, userId, parentId, assignAllTeamMembers, rescheduleWithSameRoundRobinHost } =
databaseEventType;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { ownerId, users, ...rest } = this.outputEventTypesService.getResponseEventType(
0,
@@ -117,6 +126,7 @@ export class OutputOrganizationsEventTypesService {
? this.getResponseSchedulingType(databaseEventType.schedulingType)
: databaseEventType.schedulingType,
assignAllTeamMembers: teamId ? assignAllTeamMembers : undefined,
emailSettings,
team: {
id: teamId,
name: databaseEventType?.team?.name,
@@ -128,6 +138,7 @@ export class OutputOrganizationsEventTypesService {
darkBrandColor: databaseEventType?.team?.darkBrandColor,
theme: databaseEventType?.team?.theme,
},
rescheduleWithSameRoundRobinHost,
};
}
@@ -194,6 +205,23 @@ export class OutputOrganizationsEventTypesService {
return transformedHosts;
}
private transformEmailSettings(metadata: EventTypeMetadata) {
if (!metadata?.disableStandardEmails?.all) {
return undefined;
}
const { attendee, host } = metadata.disableStandardEmails.all;
if (attendee !== undefined || host !== undefined) {
return {
disableEmailsToAttendees: attendee ?? false,
disableEmailsToHosts: host ?? false,
};
}
return undefined;
}
}
function getPriorityLabel(priority: number): keyof typeof HostPriority {
@@ -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 };
}
}
@@ -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(
@@ -1,9 +1,10 @@
import { GetUsersInput } from "@/modules/users/inputs/get-users.input";
import { ApiPropertyOptional } from "@nestjs/swagger";
import { Transform } from "class-transformer";
import { ArrayMaxSize, ArrayNotEmpty, IsEmail, IsOptional } from "class-validator";
export class GetTeamMembershipsInput extends GetUsersInput {
import { SkipTakePagination } from "@calcom/platform-types";
export class GetTeamMembershipsInput extends SkipTakePagination {
@IsOptional()
@Transform(({ value }) => {
if (value == null) return undefined;
@@ -1,8 +1,8 @@
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 { Transform, Type } from "class-transformer";
import { IsOptional, IsTimeZone, IsString, IsEnum, IsIn, IsUrl, IsObject, IsNumber } from "class-validator";
import { ValidateMetadata } from "@calcom/platform-types";
@@ -17,6 +17,8 @@ export class CreateManagedUserInput {
@ApiProperty({ example: "Alice Smith", description: "Managed user's name is used in emails" })
name!: string;
@Type(() => Number)
@IsNumber()
@IsOptional()
@IsIn([12, 24], { message: "timeFormat must be a number either 12 or 24" })
@ApiPropertyOptional({ example: 12, enum: [12, 24], description: "Must be a number 12 or 24" })
@@ -2,6 +2,7 @@ import { Locales } from "@/lib/enums/locales";
import { CapitalizeTimeZone } from "@/lib/inputs/capitalize-timezone";
import { TimeFormat, WeekDay } from "@/modules/users/inputs/create-managed-user.input";
import { ApiPropertyOptional } from "@nestjs/swagger";
import { Transform, Type } from "class-transformer";
import { IsEnum, IsIn, IsNumber, IsObject, IsOptional, IsString, IsTimeZone, IsUrl } from "class-validator";
import { ValidateMetadata } from "@calcom/platform-types";
@@ -17,6 +18,8 @@ export class UpdateManagedUserInput {
@ApiPropertyOptional()
name?: string;
@Type(() => Number)
@IsNumber()
@IsOptional()
@IsIn([12, 24])
@ApiPropertyOptional({ example: 12, enum: [12, 24], description: "Must be 12 or 24" })
@@ -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;
}
}
@@ -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";
@@ -13,8 +13,8 @@ import { getTeamEventType } from "@calcom/features/eventtypes/lib/getTeamEventTy
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 { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository";
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";
@@ -45,7 +45,7 @@ export function ShellMainAppDir(props: LayoutProps) {
className={classNames(
props.backPath
? "relative"
: "pwa:bottom-[max(7rem,_calc(5rem_+_env(safe-area-inset-bottom)))] fixed bottom-20 z-40 ltr:right-4 rtl:left-4 md:z-auto md:ltr:right-0 md:rtl:left-0",
: "pwa:bottom-[max(7rem,_calc(5rem_+_env(safe-area-inset-bottom)))] fixed bottom-32 z-40 ltr:right-4 rtl:left-4 md:z-auto md:ltr:right-0 md:rtl:left-0",
"flex-shrink-0 [-webkit-app-region:no-drag] md:relative md:bottom-auto md:right-auto"
)}>
{props.CTA}
@@ -6,9 +6,10 @@ import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { OrganizationRepository } from "@calcom/features/ee/organizations/repositories/OrganizationRepository";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { AvailabilitySliderTable } from "@calcom/features/timezone-buddy/components/AvailabilitySliderTable";
import { OrganizationRepository } from "@calcom/lib/server/repository/organization";
import { getScheduleListItemData } from "@calcom/lib/schedules/transformers/getScheduleListItemData";
import { MembershipRole } from "@calcom/prisma/enums";
import { availabilityRouter } from "@calcom/trpc/server/routers/viewer/availability/_router";
@@ -56,15 +57,7 @@ const Page = async ({ searchParams: _searchParams }: PageProps) => {
// This is because the data is cached and as a result the data is converted to a string
const availabilities = {
...cachedAvailabilities,
schedules: cachedAvailabilities.schedules.map((schedule) => ({
...schedule,
availability: schedule.availability.map((avail) => ({
...avail,
startTime: new Date(avail.startTime),
endTime: new Date(avail.endTime),
date: avail.date ? new Date(avail.date) : null,
})),
})),
schedules: cachedAvailabilities.schedules.map((schedule) => getScheduleListItemData(schedule)),
};
const organizationId = session?.user?.profile?.organizationId ?? session?.user.org?.id;
@@ -4,8 +4,8 @@ import { unstable_cache } from "next/cache";
import { TeamsListing } from "@calcom/features/ee/teams/components/TeamsListing";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { TeamRepository } from "@calcom/lib/server/repository/team";
import { TeamService } from "@calcom/lib/server/service/teamService";
import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository";
import { TeamService } from "@calcom/features/ee/teams/services/teamService";
import prisma from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
@@ -3,7 +3,7 @@ import { cookies, headers } from "next/headers";
import { getAppRegistry, getAppRegistryWithCredentials } from "@calcom/app-store/_appRegistry";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { UserRepository } from "@calcom/lib/server/repository/user";
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import prisma from "@calcom/prisma";
import type { AppCategories } from "@calcom/prisma/enums";
@@ -10,6 +10,7 @@ import type { UseFormReturn } from "react-hook-form";
import { Toaster } from "sonner";
import type { z } from "zod";
import { buildEmptyQueryValue, raqbQueryValueUtils } from "@calcom/app-store/_utils/raqb/raqbUtils";
import { routingFormAppComponents } from "@calcom/app-store/routing-forms/appComponents";
import DynamicAppComponent from "@calcom/app-store/routing-forms/components/DynamicAppComponent";
import { EmptyState } from "@calcom/app-store/routing-forms/components/_components/EmptyState";
@@ -44,7 +45,6 @@ import { useOrgBranding } from "@calcom/features/ee/organizations/context/provid
import type { EventTypesByViewer } from "@calcom/features/eventtypes/lib/getEventTypesByViewer";
import { areTheySiblingEntities } from "@calcom/lib/entityPermissionUtils.shared";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { buildEmptyQueryValue, raqbQueryValueUtils } from "@calcom/lib/raqb/raqbUtils";
import type { Prisma } from "@calcom/prisma/client";
import { SchedulingType } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc/react";
@@ -1190,7 +1190,7 @@ const Routes = ({
}) || [];
// const isConnectedForm = (id: string) => form.connectedForms.map((f) => f.id).includes(id);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
// const routers: any[] = [];
/* Disable this feature for new forms till we get it fully working with Routing Form with Attributes. This isn't much used feature */
// const routers = availableRouters.map((r) => {
@@ -1235,7 +1235,7 @@ const Routes = ({
});
setRoutes((routes) => {
// Even though it's obvious that fallbackRoute is defined here but TypeScript just can't figure it out.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return [...routes, fallbackRoute!];
});
return null;
@@ -1243,7 +1243,7 @@ const Routes = ({
// Ensure fallback is last
setRoutes((routes) => {
// Even though it's obvious that fallbackRoute is defined here but TypeScript just can't figure it out.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return [...routes.filter((route) => route.id !== fallbackRoute!.id), fallbackRoute!];
});
}
@@ -8,10 +8,7 @@ import { z } from "zod";
import { EventTypeWebWrapper } from "@calcom/atoms/event-types/wrappers/EventTypeWebWrapper";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permissions";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import { getEventTypePermissions } from "@calcom/features/pbac/lib/event-type-permissions";
import { eventTypesRouter } from "@calcom/trpc/server/routers/viewer/eventTypes/_router";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
@@ -44,95 +41,6 @@ const getCachedEventType = unstable_cache(
{ revalidate: 3600 } // Cache for 1 hour
);
const getEventPermissions = async (userId: number, teamId: number | null) => {
// Personal event - has all perms
if (!teamId)
return {
eventTypes: {
canRead: true,
canCreate: true,
canUpdate: true,
canDelete: true,
},
workflows: {
canRead: true,
canCreate: true,
canUpdate: true,
canDelete: true,
},
};
const membership = await prisma.membership.findFirst({
where: {
userId,
teamId,
},
select: {
role: true,
},
});
if (!membership) throw new Error("Membership not found");
const [eventTypePermissions, workflowPermissions] = await Promise.all([
getResourcePermissions({
userId,
teamId,
resource: Resource.EventType,
userRole: membership.role,
fallbackRoles: {
read: {
roles: [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER],
},
update: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
delete: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
create: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
},
}),
getResourcePermissions({
userId,
teamId,
resource: Resource.Workflow,
userRole: membership.role,
fallbackRoles: {
read: {
roles: [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER],
},
update: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
delete: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
create: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
},
}),
]);
return {
eventTypes: {
canRead: eventTypePermissions.canRead,
canCreate: eventTypePermissions.canCreate,
canUpdate: eventTypePermissions.canEdit,
canDelete: eventTypePermissions.canDelete,
},
workflows: {
canRead: workflowPermissions.canRead,
canCreate: workflowPermissions.canCreate,
canUpdate: workflowPermissions.canEdit,
canDelete: workflowPermissions.canDelete,
},
};
};
const ServerPage = async ({ params }: PageProps) => {
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
if (!session?.user?.id) {
@@ -153,7 +61,7 @@ const ServerPage = async ({ params }: PageProps) => {
}
// Fetch permissions for the event type's team
const permissions = await getEventPermissions(session.user.id, data.eventType.teamId);
const permissions = await getEventTypePermissions(session.user.id, data.eventType.teamId);
return <EventTypeWebWrapper data={data} id={eventTypeId} permissions={permissions} />;
};
@@ -6,7 +6,7 @@ import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { APP_NAME } from "@calcom/lib/constants";
import { UserRepository } from "@calcom/lib/server/repository/user";
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import prisma from "@calcom/prisma";
import { meRouter } from "@calcom/trpc/server/routers/viewer/me/_router";
@@ -5,7 +5,7 @@ import { z } from "zod";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
import { OrgForm } from "@calcom/features/ee/organizations/pages/settings/admin/AdminOrgEditPage";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { OrganizationRepository } from "@calcom/lib/server/repository/organization";
import { OrganizationRepository } from "@calcom/features/ee/organizations/repositories/OrganizationRepository";
const orgIdSchema = z.object({ id: z.coerce.number() });
@@ -5,7 +5,7 @@ import { z } from "zod";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
import { UsersEditView } from "@calcom/features/ee/users/pages/users-edit-view";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { UserRepository } from "@calcom/lib/server/repository/user";
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import prisma from "@calcom/prisma";
const userIdSchema = z.object({ id: z.coerce.number() });
@@ -98,7 +98,7 @@ const getTabs = (orgBranding: OrganizationBranding | null) => {
]
: []),
{
name: "privacy",
name: "privacy_and_security",
href: "/settings/organizations/privacy",
},
@@ -170,7 +170,14 @@ const getTabs = (orgBranding: OrganizationBranding | null) => {
// The following keys are assigned to admin only
const adminRequiredKeys = ["admin"];
const organizationRequiredKeys = ["organization"];
const organizationAdminKeys = ["privacy", "OAuth Clients", "SSO", "directory_sync", "delegation_credential"];
const organizationAdminKeys = [
"privacy",
"privacy_and_security",
"OAuth Clients",
"SSO",
"directory_sync",
"delegation_credential",
];
export interface SettingsPermissions {
canViewRoles?: boolean;
@@ -221,8 +228,7 @@ const useTabs = ({
});
}
// Add pbac menu item only if feature flag is enabled AND user has permission to view roles
// This prevents showing the menu item when user has no organization permissions
// Add pbac menu item - show opt-in page if not enabled, regular page if enabled
if (isPbacEnabled) {
if (permissions?.canViewRoles) {
newArray.push({
@@ -244,6 +250,14 @@ const useTabs = ({
href: "/settings/organizations/billing",
});
}
// Show roles page (modal will appear if PBAC not enabled)
if (permissions?.canUpdateOrganization) {
newArray.push({
name: "roles",
href: "/settings/organizations/roles",
isBadged: true, // Show "New" badge
});
}
}
return {
@@ -604,17 +618,23 @@ const SettingsSidebarContainer = ({
</div>
<div className="my-3 space-y-px">
{tab.children?.map((child, index) => (
<VerticalTabItem
key={child.href}
name={t(child.name)}
isExternalLink={child.isExternalLink}
href={child.href || "/"}
textClassNames="text-emphasis font-medium text-sm"
className={`me-5 h-7 w-auto !px-2 ${
tab.children && index === tab.children?.length - 1 && "!mb-3"
}`}
disableChevron
/>
<div key={child.href} className="flex items-start gap-2">
<VerticalTabItem
name={t(child.name)}
isExternalLink={child.isExternalLink}
href={child.href || "/"}
textClassNames="text-emphasis font-medium text-sm"
className={`h-7 w-fit !px-2 ${
tab.children && index === tab.children?.length - 1 && "!mb-3"
}`}
disableChevron
/>
{child.isBadged && (
<Badge variant="blue" className="mt-0.5 text-xs">
New
</Badge>
)}
</div>
))}
</div>
</React.Fragment>
@@ -4,7 +4,7 @@ import { getTranslate, _generateMetadata } from "app/_utils";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { EditWebhookView } from "@calcom/features/webhooks/pages/webhook-edit-view";
import { APP_NAME } from "@calcom/lib/constants";
import { WebhookRepository } from "@calcom/lib/server/repository/webhook";
import { WebhookRepository } from "@calcom/features/webhooks/lib/repository/WebhookRepository";
export const generateMetadata = async ({ params }: { params: Promise<{ id: string }> }) =>
await _generateMetadata(
@@ -20,7 +20,8 @@ const Page = async ({ params: _params }: PageProps) => {
const params = await _params;
const id = typeof params?.id === "string" ? params.id : undefined;
const webhook = await WebhookRepository.findByWebhookId(id);
const webhookRepository = WebhookRepository.getInstance();
const webhook = await webhookRepository.findByWebhookId(id);
return (
<SettingsHeader
@@ -53,16 +53,23 @@ export default async function SettingsLayoutAppDir(props: SettingsLayoutProps) {
// For now we only grab organization features but it would be nice to fetch these on the server side for specific team feature flags
if (orgId) {
const isOrgAdminOrOwner = checkAdminOrOwner(session.user.org?.role);
const [features, rolePermissions, organizationPermissions] = await Promise.all([
getTeamFeatures(orgId),
getCachedResourcePermissions(userId, orgId, Resource.Role),
getCachedResourcePermissions(userId, orgId, Resource.Organization),
]);
const features = await getTeamFeatures(orgId);
if (features) {
teamFeatures = {
[orgId]: features,
};
}
// Check if PBAC feature is enabled
const isPbacEnabled = features?.pbac === true;
if (isPbacEnabled) {
// Only fetch and apply PBAC permissions if the feature is enabled
const [rolePermissions, organizationPermissions] = await Promise.all([
getCachedResourcePermissions(userId, orgId, Resource.Role),
getCachedResourcePermissions(userId, orgId, Resource.Organization),
]);
// Check if user has permission to read roles
const roleActions = PermissionMapper.toActionMap(rolePermissions, Resource.Role);
@@ -71,7 +78,8 @@ export default async function SettingsLayoutAppDir(props: SettingsLayoutProps) {
canViewOrganizationBilling = orgActions[CustomAction.ManageBilling] ?? isOrgAdminOrOwner;
canUpdateOrganization = orgActions[CrudAction.Update] ?? isOrgAdminOrOwner;
} else {
canViewRoles = false;
// Fall back to legacy permissions when PBAC is not enabled or features not loaded
canViewRoles = features ? isOrgAdminOrOwner : false;
canViewOrganizationBilling = isOrgAdminOrOwner;
canUpdateOrganization = isOrgAdminOrOwner;
}

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