Compare commits

...
Author SHA1 Message Date
emrysal ee1904bb2c chore: release v5.5.14
Post release / release (push) Canceled after 0s
2025-08-08 12:11:15 +00:00
Alex van Andelandcoderabbitai[bot] aaa59f1455 chore: Add relation between a host and a membership for future cascades (#22975)
* chore: Add relation between a host and a membership for future cascades

* Apply suggestion

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

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-08-08 13:06:02 +01:00
Carina WollendorferandCarinaWolli e29b7e83b8 feat: Round Robin groups (#22296)
* add Add Group button

* add host groups to schema

* UI for host groups

* raname groups to hostGroups

* schema update

* show groups in assignment tab

* add no group hosts to Group 1

* add dummy group for non group hosts

* fix type errors

* use two dimensional array for luckyUserPools

* fix empty array

* group RR hosts in handleNewBooking

* improve logic for grouping lucky users

* find all lucky users of all groups

* allow several RR hosts on booking

* clean up migrations

* create helper function

* group hosts for slots logic

* add group logic to loading available slots

* adding hosts to groups

* add groupId to hostSchema

* disable hosts from other groups

* handle groups in checkedTeamSelect

* fix adding hosts to groups

* remove and add groups

* show hosts if there are no groups

* fixing adding first group with existing hosts

* show groups empty groups correctly

* UI upddate fixes

* fix adding hosts to existing first host group

* small fixes + code clean up

* add availability fix with test

* create new round-robin test file

* disable reassignment

* fix losing fixed hosts

* fix updating weights and priorities

* disable load balancing with Round Robin Groups

* automatically disable load balancing in update handler

* allRRHosts should only include hosts from same group

* fix type errors

* fix type error

* fix tests

* fix type error

* remove undefined from groupId type

* type changes

* add tests for hostGroups

* add tests for host groups

* fixes

* fix type errors with undefined groupId

* remove seperate host groups prop

* fix editing weights

* remove console.log

* code clean up

* improve getAggregatedAvailability tests

* throw error when no available hosts in a group

* add fixme comment

* create constant for DEFAULT_GROUP_ID

* clean up code

* mock default_group_id for unit tests

* don't show fixed hosts in edit weights side bar

* add DEFAULT_GROUP_ID to  mock test-setup

* remove unused index variable

* code clean up

* fix updating host groups

* fix imports

* add default_group_id to mocks

* add uuid() to zod schema

* remove unused code

* fix singular translation key

* remove unnessary !!

* Revert formatting changes

* add additional tests for bookingActions

* use createMany

* import DEFAULT_GROUP_ID for mocks

* fix mocks

* clean up EventTeamAssignmentTab

* fix type errors in tests

* fix mocks

* remove constants.example.test.ts

* fix type error

* add missing groupId

* fix margin

* clean up empty host groups

* fix constants mock

* useCalback

* use reduce

* extract handlers into seperate functions

* fix handler functions

* fix border radius

* fix type error in CheckForEmptyAssignment

* fix type error

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2025-08-08 12:56:13 +01:00
6af473a486 fix: prevent makeBodyVisible timeout from running after test teardown (#22972)
* fix: prevent makeBodyVisible timeout from running after test teardown

- Add timeout tracking to makeBodyVisible function to enable cleanup
- Add vi.clearAllTimers() to test teardown to clear recursive timeouts
- Prevents 'ReferenceError: document is not defined' in CI tests

Fixes intermittent test failures where makeBodyVisible recursive timeout
continues running after test environment is torn down.

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

* simplify: use only vi.clearAllTimers() to fix timeout issue

Based on code review feedback, test if vi.clearAllTimers() alone
is sufficient without modifying makeBodyVisible function.

- Reverted makeBodyVisible function changes
- Kept only vi.clearAllTimers() in test teardown
- Verified with multiple test runs and type checking

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-08-08 08:07:08 -03:00
Joe Au-YeungandHariom Balhara bf8f966811 chore: Add logging to SAML endpoints (#22968)
* Add logging to SAML endpoints

* Update packages/features/auth/lib/next-auth-options.ts

---------

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2025-08-08 11:50:46 +01:00
Anik Dhabal Babu c64b663514 chore: Add zoom waiting room setting (#22888)
* chore: zoom waiting room setting

* Update VideoApiAdapter.ts

* Update VideoApiAdapter.ts
2025-08-08 10:37:36 +00:00
29314cde75 refactor: convert getBusyTimes to service class with dependency injection (#22949)
* refactor: convert getBusyTimes to service class with dependency injection

- Create BusyTimesService following UserAvailabilityService pattern
- Add DI tokens, module, and container setup for BusyTimesService
- Update all usage locations to use service instead of direct function calls
- Maintain existing function signatures for backward compatibility
- Add legacy exports to ensure smooth transition
- Update type references in trpc util to use service prototype
- Fix linting issues by making legacy exports async with proper imports
- All tests pass and type checking succeeds

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

* refactor: busy time service

* fix: api v2 eslint plugins version mismatch

* chore: bump platform libs

* chore: bump platform libs

* fix: missing di busyTimesModule in slots service

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: morgan@cal.com <morgan@cal.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2025-08-08 12:14:08 +03:00
3ea73e75d6 feat: add Workflow resource to PBAC system with permission enforcement (#22845)
Co-authored-by: sean@cal.com <Sean@brydon.io>
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2025-08-08 08:48:18 +00:00
Lauris Skraucis d18e23378b feat: toggle round robin org and team info in booker (#22956)
* feat: toggle round robin booker org and team

* chore: add changeset

* dont hide in dev
2025-08-08 13:55:53 +05:30
Sahitya ChandraandAnik Dhabal Babu 736f1f594c fix: add strict URL validation with consistent error message (#22843)
* added strict url validation with consistent Invalid URL error message

* chore

* Update schema.ts

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-08-08 06:48:28 +00:00
09b1643478 chore: added headless routing link to embed (#22921)
* added headless routing link to embed

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

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

* fix: add missing headless embed type handlers to resolve TypeScript errors

- Add headless handlers for react, react-atom, and HTML frameworks in EmbedCodes.tsx
- Add headless case handling in EmbedTabs.tsx getEmbedTypeSpecificString function
- Add embedCalOrigin parameter to HTML framework handlers for type consistency
- All handlers return documentation comments directing users to headless routing docs

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

* temp: enable IS_CALCOM for localhost testing

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

* fix: change headless handlers to return null instead of documentation code

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

* revert: remove temporary IS_CALCOM localhost change

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

* refactor: remove unnecessary parameter destructuring from headless embed handlers

- Headless handlers now take no parameters since they only return null
- Simplified function signatures to avoid unused parameter warnings
- Maintains existing functionality while improving code clarity

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: hariom@cal.com <hariombalhara@gmail.com>
2025-08-08 06:18:59 +00:00
86f6e8bac3 Fix: Update BTCPay Server currencies and fix dark mode bug (#22887)
* Update BTCPay Server currencies

* fix type

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-08-08 05:50:14 +00:00
Sahitya Chandra 9c65d02e84 docs: refactor the v2 api README file (#22896)
* doc: fixed the V2 api README file

* chore
2025-08-08 05:49:27 +00:00
Romit be9ab02a7e fix: ICS file on workflows where the invite is added wont display the link of the integration (#22964)
* fix

* fix

* chore
2025-08-08 10:45:14 +05:30
Joe Au-Yeung cfefba6d4d Revert "perf: use repository for webhooks list query & caching in /settings/developer/webhooks/... RSC (#21781)" (#22963) 2025-08-07 11:55:13 -07:00
Joe Au-Yeung 815e13303b Compare membership with teamId (#22957) 2025-08-07 15:07:21 +00:00
Bandhan Majumder 7c8dbb3ea3 fix: contact support button overlap (#22952)
* add: lib folder in tailwind config

* move plainChat and contactForm from lib to components

* Update tailwind-preset.js
2025-08-07 14:29:21 +00:00
Alex van Andel cabd459682 chore: slots time range validation to zod (#22942)
* chore: Move date range validation to zod, removing TRPCError

* This is not a BAD_REQUEST but an internal error, should not happen

* Add a check to ensure the given start time is before end time
2025-08-07 15:46:34 +03:00
Somay ChauhanandRajiv Sahal 33aedbae20 fix: booker embed (#22898)
* fix: make organizationId optional number type in BookerEmbed

* feat: dont call useMe for embeds

* useOAuthClient hook should re-run when url is set

* feat: Remove `?orgId=0` from /public event query params

---------

Co-authored-by: Rajiv Sahal <sahalrajiv-extc@atharvacoe.ac.in>
2025-08-07 10:29:22 +02:00
Somay ChauhanandRajiv Sahal d3fbc73a39 feat: add callback for handleFormSubmit in EventType and AvailabilitySettings atoms (#22911)
* feat: Implement callback pattern in handleFormSubmit of EventTypePlatformWrapper

* feat: Implement callback pattern in handleFormSubmit of AvailabilitySettingsPlatformWrapper

* docs: add handleFormSubmit callback documentation for EventType and AvailabilitySettings atoms

* Update availability.tsx

* chore:added changelog

* update docs

---------

Co-authored-by: Rajiv Sahal <sahalrajiv-extc@atharvacoe.ac.in>
2025-08-07 10:26:09 +02:00
cfda4757e4 refactor: move getTotalBookingDuration to BookingRepository (#22931)
* refactor: move getTotalBookingDuration to BookingRepository

- Move getTotalBookingDuration function from standalone file to BookingRepository class
- Update all usage sites to call method through repository instance
- Remove standalone function file packages/lib/server/queries/booking/index.ts
- Add prisma import to util.ts for BookingRepository instantiation
- Maintain exact same method signature and functionality

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

* fix: remove eslint-config-next to resolve TypeScript ESLint conflicts

- Remove eslint-config-next dependency that was causing version conflicts
- Resolves 'Class extends value undefined is not a constructor or null' errors
- ESLint 'next' config issue appears to be pre-existing in main branch

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

* fixup! Merge branch 'main' into devin/move-getTotalBookingDuration-1754460208

* chore: bump platform libs

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: morgan@cal.com <morgan@cal.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2025-08-07 10:53:18 +03:00
Anirban Singha 895ed48bd6 feat: rename cal.ai to transcribe (#22940)
* feat: rename cal.ai to transcribe

Signed-off-by: Anirban Singha <anirbansingha20@gmail.com>

* update

Signed-off-by: Anirban Singha <anirbansingha20@gmail.com>

---------

Signed-off-by: Anirban Singha <anirbansingha20@gmail.com>
2025-08-07 04:11:46 +00:00
Yadong (Adam) Zhang eda328d196 chore: update ESLint configuration (#22924) 2025-08-06 17:41:02 +00:00
Lee Salminen 1c501d1501 BTCPayServer App - fixes #22928 (#22933) 2025-08-06 16:48:41 +00:00
PallavandKartik Saini 08a57f32c5 fix: persist team filter across tab switches (#22892)
* fix/persist team filter across tab switches

* fix failing ts errors

* tackled failing ts checks

* fix prop type consistency

* removed unecessary props

* tackled in one file

---------

Co-authored-by: Kartik Saini <41051387+kart1ka@users.noreply.github.com>
2025-08-06 22:14:31 +05:30
Sahitya Chandra b5a26bc227 fix: fixed the estonian languageon confirmation page (#22936) 2025-08-06 22:10:00 +05:30
Peer Richelsen 737b019199 hotfix for plain support (#22937) 2025-08-06 17:25:33 +01:00
Alex van Andel b41031626f chore: when using pii for rate limit (email, ip) hash (#22922) 2025-08-06 13:54:57 +01:00
Benny Joo a1fc4bccb9 fix: OG image error from custom fonts not being supported (#22916) 2025-08-06 17:19:15 +05:30
Benny Joo d764b6a32d chore: increase i18n fetch timeout to 30s for dev mode (#22912) 2025-08-06 09:56:27 +02:00
emrysal 87cb8b170a chore: release v5.5.13
Post release / release (push) Canceled after 0s
2025-08-06 07:33:08 +00:00
Omar LópezandDevin AI badd0de33c fix: resolve circular dependency in getUserAvailability by extracting getPeriodStartDatesBetween utility (#22913)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-08-06 06:10:38 +00:00
Benny Joo 124fd8ebca fix: OG image error from missing height field (#22914) 2025-08-06 14:12:28 +09:00
emrysal 5d0dc3f546 chore: release v5.5.12
Post release / release (push) Canceled after 0s
2025-08-06 01:00:26 +00:00
MorganandAlex van Andel e4baf15b53 feat: Sync timezone for users having delegation credentials for google/outlook (#22904)
* feat: sync timezone with google/outlook for delegated credentials users

* chore: dynamic sync timezone in get availble slots

* redis cache for get delegated timezone

* Update packages/lib/getUserAvailability.ts

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-08-06 01:55:47 +01:00
Alex van Andel a1bd8b6259 fix: invalid workflow (apiv2 e2e yaml) preventing checks running 2025-08-06 01:17:27 +01:00
Eunjae Lee 7987c9f45d revert: eslint-config-next to v13.2.1 (#22903) 2025-08-06 00:29:55 +01:00
Andreas Thomas 781d0d92df perf: upgrade unkey to v2 (#22902)
Unkey's v2 api runs on AWS (including us-east-2, where cal runs) and has
significantly lower latency than v1.
2025-08-05 10:49:51 -07:00
Alex van Andelandcal.com b71d8baccc chore: Implement short-lived redis cache for slots (#22787)
* chore: Implement short-lived redis cache for slots

* chore: adapt apiv2 redis service to match with upstash redis

* chore: safer redis service and ms ttl

* fixup! chore: safer redis service and ms ttl

* Wrap with timeout, currently doesn't work yet

* Updated @upstash/redis for better signal support

* Fix type errors, remove ts value

* Inject NoopRedisService for NODE_ENV test

* chore: bump platform libs

* chore: bump platform libs

* Upstash Redis upgrade no longer resulted in expected hard crash on init, so updated factory and our Upstash Redis Adapter to mimick old behaviour

* Add SLOTS_CACHE_TTL variable for configurable ttl on slots cache

* Update parseInt to use right types

* chore: bump platform libs

* chore: bump platform libs

* chore: bump platform libs

* update e2e api v2 action

* set SLOTS_CACHE_TTL env var api v2 e2e

---------

Co-authored-by: cal.com <morgan@cal.com>
2025-08-05 17:32:02 +00:00
a71d949a0c feat: add cron job to cleanup old queued form responses (#22035)
* feat: add cron job to cleanup old queued form responses

- Add cleanup cron job for App_RoutingForms_QueuedFormResponse table
- Delete records with null actualResponseId older than 1 hour
- Schedule to run twice daily (every 12 hours) via vercel.json
- Follow existing cron job patterns for authentication and structure

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

* fix: correct date comparison logic to delete older records

- Change from lte to gte in createdAt comparison
- Now properly deletes records older than 1 hour

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

* refactor: move cleanup logic to routing forms lib and import in API endpoint

- Create cleanupExpiredQueuedFormResponses function in routing forms lib
- Update API endpoint to import and use the new cleanup function
- Follow established patterns for code organization in Cal.com
- Maintain same functionality and authentication logic

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

* refactor: move entire cron logic to routing-forms cron folder

- Create routing-forms/cron/queuedFormResponseCleanup.ts with complete handler
- Move authentication, HTTP handling, and database operations to routing-forms
- Update API endpoint to simply import and call the cron handler
- Remove old lib cleanup function
- Follow user's preferred organizational pattern

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

* fix: correct date comparison logic to delete records older than 1 hour

- Change from gte (greater than or equal) to lt (less than) cutoffTime
- Now properly deletes records where createdAt < cutoffTime (older than 1 hour)
- Previous logic was deleting newer records instead of older ones

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

* fixes

* refactor: reorganize queuedFormResponse into domain-driven structure

- Move repository and service files to routingForm/queuedFormResponse subdirectory
- Clean up unused methods and tests for better maintainability
- Fix import paths and type definitions
- Ensure all tests pass and maintain backward compatibility

* fix: align QueuedFormResponse interface types with Prisma schema

- Update actualResponseId type from string to number to match Prisma Int type
- Make updatedAt nullable (Date | null) to match Prisma DateTime? type
- Fix import path for QueuedFormResponseRepository interface

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

* refactor: reorganize queuedFormResponse into domain-driven structure

- Move repository and service files to routingForm/queuedFormResponse subdirectory
- Clean up unused methods and tests for better maintainability
- Fix import paths and type definitions
- Ensure all tests pass and maintain backward compatibility

* test: add comprehensive where clause validation tests and missing test coverage

* chore: update cron schedule to weekly for 7-day retention period

* revert: restore 12-hour cron schedule for better batch management

* Add integration tests

* refactor: apply test data builder pattern to queuedFormResponse integration tests

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: hariom@cal.com <hariom@cal.com>
Co-authored-by: Hariom <hariombalhara@gmail.com>
2025-08-05 22:17:50 +05:30
emrysal e55cee443b chore: release v5.5.11
Post release / release (push) Canceled after 0s
2025-08-05 12:20:27 +00:00
MorganandAlex van Andel c3634b3aba refactor: getUserAvailability into service with DI (#22881)
* refactor: getUserAvailability into service with DI

* chore: bump platform libs

* disable bull queue in e2e for bookings

* chore: bump platform libs

* chore: bump platform libs

* fix: should update event type bookingFields test

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-08-05 13:13:42 +01:00
Eunjae LeeandDevin AI cd576f5378 refactor: use dependency injection for InsightsBookingService (#22790)
* refactor: use dependency injection for InsightsBookingService

- Add DI tokens for InsightsBookingService and module
- Rename InsightsBookingService to InsightsBookingBaseService
- Create InsightsBookingService DI interface with create method
- Add DI module and container for InsightsBookingService
- Update tRPC router to use getInsightsBookingService DI container
- Update test file to use InsightsBookingBaseService
- Update documentation to reflect new DI pattern

Follows the same dependency injection pattern established for InsightsRoutingService in PR #22677

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

* refactor: restore createInsightsBookingService helper using DI internally

- Keep createInsightsBookingService helper function for cleaner API
- Use getInsightsBookingService DI container internally
- Maintain same function signature and behavior
- All existing calls continue to work unchanged

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

* refactor: rename service files with proper capitalization

- Rename insightsBookingBase.ts to InsightsBookingBaseService.ts
- Rename insightsBookingDI.ts to InsightsBookingDIService.ts
- Update all import statements to use new file names
- Maintain existing handler functionality using createInsightsBookingService(ctx, input)

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

* revert some changes

* rename

* update doc

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-08-05 11:07:07 +02:00
github-actions[bot] 2965450929 chore: version packages (#22890)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-08-05 06:15:25 +00:00
ddoemonnandKartik Saini db9b9c45dc fix(event-types): remove gap in URL input for event creation (#22123)
* fix(event-types): remove gap in URL input for event creation

* fix: event setup tab spacing

* fix: space in profile view

---------

Co-authored-by: Kartik Saini <41051387+kart1ka@users.noreply.github.com>
2025-08-05 03:51:30 +00:00
emrysal 37f500526c chore: release v5.5.11 2025-08-05 00:46:56 +00:00
Eunjae Lee 006aecb066 fix: add backfill migration for routing response denormalized tables (#21474)
* fix: add backfill migration for routing response denormalized tables

* update script

* implement select type

* improve number handling

* clean up queries

* rename

* handle zero record case

* revert

* rename

* fix division problem

* log clean up

* force adding new rows after removing existing rows

* backfill only missing or wrong data

* remove sleep
2025-08-05 01:40:22 +01:00
Amit Sharma 0829929e98 perf: use repository for webhooks list query & caching in /settings/developer/webhooks/... RSC (#21781)
* perf: use repository for webhooks list query & caching in /settings/developer/webhooks/... RSC

* fix type check

* invalidate cache on webhook list item actions

* fix review comment

* fix merge conflicts
2025-08-05 01:25:31 +01:00
a61148cb51 test: add no show webhooks integration tests (#21697)
* test: add no show webhooks integration tests

* fix

* chore

* chore

* chore

---------

Co-authored-by: Benny Joo <sldisek783@gmail.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: Volnei Munhoz <volnei.munhoz@gmail.com>
2025-08-05 01:22:44 +01:00
Eunjae Lee 593c48c8b4 refactor: replace tremor with recharts (#22791)
* refactor: replace tremor with recharts

* update tooltip

* clean up types

* replace tremor with recharts

* replace BarList with recharts

* replace ProgressBar

* remove tremor from the repository

* clean up

* fix UserStatsTable

* fix type error

* add explicit return type
2025-08-05 01:21:23 +01:00
Lauris Skraucis fb364971fa fix: atoms fonts for non next apps (#22886)
* fix: atoms fonts for non next apps

* chore: add changeset
2025-08-04 16:51:05 +00:00
Eunjae LeeandAlex van Andel a2205d497d feat: add booking status filter to /insights (#22842)
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-08-04 13:48:17 +00:00
Eunjae LeeandDevanshu Sharma cf4e7a5d2c refactor: add useInsightsRoutingParameters() for InsightsRoutingService (#22792)
Co-authored-by: Devanshu Sharma <devanshusharma658@gmail.com>
2025-08-04 14:51:23 +02:00
Anik Dhabal Babu 0e2ee3adb2 chore: add logging for office365 calendar (#22867) 2025-08-04 12:26:57 +00:00
Anik Dhabal Babu dbe8a85cf5 fix: enable auto reording for organizer (#22882) 2025-08-04 12:23:56 +00:00
Anik Dhabal Babu 84210dc1b4 fix: build docs workflow failing in ci (#22883)
* fix: flaky e2e

* fix: build docs failure
2025-08-04 17:36:35 +05:30
Benny Joo 4061bfb3fe fix: refactor i18n loadTranslations and set timeout to 3s (#22878)
* refactor

* use abort controller

* address comment

* fix tests

* fix time

* fix tests

* fix platform library build
2025-08-04 10:30:18 +01:00
265 changed files with 8377 additions and 6265 deletions
+9
View File
@@ -0,0 +1,9 @@
---
"@calcom/atoms": minor
---
Added new callback functions to the handleFormSubmit method in the EventTypeSettings and AvailabilitySettings atoms. The handleFormSubmit method now accepts an optional callbacks object with the following properties:
- **onSuccess**: Called when the form submission is successful, allowing additional logic to be executed after the update.
- **onError**: Called when an error occurs during form submission, providing details about the error to handle specific cases or display custom messages.
+5
View File
@@ -0,0 +1,5 @@
---
"@calcom/atoms": minor
---
booker atom: allow toggling org and team info when booking round robin
+5 -1
View File
@@ -1 +1,5 @@
module.exports = require("./packages/config/eslint-preset");
// This configuration only applies to the package manager root.
/** @type {import("eslint").Linter.Config} */
module.exports = {
extends: ["./packages/config/eslint-preset.js"],
};
+1
View File
@@ -22,6 +22,7 @@ env:
STRIPE_API_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }}
STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }}
SLOTS_CACHE_TTL: ${{ secrets.CI_SLOTS_CACHE_TTL }}
jobs:
e2e:
timeout-minutes: 20
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@calcom/prisma"
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@calcom/prisma"
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@calcom/prisma"
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@calcom/prisma"
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@calcom/prisma"
+4 -3
View File
@@ -1,7 +1,7 @@
import type { NextApiRequest } from "next";
import { z } from "zod";
import { getUserAvailability } from "@calcom/lib/getUserAvailability";
import { getUserAvailabilityService } from "@calcom/lib/di/containers/get-user-availability";
import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
import prisma from "@calcom/prisma";
@@ -191,8 +191,9 @@ const availabilitySchema = z
async function handler(req: NextApiRequest) {
const { isSystemWideAdmin, userId: reqUserId } = req;
const { username, userId, eventTypeId, dateTo, dateFrom, teamId } = availabilitySchema.parse(req.query);
const userAvailabilityService = getUserAvailabilityService();
if (!teamId)
return getUserAvailability({
return userAvailabilityService.getUserAvailability({
username,
dateFrom,
dateTo,
@@ -230,7 +231,7 @@ async function handler(req: NextApiRequest) {
const availabilities = members.map(async (user) => {
return {
userId: user.id,
availability: await getUserAvailability({
availability: await userAvailabilityService.getUserAvailability({
userId: user.id,
dateFrom,
dateTo,
+6 -4
View File
@@ -42,15 +42,17 @@ $ yarn prisma generate
Copy `.env.example` to `.env` and fill values.
## Add license Key to deployments table in DB
## Add license Key to Deployment table in DB
id, logo theme licenseKey agreedLicenseAt
1, null, null, 'c4234812-12ab-42s6-a1e3-55bedd4a5bb7', '2023-05-15 21:39:47.611'
id, logo, theme, licenseKey, agreedLicenseAt:-
1, null, null, '00000000-0000-0000-0000-000000000000', '2023-05-15 21:39:47.611'
Replace with your actual license key.
your CALCOM_LICENSE_KEY env var need to contain the same value
.env
CALCOM_LICENSE_KEY=c4234812-12ab-42s6-a1e3-55bedd4a5bb
CALCOM_LICENSE_KEY=00000000-0000-0000-0000-000000000000
## Running the app
+3 -1
View File
@@ -38,7 +38,7 @@
"@axiomhq/winston": "^1.2.0",
"@calcom/platform-constants": "*",
"@calcom/platform-enums": "*",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.276",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.291",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
"@calcom/prisma": "*",
@@ -98,6 +98,8 @@
"@types/luxon": "^3.3.7",
"@types/passport-jwt": "^3.0.13",
"@types/supertest": "^2.0.12",
"@typescript-eslint/eslint-plugin": "^6",
"@typescript-eslint/parser": "^6",
"jest": "^29.7.0",
"jest-date-mock": "^1.0.10",
"node-mocks-http": "^1.16.2",
@@ -104,10 +104,11 @@ export class OutputBookingsService_2024_08_13 {
const rescheduledToInfo = databaseBooking.rescheduled
? await this.getRescheduledToInfo(databaseBooking.uid)
: undefined;
const rescheduledToUid = rescheduledToInfo?.uid;
const rescheduledByEmail = databaseBooking.rescheduled ? rescheduledToInfo?.rescheduledBy : databaseBooking.rescheduledBy;
const rescheduledToUid = rescheduledToInfo?.uid;
const rescheduledByEmail = databaseBooking.rescheduled
? rescheduledToInfo?.rescheduledBy
: databaseBooking.rescheduledBy;
const booking = {
id: databaseBooking.id,
@@ -157,13 +158,12 @@ export class OutputBookingsService_2024_08_13 {
async getRescheduledToInfo(bookingUid: string): Promise<{ uid?: string; rescheduledBy?: string | null }> {
const rescheduledTo = await this.bookingsRepository.getByFromReschedule(bookingUid);
return {
uid: rescheduledTo?.uid,
rescheduledBy: rescheduledTo?.rescheduledBy
return {
uid: rescheduledTo?.uid,
rescheduledBy: rescheduledTo?.rescheduledBy,
};
}
getUserDefinedMetadata(databaseMetadata: DatabaseMetadata) {
if (databaseMetadata === null) return {};
@@ -279,9 +279,11 @@ export class OutputBookingsService_2024_08_13 {
const rescheduledToInfo = databaseBooking.rescheduled
? await this.getRescheduledToInfo(databaseBooking.uid)
: undefined;
const rescheduledToUid = rescheduledToInfo?.uid;
const rescheduledByEmail = databaseBooking.rescheduled ? rescheduledToInfo?.rescheduledBy : databaseBooking.rescheduledBy;
const rescheduledByEmail = databaseBooking.rescheduled
? rescheduledToInfo?.rescheduledBy
: databaseBooking.rescheduledBy;
const booking = {
id: databaseBooking.id,
@@ -290,7 +290,10 @@ describe("Event types Endpoints", () => {
expect(responseBookingFields).toBeDefined();
// note(Lauris): response bookingFields are already existing default bookingFields + the new one
const responseBookingField = responseBookingFields.find((field) => field.name === bookingFieldName);
expect(responseBookingField).toEqual(bookingFields[0]);
const fields = responseBookingField
//@ts-ignore
delete fields.labelAsSafeHtml
expect(fields).toEqual(bookingFields[0]);
eventType.bookingFields = responseBookingFields;
});
});
@@ -8,9 +8,12 @@ import { PrismaSelectedSlotRepository } from "@/lib/repositories/prisma-selected
import { PrismaTeamRepository } from "@/lib/repositories/prisma-team.repository";
import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository";
import { AvailableSlotsService } from "@/lib/services/available-slots.service";
import { BusyTimesService } from "@/lib/services/busy-times.service";
import { CacheService } from "@/lib/services/cache.service";
import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service";
import { UserAvailabilityService } from "@/lib/services/user-availability.service";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { RedisService } from "@/modules/redis/redis.service";
import { Module } from "@nestjs/common";
@Module({
@@ -24,10 +27,13 @@ import { Module } from "@nestjs/common";
PrismaEventTypeRepository,
PrismaRoutingFormResponseRepository,
PrismaTeamRepository,
RedisService,
PrismaFeaturesRepository,
CheckBookingLimitsService,
CacheService,
AvailableSlotsService,
UserAvailabilityService,
BusyTimesService,
],
exports: [AvailableSlotsService],
})
@@ -7,12 +7,16 @@ import { PrismaScheduleRepository } from "@/lib/repositories/prisma-schedule.rep
import { PrismaSelectedSlotRepository } from "@/lib/repositories/prisma-selected-slot.repository";
import { PrismaTeamRepository } from "@/lib/repositories/prisma-team.repository";
import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository";
import { BusyTimesService } from "@/lib/services/busy-times.service";
import { CacheService } from "@/lib/services/cache.service";
import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service";
import { RedisService } from "@/modules/redis/redis.service";
import { Injectable } from "@nestjs/common";
import { AvailableSlotsService as BaseAvailableSlotsService } from "@calcom/platform-libraries/slots";
import { UserAvailabilityService } from "./user-availability.service";
@Injectable()
export class AvailableSlotsService extends BaseAvailableSlotsService {
constructor(
@@ -24,6 +28,7 @@ export class AvailableSlotsService extends BaseAvailableSlotsService {
selectedSlotRepository: PrismaSelectedSlotRepository,
eventTypeRepository: PrismaEventTypeRepository,
userRepository: PrismaUserRepository,
redisService: RedisService,
featuresRepository: PrismaFeaturesRepository
) {
super({
@@ -35,8 +40,16 @@ export class AvailableSlotsService extends BaseAvailableSlotsService {
selectedSlotRepo: selectedSlotRepository,
eventTypeRepo: eventTypeRepository,
userRepo: userRepository,
checkBookingLimitsService: new CheckBookingLimitsService(bookingRepository) as any,
redisClient: redisService,
checkBookingLimitsService: new CheckBookingLimitsService(bookingRepository),
cacheService: new CacheService(featuresRepository),
userAvailabilityService: new UserAvailabilityService(
oooRepoDependency,
bookingRepository,
eventTypeRepository,
redisService
),
busyTimesService: new BusyTimesService(bookingRepository),
});
}
}
@@ -0,0 +1,13 @@
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
import { Injectable } from "@nestjs/common";
import { BusyTimesService as BaseBusyTimesService } from "@calcom/platform-libraries/slots";
@Injectable()
export class BusyTimesService extends BaseBusyTimesService {
constructor(bookingRepository: PrismaBookingRepository) {
super({
bookingRepo: bookingRepository,
});
}
}
@@ -0,0 +1,24 @@
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
import { PrismaEventTypeRepository } from "@/lib/repositories/prisma-event-type.repository";
import { PrismaOOORepository } from "@/lib/repositories/prisma-ooo.repository";
import { RedisService } from "@/modules/redis/redis.service";
import { Injectable } from "@nestjs/common";
import { UserAvailabilityService as BaseUserAvailabilityService } from "@calcom/platform-libraries/schedules";
@Injectable()
export class UserAvailabilityService extends BaseUserAvailabilityService {
constructor(
oooRepoDependency: PrismaOOORepository,
bookingRepository: PrismaBookingRepository,
eventTypeRepository: PrismaEventTypeRepository,
redisService: RedisService
) {
super({
oooRepo: oooRepoDependency,
bookingRepo: bookingRepository,
eventTypeRepo: eventTypeRepository,
redisClient: redisService,
});
}
}
@@ -403,6 +403,10 @@ export class BillingService implements OnModuleDestroy {
fromReschedule?: string | null;
}
) {
if (this.configService.get("e2e")) {
return true;
}
const { uid, startTime, fromReschedule } = booking;
const delay = startTime.getTime() - Date.now();
@@ -427,6 +431,9 @@ export class BillingService implements OnModuleDestroy {
* Removing an attendee from a booking does not cancel the usage increment job.
*/
async cancelUsageByBookingUid(bookingUid: string) {
if (this.configService.get("e2e")) {
return true;
}
const job = await this.billingQueue.getJob(`increment-${bookingUid}`);
if (job) {
await job.remove();
@@ -458,6 +465,7 @@ export class BillingService implements OnModuleDestroy {
async onModuleDestroy() {
try {
await this.billingQueue.close();
} catch (err) {
this.logger.error(err);
@@ -7,12 +7,32 @@ import { Redis } from "ioredis";
export class RedisService implements OnModuleDestroy {
public redis: Redis;
private readonly logger = new Logger("RedisService");
private isReady = false; // Track connection status
constructor(readonly configService: ConfigService<AppConfig>) {
const dbUrl = configService.get<string>("db.redisUrl", { infer: true });
if (!dbUrl) throw new Error("Misconfigured Redis, halting.");
this.redis = new Redis(dbUrl);
this.redis.on("error", (err) => {
this.logger.error(`IoRedis connection error: ${err.message}`);
this.isReady = false;
});
this.redis.on("connect", () => {
this.logger.log("IoRedis connected!");
this.isReady = true;
});
this.redis.on("reconnecting", (delay: string) => {
this.logger.warn(`IoRedis reconnecting... next retry in ${delay}ms`);
});
this.redis.on("end", () => {
this.logger.warn("IoRedis connection ended.");
this.isReady = false;
});
}
async onModuleDestroy() {
@@ -22,4 +42,99 @@ export class RedisService implements OnModuleDestroy {
this.logger.error(err);
}
}
async get<TData>(key: string): Promise<TData | null> {
let data = null;
if (!this.isReady) {
return null;
}
try {
data = await this.redis.get(key);
} catch (err) {
if (err instanceof Error) this.logger.error(`IoRedis get failed: ${err.message}`);
}
if (data === null) {
return null;
}
try {
return JSON.parse(data) as TData;
} catch (e) {
return data as TData;
}
}
async del(key: string): Promise<number> {
if (!this.isReady) {
return 0;
}
try {
return this.redis.del(key);
} catch (err) {
if (err instanceof Error) this.logger.error(`IoRedis del failed: ${err.message}`);
return 0;
}
}
async set<TData>(key: string, value: TData, opts?: { ttl?: number }): Promise<"OK" | TData | null> {
if (!this.isReady) {
return null;
}
try {
const stringifiedValue = typeof value === "object" ? JSON.stringify(value) : String(value);
if (opts?.ttl) {
await this.redis.set(key, stringifiedValue, "PX", opts.ttl);
} else {
await this.redis.set(key, stringifiedValue);
}
} catch (err) {
if (err instanceof Error) this.logger.error(`IoRedis set failed: ${err.message}`);
return null;
}
return "OK";
}
async expire(key: string, seconds: number): Promise<0 | 1> {
if (!this.isReady) {
return 0;
}
try {
return this.redis.expire(key, seconds) as Promise<0 | 1>;
} catch (err) {
if (err instanceof Error) this.logger.error(`IoRedis expire failed: ${err.message}`);
return 0;
}
}
async lrange<TResult = string>(key: string, start: number, end: number): Promise<TResult[]> {
if (!this.isReady) {
return [];
}
try {
const results = await this.redis.lrange(key, start, end);
return results.map((item) => JSON.parse(item) as TResult);
} catch (err) {
if (err instanceof Error) this.logger.error(`IoRedis lrange failed: ${err.message}`);
return [];
}
}
async lpush<TData>(key: string, ...elements: TData[]): Promise<number> {
if (!this.isReady) {
return 0;
}
try {
const stringifiedElements = elements.map((element) =>
typeof element === "object" ? JSON.stringify(element) : String(element)
);
return this.redis.lpush(key, ...stringifiedElements);
} catch (err) {
if (err instanceof Error) this.logger.error(`IoRedis lpush failed: ${err.message}`);
return 0;
}
}
}
+1
View File
@@ -36,4 +36,5 @@ process.env = {
CALENDSO_ENCRYPTION_KEY: "22gfxhWUlcKliUeXcu8xNah2+HP/29ZX",
INTEGRATION_TEST_MODE: "true",
e2e: "true",
SLOTS_CACHE_TTL: "1"
};
@@ -7,6 +7,7 @@ import { passwordResetRequest } from "@calcom/features/auth/lib/passwordResetReq
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import { emailSchema } from "@calcom/lib/emailSchema";
import prisma from "@calcom/prisma";
import { piiHasher } from "@calcom/lib/server/PiiHasher";
async function handler(req: NextRequest) {
const body = await parseRequestData(req);
@@ -28,7 +29,7 @@ async function handler(req: NextRequest) {
await checkRateLimitAndThrowError({
rateLimitingType: "core",
identifier: ip,
identifier: piiHasher.hash(ip),
});
try {
+4
View File
@@ -4,13 +4,16 @@ import { NextResponse } from "next/server";
import jackson from "@calcom/features/ee/sso/lib/jackson";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
// This is the callback endpoint for the OIDC provider
// A team must set this endpoint in the OIDC provider's configuration
async function handler(req: NextRequest) {
const log = logger.getSubLogger({ prefix: ["[ODIC auth]"] });
const { searchParams } = req.nextUrl;
const code = searchParams.get("code");
const state = searchParams.get("state");
const tenant = searchParams.get("tenant");
if (!code || !state) {
return NextResponse.json({ message: "Code and state are required" }, { status: 400 });
@@ -30,6 +33,7 @@ async function handler(req: NextRequest) {
return NextResponse.redirect(redirect_url, 302);
} catch (err) {
log.error(`Error authorizing tenant ${tenant}: ${err}`);
const { message, statusCode = 500 } = err as HttpError;
return NextResponse.json({ message }, { status: statusCode });
@@ -5,17 +5,20 @@ import { NextResponse } from "next/server";
import type { OAuthReq } from "@calcom/features/ee/sso/lib/jackson";
import jackson from "@calcom/features/ee/sso/lib/jackson";
import type { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
async function handler(req: NextRequest) {
const log = logger.getSubLogger({ prefix: ["[SAML authorize]"] });
const { oauthController } = await jackson();
const oAuthReq = Object.fromEntries(req.nextUrl.searchParams) as unknown as OAuthReq;
try {
const { redirect_url } = await oauthController.authorize(
Object.fromEntries(req.nextUrl.searchParams) as unknown as OAuthReq
);
const { redirect_url } = await oauthController.authorize(oAuthReq);
return NextResponse.redirect(redirect_url as string, 302);
} catch (err) {
log.error(`Error initaiting SAML login for tenant ${oAuthReq?.tenant}: ${err}`);
const { message, statusCode = 500 } = err as HttpError;
return NextResponse.json({ message }, { status: statusCode });
+14 -3
View File
@@ -2,21 +2,32 @@ import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
import { parseRequestData } from "app/api/parseRequestData";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { uuid } from "short-uuid";
import jackson from "@calcom/features/ee/sso/lib/jackson";
import type { SAMLResponsePayload } from "@calcom/features/ee/sso/lib/jackson";
import logger from "@calcom/lib/logger";
async function handler(req: NextRequest) {
const log = logger.getSubLogger({ prefix: ["[SAML callback]"] });
const { oauthController } = await jackson();
const { redirect_url } = await oauthController.samlResponse(
(await parseRequestData(req)) as SAMLResponsePayload
);
const requestData = (await parseRequestData(req)) as SAMLResponsePayload;
const { redirect_url, error } = await oauthController.samlResponse(requestData);
if (redirect_url) {
return NextResponse.redirect(redirect_url, 302);
}
if (error) {
const uid = uuid();
log.error(
`Error authenticating user with error ${error} for relayState ${requestData?.RelayState} trace:${uid}`
);
return NextResponse.json({ message: `Error authorizing user. trace: ${uid}` }, { status: 400 });
}
return NextResponse.json({ message: "No redirect URL provided" }, { status: 400 });
}
+14 -2
View File
@@ -2,14 +2,26 @@ import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
import { parseRequestData } from "app/api/parseRequestData";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { uuid } from "short-uuid";
import jackson from "@calcom/features/ee/sso/lib/jackson";
import type { OAuthTokenReq } from "@calcom/features/ee/sso/lib/jackson";
import logger from "@calcom/lib/logger";
async function handler(req: NextRequest) {
const { oauthController } = await jackson();
const tokenResponse = await oauthController.token((await parseRequestData(req)) as OAuthTokenReq);
return NextResponse.json(tokenResponse);
const log = logger.getSubLogger({ prefix: ["[SAML token]"] });
const oauthTokenReq = (await parseRequestData(req)) as OAuthTokenReq;
try {
const tokenResponse = await oauthController.token(oauthTokenReq);
return NextResponse.json(tokenResponse);
} catch (error) {
const uid = uuid();
log.error(`Error getting auth token for client id ${oauthTokenReq?.client_id}: ${error} trace: ${uid}`);
throw new Error(`Error getting auth token with error ${error} trace: ${uid}`);
}
}
export const POST = defaultResponderForAppDir(handler);
+23 -5
View File
@@ -1,23 +1,33 @@
import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import z from "zod";
import { uuid } from "short-uuid";
import { z } from "zod";
import jackson from "@calcom/features/ee/sso/lib/jackson";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
const extractAuthToken = (req: NextRequest) => {
const log = logger.getSubLogger({ prefix: ["SAML extractAuthToken"] });
const uid = uuid();
const authHeader = req.headers.get("authorization");
const parts = (authHeader || "").split(" ");
if (parts.length > 1) return parts[1];
// check for query param
let arr: string[] = [];
const { access_token } = requestQuery.parse(Object.fromEntries(req.nextUrl.searchParams));
const tokenParse = requestQuery.safeParse(Object.fromEntries(req.nextUrl.searchParams));
let access_token;
if (!tokenParse.success) {
log.error(`Error parsing request query: ${tokenParse.error} trace ${uid}`);
throw new HttpError({ statusCode: 401, message: `Unauthorized trace: ${uid}` });
}
access_token = tokenParse.data.access_token;
arr = arr.concat(access_token);
if (arr[0].length > 0) return arr[0];
throw new HttpError({ statusCode: 401, message: "Unauthorized" });
throw new HttpError({ statusCode: 401, message: `Unauthorized trace: ${uid}` });
};
const requestQuery = z.object({
@@ -25,10 +35,18 @@ const requestQuery = z.object({
});
async function handler(req: NextRequest) {
const log = logger.getSubLogger({ prefix: ["SAML userinfo"] });
const { oauthController } = await jackson();
const token = extractAuthToken(req);
const userInfo = await oauthController.userInfo(token);
return NextResponse.json(userInfo);
try {
const userInfo = await oauthController.userInfo(token);
return NextResponse.json(userInfo);
} catch (error) {
const uid = uuid();
log.error(`trace: ${uid} Error getting user info from token: ${error}`);
throw new Error(`Error getting user info from token. trace: ${uid}`);
}
}
export const GET = defaultResponderForAppDir(handler);
@@ -0,0 +1,5 @@
import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
import { handleQueuedFormResponseCleanup } from "@calcom/app-store/routing-forms/cron/queuedFormResponseCleanup";
export const POST = defaultResponderForAppDir(handleQueuedFormResponseCleanup);
+18 -7
View File
@@ -35,20 +35,31 @@ async function handler(req: NextRequest) {
const imageType = searchParams.get("type");
try {
const [calFontData, interFontData, interFontMediumData] = await Promise.all([
const fontResults = await Promise.allSettled([
fetch(new URL("/fonts/cal.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 fonts: SatoriOptions["fonts"] = [];
if (fontResults[1].status === "fulfilled") {
fonts.push({ name: "inter", data: fontResults[1].value, weight: 400 });
}
if (fontResults[2].status === "fulfilled") {
fonts.push({ name: "inter", data: fontResults[2].value, weight: 500 });
}
if (fontResults[0].status === "fulfilled") {
fonts.push({ name: "cal", data: fontResults[0].value, weight: 400 });
fonts.push({ name: "cal", data: fontResults[0].value, weight: 600 });
}
const ogConfig = {
width: 1200,
height: 630,
fonts: [
{ name: "inter", data: interFontData, weight: 400 },
{ name: "inter", data: interFontMediumData, weight: 500 },
{ name: "cal", data: calFontData, weight: 400 },
{ name: "cal", data: calFontData, weight: 600 },
] as SatoriOptions["fonts"],
fonts,
};
switch (imageType) {
@@ -546,9 +546,7 @@ function BookingListItem(booking: BookingItemProps) {
</Link>
</div>
</div>
<div
data-testid="title-and-attendees"
className={`w-full px-4${isRejected ? " line-through" : ""}`}>
<div data-testid="title-and-attendees" className={`w-full px-4${isRejected ? "line-through" : ""}`}>
<Link href={bookingLink}>
{/* Time and Badges for mobile */}
<div className="w-full pb-2 pt-4 sm:hidden">
@@ -893,7 +891,7 @@ const FirstAttendee = ({
) : (
<a
key={user.email}
className=" hover:text-blue-500"
className="hover:text-blue-500"
href={`mailto:${user.email}`}
onClick={(e) => e.stopPropagation()}>
{user.name || user.email}
@@ -1081,7 +1079,7 @@ const GroupedAttendees = (groupedAttendeeProps: GroupedAttendeeProps) => {
/>
))}
<DropdownMenuSeparator />
<div className="flex justify-end p-2 ">
<div className="flex justify-end p-2">
<Button
data-testid="update-no-show"
color="secondary"
@@ -1204,7 +1202,7 @@ const GroupedGuests = ({ guests }: { guests: AttendeeProps[] }) => {
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<div className="flex justify-end space-x-2 p-2 ">
<div className="flex justify-end space-x-2 p-2">
<Link href={`mailto:${selectedEmail}`}>
<Button
color="secondary"
@@ -278,13 +278,14 @@ describe("Booking Actions", () => {
expect(rerouteAction).toBeDefined();
});
it("should include reassign action for round robin events", () => {
it("should include reassign action for round robin events with no host groups", () => {
const context = createMockContext({
booking: {
...createMockContext().booking,
eventType: {
...createMockContext().booking.eventType,
schedulingType: SchedulingType.ROUND_ROBIN,
hostGroups: [],
},
},
});
@@ -294,6 +295,60 @@ describe("Booking Actions", () => {
expect(reassignAction).toBeDefined();
});
it("should include reassign action for round robin events with one host group", () => {
const context = createMockContext({
booking: {
...createMockContext().booking,
eventType: {
...createMockContext().booking.eventType,
schedulingType: SchedulingType.ROUND_ROBIN,
hostGroups: [{ id: "group-1", name: "Group 1" }],
},
},
});
const actions = getEditEventActions(context);
const reassignAction = actions.find((a) => a.id === "reassign");
expect(reassignAction).toBeDefined();
});
it("should exclude reassign action for round robin events with more than one host groups", () => {
const context = createMockContext({
booking: {
...createMockContext().booking,
eventType: {
...createMockContext().booking.eventType,
schedulingType: SchedulingType.ROUND_ROBIN,
hostGroups: [
{ id: "group-1", name: "Group 1" },
{ id: "group-2", name: "Group 2" },
],
},
},
});
const actions = getEditEventActions(context);
const reassignAction = actions.find((a) => a.id === "reassign");
expect(reassignAction).toBeUndefined();
});
it("should exclude reassign action for non-round-robin events", () => {
const context = createMockContext({
booking: {
...createMockContext().booking,
eventType: {
...createMockContext().booking.eventType,
schedulingType: SchedulingType.COLLECTIVE,
hostGroups: [],
},
},
});
const actions = getEditEventActions(context);
const reassignAction = actions.find((a) => a.id === "reassign");
expect(reassignAction).toBeUndefined();
});
it("should exclude add_members when guests are disabled", () => {
const context = createMockContext({
booking: {
@@ -146,8 +146,9 @@ export function getEditEventActions(context: BookingActionContext): ActionType[]
icon: "user-plus",
disabled: false,
},
// Reassign (if round robin)
booking.eventType.schedulingType === SchedulingType.ROUND_ROBIN
// Reassign if round robin with no or one host groups
booking.eventType.schedulingType === SchedulingType.ROUND_ROBIN &&
(!booking.eventType.hostGroups || booking.eventType.hostGroups?.length <= 1)
? {
id: "reassign",
label: t("reassign"),
@@ -144,7 +144,7 @@ const PlainContactForm = () => {
};
return (
<div className="absolute bottom-4 right-4 z-50">
<div className="absolute bottom-[1rem] right-[1rem] z-50">
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger asChild className="enabled:hover:bg-subtle bg-subtle shadow-none">
<Button
@@ -156,7 +156,7 @@ const PlainContactForm = () => {
<PopoverContent
style={{ maxWidth: "450px", maxHeight: "650px" }}
className="!bg-muted no-scrollbar mr-8 mb-2 w-[450px] overflow-hidden overflow-y-scroll px-6 py-4">
className="!bg-muted no-scrollbar mb-2 mr-8 w-[450px] overflow-hidden overflow-y-scroll px-6 py-4">
<div className="flex w-full justify-between">
<p className="mb-5 text-lg font-semibold">Contact support</p>
<Button
@@ -4,7 +4,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { showToast } from "@calcom/ui/components/toast";
import PlainContactForm from "../../../lib/plain/PlainContactForm";
import PlainContactForm from "../PlainContactForm";
vi.mock("next-auth/react", () => ({
useSession: vi.fn(),
@@ -124,8 +124,9 @@ const UsernameTextfield = (props: ICustomUsernameProps & Partial<React.Component
autoComplete="none"
autoCapitalize="none"
autoCorrect="none"
containerClassName="[&>div]:gap-0"
className={classNames(
"mb-0 mt-0 rounded-md rounded-l-none",
"mb-0 mt-0 rounded-md rounded-l-none pl-0",
markAsError
? "focus:shadow-0 focus:ring-shadow-0 border-red-500 focus:border-red-500 focus:outline-none focus:ring-0"
: ""
+3 -1
View File
@@ -2,4 +2,6 @@ import dynamic from "next/dynamic";
import { Fragment } from "react";
// Preload caused by dynamic import doesn't seem to add nonce and thus preload fails but the functionality still works - https://github.com/vercel/next.js/issues/81260
export default process.env.NEXT_PUBLIC_PLAIN_CHAT_ID ? dynamic(() => import("./plainChat")) : Fragment;
export default process.env.NEXT_PUBLIC_PLAIN_CHAT_ID
? dynamic(() => import("../../components/plain/plainChat"))
: Fragment;
@@ -117,7 +117,7 @@ const checkIfUserIsHost = async ({
});
// If eventType exists, it means user is either owner, host or user
return !eventType;
return !!eventType;
};
export async function getServerSideProps(context: GetServerSidePropsContext) {
@@ -22,7 +22,6 @@ import {
RecentFeedbackTable,
TimezoneBadge,
} from "@calcom/features/insights/components/booking";
import "@calcom/features/insights/components/tremor.css";
import { InsightsOrgTeamsProvider } from "@calcom/features/insights/context/InsightsOrgTeamsProvider";
import { Download } from "@calcom/features/insights/filters/Download";
import { OrgTeamsFilter } from "@calcom/features/insights/filters/OrgTeamsFilter";
@@ -1,6 +1,5 @@
"use client";
import { ProgressBar } from "@tremor/react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState, useMemo } from "react";
@@ -16,6 +15,7 @@ import { trpc } from "@calcom/trpc/react";
import { Button } from "@calcom/ui/components/button";
import { Select } from "@calcom/ui/components/form";
import { TextField, Label, InputError } from "@calcom/ui/components/form";
import { ProgressBar } from "@calcom/ui/components/progress-bar";
import { showToast } from "@calcom/ui/toast";
import { BillingCreditsSkeleton } from "./BillingCreditsSkeleton";
+1 -1
View File
@@ -13,7 +13,7 @@ export const BUTTONS = {
iconPathDarkMode: TRANSCRIPTION_STARTED_ICON,
},
START_TRANSCRIPTION: {
label: "Cal.ai",
label: "Transcribe",
tooltip: "Transcription powered by AI",
iconPath: TRANSCRIPTION_STOPPED_ICON,
iconPathDarkMode: TRANSCRIPTION_STOPPED_ICON,
@@ -85,7 +85,7 @@ export default function JoinCall(props: PageProps) {
...(showTranscriptionButton
? {
transcription: {
label: "Cal.ai",
label: "Transcribe",
tooltip: "Transcription powered by AI",
iconPath: TRANSCRIPTION_STOPPED_ICON,
iconPathDarkMode: TRANSCRIPTION_STOPPED_ICON,
+2 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "5.5.10",
"version": "5.5.14",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
@@ -76,10 +76,9 @@
"@stripe/stripe-js": "^1.35.0",
"@tanstack/react-query": "^5.17.15",
"@team-plain/typescript-sdk": "^5.9.0",
"@tremor/react": "^2.11.0",
"@types/turndown": "^5.0.1",
"@unkey/ratelimit": "^0.1.1",
"@upstash/redis": "^1.21.0",
"@upstash/redis": "^1.35.2",
"@vercel/edge-config": "^0.1.1",
"@vercel/edge-functions-ui": "^0.2.1",
"@vercel/og": "^0.6.3",
+2 -1
View File
@@ -7,6 +7,7 @@ import getIP from "@calcom/lib/getIP";
import { checkCfTurnstileToken } from "@calcom/lib/server/checkCfTurnstileToken";
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
import { CreationSource } from "@calcom/prisma/enums";
import { piiHasher } from "@calcom/lib/server/PiiHasher";
async function handler(req: NextApiRequest & { userId?: number }) {
const userIp = getIP(req);
@@ -20,7 +21,7 @@ async function handler(req: NextApiRequest & { userId?: number }) {
await checkRateLimitAndThrowError({
rateLimitingType: "core",
identifier: userIp,
identifier: piiHasher.hash(userIp),
});
const session = await getServerSession({ req });
+2 -1
View File
@@ -6,13 +6,14 @@ import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowE
import getIP from "@calcom/lib/getIP";
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
import { CreationSource } from "@calcom/prisma/enums";
import { piiHasher } from "@calcom/lib/server/PiiHasher";
async function handler(req: NextApiRequest & { userId?: number }) {
const userIp = getIP(req);
await checkRateLimitAndThrowError({
rateLimitingType: "core",
identifier: `instant.event-${userIp}`,
identifier: `instant.event-${piiHasher.hash(userIp)}`,
});
const session = await getServerSession({ req });
+2 -1
View File
@@ -7,6 +7,7 @@ import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowE
import getIP from "@calcom/lib/getIP";
import { checkCfTurnstileToken } from "@calcom/lib/server/checkCfTurnstileToken";
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
import { piiHasher } from "@calcom/lib/server/PiiHasher";
// @TODO: Didn't look at the contents of this function in order to not break old booking page.
@@ -37,7 +38,7 @@ async function handler(req: NextApiRequest & RequestMeta) {
await checkRateLimitAndThrowError({
rateLimitingType: "core",
identifier: userIp,
identifier: piiHasher.hash(userIp),
});
const session = await getServerSession({ req });
/* To mimic API behavior and comply with types */
+7 -15
View File
@@ -203,32 +203,24 @@ test.describe("Insights", async () => {
await page.keyboard.press("Escape");
// Choose User filter item from dropdown
await addFilter(page, "bookingUserId");
await addFilter(page, "userId");
// Wait for the URL to include bookingUserId
await page.waitForURL((url) => url.toString().includes("bookingUserId"));
// Wait for the URL to include userId
await page.waitForURL((url) => url.toString().includes("userId"));
// Click User filter to see a user list
await openFilter(page, "bookingUserId");
await openFilter(page, "userId");
await page
.locator('[data-testid="select-filter-options-bookingUserId"]')
.getByRole("option")
.nth(0)
.click();
await page.locator('[data-testid="select-filter-options-userId"]').getByRole("option").nth(0).click();
await page
.locator('[data-testid="select-filter-options-bookingUserId"]')
.getByRole("option")
.nth(1)
.click();
await page.locator('[data-testid="select-filter-options-userId"]').getByRole("option").nth(1).click();
// press escape button to close the filter
await page.keyboard.press("Escape");
await clearFilters(page);
await expect(page.url()).not.toContain("bookingUserId");
await expect(page.url()).not.toContain("userId");
});
test("should test download button", async ({ page, users }) => {
+15 -1
View File
@@ -120,7 +120,7 @@
"already_signed_up_for_this_booking_error": "You are already signed up for this booking.",
"hosts_unavailable_for_booking": "Some of the hosts are unavailable for booking.",
"fixed_hosts_unavailable_for_booking": "Some of the fixed hosts are unavailable for booking.",
"round_robin_hosts_unavailable_for_booking": "No Round Robin hosts is available for booking.",
"round_robin_host_unavailable_for_booking": "Round Robin host is unavailable for booking.",
"help": "Help",
"price": "Price",
"paid": "Paid",
@@ -349,6 +349,7 @@
"available_apps_lower_case": "Available apps",
"available_apps_desc": "View popular apps below and explore more in our <0>App Store</0>",
"fixed_host_helper": "Add anyone who needs to attend the event. <0>Learn more</0>",
"round_robin_groups_helper": "People in a group take turns, one person of each group will show up for the event.",
"round_robin_helper": "People in the group take turns and only one person will show up for the event.",
"check_email_reset_password": "Check your email. We sent you a link to reset your password.",
"finish": "Finish",
@@ -2251,6 +2252,8 @@
"floating_button_trigger_modal": "Puts a floating button on your site that triggers a modal with your event type.",
"pop_up_element_click": "Pop up via element click",
"open_dialog_with_element_click": "Open your calendar as a dialog when someone clicks an element.",
"use_my_own_form": "Use my own form",
"use_our_headless_routing_api": "Use our headless routing API and use your own form for submissions.",
"need_help_embedding": "Need help? See our guides for embedding Cal on Wix, Squarespace, or WordPress, check our common questions, or explore advanced embed options.",
"book_my_cal": "Book my Cal",
"first_name": "First name",
@@ -3286,6 +3289,7 @@
"pbac_resource_booking": "Bookings",
"pbac_resource_insights": "Insights",
"pbac_resource_role": "Roles",
"pbac_resource_workflow": "Workflows",
"pbac_action_all": "All Actions",
"pbac_action_create": "Create",
"pbac_action_read": "View",
@@ -3317,6 +3321,11 @@
"pbac_desc_update_roles": "Update roles",
"pbac_desc_delete_roles": "Delete roles",
"pbac_desc_manage_roles": "All actions on roles across organization teams",
"pbac_desc_create_workflows": "Create and set up new workflows",
"pbac_desc_view_workflows": "View existing workflows and their configurations",
"pbac_desc_update_workflows": "Edit and modify workflow settings",
"pbac_desc_delete_workflows": "Remove workflows from the system",
"pbac_desc_manage_workflows": "Full management access to all workflows",
"pbac_desc_create_event_types": "Create event types",
"pbac_desc_view_event_types": "View event types",
"pbac_desc_update_event_types": "Update event types",
@@ -3354,6 +3363,7 @@
"use_booker_timezone_info": "Apply the selected restriction schedule in the booker's timezone",
"load_balancing_warning": "This will disable Load Balancing on all your Round Robin event types",
"rr_load_balancing_disabled": "Load Balancing is only available when the timestamp basis is set to Booking Creation Time",
"rr_load_balancing_disabled_with_groups": "Load Balancing is not available with Round Robin Groups",
"round_robin_settings_description": "Customize the default round robin settings for this team",
"round_robin_settings_updated_successfully": "Round Robin settings updated successfully",
"booking_creation_time": "Booking Creation Time",
@@ -3370,6 +3380,7 @@
"booking_not_allowed_by_restriction_schedule_error": "Booking outside restriction schedule availability.",
"restriction_schedule_not_found_error": "Restriction schedule not found",
"converted_image_size_limit_exceed": "Image size limit exceeded, please use a smaller image preferably in JPEG format",
"add_group": "Add Group",
"routing_funnel": "Routing Funnel",
"routing_funnel_total_submissions": "Total Submissions",
"routing_funnel_successful_routings": "Successful Routings",
@@ -3402,6 +3413,7 @@
"license_key_saved": "License key saved successfully",
"timezone_mismatch_tooltip": "You are viewing the report based on your profile timezone ({{userTimezone}}), while your browser is set to timezone ({{browserTimezone}})",
"failed_bookings_by_field": "Failed Bookings By Field",
"failed_bookings": "Failed bookings",
"event_type_no_hosts": "No hosts are assigned to event type",
"cache_status": "Cache Status",
"cache_last_updated": "Last updated: {{timestamp}}",
@@ -3429,5 +3441,7 @@
"usage_based_expiration_description": "This link can be used for {{count}} booking",
"usage_based_generic_expiration_description": "This link can be configured to expire after a set number of bookings",
"usage_based_expiration_description_plural": "This link can be used for {{count}} bookings",
"stats": "Stats",
"booking_status": "Booking status",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -380,7 +380,7 @@
"connect": "Ühenda",
"try_for_free": "Proovi tasuta",
"create_booking_link_with_calcom": "Looge oma broneerimislink rakendusega {{appName}}",
"who": "WHO",
"who": "Kes",
"what": "Mida",
"when": "Millal",
"where": "Kus",
+1 -5
View File
@@ -2,10 +2,6 @@ const base = require("@calcom/config/tailwind-preset");
/** @type {import('tailwindcss').Config} */
module.exports = {
...base,
content: [
...base.content,
"../../packages/app-store/routing-forms/**/*.{js,ts,jsx,tsx}",
"../../node_modules/@tremor/**/*.{js,ts,jsx,tsx}",
],
content: [...base.content, "../../packages/app-store/routing-forms/**/*.{js,ts,jsx,tsx}"],
plugins: [...base.plugins, require("tailwindcss-animate")],
};
+6 -5
View File
@@ -1,4 +1,5 @@
import CalendarManagerMock from "../../../../tests/libs/__mocks__/CalendarManager";
import { constantsScenarios } from "@calcom/lib/__mocks__/constants";
import {
getDate,
@@ -23,12 +24,12 @@ import { expect, expectedSlotsForSchedule } from "./getSchedule/expects";
import { setupAndTeardown } from "./getSchedule/setupAndTeardown";
import { timeTravelToTheBeginningOfToday } from "./getSchedule/utils";
vi.mock("@calcom/lib/constants", () => ({
IS_PRODUCTION: true,
constantsScenarios.set({
IS_PRODUCTION: true as any,
WEBAPP_URL: "http://localhost:3000",
RESERVED_SUBDOMAINS: ["auth", "docs"],
SINGLE_ORG_SLUG: "",
}));
RESERVED_SUBDOMAINS: ["auth", "docs"] as any,
SINGLE_ORG_SLUG: "" as any,
});
describe("getSchedule", () => {
const availableSlotsService = getAvailableSlotsService();
@@ -26,7 +26,12 @@ vi.mock("@calcom/emails/email-manager", () => {
vi.mock("@calcom/lib/server/i18n", () => {
return {
getTranslation: (key: string) => key,
getTranslation: async (locale: string, namespace: string) => {
const t = (key: string) => key;
t.locale = locale;
t.namespace = namespace;
return t;
},
};
});
@@ -121,6 +121,7 @@ type InputHost = {
userId: number;
isFixed?: boolean;
scheduleId?: number | null;
groupId?: string | null;
};
type InputSelectedSlot = {
@@ -215,6 +216,7 @@ export type InputEventType = {
useEventLevelSelectedCalendars?: boolean;
users?: { id: number }[];
hosts?: InputHost[];
hostGroups?: { id: string; name: string }[];
schedulingType?: SchedulingType;
parent?: { id: number };
beforeEventBuffer?: number;
@@ -290,6 +292,17 @@ export const Timezones = {
async function addHostsToDb(eventTypes: InputEventType[]) {
for (const eventType of eventTypes) {
// Create host groups first if they exist
if (eventType.hostGroups?.length) {
await prismock.hostGroup.createMany({
data: eventType.hostGroups.map((group) => ({
id: group.id, // Preserve the input ID
name: group.name,
eventTypeId: eventType.id,
})),
});
}
if (!eventType.hosts?.length) continue;
for (const host of eventType.hosts) {
const data: Prisma.HostCreateInput = {
@@ -311,6 +324,13 @@ async function addHostsToDb(eventTypes: InputEventType[]) {
},
}
: undefined,
group: host.groupId
? {
connect: {
id: host.groupId,
},
}
: undefined,
};
await prismock.host.create({
@@ -363,6 +383,7 @@ export async function addEventTypesToDb(
workflows: true,
destinationCalendar: true,
schedule: true,
hostGroups: true,
},
});
@@ -473,6 +494,7 @@ export async function addEventTypes(eventTypes: InputEventType[], usersStore: In
workflows: [],
users,
hosts,
hostGroups: eventType.hostGroups || [],
destinationCalendar: eventType.destinationCalendar
? {
create: eventType.destinationCalendar,
+4
View File
@@ -4,6 +4,10 @@
"path": "/api/cron/calendar-cache-cleanup",
"schedule": "0 5 * * *"
},
{
"path": "/api/cron/queuedFormResponseCleanup",
"schedule": "0 */12 * * *"
},
{
"path": "/api/tasks/cron",
"schedule": "* * * * *"
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -109,7 +109,7 @@
"group": "Embeds",
"icon": "code-simple",
"pages": ["developing/guides/embeds/embed-events"]
}
},
{
"group": "Insights",
"icon": "chart-bar",
+24 -1
View File
@@ -156,7 +156,16 @@ export function AvailabilityWithValidation() {
};
const handleSubmit = () => {
availabilityRef.current?.handleFormSubmit();
availabilityRef.current?.handleFormSubmit({
onSuccess: () => {
// Additional success handling logic here
console.log('Availability updated successfully');
},
onError: (error) => {
// Additional error handling logic here
console.error('Error updating availability:', error);
}
});
};
return (
@@ -184,6 +193,20 @@ export function AvailabilityWithValidation() {
| validateForm | Validates the current availability form state and returns a promise with validation results. |
| handleFormSubmit | Programmatically submits the availability form, triggering the same validation and submission flow as clicking the save button. Unlike `validateForm`, this method will check required fields and prevent submission unless all required fields are set |
### Callbacks
The `handleFormSubmit` method accepts an optional callbacks object with the following properties:
```typescript
type AvailabilitySettingsFormCallbacks = {
onSuccess?: () => void;
onError?: (error: Error) => void;
};
```
- **onSuccess**: Called when the form submission is successful. This allows you to execute additional logic after a successful update.
- **onError**: Called when an error occurs during form submission. The error parameter contains details about what went wrong, allowing you to handle specific error cases or display custom error messages.
The `validateForm` method returns an `AvailabilityFormValidationResult` object with:
- `isValid`: Boolean indicating if the form passed validation
- `errors`: Object containing any validation errors found
+1
View File
@@ -126,6 +126,7 @@ Below is a list of props that can be passed to the booker atom.
| 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"] |
| onTimeslotsLoaded | No | Callback function triggered once the available timeslots have been fetched. |
| roundRobinHideOrgAndTeam | No | Boolean indicating if the organization and team should be hidden in the booker atom sidebar for round robin scheduling type, defaults to false. |
## Styling
+24 -1
View File
@@ -218,7 +218,16 @@ export function EventTypeWithValidation(eventTypeId: number) {
};
const handleSubmit = () => {
eventTypeRef.current?.handleFormSubmit();
eventTypeRef.current?.handleFormSubmit({
onSuccess: () => {
// Additional success handling logic here
console.log('Event type updated successfully');
},
onError: (error) => {
// Additional error handling logic here
console.error('Error updating event type:', error);
}
});
};
return (
@@ -244,6 +253,20 @@ export function EventTypeWithValidation(eventTypeId: number) {
| validateForm | Validates the current event type form state and returns a promise with validation results. |
| handleFormSubmit | Programmatically submits the event type form, triggering the same validation and submission flow as clicking the save button. Unlike `validateForm`, this method will check required fields and prevent submission unless all required fields are set |
### Callbacks
The `handleFormSubmit` method accepts an optional callbacks object with the following properties:
```typescript
type EventTypeFormCallbacks = {
onSuccess?: () => void;
onError?: (error: Error) => void;
};
```
- **onSuccess**: Called when the form submission is successful. This allows you to execute additional logic after a successful update.
- **onError**: Called when an error occurs during form submission. The error parameter contains details about what went wrong, allowing you to handle specific error cases or display custom error messages.
The `validateForm` method returns an `EventTypeFormValidationResult` object with:
- `isValid`: Boolean indicating if the form passed validation
- `errors`: Object containing any validation errors found
+1
View File
@@ -118,6 +118,7 @@
"@daily-co/daily-js": "^0.76.0",
"@evyweb/ioctopus": "^1.2.0",
"@next/third-parties": "^14.2.5",
"@unkey/ratelimit": "^2.1.2",
"@vercel/functions": "^1.4.0",
"city-timezones": "^1.2.1",
"date-fns-tz": "^3.2.0",
@@ -60,10 +60,13 @@ const EventTypeAppSettingsInterface: EventTypeAppSettingsComponent = ({
) : (
requirePayment && (
<>
<div className="mt-4 block items-center justify-start sm:flex sm:space-x-2">
<div className="mt-4 inline-block">
<label className="text-default block text-sm font-medium mb-1" htmlFor="price">
{t("price")}
</label>
<TextField
label={t("price")}
className="text-black"
className="text-black dark:text-white w-auto"
addOnClassname="h-[38px]"
min="1"
type="number"
@@ -88,7 +91,6 @@ const EventTypeAppSettingsInterface: EventTypeAppSettingsComponent = ({
variant="default"
options={currencyOptions}
value={selectedCurrency}
className="text-black"
defaultValue={selectedCurrency}
onChange={(e) => {
if (e) {
@@ -132,3 +134,4 @@ const EventTypeAppSettingsInterface: EventTypeAppSettingsComponent = ({
};
export default EventTypeAppSettingsInterface;
@@ -1,9 +1,35 @@
export const currencyOptions = [
{ label: "SATS", value: "BTC", unit: "SATS" },
{ label: "USD", value: "USD", unit: "USD" },
{ label: "USD- US Dollar", value: "USD", unit: "USD" },
{ label: "EUR- Euro", value: "EUR", unit: "EUR" },
{ label: "JPY- Japanese Yen", value: "JPY", unit: "JPY" },
{ label: "CNY- Yuan Renminbi", value: "CNY", unit: "CNY" },
{ label: "GBP- Pounds Sterling", value: "GBP", unit: "GBP" },
{ label: "AED- UAE Dirham", value: "AED", unit: "AED" },
{ label: "ZAR- South African Rand", value: "ZAR", unit: "ZAR" },
{ label: "HKD- Hong Kong Dollar", value: "HKD", unit: "HKD" },
{ label: "BRL- Brazilian Real", value: "BRL", unit: "BRL" },
{ label: "AUD- Australian Dollar", value: "AUD", unit: "AUD" },
{ label: "CAD- Canadian Dollar", value: "CAD", unit: "CAD" },
{ label: "CZK- Czech Koruna", value: "CZK", unit: "CZK" },
{ label: "DKK- Danish Krone", value: "DKK", unit: "DKK" },
{ label: "NZD- New Zealand Dollar", value: "NZD", unit: "NZD" },
{ label: "MYR- Malaysian Ringgit", value: "MYR", unit: "MYR" },
{ label: "PHP- Philippine Peso", value: "PHP", unit: "PHP" },
{ label: "CHF- Swiss Franc", value: "CHF", unit: "CHF" },
{ label: "NOK- Norwegian Krone", value: "NOK", unit: "NOK" },
{ label: "THB- Thai Baht", value: "THB", unit: "THB" },
{ label: "SEK- Swedish Krona", value: "SEK", unit: "SEK" },
{ label: "SGD- Singapore Dollar", value: "SGD", unit: "SGD" },
{ label: "PLN- Polish Zloty", value: "PLN", unit: "PLN" },
{ label: "TWD- New Taiwan Dollar", value: "TWD", unit: "TWD" },
{ label: "MXN- Mexican Peso", value: "MXN", unit: "MXN" },
{ label: "ILS- New Isreali Shekel", value: "ILS", unit: "ILS" },
{ label: "NGN- Nigerian Naira", value: "NGN", unit: "NGN" }
];
const zeroDecimalCurrencies = ["SATS", "BTC"];
const zeroDecimalCurrencies = ["SATS", "BTC", "JPY"];
export const convertToSmallestCurrencyUnit = (amount: number, currency: string) => {
if (zeroDecimalCurrencies.includes(currency.toUpperCase())) {
@@ -17,4 +43,4 @@ export const convertFromSmallestToPresentableCurrencyUnit = (amount: number, cur
return amount;
}
return amount / 100;
};
};
@@ -1114,4 +1114,18 @@ export default class GoogleCalendarService implements Calendar {
throw error;
}
}
async getMainTimeZone(): Promise<string> {
try {
const primaryCalendar = await this.getPrimaryCalendar();
if (!primaryCalendar?.timeZone) {
this.log.warn("No timezone found in primary calendar, defaulting to UTC");
return "UTC";
}
return primaryCalendar.timeZone;
} catch (error) {
this.log.error("Error getting main timezone from Google Calendar", { error });
throw error;
}
}
}
@@ -5,6 +5,7 @@ import { renewSelectedCalendarCredentialId } from "@calcom/lib/connectedCalendar
import { WEBAPP_URL, WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants";
import { handleErrorsJson } from "@calcom/lib/errors";
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
import logger from "@calcom/lib/logger";
import prisma from "@calcom/prisma";
import { Prisma } from "@calcom/prisma/client";
@@ -91,8 +92,21 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
"Content-Type": "application/json",
},
});
logger.info("Office365 Calendar: Received calendar response", {
userId: req.session?.user?.id,
status: calRequest.status,
statusText: calRequest.statusText,
url: calRequest.url,
});
let calBody = await handleErrorsJson<{ value: OfficeCalendar[]; "@odata.nextLink"?: string }>(calRequest);
logger.info("Office365 Calendar: handleErrorsJson completed", {
userId: req.session?.user?.id,
calendarCount: calBody.value.length ?? 0,
});
if (typeof responseBody === "string") {
calBody = JSON.parse(responseBody) as { value: OfficeCalendar[] };
}
@@ -625,4 +625,21 @@ export default class Office365CalendarService implements Calendar {
return response.json();
};
async getMainTimeZone(): Promise<string> {
try {
const response = await this.fetcher(`${await this.getUserEndpoint()}/mailboxSettings/timeZone`);
const timezone = await handleErrorsJson<string>(response);
if (!timezone) {
this.log.warn("No timezone found in mailbox settings, defaulting to Europe/London");
return "Europe/London";
}
return timezone;
} catch (error) {
this.log.error("Error getting main timezone from Office365 Calendar", { error });
throw error;
}
}
}
@@ -0,0 +1,47 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import logger from "@calcom/lib/logger";
import { PrismaQueuedFormResponseRepository } from "@calcom/lib/server/repository/routingForm/queuedFormResponse/PrismaQueuedFormResponseRepository";
import { QueuedFormResponseService } from "@calcom/lib/server/service/routingForm/queuedFormResponse/QueuedFormResponseService";
import prisma from "@calcom/prisma";
function validateRequest(request: NextRequest) {
const apiKey = request.headers.get("authorization") || request.nextUrl.searchParams.get("apiKey");
if (![process.env.CRON_API_KEY, `Bearer ${process.env.CRON_SECRET}`].includes(`${apiKey}`)) {
return NextResponse.json({ message: "Not authenticated" }, { status: 401 });
}
return null;
}
export async function handleQueuedFormResponseCleanup(request: NextRequest) {
const authError = validateRequest(request);
if (authError) {
return authError;
}
try {
const queuedFormResponseRepo = new PrismaQueuedFormResponseRepository(prisma);
const queuedFormResponseService = new QueuedFormResponseService({
logger,
queuedFormResponseRepo,
});
const result = await queuedFormResponseService.cleanupExpiredResponses({
batchSize: 1000,
});
return NextResponse.json({
status: "success",
data: result,
});
} catch (error) {
console.error("Error during queued form response cleanup:", error);
return NextResponse.json(
{ status: "error", message: "Failed to cleanup queued form responses" },
{ status: 500 }
);
}
}
@@ -79,11 +79,23 @@ export const zoomUserSettingsSchema = z.object({
waiting_room: z.boolean(),
})
.nullish(),
meeting_security: z
.object({
waiting_room_settings: z
.object({
participants_to_place_in_waiting_room: z.number().optional(),
users_who_can_admit_participants_from_waiting_room: z.number().optional(),
whitelisted_domains_for_waiting_room: z.string().optional(),
})
.optional(),
})
.nullish(),
});
// https://developers.zoom.us/docs/api/rest/reference/user/methods/#operation/userSettings
// append comma separated settings here, to retrieve only these specific settings
const settingsApiFilterResp = "default_password_for_scheduled_meetings,auto_recording,waiting_room";
const settingsApiFilterResp =
"default_password_for_scheduled_meetings,auto_recording,waiting_room,waiting_room_settings";
type ZoomRecurrence = {
end_date_time?: string;
@@ -175,6 +187,12 @@ const ZoomVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter =>
const userSettings = await getUserSettings();
const recurrence = getRecurrence(event);
const waitingRoomEnabled = userSettings?.in_meeting?.waiting_room ?? false;
const advancedWaitingRoomSettings = userSettings?.meeting_security?.waiting_room_settings;
const hasAdvancedWaitingRoomSettings =
waitingRoomEnabled &&
!!advancedWaitingRoomSettings &&
typeof advancedWaitingRoomSettings === "object" &&
Object.keys(advancedWaitingRoomSettings).length > 0;
// Documentation at: https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/meetingcreate
return {
topic: event.title,
@@ -200,6 +218,9 @@ const ZoomVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter =>
enforce_login: false,
registrants_email_notification: true,
waiting_room: waitingRoomEnabled,
...(hasAdvancedWaitingRoomSettings && {
waiting_room_settings: advancedWaitingRoomSettings,
}),
},
...recurrence,
};
+1 -1
View File
@@ -12,7 +12,7 @@
"dependencies": {
"@calcom/eslint-plugin-eslint": "*",
"@todesktop/tailwind-variants": "^1.0.0",
"eslint-config-next": "^15.4.5",
"eslint-config-next": "^13.2.1",
"eslint-config-prettier": "^8.6.0",
"eslint-config-turbo": "^0.0.7",
"eslint-plugin-playwright": "^0.12.0",
@@ -11,6 +11,7 @@ afterEach(() => {
vi.clearAllMocks();
vi.resetModules();
vi.useRealTimers();
vi.clearAllTimers();
});
describe("embedStore.router.ensureQueryParamsInUrl", async () => {
@@ -5,7 +5,7 @@ const rule = createRule({
create(context) {
// Track imported names from @calcom/lib/webstorage
const safeImportedNames = new Set<string>();
return {
ImportDeclaration(node) {
// Check if this is an import from the safe webstorage module
@@ -18,11 +18,11 @@ const rule = createRule({
});
}
},
CallExpression(node) {
const webStorages = ["localStorage", "sessionStorage"];
const callee = node.callee;
// Check for window.localStorage or window.sessionStorage
if (
// Can't figure out how to fix this TS issue
@@ -40,7 +40,7 @@ const rule = createRule({
messageId: "possible-issue-with-embed",
});
}
// Check for direct localStorage or sessionStorage usage
if (
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
@@ -63,11 +63,11 @@ const rule = createRule({
});
}
},
// Also check for property access like localStorage.length
MemberExpression(node) {
const webStorages = ["localStorage", "sessionStorage"];
// Check for direct property access on localStorage/sessionStorage
if (
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
@@ -86,7 +86,7 @@ const rule = createRule({
messageId: "possible-issue-with-embed",
});
}
// Check for window.localStorage/sessionStorage property access
if (
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
@@ -31,6 +31,7 @@ import { isENVDev } from "@calcom/lib/env";
import logger from "@calcom/lib/logger";
import { randomString } from "@calcom/lib/random";
import { safeStringify } from "@calcom/lib/safeStringify";
import { hashEmail } from "@calcom/lib/server/PiiHasher";
import { CredentialRepository } from "@calcom/lib/server/repository/credential";
import { DeploymentRepository } from "@calcom/lib/server/repository/deployment";
import { OrganizationRepository } from "@calcom/lib/server/repository/organization";
@@ -131,7 +132,7 @@ const providers: Provider[] = [
}
await checkRateLimitAndThrowError({
identifier: user.email,
identifier: hashEmail(user.email),
});
if (!user.password?.hash && user.identityProvider !== IdentityProvider.CAL && !credentials.totpCode) {
+3 -2
View File
@@ -13,6 +13,7 @@ import { WEBAPP_URL } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import { getTranslation } from "@calcom/lib/server/i18n";
import { prisma } from "@calcom/prisma";
import { hashEmail } from "@calcom/lib/server/PiiHasher";
const log = logger.getSubLogger({ prefix: [`[[Auth] `] });
@@ -54,7 +55,7 @@ export const sendEmailVerification = async ({
await checkRateLimitAndThrowError({
rateLimitingType: "core",
identifier: email,
identifier: hashEmail(email),
});
await prisma.verificationToken.create({
@@ -142,7 +143,7 @@ export const sendChangeOfEmailVerification = async ({ user, language }: ChangeOf
await checkRateLimitAndThrowError({
rateLimitingType: "core",
identifier: user.emailFrom,
identifier: hashEmail(user.emailFrom),
});
await prisma.verificationToken.create({
+3 -1
View File
@@ -80,6 +80,7 @@ const BookerComponent = ({
confirmButtonDisabled,
timeZones,
eventMetaChildren,
roundRobinHideOrgAndTeam,
}: BookerProps & WrappedBookerProps) => {
const searchParams = useCompatSearchParams();
const isPlatformBookerEmbed = useIsPlatformBookerEmbed();
@@ -403,7 +404,8 @@ const BookerComponent = ({
isPlatform={isPlatform}
isPrivateLink={!!hashedLink}
locale={userLocale}
timeZones={timeZones}>
timeZones={timeZones}
roundRobinHideOrgAndTeam={roundRobinHideOrgAndTeam}>
{eventMetaChildren}
</EventMeta>
{layout !== BookerLayouts.MONTH_VIEW &&
@@ -54,6 +54,7 @@ export const EventMeta = ({
locale,
timeZones,
children,
roundRobinHideOrgAndTeam,
}: {
event?: Pick<
BookerEvent,
@@ -90,6 +91,7 @@ export const EventMeta = ({
locale?: string | null;
timeZones?: Timezone[];
children?: React.ReactNode;
roundRobinHideOrgAndTeam?: boolean;
}) => {
const { timeFormat, timezone } = useBookerTime();
const [setTimezone] = useTimePreferences((state) => [state.setTimezone]);
@@ -167,6 +169,7 @@ export const EventMeta = ({
profile={event.profile}
entity={event.entity}
isPrivateLink={isPrivateLink}
roundRobinHideOrgAndTeam={roundRobinHideOrgAndTeam}
/>
<EventTitle className={`${classNames?.eventMetaTitle} my-2`}>
{translatedTitle ?? event?.title}
@@ -140,11 +140,13 @@ export type WrappedBookerPropsForPlatform = WrappedBookerPropsMain & {
verifyCode: undefined;
customClassNames?: CustomClassNames;
timeZones?: Timezone[];
roundRobinHideOrgAndTeam?: boolean;
};
export type WrappedBookerPropsForWeb = WrappedBookerPropsMain & {
isPlatform: false;
verifyCode: UseVerifyCodeReturnType;
timeZones?: Timezone[];
roundRobinHideOrgAndTeam?: boolean;
};
export type WrappedBookerProps = WrappedBookerPropsForPlatform | WrappedBookerPropsForWeb;
@@ -18,6 +18,7 @@ export interface EventMembersProps {
profile: BookerEvent["profile"];
entity: BookerEvent["entity"];
isPrivateLink: boolean;
roundRobinHideOrgAndTeam?: boolean;
}
export const EventMembers = ({
@@ -26,6 +27,7 @@ export const EventMembers = ({
profile,
entity,
isPrivateLink,
roundRobinHideOrgAndTeam,
}: EventMembersProps) => {
const username = useBookerStore((state) => state.username);
const isDynamic = !!(username && username.indexOf("+") > -1);
@@ -40,6 +42,10 @@ export const EventMembers = ({
!users.length ||
(profile.name !== users[0].name && schedulingType === SchedulingType.COLLECTIVE);
if (schedulingType === SchedulingType.ROUND_ROBIN && roundRobinHideOrgAndTeam) {
return <div className="h-6" />;
}
const orgOrTeamAvatarItem =
isDynamic || (!profile.image && !entity.logoUrl) || !entity.teamSlug
? []
@@ -44,6 +44,7 @@ import {
import { getVideoCallUrlFromCalEvent } from "@calcom/lib/CalEventParser";
import EventManager, { placeholderCreatedEvent } from "@calcom/lib/EventManager";
import { handleAnalyticsEvents } from "@calcom/lib/analyticsManager/handleAnalyticsEvents";
import { groupHostsByGroupId } from "@calcom/lib/bookings/hostGroupUtils";
import { shouldIgnoreContactOwner } from "@calcom/lib/bookings/routing/utils";
import { getUsernameList } from "@calcom/lib/defaultEvents";
import {
@@ -281,9 +282,7 @@ export const buildEventForTeamEventType = async ({
const fixedUsers = users.filter((user) => user.isFixed);
const nonFixedUsers = users.filter((user) => !user.isFixed);
const filteredUsers =
schedulingType === SchedulingType.ROUND_ROBIN
? [...fixedUsers, ...(nonFixedUsers.length > 0 ? [nonFixedUsers[0]] : [])]
: users;
schedulingType === SchedulingType.ROUND_ROBIN ? [...fixedUsers, ...nonFixedUsers] : users;
// Organizer or user owner of this event type it's not listed as a team member.
const teamMemberPromises = filteredUsers
@@ -825,11 +824,17 @@ async function handler(
}
}
const luckyUserPool: IsFixedAwareUser[] = [];
const fixedUserPool: IsFixedAwareUser[] = [];
const nonFixedUsers: IsFixedAwareUser[] = [];
availableUsers.forEach((user) => {
user.isFixed ? fixedUserPool.push(user) : luckyUserPool.push(user);
user.isFixed ? fixedUserPool.push(user) : nonFixedUsers.push(user);
});
// Group non-fixed users by their group IDs
const luckyUserPools = groupHostsByGroupId({
hosts: nonFixedUsers,
hostGroups: eventType.hostGroups,
});
const notAvailableLuckyUsers: typeof users = [];
@@ -838,81 +843,88 @@ async function handler(
"Computed available users",
safeStringify({
availableUsers: availableUsers.map((user) => user.id),
luckyUserPool: luckyUserPool.map((user) => user.id),
luckyUserPools: Object.fromEntries(
Object.entries(luckyUserPools).map(([groupId, users]) => [groupId, users.map((user) => user.id)])
),
})
);
const luckyUsers: typeof users = [];
// loop through all non-fixed hosts and get the lucky users
// This logic doesn't run when contactOwner is used because in that case, luckUsers.length === 1
while (luckyUserPool.length > 0 && luckyUsers.length < 1 /* TODO: Add variable */) {
const freeUsers = luckyUserPool.filter(
(user) => !luckyUsers.concat(notAvailableLuckyUsers).find((existing) => existing.id === user.id)
);
// no more freeUsers after subtracting notAvailableLuckyUsers from luckyUsers :(
if (freeUsers.length === 0) break;
assertNonEmptyArray(freeUsers); // make sure TypeScript knows it too with an assertion; the error will never be thrown.
// freeUsers is ensured
for (const [groupId, luckyUserPool] of Object.entries(luckyUserPools)) {
let luckUserFound = false;
while (luckyUserPool.length > 0 && !luckUserFound) {
const freeUsers = luckyUserPool.filter(
(user) => !luckyUsers.concat(notAvailableLuckyUsers).find((existing) => existing.id === user.id)
);
// no more freeUsers after subtracting notAvailableLuckyUsers from luckyUsers :(
if (freeUsers.length === 0) break;
assertNonEmptyArray(freeUsers); // make sure TypeScript knows it too with an assertion; the error will never be thrown.
// freeUsers is ensured
const userIdsSet = new Set(users.map((user) => user.id));
const firstUserOrgId = await getOrgIdFromMemberOrTeamId({
memberId: eventTypeWithUsers.users[0].id ?? null,
teamId: eventType.teamId,
});
const newLuckyUser = await getLuckyUser({
// find a lucky user that is not already in the luckyUsers array
availableUsers: freeUsers,
allRRHosts: (
await enrichHostsWithDelegationCredentials({
orgId: firstUserOrgId ?? null,
hosts: eventTypeWithUsers.hosts,
})
).filter((host) => !host.isFixed && userIdsSet.has(host.user.id)),
eventType,
routingFormResponse,
meetingStartTime: new Date(reqBody.start),
});
if (!newLuckyUser) {
break; // prevent infinite loop
}
if (
input.bookingData.isFirstRecurringSlot &&
eventType.schedulingType === SchedulingType.ROUND_ROBIN
) {
// for recurring round robin events check if lucky user is available for next slots
try {
for (
let i = 0;
i < input.bookingData.allRecurringDates.length &&
i < input.bookingData.numSlotsToCheckForAvailability;
i++
) {
const start = input.bookingData.allRecurringDates[i].start;
const end = input.bookingData.allRecurringDates[i].end;
const userIdsSet = new Set(users.map((user) => user.id));
const firstUserOrgId = await getOrgIdFromMemberOrTeamId({
memberId: eventTypeWithUsers.users[0].id ?? null,
teamId: eventType.teamId,
});
const newLuckyUser = await getLuckyUser({
// find a lucky user that is not already in the luckyUsers array
availableUsers: freeUsers,
// only hosts from the same group
allRRHosts: (
await enrichHostsWithDelegationCredentials({
orgId: firstUserOrgId ?? null,
hosts: eventTypeWithUsers.hosts,
})
).filter((host) => !host.isFixed && userIdsSet.has(host.user.id) && host.groupId === groupId),
eventType,
routingFormResponse,
meetingStartTime: new Date(reqBody.start),
});
if (!newLuckyUser) {
break; // prevent infinite loop
}
if (
input.bookingData.isFirstRecurringSlot &&
eventType.schedulingType === SchedulingType.ROUND_ROBIN
) {
// for recurring round robin events check if lucky user is available for next slots
try {
for (
let i = 0;
i < input.bookingData.allRecurringDates.length &&
i < input.bookingData.numSlotsToCheckForAvailability;
i++
) {
const start = input.bookingData.allRecurringDates[i].start;
const end = input.bookingData.allRecurringDates[i].end;
await ensureAvailableUsers(
{ ...eventTypeWithUsers, users: [newLuckyUser] },
{
dateFrom: dayjs(start).tz(reqBody.timeZone).format(),
dateTo: dayjs(end).tz(reqBody.timeZone).format(),
timeZone: reqBody.timeZone,
originalRescheduledBooking,
},
loggerWithEventDetails,
shouldServeCache
await ensureAvailableUsers(
{ ...eventTypeWithUsers, users: [newLuckyUser] },
{
dateFrom: dayjs(start).tz(reqBody.timeZone).format(),
dateTo: dayjs(end).tz(reqBody.timeZone).format(),
timeZone: reqBody.timeZone,
originalRescheduledBooking,
},
loggerWithEventDetails,
shouldServeCache
);
}
// if no error, then lucky user is available for the next slots
luckyUsers.push(newLuckyUser);
luckUserFound = true;
} catch {
notAvailableLuckyUsers.push(newLuckyUser);
loggerWithEventDetails.info(
`Round robin host ${newLuckyUser.name} not available for first two slots. Trying to find another host.`
);
}
// if no error, then lucky user is available for the next slots
} else {
luckyUsers.push(newLuckyUser);
} catch {
notAvailableLuckyUsers.push(newLuckyUser);
loggerWithEventDetails.info(
`Round robin host ${newLuckyUser.name} not available for first two slots. Trying to find another host.`
);
luckUserFound = true;
}
} else {
luckyUsers.push(newLuckyUser);
}
}
@@ -921,8 +933,22 @@ async function handler(
throw new Error(ErrorCode.FixedHostsUnavailableForBooking);
}
const roundRobinHosts = eventType.hosts.filter((host) => !host.isFixed);
const hostGroups = groupHostsByGroupId({
hosts: roundRobinHosts,
hostGroups: eventType.hostGroups,
});
// Filter out host groups that have no hosts in them
const nonEmptyHostGroups = Object.fromEntries(
Object.entries(hostGroups).filter(([groupId, hosts]) => hosts.length > 0)
);
// If there are RR hosts, we need to find a lucky user
if ([...qualifiedRRUsers, ...additionalFallbackRRUsers].length > 0 && luckyUsers.length === 0) {
if (
[...qualifiedRRUsers, ...additionalFallbackRRUsers].length > 0 &&
luckyUsers.length !== (Object.keys(nonEmptyHostGroups).length || 1)
) {
throw new Error(ErrorCode.RoundRobinHostsUnavailableForBooking);
}
@@ -933,7 +959,9 @@ async function handler(
...troubleshooterData,
luckyUsers: luckyUsers.map((u) => u.id),
fixedUsers: fixedUserPool.map((u) => u.id),
luckyUserPool: luckyUserPool.map((u) => u.id),
luckyUserPool: Object.values(luckyUserPools)
.flat()
.map((u) => u.id),
};
} else if (
input.bookingData.allRecurringDates &&
@@ -4,9 +4,9 @@ import dayjs from "@calcom/dayjs";
import type { Dayjs } from "@calcom/dayjs";
import { checkForConflicts } from "@calcom/features/bookings/lib/conflictChecker/checkForConflicts";
import { buildDateRanges } from "@calcom/lib/date-ranges";
import { getBusyTimesService } from "@calcom/lib/di/containers/busy-times";
import { getUserAvailabilityService } from "@calcom/lib/di/containers/get-user-availability";
import { ErrorCode } from "@calcom/lib/errorCodes";
import { getBusyTimesForLimitChecks } from "@calcom/lib/getBusyTimes";
import { getUsersAvailability } from "@calcom/lib/getUserAvailability";
import { parseBookingLimit } from "@calcom/lib/intervalLimits/isBookingLimits";
import { parseDurationLimit } from "@calcom/lib/intervalLimits/isDurationLimits";
import { getPiiFreeUser } from "@calcom/lib/piiFreeData";
@@ -62,6 +62,7 @@ const _ensureAvailableUsers = async (
shouldServeCache?: boolean
// ReturnType hint of at least one IsFixedAwareUser, as it's made sure at least one entry exists
): Promise<[IsFixedAwareUser, ...IsFixedAwareUser[]]> => {
const userAvailabilityService = getUserAvailabilityService();
const availableUsers: IsFixedAwareUser[] = [];
const startDateTimeUtc = getDateTimeInUtc(input.dateFrom, input.timeZone);
@@ -73,9 +74,12 @@ const _ensureAvailableUsers = async (
const bookingLimits = parseBookingLimit(eventType?.bookingLimits);
const durationLimits = parseDurationLimit(eventType?.durationLimits);
const busyTimesFromLimitsBookingsAllUsers: Awaited<ReturnType<typeof getBusyTimesForLimitChecks>> =
const busyTimesService = getBusyTimesService();
const busyTimesFromLimitsBookingsAllUsers: Awaited<
ReturnType<typeof busyTimesService.getBusyTimesForLimitChecks>
> =
eventType && (bookingLimits || durationLimits)
? await getBusyTimesForLimitChecks({
? await busyTimesService.getBusyTimesForLimitChecks({
userIds: eventType.users.map((u) => u.id),
eventTypeId: eventType.id,
startDate: startDateTimeUtc.format(),
@@ -86,7 +90,7 @@ const _ensureAvailableUsers = async (
})
: [];
const usersAvailability = await getUsersAvailability({
const usersAvailability = await userAvailabilityService.getUsersAvailability({
users: eventType.users,
query: {
...input,
@@ -135,6 +135,7 @@ export const getEventTypesFromDB = async (eventTypeId: number) => {
priority: true,
weight: true,
createdAt: true,
groupId: true,
user: {
select: {
credentials: {
@@ -177,6 +178,12 @@ export const getEventTypesFromDB = async (eventTypeId: number) => {
assignRRMembersUsingSegment: true,
rrSegmentQueryValue: true,
useEventLevelSelectedCalendars: true,
hostGroups: {
select: {
id: true,
name: true,
},
},
},
});
@@ -206,6 +213,7 @@ export const getEventTypesFromDB = async (eventTypeId: number) => {
bookingFields: getBookingFieldsWithSystemFields({ ...restEventType, isOrgTeamEvent }),
rrSegmentQueryValue: rrSegmentQueryValueSchema.parse(eventType.rrSegmentQueryValue) ?? null,
isDynamic: false,
hostGroups: eventType.hostGroups || [],
};
} catch (error) {
if (error instanceof Error && error.message.includes("Record to update not found")) {
@@ -85,12 +85,13 @@ const loadUsersByEventType = async (eventType: EventType): Promise<NewBookingEve
eventType,
hosts: hosts ?? fallbackHosts,
});
return matchingHosts.map(({ user, isFixed, priority, weight, createdAt }) => ({
return matchingHosts.map(({ user, isFixed, priority, weight, createdAt, groupId }) => ({
...user,
isFixed,
priority,
weight,
createdAt,
groupId,
}));
};
@@ -0,0 +1,302 @@
import type { Booking, User, Webhook } from "@prisma/client";
import { afterAll, beforeAll, describe, expect, test, vi } from "vitest";
import { DailyLocationType } from "@calcom/app-store/locations";
import { getMeetingSessionsFromRoomName } from "@calcom/features/tasker/tasks/triggerNoShow/getMeetingSessionsFromRoomName";
import { triggerHostNoShow } from "@calcom/features/tasker/tasks/triggerNoShow/triggerHostNoShow";
import { sendGenericWebhookPayload } from "@calcom/features/webhooks/lib/sendPayload";
import { prisma } from "@calcom/prisma";
import { TimeUnit, WebhookTriggerEvents } from "@calcom/prisma/enums";
import { scheduleNoShowTriggers } from "./scheduleNoShowTriggers";
vi.mock("@calcom/features/tasker/tasks/triggerNoShow/getMeetingSessionsFromRoomName", () => ({
getMeetingSessionsFromRoomName: vi.fn(),
}));
vi.mock("@calcom/features/webhooks/lib/sendPayload", () => ({
sendGenericWebhookPayload: vi.fn().mockResolvedValue({}),
}));
describe("scheduleNoShowTriggers Integration", () => {
let testUser: User;
let hostWebhook: Webhook;
let guestWebhook: Webhook;
const testEventTypeId = 67890;
const testBookingIds = [98765, 98766];
const testBookingId = 123456;
beforeAll(async () => {
testUser = await prisma.user.create({
data: {
email: "test-user-no-show@example.com",
username: "test-user-no-show",
name: "Test User No Show",
},
});
[hostWebhook, guestWebhook] = await Promise.all([
prisma.webhook.create({
data: {
id: "test-host-webhook-id",
userId: testUser.id,
subscriberUrl: "https://example.com/host-webhook",
eventTriggers: [WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW],
active: true,
time: 5,
timeUnit: TimeUnit.MINUTE,
},
}),
prisma.webhook.create({
data: {
id: "test-guest-webhook-id",
userId: testUser.id,
subscriberUrl: "https://example.com/guest-webhook",
eventTriggers: [WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW],
active: true,
time: 10,
timeUnit: TimeUnit.MINUTE,
},
}),
]);
});
afterAll(async () => {
await prisma.task.deleteMany({
where: {
OR: [{ type: "triggerHostNoShowWebhook" }, { type: "triggerGuestNoShowWebhook" }],
},
});
await prisma.webhook.deleteMany({
where: {
userId: testUser.id,
},
});
await prisma.booking.deleteMany({
where: {
id: testBookingId,
},
});
await prisma.user.deleteMany({
where: {
id: testUser.id,
},
});
await prisma.eventType.deleteMany({
where: {
id: testEventTypeId,
},
});
});
test("scheduling a webhook creates the correct Tasker jobs for no-show webhooks", async () => {
const currentDate = new Date("2023-01-01T10:00:00.000Z");
const bookingData: Partial<Booking> & { startTime: Date; id: number; location: string } = {
id: testBookingIds[0],
startTime: currentDate,
endTime: new Date("2023-01-01T10:30:00.000Z"),
location: DailyLocationType,
};
await scheduleNoShowTriggers({
booking: bookingData,
organizerUser: { id: testUser.id },
eventTypeId: testEventTypeId,
triggerForUser: true,
});
const createdTasks = await prisma.task.findMany({
where: {
OR: [{ type: "triggerHostNoShowWebhook" }, { type: "triggerGuestNoShowWebhook" }],
},
});
expect(createdTasks.length).toBe(2);
const hostTask = createdTasks.find((task) => task.type === "triggerHostNoShowWebhook");
expect(hostTask).toBeDefined();
const guestTask = createdTasks.find((task) => task.type === "triggerGuestNoShowWebhook");
expect(guestTask).toBeDefined();
});
test("created task payloads for no-show webhooks are correct", async () => {
const currentDate = new Date("2023-01-01T11:00:00.000Z");
const bookingData: Partial<Booking> & { startTime: Date; id: number; location: string } = {
id: testBookingIds[1],
startTime: currentDate,
endTime: new Date("2023-01-01T11:30:00.000Z"),
location: DailyLocationType,
};
await scheduleNoShowTriggers({
booking: bookingData,
organizerUser: { id: testUser.id },
eventTypeId: testEventTypeId,
triggerForUser: true,
});
const createdTasks = await prisma.task.findMany({
where: {
OR: [{ type: "triggerHostNoShowWebhook" }, { type: "triggerGuestNoShowWebhook" }],
payload: {
contains: bookingData.id.toString(),
},
},
});
const hostTask = createdTasks.find((task) => task.type === "triggerHostNoShowWebhook");
expect(hostTask).toBeDefined();
let hostPayload;
try {
hostPayload = JSON.parse(hostTask?.payload ?? "");
} catch (e) {
console.error("Failed to parse host task payload.");
throw e;
}
expect(hostPayload).toEqual({
triggerEvent: WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW,
bookingId: bookingData.id,
webhook: expect.objectContaining({
id: hostWebhook.id,
subscriberUrl: "https://example.com/host-webhook",
eventTriggers: [WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW],
time: 5,
timeUnit: TimeUnit.MINUTE,
}),
});
const guestTask = createdTasks.find((task) => task.type === "triggerGuestNoShowWebhook");
expect(guestTask).toBeDefined();
let guestPayload;
try {
guestPayload = JSON.parse(guestTask?.payload ?? "");
} catch (e) {
console.error("Failed to parse guest task payload.");
throw e;
}
expect(guestPayload).toEqual({
triggerEvent: WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW,
bookingId: bookingData.id,
webhook: expect.objectContaining({
id: guestWebhook.id,
subscriberUrl: "https://example.com/guest-webhook",
eventTriggers: [WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW],
time: 10,
timeUnit: TimeUnit.MINUTE,
}),
});
});
test("task handler runs properly with the correct payload", async () => {
await prisma.eventType.create({
data: {
id: testEventTypeId,
title: "Test Event Type",
slug: "test-event-type",
length: 30,
userId: testUser.id,
description: "Test description",
},
});
const now = new Date();
await prisma.booking.create({
data: {
id: testBookingId,
uid: "test-uid-no-show",
title: "Test No-Show",
eventTypeId: testEventTypeId,
status: "ACCEPTED",
startTime: now,
endTime: new Date(now.getTime() + 30 * 60 * 1000),
location: DailyLocationType,
userId: testUser.id,
references: {
create: {
type: "daily_video",
uid: "meeting-test-uid",
},
},
},
});
vi.mocked(getMeetingSessionsFromRoomName).mockResolvedValue({
total_count: 1,
data: [
{
id: "meeting-test-id",
room: "meeting-test-uid",
start_time: Math.floor(now.getTime() / 1000),
duration: 30,
max_participants: 1,
participants: [
{
user_id: "999999",
participant_id: "participant-test-id",
user_name: "Guest User",
join_time: Math.floor(now.getTime() / 1000),
duration: 30,
},
],
},
],
});
const taskPayload = JSON.stringify({
triggerEvent: WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW,
bookingId: testBookingId,
webhook: {
id: hostWebhook.id,
subscriberUrl: "https://example.com/host-webhook",
eventTriggers: [WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW],
active: true,
time: 5,
timeUnit: TimeUnit.MINUTE,
payloadTemplate: null,
secret: null,
appId: null,
},
});
await triggerHostNoShow(taskPayload);
expect(sendGenericWebhookPayload).toHaveBeenCalledWith(
expect.objectContaining({
webhook: expect.objectContaining({
id: hostWebhook.id,
subscriberUrl: "https://example.com/host-webhook",
}),
triggerEvent: WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW,
data: expect.objectContaining({
bookingId: testBookingId,
bookingUid: "test-uid-no-show",
message: expect.stringContaining("Host with email"),
participants: expect.arrayContaining([
expect.objectContaining({
user_id: "999999",
user_name: "Guest User",
}),
]),
}),
})
);
const updatedBooking = await prisma.booking.findUnique({
where: {
id: testBookingId,
},
});
expect(updatedBooking?.noShowHost).toBe(true);
});
});
@@ -139,7 +139,7 @@ describe("buildEventForTeamEventType", () => {
expect(memberEmails).toContain("fixed@example.com");
expect(memberEmails).toContain("nonfixed1@example.com");
expect(memberEmails).not.toContain("nonfixed2@example.com");
expect(memberEmails).toContain("nonfixed2@example.com");
});
it("builds a team with fallback name and id", async () => {
@@ -0,0 +1,652 @@
import {
getBooker,
TestData,
getOrganizer,
createBookingScenario,
Timezones,
getScenarioData,
mockSuccessfulVideoMeetingCreation,
BookingLocations,
getDate,
getMockBookingAttendee,
getGoogleCalendarCredential,
mockCalendarToHaveNoBusySlots,
} from "@calcom/web/test/utils/bookingScenario/bookingScenario";
import { getMockRequestDataForBooking } from "@calcom/web/test/utils/bookingScenario/getMockRequestDataForBooking";
import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAndTeardown";
import { describe, test, vi, expect } from "vitest";
import { appStoreMetadata } from "@calcom/app-store/apps.metadata.generated";
import { ErrorCode } from "@calcom/lib/errorCodes";
import { SchedulingType } from "@calcom/prisma/enums";
import { BookingStatus } from "@calcom/prisma/enums";
describe("Round Robin handleNewBooking", () => {
setupAndTeardown();
describe("Round Robin with groups", () => {
test("Books one host from each round robin group", async () => {
const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default;
const booker = getBooker({
email: "booker@example.com",
name: "Booker",
});
const teamMemberOne = [
{
name: "Team Member One",
username: "other-team-member-1",
timeZone: Timezones["+5:30"],
defaultScheduleId: null,
email: "team-member-one@example.com",
id: 102,
schedule: TestData.schedules.IstMorningShift,
credentials: [getGoogleCalendarCredential()],
selectedCalendars: [TestData.selectedCalendars.google],
destinationCalendar: {
integration: TestData.apps["google-calendar"].type,
externalId: "other-team-member-1@google-calendar.com",
},
},
];
const organizer = getOrganizer({
name: "Organizer",
email: "organizer@example.com",
id: 101,
defaultScheduleId: null,
schedules: [TestData.schedules.IstMorningShift],
credentials: [getGoogleCalendarCredential()],
selectedCalendars: [TestData.selectedCalendars.google],
destinationCalendar: {
integration: TestData.apps["google-calendar"].type,
externalId: "organizer@google-calendar.com",
},
});
const teamMembers = [
{
name: "Team Member 1",
username: "team-member-1",
timeZone: Timezones["+5:30"],
defaultScheduleId: null,
email: "team-member-1@example.com",
id: 102,
schedules: [TestData.schedules.IstEveningShift],
},
{
name: "Team Member 2",
username: "team-member-2",
timeZone: Timezones["+5:30"],
defaultScheduleId: null,
email: "team-member-2@example.com",
id: 103,
schedules: [TestData.schedules.IstEveningShift],
},
{
name: "Team Member 3",
username: "team-member-3",
timeZone: Timezones["+5:30"],
defaultScheduleId: null,
email: "team-member-3@example.com",
id: 104,
schedules: [TestData.schedules.IstEveningShift],
},
{
name: "Team Member 4",
username: "team-member-4",
timeZone: Timezones["+5:30"],
defaultScheduleId: null,
email: "team-member-4@example.com",
id: 105,
schedules: [TestData.schedules.IstEveningShift],
},
];
await createBookingScenario(
getScenarioData({
eventTypes: [
{
id: 1,
slotInterval: 15,
schedulingType: SchedulingType.ROUND_ROBIN,
length: 15,
users: [
{ id: teamMembers[0].id },
{ id: teamMembers[1].id },
{ id: teamMembers[2].id },
{ id: teamMembers[3].id },
],
hosts: [
{ userId: teamMembers[0].id, isFixed: false, groupId: "group-1" },
{ userId: teamMembers[1].id, isFixed: false, groupId: "group-1" },
{ userId: teamMembers[2].id, isFixed: false, groupId: "group-2" },
{ userId: teamMembers[3].id, isFixed: false, groupId: "group-2" },
],
hostGroups: [
{ id: "group-1", name: "Group 1" },
{ id: "group-2", name: "Group 2" },
],
schedule: TestData.schedules.IstMorningShift,
destinationCalendar: {
integration: TestData.apps["google-calendar"].type,
externalId: "event-type-1@google-calendar.com",
},
},
],
organizer,
usersApartFromOrganizer: teamMembers,
apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
})
);
mockSuccessfulVideoMeetingCreation({
metadataLookupKey: appStoreMetadata.dailyvideo.dirName,
videoMeetingData: {
id: "MOCK_ID",
password: "MOCK_PASS",
url: `http://mock-dailyvideo.example.com/meeting-1`,
},
});
const calendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", {
create: {
id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID",
iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID",
},
});
const mockBookingData = getMockRequestDataForBooking({
data: {
// Try booking the first available free timeslot in both the users' schedules
start: `${getDate({ dateIncrement: 1 }).dateString}T11:30:00.000Z`,
end: `${getDate({ dateIncrement: 1 }).dateString}T11:45:00.000Z`,
eventTypeId: 1,
responses: {
email: booker.email,
name: booker.name,
location: { optionValue: "", value: BookingLocations.CalVideo },
},
},
});
const createdBooking = await handleNewBooking({
bookingData: mockBookingData,
});
// Verify that the booking was created successfully
expect(createdBooking).toBeDefined();
expect(createdBooking.responses).toEqual(
expect.objectContaining({
email: booker.email,
name: booker.name,
})
);
// Verify that lucky users were selected (one from each group)
expect(createdBooking.luckyUsers).toBeDefined();
expect(createdBooking.luckyUsers).toHaveLength(2);
// Verify that the selected users are from different groups
const selectedUserIds = createdBooking.luckyUsers;
const group1UserIds = [teamMembers[0].id, teamMembers[1].id]; // group-1
const group2UserIds = [teamMembers[2].id, teamMembers[3].id]; // group-2
// Check that one user is from group-1 and one is from group-2
const hasGroup1User = selectedUserIds.some((id) => group1UserIds.includes(id));
const hasGroup2User = selectedUserIds.some((id) => group2UserIds.includes(id));
expect(hasGroup1User).toBe(true);
expect(hasGroup2User).toBe(true);
// Verify that the booking has the correct attendees
expect(createdBooking.attendees).toHaveLength(2);
expect(createdBooking.attendees[0].email).toBe(booker.email);
// The second attendee should be one of the selected lucky users
const secondAttendeeEmail = createdBooking.attendees[1].email;
const selectedUserEmails = selectedUserIds.map(
(id) => teamMembers.find((member) => member.id === id)?.email
);
expect(selectedUserEmails).toContain(secondAttendeeEmail);
});
test("Throws error when one round robin group has no available hosts", async () => {
const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default;
const booker = getBooker({
email: "booker@example.com",
name: "Booker",
});
const teamMembers = [
{
name: "Team Member 1",
username: "team-member-1",
timeZone: Timezones["+5:30"],
defaultScheduleId: null,
email: "team-member-1@example.com",
id: 102,
schedules: [TestData.schedules.IstEveningShift],
},
{
name: "Team Member 2",
username: "team-member-2",
timeZone: Timezones["+5:30"],
defaultScheduleId: null,
email: "team-member-2@example.com",
id: 103,
schedules: [TestData.schedules.IstEveningShift],
},
{
name: "Team Member 3",
username: "team-member-3",
timeZone: Timezones["+5:30"],
defaultScheduleId: null,
email: "team-member-3@example.com",
id: 104,
schedules: [TestData.schedules.IstEveningShift],
},
{
name: "Team Member 4",
username: "team-member-4",
timeZone: Timezones["+5:30"],
defaultScheduleId: null,
email: "team-member-4@example.com",
id: 105,
schedules: [TestData.schedules.IstEveningShift],
},
];
const organizer = getOrganizer({
name: "Organizer",
email: "organizer@example.com",
id: 101,
defaultScheduleId: null,
schedules: [TestData.schedules.IstMorningShift],
credentials: [getGoogleCalendarCredential()],
selectedCalendars: [TestData.selectedCalendars.google],
destinationCalendar: {
integration: TestData.apps["google-calendar"].type,
externalId: "organizer@google-calendar.com",
},
});
await createBookingScenario(
getScenarioData({
eventTypes: [
{
id: 1,
slotInterval: 15,
schedulingType: SchedulingType.ROUND_ROBIN,
length: 15,
users: [
{ id: teamMembers[0].id },
{ id: teamMembers[1].id },
{ id: teamMembers[2].id },
{ id: teamMembers[3].id },
],
hosts: [
{ userId: teamMembers[0].id, isFixed: false, groupId: "group-1" },
{ userId: teamMembers[1].id, isFixed: false, groupId: "group-1" },
{ userId: teamMembers[2].id, isFixed: false, groupId: "group-2" },
{ userId: teamMembers[3].id, isFixed: false, groupId: "group-2" },
],
hostGroups: [
{ id: "group-1", name: "Group 1" },
{ id: "group-2", name: "Group 2" },
],
schedule: TestData.schedules.IstMorningShift,
destinationCalendar: {
integration: TestData.apps["google-calendar"].type,
externalId: "event-type-1@google-calendar.com",
},
},
],
organizer,
usersApartFromOrganizer: teamMembers,
apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
// Add existing bookings to make only group-2 hosts busy
bookings: [
{
userId: teamMembers[0].id, // Team Member 1 (group-1)
eventTypeId: 1,
startTime: `${getDate({ dateIncrement: 1 }).dateString}T11:30:00.000Z`,
endTime: `${getDate({ dateIncrement: 1 }).dateString}T11:45:00.000Z`,
status: "ACCEPTED",
attendees: [
{
email: "existing-booker-1@example.com",
},
],
},
{
userId: teamMembers[1].id, // Team Member 2 (group-1)
eventTypeId: 1,
startTime: `${getDate({ dateIncrement: 1 }).dateString}T11:30:00.000Z`,
endTime: `${getDate({ dateIncrement: 1 }).dateString}T11:45:00.000Z`,
status: "ACCEPTED",
attendees: [
{
email: "existing-booker-2@example.com",
},
],
},
],
})
);
mockSuccessfulVideoMeetingCreation({
metadataLookupKey: appStoreMetadata.dailyvideo.dirName || "dailyvideo",
videoMeetingData: {
id: "MOCK_ID",
password: "MOCK_PASS",
url: `http://mock-dailyvideo.example.com/meeting-1`,
},
});
const calendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", {
create: {
id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID",
iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID",
},
});
const mockBookingData = getMockRequestDataForBooking({
data: {
start: `${getDate({ dateIncrement: 1 }).dateString}T11:30:00.000Z`,
end: `${getDate({ dateIncrement: 1 }).dateString}T11:45:00.000Z`,
eventTypeId: 1,
responses: {
email: booker.email,
name: booker.name,
location: { optionValue: "", value: BookingLocations.CalVideo },
},
},
});
// Expect handleNewBooking to throw an error because group-2 has no available hosts
await expect(
handleNewBooking({
bookingData: mockBookingData,
})
).rejects.toThrow(ErrorCode.RoundRobinHostsUnavailableForBooking);
});
test("Creates successful booking even when one group has no hosts", async () => {
const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default;
const booker = getBooker({
email: "booker@example.com",
name: "Booker",
});
const teamMembers = [
{
name: "Team Member 1",
username: "team-member-1",
timeZone: Timezones["+5:30"],
defaultScheduleId: null,
email: "team-member-1@example.com",
id: 102,
schedules: [TestData.schedules.IstEveningShift],
},
{
name: "Team Member 2",
username: "team-member-2",
timeZone: Timezones["+5:30"],
defaultScheduleId: null,
email: "team-member-2@example.com",
id: 103,
schedules: [TestData.schedules.IstEveningShift],
},
];
const organizer = getOrganizer({
name: "Organizer",
email: "organizer@example.com",
id: 101,
defaultScheduleId: null,
schedules: [TestData.schedules.IstMorningShift],
credentials: [getGoogleCalendarCredential()],
selectedCalendars: [TestData.selectedCalendars.google],
destinationCalendar: {
integration: TestData.apps["google-calendar"].type,
externalId: "organizer@google-calendar.com",
},
});
await createBookingScenario(
getScenarioData({
eventTypes: [
{
id: 1,
slotInterval: 15,
schedulingType: SchedulingType.ROUND_ROBIN,
length: 15,
users: [{ id: teamMembers[0].id }, { id: teamMembers[1].id }],
hosts: [
{ userId: teamMembers[0].id, isFixed: false, groupId: "group-1" },
{ userId: teamMembers[1].id, isFixed: false, groupId: "group-1" },
],
hostGroups: [
{ id: "group-1", name: "Group 1" },
{ id: "group-2", name: "Group 2" }, // Empty group with no hosts
],
schedule: TestData.schedules.IstMorningShift,
destinationCalendar: {
integration: TestData.apps["google-calendar"].type,
externalId: "event-type-1@google-calendar.com",
},
},
],
organizer,
usersApartFromOrganizer: teamMembers,
apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
})
);
mockSuccessfulVideoMeetingCreation({
metadataLookupKey: appStoreMetadata.dailyvideo.dirName,
videoMeetingData: {
id: "MOCK_ID",
password: "MOCK_PASS",
url: `http://mock-dailyvideo.example.com/meeting-1`,
},
});
const calendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", {
create: {
id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID",
iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID",
},
});
const mockBookingData = getMockRequestDataForBooking({
data: {
start: `${getDate({ dateIncrement: 1 }).dateString}T11:30:00.000Z`,
end: `${getDate({ dateIncrement: 1 }).dateString}T11:45:00.000Z`,
eventTypeId: 1,
responses: {
email: booker.email,
name: booker.name,
location: { optionValue: "", value: BookingLocations.CalVideo },
},
},
});
const createdBooking = await handleNewBooking({
bookingData: mockBookingData,
});
// Verify that the booking was created successfully
expect(createdBooking).toBeDefined();
expect(createdBooking.responses).toEqual(
expect.objectContaining({
email: booker.email,
name: booker.name,
})
);
// Verify that lucky users were selected (only from group-1 since group-2 has no hosts)
expect(createdBooking.luckyUsers).toBeDefined();
expect(createdBooking.luckyUsers).toHaveLength(1);
// Verify that the selected user is from group-1
const selectedUserId = createdBooking.luckyUsers[0];
const group1UserIds = [teamMembers[0].id, teamMembers[1].id]; // group-1
expect(group1UserIds).toContain(selectedUserId);
// Verify that the booking has the correct attendees
expect(createdBooking.attendees).toHaveLength(1);
expect(createdBooking.attendees[0].email).toBe(booker.email);
// Verify that the booking user is the selected lucky user
expect(createdBooking.userId).toBe(selectedUserId);
});
});
describe("Seated Round Robin Event", () => {
test("For second seat booking, organizer remains the same with no team members included", async () => {
const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default;
const EventManager = (await import("@calcom/lib/EventManager")).default;
const eventManagerSpy = vi.spyOn(EventManager.prototype, "updateCalendarAttendees");
const booker = getBooker({
email: "seat2@example.com",
name: "Seat 2",
});
const assignedHost = getOrganizer({
name: "Assigned Host",
email: "assigned-host@example.com",
id: 101,
schedules: [TestData.schedules.IstWorkHours],
});
const teamMembers = [
{
name: "Team Member 1",
username: "team-member-1",
timeZone: Timezones["+5:30"],
defaultScheduleId: null,
email: "team-member-1@example.com",
id: 102,
schedules: [TestData.schedules.IstEveningShift],
},
{
name: "Team Member 2",
username: "team-member-2",
timeZone: Timezones["+5:30"],
defaultScheduleId: null,
email: "team-member-2@example.com",
id: 103,
schedules: [TestData.schedules.IstEveningShift],
},
];
const bookingId = 1;
const bookingUid = "abc123";
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
const bookingStartTime = `${plus1DateString}T04:00:00Z`;
const bookingEndTime = `${plus1DateString}T04:30:00Z`;
await createBookingScenario(
getScenarioData({
eventTypes: [
{
id: 1,
slug: "seated-round-robin-event",
slotInterval: 30,
length: 30,
schedulingType: SchedulingType.ROUND_ROBIN,
users: [{ id: assignedHost.id }, { id: teamMembers[0].id }, { id: teamMembers[1].id }],
hosts: [
{ userId: assignedHost.id, isFixed: false },
{ userId: teamMembers[0].id, isFixed: false },
{ userId: teamMembers[1].id, isFixed: false },
],
seatsPerTimeSlot: 3,
seatsShowAttendees: false,
},
],
bookings: [
{
id: bookingId,
uid: bookingUid,
eventTypeId: 1,
status: BookingStatus.ACCEPTED,
startTime: bookingStartTime,
endTime: bookingEndTime,
userId: assignedHost.id, // This is the assigned host for the booking
metadata: {
videoCallUrl: "https://existing-daily-video-call-url.example.com",
},
references: [
{
type: appStoreMetadata.dailyvideo.type,
uid: "MOCK_ID",
meetingId: "MOCK_ID",
meetingPassword: "MOCK_PASS",
meetingUrl: "http://mock-dailyvideo.example.com",
credentialId: null,
},
],
attendees: [
getMockBookingAttendee({
id: 1,
name: "Seat 1",
email: "seat1@test.com",
locale: "en",
timeZone: "America/Toronto",
bookingSeat: {
referenceUid: "booking-seat-1",
data: {},
},
}),
],
},
],
organizer: assignedHost,
usersApartFromOrganizer: [...teamMembers],
})
);
mockSuccessfulVideoMeetingCreation({
metadataLookupKey: "dailyvideo",
videoMeetingData: {
id: "MOCK_ID",
password: "MOCK_PASS",
url: `http://mock-dailyvideo.example.com/meeting-1`,
},
});
const reqBookingUser = "seatedAttendee";
const mockBookingData = getMockRequestDataForBooking({
data: {
eventTypeId: 1,
responses: {
email: booker.email,
name: booker.name,
location: { optionValue: "", value: BookingLocations.CalVideo },
},
bookingUid: bookingUid,
user: reqBookingUser,
},
});
await handleNewBooking({
bookingData: mockBookingData,
});
expect(eventManagerSpy).toHaveBeenCalled();
const calendarEvent = eventManagerSpy.mock.calls[0][0];
expect(calendarEvent.organizer.email).toBe(assignedHost.email);
expect(calendarEvent.team?.members).toBeDefined();
expect(calendarEvent.team?.members.length).toBe(0);
const teamMemberEmails = calendarEvent.team?.members.map((member) => member.email);
expect(teamMemberEmails).not.toContain(teamMembers[0].email);
expect(teamMemberEmails).not.toContain(teamMembers[1].email);
});
});
});
@@ -1,172 +0,0 @@
import {
getBooker,
TestData,
getOrganizer,
createBookingScenario,
Timezones,
getScenarioData,
mockSuccessfulVideoMeetingCreation,
BookingLocations,
getDate,
getMockBookingAttendee,
} from "@calcom/web/test/utils/bookingScenario/bookingScenario";
import { getMockRequestDataForBooking } from "@calcom/web/test/utils/bookingScenario/getMockRequestDataForBooking";
import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAndTeardown";
import { describe, test, vi, expect } from "vitest";
import { appStoreMetadata } from "@calcom/app-store/apps.metadata.generated";
import { SchedulingType } from "@calcom/prisma/enums";
import { BookingStatus } from "@calcom/prisma/enums";
describe("Seated Round Robin Events", () => {
setupAndTeardown();
test("For second seat booking, organizer remains the same with no team members included", async () => {
const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default;
const EventManager = (await import("@calcom/lib/EventManager")).default;
const eventManagerSpy = vi.spyOn(EventManager.prototype, "updateCalendarAttendees");
const booker = getBooker({
email: "seat2@example.com",
name: "Seat 2",
});
const assignedHost = getOrganizer({
name: "Assigned Host",
email: "assigned-host@example.com",
id: 101,
schedules: [TestData.schedules.IstWorkHours],
});
const teamMembers = [
{
name: "Team Member 1",
username: "team-member-1",
timeZone: Timezones["+5:30"],
defaultScheduleId: null,
email: "team-member-1@example.com",
id: 102,
schedules: [TestData.schedules.IstEveningShift],
},
{
name: "Team Member 2",
username: "team-member-2",
timeZone: Timezones["+5:30"],
defaultScheduleId: null,
email: "team-member-2@example.com",
id: 103,
schedules: [TestData.schedules.IstEveningShift],
},
];
const bookingId = 1;
const bookingUid = "abc123";
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
const bookingStartTime = `${plus1DateString}T04:00:00Z`;
const bookingEndTime = `${plus1DateString}T04:30:00Z`;
await createBookingScenario(
getScenarioData({
eventTypes: [
{
id: 1,
slug: "seated-round-robin-event",
slotInterval: 30,
length: 30,
schedulingType: SchedulingType.ROUND_ROBIN,
users: [{ id: assignedHost.id }, { id: teamMembers[0].id }, { id: teamMembers[1].id }],
hosts: [
{ userId: assignedHost.id, isFixed: false },
{ userId: teamMembers[0].id, isFixed: false },
{ userId: teamMembers[1].id, isFixed: false },
],
seatsPerTimeSlot: 3,
seatsShowAttendees: false,
},
],
bookings: [
{
id: bookingId,
uid: bookingUid,
eventTypeId: 1,
status: BookingStatus.ACCEPTED,
startTime: bookingStartTime,
endTime: bookingEndTime,
userId: assignedHost.id, // This is the assigned host for the booking
metadata: {
videoCallUrl: "https://existing-daily-video-call-url.example.com",
},
references: [
{
type: appStoreMetadata.dailyvideo.type,
uid: "MOCK_ID",
meetingId: "MOCK_ID",
meetingPassword: "MOCK_PASS",
meetingUrl: "http://mock-dailyvideo.example.com",
credentialId: null,
},
],
attendees: [
getMockBookingAttendee({
id: 1,
name: "Seat 1",
email: "seat1@test.com",
locale: "en",
timeZone: "America/Toronto",
bookingSeat: {
referenceUid: "booking-seat-1",
data: {},
},
}),
],
},
],
organizer: assignedHost,
usersApartFromOrganizer: [...teamMembers],
})
);
mockSuccessfulVideoMeetingCreation({
metadataLookupKey: "dailyvideo",
videoMeetingData: {
id: "MOCK_ID",
password: "MOCK_PASS",
url: `http://mock-dailyvideo.example.com/meeting-1`,
},
});
const reqBookingUser = "seatedAttendee";
const mockBookingData = getMockRequestDataForBooking({
data: {
eventTypeId: 1,
responses: {
email: booker.email,
name: booker.name,
location: { optionValue: "", value: BookingLocations.CalVideo },
},
bookingUid: bookingUid,
user: reqBookingUser,
},
});
await handleNewBooking({
bookingData: mockBookingData,
});
expect(eventManagerSpy).toHaveBeenCalled();
const calendarEvent = eventManagerSpy.mock.calls[0][0];
expect(calendarEvent.organizer.email).toBe(assignedHost.email);
expect(calendarEvent.team?.members).toBeDefined();
expect(calendarEvent.team?.members.length).toBe(0);
const teamMemberEmails = calendarEvent.team?.members.map((member) => member.email);
expect(teamMemberEmails).not.toContain(teamMembers[0].email);
expect(teamMemberEmails).not.toContain(teamMembers[1].email);
});
});
@@ -41,6 +41,7 @@ export type IsFixedAwareUser = User & {
weight?: number;
userLevelSelectedCalendars: SelectedCalendar[];
allSelectedCalendars: SelectedCalendar[];
groupId?: string | null;
availabilityData?: GetUserAvailabilityResult;
};
@@ -25,6 +25,17 @@ vi.mock("@calcom/prisma", () => {
vi.mock("stripe");
vi.mock("@calcom/lib/server/i18n", () => {
return {
getTranslation: async (locale: string, namespace: string) => {
const t = (key: string) => key;
t.locale = locale;
t.namespace = namespace;
return t;
},
};
});
vi.mock("@calcom/lib/constants", async () => {
const actual = (await vi.importActual("@calcom/lib/constants")) as typeof import("@calcom/lib/constants");
return {
@@ -185,7 +185,7 @@ const handleGroupEvents = async (event: DirectorySyncEvent, organizationId: numb
});
// Send emails to new members
const newMembers = users.filter((user) => !user.teams.find((team) => team.id === group.teamId));
const newMembers = users.filter((user) => !user.teams.find((team) => team.teamId === group.teamId));
const newOrgMembers = users.filter(
(user) => !user.profiles.find((profile) => profile.organizationId === organizationId)
);
@@ -43,6 +43,17 @@ vi.mock("@calcom/lib/domainManager/organization", () => ({
createDomain: vi.fn(),
}));
vi.mock("@calcom/lib/server/i18n", () => {
return {
getTranslation: async (locale: string, namespace: string) => {
const t = (key: string) => key;
t.locale = locale;
t.namespace = namespace;
return t;
},
};
});
const mockOrganizationOnboarding = {
name: "Test Org",
slug: "test-org",
@@ -91,6 +91,13 @@ export const roundRobinManualReassignment = async ({
throw new Error("Event type not found");
}
if (eventType.hostGroups && eventType.hostGroups.length > 1) {
roundRobinReassignLogger.error(
`Event type ${eventTypeId} has more than one round robin group, reassignment is not allowed`
);
throw new Error("Reassignment not allowed with more than one round robin group");
}
const eventTypeHosts = eventType.hosts.length
? eventType.hosts
: eventType.users.map((user) => ({
@@ -100,6 +107,7 @@ export const roundRobinManualReassignment = async ({
weight: 100,
schedule: null,
createdAt: new Date(0), // use earliest possible date as fallback
groupId: null,
}));
const fixedHost = eventTypeHosts.find((host) => host.isFixed);
@@ -89,6 +89,13 @@ export const roundRobinReassignment = async ({
throw new Error("Event type not found");
}
if (eventType.hostGroups && eventType.hostGroups.length > 1) {
logger.error(
`Event type ${eventTypeId} has more than one round robin group, reassignment is not allowed`
);
throw new Error("Reassignment not allowed with more than one round robin group");
}
eventType.hosts = eventType.hosts.length
? eventType.hosts
: eventType.users.map((user) => ({
@@ -98,6 +105,7 @@ export const roundRobinReassignment = async ({
weight: 100,
schedule: null,
createdAt: new Date(0), // use earliest possible date as fallback
groupId: null,
}));
if (eventType.hosts.length === 0) {
@@ -1,5 +1,7 @@
"use client";
import type { PermissionString } from "@calcom/features/pbac/domain/types/permission-registry";
import type { MembershipRole } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc/react";
import type { CreateBtnProps, Option } from "./CreateButton";
@@ -11,10 +13,20 @@ export function CreateButtonWithTeamsList(
onlyShowWithNoTeams?: boolean;
isAdmin?: boolean;
includeOrg?: boolean;
withPermission?: {
permission: PermissionString;
fallbackRoles?: MembershipRole[];
};
}
) {
const query = trpc.viewer.loggedInViewerRouter.teamsAndUserProfilesQuery.useQuery({
includeOrg: props.includeOrg,
withPermission: props.withPermission
? {
permission: props.withPermission.permission,
fallbackRoles: props.withPermission.fallbackRoles,
}
: undefined,
});
if (!query.data) return null;
@@ -23,6 +23,14 @@ import WorkflowStepContainer from "./WorkflowStepContainer";
type User = RouterOutputs["viewer"]["me"]["get"];
interface WorkflowPermissions {
canView: boolean;
canUpdate: boolean;
canDelete: boolean;
canManage: boolean;
readOnly: boolean; // Keep for backward compatibility
}
interface Props {
form: UseFormReturn<FormValues>;
workflowId: number;
@@ -33,13 +41,31 @@ interface Props {
readOnly: boolean;
isOrg: boolean;
allOptions: Option[];
permissions?: WorkflowPermissions;
}
export default function WorkflowDetailsPage(props: Props) {
const { form, workflowId, selectedOptions, setSelectedOptions, teamId, isOrg, allOptions } = props;
const {
form,
workflowId,
selectedOptions,
setSelectedOptions,
teamId,
isOrg,
allOptions,
permissions: _permissions,
} = props;
const { t } = useLocale();
const router = useRouter();
const permissions = _permissions || {
canView: !teamId ? true : !props.readOnly,
canUpdate: !teamId ? true : !props.readOnly,
canDelete: !teamId ? true : !props.readOnly,
canManage: !teamId ? true : !props.readOnly,
readOnly: !teamId ? false : props.readOnly,
};
const [isAddActionDialogOpen, setIsAddActionDialogOpen] = useState(false);
const [reload, setReload] = useState(false);
@@ -160,7 +186,7 @@ export default function WorkflowDetailsPage(props: Props) {
/>
</div>
<div className="md:border-subtle my-7 border-transparent md:border-t" />
{!props.readOnly && (
{permissions.canDelete && (
<Button
type="button"
StartIcon="trash-2"
@@ -6,6 +6,7 @@ import { useState } from "react";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { WorkflowPermissions } from "@calcom/lib/server/repository/workflow-permissions";
import { trpc } from "@calcom/trpc/react";
import classNames from "@calcom/ui/classNames";
import { ArrowButton } from "@calcom/ui/components/arrow-button";
@@ -52,7 +53,8 @@ export type WorkflowType = Workflow & {
};
};
}[];
readOnly?: boolean;
readOnly?: boolean; // Keep for backward compatibility
permissions?: WorkflowPermissions;
isOrg?: boolean;
};
interface Props {
@@ -136,7 +138,7 @@ export default function WorkflowListPage({ workflows }: Props) {
: "Untitled"}
</div>
<div>
{workflow.readOnly && (
{(workflow.permissions?.readOnly ?? workflow.readOnly) && (
<Badge variant="gray" className="ml-2 ">
{t("readonly")}
</Badge>
@@ -257,7 +259,9 @@ export default function WorkflowListPage({ workflows }: Props) {
color="secondary"
variant="icon"
StartIcon="pencil"
disabled={workflow.readOnly}
disabled={
workflow.permissions ? !workflow.permissions.canUpdate : workflow.readOnly
}
onClick={async () => await router.replace(`/workflows/${workflow.id}`)}
data-testid="edit-button"
/>
@@ -270,40 +274,52 @@ export default function WorkflowListPage({ workflows }: Props) {
}}
color="secondary"
variant="icon"
disabled={workflow.readOnly}
disabled={
workflow.permissions ? !workflow.permissions.canDelete : workflow.readOnly
}
StartIcon="trash-2"
data-testid="delete-button"
/>
</Tooltip>
</ButtonGroup>
</div>
{!workflow.readOnly && (
{(workflow.permissions?.canUpdate ||
workflow.permissions?.canDelete ||
(!workflow.permissions && !workflow.readOnly)) && (
<div className="block sm:hidden">
<Dropdown>
<DropdownMenuTrigger asChild>
<Button type="button" color="minimal" variant="icon" StartIcon="ellipsis" />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>
<DropdownItem
type="button"
StartIcon="pencil"
onClick={async () => await router.replace(`/workflows/${workflow.id}`)}>
{t("edit")}
</DropdownItem>
</DropdownMenuItem>
<DropdownMenuItem>
<DropdownItem
type="button"
color="destructive"
StartIcon="trash-2"
onClick={() => {
setDeleteDialogOpen(true);
setwWorkflowToDeleteId(workflow.id);
}}>
{t("delete")}
</DropdownItem>
</DropdownMenuItem>
{(workflow.permissions
? workflow.permissions.canUpdate
: !workflow.readOnly) && (
<DropdownMenuItem>
<DropdownItem
type="button"
StartIcon="pencil"
onClick={async () => await router.replace(`/workflows/${workflow.id}`)}>
{t("edit")}
</DropdownItem>
</DropdownMenuItem>
)}
{(workflow.permissions
? workflow.permissions.canDelete
: !workflow.readOnly) && (
<DropdownMenuItem>
<DropdownItem
type="button"
color="destructive"
StartIcon="trash-2"
onClick={() => {
setDeleteDialogOpen(true);
setwWorkflowToDeleteId(workflow.id);
}}>
{t("delete")}
</DropdownItem>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</Dropdown>
</div>
@@ -245,6 +245,7 @@ export const scheduleEmailReminder = async (args: scheduleEmailReminderArgs) =>
type: evt.eventType?.slug || "",
organizer: { ...evt.organizer, language: { ...evt.organizer.language, translate: organizerT } },
attendees: [attendee],
location: bookingMetadataSchema.parse(evt.metadata || {})?.videoCallUrl || evt.location,
};
const attachments = includeCalendarEvent
@@ -17,6 +17,16 @@ vi.mock("@calcom/features/ee/workflows/lib/reminders/providers/emailProvider", (
sendOrScheduleWorkflowEmails: vi.fn(),
}));
vi.mock("@calcom/lib/server/i18n", () => {
return {
getTranslation: async (locale: string, namespace: string) => {
const t = (key: string) => key;
t.locale = locale;
t.namespace = namespace;
return t;
},
};
});
describe("reminderScheduler", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -1,5 +1,5 @@
import { createInstance } from "i18next";
import { expect, test, describe } from "vitest";
import { vi, expect, test, describe } from "vitest";
import { getTranslation } from "@calcom/lib/server/i18n";
import { TimeFormat } from "@calcom/lib/timeFormat";
@@ -10,6 +10,17 @@ import { getTemplateBodyForAction } from "../actionHelperFunctions";
import compareReminderBodyToTemplate from "../compareReminderBodyToTemplate";
import plainTextReminderTemplates from "../reminders/templates/plainTextTemplates";
vi.mock("@calcom/lib/server/i18n", () => {
return {
getTranslation: async (locale: string, namespace: string) => {
const t = (key: string) => key;
t.locale = locale;
t.namespace = namespace;
return t;
},
};
});
const translation = async () => {
const _i18n = createInstance();
await _i18n.init({
@@ -84,6 +84,10 @@ function WorkflowsPage({ filteredList }: PageProps) {
disableMobileButton={true}
onlyShowWithNoTeams={true}
includeOrg={true}
withPermission={{
permission: "workflow.create",
fallbackRoles: ["ADMIN", "OWNER"],
}}
/>
) : null
}>
@@ -99,6 +103,10 @@ function WorkflowsPage({ filteredList }: PageProps) {
disableMobileButton={true}
onlyShowWithTeams={true}
includeOrg={true}
withPermission={{
permission: "workflow.create",
fallbackRoles: ["ADMIN", "OWNER"],
}}
/>
</div>
</div>
@@ -10,9 +10,8 @@ import Shell, { ShellMain } from "@calcom/features/shell/Shell";
import { SENDER_ID } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { HttpError } from "@calcom/lib/http-error";
import type { WorkflowRepository } from "@calcom/lib/server/repository/workflow";
import type { TimeUnit, WorkflowTriggerEvents } from "@calcom/prisma/enums";
import { MembershipRole, WorkflowActions } from "@calcom/prisma/enums";
import { WorkflowActions } from "@calcom/prisma/enums";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
@@ -43,17 +42,9 @@ export type FormValues = {
type PageProps = {
workflow: number;
workflowData?: Awaited<ReturnType<typeof WorkflowRepository.getById>>;
verifiedNumbers?: Awaited<ReturnType<typeof WorkflowRepository.getVerifiedNumbers>>;
verifiedEmails?: Awaited<ReturnType<typeof WorkflowRepository.getVerifiedEmails>>;
};
function WorkflowPage({
workflow: workflowId,
workflowData: workflowDataProp,
verifiedNumbers: verifiedNumbersProp,
verifiedEmails: verifiedEmailsProp,
}: PageProps) {
function WorkflowPage({ workflow: workflowId }: PageProps) {
const { t, i18n } = useLocale();
const session = useSession();
@@ -73,35 +64,23 @@ function WorkflowPage({
const {
data: workflowData,
isError: _isError,
isError,
error,
isPending: _isPendingWorkflow,
} = trpc.viewer.workflows.get.useQuery(
{ id: +workflowId },
{
enabled: workflowDataProp ? false : !!workflowId,
}
);
isPending: isPendingWorkflow,
} = trpc.viewer.workflows.get.useQuery({ id: +workflowId });
const workflow = workflowDataProp || workflowData;
const isPendingWorkflow = workflowDataProp ? false : _isPendingWorkflow;
const isError = workflowDataProp ? false : _isError;
const workflow = workflowData;
const { data: verifiedNumbersData } = trpc.viewer.workflows.getVerifiedNumbers.useQuery(
const { data: verifiedNumbers } = trpc.viewer.workflows.getVerifiedNumbers.useQuery(
{ teamId: workflow?.team?.id },
{
enabled: verifiedNumbersProp ? false : !!workflow?.id,
enabled: !!workflow?.id,
}
);
const verifiedNumbers = verifiedNumbersProp || verifiedNumbersData;
const { data: verifiedEmailsData } = trpc.viewer.workflows.getVerifiedEmails.useQuery(
{
teamId: workflow?.team?.id,
},
{ enabled: !verifiedEmailsProp }
);
const verifiedEmails = verifiedEmailsProp || verifiedEmailsData;
const { data: verifiedEmails } = trpc.viewer.workflows.getVerifiedEmails.useQuery({
teamId: workflow?.team?.id,
});
const isOrg = workflow?.team?.isOrganization ?? false;
@@ -126,9 +105,7 @@ function WorkflowPage({
});
}
const readOnly =
workflow?.team?.members?.find((member) => member.userId === session.data?.user.id)?.role ===
MembershipRole.MEMBER;
const readOnly = !workflow?.permissions.canUpdate;
const isPending = isPendingWorkflow || isPendingEventTypes;
@@ -210,8 +187,8 @@ function WorkflowPage({
const updateMutation = trpc.viewer.workflows.update.useMutation({
onSuccess: async ({ workflow }) => {
if (workflow) {
utils.viewer.workflows.get.setData({ id: +workflow.id }, workflow);
setFormData(workflow);
await utils.viewer.workflows.get.invalidate({ id: +workflow.id });
showToast(
t("workflow_updated_successfully", {
workflowName: workflow.name,
@@ -348,6 +325,7 @@ function WorkflowPage({
{isAllDataLoaded && user ? (
<>
<WorkflowDetailsPage
permissions={workflow?.permissions}
form={form}
workflowId={+workflowId}
user={user}
+7 -3
View File
@@ -207,9 +207,13 @@ const ChooseEmbedTypesDialogContent = ({
key={index}
data-testid={embed.type}
onClick={() => {
gotoState({
embedType: embed.type as EmbedType,
});
if (embed.type === "headless") {
window.open("https://cal.com/help/routing/headless-routing", "_blank");
} else {
gotoState({
embedType: embed.type as EmbedType,
});
}
}}>
<div className="bg-default order-none box-border flex-none rounded-md border border-solid transition dark:bg-transparent dark:invert">
{embed.illustration}
+55 -1
View File
@@ -1,6 +1,8 @@
import type { ComponentProps } from "react";
import { EmbedDialog, EmbedButton } from "@calcom/features/embed/Embed";
import { IS_CALCOM } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { tabs } from "./lib/EmbedTabs";
@@ -8,11 +10,63 @@ import { useEmbedTypes } from "./lib/hooks";
export const RoutingFormEmbedDialog = () => {
const types = useEmbedTypes();
const { t } = useLocale();
const { data: user } = trpc.viewer.me.get.useQuery();
const routingFormTypes = types.filter((type) => type.type !== "email");
// Add the headless option specifically for routing forms
const headlessType = {
title: t("use_my_own_form"),
subtitle: t("use_our_headless_routing_api"),
type: "headless",
illustration: (
<svg
width="100%"
height="100%"
className="rounded-md"
viewBox="0 0 308 265"
fill="none"
xmlns="http://www.w3.org/2000/svg">
<path
d="M0 1.99999C0 0.895423 0.895431 0 2 0H306C307.105 0 308 0.895431 308 2V263C308 264.105 307.105 265 306 265H2C0.895431 265 0 264.105 0 263V1.99999Z"
fill="white"
/>
<rect x="24" width="260" height="38.5" rx="6" fill="#F3F4F6" />
<rect x="24" y="50.5" width="139" height="163" rx="6" fill="#F8F8F8" />
<g opacity="0.8">
<rect x="42" y="74" width="20" height="20" rx="2" fill="#374151" />
<path
d="M48 81L52 85L56 81"
stroke="white"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</g>
<rect x="48" y="106" width="80" height="8" rx="6" fill="#F3F4F6" />
<rect x="48" y="118" width="48" height="4" rx="6" fill="#F3F4F6" />
<rect x="48" y="130" width="64" height="4" rx="6" fill="#F3F4F6" />
<rect x="48" y="142" width="32" height="4" rx="6" fill="#F3F4F6" />
<rect x="48" y="160" width="72" height="6" rx="3" fill="#292929" />
<rect x="176" y="50.5" width="108" height="164" rx="6" fill="#F3F4F6" />
<text x="220" y="120" textAnchor="middle" className="fill-gray-400" fontSize="8">
API
</text>
<rect x="200" y="125" width="40" height="2" rx="1" fill="#9CA3AF" />
<rect x="200" y="130" width="32" height="2" rx="1" fill="#9CA3AF" />
<rect x="200" y="135" width="36" height="2" rx="1" fill="#9CA3AF" />
<rect x="24" y="226.5" width="260" height="38.5" rx="6" fill="#F3F4F6" />
</svg>
),
};
const routingFormTypesWithHeadless = IS_CALCOM
? [...routingFormTypes, headlessType]
: routingFormTypes;
return (
<EmbedDialog
types={routingFormTypes}
types={routingFormTypesWithHeadless}
tabs={tabs}
eventTypeHideOptionDisabled={true}
defaultBrandColor={user ? { brandColor: user.brandColor, darkBrandColor: user.darkBrandColor } : null}

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