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/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 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..5a9fd10263dfd4 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 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 } }, "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 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 } }, "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 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 } } }, @@ -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/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/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/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/features/bookings/lib/service/RegularBookingService.ts b/packages/features/bookings/lib/service/RegularBookingService.ts index ea20654d5eef85..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,53 +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)) { - let luckUserFound = false; - while (luckyUserPool.length > 0 && !luckUserFound) { - const freeUsers = luckyUserPool.filter( - (user) => !luckyUsers.concat(notAvailableLuckyUsers).find((existing) => existing.id === user.id) - ); + 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) => !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; @@ -1116,18 +1118,20 @@ async function handler( ); } } - // if no error, then lucky user is available for the next slots luckyUsers.push(newLuckyUser); - luckUserFound = true; + 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); - luckUserFound = true; + excludedUserIds.add(newLuckyUser.id); + hostsFoundInGroup++; } } } @@ -1151,7 +1155,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); } @@ -1310,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); @@ -1358,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) { @@ -1980,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. @@ -2288,8 +2292,8 @@ async function handler( const metadata = videoCallUrl ? { - videoCallUrl: getVideoCallUrlFromCalEvent(evt) || videoCallUrl, - } + videoCallUrl: getVideoCallUrlFromCalEvent(evt) || videoCallUrl, + } : undefined; const bookingCreatedPayload = buildBookingCreatedPayload({ @@ -2303,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; @@ -2382,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, @@ -2718,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); 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..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 @@ -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 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 12e67b972ee38c..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 @@ -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 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 240a60f97a57d9..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 @@ -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 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/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)