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
373 changed files with 13376 additions and 3245 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
-5
View File
@@ -1,5 +0,0 @@
---
"@calcom/atoms": minor
---
This PR adds ability to display bookings of a user event for calendar view atom
+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
@@ -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";
@@ -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);
}
});
@@ -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;
@@ -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);
@@ -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<
@@ -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,
};
}
}
@@ -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 {
@@ -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" })
@@ -13,7 +13,7 @@ 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 { 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";
@@ -9,6 +9,7 @@ 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 { 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,7 +4,7 @@ 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 { 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";
@@ -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;
@@ -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;
}
@@ -11,7 +11,7 @@ import { validateUserHasOrg } from "../../actions/validateUserHasOrg";
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("privacy"),
(t) => t("privacy_and_security"),
(t) => t("privacy_organization_description"),
undefined,
undefined,
@@ -19,9 +19,8 @@ export const generateMetadata = async () =>
);
const Page = async () => {
const t = await getTranslate();
const session = await validateUserHasOrg();
const t = await getTranslate();
if (!session?.user.id || !session?.user.profile?.organizationId || !session?.user.org) {
return redirect("/settings/profile");
@@ -42,13 +41,31 @@ const Page = async () => {
},
});
const watchlistPermissions = await getResourcePermissions({
userId: session.user.id,
teamId: session.user.profile.organizationId,
resource: Resource.Watchlist,
userRole: session.user.org.role,
fallbackRoles: {
read: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
create: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
delete: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
},
});
if (!canRead) {
return redirect("/settings/profile");
}
return (
<SettingsHeader title={t("privacy")} description={t("privacy_organization_description")}>
<PrivacyView permissions={{ canRead, canEdit }} />
<SettingsHeader title={t("privacy_and_security")} description={t("privacy_organization_description")}>
<PrivacyView permissions={{ canRead, canEdit }} watchlistPermissions={watchlistPermissions} />
</SettingsHeader>
);
};
@@ -0,0 +1,22 @@
import { _generateMetadata } from "app/_utils";
import ResumeOnboardingPage, { LayoutWrapper } from "~/settings/organizations/new/resume-view";
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("resume_onboarding"),
(t) => t("resume_onboarding_description"),
undefined,
undefined,
"/settings/organizations/new/resume"
);
const ServerPage = async () => {
return (
<LayoutWrapper>
<ResumeOnboardingPage />
</LayoutWrapper>
);
};
export default ServerPage;
+2 -2
View File
@@ -6,11 +6,11 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
import { DelegationCredentialRepository } from "@calcom/features/delegation-credentials/repositories/DelegationCredentialRepository";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { CredentialRepository } from "@calcom/lib/server/repository/credential";
import { DelegationCredentialRepository } from "@calcom/lib/server/repository/delegationCredential";
import { defaultResponderForAppDir } from "../../defaultResponderForAppDir";
@@ -12,7 +12,7 @@ import { findUniqueDelegationCalendarCredential } from "@calcom/app-store/delega
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { CredentialRepository } from "@calcom/lib/server/repository/credential";
import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
import { SelectedCalendarRepository } from "@calcom/lib/server/repository/selectedCalendar";
import type { CredentialForCalendarServiceWithEmail } from "@calcom/types/Credential";
import type { Ensure } from "@calcom/types/utils";
@@ -5,6 +5,7 @@ import type Stripe from "stripe";
import { z } from "zod";
import { Plan, SubscriptionStatus } from "@calcom/features/ee/billing/repository/IBillingRepository";
import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billing-service";
import { InternalTeamBilling } from "@calcom/features/ee/billing/teams/internal-team-billing";
import stripe from "@calcom/features/ee/payments/server/stripe";
import { HttpError } from "@calcom/lib/http-error";
@@ -57,6 +58,9 @@ async function handler(request: NextRequest) {
});
if (checkoutSessionSubscription) {
const { subscriptionStart } =
StripeBillingService.extractSubscriptionDates(checkoutSessionSubscription);
const internalBillingService = new InternalTeamBilling(finalizedTeam);
await internalBillingService.saveTeamBilling({
teamId: finalizedTeam.id,
@@ -66,6 +70,7 @@ async function handler(request: NextRequest) {
// TODO: Implement true subscription status when webhook events are implemented
status: SubscriptionStatus.ACTIVE,
planName: Plan.TEAM,
subscriptionStart,
});
}
+3
View File
@@ -5,6 +5,7 @@ import type Stripe from "stripe";
import { z } from "zod";
import { Plan, SubscriptionStatus } from "@calcom/features/ee/billing/repository/IBillingRepository";
import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billing-service";
import { InternalTeamBilling } from "@calcom/features/ee/billing/teams/internal-team-billing";
import stripe from "@calcom/features/ee/payments/server/stripe";
import { HttpError } from "@calcom/lib/http-error";
@@ -87,6 +88,7 @@ async function getHandler(req: NextRequest) {
});
if (checkoutSession && subscription) {
const { subscriptionStart } = StripeBillingService.extractSubscriptionDates(subscription);
const internalBillingService = new InternalTeamBilling(team);
await internalBillingService.saveTeamBilling({
teamId: team.id,
@@ -96,6 +98,7 @@ async function getHandler(req: NextRequest) {
// TODO: Implement true subscription status when webhook events are implemented
status: SubscriptionStatus.ACTIVE,
planName: Plan.TEAM,
subscriptionStart,
});
}
+20 -5
View File
@@ -160,13 +160,21 @@ export const AppPage = ({
function refactorMeWithoutEffect() {
const data = appDbQuery.data;
const credentialsCount = data?.credentials.length || 0;
setExistingCredentials(data?.credentials || []);
const credentials = data?.credentials || [];
setExistingCredentials(credentials);
const hasPersonalInstall = credentials.some((c) => !!c.userId && !c.teamId);
const installedTeamIds = new Set<number>();
for (const cred of credentials) {
if (cred.teamId) installedTeamIds.add(cred.teamId);
}
const totalInstalledTargets = (hasPersonalInstall ? 1 : 0) + installedTeamIds.size;
const appInstalledForAllTargets =
availableForTeams && data?.userAdminTeams && data.userAdminTeams.length > 0
? credentialsCount >= data.userAdminTeams.length
: credentialsCount > 0;
? totalInstalledTargets >= data.userAdminTeams.length + 1
: credentials.length > 0;
setAppInstalledForAllTargets(appInstalledForAllTargets);
},
[appDbQuery.data, availableForTeams]
@@ -244,7 +252,14 @@ export const AppPage = ({
loading: isLoading,
};
}
return <InstallAppButtonChild credentials={appDbQuery.data?.credentials} paid={paid} {...props} />;
return (
<InstallAppButtonChild
credentials={availableForTeams ? undefined : appDbQuery.data?.credentials}
paid={paid}
{...props}
/>
);
}}
/>
);
+1
View File
@@ -2,6 +2,7 @@
import React from "react";
import "@calcom/embed-core/src/embed-iframe";
import { HttpError } from "@calcom/lib/http-error";
import { Button } from "@calcom/ui/components/button";
@@ -83,9 +83,12 @@ export const getServerSideProps = async function getServerSideProps(
props: {
isEmbed,
profile: {
theme: form.user.theme,
brandColor: form.user.brandColor,
darkBrandColor: form.user.darkBrandColor,
theme: formWithUserProfile.user.profile?.organization?.theme ?? formWithUserProfile.user.theme,
brandColor:
formWithUserProfile.user.profile?.organization?.brandColor ?? formWithUserProfile.user.brandColor,
darkBrandColor:
formWithUserProfile.user.profile?.organization?.darkBrandColor ??
formWithUserProfile.user.darkBrandColor,
},
form: await getSerializableForm({ form: enrichFormWithMigrationData(formWithUserProfile) }),
},
+15 -3
View File
@@ -49,6 +49,13 @@ export const getEventTypesFromDB = async (id: number) => {
profile: {
select: {
organizationId: true,
organization: {
select: {
brandColor: true,
darkBrandColor: true,
theme: true,
},
},
},
},
teamId: true,
@@ -71,9 +78,15 @@ export const getEventTypesFromDB = async (id: number) => {
slug: true,
name: true,
hideBranding: true,
brandColor: true,
darkBrandColor: true,
theme: true,
parent: {
select: {
hideBranding: true,
brandColor: true,
darkBrandColor: true,
theme: true,
},
},
createdByOAuthClientId: true,
@@ -116,12 +129,11 @@ export const getEventTypesFromDB = async (id: number) => {
}
const metadata = EventTypeMetaDataSchema.parse(eventType.metadata);
const { profile, ...restEventType } = eventType;
const isOrgTeamEvent = !!eventType?.team && !!profile?.organizationId;
const isOrgTeamEvent = !!eventType?.team && !!eventType.profile?.organizationId;
return {
isDynamic: false,
...restEventType,
...eventType,
bookingFields: getBookingFieldsWithSystemFields({ ...eventType, isOrgTeamEvent }),
metadata,
};
@@ -8,12 +8,12 @@ import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import { EventRepository } from "@calcom/features/eventtypes/repositories/EventRepository";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { HashedLinkService } from "@calcom/features/hashedLink/services/hashedLinkService";
import {
shouldHideBrandingForTeamEvent,
shouldHideBrandingForUserEvent,
} from "@calcom/features/profile/lib/hideBranding";
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import { HashedLinkService } from "@calcom/lib/server/service/hashedLinkService";
import slugify from "@calcom/lib/slugify";
import prisma from "@calcom/prisma";
import { RedirectType } from "@calcom/prisma/enums";
+2 -2
View File
@@ -1,4 +1,4 @@
import { organizationScenarios } from "@calcom/lib/server/repository/__mocks__/organization";
import { organizationScenarios } from "@calcom/features/ee/organizations/__mocks__/organizationMock";
import { describe, it, expect, beforeEach, vi } from "vitest";
@@ -17,7 +17,7 @@ vi.mock("@calcom/prisma", () => {
};
});
vi.mock("@calcom/features/ee/billing/stripe-billling-service", () => {
vi.mock("@calcom/features/ee/billing/stripe-billing-service", () => {
return {
StripeBillingService: vi.fn(),
};
+1 -1
View File
@@ -2,7 +2,7 @@ import type { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod";
import dayjs from "@calcom/dayjs";
import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billling-service";
import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billing-service";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { IS_STRIPE_ENABLED } from "@calcom/lib/constants";
import { OrganizationRepository } from "@calcom/features/ee/organizations/repositories/OrganizationRepository";
@@ -4,11 +4,11 @@ import { URLSearchParams } from "url";
import { z } from "zod";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { buildEventUrlFromBooking } from "@calcom/features/bookings/lib/buildEventUrlFromBooking";
import { determineReschedulePreventionRedirect } from "@calcom/features/bookings/lib/reschedule/determineReschedulePreventionRedirect";
import { getDefaultEvent } from "@calcom/features/eventtypes/lib/defaultEvents";
import { buildEventUrlFromBooking } from "@calcom/lib/bookings/buildEventUrlFromBooking";
import { maybeGetBookingUidFromSeat } from "@calcom/lib/server/maybeGetBookingUidFromSeat";
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import { maybeGetBookingUidFromSeat } from "@calcom/lib/server/maybeGetBookingUidFromSeat";
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
const querySchema = z.object({
@@ -7,8 +7,9 @@ import { getBookingForReschedule } from "@calcom/features/bookings/lib/get-booki
import { getSlugOrRequestedSlug, orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import { getOrganizationSEOSettings } from "@calcom/features/ee/organizations/lib/orgSettings";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { getBrandingForEventType } from "@calcom/features/profile/lib/getBranding";
import { shouldHideBrandingForTeamEvent } from "@calcom/features/profile/lib/hideBranding";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import slugify from "@calcom/lib/slugify";
import { prisma } from "@calcom/prisma";
import type { User } from "@calcom/prisma/client";
@@ -30,7 +31,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
const { req, params, query } = context;
const session = await getServerSession({ req });
const { slug: teamSlug, type: meetingSlug } = paramsSchema.parse(params);
const { rescheduleUid, isInstantMeeting: queryIsInstantMeeting, email } = query;
const { rescheduleUid, isInstantMeeting: queryIsInstantMeeting } = query;
const allowRescheduleForCancelledBooking = query.allowRescheduleForCancelledBooking === "true";
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(req, params?.orgSlug);
@@ -133,6 +134,14 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
const teamHasApiV2Route = await featureRepo.checkIfTeamHasFeature(team.id, "use-api-v2-for-team-slots");
const useApiV2 = teamHasApiV2Route && hasApiV2RouteInEnv();
const branding = getBrandingForEventType({
eventType: {
team: team.parent ?? team,
users: [],
profile: null,
},
});
return {
props: {
useApiV2,
@@ -153,6 +162,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
: getPlaceholderAvatar(team.logoUrl, team.name),
name,
username: orgSlug ?? null,
...branding,
},
title: eventData.title,
users: eventHostsUserData,
@@ -204,6 +214,9 @@ const getTeamWithEventsData = async (
bannerUrl: true,
logoUrl: true,
hideBranding: true,
brandColor: true,
darkBrandColor: true,
theme: true,
organizationSettings: {
select: {
allowSEOIndexing: true,
@@ -214,6 +227,9 @@ const getTeamWithEventsData = async (
logoUrl: true,
name: true,
slug: true,
brandColor: true,
darkBrandColor: true,
theme: true,
eventTypes: {
where: {
slug: meetingSlug,
@@ -141,6 +141,7 @@ export function AvailabilityList({ availabilities }: AvailabilityListProps) {
<ul className="divide-subtle divide-y" data-testid="schedules" ref={animationParentRef}>
{availabilities.schedules.map((schedule) => (
<ScheduleListItem
redirectUrl={`/availability/${schedule.id}`}
displayOptions={{
hour12: user?.timeFormat ? user.timeFormat === 12 : undefined,
timeZone: user?.timeZone,
@@ -6,12 +6,13 @@ import { eventTypeMetaDataSchemaWithTypedApps } from "@calcom/app-store/zod-util
import { orgDomainConfig } from "@calcom/ee/organizations/lib/orgDomains";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import getBookingInfo from "@calcom/features/bookings/lib/getBookingInfo";
import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository";
import { getDefaultEvent } from "@calcom/features/eventtypes/lib/defaultEvents";
import { getBrandingForEventType } from "@calcom/features/profile/lib/getBranding";
import { shouldHideBrandingForEvent } from "@calcom/features/profile/lib/hideBranding";
import { parseRecurringEvent } from "@calcom/lib/isRecurringEvent";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import { maybeGetBookingUidFromSeat } from "@calcom/lib/server/maybeGetBookingUidFromSeat";
import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository";
import prisma from "@calcom/prisma";
import { customInputSchema } from "@calcom/prisma/zod-utils";
import { meRouter } from "@calcom/trpc/server/routers/viewer/me/_router";
@@ -115,7 +116,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
bookingInfo["startTime"] = (bookingInfo?.startTime as Date)?.toISOString() as unknown as Date;
bookingInfo["endTime"] = (bookingInfo?.endTime as Date)?.toISOString() as unknown as Date;
eventTypeRaw.users = !!eventTypeRaw.hosts?.length
eventTypeRaw.users = eventTypeRaw.hosts?.length
? eventTypeRaw.hosts.map((host) => host.user)
: eventTypeRaw.users;
@@ -150,9 +151,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
const profile = {
name: eventType.team?.name || eventType.users[0]?.name || null,
email: eventType.team ? null : eventType.users[0].email || null,
theme: (!eventType.team?.name && eventType.users[0]?.theme) || null,
brandColor: eventType.team ? null : eventType.users[0].brandColor || null,
darkBrandColor: eventType.team ? null : eventType.users[0].darkBrandColor || null,
...getBrandingForEventType({ eventType: eventTypeRaw }),
slug: eventType.team?.slug || eventType.users[0]?.username || null,
};
@@ -24,9 +24,13 @@ import {
import { Price } from "@calcom/features/bookings/components/event-meta/Price";
import { getCalendarLinks, CalendarLinkType } from "@calcom/features/bookings/lib/getCalendarLinks";
import { RATING_OPTIONS, validateRating } from "@calcom/features/bookings/lib/rating";
import {
SMS_REMINDER_NUMBER_FIELD,
SystemField,
TITLE_FIELD,
} from "@calcom/lib/bookings/SystemField";
import type { nameObjectSchema } from "@calcom/features/eventtypes/lib/eventNaming";
import { getEventName } from "@calcom/features/eventtypes/lib/eventNaming";
import { SMS_REMINDER_NUMBER_FIELD, SystemField, TITLE_FIELD } from "@calcom/lib/SystemField";
import { APP_NAME } from "@calcom/lib/constants";
import { formatToLocalizedDate, formatToLocalizedTime, formatToLocalizedTimezone } from "@calcom/lib/dayjs";
import useGetBrandingColours from "@calcom/lib/getBrandColours";
@@ -694,7 +698,7 @@ export default function Success(props: PageProps) {
<>
<div className="mt-9 font-medium">{t("additional_notes")}</div>
<div className="col-span-2 mb-2 mt-9">
<p className="break-words">{bookingInfo.description}</p>
<p className="whitespace-pre-line break-words">{bookingInfo.description}</p>
</div>
</>
)}
@@ -767,7 +771,7 @@ export default function Success(props: PageProps) {
<Fragment key={field.name}>
<div
className="text-emphasis mt-4 font-medium"
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{
__html: markdownToSafeHTML(label),
}}
@@ -1080,7 +1084,7 @@ export default function Success(props: PageProps) {
<span className="underline">
<a
target="_blank"
href="https://cal.com/blog/google-s-new-spam-policy-may-be-affecting-your-invitations">
href="https://cal.com/blog/google-s-new-spam-policy-may-be-affecting-your-invitations" rel="noreferrer">
{t("resolve")}
</a>
</span>
@@ -147,6 +147,9 @@ const GeneralView = ({ user, travelSchedules }: GeneralViewProps) => {
const [isReceiveMonthlyDigestEmailChecked, setIsReceiveMonthlyDigestEmailChecked] = useState(
!!user.receiveMonthlyDigestEmail
);
const [isRequireBookerEmailVerificationChecked, setIsRequireBookerEmailVerificationChecked] = useState(
!!user.requiresBookerEmailVerification
);
const watchedTzSchedules = formMethods.watch("travelSchedules");
@@ -353,6 +356,19 @@ const GeneralView = ({ user, travelSchedules }: GeneralViewProps) => {
}}
switchContainerClassName="mt-6"
/>
<SettingsToggle
toggleSwitchAtTheEnd={true}
title={t("require_booker_email_verification")}
description={t("require_booker_email_verification_description")}
disabled={mutation.isPending}
checked={isRequireBookerEmailVerificationChecked}
onCheckedChange={(checked) => {
setIsRequireBookerEmailVerificationChecked(checked);
mutation.mutate({ requiresBookerEmailVerification: checked });
}}
switchContainerClassName="mt-6"
/>
<TravelScheduleModal
open={isTZScheduleOpen}
onOpenChange={() => setIsTZScheduleOpen(false)}
@@ -205,6 +205,7 @@ const AddNewTeamsFormChild = ({ teams }: { teams: { id: number; name: string; sl
<TextField
key={field.id}
{...register(`teams.${index}.name`)}
data-testid={`team.${index}.name`}
label=""
addOnClassname="bg-transparent p-0 border-l-0"
className={index > 0 ? "mb-2" : ""}
@@ -34,28 +34,28 @@ const useOrgCreation = () => {
const session = useSession();
const utils = trpc.useUtils();
const [serverErrorMessage, setServerErrorMessage] = useState("");
const { useOnboardingStore, isBillingEnabled } = useOnboarding();
const { useOnboardingStore } = useOnboarding();
const { reset } = useOnboardingStore();
const checkoutMutation = trpc.viewer.organizations.createWithPaymentIntent.useMutation({
onSuccess: (data) => {
if (data.checkoutUrl) {
window.location.href = data.checkoutUrl;
}
},
onError: (error) => {
setServerErrorMessage(t(error.message));
},
});
const createOrgMutation = trpc.viewer.organizations.createSelfHosted.useMutation({
// Single mutation for all flows (billing, self-hosted, admin)
const intentToCreateOrgMutation = trpc.viewer.organizations.intentToCreateOrg.useMutation({
onSuccess: async (data) => {
if (data.organization) {
// Invalidate the organizations query to ensure fresh data on the next page
reset({
onboardingId: data.organizationOnboardingId,
});
if (data.checkoutUrl) {
// Billing enabled - redirect to Stripe
window.location.href = data.checkoutUrl;
} else if (data.organizationId) {
// Self-hosted - org already created, redirect to organizations
await utils.viewer.organizations.listCurrent.invalidate();
await session.update();
reset();
window.location.href = `${window.location.origin}/settings/organizations/profile`;
} else {
// Unexpected state
setServerErrorMessage("Unexpected response from server");
}
},
onError: (error) => {
@@ -63,12 +63,10 @@ const useOrgCreation = () => {
},
});
const mutationToUse = isBillingEnabled ? checkoutMutation : createOrgMutation;
return {
mutation: mutationToUse,
mutate: mutationToUse.mutate,
isPending: mutationToUse.isPending,
mutation: intentToCreateOrgMutation,
mutate: intentToCreateOrgMutation.mutate,
isPending: intentToCreateOrgMutation.isPending,
errorMessage: serverErrorMessage,
};
};
@@ -84,7 +82,13 @@ export const AddNewTeamMembersForm = () => {
invitedMembers,
logo,
bio,
onboardingId,
name,
slug,
billingPeriod,
seats,
pricePerSeat,
brandColor,
bannerUrl,
} = useOnboardingStore();
const orgCreation = useOrgCreation();
@@ -155,7 +159,7 @@ export const AddNewTeamMembersForm = () => {
placeholder="colleague@company.com"
/>
</div>
<Button type="submit" StartIcon="plus" color="secondary">
<Button type="submit" StartIcon="plus" color="secondary" data-testid="invite-new-member-button">
{t("add")}
</Button>
</form>
@@ -203,22 +207,31 @@ export const AddNewTeamMembersForm = () => {
)}
</div>
<div className="mt-3 mt-6 flex items-center justify-end">
<div className="mt-3 flex items-center justify-end">
<Button
data-testid="publish-button"
onClick={() => {
if (!onboardingId) {
console.error("Org owner email and onboardingId are required", {
orgOwnerEmail,
onboardingId,
});
// Submit ALL data to intentToCreateOrg
if (!name || !slug || !orgOwnerEmail) {
console.error("Required fields missing", { name, slug, orgOwnerEmail });
showToast(t("required_fields_missing"), "error");
return;
}
orgCreation.mutation.mutate({
orgCreation.mutate({
name,
slug,
orgOwnerEmail,
seats,
pricePerSeat,
billingPeriod,
creationSource: "WEBAPP" as const,
logo,
bio,
brandColor,
bannerUrl,
teams,
invitedMembers,
onboardingId,
});
}}
loading={orgCreation.isPending}>
@@ -6,8 +6,8 @@ import { useEffect, useState } from "react";
import { useOnboarding } from "@calcom/features/ee/organizations/lib/onboardingStore";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import { Icon } from "@calcom/ui/components/icon";
import { Button } from "@calcom/ui/components/button";
import { Icon } from "@calcom/ui/components/icon";
const PaymentStatusView = () => {
const { t } = useLocale();
@@ -15,9 +15,7 @@ const PaymentStatusView = () => {
const searchParams = useSearchParams();
const paymentStatus = searchParams?.get("paymentStatus");
const paymentError = searchParams?.get("error");
const { useOnboardingStore } = useOnboarding({
step: "status",
});
const { useOnboardingStore } = useOnboarding();
const [organizationCreated, setOrganizationCreated] = useState<boolean>(false);
const { name } = useOnboardingStore();
@@ -0,0 +1,114 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect } from "react";
import { useOnboarding } from "@calcom/features/ee/organizations/lib/onboardingStore";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Alert } from "@calcom/ui/components/alert";
import { WizardLayout } from "@calcom/ui/components/layout";
import { SkeletonContainer, SkeletonText } from "@calcom/ui/components/skeleton";
export const LayoutWrapper = ({ children }: { children: React.ReactNode }) => {
return (
<WizardLayout currentStep={1} maxSteps={5}>
{children}
</WizardLayout>
);
};
const ResumeOnboardingView = () => {
const { t } = useLocale();
const router = useRouter();
const searchParams = useSearchParams();
const onboardingIdParam = searchParams?.get("onboardingId");
const { dbOnboarding, isLoadingOrgOnboarding, useOnboardingStore } = useOnboarding();
const { reset } = useOnboardingStore();
useEffect(() => {
if (isLoadingOrgOnboarding) {
return;
}
if (!onboardingIdParam) {
router.push("/settings/organizations/new");
return;
}
if (!dbOnboarding) {
return;
}
if (dbOnboarding.isComplete) {
router.push("/settings/organizations");
return;
}
// Load onboarding data into store
reset({
onboardingId: dbOnboarding.id,
name: dbOnboarding.name,
slug: dbOnboarding.slug,
orgOwnerEmail: dbOnboarding.orgOwnerEmail,
billingPeriod: dbOnboarding.billingPeriod,
seats: dbOnboarding.seats,
pricePerSeat: dbOnboarding.pricePerSeat,
logo: dbOnboarding.logo,
bio: dbOnboarding.bio,
brandColor: dbOnboarding.brandColor,
bannerUrl: dbOnboarding.bannerUrl,
});
// Redirect to next step (About page)
router.push("/settings/organizations/new/about");
}, [dbOnboarding, isLoadingOrgOnboarding, onboardingIdParam, reset, router]);
if (isLoadingOrgOnboarding) {
return (
<SkeletonContainer className="space-y-4">
<SkeletonText className="h-8 w-full" />
<SkeletonText className="h-4 w-3/4" />
<SkeletonText className="h-4 w-1/2" />
</SkeletonContainer>
);
}
if (!onboardingIdParam) {
return (
<Alert
data-testid="error"
severity="error"
title={t("error")}
message={t("no_onboarding_id_provided")}
/>
);
}
if (!dbOnboarding) {
return (
<Alert data-testid="error" severity="error" title={t("error")} message={t("onboarding_not_found")} />
);
}
if (dbOnboarding.isComplete) {
return (
<Alert
data-testid="error"
severity="info"
title={t("onboarding_already_complete")}
message={t("onboarding_already_complete_description")}
/>
);
}
// Loading state while redirecting
return (
<SkeletonContainer className="space-y-4">
<SkeletonText className="h-8 w-full" />
<SkeletonText className="h-4 w-3/4" />
</SkeletonContainer>
);
};
export default ResumeOnboardingView;
@@ -0,0 +1,207 @@
"use client";
import { keepPreviousData } from "@tanstack/react-query";
import { getCoreRowModel, getSortedRowModel, useReactTable, type ColumnDef } from "@tanstack/react-table";
import { useMemo, useState } from "react";
import { DataTableWrapper, DataTableToolbar, useDataTable } from "@calcom/features/data-table";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import type { RouterOutputs } from "@calcom/trpc/react";
import { Badge } from "@calcom/ui/components/badge";
import { Button } from "@calcom/ui/components/button";
import { ButtonGroup } from "@calcom/ui/components/buttonGroup";
import { ConfirmationDialogContent, Dialog } from "@calcom/ui/components/dialog";
import { showToast } from "@calcom/ui/components/toast";
import { BlocklistEntryDetailsSheet } from "./components/blocklist-entry-details-sheet";
import { CreateBlocklistEntryModal } from "./components/create-blocklist-entry-modal";
type BlocklistEntry = RouterOutputs["viewer"]["organizations"]["listWatchlistEntries"]["rows"][number];
interface BlocklistTableProps {
permissions?: {
canRead: boolean;
canCreate: boolean;
canDelete: boolean;
};
}
export function BlocklistTable({ permissions }: BlocklistTableProps) {
const { t } = useLocale();
const { limit, offset, searchTerm } = useDataTable();
const [showCreateModal, setShowCreateModal] = useState(false);
const [showDetailsSheet, setShowDetailsSheet] = useState(false);
const [selectedEntry, setSelectedEntry] = useState<BlocklistEntry | null>(null);
const [entryToDelete, setEntryToDelete] = useState<BlocklistEntry | null>(null);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const { data, isPending } = trpc.viewer.organizations.listWatchlistEntries.useQuery(
{
limit,
offset,
searchTerm,
},
{
placeholderData: keepPreviousData,
}
);
const utils = trpc.useUtils();
const deleteWatchlistEntry = trpc.viewer.organizations.deleteWatchlistEntry.useMutation({
onSuccess: async () => {
await utils.viewer.organizations.listWatchlistEntries.invalidate();
showToast(t("blocklist_entry_deleted"), "success");
setShowDeleteDialog(false);
setEntryToDelete(null);
},
onError: (error) => {
showToast(error.message, "error");
},
});
const handleDelete = (entry: BlocklistEntry) => {
setEntryToDelete(entry);
setShowDeleteDialog(true);
};
const confirmDelete = () => {
if (entryToDelete) {
deleteWatchlistEntry.mutate({ id: entryToDelete.id });
}
};
const handleViewDetails = (entry: BlocklistEntry) => {
setSelectedEntry(entry);
setShowDetailsSheet(true);
};
const totalRowCount = data?.meta?.totalRowCount ?? 0;
const flatData = useMemo<BlocklistEntry[]>(() => data?.rows ?? [], [data]);
const columns = useMemo<ColumnDef<BlocklistEntry>[]>(
() => [
{
id: "value",
header: t("value"),
accessorKey: "value",
enableHiding: false,
cell: ({ row }) => <span className="text-emphasis">{row.original.value}</span>,
},
{
id: "type",
header: t("type"),
accessorKey: "type",
size: 100,
cell: ({ row }) => (
<Badge variant="blue">{row.original.type === "EMAIL" ? t("email") : t("domain")}</Badge>
),
},
{
id: "createdBy",
header: t("blocked_by"),
size: 180,
cell: ({ row }) => {
const audit = row.original.audits?.[0] as
| { changedByUserId: number | null }
| {
changedByUser?: { id: number; email: string; name: string | null } | undefined;
changedByUserId: number | null;
}
| undefined;
const email =
(audit && "changedByUser" in audit ? audit.changedByUser?.email : undefined) ?? undefined;
return <span className="text-default">{email ?? "—"}</span>;
},
},
{
id: "actions",
header: "",
size: 120,
enableHiding: false,
enableSorting: false,
enableResizing: false,
cell: ({ row }) => {
const entry = row.original;
return (
<div className="flex items-center justify-end">
<ButtonGroup combined containerProps={{ className: "border-default" }}>
<Button
color="secondary"
variant="icon"
StartIcon="eye"
onClick={() => handleViewDetails(entry)}
tooltip={t("view")}
/>
{permissions?.canDelete && (
<Button
color="destructive"
variant="icon"
StartIcon="trash"
onClick={() => handleDelete(entry)}
tooltip={t("delete")}
/>
)}
</ButtonGroup>
</div>
);
},
},
],
[t, permissions?.canDelete]
);
const table = useReactTable({
data: flatData,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
manualPagination: true,
pageCount: Math.ceil(totalRowCount / limit),
});
return (
<>
<DataTableWrapper
table={table}
isPending={isPending}
variant="default"
paginationMode="standard"
totalRowCount={totalRowCount}>
<div className="flex items-center justify-between">
<DataTableToolbar.SearchBar />
<div className="flex items-center gap-2">
{permissions?.canCreate && (
<Button color="primary" StartIcon="plus" onClick={() => setShowCreateModal(true)}>
{t("create_block_entry")}
</Button>
)}
</div>
</div>
</DataTableWrapper>
<CreateBlocklistEntryModal isOpen={showCreateModal} onClose={() => setShowCreateModal(false)} />
<BlocklistEntryDetailsSheet
entry={selectedEntry}
isOpen={showDetailsSheet}
onClose={() => {
setShowDetailsSheet(false);
setSelectedEntry(null);
}}
/>
<Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<ConfirmationDialogContent
variety="danger"
title={t("delete_blocklist_entry")}
confirmBtnText={t("delete")}
onConfirm={confirmDelete}>
{t("delete_blocklist_entry_confirmation", { value: entryToDelete?.value })}
</ConfirmationDialogContent>
</Dialog>
</>
);
}
@@ -0,0 +1,131 @@
"use client";
import { format } from "date-fns";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import { Badge } from "@calcom/ui/components/badge";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@calcom/ui/components/sheet";
import { SkeletonText } from "@calcom/ui/components/skeleton";
type BlocklistEntry = RouterOutputs["viewer"]["organizations"]["listWatchlistEntries"]["rows"][number];
interface BlocklistEntryDetailsSheetProps {
entry: BlocklistEntry | null;
isOpen: boolean;
onClose: () => void;
}
export function BlocklistEntryDetailsSheet({ entry, isOpen, onClose }: BlocklistEntryDetailsSheetProps) {
const { t } = useLocale();
const { data, isLoading } = trpc.viewer.organizations.getWatchlistEntryDetails.useQuery(
{ id: entry?.id ?? "" },
{
enabled: !!entry?.id && isOpen,
}
);
const getActionVariant = (action: string) => {
switch (action) {
case "BLOCK":
return "red";
case "REPORT":
return "orange";
case "ALERT":
return "blue";
default:
return "gray";
}
};
return (
<Sheet open={isOpen} onOpenChange={(open) => { if (!open) onClose(); }}>
<SheetContent className="overflow-y-auto">
<SheetHeader>
<SheetTitle>{t("blocklist_entry_details")}</SheetTitle>
</SheetHeader>
{isLoading ? (
<div className="mt-6 space-y-4">
<SkeletonText className="h-6 w-32" />
<SkeletonText className="h-10 w-full" />
<SkeletonText className="h-6 w-32" />
<SkeletonText className="h-10 w-full" />
<SkeletonText className="h-6 w-32" />
<SkeletonText className="h-20 w-full" />
</div>
) : data?.entry ? (
<div className="mt-6">
<div className="space-y-4">
<div>
<label className="text-default mb-1 block text-sm font-medium">{t("type")}</label>
<Badge variant="blue" size="lg">
{data.entry.type === "EMAIL" ? t("email") : t("domain")}
</Badge>
</div>
<div>
<label className="text-default mb-1 block text-sm font-medium">{t("value")}</label>
<p className="text-emphasis text-base">{data.entry.value}</p>
</div>
<div>
<label className="text-default mb-1 block text-sm font-medium">{t("action")}</label>
<Badge variant={getActionVariant(data.entry.action)} size="lg">
{t(data.entry.action.toLowerCase())}
</Badge>
</div>
<div>
<label className="text-default mb-1 block text-sm font-medium">{t("description")}</label>
<p className="text-default">{data.entry.description || t("no_description_provided")}</p>
</div>
<div>
<label className="text-default mb-1 block text-sm font-medium">{t("created_by")}</label>
<p className="text-default">{data.auditHistory[0]?.changedByUser?.email || "—"}</p>
</div>
</div>
<div className="border-subtle my-6 border-t" />
<div>
<h3 className="text-emphasis mb-4 font-semibold">{t("audit_history")}</h3>
{data.auditHistory.length > 0 ? (
<div className="space-y-0">
{data.auditHistory.map((audit, index) => (
<div
key={audit.id}
className={`py-3 ${
index !== data.auditHistory.length - 1 ? "border-subtle border-b" : ""
}`}>
<div className="mb-1 flex items-start justify-between">
<div className="flex items-center gap-1.5">
<span className="text-muted text-xs">{t("by")}:</span>
<span className="text-default text-sm">{audit.changedByUser?.email || "—"}</span>
</div>
<span className="text-muted text-xs">
{format(new Date(audit.changedAt), "MMM d, h:mm a")}
</span>
</div>
<div className="flex items-center gap-1.5">
<span className="text-muted text-xs">{t("added")}:</span>
<span className="text-emphasis text-sm">{audit.value}</span>
</div>
</div>
))}
</div>
) : (
<p className="text-muted text-sm">{t("no_audit_history")}</p>
)}
</div>
</div>
) : (
<div className="text-muted mt-8 text-center">{t("blocklist_entry_not_found")}</div>
)}
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,173 @@
"use client";
import { Controller, useForm } from "react-hook-form";
import { domainRegex, emailRegex } from "@calcom/lib/emailSchema";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { WatchlistType } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc/react";
import { Button } from "@calcom/ui/components/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader } from "@calcom/ui/components/dialog";
import { Input, Select, Label, TextArea } from "@calcom/ui/components/form";
import { showToast } from "@calcom/ui/components/toast";
interface CreateBlocklistEntryModalProps {
isOpen: boolean;
onClose: () => void;
}
interface FormData {
type: WatchlistType;
value: string;
description?: string;
}
export function CreateBlocklistEntryModal({ isOpen, onClose }: CreateBlocklistEntryModalProps) {
const { t } = useLocale();
const utils = trpc.useUtils();
const {
control,
handleSubmit,
watch,
setValue,
reset,
formState: { errors, isSubmitting },
} = useForm<FormData>({
defaultValues: {
type: WatchlistType.EMAIL,
value: "",
description: "",
},
});
const watchType = watch("type");
const createWatchlistEntry = trpc.viewer.organizations.createWatchlistEntry.useMutation({
onSuccess: async () => {
await utils.viewer.organizations.listWatchlistEntries.invalidate();
showToast(t("blocklist_entry_created"), "success");
onClose();
reset();
},
onError: (error) => {
showToast(error.message, "error");
},
});
const onSubmit = (data: FormData) => {
createWatchlistEntry.mutate({
type: data.type,
value: data.value,
description: data.description,
});
};
const validateValue = (value: string) => {
if (!value) return t("required");
if (watchType === WatchlistType.EMAIL) {
if (!emailRegex.test(value)) {
return t("invalid_email_address");
}
} else if (watchType === WatchlistType.DOMAIN) {
if (!domainRegex.test(value)) {
return t("invalid_domain_format");
}
}
return true;
};
const typeOptions = [
{ label: t("email"), value: WatchlistType.EMAIL },
{ label: t("domain"), value: WatchlistType.DOMAIN },
];
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent enableOverflow>
<DialogHeader title={t("create_block_entry")} />
<form onSubmit={handleSubmit(onSubmit)}>
<div className="space-y-4">
<div>
<Label htmlFor="type" className="text-emphasis mb-2 block text-sm font-medium">
{t("what_would_you_like_to_block")} <span className="text-destructive">*</span>
</Label>
<Controller
name="type"
control={control}
rules={{ required: t("field_required") }}
render={({ field }) => (
<Select
{...field}
options={typeOptions}
onChange={(option) => {
if (option) {
field.onChange(option.value);
setValue("value", "");
}
}}
value={typeOptions.find((opt) => opt.value === field.value)}
/>
)}
/>
{errors.type && <p className="text-destructive mt-1 text-sm">{errors.type.message}</p>}
</div>
<div>
<Label htmlFor="value" className="text-emphasis mb-2 block text-sm font-medium">
{watchType === WatchlistType.EMAIL ? t("email_address") : t("domain_name")}{" "}
<span className="text-destructive">*</span>
</Label>
<Controller
name="value"
control={control}
rules={{
required: t("field_required"),
validate: validateValue,
}}
render={({ field }) => (
<Input
{...field}
placeholder={watchType === WatchlistType.EMAIL ? "user@example.com" : "spammer.com"}
/>
)}
/>
{errors.value && <p className="text-destructive mt-1 text-sm">{errors.value.message}</p>}
</div>
<div>
<Label htmlFor="description" className="text-emphasis mb-2 block text-sm font-medium">
{t("description")} <span className="text-subtle font-normal">({t("optional")})</span>
</Label>
<Controller
name="description"
control={control}
render={({ field }) => (
<TextArea {...field} placeholder={t("reason_for_adding_to_blocklist")} rows={3} />
)}
/>
</div>
</div>
<DialogFooter className="mt-6">
<Button
type="button"
color="secondary"
onClick={onClose}
disabled={isSubmitting || createWatchlistEntry.isPending}>
{t("cancel")}
</Button>
<Button
type="submit"
loading={isSubmitting || createWatchlistEntry.isPending}
disabled={isSubmitting || createWatchlistEntry.isPending}>
{t("create_entry")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
@@ -4,8 +4,8 @@ import { useRouter } from "next/navigation";
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
import { TeamEventTypeForm } from "@calcom/features/ee/teams/components/TeamEventTypeForm";
import { useCreateEventType } from "@calcom/features/eventtypes/hooks/useCreateEventType";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useCreateEventType } from "@calcom/lib/hooks/useCreateEventType";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Button } from "@calcom/ui/components/button";
+1 -1
View File
@@ -60,7 +60,7 @@ vi.mock("@calcom/features/bookings/components/event-meta/Price", () => {
return {};
});
vi.mock("@calcom/lib/SystemField", () => {
vi.mock("@calcom/lib/bookings/SystemField", () => {
return {};
});
@@ -20,7 +20,14 @@ function mockedUserPageComponentProps(props: Partial<React.ComponentProps<typeof
theme: "dark",
brandColor: "red",
darkBrandColor: "black",
organization: { requestedSlug: "slug", slug: "slug", id: 1 },
organization: {
requestedSlug: "slug",
slug: "slug",
id: 1,
brandColor: null,
darkBrandColor: null,
theme: null,
},
allowSEOIndexing: true,
username: "john",
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "5.7.12",
"version": "5.8.1",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
+1
View File
@@ -7,6 +7,7 @@ import type { ErrorProps } from "next/error";
import NextError from "next/error";
import React from "react";
import "@calcom/embed-core/src/embed-iframe";
import { getErrorFromUnknown } from "@calcom/lib/errors";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
@@ -1,45 +1,120 @@
import { expect } from "@playwright/test";
import type { Page } from "@playwright/test";
import { createTeamEventType } from "./fixtures/users";
import { test } from "./lib/fixtures";
async function testDuplicateAPICalls(
page: Page,
url: string,
testDate?: Date
): Promise<{ totalCalls: number; trpcCalls: number; apiV2Calls: number }> {
const trpcCalls: string[] = [];
const apiV2Calls: string[] = [];
if (testDate) {
await page.clock.install({ time: testDate });
}
await page.route("**/api/trpc/slots/getSchedule**", async (route) => {
trpcCalls.push(route.request().url());
await route.continue();
});
await page.route("**/api/v2/slots/available**", async (route) => {
apiV2Calls.push(route.request().url());
await route.continue();
});
await page.goto(url);
await page.waitForTimeout(5000);
return {
totalCalls: trpcCalls.length + apiV2Calls.length,
trpcCalls: trpcCalls.length,
apiV2Calls: apiV2Calls.length,
};
}
test.describe("Duplicate API Calls Prevention", () => {
test.afterEach(({ users }) => users.deleteAll());
test("should detect when schedule endpoints are called multiple times for individual user events", async ({
test("should detect when schedule endpoints are called multiple times for individual user events - beginning of month", async ({
page,
users,
}) => {
const user = await users.create();
const eventType = user.eventTypes.find((e) => e.slug === "30-min");
const beginningOfMonth = new Date();
beginningOfMonth.setDate(5);
const trpcCalls: string[] = [];
const apiV2Calls: string[] = [];
// Intercept tRPC getSchedule calls - pattern matches /api/trpc/slots/getSchedule
await page.route("**/api/trpc/slots/getSchedule**", async (route) => {
trpcCalls.push(route.request().url());
await route.continue();
});
await page.route("**/api/v2/slots/available**", async (route) => {
apiV2Calls.push(route.request().url());
await route.continue();
});
await page.goto(`/${user.username}/${eventType?.slug}`);
await page.waitForTimeout(5000);
const totalCalls = trpcCalls.length + apiV2Calls.length;
const { totalCalls, trpcCalls, apiV2Calls } = await testDuplicateAPICalls(
page,
`/${user.username}/${eventType?.slug}`,
beginningOfMonth
);
expect(totalCalls).toBeGreaterThan(0);
expect(totalCalls).toBeLessThanOrEqual(1);
expect(trpcCalls.length).toBeLessThanOrEqual(1);
expect(apiV2Calls.length).toBeLessThanOrEqual(1);
expect(trpcCalls).toBeLessThanOrEqual(1);
expect(apiV2Calls).toBeLessThanOrEqual(1);
});
test("should detect when schedule endpoints are called multiple times for team events", async ({
test("should detect when schedule endpoints are called multiple times for individual user events - end of month", async ({
page,
users,
}) => {
const user = await users.create();
const eventType = user.eventTypes.find((e) => e.slug === "30-min");
const endOfMonth = new Date();
endOfMonth.setDate(20);
const { totalCalls, trpcCalls, apiV2Calls } = await testDuplicateAPICalls(
page,
`/${user.username}/${eventType?.slug}`,
endOfMonth
);
expect(totalCalls).toBeGreaterThan(0);
expect(totalCalls).toBeLessThanOrEqual(1);
expect(trpcCalls).toBeLessThanOrEqual(1);
expect(apiV2Calls).toBeLessThanOrEqual(1);
});
test("should detect when schedule endpoints are called multiple times for team events - beginning of month", async ({
page,
users,
}) => {
const teamOwner = await users.create(
{ name: "Team Owner" },
{
hasTeam: true,
teammates: [{ name: "teammate-1" }],
}
);
const beginningOfMonth = new Date();
beginningOfMonth.setDate(5);
const { team } = await teamOwner.getFirstTeamMembership();
const teamEvent = await createTeamEventType(
{ id: teamOwner.id },
{ id: team.id },
{ teamEventSlug: "team-event-test", teamEventTitle: "Team Event Test" }
);
const { totalCalls, trpcCalls, apiV2Calls } = await testDuplicateAPICalls(
page,
`/team/${team.slug}/${teamEvent.slug}`,
beginningOfMonth
);
expect(totalCalls).toBeGreaterThan(0);
expect(totalCalls).toBeLessThanOrEqual(1);
expect(trpcCalls).toBeLessThanOrEqual(1);
expect(apiV2Calls).toBeLessThanOrEqual(1);
});
test("should detect when schedule endpoints are called multiple times for team events - end of month", async ({
page,
users,
}) => {
@@ -57,33 +132,22 @@ test.describe("Duplicate API Calls Prevention", () => {
{ id: team.id },
{ teamEventSlug: "team-event-test", teamEventTitle: "Team Event Test" }
);
const endOfMonth = new Date();
endOfMonth.setDate(20);
const trpcCalls: string[] = [];
const apiV2Calls: string[] = [];
await page.route("**/api/trpc/slots/getSchedule**", async (route) => {
trpcCalls.push(route.request().url());
await route.continue();
});
await page.route("**/api/v2/slots/available**", async (route) => {
apiV2Calls.push(route.request().url());
await route.continue();
});
await page.goto(`/team/${team.slug}/${teamEvent.slug}`);
await page.waitForTimeout(5000);
const totalCalls = trpcCalls.length + apiV2Calls.length;
const { totalCalls, trpcCalls, apiV2Calls } = await testDuplicateAPICalls(
page,
`/team/${team.slug}/${teamEvent.slug}`,
endOfMonth
);
expect(totalCalls).toBeGreaterThan(0);
expect(totalCalls).toBeLessThanOrEqual(1);
expect(trpcCalls.length).toBeLessThanOrEqual(1);
expect(apiV2Calls.length).toBeLessThanOrEqual(1);
expect(trpcCalls).toBeLessThanOrEqual(1);
expect(apiV2Calls).toBeLessThanOrEqual(1);
});
test("should detect when schedule endpoints are called multiple times for organization team events", async ({
test("should detect when schedule endpoints are called multiple times for organization team events - beginning of month", async ({
page,
users,
}) => {
@@ -105,29 +169,55 @@ test.describe("Duplicate API Calls Prevention", () => {
{ id: team.id },
{ teamEventSlug: "org-team-event", teamEventTitle: "Org Team Event" }
);
const beginningOfMonth = new Date();
beginningOfMonth.setDate(5);
const trpcCalls: string[] = [];
const apiV2Calls: string[] = [];
await page.route("**/api/trpc/slots/getSchedule**", async (route) => {
trpcCalls.push(route.request().url());
await route.continue();
});
await page.route("**/api/v2/slots/available**", async (route) => {
apiV2Calls.push(route.request().url());
await route.continue();
});
await page.goto(`/org/${org.slug}/${team.slug}/${teamEvent.slug}`);
await page.waitForTimeout(5000);
const totalCalls = trpcCalls.length + apiV2Calls.length;
const { totalCalls, trpcCalls, apiV2Calls } = await testDuplicateAPICalls(
page,
`/org/${org.slug}/${team.slug}/${teamEvent.slug}`,
beginningOfMonth
);
expect(totalCalls).toBeGreaterThan(0);
expect(totalCalls).toBeLessThanOrEqual(1);
expect(trpcCalls.length).toBeLessThanOrEqual(1);
expect(apiV2Calls.length).toBeLessThanOrEqual(1);
expect(trpcCalls).toBeLessThanOrEqual(1);
expect(apiV2Calls).toBeLessThanOrEqual(1);
});
test("should detect when schedule endpoints are called multiple times for organization team events - end of month", async ({
page,
users,
}) => {
const orgOwner = await users.create(
{ name: "Org Owner" },
{
hasTeam: true,
teammates: [{ name: "teammate-1" }],
isOrg: true,
isOrgVerified: true,
hasSubteam: true,
}
);
const { team } = await orgOwner.getFirstTeamMembership();
const { team: org } = await orgOwner.getOrgMembership();
const teamEvent = await createTeamEventType(
{ id: orgOwner.id },
{ id: team.id },
{ teamEventSlug: "org-team-event", teamEventTitle: "Org Team Event" }
);
const endOfMonth = new Date();
endOfMonth.setDate(20);
const { totalCalls, trpcCalls, apiV2Calls } = await testDuplicateAPICalls(
page,
`/org/${org.slug}/${team.slug}/${teamEvent.slug}`,
endOfMonth
);
expect(totalCalls).toBeGreaterThan(0);
expect(totalCalls).toBeLessThanOrEqual(1);
expect(trpcCalls).toBeLessThanOrEqual(1);
expect(apiV2Calls).toBeLessThanOrEqual(1);
});
});
+10 -8
View File
@@ -541,9 +541,10 @@ test.describe("Reschedule for booking with seats", () => {
data: bookingSeats,
});
const references = await prisma.bookingSeat.findMany({
where: { bookingId: booking.id },
orderBy: { id: "asc" },
const secondAttendeeId = bookingAttendees.find((attendee)=> attendee.email === "second+seats@cal.com");
const references = await prisma.bookingSeat.findFirst({
where: { bookingId: booking.id, attendeeId: secondAttendeeId?.id },
});
const secondUser = await users.create({
@@ -556,13 +557,14 @@ test.describe("Reschedule for booking with seats", () => {
await page.waitForSelector('[data-testid="bookings"]');
await page.locator('[data-testid="booking-actions-dropdown"]').nth(0).click();
const href = await page.locator('[data-testid="reschedule"]').getAttribute("href");
expect(href).toBeTruthy();
const url = new URL(href!, page.url());
const seatReferenceUid = url.searchParams.get('seatReferenceUid');
expect(seatReferenceUid).toBeTruthy();
expect(seatReferenceUid).toBe(references?.referenceUid);
await page.locator('[data-testid="reschedule"]').click();
await page.waitForURL((url) => {
const rescheduleUid = url.searchParams.get("rescheduleUid");
return !!rescheduleUid && rescheduleUid === references[1].referenceUid;
});
await selectFirstAvailableTimeSlotNextMonth(page);
const nameElement = page.locator("input[name=name]");
+2 -1
View File
@@ -117,6 +117,7 @@ export function createAppsFixture(page: Page) {
},
goToAppsTab: async () => {
await page.getByTestId("vertical-tab-apps").click();
await expect(page.getByTestId("vertical-tab-apps")).toHaveAttribute("aria-current", "page");
},
activeApp: async (app: string) => {
await page.locator(`[data-testid='${app}-app-switch']`).click();
@@ -126,7 +127,7 @@ export function createAppsFixture(page: Page) {
},
verifyAppsInfoNew: async (app: string, eventTypeId: number) => {
await page.goto(`event-types/${eventTypeId}?tabName=apps`);
await expect(page.getByTestId("vertical-tab-basics")).toContainText("Basics"); // fix the race condition
await expect(page.getByTestId("vertical-tab-apps")).toHaveAttribute("aria-current", "page"); // fix the race condition
await expect(page.locator(`[data-testid='${app}-app-switch'][data-state="checked"]`)).toBeVisible();
},
};
@@ -0,0 +1,451 @@
import type { Page } from "@playwright/test";
import { expect } from "@playwright/test";
import { uuid } from "short-uuid";
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
import { test } from "../lib/fixtures";
/**
* Utility function to complete the organization onboarding wizard steps
* (about, add-teams, onboard-members)
*/
async function completeOnboardingWizard(
page: Page,
options: {
aboutText?: string;
teams?: string[];
members?: string[];
} = {}
) {
const { aboutText = "Test organization description", teams = ["Engineering"], members = [] } = options;
// Step 1: Complete "About" step
await page.waitForURL("**/settings/organizations/new/about");
await page.locator('textarea[name="about"]').fill(aboutText);
await page.locator("button[type=submit]").click();
// Step 2: Complete "Add Teams" step
await page.waitForURL("**/settings/organizations/new/add-teams");
for (let i = 0; i < teams.length; i++) {
await page.getByTestId(`team.${i}.name`).fill(teams[i]);
if (i < teams.length - 1) {
await page.getByTestId("add_a_team").click();
}
}
await page.locator("button[type=submit]").click();
// Step 3: Complete "Onboard Members" step
await page.waitForURL("**/settings/organizations/new/onboard-members");
// Add members if provided
for (const memberEmail of members) {
await page.locator('[placeholder="colleague\\@company\\.com"]').fill(memberEmail);
await page.getByTestId("invite-new-member-button").click();
await expect(page.getByTestId("pending-member-list")).toContainText(memberEmail);
}
// Click publish and wait for response
const responsePromise = page.waitForResponse("**/api/trpc/organizations/intentToCreateOrg**");
await page.getByTestId("publish-button").click();
const response = await responsePromise;
const responseData = await response.json();
// TRPC batch response is an array with one element
const result = responseData[0].result.data.json;
return result;
}
test.describe("Organization Creation Flows - Comprehensive Suite", () => {
test.afterEach(({ users, orgs }) => {
users.deleteAll();
orgs.deleteAll();
});
test.describe("Admin Handover Flow (Billing Disabled)", () => {
test("Admin creates org for existing user - handover and complete", async ({ page, users }) => {
// Setup: Create admin and future owner
const admin = await users.create({ role: "ADMIN" });
await admin.apiLogin();
const ownerEmail = users.trackEmail({
username: "orgowner",
domain: "example.com",
});
const ownerUser = await users.create({
username: "orgowner",
email: ownerEmail,
});
const orgName = "Test Organization";
const orgSlug = `test-org-${uuid()}`.toLowerCase();
// Step 1: Admin fills form
await page.goto("/settings/organizations/new");
await page.locator("input[name=orgOwnerEmail]").fill(ownerEmail);
await page.locator("input[name=name]").fill(orgName);
await page.locator("input[name=slug]").fill(orgSlug);
// If billing enabled, admin sees billing fields but we're testing billing disabled scenario
if (IS_TEAM_BILLING_ENABLED) {
await page.locator("input[name=seats]").fill("10");
await page.locator("input[name=pricePerSeat]").fill("15");
}
// Submit and wait for intentToCreateOrg
await Promise.all([
page.waitForResponse("**/api/trpc/organizations/intentToCreateOrg**"),
page.locator("button[type=submit]").click(),
]);
// Step 2: Verify admin is on handover page
await page.waitForURL("**/settings/organizations/new/handover");
const onboardingUrlElement = page.getByTestId("onboarding-url");
await expect(onboardingUrlElement).toBeVisible();
const onboardingUrl = await onboardingUrlElement.textContent();
expect(onboardingUrl).toContain("/settings/organizations/new/resume?onboardingId=");
// Extract onboardingId from URL
const onboardingIdMatch = onboardingUrl?.match(/onboardingId=([\w-]+)/);
expect(onboardingIdMatch).toBeTruthy();
const onboardingId = onboardingIdMatch?.[1];
// Step 3: Switch to owner user
await page.context().clearCookies();
await ownerUser.apiLogin();
// Step 4: Owner opens resume URL and completes the onboarding wizard
await page.goto(`/settings/organizations/new/resume?onboardingId=${onboardingId}`);
const memberEmail = users.trackEmail({ username: "member1", domain: "example.com" });
const result = await completeOnboardingWizard(page, {
aboutText: "This is our test organization",
teams: ["Engineering", "Marketing"],
members: [memberEmail],
});
// Verify the organization was created successfully in the API response
expect(result.organizationId).toBeTruthy();
expect(result.name).toBe(orgName);
expect(result.slug).toBe(orgSlug);
});
test("Admin creates org for self - immediate creation", async ({ page, users }) => {
const admin = await users.create({ role: "ADMIN" });
await admin.apiLogin();
const orgName = "Admin Org";
const orgSlug = `admin-org-${uuid()}`.toLowerCase();
await page.goto("/settings/organizations/new");
// Fill form with admin's own email
await page.locator("input[name=orgOwnerEmail]").fill(admin.email);
await page.locator("input[name=name]").fill(orgName);
await page.locator("input[name=slug]").fill(orgSlug);
if (IS_TEAM_BILLING_ENABLED) {
await page.locator("input[name=seats]").fill("5");
await page.locator("input[name=pricePerSeat]").fill("20");
}
// Submit - admin creating for self goes through wizard
await page.locator("button[type=submit]").click();
// Admin creating for self goes through the onboarding wizard
const result = await completeOnboardingWizard(page, {
aboutText: "Admin's organization",
teams: ["Operations"],
members: [],
});
// Verify the organization was created successfully
expect(result.organizationId).toBeTruthy();
expect(result.name).toBe(orgName);
expect(result.slug).toBe(orgSlug);
});
test("Admin handover URL structure is correct", async ({ page, users }) => {
const admin = await users.create({ role: "ADMIN" });
await admin.apiLogin();
const ownerEmail = users.trackEmail({
username: "testowner",
domain: "example.com",
});
await users.create({
username: "testowner",
email: ownerEmail,
});
const orgName = "Handover Test";
const orgSlug = `handover-${uuid()}`.toLowerCase();
await page.goto("/settings/organizations/new");
await page.locator("input[name=orgOwnerEmail]").fill(ownerEmail);
await page.locator("input[name=name]").fill(orgName);
await page.locator("input[name=slug]").fill(orgSlug);
if (IS_TEAM_BILLING_ENABLED) {
await page.locator("input[name=seats]").fill("10");
await page.locator("input[name=pricePerSeat]").fill("15");
}
const responsePromise = page.waitForResponse("**/api/trpc/organizations/intentToCreateOrg**");
await page.locator("button[type=submit]").click();
// Wait for the response
await responsePromise;
// Wait for redirect to handover page
await page.waitForURL("**/settings/organizations/new/handover");
// Wait for handover page elements to be visible (with longer timeout for store to populate)
await expect(page.getByTestId("onboarding-url")).toBeVisible({ timeout: 10000 });
await expect(page.getByTestId("copy-onboarding-url")).toBeVisible();
const onboardingUrl = await page.getByTestId("onboarding-url").textContent();
// Verify URL format
expect(onboardingUrl).toMatch(/\/settings\/organizations\/new\/resume\?onboardingId=[\w-]+/);
// Verify copy button is clickable (toast message may vary by locale)
await page.getByTestId("copy-onboarding-url").click();
});
});
test.describe("Non-Admin User Flow", () => {
test("Regular user creates org through full wizard", async ({ page, users }) => {
test.skip(process.env.NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED !== "1", "Org self serve is not enabled");
const user = await users.create({
username: "regularuser",
email: users.trackEmail({ username: "regularuser", domain: "example.com" }),
});
await user.apiLogin();
const orgName = "User Organization";
const orgSlug = `user-org-${uuid()}`.toLowerCase();
await page.goto("/settings/organizations/new");
// Verify user email is pre-filled and disabled
const emailInput = page.locator("input[name=orgOwnerEmail]");
await expect(emailInput).toBeDisabled();
await expect(emailInput).toHaveValue(user.email);
// Verify billing fields are NOT visible to regular users
if (IS_TEAM_BILLING_ENABLED) {
await expect(page.locator("input[name=seats]")).not.toBeVisible();
await expect(page.locator("input[name=pricePerSeat]")).not.toBeVisible();
// Verify "Upgrade to Organizations" UI is shown
await expect(page.getByText(/upgrade to organizations/i)).toBeVisible();
}
// Fill form
await page.locator("input[name=name]").fill(orgName);
await page.locator("input[name=slug]").fill(orgSlug);
await page.locator("button[type=submit]").click();
// Verify navigated to /about step
await page.waitForURL("**/settings/organizations/new/about");
// Complete about step
await page.locator('textarea[name="about"]').fill("User's organization description");
await page.locator("button[type=submit]").click();
// Complete add-teams step
await page.waitForURL("**/settings/organizations/new/add-teams");
await page.getByTestId("team.0.name").fill("Team Alpha");
await page.locator("button[type=submit]").click();
// Complete onboard-members step
await page.waitForURL("**/settings/organizations/new/onboard-members");
await Promise.all([
page.waitForResponse("**/api/trpc/organizations/intentToCreateOrg**"),
page.getByTestId("publish-button").click(),
]);
// In billing enabled mode, should redirect to Stripe or payment
// In billing disabled mode, org created immediately
if (IS_TEAM_BILLING_ENABLED) {
// Would redirect to Stripe checkout
await page.waitForURL(/checkout\.stripe\.com|\/settings\/organizations/);
} else {
// After org creation, session update causes redirect to profile page
// Wait for it to load, then navigate to organizations
await page.waitForURL("**/settings/my-account/profile");
await page.goto("/settings/organizations");
await page.waitForURL(/\/settings\/organizations/);
}
});
test("Regular user - email field is disabled", async ({ page, users }) => {
test.skip(process.env.NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED !== "1", "Org self serve is not enabled");
const user = await users.create({
username: "testuser",
email: users.trackEmail({ username: "testuser", domain: "company.com" }),
});
await user.apiLogin();
await page.goto("/settings/organizations/new");
// Verify email field state
const emailInput = page.locator("input[name=orgOwnerEmail]");
await expect(emailInput).toBeVisible();
await expect(emailInput).toBeDisabled();
await expect(emailInput).toHaveValue(user.email);
// Verify name and slug are derived from email domain
const nameInput = page.locator("input[name=name]");
const slugInput = page.locator("input[name=slug]");
// Name should be capitalized version of domain
const nameValue = await nameInput.inputValue();
expect(nameValue).toBeTruthy();
expect(nameValue).toMatch(/company/i);
// Slug should be domain-based
const slugValue = await slugInput.inputValue();
expect(slugValue).toBeTruthy();
expect(slugValue).toContain("company");
});
});
test.describe("Form Validation", () => {
test("Admin form - required fields validation", async ({ page, users }) => {
const admin = await users.create({ role: "ADMIN" });
await admin.apiLogin();
await page.goto("/settings/organizations/new");
// Submit empty form
await page.locator("button[type=submit]").click();
// Should show validation errors
await expect(page.getByTestId("field-error")).toHaveCount(2); // name and slug required
});
test("Slug already taken shows error", async ({ page, users, orgs }) => {
const admin = await users.create({ role: "ADMIN" });
await admin.apiLogin();
// Create an existing org with a specific slug
const existingSlug = `existing-${uuid()}`.toLowerCase();
await orgs.create({
name: "Existing Org",
slug: existingSlug,
});
await page.goto("/settings/organizations/new");
const ownerEmail = users.trackEmail({ username: "owner", domain: "example.com" });
await users.create({ username: "owner", email: ownerEmail });
await page.locator("input[name=orgOwnerEmail]").fill(ownerEmail);
await page.locator("input[name=name]").fill("New Org");
await page.locator("input[name=slug]").fill(existingSlug);
if (IS_TEAM_BILLING_ENABLED) {
await page.locator("input[name=seats]").fill("5");
await page.locator("input[name=pricePerSeat]").fill("10");
}
await page.locator("button[type=submit]").click();
// Should show slug taken error
await expect(page.getByText(/url.*taken|already.*exists/i)).toBeVisible();
});
});
test.describe("Navigation Between Steps", () => {
test("User can navigate through wizard steps", async ({ page, users }) => {
test.skip(process.env.NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED !== "1", "Org self serve is not enabled");
const user = await users.create({
username: "wizarduser",
email: users.trackEmail({ username: "wizarduser", domain: "example.com" }),
});
await user.apiLogin();
await page.goto("/settings/organizations/new");
const orgName = "Wizard Test";
const orgSlug = `wizard-${uuid()}`.toLowerCase();
// Step 1
await page.locator("input[name=name]").fill(orgName);
await page.locator("input[name=slug]").fill(orgSlug);
await page.locator("button[type=submit]").click();
// Step 2: About
await page.waitForURL("**/settings/organizations/new/about");
await page.locator('textarea[name="about"]').fill("Wizard test org");
await page.locator("button[type=submit]").click();
// Step 3: Add teams
await page.waitForURL("**/settings/organizations/new/add-teams");
await page.getByTestId("team.0.name").fill("Team 1");
await page.locator("button[type=submit]").click();
// Step 4: Onboard members
await page.waitForURL("**/settings/organizations/new/onboard-members");
// Verify wizard shows correct step numbers
// The WizardLayout component shows currentStep/maxSteps
// Just verify we reached the last step
await expect(page.getByTestId("publish-button")).toBeVisible();
});
});
test.describe("Billing Field Visibility", () => {
test("Admin sees billing fields when billing enabled", async ({ page, users }) => {
test.skip(!IS_TEAM_BILLING_ENABLED, "Billing is not enabled");
const admin = await users.create({ role: "ADMIN" });
await admin.apiLogin();
await page.goto("/settings/organizations/new");
// Verify billing fields are visible for admin
await expect(page.locator("input[name=seats]")).toBeVisible();
await expect(page.locator("input[name=pricePerSeat]")).toBeVisible();
await expect(page.locator("#billingPeriod")).toBeVisible();
// Verify toggle between monthly/annual
await expect(page.getByText("Monthly")).toBeVisible();
await expect(page.getByText("Annually")).toBeVisible();
});
test("Non-admin sees pricing UI but not billing fields", async ({ page, users }) => {
test.skip(!IS_TEAM_BILLING_ENABLED, "Billing is not enabled");
test.skip(process.env.NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED !== "1", "Org self serve is not enabled");
const user = await users.create({
username: "pricinguser",
email: users.trackEmail({ username: "pricinguser", domain: "example.com" }),
});
await user.apiLogin();
await page.goto("/settings/organizations/new");
// Should NOT see admin billing fields
await expect(page.locator("input[name=seats]")).not.toBeVisible();
await expect(page.locator("input[name=pricePerSeat]")).not.toBeVisible();
await expect(page.locator("#billingPeriod")).not.toBeVisible();
// Should see upgrade/pricing UI
await expect(page.getByText(/upgrade to organizations/i)).toBeVisible();
await expect(page.getByText(/organization/i)).toBeVisible();
});
});
});
@@ -1,475 +0,0 @@
import type { Page } from "@playwright/test";
import { expect } from "@playwright/test";
import { JSDOM } from "jsdom";
import type { Messages } from "mailhog";
import path from "path";
import { uuid } from "short-uuid";
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
import type { createEmailsFixture } from "../fixtures/emails";
import { test } from "../lib/fixtures";
import { fillStripeTestCheckout } from "../lib/testUtils";
import { getEmailsReceivedByUser } from "../lib/testUtils";
async function expectEmailWithSubject(
page: Page,
emails: ReturnType<typeof createEmailsFixture>,
userEmail: string,
subject: string
) {
if (!emails) return null;
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(2000);
const receivedEmails = await getEmailsReceivedByUser({ emails, userEmail });
const allEmails = (receivedEmails as Messages).items;
const email = allEmails.find((email) => email.subject === subject);
if (!email) {
throw new Error(`Email with subject ${subject} not found`);
}
const dom = new JSDOM(email.html);
return dom;
}
export async function expectOrganizationCreationEmailToBeSent({
page,
emails,
userEmail,
orgSlug,
}: {
page: Page;
emails: ReturnType<typeof createEmailsFixture>;
userEmail: string;
orgSlug: string;
}) {
const dom = await expectEmailWithSubject(page, emails, userEmail, "Your organization has been created");
const document = dom?.window?.document;
expect(document?.querySelector(`[href*=${orgSlug}]`)).toBeTruthy();
return dom;
}
async function expectOrganizationCreationEmailToBeSentWithLinks({
page,
emails,
userEmail,
oldUsername,
newUsername,
orgSlug,
}: {
page: Page;
emails: ReturnType<typeof createEmailsFixture>;
userEmail: string;
oldUsername: string;
newUsername: string;
orgSlug: string;
}) {
const dom = await expectOrganizationCreationEmailToBeSent({
page,
emails,
userEmail,
orgSlug,
});
const document = dom?.window.document;
const links = document?.querySelectorAll(`[data-testid="organization-link-info"] [href]`);
if (!links) {
throw new Error(`data-testid="organization-link-info doesn't have links`);
}
expect((links[0] as unknown as HTMLAnchorElement).href).toContain(oldUsername);
expect((links[1] as unknown as HTMLAnchorElement).href).toContain(newUsername);
}
export async function expectEmailVerificationEmailToBeSent(
page: Page,
emails: ReturnType<typeof createEmailsFixture>,
userEmail: string
) {
const subject = "Cal.com: Verify your account";
return expectEmailWithSubject(page, emails, userEmail, subject);
}
test.afterEach(({ users, orgs }) => {
users.deleteAll();
orgs.deleteAll();
});
function capitalize(text: string) {
if (!text) {
return text;
}
return text.charAt(0).toUpperCase() + text.slice(1);
}
test.describe("Organization", () => {
test("Admin should be able to do onboarding handover an org where an existing user is made an owner", async ({
page,
users,
emails,
}) => {
const appLevelAdmin = await users.create({
role: "ADMIN",
});
await appLevelAdmin.apiLogin();
const orgOwnerUsernamePrefix = "owner";
const orgOwnerEmail = users.trackEmail({
username: orgOwnerUsernamePrefix,
domain: `example.com`,
});
const orgOwnerUser = await users.create({
username: orgOwnerUsernamePrefix,
email: orgOwnerEmail,
role: "ADMIN",
});
const orgOwnerUsernameOutsideOrg = orgOwnerUser.username;
const orgOwnerUsernameInOrg = orgOwnerEmail.split("@")[0];
const orgName = capitalize(`${orgOwnerUser.username}`);
const orgSlug = `myOrg-${uuid()}`.toLowerCase();
await page.goto("/settings/organizations/new");
await test.step("Basic info", async () => {
// Check required fields
await page.locator("button[type=submit]").click();
await expect(page.getByTestId("field-error")).toHaveCount(2);
// Happy path
await fillAndSubmitFirstStepAsAdmin(page, orgOwnerEmail, orgName, orgSlug);
});
await test.step("Handover", async () => {
const onboardingUrl = await page.getByTestId("onboarding-url").textContent();
expect(onboardingUrl).toContain("settings/organizations/new");
});
});
test.skip("Admin should be able to create an org where the owner doesn't exist yet", async ({
page,
users,
emails,
}) => {
const appLevelAdmin = await users.create({
role: "ADMIN",
});
await appLevelAdmin.apiLogin();
const orgOwnerUsername = `owner`;
const orgName = capitalize(`${orgOwnerUsername}`);
const orgSlug = `myOrg-${uuid()}`.toLowerCase();
const orgOwnerEmail = users.trackEmail({
username: orgOwnerUsername,
domain: `example.com`,
});
await page.goto("/settings/organizations/new");
await test.step("Basic info", async () => {
// Check required fields
await page.locator("button[type=submit]").click();
await expect(page.getByTestId("field-error")).toHaveCount(2);
// Happy path
await fillAndSubmitFirstStepAsAdmin(page, orgOwnerEmail, orgName, orgSlug);
});
const dom = await expectOrganizationCreationEmailToBeSent({
page,
emails,
userEmail: orgOwnerEmail,
orgSlug,
});
expect(dom?.window.document.querySelector(`[href*=${orgSlug}]`)).toBeTruthy();
await expectEmailVerificationEmailToBeSent(page, emails, orgOwnerEmail);
// Rest of the steps remain same as org creation with existing user as owner. So skipping them
});
test("User can create and upgrade a org", async ({ page, users, emails }) => {
// eslint-disable-next-line playwright/no-skipped-test
test.skip(process.env.NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED !== "1", "Org self serve is not enabled");
const stringUUID = uuid();
const orgOwnerUsername = `owner-${stringUUID}`;
const targetOrgEmail = users.trackEmail({
username: orgOwnerUsername,
domain: `example.com`,
});
const orgOwnerUser = await users.create({
username: orgOwnerUsername,
email: targetOrgEmail,
});
await orgOwnerUser.apiLogin();
const orgName = capitalize(`${orgOwnerUsername}`);
await page.goto("/settings/organizations/new");
await test.step("Basic info", async () => {
// These values are inferred due to an existing user being signed
expect(await page.locator("input[name=name]").inputValue()).toBe("Example");
expect(await page.locator("input[name=slug]").inputValue()).toBe("example");
await page.locator("input[name=name]").fill(orgName);
await page.locator("input[name=slug]").fill(orgOwnerUsername);
await page.locator("button[type=submit]").click();
});
await test.step("About the organization", async () => {
// Choosing an avatar
await page.getByTestId("open-upload-avatar-dialog").click();
const fileChooserPromise = page.waitForEvent("filechooser");
await page.getByText("Choose a file...").click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(path.join(__dirname, "../../public/apple-touch-icon.png"));
await page.getByTestId("upload-avatar").click();
// About text
await page.locator('textarea[name="about"]').fill("This is a testing org");
await page.locator("button[type=submit]").click();
// Waiting to be in next step URL
await page.waitForURL("/settings/organizations/*/onboard-members");
});
await test.step("On-board administrators", async () => {
await page.waitForSelector('[data-testid="pending-member-list"]');
await expect(page.getByTestId("pending-member-item")).toHaveCount(1);
const adminEmail = users.trackEmail({ username: "rick", domain: `example.com` });
//can add members
await page.getByTestId("new-member-button").click();
await page.locator('[placeholder="email\\@example\\.com"]').fill(adminEmail);
await page.getByTestId("invite-new-member-button").click();
await expect(page.getByTestId("pending-member-item").filter({ hasText: adminEmail })).toBeVisible();
// TODO: Check if invited admin received the invitation email
// await expectInvitationEmailToBeReceived(
// page,
// emails,
// adminEmail,
// `${orgName}'s admin invited you to join the organization ${orgName} on Cal.com`
// );
await expect(page.getByTestId("pending-member-item")).toHaveCount(2);
// can remove members
await expect(page.getByTestId("pending-member-item")).toHaveCount(2);
const lastRemoveMemberButton = page.getByTestId("remove-member-button").last();
await lastRemoveMemberButton.click();
await expect(page.getByTestId("pending-member-item")).toHaveCount(1);
await page.getByTestId("publish-button").click();
// Waiting to be in next step URL
await page.waitForURL("/settings/organizations/*/add-teams");
});
await test.step("Create teams", async () => {
// Filling one team
await page.locator('input[name="teams.0.name"]').fill("Marketing");
// Adding another team
await page.getByTestId("add_a_team").click();
await page.locator('input[name="teams.1.name"]').fill("Sales");
// Finishing the creation wizard
await page.getByTestId("continue_or_checkout").click();
});
await test.step("Login as org owner and pay", async () => {
// eslint-disable-next-line playwright/no-skipped-test
test.skip(!IS_TEAM_BILLING_ENABLED, "Skipping paying for org as stripe is disabled");
await orgOwnerUser.apiLogin();
await page.goto("/event-types");
const upgradeButton = await page.getByTestId("upgrade_org_banner_button");
await expect(upgradeButton).toBeVisible();
await upgradeButton.click();
// Check that stripe checkout is present
const expectedUrl = "https://checkout.stripe.com";
await page.waitForURL((url) => url.href.startsWith(expectedUrl));
const url = page.url();
// Check that the URL matches the expected URL
expect(url).toContain(expectedUrl);
await fillStripeTestCheckout(page);
const upgradeButtonHidden = await page.getByTestId("upgrade_org_banner_button");
await expect(upgradeButtonHidden).toBeHidden();
});
});
test("User gets prompted with >=3 teams to upgrade & can transfer existing teams to org", async ({
page,
users,
}) => {
// eslint-disable-next-line playwright/no-skipped-test
test.skip(process.env.NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED !== "1", "Org self serve is not enabled");
const numberOfTeams = 3;
const stringUUID = uuid();
const orgOwnerUsername = `owner-${stringUUID}`;
const targetOrgEmail = users.trackEmail({
username: orgOwnerUsername,
domain: `example.com`,
});
const orgOwnerUser = await users.create(
{
username: orgOwnerUsername,
email: targetOrgEmail,
},
{ hasTeam: true, numberOfTeams }
);
await orgOwnerUser.apiLogin();
await page.goto("/teams");
await test.step("Has org self serve banner", async () => {
// These values are inferred due to an existing user being signed
const selfServeButtonLocator = await page.getByTestId("setup_your_org_action_button");
await expect(selfServeButtonLocator).toBeVisible();
await selfServeButtonLocator.click();
await page.waitForURL("/settings/organizations/new");
});
await test.step("Basic info", async () => {
// These values are inferred due to an existing user being signed
const slugLocator = await page.locator("input[name=slug]");
expect(await page.locator("input[name=name]").inputValue()).toBe("Example");
expect(await slugLocator.inputValue()).toBe("example");
await slugLocator.fill(`example-${stringUUID}`);
await page.locator("button[type=submit]").click();
});
await test.step("About the organization", async () => {
// Choosing an avatar
await page.getByTestId("open-upload-avatar-dialog").click();
const fileChooserPromise = page.waitForEvent("filechooser");
await page.getByText("Choose a file...").click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(path.join(__dirname, "../../public/apple-touch-icon.png"));
await page.getByTestId("upload-avatar").click();
// About text
await page.locator('textarea[name="about"]').fill("This is a testing org");
await page.locator("button[type=submit]").click();
// Waiting to be in next step URL
await page.waitForURL("/settings/organizations/*/onboard-members");
});
await test.step("On-board administrators", async () => {
await page.waitForSelector('[data-testid="pending-member-list"]');
await expect(page.getByTestId("pending-member-item")).toHaveCount(1);
const adminEmail = users.trackEmail({ username: "rick", domain: `example.com` });
//can add members
await page.getByTestId("new-member-button").click();
await page.locator('[placeholder="email\\@example\\.com"]').fill(adminEmail);
await page.getByTestId("invite-new-member-button").click();
await expect(page.getByTestId("pending-member-item").filter({ hasText: adminEmail })).toBeVisible();
// TODO: Check if invited admin received the invitation email
// await expectInvitationEmailToBeReceived(
// page,
// emails,
// adminEmail,
// `${orgName}'s admin invited you to join the organization ${orgName} on Cal.com`
// );
await expect(page.getByTestId("pending-member-item")).toHaveCount(2);
// can remove members
await expect(page.getByTestId("pending-member-item")).toHaveCount(2);
const lastRemoveMemberButton = page.getByTestId("remove-member-button").last();
await lastRemoveMemberButton.click();
await expect(page.getByTestId("pending-member-item")).toHaveCount(1);
await page.getByTestId("publish-button").click();
// Waiting to be in next step URL
await page.waitForURL("/settings/organizations/*/add-teams");
});
await test.step("Move existing teams to org", async () => {
// No easy way to get all team checkboxes so we fill all checkboxes on the page in
const foundCheckboxes = page.locator('input[type="checkbox"]');
const count = await foundCheckboxes.count();
for (let i = 0; i < count; i++) {
const checkbox = foundCheckboxes.nth(i);
await checkbox.click();
}
});
await test.step("Create teams", async () => {
// Filling one team
await page.locator('input[name="teams.0.name"]').fill("Marketing");
// Adding another team
await page.getByTestId("new-team-dialog-button").click();
await page.locator('input[name="teams.1.name"]').fill("Sales");
// Finishing the creation wizard
await page.getByTestId("continue_or_checkout").click();
});
await test.step("Login as org owner and pay", async () => {
// eslint-disable-next-line playwright/no-skipped-test
test.skip(!IS_TEAM_BILLING_ENABLED, "Skipping paying for org as stripe is disabled");
await orgOwnerUser.apiLogin();
await page.goto("/event-types");
const upgradeButton = await page.getByTestId("upgrade_org_banner_button");
await expect(upgradeButton).toBeVisible();
await upgradeButton.click();
// Check that stripe checkout is present
const expectedUrl = "https://checkout.stripe.com";
await page.waitForURL((url) => url.href.startsWith(expectedUrl));
const url = page.url();
// Check that the URL matches the expected URL
expect(url).toContain(expectedUrl);
await fillStripeTestCheckout(page);
const upgradeButtonHidden = await page.getByTestId("upgrade_org_banner_button");
await expect(upgradeButtonHidden).toBeHidden();
});
await test.step("Ensure correctnumberOfTeams are migrated", async () => {
// eslint-disable-next-line playwright/no-skipped-test
await page.goto("/teams");
const teamListItems = await page.getByTestId("team-list-item-link").all();
// Number of teams migrated + the two created in the create teams step
expect(teamListItems.length).toBe(numberOfTeams + 2);
});
});
});
async function fillAndSubmitFirstStepAsAdmin(
page: Page,
targetOrgEmail: string,
orgName: string,
orgSlug: string
) {
await page.locator("input[name=orgOwnerEmail]").fill(targetOrgEmail);
// Since we are admin fill in this information instead of deriving it
await page.locator("input[name=name]").fill(orgName);
await page.locator("input[name=slug]").fill(orgSlug);
// Fill in seat information
await page.locator("input[name=seats]").fill("30");
await page.locator("input[name=pricePerSeat]").fill("30");
await Promise.all([
page.waitForResponse("**/api/trpc/organizations/intentToCreateOrg**"),
page.locator("button[type=submit]").click(),
]);
}
+8 -6
View File
@@ -653,11 +653,12 @@ test.describe("Out of office", () => {
test("Default date range filter set to `Last 7 Days`", async ({ page, users }) => {
const user = await users.create({ name: `userOne=${Date.now()}` });
await user.apiLogin();
await page.goto("/settings/my-account/out-of-office");
await page.waitForLoadState("domcontentloaded");
await page.waitForResponse(
const responsePromise = page.waitForResponse(
(response) => response.url().includes("outOfOfficeEntriesList") && response.status() === 200
);
await page.goto("/settings/my-account/out-of-office");
await page.waitForLoadState("domcontentloaded");
await responsePromise;
await addFilter(page, "dateRange");
await expect(
page.locator('[data-testid="filter-popover-trigger-dateRange"]', { hasText: "Last 7 Days" }).first()
@@ -667,11 +668,12 @@ test.describe("Out of office", () => {
test("Can choose date range presets", async ({ page, users }) => {
const user = await users.create({ name: `userOne=${Date.now()}` });
await user.apiLogin();
await page.goto("/settings/my-account/out-of-office");
await page.waitForLoadState("domcontentloaded");
await page.waitForResponse(
const responsePromise = page.waitForResponse(
(response) => response.url().includes("outOfOfficeEntriesList") && response.status() === 200
);
await page.goto("/settings/my-account/out-of-office");
await page.waitForLoadState("domcontentloaded");
await responsePromise;
await addFilter(page, "dateRange");
await expect(page.locator('[data-testid="date-range-options-tdy"]')).toBeVisible(); //Today
+14 -8
View File
@@ -1,6 +1,7 @@
import { expect } from "@playwright/test";
import prisma from "@calcom/prisma";
import type { Page } from "@playwright/test";
import { test } from "./lib/fixtures";
import { selectFirstAvailableTimeSlotNextMonth, submitAndWaitForResponse } from "./lib/testUtils";
@@ -8,6 +9,11 @@ import { selectFirstAvailableTimeSlotNextMonth, submitAndWaitForResponse } from
test.describe.configure({ mode: "parallel" });
test.afterEach(({ users }) => users.deleteAll());
async function goToAppsTab(page: Page, eventTypeId?: number) {
await page.goto(`event-types/${eventTypeId}?tabName=apps`);
await expect(page.getByTestId("vertical-tab-apps")).toHaveAttribute("aria-current", "page");
}
test.describe("Payment app", () => {
test("Should be able to edit alby price, currency", async ({ page, users }) => {
const user = await users.create();
@@ -29,7 +35,7 @@ test.describe("Payment app", () => {
},
});
await page.goto(`event-types/${paymentEvent?.id}?tabName=apps`);
await goToAppsTab(page, paymentEvent?.id);
await page.locator("#event-type-form").getByRole("switch").click();
await page.getByPlaceholder("Price").click();
@@ -75,7 +81,7 @@ test.describe("Payment app", () => {
},
});
await page.goto(`event-types/${paymentEvent?.id}?tabName=apps`);
await goToAppsTab(page, paymentEvent?.id);
await page.locator("#event-type-form").getByRole("switch").click();
await page.getByTestId("stripe-currency-select").click();
await page.getByTestId("select-option-usd").click();
@@ -115,7 +121,7 @@ test.describe("Payment app", () => {
},
});
await page.goto(`event-types/${paymentEvent?.id}?tabName=apps`);
await goToAppsTab(page, paymentEvent?.id);
await page.locator("#event-type-form").getByRole("switch").click();
@@ -158,7 +164,7 @@ test.describe("Payment app", () => {
},
});
await page.goto(`event-types/${paymentEvent?.id}?tabName=apps`);
await goToAppsTab(page, paymentEvent?.id);
await page.locator("#event-type-form").getByRole("switch").click();
@@ -185,7 +191,7 @@ test.describe("Payment app", () => {
},
});
await page.goto(`event-types/${paymentEvent?.id}?tabName=apps`);
await goToAppsTab(page, paymentEvent?.id);
await page.locator("#event-type-form").getByRole("switch").click();
@@ -220,7 +226,7 @@ test.describe("Payment app", () => {
},
});
await page.goto(`event-types/${paymentEvent?.id}?tabName=apps`);
await goToAppsTab(page, paymentEvent?.id);
await page.locator("#event-type-form").getByRole("switch").click();
// make sure Tracking ID is displayed
@@ -267,7 +273,7 @@ test.describe("Payment app", () => {
],
});
await page.goto(`event-types/${paymentEvent.id}?tabName=apps`);
await goToAppsTab(page, paymentEvent?.id);
await page.locator("[data-testid='paypal-app-switch']").click();
await page.locator("[data-testid='stripe-app-switch']").isDisabled();
@@ -314,7 +320,7 @@ test.describe("Payment app", () => {
],
});
await page.goto(`event-types/${paymentEvent.id}?tabName=apps`);
await goToAppsTab(page, paymentEvent?.id);
await page.getByTestId("paypal-app-switch").click();
await page.getByTestId("paypal-price-input").fill("100");
@@ -793,6 +793,7 @@
"remove_attribute": "Remove attribute",
"remove_attribute_confirmation_message": "Are you sure you want to remove the attribute '{{name}}'? All users currently assigned to it will be unassigned.",
"delete": "Delete",
"view": "View",
"update": "Update",
"save": "Save",
"pending": "Pending",
@@ -1188,6 +1189,8 @@
"featured_categories": "Featured Categories",
"feature_flags": "Feature Flags",
"admin_flags_description": "Here you can toggle your Cal.com instance features.",
"feature_assigned_successfully": "Feature assigned successfully",
"feature_unassigned_successfully": "Feature unassigned successfully",
"popular_categories": "Popular Categories",
"number_apps_one": "{{count}} App",
"number_apps_other": "{{count}} Apps",
@@ -2853,6 +2856,7 @@
"organization_price_per_user_month": "$37 per user per month",
"privacy_organization_description": "Manage privacy settings for your organization",
"privacy": "Privacy",
"privacy_and_security": "Privacy & Security",
"team_will_be_under_org": "New teams will be under your organization",
"add_group_name": "Add group name",
"group_name": "Group Name",
@@ -3295,6 +3299,12 @@
"organization_onboarding_already_completed": "Organization onboarding already completed",
"organization_onboarding_already_exists": "Organization onboarding already exists",
"organization_onboarding_not_found": "Organization onboarding not found",
"no_onboarding_id_provided": "No onboarding ID provided",
"onboarding_not_found": "Onboarding not found",
"onboarding_already_complete": "Onboarding already complete",
"onboarding_already_complete_description": "This onboarding has already been completed. You can view your organization in settings.",
"resume_onboarding": "Resume Onboarding",
"resume_onboarding_description": "Continue with your organization onboarding process",
"payment_successful": "Payment successful!",
"handover_onboarding_page_title": "Handover onboarding",
"handover_onboarding_page_description": "Handover onboarding to the user",
@@ -3545,6 +3555,7 @@
"pbac_resource_webhook": "Webhook",
"pbac_resource_availability": "Availability",
"pbac_resource_out_of_office": "Out of Office",
"pbac_resource_blocklist": "Blocklist",
"pbac_desc_create_webhooks": "Create webhooks",
"pbac_desc_view_webhooks": "View webhooks",
"pbac_desc_update_webhooks": "Update webhooks",
@@ -3623,6 +3634,8 @@
"no_members_affected_by_disabling_delegation_credential": "No members affected by disabling delegation credential",
"download_expense_log": "Download Expense Log",
"error_downloading_expense_log": "Error downloading expense log",
"require_booker_email_verification": "Prevent Impersonation on Bookings",
"require_booker_email_verification_description": "When enabled, anyone trying to book events using your email address must verify they own it via a one time code or be logged in to prevent impersonation",
"offer_to_reschedule_last_booking": "Offer to reschedule last active booking to chosen time slot",
"booker_limit_exceeded_error": "Booker maximum active booking limit exceeded",
"booker_limit_exceeded_error_reschedule": "You already have a booking for this event on {{date}}. Would you like to reschedule to the new selected time?",
@@ -3811,5 +3824,26 @@
"edit_configuration": "Edit Configuration",
"select_event_type_for_inbound_calls": "Inbound calls can book only one event type. Select the event type where meetings will be scheduled when callers reach your agent.",
"setup_inbound_agent_for_incoming_calls": "Set up inbound agent for incoming calls",
"create_block_entry": "Create Block Entry",
"blocklist_entry_details": "Blocklist Entry Details",
"blocklist_entry_not_found": "Blocklist entry not found",
"blocklist_entry_created": "Blocklist entry created successfully",
"blocklist_entry_deleted": "Blocklist entry deleted successfully",
"audit_history": "Audit History",
"no_audit_history": "No audit history available",
"delete_blocklist_entry": "Delete Blocklist Entry",
"delete_blocklist_entry_confirmation": "Are you sure you want to remove \"{{value}}\" from the blocklist? This action cannot be undone.",
"created_by": "Created By",
"blocked_by": "Blocked By",
"by": "By",
"added": "Added",
"no_description_provided": "No description provided",
"organization_blocklist": "Organization Blocklist",
"manage_blocked_emails_and_domains": "Manage blocked emails and domains for your organization",
"invalid_domain_format": "Invalid domain format. Example: example.com",
"invalid_email_address": "Invalid email address. Example: user@example.com",
"reason_for_adding_to_blocklist": "Reason for adding to blocklist",
"what_would_you_like_to_block": "What would you like to block?",
"domain_name": "Domain Name",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -4,14 +4,15 @@ import { encode } from "querystring";
import type { z } from "zod";
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import { getEventTypesPublic } from "@calcom/features/eventtypes/lib/getEventTypesPublic";
import { DEFAULT_DARK_BRAND_COLOR, DEFAULT_LIGHT_BRAND_COLOR } from "@calcom/lib/constants";
import { getUsernameList } from "@calcom/features/eventtypes/lib/defaultEvents";
import { getEventTypesPublic } from "@calcom/features/eventtypes/lib/getEventTypesPublic";
import { getBrandingForUser } from "@calcom/features/profile/lib/getBranding";
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import { DEFAULT_DARK_BRAND_COLOR, DEFAULT_LIGHT_BRAND_COLOR } from "@calcom/lib/constants";
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
import logger from "@calcom/lib/logger";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import { safeStringify } from "@calcom/lib/safeStringify";
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import { stripMarkdown } from "@calcom/lib/stripMarkdown";
import { prisma } from "@calcom/prisma";
import type { EventType, User } from "@calcom/prisma/client";
@@ -33,6 +34,9 @@ type UserPageProps = {
requestedSlug: string | null;
slug: string | null;
id: number | null;
brandColor: string | null;
darkBrandColor: string | null;
theme: string | null;
} | null;
allowSEOIndexing: boolean;
username: string | null;
@@ -130,15 +134,18 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
const [user] = usersInOrgContext; //to be used when dealing with single user, not dynamic group
const branding = getBrandingForUser({ user });
const profile = {
name: user.name || user.username || "",
image: getUserAvatarUrl({
avatarUrl: user.avatarUrl,
}),
theme: user.theme,
brandColor: user.brandColor ?? DEFAULT_LIGHT_BRAND_COLOR,
theme: branding.theme,
brandColor: branding.brandColor ?? DEFAULT_LIGHT_BRAND_COLOR,
avatarUrl: user.avatarUrl,
darkBrandColor: user.darkBrandColor ?? DEFAULT_DARK_BRAND_COLOR,
darkBrandColor:
branding.darkBrandColor ?? DEFAULT_DARK_BRAND_COLOR,
allowSEOIndexing: user.allowSEOIndexing ?? true,
username: user.username,
organization: user.profile.organization,
@@ -9,12 +9,12 @@ import type { z } from "zod";
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
import { handleStripePaymentSuccess } from "@calcom/features/ee/payments/api/webhook";
import { ProfileRepository } from "@calcom/features/profile/repositories/ProfileRepository";
import { weekdayToWeekIndex, type WeekDays } from "@calcom/lib/dayjs";
import type { HttpError } from "@calcom/lib/http-error";
import type { IntervalLimit } from "@calcom/lib/intervalLimits/intervalLimitSchema";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { ProfileRepository } from "@calcom/features/profile/repositories/ProfileRepository";
import type { BookingReference, Attendee, Booking, Membership } from "@calcom/prisma/client";
import type { Prisma } from "@calcom/prisma/client";
import type { WebhookTriggerEvents } from "@calcom/prisma/client";
@@ -240,6 +240,8 @@ type InputUser = Omit<typeof TestData.users.example, "defaultScheduleId"> & {
end: string;
}[];
};
requiresBookerEmailVerification?: boolean;
secondaryEmails?: { email: string; emailVerified: Date | null }[];
};
export type InputEventType = {
@@ -834,6 +836,21 @@ export async function addUsersToDb(users: InputUser[]) {
}
}
for (const user of users) {
if (user.secondaryEmails) {
log.debug("Creating SecondaryEmail entries for user", user.id);
for (const secondaryEmail of user.secondaryEmails) {
await prismock.secondaryEmail.create({
data: {
email: secondaryEmail.email,
emailVerified: secondaryEmail.emailVerified,
userId: user.id,
},
});
}
}
}
const allUsers = await prismock.user.findMany({
include: {
credentials: true,
@@ -845,6 +862,7 @@ export async function addUsersToDb(users: InputUser[]) {
},
},
destinationCalendar: true,
secondaryEmails: true,
},
});
@@ -1554,6 +1572,8 @@ export function getOrganizer({
username,
locked,
emailVerified,
requiresBookerEmailVerification,
secondaryEmails,
}: {
name: string;
email: string;
@@ -1572,6 +1592,8 @@ export function getOrganizer({
username?: string;
locked?: boolean;
emailVerified?: Date | null;
requiresBookerEmailVerification?: boolean;
secondaryEmails?: { email: string; emailVerified: Date | null }[];
}) {
username = username ?? TestData.users.example.username;
return {
@@ -1594,6 +1616,8 @@ export function getOrganizer({
completedOnboarding,
locked,
emailVerified,
requiresBookerEmailVerification,
secondaryEmails,
};
}
@@ -27,6 +27,7 @@ type CommonPropsMockRequestData = {
rescheduledBy?: string;
cancelledBy?: string;
schedulingType?: SchedulingType;
guests?: string[];
responses: {
email: string;
name: string;
+127
View File
@@ -15649,6 +15649,20 @@
}
}
},
"BookerActiveBookingsLimit_2024_06_14": {
"type": "object",
"properties": {
"maximumActiveBookings": {
"type": "number",
"description": "The maximum number of active bookings a booker can have for this event type.",
"example": 3
},
"offerReschedule": {
"type": "boolean",
"description": "Whether to offer rescheduling the last active booking to the chosen time slot when limit is reached."
}
}
},
"Disabled_2024_06_14": {
"type": "object",
"properties": {
@@ -15986,6 +16000,19 @@
},
"required": ["type"]
},
"EmailSettings_2024_06_14": {
"type": "object",
"properties": {
"disableEmailsToAttendees": {
"type": "boolean",
"description": "Disables all email communication to attendees for this event type, including booking confirmations, reminders, and cancellations. This DOES NOT include emails sent by custom email workflows."
},
"disableEmailsToHosts": {
"type": "boolean",
"description": "Disables all email communication to hosts for this event type, including booking confirmations, reminders, and cancellations. This DOES NOT include emails sent by custom email workflows."
}
}
},
"CalVideoSettings": {
"type": "object",
"properties": {
@@ -16146,6 +16173,17 @@
}
]
},
"bookerActiveBookingsLimit": {
"description": "Limit the number of active bookings a booker can make for this event type.",
"oneOf": [
{
"$ref": "#/components/schemas/BookerActiveBookingsLimit_2024_06_14"
},
{
"$ref": "#/components/schemas/Disabled_2024_06_14"
}
]
},
"onlyShowFirstAvailableSlot": {
"type": "boolean",
"description": "This will limit your availability for this event type to one slot per day, scheduled at the earliest available time."
@@ -17203,6 +17241,20 @@
},
"required": ["type", "slug", "label", "required", "placeholder", "hidden", "isDefault"]
},
"BookerActiveBookingsLimitOutput_2024_06_14": {
"type": "object",
"properties": {
"maximumActiveBookings": {
"type": "number",
"description": "The maximum number of active bookings a booker can have for this event type.",
"example": 3
},
"offerReschedule": {
"type": "boolean",
"description": "Whether to offer rescheduling the last active booking to the chosen time slot when limit is reached."
}
}
},
"EventTypeOutput_2024_06_14": {
"type": "object",
"properties": {
@@ -17389,6 +17441,9 @@
"bookingLimitsCount": {
"type": "object"
},
"bookerActiveBookingsLimit": {
"$ref": "#/components/schemas/BookerActiveBookingsLimitOutput_2024_06_14"
},
"onlyShowFirstAvailableSlot": {
"type": "boolean"
},
@@ -17673,6 +17728,17 @@
}
]
},
"bookerActiveBookingsLimit": {
"description": "Limit the number of active bookings a booker can make for this event type.",
"oneOf": [
{
"$ref": "#/components/schemas/BookerActiveBookingsLimit_2024_06_14"
},
{
"$ref": "#/components/schemas/Disabled_2024_06_14"
}
]
},
"onlyShowFirstAvailableSlot": {
"type": "boolean",
"description": "This will limit your availability for this event type to one slot per day, scheduled at the earliest available time."
@@ -19244,6 +19310,17 @@
}
]
},
"bookerActiveBookingsLimit": {
"description": "Limit the number of active bookings a booker can make for this event type.",
"oneOf": [
{
"$ref": "#/components/schemas/BookerActiveBookingsLimit_2024_06_14"
},
{
"$ref": "#/components/schemas/Disabled_2024_06_14"
}
]
},
"onlyShowFirstAvailableSlot": {
"type": "boolean",
"description": "This will limit your availability for this event type to one slot per day, scheduled at the earliest available time."
@@ -19420,6 +19497,18 @@
}
]
}
},
"emailSettings": {
"description": "Email settings for this event type - only available for organization team event types.",
"allOf": [
{
"$ref": "#/components/schemas/EmailSettings_2024_06_14"
}
]
},
"rescheduleWithSameRoundRobinHost": {
"type": "boolean",
"description": "Rescheduled events will be assigned to the same host as initially scheduled."
}
},
"required": ["lengthInMinutes", "title", "slug", "schedulingType"]
@@ -19653,6 +19742,9 @@
"bookingLimitsCount": {
"type": "object"
},
"bookerActiveBookingsLimit": {
"$ref": "#/components/schemas/BookerActiveBookingsLimitOutput_2024_06_14"
},
"onlyShowFirstAvailableSlot": {
"type": "boolean"
},
@@ -19755,6 +19847,18 @@
},
"team": {
"$ref": "#/components/schemas/EventTypeTeam"
},
"emailSettings": {
"description": "Email settings for this event type. Only available for organization team event types.",
"allOf": [
{
"$ref": "#/components/schemas/EmailSettings_2024_06_14"
}
]
},
"rescheduleWithSameRoundRobinHost": {
"type": "boolean",
"description": "Rescheduled events will be assigned to the same host as initially scheduled."
}
},
"required": [
@@ -20043,6 +20147,17 @@
}
]
},
"bookerActiveBookingsLimit": {
"description": "Limit the number of active bookings a booker can make for this event type.",
"oneOf": [
{
"$ref": "#/components/schemas/BookerActiveBookingsLimit_2024_06_14"
},
{
"$ref": "#/components/schemas/Disabled_2024_06_14"
}
]
},
"onlyShowFirstAvailableSlot": {
"type": "boolean",
"description": "This will limit your availability for this event type to one slot per day, scheduled at the earliest available time."
@@ -20213,6 +20328,18 @@
}
]
}
},
"emailSettings": {
"description": "Email settings for this event type. Only available for organization team event types.",
"allOf": [
{
"$ref": "#/components/schemas/EmailSettings_2024_06_14"
}
]
},
"rescheduleWithSameRoundRobinHost": {
"type": "boolean",
"description": "Rescheduled events will be assigned to the same host as initially scheduled."
}
}
},
+8 -6
View File
@@ -209,17 +209,19 @@
"group": "Atoms",
"icon": "atom",
"pages": [
"platform/atoms/cal-provider",
"platform/atoms/google-calendar-connect",
"platform/atoms/outlook-calendar-connect",
"platform/atoms/apple-calendar-connect",
"platform/atoms/availability-settings",
"platform/atoms/booker",
"platform/atoms/event-type",
"platform/atoms/cal-provider",
"platform/atoms/calendar-settings",
"platform/atoms/payment-form",
"platform/atoms/calendar-view",
"platform/atoms/conferencing-apps",
"platform/atoms/calendar-view"
"platform/atoms/create-schedule",
"platform/atoms/event-type",
"platform/atoms/google-calendar-connect",
"platform/atoms/list-schedules",
"platform/atoms/outlook-calendar-connect",
"platform/atoms/payment-form"
]
},
{
+127
View File
@@ -0,0 +1,127 @@
---
title: "Create schedule"
---
The Create Schedule atom provides a simple dialog interface for users to create new availability
schedules. Fully customizable with callback support for handling successful schedule creation.
Below code snippet can be used to render the Create schedule atom
```js
import { CreateSchedule } from "@calcom/atoms";
export default function CreateSchedule() {
return (
<>
<CreateSchedule
name="Create new schedule"
customClassNames={{
createScheduleButton: "bg-red-500 border-none my-4 mx-2 rounded-md",
}}
/>
</>
)
}
```
For a demonstration of the Create schedule atom, please refer to the video below.
<p></p>
<iframe
height="315"
style={{ width: "100%", maxWidth: "560px" }}
src="https://www.loom.com/embed/2d80dd0dddb64c6fb755b00091bdbced?sid=540f2e02-085e-4240-8b64-ca269cf2bb08"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen="true"
></iframe>
<p></p>
For developers who don't want the dialog-based interface, we provide a `CreateScheduleForm` atom to integrate the schedule
creation form directly into your own UI. This headless approach gives you full flexibility to
handle layout, styling, and user flow exactly how you need it.
Below code snippet can be used to render the Create schedule form atom
```js
import { CreateScheduleForm } from "@calcom/atoms";
export default function CreateScheduleForm() {
return (
<>
<CreateScheduleForm
customClassNames={{
atomsWrapper: "border-black border-[1px] w-[500px] my-10 mx-5 rounded-md px-5 py-5",
buttons: {
continue: "bg-red-400 border-none",
container: "justify-start",
},
}}
/>
</>
)
}
```
For a demonstration of the Create schedule form, please refer to the video below.
<p></p>
<iframe
height="315"
style={{ width: "100%", maxWidth: "560px" }}
src="https://www.loom.com/embed/6f019f1ba49a43439032d2d6b434c17c?sid=448e105f-14f2-4735-979d-30470e7c10d1"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen="true"
></iframe>
<p></p>
We offer all kinds of customizations to the Create schedule atom via props. Below is a list of props that can be passed to the atom.
<p></p>
| Name | Required | Description |
| :--------------- | :------- | :----------------------------------------------------------------------- |
| name | No | The label for the create schedule button |
| customClassNames | No | To pass in custom classnames from outside for styling the atom |
| onSuccess | No | Callback function that handles successful creation of schedule |
| onError | No | Callback function to handles errors at the time of schedule creation |
| disableToasts | No | Boolean value that determines whether the toasts are displayed or not |
Along with the props, create schedule atom accepts custom styles via the **customClassNames** prop. Below is a list of props that fall under this **customClassNames** prop.
<p></p>
| Name | Description |
|:---------------------------|:-----------------------------------------------------------------------|
| createScheduleButton | Adds styling to the create button |
| inputField | Adds styling to the container of the name input field |
| formWrapper | Adds styling to the whole form |
| actionsButtons | Object containing classnames for the submit, cancel buttons and container inside the create schedule atom |
Similar to the create schedule atom, the create schedule form also offer all kinds of customizations via props. Below is a list of props that can be passed to the atom.
<p></p>
| customClassNames | No | To pass in custom classnames from outside for styling the atom |
| onSuccess | No | Callback function that handles successful creation of schedule |
| onError | No | Callback function to handles errors at the time of schedule creation |
| disableToasts | No | Boolean value that determines whether the toasts are displayed or not |
Along with the props, create schedule form also accepts custom styles via the **customClassNames** prop. Below is a list of props that fall under this **customClassNames** prop.
| Name | Description |
|:---------------------------|:-----------------------------------------------------------------------|
| formWrapper | Adds styling to the whole form |
| inputField | Adds styling to the container of the name input field |
| actionsButtons | Object containing classnames for the submit, cancel buttons and container inside the create schedule atom |
<Note>
Please ensure all custom classnames are valid Tailwind CSS classnames. Note that sometimes the custom
classnames may fail to override the styling with the classnames that you might have passed via props. That
is because the clsx utility function that we use to override classnames inside our components has some
limitations. A simple get around to solve this issue is to just prefix your classnames with ! property just
before passing in any classname.
</Note>
+116
View File
@@ -0,0 +1,116 @@
---
title: "List schedules"
---
The List schedules atom displays all of a user's availability schedules and provides management actions such as delete, duplicate, and edit capabilities for each schedule.
Below code snippet can be used to render the List schedules atom
```js
import { ListSchedules } from "@calcom/atoms";
export default function ListSchedules() {
return (
<>
<ListSchedules getRedirectUrl={(scheduleId) => `/availability/${scheduleId}`} />
</>
);
}
```
For a demonstration of the List schedules atom, please refer to the video below.
<p></p>
<iframe
height="315"
style={{ width: "100%", maxWidth: "560px" }}
src="https://www.loom.com/embed/8e020a4848d543548c519d3a7cd532c6?sid=e6ccad23-8372-402a-8fd3-c0180bc9113c"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen="true"
></iframe>
<p></p>
Below is a list of props that can be passed to the List schedules atom.
<p></p>
| Name | Required | Description |
| :------------- | :------- | :---------------------------------------------------------------------------------------------------------------- |
| getRedirectUrl | No | Callback function that determines where the user should be redirected when they click on a schedule from the atom |
## Combining list schedules atom with availability settings atom
The List schedules atom works best when combined with the Availability settings atom to provide a seamless experience. In order to combine both of atom, here are the steps we recommend:
<Steps>
<Step title="Setup list schedules atom">
Create a page for demostrating the list schedules atom.
Below code snippet can be used to setup the list schedules atom on a new page
```js
import { ListSchedules } from "@calcom/atoms";
export default function ListSchedules() {
return (
<>
<ListSchedules getRedirectUrl={(scheduleId) => `/availability/${scheduleId}`} />
</>
);
}
```
`getRedirectUrl` prop is a callback function that determines where the user should be redirected when they click on a schedule from the atom. Say if you want to display your availability settings atom on the page `/availability/:scheduleId`, you need to make sure you return the exact same url when you call the `getRedirectUrl` function.
</Step>
<Step title="Setup availability settings atom">
Create a page for demonstrating the availability settings atom. This should be a dynamic route that accepts a schedule id as a query parameter, and then the schedule id gets passed to the availability settings atom.
Below code snippet can be used to obtain the `scheduleId` and display the availability settings atom
```js
import { AvailabilitySettings } from "@calcom/atoms";
import { useRouter } from "next/router";
export default function Availability() {
const router = useRouter();
return (
<>
<AvailabilitySettings
id={router.query.scheduleId as string}
onUpdateSuccess={() => {
console.log("Updated schedule successfully");
}}
/>
</>
)
}
```
</Step>
</Steps>
-Below video shows a demonstration of how we combined the list schedules atom and availability settings atom in one of our example app to create a seamless availability management experience.
<p></p>
<iframe
height="315"
style={{ width: "100%", maxWidth: "560px" }}
src="https://www.loom.com/embed/76b65330e61e47eb95b7072ed3a42ec8?sid=9373ec6f-ba55-431e-98de-5dd3fb252a8a"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen="true"
></iframe>
<p></p>
<Info>
Link to the example app can be found
[here](https://github.com/calcom/cal.com/tree/main/packages/platform/examples/base)
</Info>
-3
View File
@@ -1,3 +0,0 @@
## Infrastructure Folder
This folder, located within the "infra" directory of our monorepo, is dedicated to managing the infrastructure as code (IaC) and Docker-related files for our project. It plays a critical role in orchestrating, configuring, and maintaining the infrastructure that our applications rely on.
-73
View File
@@ -1,73 +0,0 @@
# syntax = docker/dockerfile:1
# Adjust NODE_VERSION as desired
ARG NODE_VERSION=20.7.0
FROM node:${NODE_VERSION}-slim as base
ARG DATABASE_URL
# Next.js/Prisma app lives here
WORKDIR /app
# Set production environment
ENV NODE_ENV="production" \
DATABASE_URL=$DATABASE_URL \
DATABASE_DIRECT_URL=$DATABASE_URL
# Throw-away build stage to reduce size of final image
FROM base as build
# copy all required files from the monorepo
COPY package.json yarn.lock .yarnrc.yml playwright.config.ts turbo.json git-init.sh git-setup.sh ./
COPY /.yarn ./.yarn
COPY ./apps/api ./apps/api
COPY ./packages ./packages
# TODO: follow up pr to remove dependencies on web
COPY ./apps/web ./apps/web
COPY ./tests ./tests
# Install node modules and dependencies, prune unneeded deps, then build
RUN set -eux; \
apt-get update -qq && \
apt-get install -y build-essential openssl pkg-config python-is-python3 && \
apt-get clean && \
rm -rf /var/lib/apt/lists /var/cache/apt/archives && \
yarn config set httpTimeout 1200000 && \
npx turbo prune --filter=@calcom/web --docker && \
npx turbo prune --filter=@calcom/api --docker && \
yarn install && \
yarn turbo run build --filter=@calcom/api
# Final stage
FROM base
WORKDIR /app
# Install packages needed for deployment
RUN apt-get update -qq && \
apt-get install --no-install-recommends -y openssl && \
rm -rf /var/lib/apt/lists /var/cache/apt/archives
# Copy built application
COPY --from=build /app/package.json ./package.json
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/apps/api/package.json ./apps/api/package.json
COPY --from=build /app/apps/api/next-i18next.config.js ./apps/api/next-i18next.config.js
COPY --from=build /app/apps/api/next.config.js ./apps/api/next.config.js
COPY --from=build /app/apps/api/tsconfig.json ./apps/api/tsconfig.json
COPY --from=build /app/apps/api/.next ./apps/api/.next
COPY --from=build /app/apps/api/.turbo ./apps/api/.turbo
COPY --from=build /app/turbo.json ./turbo.json
COPY --from=build /app/yarn.lock ./yarn.lock
COPY --from=build /app/packages/config ./packages/config
COPY --from=build /app/packages/tsconfig ./packages/tsconfig
COPY --from=build /app/packages/types ./packages/types
COPY --from=build /app/apps/web/next.config.js ./apps/web/next.config.js
COPY --from=build /app/apps/web/next-i18next.config.js ./apps/web/next-i18next.config.js
COPY --from=build /app/apps/web/public/static/locales ./apps/web/public/static/locales
COPY --from=build /app/apps/web/package.json ./apps/web/package.json
# Expose port 80
EXPOSE 80
# Start cmd, called when docker image is mounted
CMD [ "yarn", "workspace", "@calcom/api", "docker-start-api"]
-3
View File
@@ -77,9 +77,6 @@
"type-check": "turbo run type-check",
"type-check:ci": "turbo run type-check:ci --log-prefix=none",
"web": "yarn workspace @calcom/web",
"docker-build-api": "docker build -t cal-api -f ./infra/docker/api/Dockerfile .",
"docker-run-api": "docker run -p 80:80 cal-api",
"docker-stop-api": "docker ps --filter 'ancestor=cal-api' -q | xargs docker stop",
"changesets-add": "yarn changeset add",
"changesets-version": "yarn changeset version",
"changesets-release": "NODE_OPTIONS='--max_old_space_size=12288' turbo run build --filter=@calcom/atoms && yarn changeset publish",
+1 -1
View File
@@ -1,5 +1,5 @@
import { HttpError } from "@calcom/lib/http-error";
import { CredentialRepository } from "@calcom/lib/server/repository/credential";
import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
import prisma from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
import type { UserProfile } from "@calcom/types/UserProfile";
@@ -1,6 +1,6 @@
import { isInMemoryDelegationCredential } from "@calcom/lib/delegationCredential";
import logger from "@calcom/lib/logger";
import { CredentialRepository } from "@calcom/lib/server/repository/credential";
import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
import type { CredentialForCalendarService } from "@calcom/types/Credential";
import { getTokenObjectFromCredential } from "./getTokenObjectFromCredential";
@@ -1,7 +1,7 @@
import type z from "zod";
import logger from "@calcom/lib/logger";
import { CredentialRepository } from "@calcom/lib/server/repository/credential";
import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
import { prisma } from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
@@ -2,7 +2,7 @@ import type { GetServerSidePropsContext } from "next";
import { z } from "zod";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { CredentialRepository } from "@calcom/lib/server/repository/credential";
import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
import { btcpayCredentialKeysSchema } from "../../lib/btcpayCredentialKeysSchema";
@@ -318,7 +318,7 @@ const DailyVideoApiAdapter = (): VideoApiAdapter => {
bucket_region: process.env.CAL_VIDEO_BUCKET_REGION,
assume_role_arn: process.env.CAL_VIDEO_ASSUME_ROLE_ARN,
allow_api_access: true,
allow_streaming_from_bucket: true,
allow_streaming_from_bucket: false,
},
}),
enable_transcription_storage: isTranscriptionEnabled,
@@ -367,7 +367,7 @@ const DailyVideoApiAdapter = (): VideoApiAdapter => {
bucket_region: process.env.CAL_VIDEO_BUCKET_REGION,
assume_role_arn: process.env.CAL_VIDEO_ASSUME_ROLE_ARN,
allow_api_access: true,
allow_streaming_from_bucket: true,
allow_streaming_from_bucket: false,
},
}),
start_video_off: true,
@@ -4,8 +4,8 @@ import { describe, it, expect, beforeEach, vi } from "vitest";
import { metadata as googleCalendarMetadata } from "@calcom/app-store/googlecalendar/_metadata";
import { metadata as googleMeetMetadata } from "@calcom/app-store/googlevideo/_metadata";
import type { ServiceAccountKey } from "@calcom/lib/server/repository/delegationCredential";
import { DelegationCredentialRepository } from "@calcom/lib/server/repository/delegationCredential";
import type { ServiceAccountKey } from "@calcom/features/delegation-credentials/repositories/DelegationCredentialRepository";
import { DelegationCredentialRepository } from "@calcom/features/delegation-credentials/repositories/DelegationCredentialRepository";
import { OrganizationRepository } from "@calcom/features/ee/organizations/repositories/OrganizationRepository";
import { SMSLockState, RRTimestampBasis } from "@calcom/prisma/enums";
import type { CredentialForCalendarService, CredentialPayload } from "@calcom/types/Credential";
@@ -28,7 +28,7 @@ vi.mock("@calcom/features/ee/organizations/repositories/OrganizationRepository",
}));
// Mock DelegationCredentialRepository
vi.mock("@calcom/lib/server/repository/delegationCredential", () => ({
vi.mock("@calcom/features/delegation-credentials/repositories/DelegationCredentialRepository", () => ({
DelegationCredentialRepository: {
findUniqueByOrgMemberEmailIncludeSensitiveServiceAccountKey: vi.fn(),
findUniqueByOrganizationIdAndDomainIncludeSensitiveServiceAccountKey: vi.fn(),
+3 -3
View File
@@ -10,9 +10,9 @@ import {
} from "@calcom/lib/delegationCredential";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { CredentialRepository } from "@calcom/lib/server/repository/credential";
import type { ServiceAccountKey } from "@calcom/lib/server/repository/delegationCredential";
import { DelegationCredentialRepository } from "@calcom/lib/server/repository/delegationCredential";
import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
import type { ServiceAccountKey } from "@calcom/features/delegation-credentials/repositories/DelegationCredentialRepository";
import { DelegationCredentialRepository } from "@calcom/features/delegation-credentials/repositories/DelegationCredentialRepository";
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import { prisma } from "@calcom/prisma";
import type { SelectedCalendar } from "@calcom/prisma/client";
@@ -1,7 +1,7 @@
import { Dub } from "dub-package";
import logger from "@calcom/lib/logger";
import { CredentialRepository } from "@calcom/lib/server/repository/credential";
import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
import type { AnalyticsService, SendEventProps } from "@calcom/types/AnalyticsService";
import type { CredentialPayload } from "@calcom/types/Credential";
@@ -14,7 +14,7 @@ import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
import { HttpError } from "@calcom/lib/http-error";
import { defaultHandler } from "@calcom/lib/server/defaultHandler";
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
import { CredentialRepository } from "@calcom/lib/server/repository/credential";
import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
import { Prisma } from "@calcom/prisma/client";
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
@@ -466,7 +466,7 @@ export default class GoogleCalendarService implements Calendar {
args: FreeBusyArgs,
shouldServeCache?: boolean
): Promise<calendar_v3.Schema$FreeBusyResponse> {
if (shouldServeCache === false) return await this.fetchAvailability(args);
if (!shouldServeCache) return await this.fetchAvailability(args);
const calendarCache = await CalendarCache.init(null);
const cached = await calendarCache.getCachedAvailability({
credentialId: this.credential.id,
@@ -0,0 +1,135 @@
import { expect, test, beforeEach, vi, describe } from "vitest";
import "vitest-fetch-mock";
import CalendarService from "../CalendarService";
describe("GoogleCalendarService.getFreeBusyResult - shouldServeCache logic", () => {
let calendarService: CalendarService;
let fetchAvailabilitySpy: ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
calendarService = {} as CalendarService;
const mockFetchAvailability = vi.fn().mockResolvedValue({
calendars: {
"test@example.com": {
busy: [
{
start: "2023-12-01T20:00:00Z",
end: "2023-12-01T21:00:00Z",
},
],
},
},
});
calendarService.fetchAvailability = mockFetchAvailability;
fetchAvailabilitySpy = mockFetchAvailability;
calendarService.getFreeBusyResult = CalendarService.prototype.getFreeBusyResult.bind(calendarService);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(calendarService as any).credential = { id: 1, userId: 1 };
});
describe("shouldServeCache parameter handling", () => {
test("should call fetchAvailability immediately when shouldServeCache is explicitly false", async () => {
const args = {
timeMin: new Date().toISOString(),
timeMax: new Date().toISOString(),
items: [{ id: "test@example.com" }],
};
const result = await calendarService.getFreeBusyResult(args, false);
expect(fetchAvailabilitySpy).toHaveBeenCalledWith(args);
expect(fetchAvailabilitySpy).toHaveBeenCalledTimes(1);
expect(result.calendars?.["test@example.com"]?.busy).toEqual([
{
start: "2023-12-01T20:00:00Z",
end: "2023-12-01T21:00:00Z",
},
]);
});
test("should call fetchAvailability immediately when shouldServeCache is undefined (falsey)", async () => {
const args = {
timeMin: new Date().toISOString(),
timeMax: new Date().toISOString(),
items: [{ id: "test@example.com" }],
};
const result = await calendarService.getFreeBusyResult(args, undefined);
expect(fetchAvailabilitySpy).toHaveBeenCalledWith(args);
expect(fetchAvailabilitySpy).toHaveBeenCalledTimes(1);
expect(result.calendars?.["test@example.com"]?.busy).toEqual([
{
start: "2023-12-01T20:00:00Z",
end: "2023-12-01T21:00:00Z",
},
]);
});
test("should call fetchAvailability immediately when shouldServeCache is null (falsey)", async () => {
const args = {
timeMin: new Date().toISOString(),
timeMax: new Date().toISOString(),
items: [{ id: "test@example.com" }],
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await calendarService.getFreeBusyResult(args, null as any);
expect(fetchAvailabilitySpy).toHaveBeenCalledWith(args);
expect(fetchAvailabilitySpy).toHaveBeenCalledTimes(1);
expect(result.calendars?.["test@example.com"]?.busy).toEqual([
{
start: "2023-12-01T20:00:00Z",
end: "2023-12-01T21:00:00Z",
},
]);
});
test("should call fetchAvailability immediately when shouldServeCache is 0 (falsey)", async () => {
const args = {
timeMin: new Date().toISOString(),
timeMax: new Date().toISOString(),
items: [{ id: "test@example.com" }],
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await calendarService.getFreeBusyResult(args, 0 as any);
expect(fetchAvailabilitySpy).toHaveBeenCalledWith(args);
expect(fetchAvailabilitySpy).toHaveBeenCalledTimes(1);
expect(result.calendars?.["test@example.com"]?.busy).toEqual([
{
start: "2023-12-01T20:00:00Z",
end: "2023-12-01T21:00:00Z",
},
]);
});
test("should call fetchAvailability immediately when shouldServeCache is empty string (falsey)", async () => {
const args = {
timeMin: new Date().toISOString(),
timeMax: new Date().toISOString(),
items: [{ id: "test@example.com" }],
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await calendarService.getFreeBusyResult(args, "" as any);
expect(fetchAvailabilitySpy).toHaveBeenCalledWith(args);
expect(fetchAvailabilitySpy).toHaveBeenCalledTimes(1);
expect(result.calendars?.["test@example.com"]?.busy).toEqual([
{
start: "2023-12-01T20:00:00Z",
end: "2023-12-01T21:00:00Z",
},
]);
});
});
});
+10 -9
View File
@@ -7,8 +7,8 @@ import type {
SimplePublicObjectInput,
} from "@hubspot/api-client/lib/codegen/crm/objects/meetings";
import getLabelValueMapFromResponses from "@calcom/features/bookings/lib/getLabelValueMapFromResponses";
import { getLocation } from "@calcom/lib/CalEventParser";
import getLabelValueMapFromResponses from "@calcom/lib/bookings/getLabelValueMapFromResponses";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
@@ -21,10 +21,6 @@ import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
import refreshOAuthTokens from "../../_utils/oauth/refreshOAuthTokens";
import type { HubspotToken } from "../api/callback";
interface CustomPublicObjectInput extends SimplePublicObjectInput {
id?: string;
}
export default class HubspotCalendarService implements CRM {
private url = "";
private integrationName = "";
@@ -117,8 +113,14 @@ export default class HubspotCalendarService implements CRM {
return this.hubspotClient.crm.objects.meetings.basicApi.update(uid, simplePublicObjectInput);
};
private hubspotDeleteMeeting = async (uid: string) => {
return this.hubspotClient.crm.objects.meetings.basicApi.archive(uid);
private hubspotCancelMeeting = async (uid: string) => {
const simplePublicObjectInput: SimplePublicObjectInput = {
properties: {
hs_meeting_outcome: "CANCELED",
},
};
return this.hubspotClient.crm.objects.meetings.basicApi.update(uid, simplePublicObjectInput);
};
private hubspotAuth = async (credential: CredentialPayload) => {
@@ -204,7 +206,6 @@ export default class HubspotCalendarService implements CRM {
return await this.handleMeetingCreation(event, contacts);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async updateEvent(uid: string, event: CalendarEvent): Promise<any> {
const auth = await this.auth;
await auth.getToken();
@@ -214,7 +215,7 @@ export default class HubspotCalendarService implements CRM {
async deleteEvent(uid: string): Promise<void> {
const auth = await this.auth;
await auth.getToken();
return await this.hubspotDeleteMeeting(uid);
await this.hubspotCancelMeeting(uid);
}
async getContacts({ emails }: { emails: string | string[] }): Promise<Contact[]> {
@@ -1,8 +1,11 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { getSession } from "next-auth/react";
import {
entityPrismaWhereClause,
canEditEntity,
} from "@calcom/features/pbac/lib/entityPermissionUtils.server";
import { sanitizeValue } from "@calcom/lib/csvUtils";
import { entityPrismaWhereClause, canEditEntity } from "@calcom/lib/entityPermissionUtils.server";
import prisma from "@calcom/prisma";
import type { App_RoutingForms_Form } from "@calcom/prisma/client";
@@ -3,9 +3,9 @@ import { prisma } from "@calcom/prisma/__mocks__/prisma";
import { describe, it, vi, expect, beforeEach, afterEach } from "vitest";
import { WorkflowService } from "@calcom/features/ee/workflows/lib/service/WorkflowService";
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
import { sendGenericWebhookPayload } from "@calcom/features/webhooks/lib/sendPayload";
import { WorkflowService } from "@calcom/lib/server/service/workflows";
import {
WebhookTriggerEvents,
WorkflowTriggerEvents,
@@ -37,7 +37,7 @@ vi.mock("@calcom/features/tasker", () => {
});
// Mock workflow dependencies
vi.mock("@calcom/lib/server/service/workflows", () => ({
vi.mock("@calcom/features/ee/workflows/lib/service/WorkflowService", () => ({
WorkflowService: {
getAllWorkflowsFromRoutingForm: vi.fn(() => Promise.resolve([])),
scheduleFormWorkflows: vi.fn(() => Promise.resolve()),
@@ -6,7 +6,7 @@ import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import { withReporting } from "@calcom/lib/sentryWrapper";
import { WorkflowService } from "@calcom/lib/server/service/workflows";
import { WorkflowService } from "@calcom/features/ee/workflows/lib/service/WorkflowService";
import { prisma } from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
import type { App_RoutingForms_Form, User } from "@calcom/prisma/client";
@@ -1,6 +1,6 @@
import type { z } from "zod";
import { entityPrismaWhereClause } from "@calcom/lib/entityPermissionUtils.server";
import { entityPrismaWhereClause } from "@calcom/features/pbac/lib/entityPermissionUtils.server";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import type { App_RoutingForms_Form } from "@calcom/prisma/client";
@@ -1,4 +1,4 @@
import { canCreateEntity, canEditEntity } from "@calcom/lib/entityPermissionUtils.server";
import { canCreateEntity, canEditEntity } from "@calcom/features/pbac/lib/entityPermissionUtils.server";
import prisma from "@calcom/prisma";
import type { App_RoutingForms_Form, User } from "@calcom/prisma/client";
@@ -1,4 +1,4 @@
import { CredentialRepository } from "@calcom/lib/server/repository/credential";
import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
import logger from "@calcom/lib/logger";
import { routingFormIncompleteBookingDataSchema } from "../../zod";
@@ -1,4 +1,4 @@
import { CredentialRepository } from "@calcom/lib/server/repository/credential";
import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
import type { AttributeRoutingConfig } from "../../routing-forms/types/types";
import SalesforceCRMService from "./CrmService";
@@ -4,7 +4,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { TeamRepository } from "@calcom/lib/server/repository/team";
import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository";
import { MembershipRole } from "@calcom/prisma/enums";
import {
@@ -18,7 +18,7 @@ import { validateAuthentication, buildReturnUrl } from "../portal";
// Mock dependencies
vi.mock("@calcom/features/pbac/services/permission-check.service");
vi.mock("@calcom/lib/server/repository/team");
vi.mock("@calcom/features/ee/teams/repositories/TeamRepository");
vi.mock("../../lib/customer");
vi.mock("../../lib/server");
vi.mock("../../lib/subscriptions");
@@ -1,4 +1,4 @@
import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billling-service";
import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billing-service";
export async function getCustomerAndCheckoutSession(checkoutSessionId: string) {
const billingService = new StripeBillingService();
@@ -4,7 +4,7 @@ import { PermissionCheckService } from "@calcom/features/pbac/services/permissio
import { WEBAPP_URL } from "@calcom/lib/constants";
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
import logger from "@calcom/lib/logger";
import { TeamRepository } from "@calcom/lib/server/repository/team";
import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository";
import prisma from "@calcom/prisma";
import stripe from "../../server";
@@ -1,4 +1,4 @@
import { TeamRepository } from "@calcom/lib/server/repository/team";
import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository";
import prisma from "@calcom/prisma";
import type { BillingPortalService } from "../base/BillingPortalService";
@@ -1,6 +1,6 @@
import type { TFunction } from "i18next";
import getLabelValueMapFromResponses from "@calcom/features/bookings/lib/getLabelValueMapFromResponses";
import getLabelValueMapFromResponses from "@calcom/lib/bookings/getLabelValueMapFromResponses";
import type { CalendarEvent } from "@calcom/types/Calendar";
import { Info } from "./Info";
@@ -32,7 +32,7 @@ import logger from "@calcom/lib/logger";
import { randomString } from "@calcom/lib/random";
import { safeStringify } from "@calcom/lib/safeStringify";
import { hashEmail } from "@calcom/lib/server/PiiHasher";
import { CredentialRepository } from "@calcom/lib/server/repository/credential";
import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
import { DeploymentRepository } from "@calcom/lib/server/repository/deployment";
import { OrganizationRepository } from "@calcom/features/ee/organizations/repositories/OrganizationRepository";
import { ProfileRepository } from "@calcom/features/profile/repositories/ProfileRepository";
@@ -6,7 +6,8 @@ import { getLocaleFromRequest } from "@calcom/features/auth/lib/getLocaleFromReq
import { sendEmailVerification } from "@calcom/features/auth/lib/verifyEmail";
import { createOrUpdateMemberships } from "@calcom/features/auth/signup/utils/createOrUpdateMemberships";
import { prefillAvatar } from "@calcom/features/auth/signup/utils/prefillAvatar";
import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billling-service";
import { validateAndGetCorrectedUsernameAndEmail } from "@calcom/features/auth/signup/utils/validateUsername";
import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billing-service";
import { sentrySpan } from "@calcom/features/watchlist/lib/telemetry";
import { checkIfEmailIsBlockedInWatchlistController } from "@calcom/features/watchlist/operations/check-if-email-in-watchlist.controller";
import { hashPassword } from "@calcom/lib/auth/hashPassword";
@@ -15,7 +16,6 @@ import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import type { CustomNextApiHandler } from "@calcom/lib/server/username";
import { usernameHandler } from "@calcom/lib/server/username";
import { validateAndGetCorrectedUsernameAndEmail } from "@calcom/lib/validateUsername";
import { prisma } from "@calcom/prisma";
import { CreationSource } from "@calcom/prisma/enums";
import { IdentityProvider } from "@calcom/prisma/enums";
@@ -3,12 +3,12 @@ import { NextResponse } from "next/server";
import { checkPremiumUsername } from "@calcom/ee/common/lib/checkPremiumUsername";
import { sendEmailVerification } from "@calcom/features/auth/lib/verifyEmail";
import { createOrUpdateMemberships } from "@calcom/features/auth/signup/utils/createOrUpdateMemberships";
import { validateAndGetCorrectedUsernameAndEmail } from "@calcom/features/auth/signup/utils/validateUsername";
import { hashPassword } from "@calcom/lib/auth/hashPassword";
import { IS_PREMIUM_USERNAME_ENABLED } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import { isUsernameReservedDueToMigration } from "@calcom/lib/server/username";
import slugify from "@calcom/lib/slugify";
import { validateAndGetCorrectedUsernameAndEmail } from "@calcom/lib/validateUsername";
import prisma from "@calcom/prisma";
import { IdentityProvider } from "@calcom/prisma/enums";
import { signupSchema } from "@calcom/prisma/zod-utils";

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