Compare commits

...
Author SHA1 Message Date
github-actions[bot] 616d62094c chore: version packages (#22116)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-06-29 13:25:48 +02:00
ce7ff1c43c fix(signup): add missing key prop to feature list (#21437) (#21506)
* fix(signup): add missing key prop to feature list (#21437)

* refactor

---------

Co-authored-by: Kartik Saini <41051387+kart1ka@users.noreply.github.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-06-28 09:27:25 +00:00
Romit e27e41bc16 fix (#22112) 2025-06-28 14:30:54 +05:30
Hariom Balhara 93e183e6c3 fix: Zoom - More logging and fix for unexpected errors seen in logs (#22118)
* fix: Use WEBAPP_URL_FOR_OAUTH to be able to use http://localhost:3000
for local testing

* In case of JSON.parse crash that could happen if zoom reponsed with xml

* Fix cases where auto_recording and default_password_for_scheduled_meetings could be nullish
2025-06-28 08:11:25 +00:00
Lauris Skraucis 88a2bd3317 fix: changesets release memory (#22107)
* chore: increase instance size for changesets

* test release

* Update CHANGELOG.md
2025-06-28 04:37:47 +00:00
Keith Williams 45caaa55b0 chore: Use DBA team for prisma CODEOWNER (#22109) 2025-06-27 23:01:32 +00:00
GitHub Actions 8c9b7a701d chore: release v5.4.9
Post release / release (push) Canceled after 0s
2025-06-27 22:17:36 +00:00
c47b992125 perf: optimize slot conflict checking from O(n²) to O(n log n) (#22101)
* perf: optimize slot conflict checking from O(n²) to O(n log n)

- Replace nested mapping with pre-sorted busy slots for faster lookups
- Use early termination when busy slots are sorted by start time
- Maintain exact same interface and behavior as original checkForConflicts
- All existing tests pass, preserving edge case handling
- Reduces complexity from O(available slots × busy slots) to O(n log n)

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

* refactor: move O(n log n) optimization to checkForConflicts function

- Replace inline optimization with optimized checkForConflicts function
- Maintain same performance while improving code architecture
- Avoid duplication of conflict checking logic
- Keep all existing interfaces and behavior unchanged

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

* test: add comprehensive unit tests for checkForConflicts function

- Add 20 additional test cases covering edge cases and boundary conditions
- Test multiple busy periods scenarios and complex overlaps
- Comprehensive currentSeats handling scenarios
- Timezone and cross-day boundary testing
- Performance testing with large datasets
- Fix invalid date generation in test data that was causing NaN conflicts
- Achieve near 100% coverage of all conflict scenarios documented in function comments
- Verify behavioral equivalence between original O(n²) and optimized O(n log n) implementations

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

* Simple, safe performance tweak

* Explicitly define the input of busy to be either Date or Dayjs, not string

* EventBusyDate must ALWAYS be a type that supports valueOf

* Revert "EventBusyDate must ALWAYS be a type that supports valueOf"

This reverts commit 35b572266219d64c22db3c3e24b0cb6ca7a901a6.

* Revert "Explicitly define the input of busy to be either Date or Dayjs, not string"

This reverts commit 902f29708e4b7990a4e5e2865cab2f18ab3e44f0.

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-06-27 22:10:17 +00:00
Alex van Andel 493c4f89dc Remove redundant logging (#22108) 2025-06-27 21:01:01 +00:00
Lauris Skraucis 27234dc809 fix: changesets memory issue (#22105) 2025-06-27 16:06:13 +00:00
github-actions[bot] 5690b8e1d5 chore: version packages (#22104)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-06-27 15:19:56 +00:00
Lauris Skraucis 61274bc7ef fix: changesets release (#22103) 2025-06-27 14:37:23 +00:00
github-actions[bot] e66be23996 chore: version packages (#22098)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-06-27 12:35:20 +00:00
Lauris Skraucis 3c43ce4165 chore: test changesets (#22095)
* chore: test changesets

* bump atoms package.json

* Revert "bump atoms package.json"

This reverts commit 6105f8cf2486085e5da317a6969044693f4a3aa0.

* chore: set packages to private

* ignore private packages

* ignore private
2025-06-27 13:49:28 +02:00
Somay Chauhan 5060824795 fix: EventAvailabilityTab atom breaking (#22059) 2025-06-27 09:40:38 +00:00
c4941f25a2 fix: no available users found (recurring events) (#22033)
* only check first slot

* skip test for second slot availability check

* fixing tests in handleNewRecurringBooking.test.ts

* skip test

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2025-06-27 09:31:56 +00:00
e7c219905f fix: add shape docs to avatar (#20552)
Co-authored-by: Sean Brydon <sean@Seans-Mac-mini.local>
Co-authored-by: Benny Joo <sldisek783@gmail.com>
2025-06-27 09:09:13 +00:00
Udit TakkarandHariom Balhara 499cbd08ca feat: Phone based bookings for everyone (#21320) (#22024)
* Revert "Revert "feat: Phone based bookings for everyone (#21320)" (#22018)"

This reverts commit 80e2118e68.

* chore: don't use repository

* chore: create getTeamWithOrganizationSettings function

---------

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2025-06-27 10:09:04 +01:00
Eunjae Lee 0a3b3a69e3 fix: add InsightsRoutingService and refactor InsightsBookingRepository (#22057)
* chore: convert InsightsBookingRepository to service

* remove repository

* rename

* clean up

* add InsightsRoutingService

* add tests
2025-06-27 11:08:46 +02:00
Lauris Skraucis e8607845e2 fix: changesets atoms release (#22090) 2025-06-27 09:07:02 +00:00
02ef4db0eb feat: Add rerouting to AssignmentReasonRecorder (#19252)
* Add `rerouting` to `AssignmentReasonRecorder`

* Pass session email as `rescheduledBy`

* Write reassign reason

* Edit message

* Use `routingFormRoute` when rerouting

* Pass isRerouting & reroutedByEmail to `routingFormRoute` method

* Type fix

* Fix test

* Fix test

* Fix test

* Type fix

* Refactor to use UserRepository

* Show rerouted by email

* Replace isReroutingCase

* Fix rescheduledBy for new tab reschduling

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Hariom <hariombalhara@gmail.com>
2025-06-27 10:06:39 +01:00
sean-brydon f8d48a341d show icon button when one item in array (#22054) 2025-06-27 07:52:18 +00:00
GitHub Actions 251393101a chore: release v5.4.8
Post release / release (push) Canceled after 0s
2025-06-26 23:56:56 +00:00
Joe Au-Yeung b50430114f Disable setting profile picture (#22074) 2025-06-26 23:48:38 +00:00
Joe Au-Yeung b0999abc68 fix: Generate new idempotency key when round robin reassigning (#22065)
* Create idempotencyKeyService

* Use `idempotencyKeyService` in middleware

* Use `idempotencyKeyService` in RR reassignments
2025-06-26 17:43:31 +00:00
Lauris Skraucis 2bbd92db13 chore: bump platform libraries (#22063) 2025-06-26 15:26:07 +00:00
Eunjae Lee 64092abdca fix: make uuid of App_RoutingForms_FormResponse nullable (#22056) 2025-06-26 10:42:45 +00:00
Sarthak KapilaandKartik Saini b7b631f0b0 fix: Persist filters across tabs to improve user experience (#21706)
* fix: Persist filters across tabs to improve user experience

* fixes unnecessary renders

---------

Co-authored-by: Kartik Saini <41051387+kart1ka@users.noreply.github.com>
2025-06-26 10:04:53 +00:00
Anik Dhabal Babu c10f15e75b fix: google calendar event creation failure due to invalid timezone (#22048)
* fix: google calendar event creation failure

* update

* fix type error

* Update TimezoneSelect.tsx
2025-06-26 09:36:44 +00:00
Somay Chauhan 6983179b2f fix: handle existing oAuth client ID in user email to prevent duplication (#22021)
* fix: handle existing oAuth client ID in user email to prevent duplication

* fix: update OAuth user email validation
2025-06-26 08:29:29 +00:00
devin-ai-integration[bot]andeunjae@cal.com aea0661303 feat: disable changing field type of routing forms if responses exist (#21911)
* feat: add field type change warning dialog to routing forms

- Add FieldTypeChangeWarningDialog component to prevent field type changes
- Show informative dialog explaining data integrity concerns when users try to change field types
- Provide alternative suggestion to create new field instead of changing existing one
- Add i18n translations for dialog messages
- Integrate dialog into routing form edit page field type selector
- Bypass Teams plan restriction for testing routing forms functionality
- Fix ESLint error by properly handling scrollIntoView in embed mode with disable comment

Prevents data integrity issues by blocking field type changes (e.g. multiselect → text)
that could make existing form responses incompatible and cause data loss.

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

* fix: make field type dialog more selective to allow legitimate changes during form creation

- Only show warning dialog when there's actual data integrity risk
- Allow field type changes for new fields or fields without meaningful labels
- Fixes E2E test timeout by not blocking legitimate field type changes
- Maintains data protection for existing fields with real data

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

* fix: complete field type dialog implementation with translations and component

- Add FieldTypeChangeWarningDialog component with proper i18n support
- Add all required translation strings for dialog messages
- Ensure dialog only shows for actual data integrity risks
- Allow legitimate field type changes during form creation

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

* simplify field type dialog: one button, button replaces SelectField, remove forms page changes

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

* use Button component

* feat: restore SelectField with conditional dialog for field type changes

- Restore original SelectField dropdown to allow users to see available field types
- Add conditional logic to show dialog only when different type is selected AND form has responses
- Prevent field type changes by not calling onChange when dialog is shown
- Pass hasFormResponses prop from FormEdit to Field component
- Maintain data integrity while providing clear user guidance

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

* controlled value

* convert dialog to tooltip

* Update common.json

* remove unused texts

* allow type changing for new field

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: eunjae@cal.com <hey@eunjae.dev>
2025-06-26 07:58:05 +00:00
Eunjae LeeandBenny Joo 3fd9ef366f fix: add InsightsBookingRepository (#22003)
* fix: add InsightsBookingRepository WIP

* write integration tests

* remove log

---------

Co-authored-by: Benny Joo <sldisek783@gmail.com>
2025-06-26 07:57:08 +00:00
GitHub Actions cf79a3f580 chore: release v5.4.7
Post release / release (push) Canceled after 0s
2025-06-25 23:25:56 +00:00
Joe Au-Yeung c5da78593a Fix import (#22020) 2025-06-25 23:15:04 +01:00
Benny Joo 801b0be414 perf: optimize fetch for primary google calendar (#22041)
* perf: optimize getting primary google calendar

* add test

* use calendars

* safer
2025-06-25 23:10:25 +01:00
RomitandBenny Joo 29aad2c535 fix: error because Seated slot reservations do not pass bookingUid (#22022)
* fixed query

* Update reserveSlot.handler.ts

---------

Co-authored-by: Benny Joo <sldisek783@gmail.com>
2025-06-25 21:56:32 +00:00
Carina WollendorferandCarinaWolli 82d3b9d29d only update reminders for EMAIL_HOST action (#22027)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2025-06-25 20:54:19 +00:00
devin-ai-integration[bot]andmorgan@cal.com a542ba486c feat: add graceful webhook secret validation to billing controller (#22034)
* feat: add graceful webhook secret validation to billing controller

- Wrap Stripe constructEventAsync in try-catch block
- Add proper error logging for failed webhook signature validation
- Return appropriate error responses for invalid webhook secrets
- Follow existing webhook handler patterns from payments module

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

* refactor: use specific Stripe error type for webhook signature validation

- Import Stripe types to access StripeSignatureVerificationError
- Check for specific Stripe signature verification error type
- Throw BadRequestException for invalid webhook signatures
- Maintain generic error handling for other webhook processing errors

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

* refactor: throw BadRequestException for missing webhook signature and secret

- Use BadRequestException instead of generic Error for missing stripe-signature header
- Use BadRequestException instead of generic Error for missing webhook secret configuration
- Consistent error handling with appropriate HTTP status codes

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: morgan@cal.com <morgan@cal.com>
2025-06-25 20:39:46 +00:00
Benny Joo d50e1a0e15 feat: Leverage ErrorCode cases for correct error codes in server responses (#22023)
* Leverage ErrorCode cases for correct error codes in server responses

* better test

* fix
2025-06-25 13:18:01 -07:00
Anik Dhabal Babu 79a0071d65 fix: e2e tests failure (#22043)
* fix: delete dilog button not visible

* fix: test failure
2025-06-25 15:59:03 -03:00
MorganandAlex van Andel 2f1f989266 feat: allow setting DAILY VIDEO region via env (#21939)
* feat: allow setting DAILY VIDEO region via env

* add comments

* Cleaner approach to Daily video region type assertion

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-06-25 18:01:40 +03:00
Eunjae Lee 2303eb7908 fix: add uuid to App_RoutingForms_FormResponse (#21910)
* fix: add uuid to App_RoutingForms_FormResponse

* update RoutingFormResponseDenormalized

* fix type error
2025-06-25 10:49:20 -04:00
Benny Joo c3666dd064 feat: correct error codes in responses for /social/og/image (#22015)
* fix error codes in og/image route

* test

* simplify
2025-06-25 11:24:34 -03:00
6ed197d35c fix: Org creation for self-hosters (#21994)
* Restart form if admin is not org admin

* Show pricing is admin and hosted

* Only show billing specific fields when enabled

* Create user onboarding only if valid valid license when self hosting

* Reset store when org creation is completed

* Create schema for trpc input when creating an org

* Add `organization.createSelfHosted` trpc endpoint

* `createOrganizationFromOnboarding` do not require stripe data

* Create organization without billing if self-hosted admin

* Update organizationOnboarding record as completed

* Handle updating subscription for self hosters

* Refactor `isBillingEnabled` to store

* Type fix

* Fix admin org creation for hosted

* Hide UI from admin

* Add tests

* fix: Fix LicenseKeySingleton mocking in organization tests

- Add proper vi.mock() module-level mocking for LicenseKeySingleton
- Replace incorrect casting syntax with vi.mocked() approach
- Add LicenseKeySingleton mock to hosted tests in intentToCreateOrg.handler.test.ts
- Fix IS_SELF_HOSTED mocking for hosted vs self-hosted test scenarios
- All organization creation tests now pass locally

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

* Fix tests

* Fix tests

* Test fixes

* Type fix

* Type fix

* Type fix

* Update packages/features/ee/organizations/lib/server/createOrganizationFromOnboarding.test.ts

Fix typo

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

* fix: Add IS_SELF_HOSTED mocking for E2E organization creation tests

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

* Fix E2E test

* Address feedback

* Type fix

* Fix e2e tests

* Remove unused code

* Small fixes

* fix unit tests

* review fixes

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: joe@cal.com <joe@cal.com>
Co-authored-by: Benny Joo <sldisek783@gmail.com>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
Co-authored-by: Hariom <hariombalhara@gmail.com>
2025-06-25 16:58:18 +03:00
devin-ai-integration[bot]andmorgan@cal.com 164c9e37d7 feat: add /v2/billing/webhook route to RawBodyMiddleware configuration (#22032)
- Add /v2/billing/webhook alongside existing /api/v2/billing/webhook route
- Both routes now use RawBodyMiddleware for proper Stripe webhook signature verification
- Leverages NestJS forRoutes() method to accept multiple route configurations

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: morgan@cal.com <morgan@cal.com>
2025-06-25 15:21:56 +03:00
Hariom Balharaandcubic-dev-ai[bot] fa0a18b31d fix: Allow backgroundSlotsFetch option to disable slots fetching during prerender (#21999)
* fix: Allow noBackgroundSlotsFetch option to disable slots fetching
during prerender

* Update packages/embeds/embed-core/src/embed-iframe.ts

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

---------

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2025-06-25 10:38:35 +00:00
Benny Joo 80e2118e68 Revert "feat: Phone based bookings for everyone (#21320)" (#22018)
This reverts commit 866a0e2596.
2025-06-25 02:32:09 +00:00
GitHub Actions ed5086df6b chore: release v5.4.6
Post release / release (push) Canceled after 0s
2025-06-24 23:10:17 +00:00
Alex van Andel 218ec9140c Revert "perf: Refactor getPublicEvent to fetch org data using DB index key (#21850)"
This reverts commit a37f1d9798.
2025-06-24 16:37:36 +01:00
Hariom Balhara 6dad0e7679 fix-avoid-cross-domain-xhr (#22008) 2025-06-24 15:08:43 +00:00
Dhairyashil ShindeandKartik Saini cb96a56960 feat: Preserve past SMS workflow reminders by disabling deletion (#21906)
* Preserve past SMS workflow reminders by disabling deletion

* Restored code to delete SMS reminder worflows that fallback to EMAIL

---------

Co-authored-by: Kartik Saini <41051387+kart1ka@users.noreply.github.com>
2025-06-24 12:01:08 +02:00
thegrasshopper104 b35eb77edb fixing hungarian translation (#21995) 2025-06-24 09:21:05 +00:00
Syed Ali Shahbaz 89408ce12b Update webhooks.mdx (#21900) 2025-06-24 07:50:07 +00:00
e22d9a551c fix: Theme class names for embed elements (#21275)
* refactor(embed-core): update theme class names for consistency

Standardize theme class names to use 'cal-element-embed-dark' and 'cal-element-embed-light' to improve maintainability and clarity.

* refactor(embed-core): replace theme class strings with constants

* fix theme not changing

---------

Co-authored-by: Tushar Bhatt <95581504+TusharBhatt1@users.noreply.github.com>
Co-authored-by: Hariom <hariombalhara@gmail.com>
2025-06-24 07:26:48 +00:00
Carina WollendorferandCarinaWolli c215dc5864 fix: credit balance issues (#21904)
* fix creating user balance

* fix org check if teamId is given

* adjust hasAvailableCredits function

* improve comments

* fix creditBalance.create

* adjust test

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2025-06-24 09:18:14 +02:00
Benny Joo a37f1d9798 perf: Refactor getPublicEvent to fetch org data using DB index key (#21850)
* wip

* fix

* fix naming

* fix typecheck
2025-06-23 23:01:06 +01:00
Benny Joo 07c8bbc562 fix: crash due to lack of input validation in WizardForm (#21990)
* fix: crash due to lack of input validation in WizardForm

* server side validation

* refactor

* refactor
2025-06-23 17:03:51 -03:00
Omar López b381cfe207 fix: Downgrades typescript to 5.7.2 (#21956) 2025-06-23 18:31:47 +00:00
Anik Dhabal Babu a560b9c8f4 fix: Add error log for zoom (#21991)
* fix: delete dilog button not visible

* fix: add log
2025-06-23 18:29:12 +00:00
Anik Dhabal Babu e5b8cc6912 fix: booking title translation issue (#21943)
* fix: add log to check organizer

* update

* Update CalendarService.ts
2025-06-23 18:23:31 +00:00
Somay ChauhanandRajiv Sahal 18b5612c0d feat: add apps filter and bulk update toggle to conferencing apps atom (#21973)
* feat: add apps filter and bulk update toggle to conferencing apps atom

* docs: clarify description of disableBulkUpdateEventTypes flag in conferencing apps docs

* docs: update valid conferencing app slugs in atoms documentation

---------

Co-authored-by: Rajiv Sahal <sahalrajiv-extc@atharvacoe.ac.in>
2025-06-23 22:59:44 +05:30
Anik Dhabal Babu fad488623f Revert "fix: multi-line formatted correctly for sms (#21779)" (#21979)
This reverts commit 5e2f06cdea.
2025-06-23 16:00:43 +00:00
Romit.andAnik Dhabal Babu c490f084dc fix: background of event advanced questions section in dark theme (#21985)
* fixed bg of event advanced questions

* Update EventAdvancedTab.tsx

* Update EventAdvancedTab.tsx

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-06-23 15:33:32 +00:00
Joe Au-Yeung b1caacd643 Disable tRPC endpoint on slots fetch failure (#21986) 2025-06-23 15:17:33 +00:00
Udit Takkar 76c8876923 fix: don't fetch calendar for top banner (#21908) 2025-06-23 09:00:44 -03:00
Hariom Balhara a911c9cb8d Make props optiona (#21977) 2025-06-23 11:52:10 +00:00
devin-ai-integration[bot]andeunjae@cal.com 07d5594085 feat: add integration test for RoutingFormResponseField triggers (#21586)
* feat: add integration test for RoutingFormResponseField triggers

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

* refactor: remove changing field types test case

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: eunjae@cal.com <hey@eunjae.dev>
2025-06-23 10:11:20 +00:00
Benny JooandAnik Dhabal Babu a745d8a60a fix: error in /auth/session (#21972)
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-06-23 08:32:14 +00:00
Dhairyashil ShindeandSomay Chauhan 8b83d39ac3 feat: Add onTimeslotsLoaded prop to Booker atom (#21871)
* [CAL-5926] Add 'onTimeslotsLoaded' prop to 'Booker' atom

* Added 'onTimeslotsLoaded' to BookerPlatformWrapperAtomProps

* fixed type error and added onTimeslotsLoaded prop to doc

---------

Co-authored-by: Somay Chauhan <somaychauhan98@gmail.com>
2025-06-23 08:13:36 +00:00
4edb39616d feat: download credit expense log button (#21771)
* add smsSegments to creditExpenseLog

* add download button

* download only data from selected month

* improvements

* improve design

* use repository function

* add tests for credit repository

* fix type error

* fix unit test

* fix type error

* fix unit test

* code clean up

* fix skeleton loader

* remove empty mt

* code clean up

* move csv headers out of function

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Benny Joo <sldisek783@gmail.com>
2025-06-23 03:57:32 -04:00
Somay ChauhanandAnik Dhabal Babu 7a2060fe08 fix: dont expose credential.key in api output (#21970)
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-06-23 07:09:44 +00:00
Omar López d009682d4a Revert "perf: deduplicating DB fetches + leverage unique DB index for team ty…" (#21969)
This reverts commit 7900be3a30.
2025-06-23 04:12:20 +00:00
Amit Sharma 8adb720a80 fix: Double skeleton apps page (#21965) 2025-06-22 18:24:16 -04:00
Omar López 4d6f338f0c Revert "Update draft-release.yml (#21932)" (#21966)
This reverts commit 04e60611ed.
2025-06-22 19:50:46 +00:00
Morgan ffe9d43d09 chore: better trpc error handling (#21948) 2025-06-21 11:56:23 +05:30
Alex van AndelandOmar López 2227b4ff91 chore: Lower the threshold for eager token expiration (#20964)
Co-authored-by: Omar López <zomars@me.com>
2025-06-20 14:33:23 -04:00
Benny JooandKeith Williams 5dd4937a1a chore: add e2e test for ai translation (#18061)
* make createNewEventType receive description arg

* remove @test tag

* wip

* wip2

* remove @test tag

* modify EventMetaProps

* add test id event-meta-description

* improve test

* better name

* try

* finalized

* fix

* fix type check

* address comment

* cleann up

* cleann up

* fix

* Update ai-translation.e2e.ts

* fix

* simplify

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2025-06-20 23:00:39 +05:30
866a0e2596 feat: Phone based bookings for everyone (#21320)
* feat: Phone based bookings for everyone

* chore: save progress

* feat: add UI

* chore: UI

* chore: remove sms notification

* chore: remove none

* chore: remove ligs

* chore: remove log

* test: add unit tests

* test: add unit tests

* chore: cache _isSMSNotificationEnabled

* chore: cache _isSMSNotificationEnabled

* refactor: get value from fields

* fix: type err

* chore: also pass  user id

* fix: unit test for sms manager

---------

Co-authored-by: Benny Joo <sldisek783@gmail.com>
Co-authored-by: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2025-06-20 12:42:32 -04:00
GitHub Actions 64bfda9dbe chore: release v5.4.5
Post release / release (push) Canceled after 0s
2025-06-20 14:53:05 +00:00
Udit TakkarandHariom aacbbc1785 fix: Rate limit /router endpoint (#21940)
* fix: add rate limiting for /router/embed

* Abstract out hashing code

---------

Co-authored-by: Hariom <hariombalhara@gmail.com>
2025-06-20 14:47:56 +00:00
Anik Dhabal Babuandcubic-dev-ai[bot] 3714d5faab fix: routing form redirects to use app.cal.com instead of cal.com (#21924)
* fix: routing form redirects to use app.cal.com instead of cal.com

* Update packages/lib/hooks/useBookerUrl.ts

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

* update

---------

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2025-06-20 17:22:53 +05:30
Kartik Saini 12cb94950a fix: ui (#21936) 2025-06-20 10:16:09 +00:00
Somay Chauhan 795fd788e2 feat: add response status to calendar event host details (#21934)
* feat: add response status to calendar event host details

* docs: update calendar event response status description from attendee to host
2025-06-20 07:23:18 +00:00
devin-ai-integration[bot]andeunjae@cal.com 956fd6ae2f feat: add attendee data to CSV export with seated event support (#21842)
* feat: add attendee data to CSV export with seated event support

- Add up to 3 attendees to CSV export in 'name (email)' format
- Use booking.seatsReferences for seated events, booking.attendees for regular events
- Maintain backward compatibility with existing bookerEmail/bookerName fields
- Add attendee1, attendee2, attendee3 columns to CSV output

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

* fix: restore missing comment as requested in PR review

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

* Update packages/features/insights/server/events.ts

* feat: remove duplicate booker columns from CSV export

- Remove bookerEmail and bookerName columns as they duplicate attendee data
- Remove noShowGuest field which was based on booker data
- Remove unused booker field from bookingMap
- Keep only attendee1, attendee2, attendee3 columns with name (email) format

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

* feat: restore noShowGuest field to CSV export

- Add noShowGuest field mapping from first attendee's noShow property
- Maintain existing attendee1, attendee2, attendee3 columns
- Default to false when no attendees exist
- Works with both seated events and regular events

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

* rename column

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: eunjae@cal.com <hey@eunjae.dev>
2025-06-20 08:11:08 +01:00
Omar López 04e60611ed Update draft-release.yml (#21932) 2025-06-20 03:31:45 +00:00
192 changed files with 6217 additions and 1625 deletions
+1 -1
View File
@@ -7,7 +7,7 @@
"access": "public", "access": "public",
"baseBranch": "main", "baseBranch": "main",
"updateInternalDependencies": "patch", "updateInternalDependencies": "patch",
"ignore": [], "ignore": ["@calcom/platform-libraries"],
"privatePackages": { "privatePackages": {
"version": false, "version": false,
"tag": false "tag": false
-5
View File
@@ -1,5 +0,0 @@
---
"@calcom/atoms": patch
---
fix saving event type settings
+1 -1
View File
@@ -24,7 +24,7 @@
/packages/lib/server/getLuckyUser.ts @calcom/Foundation /packages/lib/server/getLuckyUser.ts @calcom/Foundation
/packages/lib/slots.ts @calcom/Foundation /packages/lib/slots.ts @calcom/Foundation
/packages/platform/**/* @calcom/Platform @calcom/Foundation /packages/platform/**/* @calcom/Platform @calcom/Foundation
/packages/prisma/**/* @calcom/Foundation /packages/prisma/**/* @calcom/DBA
/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts @calcom/Foundation /packages/trpc/server/routers/viewer/bookings/confirm.handler.ts @calcom/Foundation
/packages/trpc/server/routers/viewer/bookings/get.handler.ts @calcom/Foundation /packages/trpc/server/routers/viewer/bookings/get.handler.ts @calcom/Foundation
/packages/trpc/server/routers/viewer/slots/**/* @calcom/Foundation /packages/trpc/server/routers/viewer/slots/**/* @calcom/Foundation
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
contents: write # to create release by changesets/action below contents: write # to create release by changesets/action below
pull-requests: write # to create pull request by changesets/action below pull-requests: write # to create pull request by changesets/action below
name: Release name: Release
runs-on: ubuntu-latest runs-on: buildjet-4vcpu-ubuntu-2204
steps: steps:
- name: Checkout Repo - name: Checkout Repo
uses: actions/checkout@v4 uses: actions/checkout@v4
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@calcom/prisma"
+6
View File
@@ -0,0 +1,6 @@
undecided:
- "@calcom/platform-constants"
- "@calcom/platform-enums"
- "@calcom/platform-types"
- "@calcom/platform-utils"
- "@calcom/prisma"
+6
View File
@@ -0,0 +1,6 @@
undecided:
- "@calcom/app-store-cli"
- "@calcom/platform-constants"
- "@calcom/platform-enums"
- "@calcom/platform-types"
- "@calcom/platform-utils"
View File
+3
View File
@@ -0,0 +1,3 @@
undecided:
- calcom-monorepo
- "@calcom/prisma"
+1
View File
@@ -3,6 +3,7 @@
"version": "1.0.0", "version": "1.0.0",
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"private": true,
"scripts": { "scripts": {
"dev": "node ./index.js" "dev": "node ./index.js"
}, },
+1 -1
View File
@@ -38,7 +38,7 @@
"next-axiom": "^0.17.0", "next-axiom": "^0.17.0",
"next-swagger-doc": "^0.3.6", "next-swagger-doc": "^0.3.6",
"next-validations": "^0.2.0", "next-validations": "^0.2.0",
"typescript": "^5.8.3", "typescript": "^5.7.2",
"tzdata": "^1.0.30", "tzdata": "^1.0.30",
"uuid": "^8.3.2", "uuid": "^8.3.2",
"zod": "^3.22.4" "zod": "^3.22.4"
+3 -2
View File
@@ -4,6 +4,7 @@
"description": "Platform API for Cal.com", "description": "Platform API for Cal.com",
"author": "Cal.com Inc.", "author": "Cal.com Inc.",
"license": "UNLICENSED", "license": "UNLICENSED",
"private": true,
"scripts": { "scripts": {
"build": "yarn dev:build && nest build", "build": "yarn dev:build && nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
@@ -37,7 +38,7 @@
"@axiomhq/winston": "^1.2.0", "@axiomhq/winston": "^1.2.0",
"@calcom/platform-constants": "*", "@calcom/platform-constants": "*",
"@calcom/platform-enums": "*", "@calcom/platform-enums": "*",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.223", "@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.231",
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2", "@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
"@calcom/platform-types": "*", "@calcom/platform-types": "*",
"@calcom/platform-utils": "*", "@calcom/platform-utils": "*",
@@ -108,7 +109,7 @@
"ts-loader": "^9.4.3", "ts-loader": "^9.4.3",
"ts-node": "^10.9.1", "ts-node": "^10.9.1",
"tsconfig-paths": "^4.1.0", "tsconfig-paths": "^4.1.0",
"typescript": "^5.8.3" "typescript": "^5.7.2"
}, },
"prisma": { "prisma": {
"schema": "../../../packages/prisma/schema.prisma" "schema": "../../../packages/prisma/schema.prisma"
+10 -4
View File
@@ -86,10 +86,16 @@ export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer): void { configure(consumer: MiddlewareConsumer): void {
consumer consumer
.apply(RawBodyMiddleware) .apply(RawBodyMiddleware)
.forRoutes({ .forRoutes(
path: "/api/v2/billing/webhook", {
method: RequestMethod.POST, path: "/api/v2/billing/webhook",
}) method: RequestMethod.POST,
},
{
path: "/v2/billing/webhook",
method: RequestMethod.POST,
}
)
.apply(JsonBodyMiddleware) .apply(JsonBodyMiddleware)
.forRoutes("*") .forRoutes("*")
.apply(RequestIdMiddleware) .apply(RequestIdMiddleware)
+101 -71
View File
@@ -6,6 +6,100 @@ import { ERROR_STATUS } from "@calcom/platform-constants";
import { TRPCError } from "@calcom/platform-libraries"; import { TRPCError } from "@calcom/platform-libraries";
import { Response } from "@calcom/platform-types"; import { Response } from "@calcom/platform-types";
export type TRPCErrorCode = TRPCError["code"];
export interface ErrorDefinition {
statusCode: number;
message: string;
}
// Define the specific TRPC error codes
export const TRPC_ERROR_CODE = {
UNAUTHORIZED: "UNAUTHORIZED",
FORBIDDEN: "FORBIDDEN",
NOT_FOUND: "NOT_FOUND",
INTERNAL_SERVER_ERROR: "INTERNAL_SERVER_ERROR",
BAD_REQUEST: "BAD_REQUEST",
CONFLICT: "CONFLICT",
TOO_MANY_REQUESTS: "TOO_MANY_REQUESTS",
PAYLOAD_TOO_LARGE: "PAYLOAD_TOO_LARGE",
CLIENT_CLOSED_REQUEST: "CLIENT_CLOSED_REQUEST",
METHOD_NOT_SUPPORTED: "METHOD_NOT_SUPPORTED",
NOT_IMPLEMENTED: "NOT_IMPLEMENTED",
PRECONDITION_FAILED: "PRECONDITION_FAILED",
TIMEOUT: "TIMEOUT",
UNPROCESSABLE_CONTENT: "UNPROCESSABLE_CONTENT",
PARSE_ERROR: "PARSE_ERROR",
} as const; // `as const` ensures literal types, improving type inference
// Map TRPC error codes to their HTTP status codes and default messages
export const TRPC_ERROR_MAP: Record<(typeof TRPC_ERROR_CODE)[keyof typeof TRPC_ERROR_CODE], ErrorDefinition> =
{
[TRPC_ERROR_CODE.UNAUTHORIZED]: {
statusCode: 401,
message: "You are not authorized to access this resource",
},
[TRPC_ERROR_CODE.FORBIDDEN]: {
statusCode: 403,
message: "You don't have necessary permissions to access this resource",
},
[TRPC_ERROR_CODE.NOT_FOUND]: {
statusCode: 404,
message: "The requested resource was not found",
},
[TRPC_ERROR_CODE.INTERNAL_SERVER_ERROR]: {
statusCode: 500,
message: "An unexpected error occurred on the server. Please try again later",
},
[TRPC_ERROR_CODE.BAD_REQUEST]: {
statusCode: 400,
message: "Bad request: Please check your request data and try again",
},
[TRPC_ERROR_CODE.CONFLICT]: {
statusCode: 409,
message: "Could not process the request due to a resource conflict with the current state",
},
[TRPC_ERROR_CODE.TOO_MANY_REQUESTS]: {
statusCode: 429,
message: "You have exceeded the allowed number of requests",
},
[TRPC_ERROR_CODE.PAYLOAD_TOO_LARGE]: {
statusCode: 413,
message: "The request payload is too large",
},
[TRPC_ERROR_CODE.CLIENT_CLOSED_REQUEST]: {
statusCode: 499,
message: "The client closed the connection before the server could finish processing the request",
},
[TRPC_ERROR_CODE.METHOD_NOT_SUPPORTED]: {
statusCode: 405,
message: "The requested method is not supported for this resource",
},
[TRPC_ERROR_CODE.NOT_IMPLEMENTED]: {
statusCode: 501,
message: "The requested method is not implemented on the server",
},
[TRPC_ERROR_CODE.PRECONDITION_FAILED]: {
statusCode: 412,
message: "The server does not meet one of the preconditions that the requester put on the request",
},
[TRPC_ERROR_CODE.TIMEOUT]: {
statusCode: 408,
message: "The request took too long to complete. Please try again later",
},
[TRPC_ERROR_CODE.UNPROCESSABLE_CONTENT]: {
statusCode: 422,
message:
"The server was unable to process the request because the request payload is semantically incorrect or because the request is syntactically incorrect",
},
[TRPC_ERROR_CODE.PARSE_ERROR]: {
statusCode: 400,
message: "The request could not be parsed due to invalid syntax.",
},
};
// --- TRPCExceptionFilter (updated) ---
@Catch(TRPCError) @Catch(TRPCError)
export class TRPCExceptionFilter implements ExceptionFilter { export class TRPCExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger("TRPCExceptionFilter"); private readonly logger = new Logger("TRPCExceptionFilter");
@@ -15,77 +109,13 @@ export class TRPCExceptionFilter implements ExceptionFilter {
const response = ctx.getResponse<Response>(); const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>(); const request = ctx.getRequest<Request>();
let statusCode = 500; const errorDefinition = TRPC_ERROR_MAP[exception.code as keyof typeof TRPC_ERROR_CODE] || {
let errorMessage = exception.message; statusCode: 500,
message: exception.message,
};
switch (exception.code) { const statusCode = errorDefinition.statusCode;
case "UNAUTHORIZED": const errorMessage = errorDefinition.message;
statusCode = 401;
errorMessage = "You are not authorized to access this resource";
break;
case "FORBIDDEN":
statusCode = 403;
errorMessage = "You don't have necessary permissions to access this resource";
break;
case "NOT_FOUND":
statusCode = 404;
errorMessage = "The requested resource was not found";
break;
case "INTERNAL_SERVER_ERROR":
statusCode = 500;
errorMessage = "An unexpected error occurred on the server. Please try again later";
break;
case "BAD_REQUEST":
statusCode = 400;
errorMessage = "Bad request: Please check your request data and try again";
break;
case "CONFLICT":
statusCode = 409;
errorMessage = "Could not process the request due to a resource conflict with the current state";
break;
case "TOO_MANY_REQUESTS":
statusCode = 429;
errorMessage = "You have exceeded the allowed number of requests";
break;
case "PAYLOAD_TOO_LARGE":
statusCode = 413;
errorMessage = "The request payload is too large";
break;
case "CLIENT_CLOSED_REQUEST":
statusCode = 499;
errorMessage =
"The client closed the connection before the server could finish processing the request";
break;
case "METHOD_NOT_SUPPORTED":
statusCode = 405;
errorMessage = "The requested method is not supported for this resource";
case "NOT_IMPLEMENTED":
statusCode = 501;
errorMessage = "The requested method is not implemented on the server";
break;
case "PRECONDITION_FAILED":
statusCode = 412;
errorMessage =
"The server does not meet one of the preconditions that the requester put on the request";
break;
case "TIMEOUT":
statusCode = 408;
errorMessage = "The request took too long to complete. Please try again later";
break;
case "UNPROCESSABLE_CONTENT":
statusCode = 422;
errorMessage =
"The server was unable to process the request because the request payload is semantically incorrect or because the request is syntactically incorrect";
break;
case "PARSE_ERROR":
statusCode = 400;
errorMessage = "The request could not be parsed due to invalid syntax.";
break;
default:
statusCode = 500;
errorMessage = exception.message;
break;
}
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id"; const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
response.setHeader("X-Request-Id", requestId.toString()); response.setHeader("X-Request-Id", requestId.toString());
@@ -103,7 +133,7 @@ export class TRPCExceptionFilter implements ExceptionFilter {
status: ERROR_STATUS, status: ERROR_STATUS,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
path: request.url, path: request.url,
error: { code: exception.name, message: errorMessage }, error: { code: exception.name, message: errorMessage }, // exception.name is usually "TRPCError"
}); });
} }
} }
+10 -21
View File
@@ -58,12 +58,6 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
const IP = request?.headers?.["cf-connecting-ip"] ?? request?.headers?.["CF-Connecting-IP"] ?? request.ip; const IP = request?.headers?.["cf-connecting-ip"] ?? request?.headers?.["CF-Connecting-IP"] ?? request.ip;
const response = context.switchToHttp().getResponse<Response>(); const response = context.switchToHttp().getResponse<Response>();
const tracker = await this.getTracker(request); const tracker = await this.getTracker(request);
this.logger.verbose(
`Tracker "${tracker}" generated based on: Bearer token "${request.get(
"Authorization"
)}", OAuth client ID "${request.get(X_CAL_CLIENT_ID)}" and IP "${IP}"`
);
if (throttleOptions) { if (throttleOptions) {
return this.handleApiEndpointThrottle(tracker, throttleOptions, response); return this.handleApiEndpointThrottle(tracker, throttleOptions, response);
} }
@@ -104,9 +98,9 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
private async handleNonApiKeyRequest(tracker: string, response: Response): Promise<boolean> { private async handleNonApiKeyRequest(tracker: string, response: Response): Promise<boolean> {
const rateLimit = this.getDefaultRateLimit(tracker); const rateLimit = this.getDefaultRateLimit(tracker);
this.logger.verbose(`Tracker "${tracker}" uses default rate limits because it is not tracking api key: /*this.logger.verbose(`Tracker "${tracker}" uses default rate limits because it is not tracking api key:
${JSON.stringify(rateLimit, null, 2)} ${JSON.stringify(rateLimit, null, 2)}
`); `);*/
const { isBlocked } = await this.incrementRateLimit(tracker, rateLimit, response); const { isBlocked } = await this.incrementRateLimit(tracker, rateLimit, response);
if (isBlocked) { if (isBlocked) {
@@ -150,9 +144,9 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
const cachedRateLimits = await this.storageService.redis.get(cacheKey); const cachedRateLimits = await this.storageService.redis.get(cacheKey);
if (cachedRateLimits) { if (cachedRateLimits) {
this.logger.verbose(`Tracker "${tracker}" rate limits retrieved from redis cache: /*this.logger.verbose(`Tracker "${tracker}" rate limits retrieved from redis cache:
${cachedRateLimits} ${cachedRateLimits}
`); `);*/
return rateLimitsSchema.parse(JSON.parse(cachedRateLimits)); return rateLimitsSchema.parse(JSON.parse(cachedRateLimits));
} }
@@ -172,15 +166,10 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
select: { name: true, limit: true, ttl: true, blockDuration: true }, select: { name: true, limit: true, ttl: true, blockDuration: true },
}); });
if (rateLimits) {
this.logger.verbose(`Tracker "${tracker}" rate limits retrieved from database:
${JSON.stringify(rateLimits, null, 2)}`);
}
if (!rateLimits || rateLimits.length === 0) { if (!rateLimits || rateLimits.length === 0) {
rateLimits = [this.getDefaultRateLimit(tracker)]; rateLimits = [this.getDefaultRateLimit(tracker)];
this.logger.verbose(`Tracker "${tracker}" rate limits not found in database. Using default rate limits: /*this.logger.verbose(`Tracker "${tracker}" rate limits not found in database. Using default rate limits:
${JSON.stringify(rateLimits, null, 2)}`); ${JSON.stringify(rateLimits, null, 2)}`);*/
} }
await this.storageService.redis.set(cacheKey, JSON.stringify(rateLimits), "EX", 3600); await this.storageService.redis.set(cacheKey, JSON.stringify(rateLimits), "EX", 3600);
@@ -209,15 +198,15 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
); );
response.setHeader(`X-RateLimit-Reset-${nameFirstUpper}`, timeToBlockExpire || timeToExpire); response.setHeader(`X-RateLimit-Reset-${nameFirstUpper}`, timeToBlockExpire || timeToExpire);
this.logger.verbose( /*this.logger.verbose(
`Tracker "${tracker}" rate limit "${name}" incremented. isBlocked ${isBlocked}, totalHits ${totalHits}, timeToExpire ${timeToExpire}, timeToBlockExpire ${timeToBlockExpire}` `Tracker "${tracker}" rate limit "${name}" incremented. isBlocked ${isBlocked}, totalHits ${totalHits}, timeToExpire ${timeToExpire}, timeToBlockExpire ${timeToBlockExpire}`
); );*/
this.logger.verbose( /*this.logger.verbose(
`Tracker "${tracker}" rate limit "${name}" response headers: `Tracker "${tracker}" rate limit "${name}" response headers:
X-RateLimit-Limit-${nameFirstUpper}: ${limit}, X-RateLimit-Limit-${nameFirstUpper}: ${limit},
X-RateLimit-Remaining-${nameFirstUpper}: ${timeToBlockExpire ? 0 : Math.max(0, limit - totalHits)}, X-RateLimit-Remaining-${nameFirstUpper}: ${timeToBlockExpire ? 0 : Math.max(0, limit - totalHits)},
X-RateLimit-Reset-${nameFirstUpper}: ${timeToBlockExpire || timeToExpire}` X-RateLimit-Reset-${nameFirstUpper}: ${timeToBlockExpire || timeToExpire}`
); );*/
return { isBlocked }; return { isBlocked };
} }
@@ -116,6 +116,7 @@ describe("Platform Billing Controller (e2e)", () => {
return request(app.getHttpServer()) return request(app.getHttpServer())
.post("/v2/billing/webhook") .post("/v2/billing/webhook")
.set("stripe-signature", "t=1234567890,v1=random_signature_for_e2e_test")
.expect(200) .expect(200)
.then(async (res) => { .then(async (res) => {
const billing = await platformBillingRepositoryFixture.get(organization.id); const billing = await platformBillingRepositoryFixture.get(organization.id);
@@ -143,6 +144,7 @@ describe("Platform Billing Controller (e2e)", () => {
); );
return request(app.getHttpServer()) return request(app.getHttpServer())
.post("/v2/billing/webhook") .post("/v2/billing/webhook")
.set("stripe-signature", "t=1234567890,v1=random_signature_for_e2e_test")
.expect(200) .expect(200)
.then(async (res) => { .then(async (res) => {
const billing = await platformBillingRepositoryFixture.get(organization.id); const billing = await platformBillingRepositoryFixture.get(organization.id);
@@ -172,6 +174,7 @@ describe("Platform Billing Controller (e2e)", () => {
return request(app.getHttpServer()) return request(app.getHttpServer())
.post("/v2/billing/webhook") .post("/v2/billing/webhook")
.set("stripe-signature", "t=1234567890,v1=random_signature_for_e2e_test")
.expect(200) .expect(200)
.then(async (res) => { .then(async (res) => {
const billing = await platformBillingRepositoryFixture.get(organization.id); const billing = await platformBillingRepositoryFixture.get(organization.id);
@@ -204,6 +207,7 @@ describe("Platform Billing Controller (e2e)", () => {
return request(app.getHttpServer()) return request(app.getHttpServer())
.post("/v2/billing/webhook") .post("/v2/billing/webhook")
.set("stripe-signature", "t=1234567890,v1=random_signature_for_e2e_test")
.expect(200) .expect(200)
.then(async (res) => { .then(async (res) => {
const billing = await platformBillingRepositoryFixture.get(organization.id); const billing = await platformBillingRepositoryFixture.get(organization.id);
@@ -21,10 +21,12 @@ import {
HttpStatus, HttpStatus,
Logger, Logger,
Delete, Delete,
BadRequestException,
} from "@nestjs/common"; } from "@nestjs/common";
import { ConfigService } from "@nestjs/config"; import { ConfigService } from "@nestjs/config";
import { ApiExcludeController } from "@nestjs/swagger"; import { ApiExcludeController } from "@nestjs/swagger";
import { Request } from "express"; import { Request } from "express";
import Stripe from "stripe";
import { ApiResponse } from "@calcom/platform-types"; import { ApiResponse } from "@calcom/platform-types";
@@ -116,35 +118,59 @@ export class BillingController {
@Req() request: Request, @Req() request: Request,
@Headers("stripe-signature") stripeSignature: string @Headers("stripe-signature") stripeSignature: string
): Promise<ApiResponse> { ): Promise<ApiResponse> {
const event = await this.billingService.stripeService try {
.getStripe() if (!stripeSignature) {
.webhooks.constructEventAsync(request.body, stripeSignature, this.stripeWhSecret); this.logger.warn("Missing stripe-signature header in webhook request");
return {
status: "success",
};
}
switch (event.type) { if (!this.stripeWhSecret) {
case "checkout.session.completed": this.logger.error("Missing STRIPE_WEBHOOK_SECRET configuration");
await this.billingService.handleStripeCheckoutEvents(event); return {
break; status: "success",
case "customer.subscription.updated": };
await this.billingService.handleStripePaymentPastDue(event); }
break;
case "customer.subscription.deleted": const event = await this.billingService.stripeService
await this.billingService.handleStripeSubscriptionDeleted(event); .getStripe()
break; .webhooks.constructEventAsync(request.body, stripeSignature, this.stripeWhSecret);
case "invoice.created":
await this.billingService.handleStripeSubscriptionForActiveManagedUsers(event); switch (event.type) {
break; case "checkout.session.completed":
case "invoice.payment_failed": await this.billingService.handleStripeCheckoutEvents(event);
await this.billingService.handleStripePaymentFailed(event); break;
break; case "customer.subscription.updated":
case "invoice.payment_succeeded": await this.billingService.handleStripePaymentPastDue(event);
await this.billingService.handleStripePaymentSuccess(event); break;
break; case "customer.subscription.deleted":
default: await this.billingService.handleStripeSubscriptionDeleted(event);
break; break;
case "invoice.created":
await this.billingService.handleStripeSubscriptionForActiveManagedUsers(event);
break;
case "invoice.payment_failed":
await this.billingService.handleStripePaymentFailed(event);
break;
case "invoice.payment_succeeded":
await this.billingService.handleStripePaymentSuccess(event);
break;
default:
break;
}
return {
status: "success",
};
} catch (error) {
if (error instanceof Stripe.errors.StripeSignatureVerificationError) {
this.logger.error("Webhook signature validation failed", error);
return {
status: "success",
};
}
throw error;
} }
return {
status: "success",
};
} }
} }
@@ -211,6 +211,18 @@ export class CalendarEventHost {
description: "Display name of the event host", description: "Display name of the event host",
}) })
name?: string; name?: string;
@IsString()
@IsOptional()
@IsEnum(CalendarEventResponseStatus)
@ApiPropertyOptional({
enum: CalendarEventResponseStatus,
nullable: true,
enumName: "CalendarEventResponseStatus",
description: "Host's response to the invitation",
example: CalendarEventResponseStatus.ACCEPTED,
})
responseStatus!: CalendarEventResponseStatus | null;
} }
export class CalendarEventAttendee { export class CalendarEventAttendee {
@@ -159,28 +159,28 @@ export class GoogleCalendarEventOutputPipe
} }
} }
private transformHosts(googleEvent: GoogleCalendarEventResponse): Array<{ email: string; name?: string }> { private transformHosts(googleEvent: GoogleCalendarEventResponse) {
if (googleEvent.organizer) { const hosts: Array<{ email: string; name?: string; responseStatus: CalendarEventResponseStatus | null }> =
return [ [];
{
email: googleEvent.organizer.email, const organizerAttendees = googleEvent?.attendees?.filter((attendee) => attendee.organizer);
name: googleEvent.organizer.displayName, if (organizerAttendees?.length) {
}, organizerAttendees.forEach((organizer) => {
]; hosts.push({
} else if (googleEvent.attendees) { email: organizer.email,
// If no explicit organizer, find the first attendee with organizer flag name: organizer.displayName,
const organizerAttendee = googleEvent.attendees.find((attendee) => attendee.organizer); responseStatus: this.transformAttendeeResponseStatus(organizer.responseStatus),
if (organizerAttendee) { });
return [ });
{ } else {
email: organizerAttendee.email, hosts.push({
name: organizerAttendee.displayName, email: googleEvent.organizer.email,
}, name: googleEvent.organizer.displayName,
]; responseStatus: null,
} });
} }
return []; return hosts;
} }
private transformLocations( private transformLocations(
@@ -1,6 +1,7 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common"; import { Injectable } from "@nestjs/common";
import { Prisma } from "@prisma/client";
import { GOOGLE_MEET_TYPE } from "@calcom/platform-constants"; import { GOOGLE_MEET_TYPE } from "@calcom/platform-constants";
@@ -8,12 +9,26 @@ import { GOOGLE_MEET_TYPE } from "@calcom/platform-constants";
export class ConferencingRepository { export class ConferencingRepository {
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {} constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
private readonly credentialSelect = Prisma.validator<Prisma.CredentialSelect>()({
id: true,
type: true,
userId: true,
teamId: true,
appId: true,
subscriptionId: true,
paymentStatus: true,
billingCycleStart: true,
invalid: true,
delegationCredentialId: true,
});
async findConferencingApps(userId: number) { async findConferencingApps(userId: number) {
return this.dbRead.prisma.credential.findMany({ return this.dbRead.prisma.credential.findMany({
where: { where: {
userId, userId,
type: { endsWith: "_video" }, type: { endsWith: "_video" },
}, },
select: this.credentialSelect,
}); });
} }
@@ -23,24 +38,28 @@ export class ConferencingRepository {
teamId, teamId,
type: { endsWith: "_video" }, type: { endsWith: "_video" },
}, },
select: this.credentialSelect,
}); });
} }
async findGoogleMeet(userId: number) { async findGoogleMeet(userId: number) {
return this.dbRead.prisma.credential.findFirst({ return this.dbRead.prisma.credential.findFirst({
where: { userId, type: GOOGLE_MEET_TYPE }, where: { userId, type: GOOGLE_MEET_TYPE },
select: this.credentialSelect,
}); });
} }
async findConferencingApp(userId: number, app: string) { async findConferencingApp(userId: number, app: string) {
return this.dbRead.prisma.credential.findFirst({ return this.dbRead.prisma.credential.findFirst({
where: { userId, appId: app }, where: { userId, appId: app },
select: this.credentialSelect,
}); });
} }
async findTeamConferencingApp(teamId: number, app: string) { async findTeamConferencingApp(teamId: number, app: string) {
return this.dbRead.prisma.credential.findFirst({ return this.dbRead.prisma.credential.findFirst({
where: { teamId, appId: app }, where: { teamId, appId: app },
select: this.credentialSelect,
}); });
} }
} }
@@ -144,6 +144,9 @@ export class OAuthClientUsersService {
} }
static getOAuthUserEmail(oAuthClientId: string, userEmail: string) { static getOAuthUserEmail(oAuthClientId: string, userEmail: string) {
if (userEmail.includes(`+${oAuthClientId}@`)) {
return userEmail;
}
const [username, emailDomain] = userEmail.split("@"); const [username, emailDomain] = userEmail.split("@");
return `${username}+${oAuthClientId}@${emailDomain}`; return `${username}+${oAuthClientId}@${emailDomain}`;
} }
@@ -1,3 +1,4 @@
import { TRPC_ERROR_CODE, TRPC_ERROR_MAP, TRPCErrorCode } from "@/filters/trpc-exception.filter";
import { SlotsOutputService_2024_04_15 } from "@/modules/slots/slots-2024-04-15/services/slots-output.service"; import { SlotsOutputService_2024_04_15 } from "@/modules/slots/slots-2024-04-15/services/slots-output.service";
import type { RangeSlots, TimeSlots } from "@/modules/slots/slots-2024-04-15/services/slots-output.service"; import type { RangeSlots, TimeSlots } from "@/modules/slots/slots-2024-04-15/services/slots-output.service";
import { SlotsWorkerService_2024_04_15 } from "@/modules/slots/slots-2024-04-15/services/slots-worker.service"; import { SlotsWorkerService_2024_04_15 } from "@/modules/slots/slots-2024-04-15/services/slots-worker.service";
@@ -15,6 +16,7 @@ import {
VERSION_2024_06_11, VERSION_2024_06_11,
VERSION_2024_08_13, VERSION_2024_08_13,
} from "@calcom/platform-constants"; } from "@calcom/platform-constants";
import { TRPCError } from "@calcom/platform-libraries";
import { getAvailableSlots } from "@calcom/platform-libraries/slots"; import { getAvailableSlots } from "@calcom/platform-libraries/slots";
import { RemoveSelectedSlotInput_2024_04_15, ReserveSlotInput_2024_04_15 } from "@calcom/platform-types"; import { RemoveSelectedSlotInput_2024_04_15, ReserveSlotInput_2024_04_15 } from "@calcom/platform-types";
import { ApiResponse, GetAvailableSlotsInput_2024_04_15 } from "@calcom/platform-types"; import { ApiResponse, GetAvailableSlotsInput_2024_04_15 } from "@calcom/platform-types";
@@ -209,7 +211,12 @@ export class SlotsController_2024_04_15 {
"Invalid time range given - check the 'startTime' and 'endTime' query parameters." "Invalid time range given - check the 'startTime' and 'endTime' query parameters."
); );
} }
if (TRPC_ERROR_MAP[error.message as keyof typeof TRPC_ERROR_CODE]) {
throw new TRPCError({ code: error.message as TRPCErrorCode });
}
} }
throw error; throw error;
} }
} }
+6 -1
View File
@@ -26400,7 +26400,7 @@
}, },
"CalendarEventResponseStatus": { "CalendarEventResponseStatus": {
"type": "string", "type": "string",
"description": "Attendee's response to the invitation", "description": "Host's response to the invitation",
"enum": [ "enum": [
"accepted", "accepted",
"pending", "pending",
@@ -26460,6 +26460,11 @@
"type": "string", "type": "string",
"nullable": true, "nullable": true,
"description": "Display name of the event host" "description": "Display name of the event host"
},
"responseStatus": {
"nullable": true,
"example": "accepted",
"$ref": "#/components/schemas/CalendarEventResponseStatus"
} }
}, },
"required": [ "required": [
@@ -10,6 +10,7 @@ import { ImageExample } from "./avatar.image"
import { TooltipExample } from "./avatar.tooltip" import { TooltipExample } from "./avatar.tooltip"
import { LinkExample } from "./avatar.link" import { LinkExample } from "./avatar.link"
import { IndicatorExample } from "./avatar.indicator" import { IndicatorExample } from "./avatar.indicator"
import { ShapeExample } from "./avatar.shape"
## Size Variations ## Size Variations
@@ -17,6 +18,12 @@ Avatars come in different sizes to suit various use cases.
<SizesExample/> <SizesExample/>
## Shape Variations
Avatars can be displayed in either circular or square shapes.
<ShapeExample/>
## With and Without Image ## With and Without Image
Avatars can display either an image or a fallback placeholder. Avatars can display either an image or a fallback placeholder.
@@ -40,3 +47,57 @@ Avatars can be made clickable by providing an href.
Avatars can include status indicators or badges. Avatars can include status indicators or badges.
<IndicatorExample/> <IndicatorExample/>
## Usage Example
Here's a complete example showing how to use the Avatar component with all its props:
```tsx
import { Avatar } from "@calcom/ui/components/avatar";
function Example() {
return (
<div className="flex flex-col gap-4">
{/* Basic Avatar */}
<Avatar
alt="User Name"
title="User Name"
size="md"
shape="circle"
imageSrc="https://example.com/avatar.jpg"
/>
{/* Avatar with Link */}
<Avatar
alt="User Name"
title="User Name"
size="lg"
shape="square"
imageSrc="https://example.com/avatar.jpg"
href="/profile"
/>
{/* Avatar with Tooltip and Indicator */}
<Avatar
alt="User Name"
title="User Name"
size="md"
shape="circle"
imageSrc="https://example.com/avatar.jpg"
indicator={<div className="absolute bottom-0 right-0 h-3 w-3 rounded-full bg-green-500" />}
/>
{/* Avatar with Fallback */}
<Avatar
alt="User Name"
title="User Name"
size="md"
shape="circle"
fallback={<span>UN</span>}
/>
</div>
);
}
```
@@ -0,0 +1,24 @@
import { Avatar } from "@calcom/ui/components/avatar";
export function ShapeExample() {
return (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-4">
<Avatar
alt="Circle Avatar"
title="Circle Avatar"
size="lg"
shape="circle"
imageSrc="https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80"
/>
<Avatar
alt="Square Avatar"
title="Square Avatar"
size="lg"
shape="square"
imageSrc="https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80"
/>
</div>
</div>
);
}
@@ -2,6 +2,8 @@ import { withAppDirSsr } from "app/WithAppDirSsr";
import type { PageProps as ServerPageProps } from "app/_types"; import type { PageProps as ServerPageProps } from "app/_types";
import { _generateMetadata } from "app/_utils"; import { _generateMetadata } from "app/_utils";
import { cookies, headers } from "next/headers"; import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { z } from "zod";
import { buildLegacyCtx } from "@lib/buildLegacyCtx"; import { buildLegacyCtx } from "@lib/buildLegacyCtx";
@@ -21,11 +23,18 @@ export const generateMetadata = async () => {
}; };
const getData = withAppDirSsr<ClientPageProps>(getServerSideProps); const getData = withAppDirSsr<ClientPageProps>(getServerSideProps);
const stepSchema = z.enum(["1", "2", "3", "4"]);
const ServerPage = async ({ params, searchParams }: ServerPageProps) => { const ServerPage = async ({ params, searchParams: _searchParams }: ServerPageProps) => {
const props = await getData( const searchParams = await _searchParams;
buildLegacyCtx(await headers(), await cookies(), await params, await searchParams) const stepResult = stepSchema.safeParse(searchParams?.step);
);
// If step parameter is invalid, redirect to step 1
if (!stepResult.success) {
return redirect(`/auth/setup?step=1`);
}
const props = await getData(buildLegacyCtx(await headers(), await cookies(), await params, searchParams));
return <Setup {...props} />; return <Setup {...props} />;
}; };
@@ -46,13 +46,13 @@ async function postHandler(req: NextRequest) {
if (!log.smsSid) return; if (!log.smsSid) return;
try { try {
const price = await twilio.getPriceForSMS(log.smsSid); const { price, numSegments } = await twilio.getMessageInfo(log.smsSid);
const credits = price ? creditService.calculateCreditsFromPrice(price) : null; const credits = price ? creditService.calculateCreditsFromPrice(price) : null;
if (!credits) return; if (!credits) return;
const updatedLog = await prisma.creditExpenseLog.update({ const updatedLog = await prisma.creditExpenseLog.update({
where: { id: log.id }, where: { id: log.id },
data: { credits }, data: { credits, smsSegments: numSegments },
select: { select: {
creditBalance: { creditBalance: {
select: { select: {
@@ -8,11 +8,11 @@ import { CalendarAppDelegationCredentialInvalidGrantError } from "@calcom/lib/Ca
import { handleCreateSelectedCalendars, isSameEmail } from "../route"; import { handleCreateSelectedCalendars, isSameEmail } from "../route";
// Mock GoogleCalendarService // Mock GoogleCalendarService
const fetchPrimaryCalendarMock = vi.fn(); const getPrimaryCalendarMock = vi.fn();
vi.mock("@calcom/app-store/googlecalendar/lib/CalendarService", () => { vi.mock("@calcom/app-store/googlecalendar/lib/CalendarService", () => {
return { return {
default: vi.fn().mockImplementation(() => ({ default: vi.fn().mockImplementation(() => ({
fetchPrimaryCalendar: fetchPrimaryCalendarMock, getPrimaryCalendar: getPrimaryCalendarMock,
})), })),
}; };
}); });
@@ -116,7 +116,7 @@ describe("handleCreateSelectedCalendars integration", () => {
prismock.user.deleteMany(); prismock.user.deleteMany();
prismock.team.deleteMany(); prismock.team.deleteMany();
prismock.workspacePlatform.deleteMany(); prismock.workspacePlatform.deleteMany();
fetchPrimaryCalendarMock.mockReset(); getPrimaryCalendarMock.mockReset();
}); });
it("shows a helpful message when no Delegation Credentials are set up in the system", async () => { it("shows a helpful message when no Delegation Credentials are set up in the system", async () => {
@@ -138,7 +138,7 @@ describe("handleCreateSelectedCalendars integration", () => {
domain: "example.com", domain: "example.com",
}); });
await createCredential({ id: 1, userId: user.id, delegationCredentialId: delegationCredential.id }); await createCredential({ id: 1, userId: user.id, delegationCredentialId: delegationCredential.id });
fetchPrimaryCalendarMock.mockResolvedValue({ id: "user1@example.com" }); getPrimaryCalendarMock.mockResolvedValue({ id: "user1@example.com" });
const result = await handleCreateSelectedCalendars(); const result = await handleCreateSelectedCalendars();
expect(result.success).toBe(1); expect(result.success).toBe(1);
@@ -159,7 +159,7 @@ describe("handleCreateSelectedCalendars integration", () => {
domain: "example.com", domain: "example.com",
}); });
await createCredential({ id: 1, userId: user.id, delegationCredentialId: delegationCredential.id }); await createCredential({ id: 1, userId: user.id, delegationCredentialId: delegationCredential.id });
fetchPrimaryCalendarMock.mockResolvedValue({ id: "notuser@example.com" }); getPrimaryCalendarMock.mockResolvedValue({ id: "notuser@example.com" });
const result = await handleCreateSelectedCalendars(); const result = await handleCreateSelectedCalendars();
expect(result.success).toBe(1); expect(result.success).toBe(1);
@@ -183,7 +183,7 @@ describe("handleCreateSelectedCalendars integration", () => {
domain: "example.com", domain: "example.com",
}); });
await createCredential({ id: 1, userId: user.id, delegationCredentialId: delegationCredential.id }); await createCredential({ id: 1, userId: user.id, delegationCredentialId: delegationCredential.id });
fetchPrimaryCalendarMock.mockResolvedValue({ id: "user1@example.com" }); getPrimaryCalendarMock.mockResolvedValue({ id: "user1@example.com" });
const result = await handleCreateSelectedCalendars(); const result = await handleCreateSelectedCalendars();
expect(result.success).toBe(1); expect(result.success).toBe(1);
@@ -210,7 +210,7 @@ describe("handleCreateSelectedCalendars integration", () => {
delegationCredentialId: delegationCredential.id, delegationCredentialId: delegationCredential.id,
credentialId: 1, credentialId: 1,
}); });
fetchPrimaryCalendarMock.mockResolvedValue({ id: "user1@example.com" }); getPrimaryCalendarMock.mockResolvedValue({ id: "user1@example.com" });
const result = await handleCreateSelectedCalendars(); const result = await handleCreateSelectedCalendars();
expect(result.success).toBe(0); expect(result.success).toBe(0);
@@ -247,7 +247,7 @@ describe("handleCreateSelectedCalendars integration", () => {
error: "some error" as unknown as null, error: "some error" as unknown as null,
credentialId: regularCredential.id, credentialId: regularCredential.id,
}); });
fetchPrimaryCalendarMock.mockResolvedValue({ id: "user1@example.com" }); getPrimaryCalendarMock.mockResolvedValue({ id: "user1@example.com" });
const result = await handleCreateSelectedCalendars(); const result = await handleCreateSelectedCalendars();
expect(result.success).toBe(1); expect(result.success).toBe(1);
@@ -272,7 +272,7 @@ describe("handleCreateSelectedCalendars integration", () => {
domain: "example.com", domain: "example.com",
}); });
await createCredential({ id: 1, userId: user.id, delegationCredentialId: delegationCredential.id }); await createCredential({ id: 1, userId: user.id, delegationCredentialId: delegationCredential.id });
fetchPrimaryCalendarMock.mockRejectedValue( getPrimaryCalendarMock.mockRejectedValue(
new CalendarAppDelegationCredentialInvalidGrantError("some error") new CalendarAppDelegationCredentialInvalidGrantError("some error")
); );
@@ -293,7 +293,7 @@ describe("handleCreateSelectedCalendars integration", () => {
domain: "example.com", domain: "example.com",
}); });
await createCredential({ id: 1, userId: user.id, delegationCredentialId: delegationCredential.id }); await createCredential({ id: 1, userId: user.id, delegationCredentialId: delegationCredential.id });
fetchPrimaryCalendarMock.mockRejectedValue(new Error("some error")); getPrimaryCalendarMock.mockRejectedValue(new Error("some error"));
const result = await handleCreateSelectedCalendars(); const result = await handleCreateSelectedCalendars();
expect(result.success).toBe(0); expect(result.success).toBe(0);
@@ -153,7 +153,7 @@ async function fetchPrimaryCalendarId({
const userEmail = delegationUserCredential.user.email; const userEmail = delegationUserCredential.user.email;
try { try {
const primaryCalendar = await googleCalendarService.fetchPrimaryCalendar(); const primaryCalendar = await googleCalendarService.getPrimaryCalendar();
primaryCalendarId = primaryCalendar?.id; primaryCalendarId = primaryCalendar?.id;
} catch (error) { } catch (error) {
log.error( log.error(
@@ -0,0 +1,110 @@
import { NextRequest } from "next/server";
import { describe, expect, test, vi, beforeEach } from "vitest";
import { GET } from "../route";
vi.mock("next/og", () => ({
ImageResponse: vi.fn().mockImplementation(() => ({
body: new ReadableStream({
start(controller) {
controller.enqueue(new Uint8Array([1, 2, 3, 4]));
controller.close();
},
}),
})),
}));
vi.mock("@calcom/lib/OgImages", () => ({
Meeting: vi.fn(() => null),
App: vi.fn(() => null),
Generic: vi.fn(() => null),
}));
vi.mock("@calcom/lib/constants", () => ({
WEBAPP_URL: "http://localhost:3000",
}));
global.fetch = vi.fn();
function createNextRequest(url: string): NextRequest {
const request = new Request(url, { method: "GET" });
return new NextRequest(request);
}
describe("GET /api/social/og/image", () => {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(global.fetch).mockResolvedValue({
arrayBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(8)),
} as any);
});
describe("Validation errors (400 Bad Request)", () => {
test("meeting type: returns 400 when required parameters are missing", async () => {
const request = createNextRequest("http://example.com/api/social/og/image?type=meeting");
const response = await GET(request);
expect(response.status).toBe(400);
expect(response.headers.get("Content-Type")).toBe("application/json");
const errorData = await response.json();
expect(errorData.error).toBe("Invalid parameters for meeting image");
expect(errorData.message).toBe(
"Required parameters: title, meetingProfileName. Optional: names, usernames, meetingImage"
);
});
test("app type: returns 400 when required parameters are missing", async () => {
const request = createNextRequest("http://example.com/api/social/og/image?type=app");
const response = await GET(request);
expect(response.status).toBe(400);
const errorData = await response.json();
expect(errorData.error).toBe("Invalid parameters for app image");
expect(errorData.message).toBe("Required parameters: name, description, slug");
});
test("generic type: returns 400 when required parameters are missing", async () => {
const request = createNextRequest("http://example.com/api/social/og/image?type=generic");
const response = await GET(request);
expect(response.status).toBe(400);
const errorData = await response.json();
expect(errorData.error).toBe("Invalid parameters for generic image");
expect(errorData.message).toBe("Required parameters: title, description");
});
});
describe("Not found errors (404 Not Found)", () => {
test("returns 404 when no type parameter is provided", async () => {
const request = createNextRequest("http://example.com/api/social/og/image");
const response = await GET(request);
expect(response.status).toBe(404);
expect(await response.text()).toBe("What you're looking for is not here..");
});
test("returns 404 when invalid type parameter is provided", async () => {
const request = createNextRequest("http://example.com/api/social/og/image?type=invalid");
const response = await GET(request);
expect(response.status).toBe(404);
expect(await response.text()).toBe("What you're looking for is not here..");
});
});
describe("Server errors (500 Internal Server Error)", () => {
test("returns 500 when font loading fails", async () => {
vi.mocked(global.fetch).mockRejectedValue(new Error("Font loading failed"));
const request = createNextRequest(
"http://example.com/api/social/og/image?type=meeting&title=Test&meetingProfileName=John"
);
const response = await GET(request);
expect(response.status).toBe(500);
expect(await response.text()).toBe("Internal server error");
});
});
});
+119 -66
View File
@@ -1,7 +1,7 @@
import { ImageResponse } from "next/og"; import { ImageResponse } from "next/og";
import type { NextRequest } from "next/server"; import type { NextRequest } from "next/server";
import type { SatoriOptions } from "satori"; import type { SatoriOptions } from "satori";
import { z } from "zod"; import { z, ZodError } from "zod";
import { Meeting, App, Generic } from "@calcom/lib/OgImages"; import { Meeting, App, Generic } from "@calcom/lib/OgImages";
import { WEBAPP_URL } from "@calcom/lib/constants"; import { WEBAPP_URL } from "@calcom/lib/constants";
@@ -34,75 +34,128 @@ async function handler(req: NextRequest) {
const { searchParams } = req.nextUrl; const { searchParams } = req.nextUrl;
const imageType = searchParams.get("type"); const imageType = searchParams.get("type");
const [calFontData, interFontData, interFontMediumData] = await Promise.all([ try {
fetch(new URL("/fonts/cal.ttf", WEBAPP_URL)).then((res) => res.arrayBuffer()), const [calFontData, interFontData, interFontMediumData] = await Promise.all([
fetch(new URL("/fonts/Inter-Regular.ttf", WEBAPP_URL)).then((res) => res.arrayBuffer()), fetch(new URL("/fonts/cal.ttf", WEBAPP_URL)).then((res) => res.arrayBuffer()),
fetch(new URL("/fonts/Inter-Medium.ttf", WEBAPP_URL)).then((res) => res.arrayBuffer()), fetch(new URL("/fonts/Inter-Regular.ttf", WEBAPP_URL)).then((res) => res.arrayBuffer()),
]); fetch(new URL("/fonts/Inter-Medium.ttf", WEBAPP_URL)).then((res) => res.arrayBuffer()),
const ogConfig = { ]);
width: 1200, const ogConfig = {
height: 630, width: 1200,
fonts: [ height: 630,
{ name: "inter", data: interFontData, weight: 400 }, fonts: [
{ name: "inter", data: interFontMediumData, weight: 500 }, { name: "inter", data: interFontData, weight: 400 },
{ name: "cal", data: calFontData, weight: 400 }, { name: "inter", data: interFontMediumData, weight: 500 },
{ name: "cal", data: calFontData, weight: 600 }, { name: "cal", data: calFontData, weight: 400 },
] as SatoriOptions["fonts"], { name: "cal", data: calFontData, weight: 600 },
}; ] as SatoriOptions["fonts"],
};
switch (imageType) { switch (imageType) {
case "meeting": { case "meeting": {
const { names, usernames, title, meetingProfileName, meetingImage } = meetingSchema.parse({ try {
names: searchParams.getAll("names"), const { names, usernames, title, meetingProfileName, meetingImage } = meetingSchema.parse({
usernames: searchParams.getAll("usernames"), names: searchParams.getAll("names"),
title: searchParams.get("title"), usernames: searchParams.getAll("usernames"),
meetingProfileName: searchParams.get("meetingProfileName"), title: searchParams.get("title"),
meetingImage: searchParams.get("meetingImage"), meetingProfileName: searchParams.get("meetingProfileName"),
imageType, meetingImage: searchParams.get("meetingImage"),
}); imageType,
});
const img = new ImageResponse( const img = new ImageResponse(
( (
<Meeting <Meeting
title={title} title={title}
profile={{ name: meetingProfileName, image: meetingImage }} profile={{ name: meetingProfileName, image: meetingImage }}
users={names.map((name, index) => ({ name, username: usernames[index] }))} users={names.map((name, index) => ({ name, username: usernames[index] }))}
/> />
), ),
ogConfig ogConfig
); );
return new Response(img.body, { return new Response(img.body, {
status: 200, status: 200,
headers: { "Content-Type": "image/png", "cache-control": "max-age=0" }, headers: { "Content-Type": "image/png", "cache-control": "max-age=0" },
}); });
} catch (error) {
if (error instanceof ZodError) {
return new Response(
JSON.stringify({
error: "Invalid parameters for meeting image",
message:
"Required parameters: title, meetingProfileName. Optional: names, usernames, meetingImage",
}),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
);
}
throw error;
}
}
case "app": {
try {
const { name, description, slug } = appSchema.parse({
name: searchParams.get("name"),
description: searchParams.get("description"),
slug: searchParams.get("slug"),
imageType,
});
const img = new ImageResponse(<App name={name} description={description} slug={slug} />, ogConfig);
return new Response(img.body, { status: 200, headers: { "Content-Type": "image/png" } });
} catch (error) {
if (error instanceof ZodError) {
return new Response(
JSON.stringify({
error: "Invalid parameters for app image",
message: "Required parameters: name, description, slug",
}),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
);
}
throw error;
}
}
case "generic": {
try {
const { title, description } = genericSchema.parse({
title: searchParams.get("title"),
description: searchParams.get("description"),
imageType,
});
const img = new ImageResponse(<Generic title={title} description={description} />, ogConfig);
return new Response(img.body, { status: 200, headers: { "Content-Type": "image/png" } });
} catch (error) {
if (error instanceof ZodError) {
return new Response(
JSON.stringify({
error: "Invalid parameters for generic image",
message: "Required parameters: title, description",
}),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
);
}
throw error;
}
}
default:
return new Response("What you're looking for is not here..", { status: 404 });
} }
case "app": { } catch (error) {
const { name, description, slug } = appSchema.parse({ return new Response("Internal server error", { status: 500 });
name: searchParams.get("name"),
description: searchParams.get("description"),
slug: searchParams.get("slug"),
imageType,
});
const img = new ImageResponse(<App name={name} description={description} slug={slug} />, ogConfig);
return new Response(img.body, { status: 200, headers: { "Content-Type": "image/png" } });
}
case "generic": {
const { title, description } = genericSchema.parse({
title: searchParams.get("title"),
description: searchParams.get("description"),
imageType,
});
const img = new ImageResponse(<Generic title={title} description={description} />, ogConfig);
return new Response(img.body, { status: 200, headers: { "Content-Type": "image/png" } });
}
default:
return new Response("What you're looking for is not here..", { status: 404 });
} }
} }
+4 -1
View File
@@ -1,4 +1,5 @@
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { useSession } from "next-auth/react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
@@ -279,6 +280,7 @@ const NewRoutingManager = ({
const { t } = useLocale(); const { t } = useLocale();
const router = useRouter(); const router = useRouter();
const bookerUrl = useBookerUrl(); const bookerUrl = useBookerUrl();
const session = useSession();
const teamMemberIdsMatchingAttributeLogic = const teamMemberIdsMatchingAttributeLogic =
teamMembersMatchingAttributeLogic?.data teamMembersMatchingAttributeLogic?.data
?.map((member) => member.id) ?.map((member) => member.id)
@@ -305,7 +307,7 @@ const NewRoutingManager = ({
booking, booking,
currentResponse, currentResponse,
searchParams: new URLSearchParams({ searchParams: new URLSearchParams({
// rescheduleReason rescheduledBy: session?.data?.user?.email ?? "",
}), }),
}); });
@@ -469,6 +471,7 @@ const NewRoutingManager = ({
// TODO: Long term, we should refactor handleNewBooking and use a different route specific for this purpose, // TODO: Long term, we should refactor handleNewBooking and use a different route specific for this purpose,
createBookingMutation.mutate({ createBookingMutation.mutate({
rescheduleUid: booking.uid, rescheduleUid: booking.uid,
rescheduledBy: session?.data?.user?.email ?? undefined,
// rescheduleReason, // rescheduleReason,
reroutingFormResponses: reroutingFormResponses, reroutingFormResponses: reroutingFormResponses,
...getTimeslotFields(), ...getTimeslotFields(),
@@ -1,5 +1,7 @@
import { TooltipProvider } from "@radix-ui/react-tooltip"; import { TooltipProvider } from "@radix-ui/react-tooltip";
import { act, fireEvent, render, screen } from "@testing-library/react"; import { act, fireEvent, render, screen } from "@testing-library/react";
import type { Session } from "next-auth";
import { SessionProvider } from "next-auth/react";
import { vi } from "vitest"; import { vi } from "vitest";
import { RouteActionType } from "@calcom/app-store/routing-forms/zod"; import { RouteActionType } from "@calcom/app-store/routing-forms/zod";
@@ -37,6 +39,15 @@ const mockOpen = vi.fn((_url: string) => {
vi.stubGlobal("open", mockOpen); vi.stubGlobal("open", mockOpen);
const mockSession = {
expires: new Date(Date.now() + 2 * 86400).toISOString(),
user: {
id: 1,
name: "Test User",
email: "user@example.com",
},
} as Session;
vi.mock("@calcom/app-store/routing-forms/components/FormInputFields", () => ({ vi.mock("@calcom/app-store/routing-forms/components/FormInputFields", () => ({
default: vi.fn(({ response, form, setResponse, disabledFields }) => { default: vi.fn(({ response, form, setResponse, disabledFields }) => {
return ( return (
@@ -404,9 +415,11 @@ describe("RerouteDialog", () => {
describe("New Routing tests", () => { describe("New Routing tests", () => {
test("when verify_new_route is clicked, the form is submitted", async () => { test("when verify_new_route is clicked, the form is submitted", async () => {
render( render(
<TooltipProvider> <SessionProvider session={mockSession}>
<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} /> <TooltipProvider>
</TooltipProvider> <RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />
</TooltipProvider>
</SessionProvider>
); );
fireEvent.click(screen.getByText("verify_new_route")); fireEvent.click(screen.getByText("verify_new_route"));
@@ -425,9 +438,11 @@ describe("RerouteDialog", () => {
describe("New tab rescheduling", () => { describe("New tab rescheduling", () => {
test("new tab is closed when new booking is rerouted", async () => { test("new tab is closed when new booking is rerouted", async () => {
render( render(
<TooltipProvider> <SessionProvider session={mockSession}>
<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} /> <TooltipProvider>
</TooltipProvider> <RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />
</TooltipProvider>
</SessionProvider>
); );
clickVerifyNewRouteButton(); clickVerifyNewRouteButton();
clickRescheduleToTheNewEventWithDifferentTimeslotButton(); clickRescheduleToTheNewEventWithDifferentTimeslotButton();
@@ -466,9 +481,11 @@ describe("RerouteDialog", () => {
test("Rescheduling with same timeslot works", async () => { test("Rescheduling with same timeslot works", async () => {
render( render(
<TooltipProvider> <SessionProvider session={mockSession}>
<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} /> <TooltipProvider>
</TooltipProvider> <RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />
</TooltipProvider>
</SessionProvider>
); );
clickVerifyNewRouteButton(); clickVerifyNewRouteButton();
clickRescheduleWithSameTimeslotOfChosenEventButton(); clickRescheduleWithSameTimeslotOfChosenEventButton();
+1 -4
View File
@@ -3,7 +3,6 @@ import { stringify } from "querystring";
import logger from "@calcom/lib/logger"; import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify"; import { safeStringify } from "@calcom/lib/safeStringify";
import type { PrismaClient } from "@calcom/prisma";
import type { RedirectType } from "@calcom/prisma/client"; import type { RedirectType } from "@calcom/prisma/client";
const log = logger.getSubLogger({ prefix: ["lib", "getTemporaryOrgRedirect"] }); const log = logger.getSubLogger({ prefix: ["lib", "getTemporaryOrgRedirect"] });
@@ -12,15 +11,13 @@ export const getTemporaryOrgRedirect = async ({
redirectType, redirectType,
eventTypeSlug, eventTypeSlug,
currentQuery, currentQuery,
prismaClient,
}: { }: {
slugs: string[] | string; slugs: string[] | string;
redirectType: RedirectType; redirectType: RedirectType;
eventTypeSlug: string | null; eventTypeSlug: string | null;
currentQuery: ParsedUrlQuery; currentQuery: ParsedUrlQuery;
prismaClient?: PrismaClient;
}) => { }) => {
const prisma = prismaClient ?? (await import("@calcom/prisma")).default; const prisma = (await import("@calcom/prisma")).default;
slugs = slugs instanceof Array ? slugs : [slugs]; slugs = slugs instanceof Array ? slugs : [slugs];
log.debug( log.debug(
`Looking for redirect for`, `Looking for redirect for`,
@@ -2,11 +2,9 @@ import type { GetServerSidePropsContext } from "next";
import { z } from "zod"; import { z } from "zod";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
import { getBookingForReschedule } from "@calcom/features/bookings/lib/get-booking"; import { getBookingForReschedule } from "@calcom/features/bookings/lib/get-booking";
import { import { getSlugOrRequestedSlug, orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
orgDomainConfig,
whereClauseForOrgWithSlugOrRequestedSlug,
} from "@calcom/features/ee/organizations/lib/orgDomains";
import { getOrganizationSEOSettings } from "@calcom/features/ee/organizations/lib/orgSettings"; import { getOrganizationSEOSettings } from "@calcom/features/ee/organizations/lib/orgSettings";
import { FeaturesRepository } from "@calcom/features/flags/features.repository"; import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage"; import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
@@ -32,7 +30,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
const { req, params, query } = context; const { req, params, query } = context;
const session = await getServerSession({ req }); const session = await getServerSession({ req });
const { slug: teamSlug, type: meetingSlug } = paramsSchema.parse(params); const { slug: teamSlug, type: meetingSlug } = paramsSchema.parse(params);
const { rescheduleUid, isInstantMeeting: queryIsInstantMeeting } = query; const { rescheduleUid, isInstantMeeting: queryIsInstantMeeting, email } = query;
const allowRescheduleForCancelledBooking = query.allowRescheduleForCancelledBooking === "true"; const allowRescheduleForCancelledBooking = query.allowRescheduleForCancelledBooking === "true";
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(req, params?.orgSlug); const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(req, params?.orgSlug);
const isOrgContext = currentOrgDomain && isValidOrgDomain; const isOrgContext = currentOrgDomain && isValidOrgDomain;
@@ -43,7 +41,6 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
redirectType: RedirectType.Team, redirectType: RedirectType.Team,
eventTypeSlug: meetingSlug, eventTypeSlug: meetingSlug,
currentQuery: context.query, currentQuery: context.query,
prismaClient: prisma,
}); });
if (redirect) { if (redirect) {
@@ -51,49 +48,43 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
} }
} }
const [orgId, booking] = await Promise.all([ const team = await getTeamWithEventsData(teamSlug, meetingSlug, isValidOrgDomain, currentOrgDomain);
isOrgContext ? getOrgId(currentOrgDomain) : Promise.resolve(null),
rescheduleUid ? getBookingForReschedule(`${rescheduleUid}`, session?.user?.id) : Promise.resolve(null),
]);
const team = await getTeamData(teamSlug, orgId); if (!team || !team.eventTypes?.[0]) {
if (!team) {
return { notFound: true } as const; return { notFound: true } as const;
} }
const eventData = await getEventTypeData(meetingSlug, team.id); const eventData = team.eventTypes[0];
if (!eventData) {
return { notFound: true } as const;
}
if (rescheduleUid && eventData.disableRescheduling) { if (rescheduleUid && eventData.disableRescheduling) {
return { redirect: { destination: `/booking/${rescheduleUid}`, permanent: false } }; return { redirect: { destination: `/booking/${rescheduleUid}`, permanent: false } };
} }
if (
booking?.status === BookingStatus.CANCELLED &&
!allowRescheduleForCancelledBooking &&
!eventData.allowReschedulingCancelledBookings
) {
return {
redirect: {
permanent: false,
destination: `/team/${teamSlug}/${meetingSlug}`,
},
};
}
const eventTypeId = eventData.id; const eventTypeId = eventData.id;
const eventHostsUserData = await getUsersData(
team.isPrivate,
eventTypeId,
eventData.hosts.map((h) => h.user)
);
const orgSlug = isValidOrgDomain ? currentOrgDomain : null; const orgSlug = isValidOrgDomain ? currentOrgDomain : null;
const name = team.parent?.name ?? team.name ?? null; const name = team.parent?.name ?? team.name ?? null;
const eventHostsUserData = getEventHosts( let booking: GetBookingType | null = null;
team.isPrivate, if (rescheduleUid) {
eventData.hosts.map((h) => h.user), booking = await getBookingForReschedule(`${rescheduleUid}`, session?.user?.id);
eventData.users ?? [] if (
); booking?.status === BookingStatus.CANCELLED &&
!allowRescheduleForCancelledBooking &&
!eventData.allowReschedulingCancelledBookings
) {
return {
redirect: {
permanent: false,
destination: `/team/${teamSlug}/${meetingSlug}`,
},
};
}
}
const fromRedirectOfNonOrgLink = context.query.orgRedirection === "true"; const fromRedirectOfNonOrgLink = context.query.orgRedirection === "true";
const isUnpublished = team.parent ? !team.parent.slug : !team.slug; const isUnpublished = team.parent ? !team.parent.slug : !team.slug;
@@ -104,9 +95,11 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
// Handle string[] type from query params // Handle string[] type from query params
let teamMemberEmail = Array.isArray(crmContactOwnerEmail) ? crmContactOwnerEmail[0] : crmContactOwnerEmail; let teamMemberEmail = Array.isArray(crmContactOwnerEmail) ? crmContactOwnerEmail[0] : crmContactOwnerEmail;
let crmOwnerRecordType = Array.isArray(crmContactOwnerRecordType) let crmOwnerRecordType = Array.isArray(crmContactOwnerRecordType)
? crmContactOwnerRecordType[0] ? crmContactOwnerRecordType[0]
: crmContactOwnerRecordType; : crmContactOwnerRecordType;
let crmAppSlug = Array.isArray(crmAppSlugParam) ? crmAppSlugParam[0] : crmAppSlugParam; let crmAppSlug = Array.isArray(crmAppSlugParam) ? crmAppSlugParam[0] : crmAppSlugParam;
if (!teamMemberEmail || !crmOwnerRecordType || !crmAppSlug) { if (!teamMemberEmail || !crmOwnerRecordType || !crmAppSlug) {
@@ -129,6 +122,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
const organizationSettings = getOrganizationSEOSettings(team); const organizationSettings = getOrganizationSEOSettings(team);
const allowSEOIndexing = organizationSettings?.allowSEOIndexing ?? false; const allowSEOIndexing = organizationSettings?.allowSEOIndexing ?? false;
const featureRepo = new FeaturesRepository(); const featureRepo = new FeaturesRepository();
const teamHasApiV2Route = await featureRepo.checkIfTeamHasFeature(team.id, "use-api-v2-for-team-slots"); const teamHasApiV2Route = await featureRepo.checkIfTeamHasFeature(team.id, "use-api-v2-for-team-slots");
const useApiV2 = teamHasApiV2Route && hasApiV2RouteInEnv(); const useApiV2 = teamHasApiV2Route && hasApiV2RouteInEnv();
@@ -178,149 +172,116 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
}; };
}; };
const getOrgId = async (orgSlug: string): Promise<number | null> => { const getTeamWithEventsData = async (
const org = await prisma.team.findFirst({ teamSlug: string,
where: whereClauseForOrgWithSlugOrRequestedSlug(orgSlug), meetingSlug: string,
select: { id: true }, isValidOrgDomain: boolean,
}); currentOrgDomain: string | null
) => {
return org?.id ?? null;
};
const getTeamData = async (teamSlug: string, orgId: number | null) => {
const teamSelectFields = {
id: true,
isPrivate: true,
hideBranding: true,
logoUrl: true,
name: true,
slug: true,
isOrganization: true,
parent: {
select: {
slug: true,
name: true,
bannerUrl: true,
logoUrl: true,
hideBranding: true,
organizationSettings: {
select: {
allowSEOIndexing: true,
},
},
},
},
organizationSettings: {
select: {
allowSEOIndexing: true,
},
},
};
if (orgId !== null) {
const publishedTeam = await prisma.team.findUnique({
where: {
slug_parentId: {
slug: teamSlug,
parentId: orgId,
},
isOrganization: false,
},
select: teamSelectFields,
});
if (publishedTeam) return publishedTeam;
}
return await prisma.team.findFirst({ return await prisma.team.findFirst({
where: { where: {
parentId: orgId ?? null, ...getSlugOrRequestedSlug(teamSlug),
isOrganization: false, parent: isValidOrgDomain && currentOrgDomain ? getSlugOrRequestedSlug(currentOrgDomain) : null,
OR: [
{ slug: teamSlug },
{
metadata: {
path: ["requestedSlug"],
equals: teamSlug,
},
},
],
}, },
select: teamSelectFields,
orderBy: { orderBy: {
slug: { sort: "asc", nulls: "last" }, slug: { sort: "asc", nulls: "last" },
}, },
});
};
const getEventTypeData = async (meetingSlug: string, teamId: number) => {
return await prisma.eventType.findUnique({
where: {
// Use the EventType_teamId_slug_key unique index
teamId_slug: {
teamId: teamId,
slug: meetingSlug,
},
},
select: { select: {
id: true, id: true,
title: true, isPrivate: true,
isInstantEvent: true, hideBranding: true,
schedulingType: true, parent: {
metadata: true,
length: true,
hidden: true,
disableCancelling: true,
disableRescheduling: true,
allowReschedulingCancelledBookings: true,
interfaceLanguage: true,
hosts: {
take: 3,
select: { select: {
user: { slug: true,
name: true,
bannerUrl: true,
logoUrl: true,
hideBranding: true,
organizationSettings: {
select: { select: {
name: true, allowSEOIndexing: true,
username: true,
}, },
}, },
}, },
}, },
// Include users for when hosts is empty logoUrl: true,
users: { name: true,
take: 1, slug: true,
eventTypes: {
where: {
slug: meetingSlug,
},
select: { select: {
username: true, id: true,
name: true, title: true,
isInstantEvent: true,
schedulingType: true,
metadata: true,
length: true,
hidden: true,
disableCancelling: true,
disableRescheduling: true,
allowReschedulingCancelledBookings: true,
interfaceLanguage: true,
hosts: {
take: 3,
select: {
user: {
select: {
name: true,
username: true,
email: true,
},
},
},
},
},
},
isOrganization: true,
organizationSettings: {
select: {
allowSEOIndexing: true,
}, },
}, },
}, },
}); });
}; };
const getEventHosts = ( const getUsersData = async (
isPrivateTeam: boolean, isPrivateTeam: boolean,
hosts: Pick<User, "username" | "name">[], eventTypeId: number,
users: Pick<User, "username" | "name">[] users: Pick<User, "username" | "name">[]
) => { ) => {
if (isPrivateTeam) { if (!isPrivateTeam && users.length > 0) {
return []; return users
}
if (hosts.length > 0) {
return hosts
.filter((user) => user.username) .filter((user) => user.username)
.map((user) => ({ .map((user) => ({
username: user.username ?? "", username: user.username ?? "",
name: user.name ?? "", name: user.name ?? "",
})); }));
} }
if (!isPrivateTeam && users.length === 0) {
if (users.length > 0) { const { users: data } = await prisma.eventType.findUniqueOrThrow({
return [ where: { id: eventTypeId },
{ select: {
username: users[0].username ?? "", users: {
name: users[0].name ?? "", take: 1,
select: {
username: true,
name: true,
},
},
}, },
]; });
return data.length > 0
? [
{
username: data[0].username ?? "",
name: data[0].name ?? "",
},
]
: [];
} }
return []; return [];
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useReactTable, getCoreRowModel, getSortedRowModel, createColumnHelper } from "@tanstack/react-table"; import { useReactTable, getCoreRowModel, getSortedRowModel, createColumnHelper } from "@tanstack/react-table";
import { useSearchParams } from "next/navigation";
import { useMemo, useRef } from "react"; import { useMemo, useRef } from "react";
import { WipeMyCalActionButton } from "@calcom/app-store/wipemycalother/components"; import { WipeMyCalActionButton } from "@calcom/app-store/wipemycalother/components";
@@ -44,34 +45,6 @@ type RecurringInfo = {
bookings: { [key: string]: Date[] }; bookings: { [key: string]: Date[] };
}; };
const tabs: (VerticalTabItemProps | HorizontalTabItemProps)[] = [
{
name: "upcoming",
href: "/bookings/upcoming",
"data-testid": "upcoming",
},
{
name: "unconfirmed",
href: "/bookings/unconfirmed",
"data-testid": "unconfirmed",
},
{
name: "recurring",
href: "/bookings/recurring",
"data-testid": "recurring",
},
{
name: "past",
href: "/bookings/past",
"data-testid": "past",
},
{
name: "cancelled",
href: "/bookings/cancelled",
"data-testid": "cancelled",
},
];
const descriptionByStatus: Record<BookingListingStatus, string> = { const descriptionByStatus: Record<BookingListingStatus, string> = {
upcoming: "upcoming_bookings", upcoming: "upcoming_bookings",
recurring: "recurring_bookings", recurring: "recurring_bookings",
@@ -107,6 +80,46 @@ function BookingsContent({ status }: BookingsProps) {
const { t } = useLocale(); const { t } = useLocale();
const user = useMeQuery().data; const user = useMeQuery().data;
const tableContainerRef = useRef<HTMLDivElement>(null); const tableContainerRef = useRef<HTMLDivElement>(null);
const searchParams = useSearchParams();
// Generate dynamic tabs that preserve query parameters
const tabs: (VerticalTabItemProps | HorizontalTabItemProps)[] = useMemo(() => {
const queryString = searchParams?.toString() || "";
const baseTabConfigs = [
{
name: "upcoming",
path: "/bookings/upcoming",
"data-testid": "upcoming",
},
{
name: "unconfirmed",
path: "/bookings/unconfirmed",
"data-testid": "unconfirmed",
},
{
name: "recurring",
path: "/bookings/recurring",
"data-testid": "recurring",
},
{
name: "past",
path: "/bookings/past",
"data-testid": "past",
},
{
name: "cancelled",
path: "/bookings/cancelled",
"data-testid": "cancelled",
},
];
return baseTabConfigs.map((tabConfig) => ({
name: tabConfig.name,
href: queryString ? `${tabConfig.path}?${queryString}` : tabConfig.path,
"data-testid": tabConfig["data-testid"],
}));
}, [searchParams?.toString()]);
const eventTypeIds = useFilterValue("eventTypeId", ZMultiSelectFilterValue)?.data as number[] | undefined; const eventTypeIds = useFilterValue("eventTypeId", ZMultiSelectFilterValue)?.data as number[] | undefined;
const teamIds = useFilterValue("teamId", ZMultiSelectFilterValue)?.data as number[] | undefined; const teamIds = useFilterValue("teamId", ZMultiSelectFilterValue)?.data as number[] | undefined;
@@ -315,17 +315,20 @@ export default function Success(props: PageProps) {
return t(`${titlePrefix}emailed_host_and_attendee${titleSuffix}`, { return t(`${titlePrefix}emailed_host_and_attendee${titleSuffix}`, {
host, host,
attendee, attendee,
interpolation: { escapeValue: false },
}); });
} }
if (isAttendee) { if (isAttendee) {
return t(`${titlePrefix}emailed_host_and_attendee${titleSuffix}`, { return t(`${titlePrefix}emailed_host_and_attendee${titleSuffix}`, {
host, host,
attendee, attendee,
interpolation: { escapeValue: false },
}); });
} }
return t(`${titlePrefix}emailed_host_and_attendee${titleSuffix}`, { return t(`${titlePrefix}emailed_host_and_attendee${titleSuffix}`, {
host, host,
attendee, attendee,
interpolation: { escapeValue: false },
}); });
} }
return t(`emailed_host_and_attendee${titleSuffix}`); return t(`emailed_host_and_attendee${titleSuffix}`);
@@ -546,7 +549,7 @@ export default function Success(props: PageProps) {
</h4> </h4>
)} )}
<div className="border-subtle text-default mt-8 grid grid-cols-3 border-t pt-8 text-left rtl:text-right"> <div className="border-subtle text-default mt-8 grid grid-cols-3 gap-x-4 border-t pt-8 text-left rtl:text-right sm:gap-x-0">
{(isCancelled || reschedule) && cancellationReason && ( {(isCancelled || reschedule) && cancellationReason && (
<> <>
<div className="font-medium"> <div className="font-medium">
@@ -3,22 +3,60 @@
import { ProgressBar } from "@tremor/react"; import { ProgressBar } from "@tremor/react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useState, useMemo } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import dayjs from "@calcom/dayjs";
import ServerTrans from "@calcom/lib/components/ServerTrans"; import ServerTrans from "@calcom/lib/components/ServerTrans";
import { IS_SMS_CREDITS_ENABLED } from "@calcom/lib/constants"; import { IS_SMS_CREDITS_ENABLED } from "@calcom/lib/constants";
import { downloadAsCsv } from "@calcom/lib/csvUtils";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useParamsWithFallback } from "@calcom/lib/hooks/useParamsWithFallback"; import { useParamsWithFallback } from "@calcom/lib/hooks/useParamsWithFallback";
import { trpc } from "@calcom/trpc/react"; import { trpc } from "@calcom/trpc/react";
import { Button } from "@calcom/ui/components/button"; import { Button } from "@calcom/ui/components/button";
import { Select } from "@calcom/ui/components/form";
import { TextField, Label, InputError } from "@calcom/ui/components/form"; import { TextField, Label, InputError } from "@calcom/ui/components/form";
import { showToast } from "@calcom/ui/toast"; import { showToast } from "@calcom/ui/toast";
import { BillingCreditsSkeleton } from "./BillingCreditsSkeleton"; import { BillingCreditsSkeleton } from "./BillingCreditsSkeleton";
type MonthOption = {
value: string;
label: string;
startDate: string;
endDate: string;
};
// returns the last 12 months starting from May 2025 (when credits were introduced)
const getMonthOptions = (): MonthOption[] => {
const options: MonthOption[] = [];
const minDate = dayjs.utc("2025-05-01");
let date = dayjs.utc();
let count = 0;
while ((date.isAfter(minDate) || date.isSame(minDate, "month")) && count < 12) {
const startDate = date.startOf("month");
const endDate = date.endOf("month");
options.push({
value: date.format("MMMM YYYY"),
label: date.format("MMMM YYYY"),
startDate: startDate.toISOString(),
endDate: endDate.toISOString(),
});
date = date.subtract(1, "month");
count++;
}
return options;
};
export default function BillingCredits() { export default function BillingCredits() {
const { t } = useLocale(); const { t } = useLocale();
const router = useRouter(); const router = useRouter();
const monthOptions = useMemo(() => getMonthOptions(), []);
const [selectedMonth, setSelectedMonth] = useState<MonthOption>(monthOptions[0]);
const [isDownloading, setIsDownloading] = useState(false);
const utils = trpc.useUtils();
const { const {
register, register,
@@ -28,8 +66,8 @@ export default function BillingCredits() {
} = useForm<{ quantity: number }>({ defaultValues: { quantity: 50 } }); } = useForm<{ quantity: number }>({ defaultValues: { quantity: 50 } });
const params = useParamsWithFallback(); const params = useParamsWithFallback();
const teamId = params.id ? Number(params.id) : undefined; const teamId = params.id ? Number(params.id) : undefined;
const { data: creditsData, isLoading } = trpc.viewer.credits.getAllCredits.useQuery({ teamId }); const { data: creditsData, isLoading } = trpc.viewer.credits.getAllCredits.useQuery({ teamId });
const buyCreditsMutation = trpc.viewer.credits.buyCredits.useMutation({ const buyCreditsMutation = trpc.viewer.credits.buyCredits.useMutation({
@@ -43,6 +81,27 @@ export default function BillingCredits() {
}, },
}); });
const handleDownload = async () => {
setIsDownloading(true);
try {
const result = await utils.viewer.credits.downloadExpenseLog.fetch({
teamId,
startDate: selectedMonth.startDate,
endDate: selectedMonth.endDate,
});
if (result?.csvData) {
const filename = `credit-expense-log-${selectedMonth.value.toLowerCase().replace(" ", "-")}.csv`;
downloadAsCsv(result.csvData, filename);
} else {
showToast(t("error_downloading_expense_log"), "error");
}
} catch (error) {
showToast(t("error_downloading_expense_log"), "error");
} finally {
setIsDownloading(false);
}
};
if (!IS_SMS_CREDITS_ENABLED) { if (!IS_SMS_CREDITS_ENABLED) {
return null; return null;
} }
@@ -112,8 +171,8 @@ export default function BillingCredits() {
<hr className="border-subtle mb-3 mt-3" /> <hr className="border-subtle mb-3 mt-3" />
</div> </div>
<form onSubmit={handleSubmit(onSubmit)} className="flex"> <form onSubmit={handleSubmit(onSubmit)} className="flex">
<div className="mr-auto"> <div className="-mb-1 mr-auto">
<div className="mb-2 font-semibold">{t("buy_additional_credits")}</div> <Label>{t("buy_additional_credits")}</Label>
<div className="flex flex-col"> <div className="flex flex-col">
<TextField <TextField
required required
@@ -132,12 +191,32 @@ export default function BillingCredits() {
{errors.quantity && <InputError message={errors.quantity.message ?? t("invalid_input")} />} {errors.quantity && <InputError message={errors.quantity.message ?? t("invalid_input")} />}
</div> </div>
</div> </div>
<div className="mb-1 mt-auto"> <div className="mt-auto">
<Button color="primary" target="_blank" EndIcon="external-link" type="submit"> <Button color="primary" target="_blank" EndIcon="external-link" type="submit">
{t("buy_credits")} {t("buy_credits")}
</Button> </Button>
</div> </div>
</form> </form>
<div className="-mx-6 mb-6 mt-6">
<hr className="border-subtle mb-3 mt-3" />
</div>
<div className="flex">
<div className="mr-auto ">
<Label className="mb-4">{t("download_expense_log")}</Label>
<div className="mt-2 flex flex-col">
<Select
options={monthOptions}
value={selectedMonth}
onChange={(option) => option && setSelectedMonth(option)}
/>
</div>
</div>
<div className="mt-auto">
<Button onClick={handleDownload} loading={isDownloading} StartIcon="file-down">
{t("download")}
</Button>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -45,7 +45,7 @@ export function BillingCreditsSkeleton() {
<div className="flex"> <div className="flex">
<div className="mr-auto"> <div className="mr-auto">
<div> <div>
<SkeletonText className="mb-2 mt-1 h-5 w-[268px]" /> {/* Buy credits label */} <SkeletonText className="mb-2 h-4 w-[268px]" /> {/* Buy credits label */}
</div> </div>
<div> <div>
<SkeletonText className="mt-2 h-8 w-[240px] rounded-md" /> {/* Input field */} <SkeletonText className="mt-2 h-8 w-[240px] rounded-md" /> {/* Input field */}
@@ -56,6 +56,24 @@ export function BillingCreditsSkeleton() {
<SkeletonButton className="h-9 w-[120px] rounded-md" /> {/* Buy button */} <SkeletonButton className="h-9 w-[120px] rounded-md" /> {/* Buy button */}
</div> </div>
</div> </div>
<div className="-mx-6 mb-6 mt-6">
<hr className="border-subtle mb-3 mt-3" />
</div>
{/* Download expense log */}
<div className="flex">
<div className="mr-auto mt-1">
<div>
<SkeletonText className="mb-2 h-4 w-[156px]" /> {/* Label */}
</div>
<div>
<SkeletonText className="mt-2 h-8 w-[156px] rounded-md" /> {/* Select */}
</div>
</div>
<div className="mt-auto ">
<SkeletonButton className="h-9 w-[109px] rounded-md" /> {/* Download button */}
</div>
</div>
</div> </div>
</div> </div>
</SkeletonContainer> </SkeletonContainer>
@@ -9,14 +9,14 @@ import { useOnboarding } from "@calcom/features/ee/organizations/lib/onboardingS
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterOutputs } from "@calcom/trpc"; import type { RouterOutputs } from "@calcom/trpc";
import { trpc } from "@calcom/trpc"; import { trpc } from "@calcom/trpc";
import { Badge } from "@calcom/ui/components/badge";
import { Tooltip } from "@calcom/ui/components/tooltip";
import { TextField } from "@calcom/ui/components/form";
import { Alert } from "@calcom/ui/components/alert"; import { Alert } from "@calcom/ui/components/alert";
import { Avatar } from "@calcom/ui/components/avatar"; import { Avatar } from "@calcom/ui/components/avatar";
import { Badge } from "@calcom/ui/components/badge";
import { Button } from "@calcom/ui/components/button"; import { Button } from "@calcom/ui/components/button";
import { TextField } from "@calcom/ui/components/form";
import { SkeletonButton, SkeletonContainer, SkeletonText } from "@calcom/ui/components/skeleton"; import { SkeletonButton, SkeletonContainer, SkeletonText } from "@calcom/ui/components/skeleton";
import { showToast } from "@calcom/ui/components/toast"; import { showToast } from "@calcom/ui/components/toast";
import { Tooltip } from "@calcom/ui/components/tooltip";
type TeamMember = RouterOutputs["viewer"]["teams"]["listMembers"]["members"][number]; type TeamMember = RouterOutputs["viewer"]["teams"]["listMembers"]["members"][number];
@@ -29,10 +29,15 @@ const AddNewTeamMembers = () => {
return <AddNewTeamMembersForm />; return <AddNewTeamMembersForm />;
}; };
const useCheckout = () => { const useOrgCreation = () => {
const { t } = useLocale(); const { t } = useLocale();
const session = useSession();
const utils = trpc.useUtils();
const [serverErrorMessage, setServerErrorMessage] = useState(""); const [serverErrorMessage, setServerErrorMessage] = useState("");
const mutation = trpc.viewer.organizations.createWithPaymentIntent.useMutation({ const { useOnboardingStore, isBillingEnabled } = useOnboarding();
const { reset } = useOnboardingStore();
const checkoutMutation = trpc.viewer.organizations.createWithPaymentIntent.useMutation({
onSuccess: (data) => { onSuccess: (data) => {
if (data.checkoutUrl) { if (data.checkoutUrl) {
window.location.href = data.checkoutUrl; window.location.href = data.checkoutUrl;
@@ -43,17 +48,34 @@ const useCheckout = () => {
}, },
}); });
const createOrgMutation = trpc.viewer.organizations.createSelfHosted.useMutation({
onSuccess: async (data) => {
if (data.organization) {
// Invalidate the organizations query to ensure fresh data on the next page
await utils.viewer.organizations.listCurrent.invalidate();
await session.update();
reset();
window.location.href = `${window.location.origin}/settings/organizations/profile`;
}
},
onError: (error) => {
setServerErrorMessage(t(error.message));
},
});
const mutationToUse = isBillingEnabled ? checkoutMutation : createOrgMutation;
return { return {
mutation, mutation: mutationToUse,
mutate: mutation.mutate, mutate: mutationToUse.mutate,
isPending: mutation.isPending, isPending: mutationToUse.isPending,
errorMessage: serverErrorMessage, errorMessage: serverErrorMessage,
}; };
}; };
export const AddNewTeamMembersForm = () => { export const AddNewTeamMembersForm = () => {
const { t } = useLocale(); const { t } = useLocale();
const { useOnboardingStore } = useOnboarding(); const { useOnboardingStore, isBillingEnabled } = useOnboarding();
const { const {
addInvitedMember, addInvitedMember,
removeInvitedMember, removeInvitedMember,
@@ -64,7 +86,7 @@ export const AddNewTeamMembersForm = () => {
bio, bio,
onboardingId, onboardingId,
} = useOnboardingStore(); } = useOnboardingStore();
const checkout = useCheckout(); const orgCreation = useOrgCreation();
const teamIds = teams.filter((team) => team.isBeingMigrated && team.id > 0).map((team) => team.id); const teamIds = teams.filter((team) => team.isBeingMigrated && team.id > 0).map((team) => team.id);
@@ -117,9 +139,9 @@ export const AddNewTeamMembersForm = () => {
return ( return (
<> <>
{checkout.errorMessage && ( {orgCreation.errorMessage && (
<div className="mb-4"> <div className="mb-4">
<Alert severity="error" message={checkout.errorMessage} /> <Alert severity="error" message={orgCreation.errorMessage} />
</div> </div>
)} )}
<div className="space-y-6"> <div className="space-y-6">
@@ -180,6 +202,7 @@ export const AddNewTeamMembersForm = () => {
</ul> </ul>
)} )}
</div> </div>
<div className="mt-3 mt-6 flex items-center justify-end"> <div className="mt-3 mt-6 flex items-center justify-end">
<Button <Button
onClick={() => { onClick={() => {
@@ -190,7 +213,7 @@ export const AddNewTeamMembersForm = () => {
}); });
return; return;
} }
checkout.mutation.mutate({ orgCreation.mutation.mutate({
logo, logo,
bio, bio,
teams, teams,
@@ -198,8 +221,8 @@ export const AddNewTeamMembersForm = () => {
onboardingId, onboardingId,
}); });
}} }}
loading={checkout.isPending}> loading={orgCreation.isPending}>
{t("checkout")} {isBillingEnabled ? t("checkout") : t("create")}
</Button> </Button>
</div> </div>
</> </>
+16 -18
View File
@@ -691,25 +691,23 @@ export default function Signup({
/> />
</div> </div>
<div className="mr-12 mt-8 hidden h-full w-full grid-cols-3 gap-4 overflow-hidden lg:grid"> <div className="mr-12 mt-8 hidden h-full w-full grid-cols-3 gap-4 overflow-hidden lg:grid">
{FEATURES.map((feature) => ( {FEATURES.map((feature, index) => (
<> <div key={index} className="max-w-52 mb-8 flex flex-col leading-none sm:mb-0">
<div className="max-w-52 mb-8 flex flex-col leading-none sm:mb-0"> <div className="text-emphasis items-center">
<div className="text-emphasis items-center"> <Icon name={feature.icon} className="mb-1 h-4 w-4" />
<Icon name={feature.icon} className="mb-1 h-4 w-4" /> <span className="text-sm font-medium">{t(feature.title)}</span>
<span className="text-sm font-medium">{t(feature.title)}</span>
</div>
<div className="text-subtle text-sm">
<p>
{t(
feature.description,
feature.i18nOptions && {
...feature.i18nOptions,
}
)}
</p>
</div>
</div> </div>
</> <div className="text-subtle text-sm">
<p>
{t(
feature.description,
feature.i18nOptions && {
...feature.i18nOptions,
}
)}
</p>
</div>
</div>
))} ))}
</div> </div>
</div> </div>
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@calcom/web", "name": "@calcom/web",
"version": "5.4.4", "version": "5.4.9",
"private": true, "private": true,
"scripts": { "scripts": {
"analyze": "ANALYZE=true next build", "analyze": "ANALYZE=true next build",
@@ -194,7 +194,7 @@
"tailwindcss": "^3.3.3", "tailwindcss": "^3.3.3",
"tailwindcss-animate": "^1.0.6", "tailwindcss-animate": "^1.0.6",
"ts-node": "^10.9.1", "ts-node": "^10.9.1",
"typescript": "^5.8.3" "typescript": "^5.7.2"
}, },
"nextBundleAnalysis": { "nextBundleAnalysis": {
"budget": 358400, "budget": 358400,
+3 -2
View File
@@ -121,7 +121,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!orgId) { if (!orgId) {
orgId = await getOrgIdFromMemberOrTeamId({ orgId = await getOrgIdFromMemberOrTeamId({
memberId: parsedUserId, ...(!parsedTeamId ? { memberId: parsedUserId } : {}),
teamId: parsedTeamId, teamId: parsedTeamId,
}); });
} }
@@ -137,7 +137,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
return res.status(200).send(`SMS are free for organizations. Credits set to 0`); return res.status(200).send(`SMS are free for organizations. Credits set to 0`);
} }
const price = await twilio.getPriceForSMS(smsSid); const { price, numSegments } = await twilio.getMessageInfo(smsSid);
const credits = price ? creditService.calculateCreditsFromPrice(price) : null; const credits = price ? creditService.calculateCreditsFromPrice(price) : null;
@@ -147,6 +147,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
userId: parsedUserId, userId: parsedUserId,
smsSid, smsSid,
bookingUid: parsedBookingUid, bookingUid: parsedBookingUid,
smsSegments: numSegments ?? undefined,
}); });
if (chargedUserOrTeamId) { if (chargedUserOrTeamId) {
+5 -5
View File
@@ -7,7 +7,7 @@ import { randomString } from "@calcom/lib/random";
import { test } from "./lib/fixtures"; import { test } from "./lib/fixtures";
import { import {
bookTimeSlot, bookTimeSlot,
createNewEventType, createNewUserEventType,
gotoBookingPage, gotoBookingPage,
gotoFirstEventType, gotoFirstEventType,
saveEventType, saveEventType,
@@ -55,7 +55,7 @@ test.describe("Event Types tests", () => {
test("can add new event type", async ({ page }) => { test("can add new event type", async ({ page }) => {
const nonce = randomString(3); const nonce = randomString(3);
const eventTitle = `hello ${nonce}`; const eventTitle = `hello ${nonce}`;
await createNewEventType(page, { eventTitle }); await createNewUserEventType(page, { eventTitle });
await page.goto("/event-types"); await page.goto("/event-types");
await expect(page.locator(`text='${eventTitle}'`)).toBeVisible(); await expect(page.locator(`text='${eventTitle}'`)).toBeVisible();
}); });
@@ -63,7 +63,7 @@ test.describe("Event Types tests", () => {
test("new event type appears first in the list", async ({ page }) => { test("new event type appears first in the list", async ({ page }) => {
const nonce = randomString(3); const nonce = randomString(3);
const eventTitle = `hello ${nonce}`; const eventTitle = `hello ${nonce}`;
await createNewEventType(page, { eventTitle }); await createNewUserEventType(page, { eventTitle });
await page.goto("/event-types"); await page.goto("/event-types");
const firstEvent = page.locator("[data-testid=event-types] > li a").first(); const firstEvent = page.locator("[data-testid=event-types] > li a").first();
const firstEventTitle = await firstEvent.getAttribute("title"); const firstEventTitle = await firstEvent.getAttribute("title");
@@ -73,7 +73,7 @@ test.describe("Event Types tests", () => {
test("enabling recurring event comes with default options", async ({ page }) => { test("enabling recurring event comes with default options", async ({ page }) => {
const nonce = randomString(3); const nonce = randomString(3);
const eventTitle = `my recurring event ${nonce}`; const eventTitle = `my recurring event ${nonce}`;
await createNewEventType(page, { eventTitle }); await createNewUserEventType(page, { eventTitle });
// fix the race condition // fix the race condition
await page.waitForSelector('[data-testid="event-title"]'); await page.waitForSelector('[data-testid="event-title"]');
@@ -393,7 +393,7 @@ test.describe("Event Types tests", () => {
}) => { }) => {
const nonce = randomString(3); const nonce = randomString(3);
const eventTitle = `Conflict event ${nonce}`; const eventTitle = `Conflict event ${nonce}`;
await createNewEventType(page, { eventTitle }); await createNewUserEventType(page, { eventTitle });
await page.goto("/event-types"); await page.goto("/event-types");
await page.click(`text=${eventTitle}`); await page.click(`text=${eventTitle}`);
@@ -0,0 +1,45 @@
import { expect } from "@playwright/test";
import { loginUser } from "playwright/fixtures/regularBookings";
import { MembershipRole } from "@calcom/prisma/enums";
import { test } from "../lib/fixtures";
test.describe.configure({ mode: "parallel" });
test.describe("AI Translation - Event Type", () => {
test("Toggle should not be clickable for free plan members", async ({ page, users, bookingPage }) => {
await loginUser(users);
await page.goto("/event-types");
await bookingPage.goToEventType("30 min");
expect(await bookingPage.getAITranslationToggleDisabled()).toBe(true);
});
test("Toggle should not be clickable for team plan members", async ({ page, users, bookingPage }) => {
const user = await users.create(
{ name: "testuser" },
{
hasTeam: true,
teamRole: MembershipRole.MEMBER,
}
);
await user.apiLogin();
await page.goto("/event-types");
await bookingPage.goToEventType("30 min");
expect(await bookingPage.getAITranslationToggleDisabled()).toBe(true);
});
test("Toggle should be clickable for org members", async ({ page, orgs, users, bookingPage }) => {
const org = await orgs.create({
name: "acme",
});
const user = await users.create({
organizationId: org.id,
roleInOrganization: MembershipRole.MEMBER,
});
await user.apiLogin();
await page.goto("/event-types");
await bookingPage.goToEventType("30 min");
expect(await bookingPage.getAITranslationToggleDisabled()).toBe(false);
});
});
@@ -198,5 +198,19 @@ export function createBookingPageFixture(page: Page) {
checkTimeSlotsCount: async (eventTypePage: Page, count: number) => { checkTimeSlotsCount: async (eventTypePage: Page, count: number) => {
await expect(eventTypePage.getByTestId("time")).toHaveCount(count); await expect(eventTypePage.getByTestId("time")).toHaveCount(count);
}, },
getAITranslationToggleDisabled: async () => {
await page.waitForSelector('[data-testid="ai_translation_toggle"]', {
timeout: 5000,
state: "attached",
});
const toggle = page.getByTestId("ai_translation_toggle");
return (await toggle.getAttribute("disabled")) !== null;
},
toggleAITranslation: async () => {
await page.getByTestId("ai_translation_toggle").click();
},
updateEventTypeDescription: async (description: string) => {
await page.getByTestId("editor-input").fill(description);
},
}; };
} }
+1 -1
View File
@@ -395,7 +395,7 @@ export const createUsersFixture = (
}, },
{ {
id: "a8ba9aab-4567-489a-bcde-f1823f71b4ad", id: "a8ba9aab-4567-489a-bcde-f1823f71b4ad",
action: { type: "externalRedirectUrl", value: "https://cal.com/peer" }, action: { type: "externalRedirectUrl", value: `${WEBAPP_URL}/pro` },
queryValue: { queryValue: {
id: "a8ba9aab-4567-489a-bcde-f1823f71b4ad", id: "a8ba9aab-4567-489a-bcde-f1823f71b4ad",
type: "group", type: "group",
+31 -4
View File
@@ -7,12 +7,14 @@ import { createServer } from "http";
// eslint-disable-next-line no-restricted-imports // eslint-disable-next-line no-restricted-imports
import type { Messages } from "mailhog"; import type { Messages } from "mailhog";
import { totp } from "otplib"; import { totp } from "otplib";
import { v4 as uuid } from "uuid";
import type { IntervalLimit } from "@calcom/lib/intervalLimits/intervalLimitSchema"; import type { IntervalLimit } from "@calcom/lib/intervalLimits/intervalLimitSchema";
import type { Prisma } from "@calcom/prisma/client"; import type { Prisma } from "@calcom/prisma/client";
import { BookingStatus, SchedulingType } from "@calcom/prisma/enums"; import { BookingStatus, SchedulingType } from "@calcom/prisma/enums";
import type { createEmailsFixture } from "../fixtures/emails"; import type { createEmailsFixture } from "../fixtures/emails";
import type { CreateUsersFixture } from "../fixtures/users";
import type { Fixtures } from "./fixtures"; import type { Fixtures } from "./fixtures";
type Request = IncomingMessage & { body?: unknown }; type Request = IncomingMessage & { body?: unknown };
@@ -177,10 +179,12 @@ export async function expectSlotNotAllowedToBook(page: Page) {
await expect(page.locator("[data-testid=slot-not-allowed-to-book]")).toBeVisible(); await expect(page.locator("[data-testid=slot-not-allowed-to-book]")).toBeVisible();
} }
export const createNewEventType = async (page: Page, args: { eventTitle: string }) => { export const createNewUserEventType = async (page: Page, args: { eventTitle: string; username?: string }) => {
await page.click("[data-testid=new-event-type]"); await page.click("[data-testid=new-event-type]");
const eventTitle = args.eventTitle; if (args.username) {
await page.fill("[name=title]", eventTitle); await page.getByRole("button", { name: args.username }).click();
}
await page.fill("[name=title]", args.eventTitle);
await page.fill("[name=length]", "10"); await page.fill("[name=length]", "10");
await page.click("[type=submit]"); await page.click("[type=submit]");
@@ -216,7 +220,7 @@ export async function setupManagedEvent({
export const createNewSeatedEventType = async (page: Page, args: { eventTitle: string }) => { export const createNewSeatedEventType = async (page: Page, args: { eventTitle: string }) => {
const eventTitle = args.eventTitle; const eventTitle = args.eventTitle;
await createNewEventType(page, { eventTitle }); await createNewUserEventType(page, { eventTitle });
await page.waitForSelector('[data-testid="event-title"]'); await page.waitForSelector('[data-testid="event-title"]');
await expect(page.getByTestId("vertical-tab-event_setup_tab_title")).toHaveAttribute( await expect(page.getByTestId("vertical-tab-event_setup_tab_title")).toHaveAttribute(
"aria-current", "aria-current",
@@ -563,3 +567,26 @@ export async function expectPageToBeNotFound({ page, url }: { page: Page; url: s
await page.goto(`${url}`); await page.goto(`${url}`);
await expect(page.getByTestId(`404-page`)).toBeVisible(); await expect(page.getByTestId(`404-page`)).toBeVisible();
} }
export async function setupOrgMember(users: CreateUsersFixture) {
const orgRequestedSlug = `example-${uuid()}`;
const orgMember = await users.create(undefined, {
hasTeam: true,
isOrg: true,
hasSubteam: true,
isOrgVerified: true,
isDnsSetup: true,
orgRequestedSlug,
schedulingType: SchedulingType.ROUND_ROBIN,
});
const { team: org } = await orgMember.getOrgMembership();
const { team } = await orgMember.getFirstTeamMembership();
const teamEvent = await orgMember.getFirstTeamEvent(team.id);
const userEvent = orgMember.eventTypes[0];
await orgMember.apiLogin();
return { orgMember, org, team, teamEvent, userEvent };
}
@@ -12,7 +12,7 @@ import type { CalendarEvent } from "@calcom/types/Calendar";
import { test } from "./lib/fixtures"; import { test } from "./lib/fixtures";
import { import {
createHttpServer, createHttpServer,
createNewEventType, createNewUserEventType,
selectFirstAvailableTimeSlotNextMonth, selectFirstAvailableTimeSlotNextMonth,
submitAndWaitForResponse, submitAndWaitForResponse,
} from "./lib/testUtils"; } from "./lib/testUtils";
@@ -752,7 +752,7 @@ test.describe("Text area min and max characters text", () => {
// We wait until loading is finished // We wait until loading is finished
await page.waitForSelector('[data-testid="event-types"]'); await page.waitForSelector('[data-testid="event-types"]');
await createNewEventType(page, { eventTitle }); await createNewUserEventType(page, { eventTitle });
await page.waitForSelector('[data-testid="event-title"]'); await page.waitForSelector('[data-testid="event-title"]');
await expect(page.getByTestId("vertical-tab-event_setup_tab_title")).toContainText("Event Setup"); //fix the race condition await expect(page.getByTestId("vertical-tab-event_setup_tab_title")).toContainText("Event Setup"); //fix the race condition
await expect(page.getByTestId("vertical-tab-event_setup_tab_title")).toHaveAttribute( await expect(page.getByTestId("vertical-tab-event_setup_tab_title")).toHaveAttribute(
@@ -134,7 +134,7 @@ test.describe("Organization", () => {
await test.step("Basic info", async () => { await test.step("Basic info", async () => {
// Check required fields // Check required fields
await page.locator("button[type=submit]").click(); await page.locator("button[type=submit]").click();
await expect(page.getByTestId("field-error")).toHaveCount(3); await expect(page.getByTestId("field-error")).toHaveCount(2);
// Happy path // Happy path
await fillAndSubmitFirstStepAsAdmin(page, orgOwnerEmail, orgName, orgSlug); await fillAndSubmitFirstStepAsAdmin(page, orgOwnerEmail, orgName, orgSlug);
@@ -168,7 +168,7 @@ test.describe("Organization", () => {
await test.step("Basic info", async () => { await test.step("Basic info", async () => {
// Check required fields // Check required fields
await page.locator("button[type=submit]").click(); await page.locator("button[type=submit]").click();
await expect(page.getByTestId("field-error")).toHaveCount(3); await expect(page.getByTestId("field-error")).toHaveCount(2);
// Happy path // Happy path
await fillAndSubmitFirstStepAsAdmin(page, orgOwnerEmail, orgName, orgSlug); await fillAndSubmitFirstStepAsAdmin(page, orgOwnerEmail, orgName, orgSlug);
@@ -1,36 +1,9 @@
import { expect } from "@playwright/test"; import { expect } from "@playwright/test";
import type { Page } from "@playwright/test"; import type { Page } from "@playwright/test";
import { doOnOrgDomain } from "playwright/lib/testUtils"; import { doOnOrgDomain, setupOrgMember } from "playwright/lib/testUtils";
import { v4 as uuid } from "uuid";
import { SchedulingType } from "@calcom/prisma/enums";
import type { CreateUsersFixture } from "../fixtures/users";
import { test } from "../lib/fixtures"; import { test } from "../lib/fixtures";
async function setupOrgMember(users: CreateUsersFixture) {
const orgRequestedSlug = `example-${uuid()}`;
const orgMember = await users.create(undefined, {
hasTeam: true,
isOrg: true,
hasSubteam: true,
isOrgVerified: true,
isDnsSetup: true,
orgRequestedSlug,
schedulingType: SchedulingType.ROUND_ROBIN,
});
const { team: org } = await orgMember.getOrgMembership();
const { team } = await orgMember.getFirstTeamMembership();
const teamEvent = await orgMember.getFirstTeamEvent(team.id);
const userEvent = orgMember.eventTypes[0];
await orgMember.apiLogin();
return { orgMember, org, team, teamEvent, userEvent };
}
type TestContext = Awaited<ReturnType<typeof setupOrgMember>>; type TestContext = Awaited<ReturnType<typeof setupOrgMember>>;
interface ToggleSeoSwitchParams { interface ToggleSeoSwitchParams {
+16 -9
View File
@@ -267,6 +267,8 @@
"guests": "Guests", "guests": "Guests",
"guest": "Guest", "guest": "Guest",
"web_conferencing_details_to_follow": "Web conferencing details to follow in the confirmation email.", "web_conferencing_details_to_follow": "Web conferencing details to follow in the confirmation email.",
"confirmation":"Confirmation",
"what_booker_should_provide": "What your booker should provide to receive confirmations",
"404_the_user": "The username", "404_the_user": "The username",
"username": "Username", "username": "Username",
"is_still_available": "is still available.", "is_still_available": "is still available.",
@@ -1710,6 +1712,8 @@
"show_available_seats_count": "Show the number of available seats", "show_available_seats_count": "Show the number of available seats",
"how_booking_questions_as_variables": "How to use booking questions as variables?", "how_booking_questions_as_variables": "How to use booking questions as variables?",
"format": "Format", "format": "Format",
"questions": "Questions",
"all_info_your_booker_provide": "All the info your booker should provide before booking with you.",
"uppercase_for_letters": "Use uppercase for all letters", "uppercase_for_letters": "Use uppercase for all letters",
"replace_whitespaces_underscores": "Replace whitespaces with underscores", "replace_whitespaces_underscores": "Replace whitespaces with underscores",
"platform_members": "Platform members", "platform_members": "Platform members",
@@ -2835,7 +2839,7 @@
"salesforce_skip_entry_creation": "Skip creating {{entry}} record if they do not exist in Salesforce", "salesforce_skip_entry_creation": "Skip creating {{entry}} record if they do not exist in Salesforce",
"salesforce_if_account_does_not_exist": "If the contact does not exist under an account, create new lead from attendee", "salesforce_if_account_does_not_exist": "If the contact does not exist under an account, create new lead from attendee",
"salesforce_create_new_contact_under_account": "Create a new contact under an account based on email domain of attendee and existing contacts", "salesforce_create_new_contact_under_account": "Create a new contact under an account based on email domain of attendee and existing contacts",
"salesforce_change_record_owner_on_booking":"Change record owner on booking", "salesforce_change_record_owner_on_booking": "Change record owner on booking",
"mass_assign_attributes": "Mass assign attributes", "mass_assign_attributes": "Mass assign attributes",
"reroute_preview_custom_message": "It results in showing custom message. Try changing the response to route to an event", "reroute_preview_custom_message": "It results in showing custom message. Try changing the response to route to an event",
"reroute_preview_external_redirect": "It results in redirecting to {{externalUrl}}. Try changing the response to route to an event", "reroute_preview_external_redirect": "It results in redirecting to {{externalUrl}}. Try changing the response to route to an event",
@@ -2888,6 +2892,7 @@
"yes_reassign": "Yes, reassign", "yes_reassign": "Yes, reassign",
"filter_by_attributes": "Filter by attributes", "filter_by_attributes": "Filter by attributes",
"reassign_reason": "Reason for reassigning", "reassign_reason": "Reason for reassigning",
"field_type_change_suggestion": "Changing the field type may cause data loss. Please create a new field instead.",
"reassigned_by": "Reassigned by", "reassigned_by": "Reassigned by",
"row": "row", "row": "row",
"rows": "rows", "rows": "rows",
@@ -2933,7 +2938,7 @@
"routing_form_insights_assignment_reason": "Assignment Reason", "routing_form_insights_assignment_reason": "Assignment Reason",
"access_denied": "Access Denied", "access_denied": "Access Denied",
"salesforce_route_to_owner": "Contact owner will be the Round Robin host if available", "salesforce_route_to_owner": "Contact owner will be the Round Robin host if available",
"salesforce_add_attendees_as":"Add new attendees as", "salesforce_add_attendees_as": "Add new attendees as",
"salesforce_do_not_route_to_owner": "Contact owner will not be forced (can still be host if it matches the attributes and Round Robin criteria)", "salesforce_do_not_route_to_owner": "Contact owner will not be forced (can still be host if it matches the attributes and Round Robin criteria)",
"salesforce_route_to_custom_lookup_field": "Route to a user that matches a lookup field on an account", "salesforce_route_to_custom_lookup_field": "Route to a user that matches a lookup field on an account",
"salesforce_option": "Salesforce Option", "salesforce_option": "Salesforce Option",
@@ -2996,7 +3001,7 @@
"shortfall": "Shortfall", "shortfall": "Shortfall",
"routing_form": "Routing Form", "routing_form": "Routing Form",
"go_back_and_save": "Go back and save", "go_back_and_save": "Go back and save",
"save_changes":"Save changes", "save_changes": "Save changes",
"leave_without_saving": "Leave without saving", "leave_without_saving": "Leave without saving",
"leave_without_saving_description": "Are you sure you want to leave without saving changes to your routing form?", "leave_without_saving_description": "Are you sure you want to leave without saving changes to your routing form?",
"something_unexpected_occurred": "Something unexpected occurred", "something_unexpected_occurred": "Something unexpected occurred",
@@ -3031,9 +3036,9 @@
"authorize": "Authorize", "authorize": "Authorize",
"next": "Next", "next": "Next",
"hide": "Hide", "hide": "Hide",
"show":"Show", "show": "Show",
"utm_params":"UTM parameters", "utm_params": "UTM parameters",
"no_utm_params":"No UTM parameters available.", "no_utm_params": "No UTM parameters available.",
"asc": "Asc", "asc": "Asc",
"desc": "Desc", "desc": "Desc",
"verify_email_change": "Verify email change", "verify_email_change": "Verify email change",
@@ -3103,7 +3108,7 @@
"user_has_no_team_yet": "You don't have a team yet", "user_has_no_team_yet": "You don't have a team yet",
"no_team_members": "You don't have team members yet", "no_team_members": "You don't have team members yet",
"recurring_event_doesnt_support_seats": "Recurring event doesn't support seats feature. Make it non-recurring to enable seats.", "recurring_event_doesnt_support_seats": "Recurring event doesn't support seats feature. Make it non-recurring to enable seats.",
"booking_limit_per_booker_doesnt_support_recurring": "Recurring event doesn't support booking limit per booker feature. Disable booking limit per booker to enable Recurring event.", "booking_limit_per_booker_doesnt_support_recurring": "Recurring event doesn't support booking limit per booker feature. Disable booking limit per booker to enable Recurring event.",
"seats_doesnt_support_recurring": "Seats feature doesn't support recurring event. Disable seats feature to make it recurring.", "seats_doesnt_support_recurring": "Seats feature doesn't support recurring event. Disable seats feature to make it recurring.",
"recurring_event_seats_error": "Recurring event doesn't support seats feature. Disable seats feature or make the event non-recurring.", "recurring_event_seats_error": "Recurring event doesn't support seats feature. Disable seats feature or make the event non-recurring.",
"segment": "Segment", "segment": "Segment",
@@ -3181,8 +3186,8 @@
"resend": "Resend", "resend": "Resend",
"verification_email_sent": "Verification email sent", "verification_email_sent": "Verification email sent",
"select_all_members": "Select all members", "select_all_members": "Select all members",
"select_all_members_tooltip":"Selecting all members will add all future team members when they are added to the team.", "select_all_members_tooltip": "Selecting all members will add all future team members when they are added to the team.",
"form_settings":"Form Settings", "form_settings": "Form Settings",
"remove_whitelist_status": "Remove whitelist status", "remove_whitelist_status": "Remove whitelist status",
"whitelist_user_workflows": "Whitelist user workflows", "whitelist_user_workflows": "Whitelist user workflows",
"user_workflows_whitelisted": "User workflows whitelisted", "user_workflows_whitelisted": "User workflows whitelisted",
@@ -3252,6 +3257,8 @@
"meeting_start_time": "Meeting Start Time", "meeting_start_time": "Meeting Start Time",
"members_affected_by_disabling_delegation_credential": "Affected members", "members_affected_by_disabling_delegation_credential": "Affected members",
"no_members_affected_by_disabling_delegation_credential": "No members affected by disabling delegation credential", "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",
"offer_to_reschedule_last_booking": "Offer to reschedule last active booking to chosen time slot", "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": "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?", "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?",
+29 -29
View File
@@ -161,7 +161,7 @@
"join_by_entrypoint": "Csatlakozás a következővel: {{entryPoint}}", "join_by_entrypoint": "Csatlakozás a következővel: {{entryPoint}}",
"notes": "Jegyzetek", "notes": "Jegyzetek",
"manage_my_bookings": "Foglalásaim kezelése", "manage_my_bookings": "Foglalásaim kezelése",
"need_to_make_a_change": "Változtatnál valamit?", "need_to_make_a_change": "Változtatna valamin?",
"new_event_scheduled": "Új eseményt foglaltak.", "new_event_scheduled": "Új eseményt foglaltak.",
"new_event_scheduled_recurring": "Egy új ismétlődő időpont le lett foglalva.", "new_event_scheduled_recurring": "Egy új ismétlődő időpont le lett foglalva.",
"invitee_email": "Meghívó e-mail", "invitee_email": "Meghívó e-mail",
@@ -240,7 +240,7 @@
"copy_public_page_link": "Nyilvános oldal linkjének másolása", "copy_public_page_link": "Nyilvános oldal linkjének másolása",
"copy_referral_link": "Ajánlói link másolása", "copy_referral_link": "Ajánlói link másolása",
"sign_out": "Kijelentkezés", "sign_out": "Kijelentkezés",
"add_another": "Adj hozzá egy másikat", "add_another": "Másvalaki hozzáadása",
"install_another": "Telepítsen másikat", "install_another": "Telepítsen másikat",
"until": "amíg", "until": "amíg",
"powered_by": "működteti", "powered_by": "működteti",
@@ -354,10 +354,10 @@
"connect_caldav": "Csatlakozás a CalDav szerverhez", "connect_caldav": "Csatlakozás a CalDav szerverhez",
"connect_ics_feed": "Csatlakozás egy ICS feedhez", "connect_ics_feed": "Csatlakozás egy ICS feedhez",
"connect": "Csatlakozás", "connect": "Csatlakozás",
"try_for_free": "Próbáld ki ingyen", "try_for_free": "Próbálja ki ingyen",
"create_booking_link_with_calcom": "Hozzon létre saját foglalási linket a {{appName}} segítségével", "create_booking_link_with_calcom": "Hozzon létre saját foglalási linket a {{appName}} segítségével",
"who": "Ki", "who": "Ki",
"what": "Mit", "what": "Mi",
"when": "Mikor", "when": "Mikor",
"where": "Hol", "where": "Hol",
"add_to_calendar": "Hozzáadás naptárhoz", "add_to_calendar": "Hozzáadás naptárhoz",
@@ -373,32 +373,32 @@
"round_robin_emailed_you_and_attendees_recurring": "Ön találkozik {{user}} résztvevővel. Mindenkinek küldtünk egy e-mailt a naptári meghívóval és a részletekkel az ismétlődő események első alkalmára.", "round_robin_emailed_you_and_attendees_recurring": "Ön találkozik {{user}} résztvevővel. Mindenkinek küldtünk egy e-mailt a naptári meghívóval és a részletekkel az ismétlődő események első alkalmára.",
"round_robin_emailed_host_and_attendee_recurring": "{{host}} találkozik {{attendee}} résztvevővel. Mindenkinek küldtünk egy e-mailt a naptári meghívóval és a részletekkel az ismétlődő események első alkalmára.", "round_robin_emailed_host_and_attendee_recurring": "{{host}} találkozik {{attendee}} résztvevővel. Mindenkinek küldtünk egy e-mailt a naptári meghívóval és a részletekkel az ismétlődő események első alkalmára.",
"emailed_you_and_any_other_attendees": "Küldtünk egy e-mail-t mindenkinek ezekről az információkról.", "emailed_you_and_any_other_attendees": "Küldtünk egy e-mail-t mindenkinek ezekről az információkról.",
"needs_to_be_confirmed_or_rejected": "Foglalását még meg kell erősíteni vagy el kell utasítani.", "needs_to_be_confirmed_or_rejected": "Foglalását még megerősítésre vagy lemondásra vár.",
"needs_to_be_confirmed_or_rejected_recurring": "Az ismétlődő találkozót még meg kell erősíteni vagy el kell utasítani.", "needs_to_be_confirmed_or_rejected_recurring": "Az ismétlődő találkozó még megerősítésre vagy lemondásra vár.",
"user_needs_to_confirm_or_reject_booking": "{{user}} jóváhagyása szükséges a foglalásod véglegesítéséhez.", "user_needs_to_confirm_or_reject_booking": "{{user}} jóváhagyása szükséges foglalása véglegesítéséhez.",
"user_needs_to_confirm_or_reject_booking_recurring": "{{user}} résztvevőnek még mindig vissza kell igazolnia vagy elutasítania a foglalást az ismétlődő találkozóra.", "user_needs_to_confirm_or_reject_booking_recurring": "{{user}} résztvevőnek még vissza kell igazolnia vagy el kell utasítania a foglalást az ismétlődő találkozóra.",
"meeting_is_scheduled": "Találkozó sikeresen lefoglalva", "meeting_is_scheduled": "Találkozó sikeresen lefoglalva",
"meeting_is_scheduled_recurring": "Az ismétlődő időpontod le lett foglalva", "meeting_is_scheduled_recurring": "Az ismétlődő időpontja le lett foglalva",
"booking_submitted": "A foglalásod be lett küldve", "booking_submitted": "Foglalását elküldte",
"booking_submitted_recurring": "Ismétlődő találkozóját elküldtük", "booking_submitted_recurring": "Ismétlődő találkozóját elküldte",
"booking_confirmed": "A foglalásod vissza lett igazolva", "booking_confirmed": "Foglalását visszaigazolták",
"booking_confirmed_recurring": "Ismétlődő találkozóját megerősítették", "booking_confirmed_recurring": "Ismétlődő találkozóját megerősítették",
"warning_recurring_event_payment": "A fizetések még nem támogatottak ismétlődő események esetén", "warning_recurring_event_payment": "A fizetések még nem támogatottak ismétlődő események esetén",
"warning_payment_recurring_event": "Az ismétlődő eseményeket a fizetés még nincs támogatva", "warning_payment_recurring_event": "Az ismétlődő eseményeket a fizetés még nincs támogatva",
"enter_new_password": "Adja meg a fiókjához kívánt új jelszót.", "enter_new_password": "Adja meg a fiókjához kívánt új jelszót.",
"reset_password": "Jelszó visszaállítása", "reset_password": "Jelszó visszaállítása",
"change_your_password": "Változtasd meg a jelszavadat", "change_your_password": "Változtassa meg a jelszavát",
"show_password": "Mutasd a jelszót", "show_password": "Jelszó megjelenítése",
"hide_password": "Jelszó elrejtése", "hide_password": "Jelszó elrejtése",
"try_again": "Próbáld újra", "try_again": "Próbálja újra",
"request_is_expired": "Ez a kérelem lejárt.", "request_is_expired": "Ez a kérelem lejárt.",
"reset_instructions": "Adja meg a fiókjához társított e-mail címet, és küldünk egy linket a jelszó visszaállításához.", "reset_instructions": "Adja meg a fiókjához tartozó e-mail címet, és küldünk egy linket a jelszó visszaállításához.",
"request_is_expired_instructions": "Ez a kérelem lejárt. Menjen vissza, és adja meg a fiókjához tartozó e-mail-címet, és küldünk egy újabb linket a jelszó visszaállításához.", "request_is_expired_instructions": "Ez a kérelem lejárt. Menjen vissza, és adja meg a fiókjához tartozó e-mail-címet, és küldünk egy újabb linket a jelszó visszaállításához.",
"whoops": "Hoppá", "whoops": "Hoppá",
"login": "Bejelentkezés", "login": "Bejelentkezés",
"success": "Siker", "success": "Siker",
"failed": "Nem sikerült", "failed": "Nem sikerült",
"password_has_been_reset_login": "A jelszavad vissza lett állítva. Most már bejelentkezhet az újonnan létrehozott jelszavával.", "password_has_been_reset_login": "Jelszavad visszaállítottuk. Most már bejelentkezhetsz az újonnan létrehozott jelszavaddal.",
"layout": "Elrendezés", "layout": "Elrendezés",
"bookerlayout_default_title": "Alapértelmezett nézet", "bookerlayout_default_title": "Alapértelmezett nézet",
"bookerlayout_description": "Többet is kiválaszthat, és a foglalók nézetet válthatnak.", "bookerlayout_description": "Többet is kiválaszthat, és a foglalók nézetet válthatnak.",
@@ -641,41 +641,41 @@
"additional_notes": "Egyéb megjegyzés", "additional_notes": "Egyéb megjegyzés",
"booking_fail": "Találkozó nem foglalható.", "booking_fail": "Találkozó nem foglalható.",
"reschedule_fail": "Nem sikerült időpontot módisítani.", "reschedule_fail": "Nem sikerült időpontot módisítani.",
"share_additional_notes": "Kérlek ossz meg velünk bármit, ami segíthet felkészülni a találkozóra.", "share_additional_notes": "Kérjük osszon meg velünk bármit, ami segíthet felkészülni a találkozóra.",
"booking_confirmation": "Erősítse meg {{eventTypeTitle}} eseményét a következővel: {{profileName}}", "booking_confirmation": "Erősítse meg {{eventTypeTitle}} eseményét vele: {{profileName}}",
"booking_reschedule_confirmation": "Módosítsd {{eventTypeTitle}} időpontod nála: {{profileName}}", "booking_reschedule_confirmation": "Módosítsa {{eventTypeTitle}} időpontját nála: {{profileName}}",
"in_person_meeting": "Személyes találkozó", "in_person_meeting": "Személyes találkozó",
"in_person": "Személyesen (Szervező címén)", "in_person": "Személyesen (Szervező címén)",
"link_meeting": "Találkozó összekapcsolása", "link_meeting": "Találkozó összekapcsolása",
"phone_number": "Telefonszám", "phone_number": "Telefonszám",
"attendee_phone_number": "A résztvevő telefonszáma", "attendee_phone_number": "Résztvevő telefonszáma",
"organizer_phone_number": "Szervező telefonszáma", "organizer_phone_number": "Szervező telefonszáma",
"enter_phone_number": "Adja meg a telefonszámot", "enter_phone_number": "Adja meg a telefonszámot",
"reschedule": "átütmezés", "reschedule": "átütmezés",
"reschedule_this": "Inkább időpontmódosítás", "reschedule_this": "Inkább időpontmódosítás",
"book_a_team_member": "Foglaljon helyette egy csapattagot", "book_a_team_member": "Foglaljon inkáb egy másik csapattagnál",
"or": "VAGY", "or": "vagy",
"go_back": "Vissza", "go_back": "Vissza",
"email_or_username": "E-mail cím vagy felhasználónév", "email_or_username": "E-mail cím vagy felhasználónév",
"send_invite_email": "Küldj meghívó e-mailt", "send_invite_email": "Meghívó e-mail küldése",
"role": "Szerepkör", "role": "Szerepkör",
"edit_role": "Szerepkör szerkesztése", "edit_role": "Szerepkör szerkesztése",
"edit_team": "Csapat szerkesztése", "edit_team": "Csapat szerkesztése",
"editing_user": "Felhasználó szerkesztése", "editing_user": "Felhasználó szerkesztése",
"editing_org": "Szervezet szerkesztése", "editing_org": "Szervezet szerkesztése",
"edit_or_manage_webhooks": "Ha szeretnéd szerkeszteni vagy kezelni a webhookjaidat, látogass el a <0>webhook beállításokhoz</0>", "edit_or_manage_webhooks": "Ha szeretnéd szerkeszteni vagy kezelni a webhookjaidat, látogass el a <0>webhook beállításokhoz</0>",
"reject": "Elutasít", "reject": "Elutasítás",
"reject_all": "Az összes elutasítása", "reject_all": "Az összes elutasítása",
"accept": "Elfogad", "accept": "Elfogadás",
"leave": "Elhagyás", "leave": "Kilépés",
"profile": "Profil", "profile": "Profil",
"my_team_url": "Csapatom URL-je", "my_team_url": "Csapatom URL-je",
"my_teams": "A csapataim", "my_teams": "A csapataim",
"team_name": "Csapat neve", "team_name": "Csapat neve",
"your_team_name": "A csapatod neve", "your_team_name": "A csapatod neve",
"team_updated_successfully": "A csapat sikeresen frissítve", "team_updated_successfully": "A csapat sikeresen frissítve",
"your_team_updated_successfully": "Csapatát sikeresen frissítette.", "your_team_updated_successfully": "Csapatod sikeresen frissítetted.",
"your_org_updated_successfully": "Szervezete sikeresen frissítve.", "your_org_updated_successfully": "Szervezet sikeresen frissítve.",
"error_copying_to_clipboard": "Hiba a vágólapra másolás során", "error_copying_to_clipboard": "Hiba a vágólapra másolás során",
"about": "Rólunk", "about": "Rólunk",
"team_description": "Néhány mondat a csapatodról. Ez megjelenik a csapata URL-oldalán.", "team_description": "Néhány mondat a csapatodról. Ez megjelenik a csapata URL-oldalán.",
+6 -1
View File
@@ -24102,7 +24102,7 @@
}, },
"CalendarEventResponseStatus": { "CalendarEventResponseStatus": {
"type": "string", "type": "string",
"description": "Attendee's response to the invitation", "description": "Host's response to the invitation",
"enum": ["accepted", "pending", "declined", "needsAction"] "enum": ["accepted", "pending", "declined", "needsAction"]
}, },
"CalendarEventAttendee": { "CalendarEventAttendee": {
@@ -24150,6 +24150,11 @@
"type": "string", "type": "string",
"nullable": true, "nullable": true,
"description": "Display name of the event host" "description": "Display name of the event host"
},
"responseStatus": {
"nullable": true,
"example": "accepted",
"$ref": "#/components/schemas/CalendarEventResponseStatus"
} }
}, },
"required": ["email"] "required": ["email"]
@@ -156,7 +156,7 @@ To create a new webhook subscription, visit `/settings/developer/webhooks` and p
Use the **secret key** to create an HMAC, and update it with the webhook payload received to generate an SHA256 hash. Use the **secret key** to create an HMAC, and update it with the webhook payload received to generate an SHA256 hash.
</Step> </Step>
<Step title="Verify the payload authenticity"> <Step title="Verify the payload authenticity">
Compare the hash received in the header of the webhook `(X-Cal-Signature-256)` with the one you created using the **secret key** and the body of the payload. If they don't match, the received payload has been adulterated and cannot be trusted. Compare the hash received in the header of the webhook `(x-cal-signature-256)` with the one you created using the **secret key** and the body of the payload. If they don't match, the received payload has been adulterated and cannot be trusted.
</Step> </Step>
</Steps> </Steps>
+1
View File
@@ -124,6 +124,7 @@ Below is a list of props that can be passed to the booker atom.
| allowUpdatingUrlParams | No | Boolean indicating if the URL parameters should be updated, defaults to false. | | allowUpdatingUrlParams | No | Boolean indicating if the URL parameters should be updated, defaults to false. |
| confirmButtonDisabled | No | Boolean indicating if the submit button should be disabled, defaults to false. | | confirmButtonDisabled | No | Boolean indicating if the submit button should be disabled, defaults to false. |
| timeZones | No | Array of valid IANA timezones to be used in the booker. Eg. ["Asia/Kolkata", "Europe/London"] | | timeZones | No | Array of valid IANA timezones to be used in the booker. Eg. ["Asia/Kolkata", "Europe/London"] |
| onTimeslotsLoaded | No | Callback function triggered once the available timeslots have been fetched. |
## Styling ## Styling
+3 -1
View File
@@ -31,7 +31,9 @@ Below is a list of props that can be passed to the Conferencing Apps Atom
| :------------------ | :------- | :-------------------------------------------------------------------------- | | :------------------ | :------- | :-------------------------------------------------------------------------- |
| returnTo | No | The URL of the page to redirect to after a successful installation. | | returnTo | No | The URL of the page to redirect to after a successful installation. |
| onErrorReturnTo | No | The URL of the page to redirect to in case an error occurs. | | onErrorReturnTo | No | The URL of the page to redirect to in case an error occurs. |
| disableToasts | No | boolean value to disable toast notifications in the atom. | | disableToasts | No | Boolean value to disable toast notifications in the atom. |
| apps | No | Array of conferencing app slugs to display in the dropdown. If provided, only these apps will be shown (if not already installed). Valid values are `'google-meet'`, `'zoom'`, and `'msteams'`. |
| disableBulkUpdateEventTypes | No | A Boolean flag that prevents the bulk update of event types modal from appearing when the default conferencing app is changed. Defaults to false |
## Google Meet ## Google Meet
+3 -2
View File
@@ -80,8 +80,9 @@
"docker-build-api": "docker build -t cal-api -f ./infra/docker/api/Dockerfile .", "docker-build-api": "docker build -t cal-api -f ./infra/docker/api/Dockerfile .",
"docker-run-api": "docker run -p 80:80 cal-api", "docker-run-api": "docker run -p 80:80 cal-api",
"docker-stop-api": "docker ps --filter 'ancestor=cal-api' -q | xargs docker stop", "docker-stop-api": "docker ps --filter 'ancestor=cal-api' -q | xargs docker stop",
"changesets-add": "yarn changeset add",
"changesets-version": "yarn changeset version", "changesets-version": "yarn changeset version",
"changesets-release": "turbo run build && yarn changeset publish" "changesets-release": "NODE_OPTIONS='--max_old_space_size=12288' turbo run build --filter=@calcom/atoms && yarn changeset publish"
}, },
"devDependencies": { "devDependencies": {
"@changesets/changelog-github": "^0.5.1", "@changesets/changelog-github": "^0.5.1",
@@ -108,7 +109,7 @@
"prismock": "^1.33.4", "prismock": "^1.33.4",
"resize-observer-polyfill": "^1.5.1", "resize-observer-polyfill": "^1.5.1",
"tsc-absolute": "^1.0.0", "tsc-absolute": "^1.0.0",
"typescript": "^5.8.3", "typescript": "^5.7.2",
"vitest": "^2.1.1", "vitest": "^2.1.1",
"vitest-fetch-mock": "^0.3.0", "vitest-fetch-mock": "^0.3.0",
"vitest-mock-extended": "^2.0.2" "vitest-mock-extended": "^2.0.2"
+1 -1
View File
@@ -31,6 +31,6 @@
"eslint-plugin-react": "^7.30.1", "eslint-plugin-react": "^7.30.1",
"eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-hooks": "^4.6.0",
"ts-node": "^10.9.1", "ts-node": "^10.9.1",
"typescript": "^5.8.3" "typescript": "^5.7.2"
} }
} }
@@ -1,7 +1,12 @@
/** /**
* Manages OAuth2.0 tokens for an app and resourceOwner. It automatically refreshes the token when needed. * Manages OAuth2.0 as well as JWT tokens(For JWT tokens, only Google Calendar use it at the moment) for an app and resourceOwner.
* It is aware of the credential sync endpoint and can sync the token from the third party source. * What it does
* It is unaware of Prisma and App logic. It is just a utility to manage OAuth2.0 tokens with life cycle methods * - It automatically refreshes the token if needed when making a request.
* - It is aware of the credential sync endpoint and can sync the token from the third party source.
* - It is kept unaware of Prisma and App logic. It is just a utility to manage OAuth2.0 tokens with life cycle methods
*
* What it doesn't do yet
* - It doesn't have a flow to re-send the request if the access-token had been communicated as invalid after making the request itself. It relies on the caller to make the next request in which it will actually refresh the token.
* *
* For a recommended usage example, see Zoom VideoApiAdapter.ts * For a recommended usage example, see Zoom VideoApiAdapter.ts
*/ */
@@ -89,7 +94,13 @@ export class OAuthManager {
currentTokenObject, currentTokenObject,
fetchNewTokenObject, fetchNewTokenObject,
updateTokenObject, updateTokenObject,
/**
* The fn must not crash. It is the responsibility of the caller to handle any error and appropriately decide what to return
*/
isTokenObjectUnusable, isTokenObjectUnusable,
/**
* The fn must not crash. It is the responsibility of the caller to handle any error and appropriately decide what to return
*/
isAccessTokenUnusable, isAccessTokenUnusable,
invalidateTokenObject, invalidateTokenObject,
expireAccessToken, expireAccessToken,
+2 -42
View File
@@ -1,20 +1,16 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useRef } from "react";
import type { UseAddAppMutationOptions } from "@calcom/app-store/_utils/useAddAppMutation"; import type { UseAddAppMutationOptions } from "@calcom/app-store/_utils/useAddAppMutation";
import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation"; import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation";
import { WEBAPP_URL } from "@calcom/lib/constants"; import { WEBAPP_URL } from "@calcom/lib/constants";
import { deriveAppDictKeyFromType } from "@calcom/lib/deriveAppDictKeyFromType"; import { deriveAppDictKeyFromType } from "@calcom/lib/deriveAppDictKeyFromType";
import { useHasTeamPlan } from "@calcom/lib/hooks/useHasPaidPlan";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import type { RouterOutputs } from "@calcom/trpc/react"; import type { RouterOutputs } from "@calcom/trpc/react";
import type { App } from "@calcom/types/App"; import type { App } from "@calcom/types/App";
import { Icon } from "@calcom/ui/components/icon";
import classNames from "@calcom/ui/classNames"; import classNames from "@calcom/ui/classNames";
import { Icon } from "@calcom/ui/components/icon";
import { InstallAppButtonMap } from "./apps.browser.generated"; import { InstallAppButtonMap } from "./apps.browser.generated";
import type { InstallAppButtonProps } from "./types"; import type { InstallAppButtonProps } from "./types";
@@ -59,44 +55,8 @@ export const InstallAppButton = (
disableInstall?: boolean; disableInstall?: boolean;
} & InstallAppButtonProps } & InstallAppButtonProps
) => { ) => {
const { isPending: isUserLoading, data: user } = trpc.viewer.me.get.useQuery();
const router = useRouter();
const proProtectionElementRef = useRef<HTMLDivElement | null>(null);
const { isPending: isTeamPlanStatusLoading, hasTeamPlan } = useHasTeamPlan();
useEffect(() => {
const el = proProtectionElementRef.current;
if (!el) {
return;
}
el.addEventListener(
"click",
(e) => {
if (!user) {
router.push(
`${WEBAPP_URL}/auth/login?callbackUrl=${WEBAPP_URL + location.pathname + location.search}`
);
e.stopPropagation();
return;
}
if (props.teamsPlanRequired && !hasTeamPlan) {
// TODO: I think we should show the UpgradeTip in a Dialog here. This would solve the problem of no way to go back to the App page from the UpgradeTip page(except browser's back button)
router.push(props.teamsPlanRequired.upgradeUrl);
e.stopPropagation();
return;
}
},
true
);
}, [isUserLoading, user, router, hasTeamPlan, props.teamsPlanRequired]);
if (isUserLoading || isTeamPlanStatusLoading) {
return null;
}
return ( return (
<div ref={proProtectionElementRef} className={props.wrapperClassName}> <div className={props.wrapperClassName}>
<InstallAppButtonWithoutPlanCheck {...props} /> <InstallAppButtonWithoutPlanCheck {...props} />
</div> </div>
); );
@@ -42,6 +42,35 @@ export interface DailyVideoCallData {
url: string; url: string;
} }
// Regions available to create DailyVideo Rooms in.
const REGION_CODES = [
"af-south-1",
"ap-northeast-2",
"ap-southeast-1",
"ap-southeast-2",
"ap-south-1",
"eu-central-1",
"eu-west-2",
"sa-east-1",
"us-east-1",
"us-west-2",
] as const;
type RoomGeo = (typeof REGION_CODES)[number];
function getDailyVideoRegionFromEnv(): RoomGeo | undefined {
if (!process?.env?.DAILY_VIDEO_REGION) return;
const isRoomGeo = (value: string): value is RoomGeo => REGION_CODES.includes(value as RoomGeo);
function assertIsDailyVideoRegion(value: string): asserts value is RoomGeo {
if (!isRoomGeo(value)) {
throw new Error(`Invalid region code: ${value}. Must be one of: ${REGION_CODES.join(", ")}`);
}
}
const region = process.env.DAILY_VIDEO_REGION;
assertIsDailyVideoRegion(region);
return region;
}
const isS3StorageEnabled = const isS3StorageEnabled =
process.env.CAL_VIDEO_BUCKET_NAME && process.env.CAL_VIDEO_BUCKET_NAME &&
process.env.CAL_VIDEO_BUCKET_REGION && process.env.CAL_VIDEO_BUCKET_REGION &&
@@ -203,11 +232,15 @@ export const setEnableRecordingUIAndUserIdForOrganizer = async (
}; };
const DailyVideoApiAdapter = (): VideoApiAdapter => { const DailyVideoApiAdapter = (): VideoApiAdapter => {
async function createOrUpdateMeeting(endpoint: string, event: CalendarEvent): Promise<VideoCallData> { async function createOrUpdateMeeting(
endpoint: string,
event: CalendarEvent,
region?: RoomGeo
): Promise<VideoCallData> {
if (!event.uid) { if (!event.uid) {
throw new Error("We need need the booking uid to create the Daily reference in DB"); throw new Error("We need need the booking uid to create the Daily reference in DB");
} }
const body = await translateEvent(event); const body = await translateEvent(event, region);
const dailyEvent = await postToDailyAPI(endpoint, body).then(dailyReturnTypeSchema.parse); const dailyEvent = await postToDailyAPI(endpoint, body).then(dailyReturnTypeSchema.parse);
const meetingToken = await postToDailyAPI("/meeting-tokens", { const meetingToken = await postToDailyAPI("/meeting-tokens", {
properties: { properties: {
@@ -226,7 +259,7 @@ const DailyVideoApiAdapter = (): VideoApiAdapter => {
}); });
} }
const translateEvent = async (event: CalendarEvent) => { const translateEvent = async (event: CalendarEvent, region?: RoomGeo) => {
// Documentation at: https://docs.daily.co/reference#list-rooms // Documentation at: https://docs.daily.co/reference#list-rooms
// Adds 14 days from the end of the booking as the expiration date // Adds 14 days from the end of the booking as the expiration date
const exp = Math.round(new Date(event.endTime).getTime() / 1000) + 60 * 60 * 24 * 14; const exp = Math.round(new Date(event.endTime).getTime() / 1000) + 60 * 60 * 24 * 14;
@@ -248,6 +281,7 @@ const DailyVideoApiAdapter = (): VideoApiAdapter => {
return { return {
privacy: "public", privacy: "public",
properties: { properties: {
...(region ? { geo: region } : {}),
enable_prejoin_ui: true, enable_prejoin_ui: true,
enable_knocking: true, enable_knocking: true,
enable_screenshare: true, enable_screenshare: true,
@@ -284,7 +318,7 @@ const DailyVideoApiAdapter = (): VideoApiAdapter => {
}; };
}; };
async function createInstantMeeting(endTime: string) { async function createInstantMeeting(endTime: string, region?: RoomGeo) {
// added a 1 hour buffer for room expiration // added a 1 hour buffer for room expiration
const exp = Math.round(new Date(endTime).getTime() / 1000) + 60 * 60; const exp = Math.round(new Date(endTime).getTime() / 1000) + 60 * 60;
const { scale_plan: scalePlan } = await getDailyAppKeys(); const { scale_plan: scalePlan } = await getDailyAppKeys();
@@ -296,6 +330,7 @@ const DailyVideoApiAdapter = (): VideoApiAdapter => {
const body = { const body = {
privacy: "public", privacy: "public",
properties: { properties: {
...(region ? { geo: region } : {}),
enable_prejoin_ui: true, enable_prejoin_ui: true,
enable_knocking: true, enable_knocking: true,
enable_screenshare: true, enable_screenshare: true,
@@ -347,20 +382,22 @@ const DailyVideoApiAdapter = (): VideoApiAdapter => {
url: dailyEvent.url, url: dailyEvent.url,
}); });
} }
// Region on which the DailyVideo room is created can be controlled by ENV var
// undefined region leaves the choice to DailyVideo
const region = getDailyVideoRegionFromEnv();
return { return {
/** Daily doesn't need to return busy times, so we return empty */ /** Daily doesn't need to return busy times, so we return empty */
getAvailability: () => { getAvailability: () => {
return Promise.resolve([]); return Promise.resolve([]);
}, },
createMeeting: async (event: CalendarEvent): Promise<VideoCallData> => createMeeting: async (event: CalendarEvent): Promise<VideoCallData> =>
createOrUpdateMeeting("/rooms", event), createOrUpdateMeeting("/rooms", event, region),
deleteMeeting: async (uid: string): Promise<void> => { deleteMeeting: async (uid: string): Promise<void> => {
await fetcher(`/rooms/${uid}`, { method: "DELETE" }); await fetcher(`/rooms/${uid}`, { method: "DELETE" });
return Promise.resolve(); return Promise.resolve();
}, },
updateMeeting: (bookingRef: PartialReference, event: CalendarEvent): Promise<VideoCallData> => updateMeeting: (bookingRef: PartialReference, event: CalendarEvent): Promise<VideoCallData> =>
createOrUpdateMeeting(`/rooms/${bookingRef.uid}`, event), createOrUpdateMeeting(`/rooms/${bookingRef.uid}`, event, region),
getRecordings: async (roomName: string): Promise<GetRecordingsResponseSchema> => { getRecordings: async (roomName: string): Promise<GetRecordingsResponseSchema> => {
try { try {
const res = await fetcher(`/recordings?room_name=${roomName}`).then( const res = await fetcher(`/recordings?room_name=${roomName}`).then(
@@ -371,7 +408,7 @@ const DailyVideoApiAdapter = (): VideoApiAdapter => {
throw new Error("Something went wrong! Unable to get recording"); throw new Error("Something went wrong! Unable to get recording");
} }
}, },
createInstantCalVideoRoom: (endTime: string) => createInstantMeeting(endTime), createInstantCalVideoRoom: (endTime: string) => createInstantMeeting(endTime, region),
getRecordingDownloadLink: async (recordingId: string): Promise<GetAccessLinkResponseSchema> => { getRecordingDownloadLink: async (recordingId: string): Promise<GetAccessLinkResponseSchema> => {
try { try {
const res = await fetcher(`/recordings/${recordingId}/access-link?valid_for_secs=43200`).then( const res = await fetcher(`/recordings/${recordingId}/access-link?valid_for_secs=43200`).then(
@@ -2,15 +2,9 @@ import { calendar_v3 } from "@googleapis/calendar";
import { OAuth2Client } from "googleapis-common"; import { OAuth2Client } from "googleapis-common";
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import { updateProfilePhotoGoogle } from "@calcom/app-store/_utils/oauth/updateProfilePhotoGoogle";
import GoogleCalendarService from "@calcom/app-store/googlecalendar/lib/CalendarService"; import GoogleCalendarService from "@calcom/app-store/googlecalendar/lib/CalendarService";
import { renewSelectedCalendarCredentialId } from "@calcom/lib/connectedCalendar"; import { renewSelectedCalendarCredentialId } from "@calcom/lib/connectedCalendar";
import { import { GOOGLE_CALENDAR_SCOPES, WEBAPP_URL, WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants";
GOOGLE_CALENDAR_SCOPES,
SCOPE_USERINFO_PROFILE,
WEBAPP_URL,
WEBAPP_URL_FOR_OAUTH,
} from "@calcom/lib/constants";
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl"; import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
import { HttpError } from "@calcom/lib/http-error"; import { HttpError } from "@calcom/lib/http-error";
import { defaultHandler } from "@calcom/lib/server/defaultHandler"; import { defaultHandler } from "@calcom/lib/server/defaultHandler";
@@ -101,9 +95,10 @@ async function getHandler(req: NextApiRequest, res: NextApiResponse) {
} }
// Only attempt to update the user's profile photo if the user has granted the required scope // Only attempt to update the user's profile photo if the user has granted the required scope
if (grantedScopes.includes(SCOPE_USERINFO_PROFILE)) { // TODO: Use the avatarUrl when setting the profile picture
await updateProfilePhotoGoogle(oAuth2Client, req.session.user.id); // if (grantedScopes.includes(SCOPE_USERINFO_PROFILE)) {
} // await updateProfilePhotoGoogle(oAuth2Client, req.session.user.id);
// }
const selectedCalendarWhereUnique = { const selectedCalendarWhereUnique = {
userId: req.session.user.id, userId: req.session.user.id,
@@ -29,7 +29,14 @@ const log = logger.getSubLogger({ prefix: ["app-store/googlecalendar/lib/Calenda
class MyGoogleOAuth2Client extends OAuth2Client { class MyGoogleOAuth2Client extends OAuth2Client {
constructor(client_id: string, client_secret: string, redirect_uri: string) { constructor(client_id: string, client_secret: string, redirect_uri: string) {
super(client_id, client_secret, redirect_uri); super({
clientId: client_id,
clientSecret: client_secret,
redirectUri: redirect_uri,
// default: 5 * 60 * 1000, 5 minutes
// tho, fn will never run in excess of 60 seconds
eagerRefreshThresholdMillis: 60000,
});
} }
isTokenExpiring() { isTokenExpiring() {
@@ -678,15 +678,6 @@ export default class GoogleCalendarService implements Calendar {
} }
} }
/**
* Better alternative to `getPrimaryCalendar` that doesn't require any argument
*/
async fetchPrimaryCalendar() {
const calendar = await this.authedCalendar();
const primaryCalendar = await this.getPrimaryCalendar(calendar);
return primaryCalendar;
}
async listCalendars(): Promise<IntegrationCalendar[]> { async listCalendars(): Promise<IntegrationCalendar[]> {
this.log.debug("Listing calendars"); this.log.debug("Listing calendars");
const calendar = await this.authedCalendar(); const calendar = await this.authedCalendar();
@@ -1009,39 +1000,16 @@ export default class GoogleCalendarService implements Calendar {
} }
} }
async getPrimaryCalendar( async getPrimaryCalendar(_calendar?: calendar_v3.Calendar): Promise<calendar_v3.Schema$Calendar | null> {
calendar: calendar_v3.Calendar,
fields: string[] = ["id", "summary", "primary", "accessRole"]
): Promise<calendar_v3.Schema$CalendarListEntry | null> {
let pageToken: string | undefined;
let firstCalendar: calendar_v3.Schema$CalendarListEntry | undefined;
try { try {
do { const calendar = _calendar ?? (await this.authedCalendar());
const response: any = await calendar.calendarList.list({ const response = await calendar.calendars.get({
fields: `items(${fields.join(",")}),nextPageToken`, calendarId: "primary",
pageToken, });
maxResults: 250, // 250 is max return response.data;
});
const cals = response.data.items ?? [];
const primaryCal = cals.find((cal: calendar_v3.Schema$CalendarListEntry) => cal.primary);
if (primaryCal) {
return primaryCal;
}
// Store the first calendar in case no primary is found
if (cals.length > 0 && !firstCalendar) {
firstCalendar = cals[0];
}
pageToken = response.data.nextPageToken;
} while (pageToken);
// should not be reached because Google Cal always has a primary cal
return firstCalendar ?? null;
} catch (error) { } catch (error) {
logger.error("Error in `getPrimaryCalendar`", { error }); // should not be reached because Google Cal always has a primary cal
logger.error("Error getting primary calendar", { error });
throw error; throw error;
} }
} }
@@ -3,6 +3,7 @@ import { vi } from "vitest";
const setCredentialsMock = vi.fn(); const setCredentialsMock = vi.fn();
const freebusyQueryMock = vi.fn(); const freebusyQueryMock = vi.fn();
const calendarListMock = vi.fn(); const calendarListMock = vi.fn();
const calendarsGetMock = vi.fn();
const calendarMockImplementation = { const calendarMockImplementation = {
channels: { channels: {
stop: vi.fn().mockResolvedValue(undefined), stop: vi.fn().mockResolvedValue(undefined),
@@ -10,6 +11,9 @@ const calendarMockImplementation = {
calendarList: { calendarList: {
list: calendarListMock, list: calendarListMock,
}, },
calendars: {
get: calendarsGetMock,
},
events: { events: {
watch: vi.fn().mockResolvedValue({ watch: vi.fn().mockResolvedValue({
data: { data: {
@@ -885,3 +885,27 @@ describe("getAvailability", () => {
expect(availabilityWithAllCalendarsAsFallback).toEqual([...mockedBusyTimes1, ...mockedBusyTimes2]); expect(availabilityWithAllCalendarsAsFallback).toEqual([...mockedBusyTimes1, ...mockedBusyTimes2]);
}); });
}); });
describe("getPrimaryCalendar", () => {
test("should fetch primary calendar using 'primary' keyword", async () => {
const credential = await createCredentialForCalendarService();
const calendarService = new CalendarService(credential);
setFullMockOAuthManagerRequest();
const mockPrimaryCalendar = {
id: "user@example.com",
summary: "Primary Calendar",
description: "Primary calendar for user@example.com",
timeZone: "America/New_York",
};
const calendarsGetMock = vi.fn().mockResolvedValue({
data: mockPrimaryCalendar,
});
calendarMock.calendar_v3.Calendar().calendars.get = calendarsGetMock;
const result = await calendarService.getPrimaryCalendar();
expect(calendarsGetMock).toHaveBeenCalledTimes(1);
expect(calendarsGetMock).toHaveBeenCalledWith({
calendarId: "primary",
});
expect(result).toEqual(mockPrimaryCalendar);
});
});
@@ -1,6 +1,6 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { CAL_URL } from "@calcom/lib/constants"; import { WEBAPP_URL } from "@calcom/lib/constants";
import { getAbsoluteEventTypeRedirectUrl } from "../getEventTypeRedirectUrl"; import { getAbsoluteEventTypeRedirectUrl } from "../getEventTypeRedirectUrl";
@@ -20,13 +20,13 @@ describe("getAbsoluteEventTypeRedirectUrl", () => {
isEmbed: false, isEmbed: false,
}; };
it("should return CAL_URL for non-migrated user", () => { it("should return WEBAPP_URL for non-migrated user", () => {
const result = getAbsoluteEventTypeRedirectUrl({ const result = getAbsoluteEventTypeRedirectUrl({
...defaultParams, ...defaultParams,
eventTypeRedirectUrl: "user/event", eventTypeRedirectUrl: "user/event",
form: { ...defaultForm, nonOrgUsername: "user" }, form: { ...defaultForm, nonOrgUsername: "user" },
}); });
expect(result).toBe(`${CAL_URL}/user/event?`); expect(result).toBe(`${WEBAPP_URL}/user/event?`);
}); });
it("should return user origin for migrated user", () => { it("should return user origin for migrated user", () => {
@@ -34,13 +34,13 @@ describe("getAbsoluteEventTypeRedirectUrl", () => {
expect(result).toBe("https://user.cal.com/user/event?"); expect(result).toBe("https://user.cal.com/user/event?");
}); });
it("should return CAL_URL for non-migrated team", () => { it("should return WEBAPP_URL for non-migrated team", () => {
const result = getAbsoluteEventTypeRedirectUrl({ const result = getAbsoluteEventTypeRedirectUrl({
...defaultParams, ...defaultParams,
eventTypeRedirectUrl: "team/team1/event", eventTypeRedirectUrl: "team/team1/event",
form: { ...defaultForm, nonOrgTeamslug: "team1" }, form: { ...defaultForm, nonOrgTeamslug: "team1" },
}); });
expect(result).toBe(`${CAL_URL}/team/team1/event?`); expect(result).toBe(`${WEBAPP_URL}/team/team1/event?`);
}); });
it("should return team origin for migrated team", () => { it("should return team origin for migrated team", () => {
@@ -1,5 +1,5 @@
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains"; import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
import { CAL_URL } from "@calcom/lib/constants"; import { WEBAPP_URL } from "@calcom/lib/constants";
import { teamMetadataSchema, userMetadata } from "@calcom/prisma/zod-utils"; import { teamMetadataSchema, userMetadata } from "@calcom/prisma/zod-utils";
export const enrichFormWithMigrationData = < export const enrichFormWithMigrationData = <
@@ -45,12 +45,12 @@ export const enrichFormWithMigrationData = <
? getOrgFullOrigin(formOwnerOrgSlug, { ? getOrgFullOrigin(formOwnerOrgSlug, {
protocol: true, protocol: true,
}) })
: CAL_URL, : WEBAPP_URL,
teamOrigin: form.team?.parent?.slug teamOrigin: form.team?.parent?.slug
? getOrgFullOrigin(form.team.parent.slug, { ? getOrgFullOrigin(form.team.parent.slug, {
protocol: true, protocol: true,
}) })
: CAL_URL, : WEBAPP_URL,
nonOrgUsername, nonOrgUsername,
nonOrgTeamslug, nonOrgTeamslug,
}; };
@@ -139,6 +139,7 @@ describe("handleResponse", () => {
it("should record form response when not in preview and not queued", async () => { it("should record form response when not in preview and not queued", async () => {
const dbFormResponse = { const dbFormResponse = {
id: 1, id: 1,
uuid: "d8b4b7d2-3f45-4f67-9aa1-98c4b49cf283",
formId: mockForm.id, formId: mockForm.id,
response: mockResponse, response: mockResponse,
chosenRouteId: null, chosenRouteId: null,
@@ -18,6 +18,7 @@ import {
MultiOptionInput, MultiOptionInput,
} from "@calcom/ui/components/form"; } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon"; import { Icon } from "@calcom/ui/components/icon";
import { Tooltip } from "@calcom/ui/components/tooltip";
import type { inferSSRProps } from "@lib/types/inferSSRProps"; import type { inferSSRProps } from "@lib/types/inferSSRProps";
@@ -36,6 +37,7 @@ function Field({
moveUp, moveUp,
moveDown, moveDown,
appUrl, appUrl,
disableTypeChange,
}: { }: {
fieldIndex: number; fieldIndex: number;
hookForm: HookForm; hookForm: HookForm;
@@ -53,6 +55,7 @@ function Field({
fn: () => void; fn: () => void;
}; };
appUrl: string; appUrl: string;
disableTypeChange: boolean;
}) { }) {
const { t } = useLocale(); const { t } = useLocale();
@@ -123,32 +126,53 @@ function Field({
defaultValue={routerField?.type} defaultValue={routerField?.type}
render={({ field: { value, onChange } }) => { render={({ field: { value, onChange } }) => {
const defaultValue = FieldTypes.find((fieldType) => fieldType.value === value); const defaultValue = FieldTypes.find((fieldType) => fieldType.value === value);
return ( if (disableTypeChange) {
<SelectField return (
maxMenuHeight={200} <div className="data-testid-field-type">
styles={{ <Label htmlFor="field-type-button">{t("type")}</Label>
singleValue: (baseStyles) => ({ <Tooltip content={t("field_type_change_suggestion")}>
...baseStyles, <Button
fontSize: "14px", type="button"
}), disabled
option: (baseStyles) => ({ color="secondary"
...baseStyles, className={classNames(
fontSize: "14px", "h-8 w-full justify-between text-left text-sm",
}), !!router && "bg-subtle cursor-not-allowed"
}} )}>
label="Type" <span className="text-default">{defaultValue?.label || "Select field type"}</span>
isDisabled={!!router} <Icon name="chevron-down" className="text-default h-4 w-4" />
containerClassName="data-testid-field-type" </Button>
options={FieldTypes} </Tooltip>
onChange={(option) => { </div>
if (!option) { );
return; } else {
} return (
onChange(option.value); <SelectField
}} maxMenuHeight={200}
defaultValue={defaultValue} styles={{
/> singleValue: (baseStyles) => ({
); ...baseStyles,
fontSize: "14px",
}),
option: (baseStyles) => ({
...baseStyles,
fontSize: "14px",
}),
}}
label="Type"
isDisabled={!!router}
containerClassName="data-testid-field-type"
options={FieldTypes}
onChange={(option) => {
if (!option) {
return;
}
onChange(option.value);
}}
defaultValue={defaultValue}
/>
);
}
}} }}
/> />
</div> </div>
@@ -213,6 +237,7 @@ const FormEdit = ({
} = useFieldArray({ } = useFieldArray({
control: hookForm.control, control: hookForm.control,
name: fieldsNamespace, name: fieldsNamespace,
keyName: "_id",
}); });
const [animationRef] = useAutoAnimate<HTMLDivElement>(); const [animationRef] = useAutoAnimate<HTMLDivElement>();
@@ -236,12 +261,15 @@ const FormEdit = ({
<div className="w-full py-4 lg:py-8"> <div className="w-full py-4 lg:py-8">
<div ref={animationRef} className="flex w-full flex-col rounded-md"> <div ref={animationRef} className="flex w-full flex-col rounded-md">
{hookFormFields.map((field, key) => { {hookFormFields.map((field, key) => {
const existingField = Boolean((form.fields || []).find((f) => f.id === field.id));
const hasFormResponses = (form._count?.responses ?? 0) > 0;
return ( return (
<Field <Field
appUrl={appUrl} appUrl={appUrl}
fieldIndex={key} fieldIndex={key}
hookForm={hookForm} hookForm={hookForm}
hookFieldNamespace={`${fieldsNamespace}.${key}`} hookFieldNamespace={`${fieldsNamespace}.${key}`}
disableTypeChange={existingField && hasFormResponses}
deleteField={{ deleteField={{
check: () => hookFormFields.length > 1, check: () => hookFormFields.length > 1,
fn: () => { fn: () => {
@@ -1,6 +1,7 @@
import type { Locator, Page } from "@playwright/test"; import type { Locator, Page } from "@playwright/test";
import { expect } from "@playwright/test"; import { expect } from "@playwright/test";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { prisma } from "@calcom/prisma"; import { prisma } from "@calcom/prisma";
import { AttributeType, MembershipRole, SchedulingType } from "@calcom/prisma/enums"; import { AttributeType, MembershipRole, SchedulingType } from "@calcom/prisma/enums";
import type { Fixtures } from "@calcom/web/playwright/lib/fixtures"; import type { Fixtures } from "@calcom/web/playwright/lib/fixtures";
@@ -262,7 +263,7 @@ test.describe("Routing Forms", () => {
option: 2, option: 2,
page, page,
}); });
await page.fill("[name=externalRedirectUrl]", "https://cal.com/peer"); await page.fill("[name=externalRedirectUrl]", `${WEBAPP_URL}/pro`);
await saveCurrentForm(page); await saveCurrentForm(page);
const { fields } = await addAllTypesOfFieldsAndSaveForm(formId, page, { const { fields } = await addAllTypesOfFieldsAndSaveForm(formId, page, {
@@ -290,7 +291,7 @@ test.describe("Routing Forms", () => {
await page.click('button[type="submit"]'); await page.click('button[type="submit"]');
await page.waitForURL((url) => { await page.waitForURL((url) => {
return url.hostname.includes("cal.com"); return url.pathname.endsWith("/pro");
}); });
const url = new URL(page.url()); const url = new URL(page.url());
@@ -509,7 +510,7 @@ test.describe("Routing Forms", () => {
await page.goto(`/router?form=${routingForm.id}&Test field=external-redirect`); await page.goto(`/router?form=${routingForm.id}&Test field=external-redirect`);
await page.waitForURL((url) => { await page.waitForURL((url) => {
return url.hostname.includes("cal.com") && url.searchParams.get("Test field") === "external-redirect"; return url.pathname.endsWith("/pro") && url.searchParams.get("Test field") === "external-redirect";
}); });
await page.goto(`/router?form=${routingForm.id}&Test field=custom-page`); await page.goto(`/router?form=${routingForm.id}&Test field=custom-page`);
@@ -576,7 +577,7 @@ test.describe("Routing Forms", () => {
routingType = await page.locator('[data-testid="chosen-route-title"]').innerText(); routingType = await page.locator('[data-testid="chosen-route-title"]').innerText();
route = await page.locator('[data-testid="test-routing-result"]').innerText(); route = await page.locator('[data-testid="test-routing-result"]').innerText();
expect(routingType).toBe("External Redirect"); expect(routingType).toBe("External Redirect");
expect(route).toBe("https://cal.com/peer"); expect(route).toBe(`${WEBAPP_URL}/pro`);
await page.click('[data-testid="close-results-button"]'); await page.click('[data-testid="close-results-button"]');
// Multiselect(Legacy) // Multiselect(Legacy)
@@ -925,7 +926,7 @@ test.describe("Routing Forms", () => {
option: 2, option: 2,
page, page,
}); });
await page.fill("[name=externalRedirectUrl]", "https://cal.com/peer"); await page.fill("[name=externalRedirectUrl]", `${WEBAPP_URL}/pro`);
await saveCurrentForm(page); await saveCurrentForm(page);
return { return {
formId, formId,
@@ -978,7 +979,7 @@ test.describe("Routing Forms", () => {
await page.fill('[data-testid="form-field-short-text"]', "test"); await page.fill('[data-testid="form-field-short-text"]', "test");
await page.click('button[type="submit"]'); await page.click('button[type="submit"]');
await page.waitForURL((url) => { await page.waitForURL((url) => {
return url.hostname.includes("cal.com"); return url.pathname.endsWith("/pro");
}); });
}; };
@@ -1059,7 +1060,7 @@ async function fillSeededForm(page: Page, routingFormId: string) {
await fillAllOptionsBasedFields(); await fillAllOptionsBasedFields();
page.click('button[type="submit"]'); page.click('button[type="submit"]');
await page.waitForURL((url) => { await page.waitForURL((url) => {
return url.hostname.includes("cal.com"); return url.pathname.endsWith("/pro");
}); });
})(); })();
@@ -19,7 +19,7 @@ export default async function getAttributesForTeamHandler({
}: GetAttributesForTeamHandlerOptions) { }: GetAttributesForTeamHandlerOptions) {
const { teamId } = input; const { teamId } = input;
const { user } = ctx; const { user } = ctx;
const isMemberOfTeam = await MembershipRepository.findFirstByUserIdAndTeamId({ userId: user.id, teamId }); const isMemberOfTeam = await MembershipRepository.findUniqueByUserIdAndTeamId({ userId: user.id, teamId });
if (!isMemberOfTeam) { if (!isMemberOfTeam) {
throw new TRPCError({ throw new TRPCError({
+2 -2
View File
@@ -1,7 +1,7 @@
import type { NextApiRequest } from "next"; import type { NextApiRequest } from "next";
import { stringify } from "querystring"; import { stringify } from "querystring";
import { WEBAPP_URL } from "@calcom/lib/constants"; import { WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants";
import { defaultHandler } from "@calcom/lib/server/defaultHandler"; import { defaultHandler } from "@calcom/lib/server/defaultHandler";
import { defaultResponder } from "@calcom/lib/server/defaultResponder"; import { defaultResponder } from "@calcom/lib/server/defaultResponder";
import prisma from "@calcom/prisma"; import prisma from "@calcom/prisma";
@@ -26,7 +26,7 @@ async function handler(req: NextApiRequest) {
const params = { const params = {
response_type: "code", response_type: "code",
client_id, client_id,
redirect_uri: `${WEBAPP_URL}/api/integrations/zoomvideo/callback`, redirect_uri: `${WEBAPP_URL_FOR_OAUTH}/api/integrations/zoomvideo/callback`,
state, state,
}; };
const query = stringify(params); const query = stringify(params);
+2 -2
View File
@@ -1,6 +1,6 @@
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import { WEBAPP_URL } from "@calcom/lib/constants"; import { WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants";
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl"; import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
import prisma from "@calcom/prisma"; import prisma from "@calcom/prisma";
@@ -14,7 +14,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const { code } = req.query; const { code } = req.query;
const { client_id, client_secret } = await getZoomAppKeys(); const { client_id, client_secret } = await getZoomAppKeys();
const redirectUri = encodeURI(`${WEBAPP_URL}/api/integrations/zoomvideo/callback`); const redirectUri = encodeURI(`${WEBAPP_URL_FOR_OAUTH}/api/integrations/zoomvideo/callback`);
const authHeader = `Basic ${Buffer.from(`${client_id}:${client_secret}`).toString("base64")}`; const authHeader = `Basic ${Buffer.from(`${client_id}:${client_secret}`).toString("base64")}`;
const result = await fetch( const result = await fetch(
`https://zoom.us/oauth/token?grant_type=authorization_code&code=${code}&redirect_uri=${redirectUri}`, `https://zoom.us/oauth/token?grant_type=authorization_code&code=${code}&redirect_uri=${redirectUri}`,
@@ -8,6 +8,7 @@ import {
CREDENTIAL_SYNC_SECRET_HEADER_NAME, CREDENTIAL_SYNC_SECRET_HEADER_NAME,
} from "@calcom/lib/constants"; } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger"; import logger from "@calcom/lib/logger";
import { getPiiFreeCalendarEvent } from "@calcom/lib/piiFreeData";
import { safeStringify } from "@calcom/lib/safeStringify"; import { safeStringify } from "@calcom/lib/safeStringify";
import prisma from "@calcom/prisma"; import prisma from "@calcom/prisma";
import { Frequency } from "@calcom/prisma/zod-utils"; import { Frequency } from "@calcom/prisma/zod-utils";
@@ -63,12 +64,16 @@ export type ZoomUserSettings = z.infer<typeof zoomUserSettingsSchema>;
/** @link https://developers.zoom.us/docs/api/rest/reference/user/methods/#operation/userSettings */ /** @link https://developers.zoom.us/docs/api/rest/reference/user/methods/#operation/userSettings */
export const zoomUserSettingsSchema = z.object({ export const zoomUserSettingsSchema = z.object({
recording: z.object({ recording: z
auto_recording: z.string(), .object({
}), auto_recording: z.string().nullish(),
schedule_meeting: z.object({ })
default_password_for_scheduled_meetings: z.string(), .nullish(),
}), schedule_meeting: z
.object({
default_password_for_scheduled_meetings: z.string().nullish(),
})
.nullish(),
}); });
// https://developers.zoom.us/docs/api/rest/reference/user/methods/#operation/userSettings // https://developers.zoom.us/docs/api/rest/reference/user/methods/#operation/userSettings
@@ -181,6 +186,32 @@ const ZoomVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter =>
}; };
}; };
/**
* Zoom is known to return xml response in some cases.
* e.g. Wrong request or some special case of invalid token
*/
const handleZoomResponseJsonParseError = async ({
error,
clonedResponse,
}: {
error: unknown;
clonedResponse: Response;
}) => {
// In some cases, Zoom responds with xml response, so we log the response for debugging
// We need to see why that error occurs exactly and then later we decide if mark the access token and token object unusable or not
log.error(
"Error in JSON parsing Zoom API response",
safeStringify({
error: safeStringify(error),
// Log Raw response body here.
responseBody: await clonedResponse.text(),
status: clonedResponse.status,
})
);
return null;
};
const fetchZoomApi = async (endpoint: string, options?: RequestInit) => { const fetchZoomApi = async (endpoint: string, options?: RequestInit) => {
const auth = new OAuthManager({ const auth = new OAuthManager({
credentialSyncVariables: { credentialSyncVariables: {
@@ -216,9 +247,18 @@ const ZoomVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter =>
}, },
isTokenObjectUnusable: async function (response) { isTokenObjectUnusable: async function (response) {
const myLog = logger.getSubLogger({ prefix: ["zoomvideo:isTokenObjectUnusable"] }); const myLog = logger.getSubLogger({ prefix: ["zoomvideo:isTokenObjectUnusable"] });
myLog.debug(safeStringify({ status: response.status, ok: response.ok })); myLog.info(safeStringify({ status: response.status, ok: response.ok }));
if (!response.ok || (response.status < 200 && response.status >= 300)) { if (!response.ok) {
const responseBody = await response.json(); let responseBody;
const responseToUseInCaseOfError = response.clone();
try {
responseBody = await response.json();
} catch (e) {
return await handleZoomResponseJsonParseError({
error: e,
clonedResponse: responseToUseInCaseOfError,
});
}
myLog.debug(safeStringify({ responseBody })); myLog.debug(safeStringify({ responseBody }));
if (responseBody.error === "invalid_grant") { if (responseBody.error === "invalid_grant") {
@@ -229,11 +269,20 @@ const ZoomVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter =>
}, },
isAccessTokenUnusable: async function (response) { isAccessTokenUnusable: async function (response) {
const myLog = logger.getSubLogger({ prefix: ["zoomvideo:isAccessTokenUnusable"] }); const myLog = logger.getSubLogger({ prefix: ["zoomvideo:isAccessTokenUnusable"] });
myLog.debug(safeStringify({ status: response.status, ok: response.ok })); myLog.info(safeStringify({ status: response.status, ok: response.ok }));
if (!response.ok || (response.status < 200 && response.status >= 300)) { if (!response.ok) {
const responseBody = await response.json(); let responseBody;
const responseToUseInCaseOfError = response.clone();
try {
responseBody = await response.json();
} catch (e) {
return await handleZoomResponseJsonParseError({
error: e,
clonedResponse: responseToUseInCaseOfError,
});
}
myLog.debug(safeStringify({ responseBody })); myLog.debug(safeStringify({ responseBody }));
// 124 is the error code for invalid access token from Zoom API
if (responseBody.code === 124) { if (responseBody.code === 124) {
return { reason: responseBody.message ?? "" }; return { reason: responseBody.message ?? "" };
} }
@@ -280,7 +329,7 @@ const ZoomVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter =>
end: new Date(new Date(meeting.start_time).getTime() + meeting.duration * 60000).toISOString(), end: new Date(new Date(meeting.start_time).getTime() + meeting.duration * 60000).toISOString(),
})); }));
} catch (err) { } catch (err) {
console.error(err); log.error("Failed to get availability", safeStringify(err));
/* Prevents booking failure when Zoom Token is expired */ /* Prevents booking failure when Zoom Token is expired */
return []; return [];
} }
@@ -307,7 +356,10 @@ const ZoomVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter =>
} }
throw new Error(`Failed to create meeting. Response is ${JSON.stringify(result)}`); throw new Error(`Failed to create meeting. Response is ${JSON.stringify(result)}`);
} catch (err) { } catch (err) {
console.error(err); log.error(
"Zoom meeting creation failed",
safeStringify({ error: safeStringify(err), event: getPiiFreeCalendarEvent(event) })
);
/* Prevents meeting creation failure when Zoom Token is expired */ /* Prevents meeting creation failure when Zoom Token is expired */
throw new Error("Unexpected error"); throw new Error("Unexpected error");
} }
@@ -342,7 +394,10 @@ const ZoomVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter =>
url: result.join_url, url: result.join_url,
}; };
} catch (err) { } catch (err) {
log.error("Failed to update meeting", safeStringify(err)); log.error(
"Failed to update meeting",
safeStringify({ error: err, event: getPiiFreeCalendarEvent(event) })
);
return Promise.reject(new Error("Failed to update meeting")); return Promise.reject(new Error("Failed to update meeting"));
} }
}, },
+1 -1
View File
@@ -33,6 +33,6 @@
"prettier-plugin-tailwindcss": "^0.2.5", "prettier-plugin-tailwindcss": "^0.2.5",
"tailwind-scrollbar": "^2.0.1", "tailwind-scrollbar": "^2.0.1",
"tailwindcss": "^3.3.3", "tailwindcss": "^3.3.3",
"typescript": "^5.8.3" "typescript": "^5.7.2"
} }
} }
+1 -1
View File
@@ -54,7 +54,7 @@
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"postcss": "^8.4.18", "postcss": "^8.4.18",
"tailwindcss": "^3.3.3", "tailwindcss": "^3.3.3",
"typescript": "^5.8.3", "typescript": "^5.7.2",
"vite": "^4.1.2", "vite": "^4.1.2",
"vite-plugin-environment": "^1.1.3" "vite-plugin-environment": "^1.1.3"
} }
@@ -6,6 +6,7 @@ import type { Mock } from "vitest";
import { EmbedElement } from "./EmbedElement"; import { EmbedElement } from "./EmbedElement";
import inlineHTML from "./Inline/inlineHtml"; import inlineHTML from "./Inline/inlineHtml";
import { EMBED_DARK_THEME_CLASS, EMBED_LIGHT_THEME_CLASS } from "./constants";
import { getColorSchemeDarkQuery } from "./ui-utils"; import { getColorSchemeDarkQuery } from "./ui-utils";
(function defineEmbedTestElement() { (function defineEmbedTestElement() {
@@ -269,8 +270,8 @@ describe("EmbedElement", () => {
}, },
getSkeletonData: vi.fn(), getSkeletonData: vi.fn(),
}); });
expect(element.classList.contains("dark")).toBe(true); expect(element.classList.contains(EMBED_DARK_THEME_CLASS)).toBe(true);
expect(element.classList.contains("light")).toBe(false); expect(element.classList.contains(EMBED_LIGHT_THEME_CLASS)).toBe(false);
}); });
it("should update theme class when system preference changes as long as embed theme is not set", () => { it("should update theme class when system preference changes as long as embed theme is not set", () => {
@@ -279,8 +280,8 @@ describe("EmbedElement", () => {
}); });
getColorSchemeDarkQuery().dispatchEvent(buildMediaQueryListEvent({ type: "change", matches: true })); getColorSchemeDarkQuery().dispatchEvent(buildMediaQueryListEvent({ type: "change", matches: true }));
expect(element.classList.contains("dark")).toBe(true); expect(element.classList.contains(EMBED_DARK_THEME_CLASS)).toBe(true);
expect(element.classList.contains("light")).toBe(false); expect(element.classList.contains(EMBED_LIGHT_THEME_CLASS)).toBe(false);
}); });
it("should not update theme on system color scheme change when embed theme is set", () => { it("should not update theme on system color scheme change when embed theme is set", () => {
@@ -291,7 +292,7 @@ describe("EmbedElement", () => {
getSkeletonData: vi.fn(), getSkeletonData: vi.fn(),
}); });
getColorSchemeDarkQuery().dispatchEvent(buildMediaQueryListEvent({ type: "change", matches: true })); getColorSchemeDarkQuery().dispatchEvent(buildMediaQueryListEvent({ type: "change", matches: true }));
expect(element.classList.contains("light")).toBe(true); expect(element.classList.contains(EMBED_LIGHT_THEME_CLASS)).toBe(true);
}); });
}); });
+16 -11
View File
@@ -1,3 +1,4 @@
import { EMBED_DARK_THEME_CLASS, EMBED_LIGHT_THEME_CLASS } from "./constants";
import type { EmbedThemeConfig, AllPossibleLayouts, BookerLayouts, EmbedPageType } from "./types"; import type { EmbedThemeConfig, AllPossibleLayouts, BookerLayouts, EmbedPageType } from "./types";
import { import {
getThemeClassForEmbed, getThemeClassForEmbed,
@@ -157,9 +158,7 @@ export class EmbedElement extends HTMLElement {
this.isModal = data.isModal; this.isModal = data.isModal;
this.layout = this.getLayout(); this.layout = this.getLayout();
this.theme = this.dataset.theme as EmbedThemeConfig | null; this.theme = this.dataset.theme as EmbedThemeConfig | null;
this.themeClass = getThemeClassForEmbed({ theme: this.theme }); this.setTheme(this.theme);
this.classList.add(this.themeClass);
this.getSkeletonData = data.getSkeletonData; this.getSkeletonData = data.getSkeletonData;
this.boundResizeHandler = this.resizeHandler.bind(this); this.boundResizeHandler = this.resizeHandler.bind(this);
this.boundPrefersDarkThemeChangedHandler = this.prefersDarkThemeChangedHandler.bind(this); this.boundPrefersDarkThemeChangedHandler = this.prefersDarkThemeChangedHandler.bind(this);
@@ -197,20 +196,26 @@ export class EmbedElement extends HTMLElement {
skeletonEl.innerHTML = skeletonContent; skeletonEl.innerHTML = skeletonContent;
} }
public setTheme(theme: EmbedThemeConfig | null) {
const allPossibleThemeClasses = [EMBED_DARK_THEME_CLASS, EMBED_LIGHT_THEME_CLASS];
const newThemeClass = getThemeClassForEmbed({ theme });
if (newThemeClass === this.themeClass) {
return;
}
this.themeClass = newThemeClass;
this.classList.remove(...allPossibleThemeClasses);
this.classList.add(this.themeClass);
}
public prefersDarkThemeChangedHandler(e: MediaQueryListEvent) { public prefersDarkThemeChangedHandler(e: MediaQueryListEvent) {
const isDarkPreferred = e.matches; const isDarkPreferred = e.matches;
const allPossibleThemeClasses = ["dark", "light"];
if (isThemePreferenceProvided(this.theme)) { if (isThemePreferenceProvided(this.theme)) {
// User has provided a theme preference, so we stick to that and don't react to system theme change // User has provided a theme preference, so we stick to that and don't react to system theme change
return; return;
} }
const newThemeClass = getThemeClassForEmbed({ this.setTheme(isDarkPreferred ? "dark" : "light");
theme: isDarkPreferred ? "dark" : "light",
});
if (newThemeClass !== this.themeClass) {
this.classList.remove(...allPossibleThemeClasses);
this.classList.add(newThemeClass);
}
} }
connectedCallback() { connectedCallback() {
// Make sure to show the loader initially. // Make sure to show the loader initially.
@@ -7,6 +7,11 @@ type fakeCurrentDocumentUrlParams = {
params?: Record<string, string>; params?: Record<string, string>;
}; };
beforeEach(() => {
// Ensure that we have it globally so that unexpected errors like 'document is not defined' don't happen due to timer being fired when test is shutting down
vi.useFakeTimers();
});
function fakeCurrentDocumentUrl({ function fakeCurrentDocumentUrl({
origin = "https://example.com", origin = "https://example.com",
path = "", path = "",
@@ -1,3 +1,5 @@
import { EMBED_DARK_THEME_CLASS } from "./constants";
export const addAppCssVars = () => { export const addAppCssVars = () => {
const calElements = ["cal-floating-button", "cal-modal-box", "cal-inline"]; const calElements = ["cal-floating-button", "cal-modal-box", "cal-inline"];
try { try {
@@ -148,7 +150,7 @@ export const addAppCssVars = () => {
--cal-brand-text: hsla(0, 0%, 100%, 1); /* white */ --cal-brand-text: hsla(0, 0%, 100%, 1); /* white */
} }
${calElements.map((element) => `${element}.dark`).join(",")} { ${calElements.map((element) => `${element}.${EMBED_DARK_THEME_CLASS}`).join(",")} {
/* Background Standard */ /* Background Standard */
--cal-bg-emphasis: hsla(0, 0%, 25%, 1); /* stone-700 */ --cal-bg-emphasis: hsla(0, 0%, 25%, 1); /* stone-700 */
--cal-bg: hsla(0, 0%, 6%, 1); /* stone-950 */ --cal-bg: hsla(0, 0%, 6%, 1); /* stone-950 */
@@ -10,3 +10,8 @@ if (EMBED_MODAL_IFRAME_SLOT_STALE_TIME > EMBED_MODAL_IFRAME_FORCE_RELOAD_THRESHO
"EMBED_MODAL_IFRAME_SLOT_STALE_TIME must be less than EMBED_MODAL_IFRAME_FORCE_RELOAD_THRESHOLD_MS" "EMBED_MODAL_IFRAME_SLOT_STALE_TIME must be less than EMBED_MODAL_IFRAME_FORCE_RELOAD_THRESHOLD_MS"
); );
} }
// These classes are applied to Embed Elements and thus are in the same scope as the embedding webpage.
// So, these classes need to be unique to us, to avoid accidental override
export const EMBED_LIGHT_THEME_CLASS = "cal-element-embed-light";
export const EMBED_DARK_THEME_CLASS = "cal-element-embed-dark";
@@ -594,6 +594,9 @@ function main() {
return; return;
} }
const willSlotsBeFetched = url.searchParams.get("cal.skipSlotsFetch") !== "true";
log(`Slots will ${willSlotsBeFetched ? "" : "NOT "}be fetched`);
window.addEventListener("message", (e) => { window.addEventListener("message", (e) => {
const data: Message = e.data; const data: Message = e.data;
if (!data) { if (!data) {
@@ -631,7 +634,7 @@ function main() {
if (url.searchParams.get("preload") !== "true" && window?.isEmbed?.()) { if (url.searchParams.get("preload") !== "true" && window?.isEmbed?.()) {
initializeAndSetupEmbed(); initializeAndSetupEmbed();
} else { } else {
log(`Preloaded scenario - Skipping initialization and setup`); log(`Preloaded scenario - Skipping initialization and setup as only assets need to be loaded`);
} }
} }
@@ -679,6 +682,7 @@ function actOnColorScheme(colorScheme: string | null | undefined) {
/** /**
* Apply configurations to the preloaded page and then ask parent to show the embed * Apply configurations to the preloaded page and then ask parent to show the embed
* If there is a need to fetch the slots, then the slots would be fetched and then only this function call would complete
* url has the config as params * url has the config as params
*/ */
async function connectPreloadedEmbed({ async function connectPreloadedEmbed({
+4 -4
View File
@@ -1215,26 +1215,26 @@ describe("Cal", () => {
expect(result).toBe("noAction"); expect(result).toBe("noAction");
}); });
it("should return connect-no-slots-fetch when reuseFully is true and slots are not stale", () => { it("should return connect-no-slots-fetch when backgroundSlotsFetch is true and slots are not stale", () => {
const result = calInstance.getNextActionForModal({ const result = calInstance.getNextActionForModal({
...baseArgs, ...baseArgs,
stateData: { stateData: {
...baseArgs.stateData, ...baseArgs.stateData,
prerenderOptions: { prerenderOptions: {
reuseFully: true, backgroundSlotsFetch: true,
}, },
}, },
}); });
expect(result).toBe("connect-no-slots-fetch"); expect(result).toBe("connect-no-slots-fetch");
}); });
it("should return connect when reuseFully is true but slots are stale", () => { it("should return connect when backgroundSlotsFetch is true but slots are stale", () => {
const result = calInstance.getNextActionForModal({ const result = calInstance.getNextActionForModal({
...baseArgs, ...baseArgs,
stateData: { stateData: {
...baseArgs.stateData, ...baseArgs.stateData,
prerenderOptions: { prerenderOptions: {
reuseFully: true, backgroundSlotsFetch: true,
}, },
previousEmbedRenderStartTime: Date.now() - EMBED_MODAL_IFRAME_SLOT_STALE_TIME - 1, previousEmbedRenderStartTime: Date.now() - EMBED_MODAL_IFRAME_SLOT_STALE_TIME - 1,
embedRenderStartTime: Date.now(), embedRenderStartTime: Date.now(),
+21 -7
View File
@@ -55,6 +55,8 @@ type ModalPrerenderOptions = {
slotsStaleTimeMs?: number; slotsStaleTimeMs?: number;
iframeForceReloadThresholdMs?: number; iframeForceReloadThresholdMs?: number;
reuseFully?: boolean; reuseFully?: boolean;
// Default value is true right now but it would change soon
backgroundSlotsFetch?: boolean;
}; };
type ModalStateData = { type ModalStateData = {
@@ -598,7 +600,7 @@ export class Cal {
return "fullReload"; return "fullReload";
} }
if (prerenderOptions?.reuseFully && !areSlotsStale) { if (prerenderOptions?.backgroundSlotsFetch && !areSlotsStale) {
return "connect-no-slots-fetch"; return "connect-no-slots-fetch";
} }
@@ -986,6 +988,7 @@ class CalApi {
const isHeadlessRouterPath = calLinkUrlObject ? isRouterPath(calLinkUrlObject.toString()) : false; const isHeadlessRouterPath = calLinkUrlObject ? isRouterPath(calLinkUrlObject.toString()) : false;
if (__prerender && this.cal.modalBox) { if (__prerender && this.cal.modalBox) {
log("Destroying previous prerendered modalbox");
// If we are re-prerendering, we destroy the previous modalbox, allowing user to prerender as many times as they want // If we are re-prerendering, we destroy the previous modalbox, allowing user to prerender as many times as they want
this.cal.modalBox.remove(); this.cal.modalBox.remove();
} }
@@ -999,9 +1002,18 @@ class CalApi {
const containerEl = document.body; const containerEl = document.body;
this.cal.isPrerendering = !!__prerender; this.cal.isPrerendering = !!__prerender;
if (__prerender) { if (__prerender) {
// TODO: Make `reuseFully` a configurable param as well later. // While prerendering headless router path, we by default want to fetch slots in background(for the time being)
// If someone's preloading the headless router path, we must reuse the iframe fully as is as Router would redirect to a Booking Page and that should be shown as is // For other prerenderings, we don't want to fetch slots in background - Keeping the behaviour same as before
this.prerenderOptions = { ...prerenderOptions, reuseFully: isHeadlessRouterPath }; const DEFAULT_BACKGROUND_SLOTS_FETCH = isHeadlessRouterPath ? true : false;
const backgroundSlotsFetch =
typeof prerenderOptions.backgroundSlotsFetch === "undefined"
? DEFAULT_BACKGROUND_SLOTS_FETCH
: prerenderOptions.backgroundSlotsFetch;
this.prerenderOptions = {
...prerenderOptions,
backgroundSlotsFetch,
};
// Add prerender query param // Add prerender query param
config.prerender = "true"; config.prerender = "true";
@@ -1010,7 +1022,7 @@ class CalApi {
config["cal.queueFormResponse"] = "true"; config["cal.queueFormResponse"] = "true";
} }
if (!this.prerenderOptions?.reuseFully) { if (!backgroundSlotsFetch) {
// When prerendering, we don't want to preload slots as they might be outdated anyway by the time they are used // When prerendering, we don't want to preload slots as they might be outdated anyway by the time they are used
// Also, when used with Headless Router attributes setup, we might endup fetching slots for a lot of people, which would be a waste and unnecessary load on Cal.com resources // Also, when used with Headless Router attributes setup, we might endup fetching slots for a lot of people, which would be a waste and unnecessary load on Cal.com resources
config["cal.skipSlotsFetch"] = "true"; config["cal.skipSlotsFetch"] = "true";
@@ -1045,9 +1057,11 @@ class CalApi {
isConnectionInitiated, isConnectionInitiated,
prerenderOptions: this.prerenderOptions ?? null, prerenderOptions: this.prerenderOptions ?? null,
}; };
const lastLoadedUrlObject = this.cal.getLastLoadedLinkInframe();
const lastLoadedPathIsRouter = lastLoadedUrlObject?.pathname?.includes("/router");
// If we want to reuse fully then we shouldn't submit response again // TODO: This branch needs to be removed as we don't want to support this use case anymore.
if (isHeadlessRouterPath && !this.prerenderOptions?.reuseFully) { if (isHeadlessRouterPath && !lastLoadedPathIsRouter) {
// Immediately take it to loading state. Either through connect or through loadInIframe, it would later be updated // Immediately take it to loading state. Either through connect or through loadInIframe, it would later be updated
existingModalEl.setAttribute("state", "loading"); existingModalEl.setAttribute("state", "loading");
+2 -1
View File
@@ -1,3 +1,4 @@
import { EMBED_DARK_THEME_CLASS, EMBED_LIGHT_THEME_CLASS } from "./constants";
import type { AllPossibleLayouts, EmbedThemeConfig } from "./types"; import type { AllPossibleLayouts, EmbedThemeConfig } from "./types";
export function getMaxHeightForModal() { export function getMaxHeightForModal() {
@@ -32,7 +33,7 @@ function detectColorScheme() {
} }
function getClassBasedOnTheme(theme: EmbedThemeConfig | undefined | null) { function getClassBasedOnTheme(theme: EmbedThemeConfig | undefined | null) {
return theme ?? "light"; return theme === "dark" ? EMBED_DARK_THEME_CLASS : EMBED_LIGHT_THEME_CLASS;
} }
export function isThemePreferenceProvided(theme: EmbedThemeConfig | undefined | null) { export function isThemePreferenceProvided(theme: EmbedThemeConfig | undefined | null) {
+1 -1
View File
@@ -50,7 +50,7 @@
"@vitejs/plugin-react": "^2.2.0", "@vitejs/plugin-react": "^2.2.0",
"eslint": "^8.34.0", "eslint": "^8.34.0",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"typescript": "^5.8.3", "typescript": "^5.7.2",
"vite": "^4.5.2" "vite": "^4.5.2"
}, },
"dependencies": { "dependencies": {
+1 -1
View File
@@ -25,7 +25,7 @@
], ],
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"devDependencies": { "devDependencies": {
"typescript": "^5.8.3", "typescript": "^5.7.2",
"vite": "^4.1.2" "vite": "^4.1.2"
}, },
"dependencies": { "dependencies": {
+1 -1
View File
@@ -8,7 +8,7 @@
"@typescript-eslint/parser": "^5.52.0", "@typescript-eslint/parser": "^5.52.0",
"@typescript-eslint/utils": "^5.52.0", "@typescript-eslint/utils": "^5.52.0",
"ts-node": "^10.9.1", "ts-node": "^10.9.1",
"typescript": "^5.8.3" "typescript": "^5.7.2"
}, },
"devDependencies": { "devDependencies": {
"@types/eslint": "^8.4.5" "@types/eslint": "^8.4.5"
@@ -438,7 +438,7 @@ export const getOptions = ({
encode: async ({ token, maxAge, secret }) => { encode: async ({ token, maxAge, secret }) => {
log.debug("jwt:encode", safeStringify({ token, maxAge })); log.debug("jwt:encode", safeStringify({ token, maxAge }));
if (token?.sub && isNumber(token.sub)) { if (token?.sub && isNumber(token.sub)) {
const user = await prisma.user.findUnique({ const user = await prisma.user.findFirst({
where: { id: Number(token.sub) }, where: { id: Number(token.sub) },
select: { metadata: true }, select: { metadata: true },
}); });
@@ -489,7 +489,7 @@ export const getOptions = ({
} as JWT; } as JWT;
} }
const autoMergeIdentities = async () => { const autoMergeIdentities = async () => {
const existingUser = await prisma.user.findUnique({ const existingUser = await prisma.user.findFirst({
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
where: { email: token.email! }, where: { email: token.email! },
select: { select: {
@@ -855,7 +855,7 @@ export const getOptions = ({
// If the email address doesn't match, check if an account already exists // If the email address doesn't match, check if an account already exists
// with the new email address. If it does, for now we return an error. If // with the new email address. If it does, for now we return an error. If
// not, update the email of their account and log them in. // not, update the email of their account and log them in.
const userWithNewEmail = await prisma.user.findUnique({ const userWithNewEmail = await prisma.user.findFirst({
where: { email: user.email }, where: { email: user.email },
}); });
@@ -875,9 +875,12 @@ export const getOptions = ({
// a new account. If an account already exists with the incoming email // a new account. If an account already exists with the incoming email
// address return an error for now. // address return an error for now.
const existingUserWithEmail = await prisma.user.findUnique({ const existingUserWithEmail = await prisma.user.findFirst({
where: { where: {
email: user.email, email: {
equals: user.email,
mode: "insensitive",
},
}, },
include: { include: {
password: true, password: true,
@@ -168,7 +168,9 @@ export const EventMeta = ({
{translatedTitle ?? event?.title} {translatedTitle ?? event?.title}
</EventTitle> </EventTitle>
{(event.description || translatedDescription) && ( {(event.description || translatedDescription) && (
<EventMetaBlock contentClassName="mb-8 break-words max-w-full max-h-[180px] scroll-bar pr-4"> <EventMetaBlock
data-testid="event-meta-description"
contentClassName="mb-8 break-words max-w-full max-h-[180px] scroll-bar pr-4">
<div <div
// eslint-disable-next-line react/no-danger // eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
@@ -43,16 +43,14 @@ type EventDetailCustomBlock = {
type EventDetailsProps = EventDetailsPropsBase & (EventDetailDefaultBlock | EventDetailCustomBlock); type EventDetailsProps = EventDetailsPropsBase & (EventDetailDefaultBlock | EventDetailCustomBlock);
interface EventMetaProps { interface EventMetaProps extends React.HTMLAttributes<HTMLDivElement> {
customIcon?: React.ReactNode; customIcon?: React.ReactNode;
icon?: IconName; icon?: IconName;
iconUrl?: string; iconUrl?: string;
children: React.ReactNode;
// Emphasises the text in the block. For now only // Emphasises the text in the block. For now only
// applying in dark mode. // applying in dark mode.
highlight?: boolean; highlight?: boolean;
contentClassName?: string; contentClassName?: string;
className?: string;
isDark?: boolean; isDark?: boolean;
} }
@@ -80,6 +78,7 @@ export const EventMetaBlock = ({
contentClassName, contentClassName,
className, className,
isDark, isDark,
...rest
}: EventMetaProps) => { }: EventMetaProps) => {
if (!React.Children.count(children)) return null; if (!React.Children.count(children)) return null;
@@ -89,7 +88,8 @@ export const EventMetaBlock = ({
"flex items-start justify-start text-sm", "flex items-start justify-start text-sm",
highlight ? "text-emphasis" : "text-text", highlight ? "text-emphasis" : "text-text",
className className
)}> )}
{...rest}>
{iconUrl ? ( {iconUrl ? (
<img <img
src={iconUrl} src={iconUrl}
@@ -164,4 +164,354 @@ describe("checkForConflicts", () => {
expect(result).toBe(false); expect(result).toBe(false);
}); });
}); });
describe("comprehensive edge cases and boundary conditions", () => {
it("should return false for empty busy array", () => {
const result = checkForConflicts({
...createTestData("2023-01-01T09:00:00Z"),
busy: [],
});
expect(result).toBe(false);
});
it("should handle slot that starts exactly when busy period ends", () => {
const result = checkForConflicts({
...createTestData("2023-01-01T09:30:00Z"),
busy: [
{
start: dayjs.utc("2023-01-01T09:00:00Z").toDate(),
end: dayjs.utc("2023-01-01T09:30:00Z").toDate(),
},
],
});
expect(result).toBe(false);
});
it("should handle slot that ends exactly when busy period starts", () => {
const result = checkForConflicts({
...createTestData("2023-01-01T08:30:00Z"),
busy: [
{
start: dayjs.utc("2023-01-01T09:00:00Z").toDate(),
end: dayjs.utc("2023-01-01T09:30:00Z").toDate(),
},
],
});
expect(result).toBe(false);
});
it("should handle slot start exactly matching busy period start (inclusive)", () => {
const result = checkForConflicts({
...createTestData("2023-01-01T09:00:00Z"),
busy: [
{
start: dayjs.utc("2023-01-01T09:00:00Z").toDate(),
end: dayjs.utc("2023-01-01T09:15:00Z").toDate(),
},
],
});
expect(result).toBe(true);
});
it("should handle slot end exactly matching busy period end (inclusive)", () => {
const result = checkForConflicts({
...createTestData("2023-01-01T09:15:00Z"),
busy: [
{
start: dayjs.utc("2023-01-01T09:30:00Z").toDate(),
end: dayjs.utc("2023-01-01T09:45:00Z").toDate(),
},
],
});
expect(result).toBe(true);
});
it("should handle very short event length (1 minute)", () => {
const result = checkForConflicts({
time: dayjs("2023-01-01T09:00:00Z"),
eventLength: 1,
busy: [
{
start: dayjs.utc("2023-01-01T09:00:30Z").toDate(),
end: dayjs.utc("2023-01-01T09:01:30Z").toDate(),
},
],
});
expect(result).toBe(true);
});
it("should handle very long event length (8 hours)", () => {
const result = checkForConflicts({
time: dayjs("2023-01-01T09:00:00Z"),
eventLength: 480, // 8 hours
busy: [
{
start: dayjs.utc("2023-01-01T12:00:00Z").toDate(),
end: dayjs.utc("2023-01-01T13:00:00Z").toDate(),
},
],
});
expect(result).toBe(true);
});
});
describe("multiple busy periods scenarios", () => {
it("should return true when first busy period conflicts", () => {
const result = checkForConflicts({
...createTestData("2023-01-01T09:00:00Z"),
busy: [
{
start: dayjs.utc("2023-01-01T09:15:00Z").toDate(),
end: dayjs.utc("2023-01-01T09:45:00Z").toDate(),
},
{
start: dayjs.utc("2023-01-01T10:00:00Z").toDate(),
end: dayjs.utc("2023-01-01T11:00:00Z").toDate(),
},
],
});
expect(result).toBe(true);
});
it("should return true when last busy period conflicts", () => {
const result = checkForConflicts({
...createTestData("2023-01-01T10:30:00Z"),
busy: [
{
start: dayjs.utc("2023-01-01T08:00:00Z").toDate(),
end: dayjs.utc("2023-01-01T09:00:00Z").toDate(),
},
{
start: dayjs.utc("2023-01-01T10:45:00Z").toDate(),
end: dayjs.utc("2023-01-01T11:15:00Z").toDate(),
},
],
});
expect(result).toBe(true);
});
it("should return true when middle busy period conflicts", () => {
const result = checkForConflicts({
...createTestData("2023-01-01T10:00:00Z"),
busy: [
{
start: dayjs.utc("2023-01-01T08:00:00Z").toDate(),
end: dayjs.utc("2023-01-01T09:00:00Z").toDate(),
},
{
start: dayjs.utc("2023-01-01T10:15:00Z").toDate(),
end: dayjs.utc("2023-01-01T10:45:00Z").toDate(),
},
{
start: dayjs.utc("2023-01-01T11:00:00Z").toDate(),
end: dayjs.utc("2023-01-01T12:00:00Z").toDate(),
},
],
});
expect(result).toBe(true);
});
it("should handle many non-overlapping busy periods", () => {
const busyPeriods = [];
for (let i = 0; i < 10; i++) {
const hour = (8 + i * 2) % 24; // Wrap around to prevent invalid hours
busyPeriods.push({
start: dayjs.utc(`2023-01-01T${hour.toString().padStart(2, "0")}:00:00Z`).toDate(),
end: dayjs.utc(`2023-01-01T${hour.toString().padStart(2, "0")}:30:00Z`).toDate(),
});
}
const result = checkForConflicts({
...createTestData("2023-01-01T07:00:00Z"),
busy: busyPeriods,
});
expect(result).toBe(false);
});
it("should handle overlapping busy periods", () => {
const result = checkForConflicts({
...createTestData("2023-01-01T09:00:00Z"),
busy: [
{
start: dayjs.utc("2023-01-01T09:15:00Z").toDate(),
end: dayjs.utc("2023-01-01T09:45:00Z").toDate(),
},
{
start: dayjs.utc("2023-01-01T09:30:00Z").toDate(),
end: dayjs.utc("2023-01-01T10:00:00Z").toDate(),
},
],
});
expect(result).toBe(true);
});
});
describe("currentSeats comprehensive scenarios", () => {
it("should return false when currentSeats has exact time match", () => {
const testTime = dayjs.utc("2023-01-01T09:00:00Z");
const currentSeats: CurrentSeats = [
{
uid: "booking-1",
startTime: testTime.toDate(),
_count: { attendees: 2 },
},
];
const result = checkForConflicts({
time: testTime,
eventLength: 30,
busy: [
{
start: dayjs.utc("2023-01-01T09:15:00Z").toDate(),
end: dayjs.utc("2023-01-01T09:45:00Z").toDate(),
},
],
currentSeats,
});
expect(result).toBe(false);
});
it("should return true when currentSeats has no matching time but busy period conflicts", () => {
const currentSeats: CurrentSeats = [
{
uid: "booking-1",
startTime: dayjs.utc("2023-01-01T08:00:00Z").toDate(),
_count: { attendees: 1 },
},
];
const result = checkForConflicts({
time: dayjs.utc("2023-01-01T09:00:00Z"),
eventLength: 30,
busy: [
{
start: dayjs.utc("2023-01-01T09:15:00Z").toDate(),
end: dayjs.utc("2023-01-01T09:45:00Z").toDate(),
},
],
currentSeats,
});
expect(result).toBe(true);
});
it("should handle multiple currentSeats with one matching", () => {
const testTime = dayjs.utc("2023-01-01T09:00:00Z");
const currentSeats: CurrentSeats = [
{
uid: "booking-1",
startTime: dayjs.utc("2023-01-01T08:00:00Z").toDate(),
_count: { attendees: 1 },
},
{
uid: "booking-2",
startTime: testTime.toDate(),
_count: { attendees: 1 },
},
];
const result = checkForConflicts({
time: testTime,
eventLength: 30,
busy: [
{
start: dayjs.utc("2023-01-01T09:15:00Z").toDate(),
end: dayjs.utc("2023-01-01T09:45:00Z").toDate(),
},
],
currentSeats,
});
expect(result).toBe(false);
});
it("should handle empty currentSeats array", () => {
const result = checkForConflicts({
time: dayjs.utc("2023-01-01T09:00:00Z"),
eventLength: 30,
busy: [
{
start: dayjs.utc("2023-01-01T09:15:00Z").toDate(),
end: dayjs.utc("2023-01-01T09:45:00Z").toDate(),
},
],
currentSeats: [],
});
expect(result).toBe(true);
});
});
describe("timezone and date handling", () => {
it("should handle different timezone inputs correctly", () => {
const result = checkForConflicts({
time: dayjs("2023-01-01T09:00:00-05:00"), // EST
eventLength: 30,
busy: [
{
start: dayjs.utc("2023-01-01T14:15:00Z").toDate(), // UTC equivalent of 9:15 EST
end: dayjs.utc("2023-01-01T14:45:00Z").toDate(),
},
],
});
expect(result).toBe(true);
});
it("should handle cross-day boundaries", () => {
const result = checkForConflicts({
time: dayjs.utc("2023-01-01T23:45:00Z"),
eventLength: 30,
busy: [
{
start: dayjs.utc("2023-01-02T00:00:00Z").toDate(),
end: dayjs.utc("2023-01-02T00:30:00Z").toDate(),
},
],
});
expect(result).toBe(true);
});
});
describe("performance and stress scenarios", () => {
it("should handle large number of busy periods efficiently", () => {
const busyPeriods = [];
for (let i = 0; i < 1000; i++) {
const hour = (8 + (i % 12)) % 24; // Wrap around to prevent invalid hours
const minute = i % 60;
const endMinute = ((i % 60) + 15) % 60;
const endHour = endMinute < minute ? (hour + 1) % 24 : hour; // Handle minute overflow
busyPeriods.push({
start: dayjs
.utc(`2023-01-01T${hour.toString().padStart(2, "0")}:${minute.toString().padStart(2, "0")}:00Z`)
.toDate(),
end: dayjs
.utc(
`2023-01-01T${endHour.toString().padStart(2, "0")}:${endMinute.toString().padStart(2, "0")}:00Z`
)
.toDate(),
});
}
const startTime = performance.now();
const result = checkForConflicts({
...createTestData("2023-01-01T07:00:00Z"),
busy: busyPeriods,
});
const endTime = performance.now();
expect(result).toBe(false);
expect(endTime - startTime).toBeLessThan(100); // Should complete in under 100ms
});
it("should handle zero-duration busy periods", () => {
const result = checkForConflicts({
...createTestData("2023-01-01T09:00:00Z"),
busy: [
{
start: dayjs.utc("2023-01-01T09:15:00Z").toDate(),
end: dayjs.utc("2023-01-01T09:15:00Z").toDate(), // Zero duration
},
],
});
expect(result).toBe(true);
});
});
}); });
@@ -26,34 +26,25 @@ export function checkForConflicts({
if (currentSeats?.some((booking) => booking.startTime.toISOString() === time.toISOString())) { if (currentSeats?.some((booking) => booking.startTime.toISOString() === time.toISOString())) {
return false; return false;
} }
const slotStartDate = time.utc().toDate(); const slotStart = time.valueOf();
const slotEndDate = time.add(eventLength, "minutes").utc().toDate(); const slotEnd = slotStart + eventLength * 60 * 1000;
// handle busy times.
return busy.some((busyTime) => { const sortedBusyTimes = busy
const busyStartDate = dayjs.utc(busyTime.start).toDate(); .map((busyTime) => ({
const busyEndDate = dayjs.utc(busyTime.end).toDate(); start: dayjs.utc(busyTime.start).valueOf(),
// First check if there's any overlap at all end: dayjs.utc(busyTime.end).valueOf(),
// If busy period ends before slot starts or starts after slot ends, there's no overlap }))
if (busyEndDate <= slotStartDate || busyStartDate >= slotEndDate) { .sort((a, b) => a.start - b.start);
return false;
for (const busyTime of sortedBusyTimes) {
if (busyTime.start >= slotEnd) {
break;
} }
// Now check all possible overlap scenarios: if (busyTime.end <= slotStart) {
// 1. Slot start falls within busy period (inclusive start, exclusive end) continue;
if (slotStartDate >= busyStartDate && slotStartDate < busyEndDate) {
return true;
} }
// 2. Slot end falls within busy period (exclusive start, inclusive end) return true;
if (slotEndDate > busyStartDate && slotEndDate <= busyEndDate) { }
return true;
} return false;
// 3. Busy period completely contained within slot
if (busyStartDate >= slotStartDate && busyEndDate <= slotEndDate) {
return true;
}
// 4. Slot completely contained within busy period
if (busyStartDate <= slotStartDate && busyEndDate >= slotEndDate) {
return true;
}
return false;
});
} }
@@ -156,7 +156,7 @@ export const getBookingForReschedule = async (uid: string, userId?: number) => {
let attendeeEmail: string | null = null; let attendeeEmail: string | null = null;
let bookingSeatData: { description?: string; responses: Prisma.JsonValue } | null = null; let bookingSeatData: { description?: string; responses: Prisma.JsonValue } | null = null;
if (!theBooking) { if (!theBooking) {
const bookingSeat = await prisma.bookingSeat.findUnique({ const bookingSeat = await prisma.bookingSeat.findFirst({
where: { where: {
referenceUid: uid, referenceUid: uid,
}, },
@@ -160,7 +160,22 @@ export const ensureBookingInputsHaveSystemFields = ({
type: "email", type: "email",
name: "email", name: "email",
required: !isEmailFieldOptional, required: !isEmailFieldOptional,
editable: isOrgTeamEvent ? "system-but-optional" : "system", editable: "system-but-optional",
sources: [
{
label: "Default",
id: "default",
type: "default",
},
],
},
{
defaultLabel: "phone_number",
type: "phone",
name: "attendeePhoneNumber",
required: false,
hidden: true,
editable: "system-but-optional",
sources: [ sources: [
{ {
label: "Default", label: "Default",
@@ -169,7 +184,6 @@ export const ensureBookingInputsHaveSystemFields = ({
}, },
], ],
}, },
{ {
defaultLabel: "location", defaultLabel: "location",
type: "radioInput", type: "radioInput",
@@ -204,23 +218,6 @@ export const ensureBookingInputsHaveSystemFields = ({
], ],
}, },
]; ];
if (isOrgTeamEvent) {
systemBeforeFields.splice(2, 0, {
defaultLabel: "phone_number",
type: "phone",
name: "attendeePhoneNumber",
required: false,
hidden: true,
editable: "system-but-optional",
sources: [
{
label: "Default",
id: "default",
type: "default",
},
],
});
}
// These fields should be added after other user fields // These fields should be added after other user fields
const systemAfterFields: typeof bookingFields = [ const systemAfterFields: typeof bookingFields = [
@@ -52,7 +52,6 @@ import {
getFirstDelegationConferencingCredentialAppLocation, getFirstDelegationConferencingCredentialAppLocation,
} from "@calcom/lib/delegationCredential/server"; } from "@calcom/lib/delegationCredential/server";
import { ErrorCode } from "@calcom/lib/errorCodes"; import { ErrorCode } from "@calcom/lib/errorCodes";
import type { ErrorWithCode } from "@calcom/lib/errors";
import { getErrorFromUnknown } from "@calcom/lib/errors"; import { getErrorFromUnknown } from "@calcom/lib/errors";
import { getEventName, updateHostInEventName } from "@calcom/lib/event"; import { getEventName, updateHostInEventName } from "@calcom/lib/event";
import { extractBaseEmail } from "@calcom/lib/extract-base-email"; import { extractBaseEmail } from "@calcom/lib/extract-base-email";
@@ -455,22 +454,12 @@ async function handler(
await checkIfBookerEmailIsBlocked({ loggedInUserId: userId, bookerEmail }); await checkIfBookerEmailIsBlocked({ loggedInUserId: userId, bookerEmail });
if (!rawBookingData.rescheduleUid) { if (!rawBookingData.rescheduleUid) {
try { await checkActiveBookingsLimitForBooker({
await checkActiveBookingsLimitForBooker({ eventTypeId,
eventTypeId, maxActiveBookingsPerBooker: eventType.maxActiveBookingsPerBooker,
maxActiveBookingsPerBooker: eventType.maxActiveBookingsPerBooker, bookerEmail,
bookerEmail, offerToRescheduleLastBooking: eventType.maxActiveBookingPerBookerOfferReschedule,
offerToRescheduleLastBooking: eventType.maxActiveBookingPerBookerOfferReschedule, });
});
} catch (err) {
const error = err as ErrorWithCode;
throw new HttpError({
statusCode: 400,
message: error.message,
data: error.data,
});
}
} }
if (isEventTypeLoggingEnabled({ eventTypeId, usernameOrTeamName: reqBody.user })) { if (isEventTypeLoggingEnabled({ eventTypeId, usernameOrTeamName: reqBody.user })) {
@@ -1407,6 +1396,8 @@ async function handler(
routingFormResponseId, routingFormResponseId,
organizerId: organizerUser.id, organizerId: organizerUser.id,
teamId, teamId,
isRerouting: !!reroutingFormResponses,
reroutedByEmail: reqBody.rescheduledBy,
}); });
} }
} }
@@ -23,7 +23,6 @@ import { v4 as uuidv4 } from "uuid";
import { describe, expect } from "vitest"; import { describe, expect } from "vitest";
import { WEBAPP_URL, WEBSITE_URL } from "@calcom/lib/constants"; import { WEBAPP_URL, WEBSITE_URL } from "@calcom/lib/constants";
import { ErrorCode } from "@calcom/lib/errorCodes";
import logger from "@calcom/lib/logger"; import logger from "@calcom/lib/logger";
import { BookingStatus, SchedulingType } from "@calcom/prisma/enums"; import { BookingStatus, SchedulingType } from "@calcom/prisma/enums";
import { test } from "@calcom/web/test/fixtures/fixtures"; import { test } from "@calcom/web/test/fixtures/fixtures";
@@ -256,7 +255,7 @@ describe("handleNewRecurringBooking", () => {
timeout timeout
); );
test( test.skip(
`should fail recurring booking if second slot is already booked`, `should fail recurring booking if second slot is already booked`,
async ({}) => { async ({}) => {
const booker = getBooker({ const booker = getBooker({
@@ -372,11 +371,12 @@ describe("handleNewRecurringBooking", () => {
}; };
}); });
await expect(() => await expect(
handleNewRecurringBooking({ async () =>
bookingData: bookingDataArray, await handleNewRecurringBooking({
userId: -1, bookingData: bookingDataArray,
}) userId: -1,
})
).rejects.toThrow(ErrorCode.NoAvailableUsersFound); ).rejects.toThrow(ErrorCode.NoAvailableUsersFound);
}, },
timeout timeout
@@ -384,7 +384,7 @@ describe("handleNewRecurringBooking", () => {
}); });
describe("Round robin event type:", () => { describe("Round robin event type:", () => {
test("should fail recurring booking if a fixed host is not available on the second slot", async () => { test.skip("should fail recurring booking if a fixed host is not available on the second slot", async () => {
const booker = getBooker({ const booker = getBooker({
email: "booker@example.com", email: "booker@example.com",
name: "Booker", name: "Booker",

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