From ac6bc9a4d64c20e859116463bd714893f5b687d0 Mon Sep 17 00:00:00 2001 From: Pedro Castro Date: Mon, 8 Dec 2025 14:48:52 -0300 Subject: [PATCH 1/4] feat: add multi-host assignment for round robin bookings Add maxRoundRobinHosts config to assign up to N hosts per RR booking. Includes schema, API v2, booking logic, UI, and tRPC integration. Default (null/1) maintains existing single-host behavior feat: add multi-host assignment for round robin bookings Add maxRoundRobinHosts config to assign up to N hosts per RR booking. Includes schema, API v2, booking logic, UI, and tRPC integration. Default (null/1) maintains existing single-host behavior --- .../services/output-event-types.service.ts | 1 + .../event-types/services/output.service.ts | 4 +- apps/web/public/static/locales/en/common.json | 2 + docs/api-reference/v2/openapi.json | 27 +++++++++++- .../handleNewBooking/getEventTypesFromDB.ts | 1 + .../lib/service/RegularBookingService.ts | 11 ++--- .../assignment/EventTeamAssignmentTab.tsx | 42 ++++++++++++++----- .../features/eventtypes/lib/defaultEvents.ts | 1 + packages/features/eventtypes/lib/types.ts | 1 + .../repositories/eventTypeRepository.ts | 3 ++ packages/lib/test/builder.ts | 1 + .../event-types/hooks/useEventTypeForm.ts | 1 + .../inputs/create-event-type.input.ts | 9 ++++ .../inputs/update-event-type.input.ts | 9 ++++ .../outputs/event-type.output.ts | 9 ++++ .../migration.sql | 2 + packages/prisma/schema.prisma | 2 + 17 files changed, 108 insertions(+), 18 deletions(-) create mode 100644 packages/prisma/migrations/20251208104340_add_max_round_robin_hosts_optional/migration.sql diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/services/output-event-types.service.ts b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/services/output-event-types.service.ts index d53d14e4e037fd..30f60f5fcb28e4 100644 --- a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/services/output-event-types.service.ts +++ b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/services/output-event-types.service.ts @@ -94,6 +94,7 @@ type Input = Pick< | "bookingRequiresAuthentication" | "maxActiveBookingsPerBooker" | "maxActiveBookingPerBookerOfferReschedule" + | "maxRoundRobinHosts" >; @Injectable() diff --git a/apps/api/v2/src/modules/organizations/event-types/services/output.service.ts b/apps/api/v2/src/modules/organizations/event-types/services/output.service.ts index 06776e5e301ef1..cff398d9d2a287 100644 --- a/apps/api/v2/src/modules/organizations/event-types/services/output.service.ts +++ b/apps/api/v2/src/modules/organizations/event-types/services/output.service.ts @@ -88,6 +88,7 @@ type Input = Pick< | "rescheduleWithSameRoundRobinHost" | "maxActiveBookingPerBookerOfferReschedule" | "maxActiveBookingsPerBooker" + | "maxRoundRobinHosts" >; @Injectable() @@ -103,7 +104,7 @@ export class OutputOrganizationsEventTypesService { const emailSettings = this.transformEmailSettings(metadata); - const { teamId, userId, parentId, assignAllTeamMembers, rescheduleWithSameRoundRobinHost } = + const { teamId, userId, parentId, assignAllTeamMembers, rescheduleWithSameRoundRobinHost, maxRoundRobinHosts } = databaseEventType; // eslint-disable-next-line @typescript-eslint/no-unused-vars const { ownerId, users, ...rest } = this.outputEventTypesService.getResponseEventType( @@ -139,6 +140,7 @@ export class OutputOrganizationsEventTypesService { theme: databaseEventType?.team?.theme, }, rescheduleWithSameRoundRobinHost, + maxRoundRobinHosts, }; } diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 3fb28a58406109..47edf057370c5c 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -4105,5 +4105,7 @@ "actor": "Actor", "timestamp": "Timestamp", "json": "JSON", + "max_round_robin_hosts_count": "Maximum hosts per booking", + "max_round_robin_hosts_description": "Set the maximum number of hosts to be assigned per booking. Defaults to 1.", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/docs/api-reference/v2/openapi.json b/docs/api-reference/v2/openapi.json index 374cdea6141c8a..1d2fb2088fd6a5 100644 --- a/docs/api-reference/v2/openapi.json +++ b/docs/api-reference/v2/openapi.json @@ -13841,7 +13841,7 @@ ], "responses": { "200": { - "description": "A map of available slots indexed by date, where each date is associated with an array of time slots. If format=range is specified, each slot will be an object with start and end properties denoting start and end of the slot.\n For seated slots each object will have attendeesCount and bookingUid properties.\n If no slots are available, the data object will be empty {}.", + "description": "A map of available slots indexed by date, where each date is associated with an array of time slots. If format=range is specified, each slot will be an object with start and end properties denoting start and end of the slot.\n For seated slots each object will have attendeesCount and bookingUid properties.\n If no slots are available, the data field will be an empty object {}.", "content": { "application/json": { "schema": { @@ -14452,7 +14452,7 @@ }, "get": { "operationId": "TeamsEventTypesController_getTeamEventTypes", - "summary": "Get a team event type", + "summary": "Get team event types", "description": "Use the optional `sortCreatedAt` query parameter to order results by creation date (by ID). Accepts \"asc\" (oldest first) or \"desc\" (newest first). When not provided, no explicit ordering is applied.", "parameters": [ { @@ -19663,6 +19663,11 @@ "rescheduleWithSameRoundRobinHost": { "type": "boolean", "description": "Rescheduled events will be assigned to the same host as initially scheduled." + }, + "maxRoundRobinHosts": { + "type": "number", + "description": "Only relevant for round robin event types. Specifies the maximum number of hosts that can be assigned per booking. Defaults to 1.", + "example": 1 } }, "required": [ @@ -21676,6 +21681,11 @@ "rescheduleWithSameRoundRobinHost": { "type": "boolean", "description": "Rescheduled events will be assigned to the same host as initially scheduled." + }, + "maxRoundRobinHosts": { + "type": "number", + "description": "Only relevant for round robin event types. Specifies the maximum number of hosts that can be assigned per booking. Defaults to 1.", + "example": 1 } }, "required": ["lengthInMinutes", "title", "slug", "schedulingType"] @@ -22139,6 +22149,11 @@ "rescheduleWithSameRoundRobinHost": { "type": "boolean", "description": "Rescheduled events will be assigned to the same host as initially scheduled." + }, + "maxRoundRobinHosts": { + "type": "number", + "description": "Only relevant for round robin event types. Specifies the maximum number of hosts that can be assigned per booking. Defaults to 1.", + "example": 1 } } }, @@ -29442,6 +29457,10 @@ "example": "success", "enum": ["success", "error"] }, + "message": { + "type": "string", + "example": "This endpoint will require authentication in a future release." + }, "error": { "type": "object" }, @@ -29469,6 +29488,10 @@ "type": "string" } }, + "message": { + "type": "string", + "example": "This endpoint will require authentication in a future release." + }, "error": { "type": "object" } diff --git a/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts b/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts index b7646ce77f231d..6b68c758465d26 100644 --- a/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts +++ b/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts @@ -85,6 +85,7 @@ const getEventTypesFromDBSelect = { bookingLimits: true, durationLimits: true, rescheduleWithSameRoundRobinHost: true, + maxRoundRobinHosts: true, assignAllTeamMembers: true, isRRWeightsEnabled: true, beforeEventBuffer: true, diff --git a/packages/features/bookings/lib/service/RegularBookingService.ts b/packages/features/bookings/lib/service/RegularBookingService.ts index ea20654d5eef85..44d2926190a808 100644 --- a/packages/features/bookings/lib/service/RegularBookingService.ts +++ b/packages/features/bookings/lib/service/RegularBookingService.ts @@ -1048,8 +1048,9 @@ async function handler( // 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 for (const [groupId, luckyUserPool] of Object.entries(luckyUserPools)) { - let luckUserFound = false; - while (luckyUserPool.length > 0 && !luckUserFound) { + const maxHostsPerGroup = eventType.maxRoundRobinHosts ?? 1; + let hostsFoundInGroup = 0; + while (luckyUserPool.length > 0 && hostsFoundInGroup < maxHostsPerGroup) { const freeUsers = luckyUserPool.filter( (user) => !luckyUsers.concat(notAvailableLuckyUsers).find((existing) => existing.id === user.id) ); @@ -1118,7 +1119,7 @@ async function handler( } // if no error, then lucky user is available for the next slots luckyUsers.push(newLuckyUser); - luckUserFound = true; + hostsFoundInGroup++; } catch { notAvailableLuckyUsers.push(newLuckyUser); tracingLogger.info( @@ -1127,7 +1128,7 @@ async function handler( } } else { luckyUsers.push(newLuckyUser); - luckUserFound = true; + hostsFoundInGroup++; } } } @@ -1151,7 +1152,7 @@ async function handler( // If there are RR hosts, we need to find a lucky user if ( [...qualifiedRRUsers, ...additionalFallbackRRUsers].length > 0 && - luckyUsers.length !== (Object.keys(nonEmptyHostGroups).length || 1) + luckyUsers.length < (Object.keys(nonEmptyHostGroups).length || 1) ) { throw new Error(ErrorCode.RoundRobinHostsUnavailableForBooking); } diff --git a/packages/features/eventtypes/components/tabs/assignment/EventTeamAssignmentTab.tsx b/packages/features/eventtypes/components/tabs/assignment/EventTeamAssignmentTab.tsx index 70b393c773a3d9..1f06343d668317 100644 --- a/packages/features/eventtypes/components/tabs/assignment/EventTeamAssignmentTab.tsx +++ b/packages/features/eventtypes/components/tabs/assignment/EventTeamAssignmentTab.tsx @@ -27,10 +27,8 @@ import ServerTrans from "@calcom/lib/components/ServerTrans"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { RRTimestampBasis, SchedulingType } from "@calcom/prisma/enums"; import classNames from "@calcom/ui/classNames"; +import { Label, Select, SettingsToggle, NumberInput } from "@calcom/ui/components/form"; import { Button } from "@calcom/ui/components/button"; -import { Label } from "@calcom/ui/components/form"; -import { Select } from "@calcom/ui/components/form"; -import { SettingsToggle } from "@calcom/ui/components/form"; import { Icon } from "@calcom/ui/components/icon"; import { RadioAreaGroup as RadioArea } from "@calcom/ui/components/radio"; import { Tooltip } from "@calcom/ui/components/tooltip"; @@ -103,12 +101,13 @@ const ChildrenEventTypesList = ({ aria-label="assignment-dropdown" data-testid="assignment-dropdown" onChange={(options) => { - onChange && + if (onChange) { onChange( options.map((option) => ({ ...option, })) ); + } }} value={value} options={options.filter((opt) => !value.find((val) => val.owner.id.toString() === opt.value))} @@ -280,7 +279,7 @@ type RoundRobinHostsCustomClassNames = { }; const RoundRobinHosts = ({ - orgId, + orgId: _orgId, teamMembers, value, onChange, @@ -302,7 +301,7 @@ const RoundRobinHosts = ({ }) => { const { t } = useLocale(); - const { setValue, getValues, control, formState } = useFormContext(); + const { setValue, getValues, control } = useFormContext(); const assignRRMembersUsingSegment = getValues("assignRRMembersUsingSegment"); const isRRWeightsEnabled = useWatch({ control, @@ -515,6 +514,31 @@ const RoundRobinHosts = ({ )} /> + + name="maxRoundRobinHosts" + render={({ field: { value, onChange } }) => ( +
+ +

+ {t("max_round_robin_hosts_description")} +

+ ) => { + const val = e.target.value === "" ? null : parseInt(e.target.value, 10); + if (val === null || (!isNaN(val) && val >= 1)) { + onChange(val); + } + }} + /> +
+ )} + /> {!hostGroups.length ? ( @@ -725,7 +749,7 @@ const Hosts = ({ ), MANAGED: <>, }; - return !!schedulingType ? schedulingTypeRender[schedulingType] : <>; + return schedulingType ? schedulingTypeRender[schedulingType] : <>; }} /> ); @@ -891,10 +915,8 @@ export const EventTeamAssignmentTab = ({ hostGroups?.length > 1 ? ( diff --git a/packages/features/eventtypes/lib/defaultEvents.ts b/packages/features/eventtypes/lib/defaultEvents.ts index 658c04e6e4e42f..79e8f087e9b14a 100644 --- a/packages/features/eventtypes/lib/defaultEvents.ts +++ b/packages/features/eventtypes/lib/defaultEvents.ts @@ -126,6 +126,7 @@ const commons = { rrSegmentQueryValue: null, isRRWeightsEnabled: false, rescheduleWithSameRoundRobinHost: false, + maxRoundRobinHosts: null, useEventTypeDestinationCalendarEmail: false, secondaryEmailId: null, secondaryEmail: null, diff --git a/packages/features/eventtypes/lib/types.ts b/packages/features/eventtypes/lib/types.ts index b614d3e7150ecb..535ff9e22305f6 100644 --- a/packages/features/eventtypes/lib/types.ts +++ b/packages/features/eventtypes/lib/types.ts @@ -168,6 +168,7 @@ export type FormValues = { assignRRMembersUsingSegment: boolean; rrSegmentQueryValue: AttributesQueryValue | null; rescheduleWithSameRoundRobinHost: boolean; + maxRoundRobinHosts: number | null; useEventTypeDestinationCalendarEmail: boolean; forwardParamsSuccessRedirect: boolean | null; secondaryEmailId?: number; diff --git a/packages/features/eventtypes/repositories/eventTypeRepository.ts b/packages/features/eventtypes/repositories/eventTypeRepository.ts index 983522cce47d25..7ec94faf8206b4 100644 --- a/packages/features/eventtypes/repositories/eventTypeRepository.ts +++ b/packages/features/eventtypes/repositories/eventTypeRepository.ts @@ -603,6 +603,7 @@ export class EventTypeRepository { rrSegmentQueryValue: true, isRRWeightsEnabled: true, rescheduleWithSameRoundRobinHost: true, + maxRoundRobinHosts: true, successRedirectUrl: true, forwardParamsSuccessRedirect: true, currency: true, @@ -901,6 +902,7 @@ export class EventTypeRepository { rrSegmentQueryValue: true, isRRWeightsEnabled: true, rescheduleWithSameRoundRobinHost: true, + maxRoundRobinHosts: true, successRedirectUrl: true, forwardParamsSuccessRedirect: true, currency: true, @@ -1260,6 +1262,7 @@ export class EventTypeRepository { showOptimizedSlots: true, periodCountCalendarDays: true, rescheduleWithSameRoundRobinHost: true, + maxRoundRobinHosts: true, periodDays: true, metadata: true, assignRRMembersUsingSegment: true, diff --git a/packages/lib/test/builder.ts b/packages/lib/test/builder.ts index 119dff6bdb9549..84c3f025a2d4fa 100644 --- a/packages/lib/test/builder.ts +++ b/packages/lib/test/builder.ts @@ -140,6 +140,7 @@ export const buildEventType = (eventType?: Partial): EventType => { durationLimits: null, assignAllTeamMembers: false, rescheduleWithSameRoundRobinHost: false, + maxRoundRobinHosts: 1, price: 0, currency: "usd", slotInterval: null, diff --git a/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts b/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts index 467e581e0062b4..e4134c4edb964e 100644 --- a/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts +++ b/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts @@ -119,6 +119,7 @@ export const useEventTypeForm = ({ seatsPerTimeSlotEnabled: eventType.seatsPerTimeSlot, autoTranslateDescriptionEnabled: eventType.autoTranslateDescriptionEnabled, rescheduleWithSameRoundRobinHost: eventType.rescheduleWithSameRoundRobinHost, + maxRoundRobinHosts: eventType.maxRoundRobinHosts, assignAllTeamMembers: eventType.assignAllTeamMembers, assignRRMembersUsingSegment: eventType.assignRRMembersUsingSegment, rrSegmentQueryValue: eventType.rrSegmentQueryValue, diff --git a/packages/platform/types/event-types/event-types_2024_06_14/inputs/create-event-type.input.ts b/packages/platform/types/event-types/event-types_2024_06_14/inputs/create-event-type.input.ts index 6c7031052c701f..aea9c004f10819 100644 --- a/packages/platform/types/event-types/event-types_2024_06_14/inputs/create-event-type.input.ts +++ b/packages/platform/types/event-types/event-types_2024_06_14/inputs/create-event-type.input.ts @@ -638,4 +638,13 @@ export class CreateTeamEventTypeInput_2024_06_14 extends BaseCreateEventTypeInpu description: "Rescheduled events will be assigned to the same host as initially scheduled.", }) rescheduleWithSameRoundRobinHost?: boolean; + + @IsInt() + @Min(1) + @IsOptional() + @DocsPropertyOptional({ + description: "Only relevant for round robin event types. Specifies the maximum number of hosts that can be assigned per booking. Defaults to 1.", + example: 1, + }) + maxRoundRobinHosts?: number; } diff --git a/packages/platform/types/event-types/event-types_2024_06_14/inputs/update-event-type.input.ts b/packages/platform/types/event-types/event-types_2024_06_14/inputs/update-event-type.input.ts index 12e67b972ee38c..96f516981b80dd 100644 --- a/packages/platform/types/event-types/event-types_2024_06_14/inputs/update-event-type.input.ts +++ b/packages/platform/types/event-types/event-types_2024_06_14/inputs/update-event-type.input.ts @@ -552,4 +552,13 @@ export class UpdateTeamEventTypeInput_2024_06_14 extends BaseUpdateEventTypeInpu description: "Rescheduled events will be assigned to the same host as initially scheduled.", }) rescheduleWithSameRoundRobinHost?: boolean; + + @IsInt() + @Min(1) + @IsOptional() + @DocsPropertyOptional({ + description: "Only relevant for round robin event types. Specifies the maximum number of hosts that can be assigned per booking. Defaults to 1.", + example: 1, + }) + maxRoundRobinHosts?: number; } diff --git a/packages/platform/types/event-types/event-types_2024_06_14/outputs/event-type.output.ts b/packages/platform/types/event-types/event-types_2024_06_14/outputs/event-type.output.ts index 240a60f97a57d9..25546f482ba089 100644 --- a/packages/platform/types/event-types/event-types_2024_06_14/outputs/event-type.output.ts +++ b/packages/platform/types/event-types/event-types_2024_06_14/outputs/event-type.output.ts @@ -550,4 +550,13 @@ export class TeamEventTypeOutput_2024_06_14 extends BaseEventTypeOutput_2024_06_ description: "Rescheduled events will be assigned to the same host as initially scheduled.", }) rescheduleWithSameRoundRobinHost?: boolean; + + @IsInt() + @Min(1) + @IsOptional() + @ApiPropertyOptional({ + description: "Only relevant for round robin event types. Specifies the maximum number of hosts that can be assigned per booking. Defaults to 1.", + example: 1, + }) + maxRoundRobinHosts?: number; } diff --git a/packages/prisma/migrations/20251208104340_add_max_round_robin_hosts_optional/migration.sql b/packages/prisma/migrations/20251208104340_add_max_round_robin_hosts_optional/migration.sql new file mode 100644 index 00000000000000..15867d36eaecac --- /dev/null +++ b/packages/prisma/migrations/20251208104340_add_max_round_robin_hosts_optional/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "public"."EventType" ADD COLUMN "maxRoundRobinHosts" INTEGER; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 12aa23f02169ac..0020143403a7d9 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -246,6 +246,8 @@ model EventType { eventTypeColor Json? rescheduleWithSameRoundRobinHost Boolean @default(false) + maxRoundRobinHosts Int? + secondaryEmailId Int? secondaryEmail SecondaryEmail? @relation(fields: [secondaryEmailId], references: [id], onDelete: Cascade) From e895d3a8e058205a21d6067aa92c14dba496130a Mon Sep 17 00:00:00 2001 From: Pedro Castro Date: Mon, 8 Dec 2025 18:12:08 -0300 Subject: [PATCH 2/4] test: add unit tests for maxRoundRobinHosts feature - Add 4 unit tests covering multi-host assignment scenarios - Update event-types-hooks.mdx with maxRoundRobinHosts documentation - Improve API field description in DTOs for better OpenAPI docs --- docs/api-reference/v2/openapi.json | 6 +- docs/platform/event-types-hooks.mdx | 3 +- .../test/team-bookings/round-robin.test.ts | 149 ++++++++++++++++++ .../inputs/create-event-type.input.ts | 2 +- .../inputs/update-event-type.input.ts | 2 +- .../outputs/event-type.output.ts | 2 +- 6 files changed, 157 insertions(+), 7 deletions(-) diff --git a/docs/api-reference/v2/openapi.json b/docs/api-reference/v2/openapi.json index 1d2fb2088fd6a5..5a9fd10263dfd4 100644 --- a/docs/api-reference/v2/openapi.json +++ b/docs/api-reference/v2/openapi.json @@ -19666,7 +19666,7 @@ }, "maxRoundRobinHosts": { "type": "number", - "description": "Only relevant for round robin event types. Specifies the maximum number of hosts that can be assigned per booking. Defaults to 1.", + "description": "Only relevant for round robin event types. Specifies the maximum number of hosts to automatically assign per booking. When a booking is created, the system assigns up to this number of available hosts. If fewer hosts are available than the configured maximum, all available hosts are assigned. Minimum value is 1, defaults to 1.", "example": 1 } }, @@ -21684,7 +21684,7 @@ }, "maxRoundRobinHosts": { "type": "number", - "description": "Only relevant for round robin event types. Specifies the maximum number of hosts that can be assigned per booking. Defaults to 1.", + "description": "Only relevant for round robin event types. Specifies the maximum number of hosts to automatically assign per booking. When a booking is created, the system assigns up to this number of available hosts. If fewer hosts are available than the configured maximum, all available hosts are assigned. Minimum value is 1, defaults to 1.", "example": 1 } }, @@ -22152,7 +22152,7 @@ }, "maxRoundRobinHosts": { "type": "number", - "description": "Only relevant for round robin event types. Specifies the maximum number of hosts that can be assigned per booking. Defaults to 1.", + "description": "Only relevant for round robin event types. Specifies the maximum number of hosts to automatically assign per booking. When a booking is created, the system assigns up to this number of available hosts. If fewer hosts are available than the configured maximum, all available hosts are assigned. Minimum value is 1, defaults to 1.", "example": 1 } } diff --git a/docs/platform/event-types-hooks.mdx b/docs/platform/event-types-hooks.mdx index af8d6bf213c5e4..45a2bd8e8a6afd 100644 --- a/docs/platform/event-types-hooks.mdx +++ b/docs/platform/event-types-hooks.mdx @@ -130,7 +130,7 @@ export default function CreateEventType() { ### 5. `useCreateTeamEventType` -The useCreateTeamEventType hook allows you to create a new team event type. This hook returns a mutation function that handles the event type creation process. The mutation function accepts an object with the following properties: ***lengthInMinutes*** which is the length of the event in minutes, ***title*** which is the title of the event, ***slug*** which is the slug of the event, ***description*** which is the description of the event, schedulingType which can be either ***COLLECTIVE***, ***ROUND_ROBIN*** or ***MANAGED***, ***hosts*** which is an array of hosts for the event and the ***teamId*** which is the id of the team. +The useCreateTeamEventType hook allows you to create a new team event type. This hook returns a mutation function that handles the event type creation process. The mutation function accepts an object with the following properties: ***lengthInMinutes*** which is the length of the event in minutes, ***title*** which is the title of the event, ***slug*** which is the slug of the event, ***description*** which is the description of the event, schedulingType which can be either ***COLLECTIVE***, ***ROUND_ROBIN*** or ***MANAGED***, ***hosts*** which is an array of hosts for the event and the ***teamId*** which is the id of the team, and optionally ***maxRoundRobinHosts*** which specifies the maximum number of hosts to assign per booking for Round Robin events (defaults to 1). Below code snippet shows how to use the useCreateTeamEventType hook to set up a team event type. @@ -160,6 +160,7 @@ export default function CreateTeamEventType() { schedulingType: "COLLECTIVE", hosts: [{"userId": 1456}, {"userId": 2357}], teamId: 1234, + maxRoundRobinHosts: 2, }) }}> Create team event type diff --git a/packages/features/bookings/lib/handleNewBooking/test/team-bookings/round-robin.test.ts b/packages/features/bookings/lib/handleNewBooking/test/team-bookings/round-robin.test.ts index 0c9de8d828004c..738be8c51fc2ed 100644 --- a/packages/features/bookings/lib/handleNewBooking/test/team-bookings/round-robin.test.ts +++ b/packages/features/bookings/lib/handleNewBooking/test/team-bookings/round-robin.test.ts @@ -1086,4 +1086,153 @@ describe("Round Robin handleNewBooking", () => { }); }); }); + + describe("Round Robin with maxRoundRobinHosts", () => { + const createBooker = () => + getBooker({ + email: "booker@example.com", + name: "Booker", + }); + + const createOrganizer = () => + getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + defaultScheduleId: null, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "organizer@google-calendar.com", + }, + }); + + const createTeamMember = (id: number, index: number) => ({ + name: `Team Member ${index}`, + username: `team-member-${index}`, + timeZone: Timezones["+5:30"], + defaultScheduleId: null, + email: `team-member-${index}@example.com`, + id, + schedules: [TestData.schedules.IstWorkHours], + }); + + const createTeamMembers = (count: number, startId = 102) => + Array.from({ length: count }, (_, i) => createTeamMember(startId + i, i + 1)); + + const setupScenario = async ( + teamMembers: ReturnType[], + maxRoundRobinHosts?: number + ) => { + const organizer = createOrganizer(); + + await createBookingScenario( + getScenarioData({ + eventTypes: [ + { + id: 1, + slotInterval: 30, + schedulingType: SchedulingType.ROUND_ROBIN, + length: 30, + ...(maxRoundRobinHosts !== undefined && { maxRoundRobinHosts }), + users: teamMembers.map((m) => ({ id: m.id })), + hosts: teamMembers.map((m) => ({ userId: m.id, isFixed: false })), + schedule: TestData.schedules.IstWorkHours, + 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: "dailyvideo", + videoMeetingData: { + id: "MOCK_ID", + password: "MOCK_PASS", + url: "http://mock-dailyvideo.example.com/meeting-1", + }, + }); + + mockCalendarToHaveNoBusySlots("googlecalendar", { + create: { + id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", + iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", + }, + }); + }; + + const createBooking = async (booker: ReturnType) => { + const handleNewBooking = getNewBookingHandler(); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + start: `${getDate({ dateIncrement: 1 }).dateString}T10:00:00.000Z`, + end: `${getDate({ dateIncrement: 1 }).dateString}T10:30:00.000Z`, + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: BookingLocations.CalVideo }, + }, + }, + }); + + return handleNewBooking({ bookingData: mockBookingData }); + }; + + test("assigns multiple hosts when maxRoundRobinHosts > 1", async () => { + const booker = createBooker(); + const teamMembers = createTeamMembers(3); + + await setupScenario(teamMembers, 3); + const createdBooking = await createBooking(booker); + + expect(createdBooking).toBeDefined(); + expect(createdBooking.luckyUsers).toHaveLength(3); + createdBooking.luckyUsers.forEach((userId) => { + expect(teamMembers.map((m) => m.id)).toContain(userId); + }); + }); + + test("assigns all available hosts when fewer than maxRoundRobinHosts", async () => { + const booker = createBooker(); + const teamMembers = createTeamMembers(2); + + await setupScenario(teamMembers, 5); + const createdBooking = await createBooking(booker); + + expect(createdBooking).toBeDefined(); + expect(createdBooking.luckyUsers).toHaveLength(2); + }); + + test("assigns single host when maxRoundRobinHosts is undefined (default)", async () => { + const booker = createBooker(); + const teamMembers = createTeamMembers(3); + + await setupScenario(teamMembers); + const createdBooking = await createBooking(booker); + + expect(createdBooking).toBeDefined(); + expect(createdBooking.luckyUsers).toHaveLength(1); + }); + + test("assigns single host when maxRoundRobinHosts is 1", async () => { + const booker = createBooker(); + const teamMembers = createTeamMembers(3); + + await setupScenario(teamMembers, 1); + const createdBooking = await createBooking(booker); + + expect(createdBooking).toBeDefined(); + expect(createdBooking.luckyUsers).toHaveLength(1); + }); + }); }); diff --git a/packages/platform/types/event-types/event-types_2024_06_14/inputs/create-event-type.input.ts b/packages/platform/types/event-types/event-types_2024_06_14/inputs/create-event-type.input.ts index aea9c004f10819..5530fb7c9130de 100644 --- a/packages/platform/types/event-types/event-types_2024_06_14/inputs/create-event-type.input.ts +++ b/packages/platform/types/event-types/event-types_2024_06_14/inputs/create-event-type.input.ts @@ -643,7 +643,7 @@ export class CreateTeamEventTypeInput_2024_06_14 extends BaseCreateEventTypeInpu @Min(1) @IsOptional() @DocsPropertyOptional({ - description: "Only relevant for round robin event types. Specifies the maximum number of hosts that can be assigned per booking. Defaults to 1.", + description: "Only relevant for round robin event types. Specifies the maximum number of hosts to automatically assign per booking. When a booking is created, the system assigns up to this number of available hosts. If fewer hosts are available than the configured maximum, all available hosts are assigned. Minimum value is 1, defaults to 1.", example: 1, }) maxRoundRobinHosts?: number; diff --git a/packages/platform/types/event-types/event-types_2024_06_14/inputs/update-event-type.input.ts b/packages/platform/types/event-types/event-types_2024_06_14/inputs/update-event-type.input.ts index 96f516981b80dd..2bb519c9b5e903 100644 --- a/packages/platform/types/event-types/event-types_2024_06_14/inputs/update-event-type.input.ts +++ b/packages/platform/types/event-types/event-types_2024_06_14/inputs/update-event-type.input.ts @@ -557,7 +557,7 @@ export class UpdateTeamEventTypeInput_2024_06_14 extends BaseUpdateEventTypeInpu @Min(1) @IsOptional() @DocsPropertyOptional({ - description: "Only relevant for round robin event types. Specifies the maximum number of hosts that can be assigned per booking. Defaults to 1.", + description: "Only relevant for round robin event types. Specifies the maximum number of hosts to automatically assign per booking. When a booking is created, the system assigns up to this number of available hosts. If fewer hosts are available than the configured maximum, all available hosts are assigned. Minimum value is 1, defaults to 1.", example: 1, }) maxRoundRobinHosts?: number; diff --git a/packages/platform/types/event-types/event-types_2024_06_14/outputs/event-type.output.ts b/packages/platform/types/event-types/event-types_2024_06_14/outputs/event-type.output.ts index 25546f482ba089..2187fb3586584a 100644 --- a/packages/platform/types/event-types/event-types_2024_06_14/outputs/event-type.output.ts +++ b/packages/platform/types/event-types/event-types_2024_06_14/outputs/event-type.output.ts @@ -555,7 +555,7 @@ export class TeamEventTypeOutput_2024_06_14 extends BaseEventTypeOutput_2024_06_ @Min(1) @IsOptional() @ApiPropertyOptional({ - description: "Only relevant for round robin event types. Specifies the maximum number of hosts that can be assigned per booking. Defaults to 1.", + description: "Only relevant for round robin event types. Specifies the maximum number of hosts to automatically assign per booking. When a booking is created, the system assigns up to this number of available hosts. If fewer hosts are available than the configured maximum, all available hosts are assigned. Minimum value is 1, defaults to 1.", example: 1, }) maxRoundRobinHosts?: number; From 4e6b9a8226ce4642dd81f09a17a026a7e4ffae31 Mon Sep 17 00:00:00 2001 From: Pedro Castro Date: Mon, 8 Dec 2025 19:26:29 -0300 Subject: [PATCH 3/4] test(e2e): add maxRoundRobinHosts API tests - Test create round robin event type with maxRoundRobinHosts - Test default value (null) when not provided - Test update maxRoundRobinHosts value --- .../max-round-robin-hosts.e2e-spec.ts | 231 ++++++++++++++++++ 1 file changed, 231 insertions(+) create mode 100644 apps/api/v2/src/modules/organizations/event-types/max-round-robin-hosts.e2e-spec.ts diff --git a/apps/api/v2/src/modules/organizations/event-types/max-round-robin-hosts.e2e-spec.ts b/apps/api/v2/src/modules/organizations/event-types/max-round-robin-hosts.e2e-spec.ts new file mode 100644 index 00000000000000..5a2d1e519d30c2 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/event-types/max-round-robin-hosts.e2e-spec.ts @@ -0,0 +1,231 @@ +import { bootstrap } from "@/app"; +import { AppModule } from "@/app.module"; +import { HttpExceptionFilter } from "@/filters/http-exception.filter"; +import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter"; +import { Locales } from "@/lib/enums/locales"; +import type { CreateManagedUserData } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/create-managed-user.output"; +import { CreateOrgTeamDto } from "@/modules/organizations/teams/index/inputs/create-organization-team.input"; +import { CreateOrgTeamMembershipDto } from "@/modules/organizations/teams/memberships/inputs/create-organization-team-membership.input"; +import { CreateManagedUserInput } from "@/modules/users/inputs/create-managed-user.input"; +import { UsersModule } from "@/modules/users/users.module"; +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +import * as request from "supertest"; +import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture"; +import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture"; +import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture"; +import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { randomString } from "test/utils/randomString"; + +import { SUCCESS_STATUS, X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom/platform-constants"; +import type { + ApiSuccessResponse, + CreateTeamEventTypeInput_2024_06_14, + OrgTeamOutputDto, + TeamEventTypeOutput_2024_06_14, + UpdateTeamEventTypeInput_2024_06_14, +} from "@calcom/platform-types"; +import type { PlatformOAuthClient, Team, User } from "@calcom/prisma/client"; + +describe("maxRoundRobinHosts for Round Robin event types", () => { + let app: INestApplication; + let oAuthClient: PlatformOAuthClient; + let organization: Team; + let managedTeam: OrgTeamOutputDto; + let platformAdmin: User; + let managedUsers: CreateManagedUserData[] = []; + + // Fixtures + let userRepositoryFixture: UserRepositoryFixture; + let oauthClientRepositoryFixture: OAuthClientRepositoryFixture; + let teamRepositoryFixture: TeamRepositoryFixture; + let profilesRepositoryFixture: ProfileRepositoryFixture; + let membershipsRepositoryFixture: MembershipRepositoryFixture; + + // Helpers + const createManagedUser = async (name: string): Promise => { + const body: CreateManagedUserInput = { + email: `max-rr-${name.toLowerCase()}-${randomString()}@api.com`, + timeZone: "Europe/Rome", + weekStart: "Monday", + timeFormat: 24, + locale: Locales.FR, + name, + }; + + const response = await request(app.getHttpServer()) + .post(`/api/v2/oauth-clients/${oAuthClient.id}/users`) + .set(X_CAL_SECRET_KEY, oAuthClient.secret) + .send(body) + .expect(201); + + return response.body.data; + }; + + const addUserToTeam = async (userId: number) => { + const body: CreateOrgTeamMembershipDto = { userId, accepted: true, role: "MEMBER" }; + await request(app.getHttpServer()) + .post(`/v2/organizations/${organization.id}/teams/${managedTeam.id}/memberships`) + .send(body) + .set(X_CAL_SECRET_KEY, oAuthClient.secret) + .set(X_CAL_CLIENT_ID, oAuthClient.id) + .expect(201); + }; + + const createRoundRobinEventType = async ( + overrides: Partial = {} + ): Promise => { + const body: CreateTeamEventTypeInput_2024_06_14 = { + title: "Round Robin Event", + slug: `max-rr-hosts-${randomString()}`, + lengthInMinutes: 30, + // @ts-ignore - schedulingType accepts string + schedulingType: "roundRobin", + assignAllTeamMembers: true, + ...overrides, + }; + + const response = await request(app.getHttpServer()) + .post(`/v2/organizations/${organization.id}/teams/${managedTeam.id}/event-types`) + .send(body) + .set(X_CAL_SECRET_KEY, oAuthClient.secret) + .set(X_CAL_CLIENT_ID, oAuthClient.id) + .expect(201); + + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + return responseBody.data; + }; + + const updateEventType = async ( + eventTypeId: number, + body: UpdateTeamEventTypeInput_2024_06_14 + ): Promise => { + const response = await request(app.getHttpServer()) + .patch(`/v2/organizations/${organization.id}/teams/${managedTeam.id}/event-types/${eventTypeId}`) + .send(body) + .set(X_CAL_SECRET_KEY, oAuthClient.secret) + .set(X_CAL_CLIENT_ID, oAuthClient.id) + .expect(200); + + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + return responseBody.data; + }; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + providers: [PrismaExceptionFilter, HttpExceptionFilter], + imports: [AppModule, UsersModule], + }).compile(); + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + // Initialize fixtures + oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef); + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + teamRepositoryFixture = new TeamRepositoryFixture(moduleRef); + profilesRepositoryFixture = new ProfileRepositoryFixture(moduleRef); + membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef); + + // Create organization with platform billing + organization = await teamRepositoryFixture.create({ + name: `max-rr-hosts-org-${randomString()}`, + isPlatform: true, + isOrganization: true, + platformBilling: { + create: { + customerId: `cus_${randomString()}`, + plan: "ESSENTIALS", + subscriptionId: `sub_${randomString()}`, + }, + }, + }); + + // Create OAuth client + oAuthClient = await oauthClientRepositoryFixture.create( + organization.id, + { + logo: "logo-url", + name: "test-client", + redirectUris: ["http://localhost:4321"], + permissions: 1023, + areDefaultEventTypesEnabled: false, + }, + "secret" + ); + + // Create platform admin + const adminEmail = `max-rr-hosts-admin-${randomString()}@api.com`; + platformAdmin = await userRepositoryFixture.create({ email: adminEmail }); + + await profilesRepositoryFixture.create({ + uid: randomString(), + username: adminEmail, + organization: { connect: { id: organization.id } }, + user: { connect: { id: platformAdmin.id } }, + }); + + await membershipsRepositoryFixture.create({ + role: "OWNER", + user: { connect: { id: platformAdmin.id } }, + team: { connect: { id: organization.id } }, + accepted: true, + }); + + await app.init(); + + // Create team + const teamBody: CreateOrgTeamDto = { name: `team-${randomString()}` }; + const teamResponse = await request(app.getHttpServer()) + .post(`/v2/organizations/${organization.id}/teams`) + .send(teamBody) + .set(X_CAL_SECRET_KEY, oAuthClient.secret) + .set(X_CAL_CLIENT_ID, oAuthClient.id) + .expect(201); + managedTeam = teamResponse.body.data; + + // Create and add 3 users to team + for (const name of ["Alice", "Bob", "Charlie"]) { + const user = await createManagedUser(name); + await addUserToTeam(user.user.id); + managedUsers.push(user); + } + }); + + afterAll(async () => { + await Promise.all(managedUsers.map((u) => userRepositoryFixture.delete(u.user.id))); + await userRepositoryFixture.delete(platformAdmin.id); + await oauthClientRepositoryFixture.delete(oAuthClient.id); + await teamRepositoryFixture.delete(organization.id); + await app.close(); + }); + + describe("when creating round robin event type", () => { + it("sets maxRoundRobinHosts when provided", async () => { + const eventType = await createRoundRobinEventType({ maxRoundRobinHosts: 2 }); + + expect(eventType.schedulingType).toEqual("roundRobin"); + expect(eventType.hosts.length).toEqual(3); + expect(eventType.maxRoundRobinHosts).toEqual(2); + }); + + it("returns null for maxRoundRobinHosts when not provided", async () => { + const eventType = await createRoundRobinEventType(); + + expect(eventType.maxRoundRobinHosts).toBeNull(); + }); + }); + + describe("when updating round robin event type", () => { + it("updates maxRoundRobinHosts value", async () => { + const eventType = await createRoundRobinEventType({ maxRoundRobinHosts: 1 }); + const updated = await updateEventType(eventType.id, { maxRoundRobinHosts: 3 }); + + expect(updated.maxRoundRobinHosts).toEqual(3); + }); + }); +}); \ No newline at end of file From 11315b87f966882f83351e8f24fed0956996781a Mon Sep 17 00:00:00 2001 From: Pedro Castro Date: Mon, 8 Dec 2025 19:31:29 -0300 Subject: [PATCH 4/4] perf: optimize round-robin host selection loop - Use Set for faster user lookups - Move userIdsSet outside the loop - Move async calls (firstUserOrgId, enrichedHosts) outside loops - Filter group hosts once per group instead of every iteration --- .../lib/service/RegularBookingService.ts | 109 +++++++++--------- 1 file changed, 57 insertions(+), 52 deletions(-) diff --git a/packages/features/bookings/lib/service/RegularBookingService.ts b/packages/features/bookings/lib/service/RegularBookingService.ts index 44d2926190a808..1ca354a2721eac 100644 --- a/packages/features/bookings/lib/service/RegularBookingService.ts +++ b/packages/features/bookings/lib/service/RegularBookingService.ts @@ -35,8 +35,8 @@ import { handlePayment } from "@calcom/features/bookings/lib/handlePayment"; import { handleWebhookTrigger } from "@calcom/features/bookings/lib/handleWebhookTrigger"; import { isEventTypeLoggingEnabled } from "@calcom/features/bookings/lib/isEventTypeLoggingEnabled"; import { BookingEventHandlerService } from "@calcom/features/bookings/lib/onBookingEvents/BookingEventHandlerService"; -import { BookingEmailAndSmsTasker } from "@calcom/features/bookings/lib/tasker/BookingEmailAndSmsTasker"; import type { BookingRescheduledPayload } from "@calcom/features/bookings/lib/onBookingEvents/types.d"; +import { BookingEmailAndSmsTasker } from "@calcom/features/bookings/lib/tasker/BookingEmailAndSmsTasker"; import { getSpamCheckService } from "@calcom/features/di/watchlist/containers/SpamCheckService.container"; import { CreditService } from "@calcom/features/ee/billing/credit-service"; import { getBookerBaseUrl } from "@calcom/features/ee/organizations/lib/getBookerUrlServer"; @@ -456,7 +456,6 @@ function buildBookingCreatedPayload({ }; } - export interface IBookingServiceDependencies { checkBookingAndDurationLimitsService: CheckBookingAndDurationLimitsService; prismaClient: PrismaClient; @@ -1045,54 +1044,56 @@ async function handler( ); const luckyUsers: typeof users = []; + const maxHostsPerGroup = eventType.maxRoundRobinHosts ?? 1; + const excludedUserIds = new Set(); + const userIdsSet = new Set(users.map((user) => user.id)); + + const firstUserOrgId = await getOrgIdFromMemberOrTeamId({ + memberId: eventTypeWithUsers.users[0]?.id ?? null, + teamId: eventType.teamId, + }); + + const enrichedHosts = await enrichHostsWithDelegationCredentials({ + orgId: firstUserOrgId ?? null, + hosts: eventTypeWithUsers.hosts, + }); // 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 for (const [groupId, luckyUserPool] of Object.entries(luckyUserPools)) { - const maxHostsPerGroup = eventType.maxRoundRobinHosts ?? 1; let hostsFoundInGroup = 0; + + const groupRRHosts = enrichedHosts.filter( + (host) => + !host.isFixed && + userIdsSet.has(host.user.id) && + (host.groupId === groupId || (!host.groupId && groupId === DEFAULT_GROUP_ID)) + ); + while (luckyUserPool.length > 0 && hostsFoundInGroup < maxHostsPerGroup) { - const freeUsers = luckyUserPool.filter( - (user) => !luckyUsers.concat(notAvailableLuckyUsers).find((existing) => existing.id === user.id) - ); + const freeUsers = luckyUserPool.filter((user) => !excludedUserIds.has(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 deps.luckyUserService.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 || (!host.groupId && groupId === DEFAULT_GROUP_ID)) - ), + allRRHosts: groupRRHosts, eventType, routingFormResponse, meetingStartTime: new Date(reqBody.start), }); + if (!newLuckyUser) { - break; // prevent infinite loop + break; } + if ( input.bookingData.isFirstRecurringSlot && eventType.schedulingType === SchedulingType.ROUND_ROBIN && input.bookingData.numSlotsToCheckForAvailability && input.bookingData.allRecurringDates ) { - // for recurring round robin events check if lucky user is available for next slots try { for ( let i = 0; @@ -1117,17 +1118,19 @@ async function handler( ); } } - // if no error, then lucky user is available for the next slots luckyUsers.push(newLuckyUser); + excludedUserIds.add(newLuckyUser.id); hostsFoundInGroup++; } catch { notAvailableLuckyUsers.push(newLuckyUser); + excludedUserIds.add(newLuckyUser.id); tracingLogger.info( - `Round robin host ${newLuckyUser.name} not available for first two slots. Trying to find another host.` + `Round robin host ${newLuckyUser.name} not available for first two slots. Trying another host.` ); } } else { luckyUsers.push(newLuckyUser); + excludedUserIds.add(newLuckyUser.id); hostsFoundInGroup++; } } @@ -1311,9 +1314,9 @@ async function handler( // This ensures that createMeeting isn't called for static video apps as bookingLocation becomes just a regular value for them. const { bookingLocation, conferenceCredentialId } = organizerOrFirstDynamicGroupMemberDefaultLocationUrl ? { - bookingLocation: organizerOrFirstDynamicGroupMemberDefaultLocationUrl, - conferenceCredentialId: undefined, - } + bookingLocation: organizerOrFirstDynamicGroupMemberDefaultLocationUrl, + conferenceCredentialId: undefined, + } : getLocationValueForDB(locationBodyString, eventType.locations); tracingLogger.info("locationBodyString", locationBodyString); @@ -1359,8 +1362,8 @@ async function handler( const destinationCalendar = eventType.destinationCalendar ? [eventType.destinationCalendar] : organizerUser.destinationCalendar - ? [organizerUser.destinationCalendar] - : null; + ? [organizerUser.destinationCalendar] + : null; let organizerEmail = organizerUser.email || "Email-less"; if (eventType.useEventTypeDestinationCalendarEmail && destinationCalendar?.[0]?.primaryEmail) { @@ -1981,14 +1984,14 @@ async function handler( } const updateManager = !skipCalendarSyncTaskCreation ? await eventManager.reschedule( - evt, - originalRescheduledBooking.uid, - undefined, - changedOrganizer, - previousHostDestinationCalendar, - isBookingRequestedReschedule, - skipDeleteEventsAndMeetings - ) + evt, + originalRescheduledBooking.uid, + undefined, + changedOrganizer, + previousHostDestinationCalendar, + isBookingRequestedReschedule, + skipDeleteEventsAndMeetings + ) : placeholderCreatedEvent; // This gets overridden when updating the event - to check if notes have been hidden or not. We just reset this back // to the default description when we are sending the emails. @@ -2289,8 +2292,8 @@ async function handler( const metadata = videoCallUrl ? { - videoCallUrl: getVideoCallUrlFromCalEvent(evt) || videoCallUrl, - } + videoCallUrl: getVideoCallUrlFromCalEvent(evt) || videoCallUrl, + } : undefined; const bookingCreatedPayload = buildBookingCreatedPayload({ @@ -2304,10 +2307,12 @@ async function handler( const bookingRescheduledPayload: BookingRescheduledPayload = { ...bookingCreatedPayload, - oldBooking: originalRescheduledBooking ? { - startTime: originalRescheduledBooking.startTime, - endTime: originalRescheduledBooking.endTime, - } : undefined, + oldBooking: originalRescheduledBooking + ? { + startTime: originalRescheduledBooking.startTime, + endTime: originalRescheduledBooking.endTime, + } + : undefined, }; const bookingEventHandler = deps.bookingEventHandler; @@ -2383,9 +2388,9 @@ async function handler( ...eventType, metadata: eventType.metadata ? { - ...eventType.metadata, - apps: eventType.metadata?.apps as Prisma.JsonValue, - } + ...eventType.metadata, + apps: eventType.metadata?.apps as Prisma.JsonValue, + } : {}, }, paymentAppCredentials: eventTypePaymentAppCredential as IEventTypePaymentCredentialType, @@ -2719,7 +2724,7 @@ async function handler( * We are open to renaming it to something more descriptive. */ export class RegularBookingService implements IBookingService { - constructor(private readonly deps: IBookingServiceDependencies) { } + constructor(private readonly deps: IBookingServiceDependencies) {} async createBooking(input: { bookingData: CreateRegularBookingData; bookingMeta?: CreateBookingMeta }) { return handler({ bookingData: input.bookingData, ...input.bookingMeta }, this.deps);