From 6b62557a92ecc025bdca5458ba9a0571bd65a90d Mon Sep 17 00:00:00 2001 From: Morgan <33722304+ThyMinimalDev@users.noreply.github.com> Date: Wed, 10 Dec 2025 12:37:39 +0200 Subject: [PATCH] feat: add hostSubsetIds parameter for round robin host filtering (#25627) * feat: add hostSubsetIds parameter for round robin host filtering Add support for filtering round robin event type hosts via API v2. When hostSubsetIds is provided, only the specified hosts are considered for availability calculation and booking assignment. Changes: - Add hostSubsetIds to slots API input (GET /slots/available) - Add hostSubsetIds to booking API input (POST /bookings) - Update _findQualifiedHostsWithDelegationCredentials to filter by hostSubsetIds - Pass hostSubsetIds through all layers: API -> tRPC -> slots/booking services This allows API consumers to request availability and create bookings for a subset of hosts within a round robin event type. Co-Authored-By: morgan@cal.com * chore: add e2e tests * chore: add enableHostSubset team event-type setting * fixup! chore: add enableHostSubset team event-type setting * fix tests * fix tests * improve isWithinRRHostSubset * rename to rrHost subset * fix ai review * fix: add booker platform wrapper rrHostSubsetIds prop --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../2024-04-15/inputs/create-booking.input.ts | 15 +- .../controllers/e2e/team-bookings.e2e-spec.ts | 208 +++++++++++++++++- .../2024-08-13/services/input.service.ts | 2 + .../event-types.controller.e2e-spec.ts | 2 + ...-member-team-admin-event-types.e2e-spec.ts | 4 + .../event-types/services/output.service.ts | 12 +- ...am-event-type-slots.controller.e2e-spec.ts | 114 +++++++++- .../services/slots-input.service.ts | 2 + .../test/lib/handleChildrenEventTypes.test.ts | 1 + docs/api-reference/v2/openapi.json | 87 +++++++- .../bookings/lib/bookingCreateBodySchema.ts | 1 + .../handleNewBooking/getEventTypesFromDB.ts | 1 + .../handleNewBooking/loadAndValidateUsers.ts | 4 + ...QualifiedHostsWithDelegationCredentials.ts | 36 ++- .../lib/service/RegularBookingService.ts | 2 + .../lib/handleChildrenEventTypes.ts | 2 + .../features/eventtypes/lib/defaultEvents.ts | 1 + .../repositories/eventTypeRepository.ts | 61 ++--- packages/lib/test/builder.ts | 1 + .../atoms/booker/BookerPlatformWrapper.tsx | 7 + packages/platform/atoms/booker/types.ts | 13 +- .../hooks/bookings/useHandleBookEvent.ts | 3 + .../platform/atoms/hooks/useAvailableSlots.ts | 1 + .../2024-08-13/inputs/create-booking.input.ts | 20 +- .../inputs/create-event-type.input.ts | 10 + .../inputs/update-event-type.input.ts | 16 +- .../outputs/event-type.output.ts | 10 + .../slots/slots-2024-04-15/inputs/index.ts | 21 ++ .../inputs/get-slots.input.ts | 23 +- .../migration.sql | 2 + packages/prisma/schema.prisma | 1 + .../trpc/server/routers/viewer/slots/types.ts | 1 + .../trpc/server/routers/viewer/slots/util.ts | 73 +++--- 33 files changed, 673 insertions(+), 84 deletions(-) create mode 100644 packages/prisma/migrations/20251205150624_enable_host_subset/migration.sql diff --git a/apps/api/v2/src/ee/bookings/2024-04-15/inputs/create-booking.input.ts b/apps/api/v2/src/ee/bookings/2024-04-15/inputs/create-booking.input.ts index 7fdc39cbb7..e6ee8d02b3 100644 --- a/apps/api/v2/src/ee/bookings/2024-04-15/inputs/create-booking.input.ts +++ b/apps/api/v2/src/ee/bookings/2024-04-15/inputs/create-booking.input.ts @@ -11,6 +11,7 @@ import { ValidateNested, isEmail, Validate, + IsInt, } from "class-validator"; import { ValidationOptions, registerDecorator } from "class-validator"; @@ -26,7 +27,7 @@ function ValidateBookingName(validationOptions?: ValidationOptions) { propertyName: propertyName, options: validationOptions, validator: { - validate(value: any) { + validate(value: string | Record): boolean { if (typeof value === "string") { return value.trim().length > 0; } @@ -226,4 +227,16 @@ export class CreateBookingInput_2024_04_15 { @IsOptional() @ApiPropertyOptional() crmOwnerRecordType?: string; + + @ApiPropertyOptional({ + type: [Number], + description: + "For round robin event types, filter available hosts to only consider the specified subset of host user IDs. This allows you to book with specific hosts within a round robin event type.", + example: [1, 2, 3], + }) + @ApiHideProperty() + @IsOptional() + @IsArray() + @IsInt({ each: true }) + rrHostSubsetIds?: number[]; } diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/team-bookings.e2e-spec.ts b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/team-bookings.e2e-spec.ts index 1be96e84e8..db99061edf 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/team-bookings.e2e-spec.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/team-bookings.e2e-spec.ts @@ -64,12 +64,14 @@ describe("Bookings Endpoints 2024-08-13", () => { let team1EventTypeId: number; let team2EventTypeId: number; + let team2RREventTypeId: number; let phoneOnlyEventTypeId: number; let collectiveEventWithoutHostsId: number; let roundRobinEventWithoutHostsId: number; const team1EventTypeSlug = `team-bookings-event-type-${randomString()}`; const team2EventTypeSlug = `team-bookings-event-type-${randomString()}`; + const team2RREventTypeSlug = `team-bookings-rr-event-type-${randomString()}`; const phoneOnlyEventTypeSlug = `team-bookings-event-type-${randomString()}`; let phoneBasedBooking: BookingOutput_2024_08_13; @@ -339,6 +341,50 @@ describe("Bookings Endpoints 2024-08-13", () => { team2EventTypeId = team2EventType.id; + const team2RREventType = await eventTypesRepositoryFixture.createTeamEventType({ + schedulingType: "ROUND_ROBIN", + team: { + connect: { id: team2.id }, + }, + title: `team-bookings-2024-08-13-event-type-rr-${randomString()}`, + slug: team2RREventTypeSlug, + length: 60, + assignAllTeamMembers: true, + bookingFields: [], + locations: [], + rrHostSubsetEnabled: true, + }); + + team2RREventTypeId = team2RREventType.id; + + await hostsRepositoryFixture.create({ + isFixed: false, + user: { + connect: { + id: teamUser2.id, + }, + }, + eventType: { + connect: { + id: team2RREventType.id, + }, + }, + }); + + await hostsRepositoryFixture.create({ + isFixed: true, + user: { + connect: { + id: teamUser.id, + }, + }, + eventType: { + connect: { + id: team2RREventType.id, + }, + }, + }); + await hostsRepositoryFixture.create({ isFixed: true, user: { @@ -603,6 +649,163 @@ describe("Bookings Endpoints 2024-08-13", () => { } }); }); + + it("should create a team 2 RR booking and use rrHostSubsetIds to force teamUser2 as host ", async () => { + const body: CreateBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2030, 0, 8, 12, 0, 0)).toISOString(), + eventTypeId: team2RREventTypeId, + attendee: { + name: "bob2", + email: "bob2@gmail.com", + timeZone: "Europe/Rome", + language: "it", + }, + meetingUrl: "https://meet.google.com/abc-def-ghi", + rrHostSubsetIds: [teamUser2.id], + }; + + return request(app.getHttpServer()) + .post("/v2/bookings") + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201) + .then(async (response) => { + const responseBody: CreateBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseDataIsBooking(responseBody.data)).toBe(true); + + if (responseDataIsBooking(responseBody.data)) { + const data: BookingOutput_2024_08_13 = responseBody.data; + expect(data.id).toBeDefined(); + expect(data.uid).toBeDefined(); + expect(data.hosts.length).toEqual(1); + expect(data.hosts[0].id).toEqual(teamUser2.id); + expect(data.status).toEqual("accepted"); + expect(data.start).toEqual(body.start); + expect(data.end).toEqual(new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString()); + expect(data.duration).toEqual(60); + expect(data.eventTypeId).toEqual(team2RREventTypeId); + expect(data.attendees.length).toEqual(1); + expect(data.attendees[0]).toEqual({ + name: body.attendee.name, + email: body.attendee.email, + timeZone: body.attendee.timeZone, + language: body.attendee.language, + absent: false, + }); + expect(data.meetingUrl).toEqual(body.meetingUrl); + expect(data.absentHost).toEqual(false); + } else { + throw new Error( + "Invalid response data - expected booking but received array of possibly recurring bookings" + ); + } + }); + }); + + it("should create a team 2 RR booking and use rrHostSubsetIds to force teamUser as host ", async () => { + const body: CreateBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2030, 0, 8, 12, 0, 0)).toISOString(), + eventTypeId: team2RREventTypeId, + attendee: { + name: "bob", + email: "bob@gmail.com", + timeZone: "Europe/Rome", + language: "it", + }, + meetingUrl: "https://meet.google.com/abc-def-ghi", + rrHostSubsetIds: [teamUser.id], + }; + + return request(app.getHttpServer()) + .post("/v2/bookings") + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201) + .then(async (response) => { + const responseBody: CreateBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseDataIsBooking(responseBody.data)).toBe(true); + + if (responseDataIsBooking(responseBody.data)) { + const data: BookingOutput_2024_08_13 = responseBody.data; + expect(data.id).toBeDefined(); + expect(data.uid).toBeDefined(); + expect(data.hosts.length).toEqual(1); + expect(data.hosts[0].id).toEqual(teamUser.id); + expect(data.status).toEqual("accepted"); + expect(data.start).toEqual(body.start); + expect(data.end).toEqual(new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString()); + expect(data.duration).toEqual(60); + expect(data.eventTypeId).toEqual(team2RREventTypeId); + expect(data.attendees.length).toEqual(1); + expect(data.attendees[0]).toEqual({ + name: body.attendee.name, + email: body.attendee.email, + timeZone: body.attendee.timeZone, + language: body.attendee.language, + absent: false, + }); + expect(data.meetingUrl).toEqual(body.meetingUrl); + expect(data.absentHost).toEqual(false); + } else { + throw new Error( + "Invalid response data - expected booking but received array of possibly recurring bookings" + ); + } + }); + }); + + it("should create a team 2 RR booking and use rrHostSubsetIds to force teamUser and teamUser2 as host ", async () => { + const body: CreateBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2030, 0, 8, 14, 0, 0)).toISOString(), + eventTypeId: team2RREventTypeId, + attendee: { + name: "bob", + email: "bob@gmail.com", + timeZone: "Europe/Rome", + language: "it", + }, + meetingUrl: "https://meet.google.com/abc-def-ghi", + rrHostSubsetIds: [teamUser.id, teamUser2.id], + }; + + return request(app.getHttpServer()) + .post("/v2/bookings") + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201) + .then(async (response) => { + const responseBody: CreateBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseDataIsBooking(responseBody.data)).toBe(true); + + if (responseDataIsBooking(responseBody.data)) { + const data: BookingOutput_2024_08_13 = responseBody.data; + expect(data.id).toBeDefined(); + expect(data.uid).toBeDefined(); + expect(data.hosts.length).toEqual(1); + expect(data.hosts[0].id).toEqual(teamUser.id); + expect(data.status).toEqual("accepted"); + expect(data.start).toEqual(body.start); + expect(data.end).toEqual(new Date(Date.UTC(2030, 0, 8, 15, 0, 0)).toISOString()); + expect(data.duration).toEqual(60); + expect(data.eventTypeId).toEqual(team2RREventTypeId); + expect(data.attendees.length).toEqual(2); + expect(data.attendees.find((a) => a.email === body.attendee.email)).toBeDefined(); + expect(data.attendees.find((a) => a.email === teamUser2.email)).toBeDefined(); + expect(data.meetingUrl).toEqual(body.meetingUrl); + expect(data.absentHost).toEqual(false); + } else { + throw new Error( + "Invalid response data - expected booking but received array of possibly recurring bookings" + ); + } + }); + }); }); describe("get team bookings", () => { @@ -639,7 +842,7 @@ describe("Bookings Endpoints 2024-08-13", () => { | RecurringBookingOutput_2024_08_13 | GetSeatedBookingOutput_2024_08_13 )[] = responseBody.data; - expect(data.length).toEqual(1); + expect(data.length).toEqual(3); expect(data[0].eventTypeId).toEqual(team2EventTypeId); }); }); @@ -684,7 +887,7 @@ describe("Bookings Endpoints 2024-08-13", () => { | RecurringBookingOutput_2024_08_13 | GetSeatedBookingOutput_2024_08_13 )[] = responseBody.data; - expect(data.length).toEqual(3); + expect(data.length).toEqual(5); expect(data.find((booking) => booking.eventTypeId === team1EventTypeId)).toBeDefined(); expect(data.find((booking) => booking.eventTypeId === team2EventTypeId)).toBeDefined(); }); @@ -845,6 +1048,7 @@ describe("Bookings Endpoints 2024-08-13", () => { await userRepositoryFixture.deleteByEmail(teamUser.email); await userRepositoryFixture.deleteByEmail(teamUserEmail2); await bookingsRepositoryFixture.deleteAllBookings(teamUser.id, teamUser.email); + await bookingsRepositoryFixture.deleteAllBookings(teamUser2.id, teamUser2.email); await app.close(); }); }); diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/services/input.service.ts b/apps/api/v2/src/ee/bookings/2024-08-13/services/input.service.ts index 5e6e92977e..165602b970 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/services/input.service.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/services/input.service.ts @@ -188,6 +188,7 @@ export class InputBookingsService_2024_08_13 { location, }, ...this.getRoutingFormData(inputBooking.routing), + rrHostSubsetIds: inputBooking.rrHostSubsetIds, }; } @@ -484,6 +485,7 @@ export class InputBookingsService_2024_08_13 { }, schedulingType: eventType.schedulingType, ...this.getRoutingFormData(inputBooking.routing), + rrHostSubsetIds: inputBooking.rrHostSubsetIds, }); switch (timeBetween) { diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/controllers/event-types.controller.e2e-spec.ts b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/controllers/event-types.controller.e2e-spec.ts index 54c5ca7f53..a0da2d4bfe 100644 --- a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/controllers/event-types.controller.e2e-spec.ts +++ b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/controllers/event-types.controller.e2e-spec.ts @@ -1486,6 +1486,7 @@ describe("Event types Endpoints", () => { locations: [], schedulingType: "COLLECTIVE", team: { connect: { id: team.id } }, + rrHostSubsetEnabled: true, }); return request(app.getHttpServer()) @@ -1498,6 +1499,7 @@ describe("Event types Endpoints", () => { expect(responseBody.status).toEqual(SUCCESS_STATUS); expect(responseBody.data.id).toEqual(teamEventType.id); expect(responseBody.data.teamId).toEqual(team.id); + expect(responseBody.data.rrHostSubsetEnabled).toEqual(true); await teamRepositoryFixture.delete(team.id); }); }); diff --git a/apps/api/v2/src/modules/organizations/event-types/organizations-member-team-admin-event-types.e2e-spec.ts b/apps/api/v2/src/modules/organizations/event-types/organizations-member-team-admin-event-types.e2e-spec.ts index fd78fec270..ee1c501125 100644 --- a/apps/api/v2/src/modules/organizations/event-types/organizations-member-team-admin-event-types.e2e-spec.ts +++ b/apps/api/v2/src/modules/organizations/event-types/organizations-member-team-admin-event-types.e2e-spec.ts @@ -389,6 +389,7 @@ describe("Organizations Event Types Endpoints", () => { expect(data.hideOrganizerEmail).toEqual(body.hideOrganizerEmail); expect(data.lockTimeZoneToggleOnBookingPage).toEqual(body.lockTimeZoneToggleOnBookingPage); expect(data.color).toEqual(body.color); + expect(data.rrHostSubsetEnabled).toEqual(false); expect(data.successRedirectUrl).toEqual("https://masterchief.com/argentina/flan/video/1234"); expect(data.emailSettings).toEqual(body.emailSettings); collectiveEventType = responseBody.data; @@ -537,6 +538,7 @@ describe("Organizations Event Types Endpoints", () => { const data = responseBody.data; expect(data.title).toEqual(collectiveEventType.title); expect(data.hosts.length).toEqual(2); + expect(data.rrHostSubsetEnabled).toEqual(false); evaluateHost(collectiveEventType.hosts[0], data.hosts[0]); evaluateHost(collectiveEventType.hosts[1], data.hosts[1]); @@ -1142,6 +1144,7 @@ describe("Organizations Event Types Endpoints", () => { hideCalendarEventDetails: true, hideOrganizerEmail: true, lockTimeZoneToggleOnBookingPage: true, + rrHostSubsetEnabled: true, color: { darkThemeHex: "#292929", lightThemeHex: "#fafafa", @@ -1175,6 +1178,7 @@ describe("Organizations Event Types Endpoints", () => { expect(data.hideOrganizerEmail).toEqual(body.hideOrganizerEmail); expect(data.lockTimeZoneToggleOnBookingPage).toEqual(body.lockTimeZoneToggleOnBookingPage); expect(data.color).toEqual(body.color); + expect(data.rrHostSubsetEnabled).toEqual(true); expect(data.successRedirectUrl).toEqual("https://masterchief.com/argentina/flan/video/1234"); collectiveEventType = responseBody.data; }); 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 06776e5e30..ba0cd2dc59 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" + | "rrHostSubsetEnabled" >; @Injectable() @@ -103,8 +104,14 @@ export class OutputOrganizationsEventTypesService { const emailSettings = this.transformEmailSettings(metadata); - const { teamId, userId, parentId, assignAllTeamMembers, rescheduleWithSameRoundRobinHost } = - databaseEventType; + const { + teamId, + userId, + parentId, + assignAllTeamMembers, + rescheduleWithSameRoundRobinHost, + rrHostSubsetEnabled, + } = databaseEventType; // eslint-disable-next-line @typescript-eslint/no-unused-vars const { ownerId, users, ...rest } = this.outputEventTypesService.getResponseEventType( 0, @@ -139,6 +146,7 @@ export class OutputOrganizationsEventTypesService { theme: databaseEventType?.team?.theme, }, rescheduleWithSameRoundRobinHost, + rrHostSubsetEnabled, }; } diff --git a/apps/api/v2/src/modules/slots/slots-2024-09-04/controllers/e2e/team-event-type-slots.controller.e2e-spec.ts b/apps/api/v2/src/modules/slots/slots-2024-09-04/controllers/e2e/team-event-type-slots.controller.e2e-spec.ts index fba616befd..b91853a9a3 100644 --- a/apps/api/v2/src/modules/slots/slots-2024-09-04/controllers/e2e/team-event-type-slots.controller.e2e-spec.ts +++ b/apps/api/v2/src/modules/slots/slots-2024-09-04/controllers/e2e/team-event-type-slots.controller.e2e-spec.ts @@ -53,6 +53,7 @@ describe("Slots 2024-09-04 Endpoints", () => { const teammateEmailOne = `slots-2024-09-04-user-1-team-slots-${randomString()}`; let teammateApiKeyString: string; const teammateEmailTwo = `slots-2024-09-04-user-2-team-slots-${randomString()}`; + const teammateEmailThree = `slots-2024-09-04-user-3-team-slots-${randomString()}`; let teammateTwoApiKeyString: string; const outsiderEmail = `slots-2024-09-04-unrelated-team-slots-${randomString()}`; @@ -63,6 +64,7 @@ describe("Slots 2024-09-04 Endpoints", () => { let team: Team; let teammateOne: User; let teammateTwo: User; + let teammateThree: User; let collectiveEventTypeId: number; let collectiveEventTypeSlug: string; let collectiveEventTypeWithoutHostsId: number; @@ -114,6 +116,12 @@ describe("Slots 2024-09-04 Endpoints", () => { username: teammateEmailTwo, }); + teammateThree = await userRepositoryFixture.create({ + email: teammateEmailThree, + name: teammateEmailThree, + username: teammateEmailThree, + }); + outsider = await userRepositoryFixture.create({ email: outsiderEmail, name: outsiderEmail, @@ -155,6 +163,13 @@ describe("Slots 2024-09-04 Endpoints", () => { accepted: true, }); + await membershipsRepositoryFixture.create({ + role: "MEMBER", + user: { connect: { id: teammateThree.id } }, + team: { connect: { id: team.id } }, + accepted: true, + }); + const collectiveEventType = await eventTypesRepositoryFixture.createTeamEventType({ schedulingType: "COLLECTIVE", team: { @@ -243,7 +258,7 @@ describe("Slots 2024-09-04 Endpoints", () => { bookingFields: [], locations: [], users: { - connect: [{ id: teammateOne.id }, { id: teammateTwo.id }], + connect: [{ id: teammateOne.id }, { id: teammateTwo.id }, { id: teammateThree.id }], }, hosts: { create: [ @@ -255,8 +270,13 @@ describe("Slots 2024-09-04 Endpoints", () => { userId: teammateTwo.id, isFixed: false, }, + { + userId: teammateThree.id, + isFixed: false, + }, ], }, + rrHostSubsetEnabled: true, }); roundRobinEventTypeWithoutFixedHostsId = roundRobinEventTypeWithoutFixedHosts.id; @@ -274,7 +294,7 @@ describe("Slots 2024-09-04 Endpoints", () => { bookingFields: [], locations: [], users: { - connect: [{ id: teammateOne.id }, { id: teammateTwo.id }], + connect: [{ id: teammateOne.id }, { id: teammateTwo.id }, { id: teammateThree.id }], }, hosts: { create: [ @@ -286,8 +306,13 @@ describe("Slots 2024-09-04 Endpoints", () => { userId: teammateTwo.id, isFixed: false, }, + { + userId: teammateThree.id, + isFixed: false, + }, ], }, + rrHostSubsetEnabled: true, }); roundRobinEventTypeWithFixedAndNonFixedHostsId = roundRobinEventTypeWithFixedAndNonFixedHosts.id; @@ -301,6 +326,17 @@ describe("Slots 2024-09-04 Endpoints", () => { await schedulesService.createUserSchedule(teammateOne.id, userSchedule); await schedulesService.createUserSchedule(teammateTwo.id, userSchedule); + await schedulesService.createUserSchedule(teammateThree.id, { + ...userSchedule, + availability: [ + { + days: ["Monday", "Friday"], + startTime: "09:00", + endTime: "17:00", + }, + ], + }); + app = moduleRef.createNestApplication(); bootstrap(app as NestExpressApplication); @@ -360,6 +396,79 @@ describe("Slots 2024-09-04 Endpoints", () => { }); }); + it("should get round robin team event without fixed hosts slots in UTC with subsetIds for teammateThree who has a smaller schedule", async () => { + return request(app.getHttpServer()) + .get( + `/v2/slots?eventTypeId=${roundRobinEventTypeWithoutFixedHostsId}&start=2050-09-05&end=2050-09-09&rrHostSubsetIds[]=${teammateThree.id}` + ) + .set(CAL_API_VERSION_HEADER, VERSION_2024_09_04) + .expect(200) + .then(async (response) => { + const responseBody: GetSlotsOutput_2024_09_04 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + const slots = responseBody.data; + expect(slots).toBeDefined(); + const days = Object.keys(slots); + expect(days.length).toEqual(2); + }); + }); + + it("should get round robin team event without fixed hosts slots in UTC with subsetIds for teammateOne", async () => { + return request(app.getHttpServer()) + .get( + `/v2/slots?eventTypeId=${roundRobinEventTypeWithoutFixedHostsId}&start=2050-09-05&end=2050-09-09&rrHostSubsetIds[]=${teammateOne.id}` + ) + .set(CAL_API_VERSION_HEADER, VERSION_2024_09_04) + .expect(200) + .then(async (response) => { + const responseBody: GetSlotsOutput_2024_09_04 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + const slots = responseBody.data; + + expect(slots).toBeDefined(); + const days = Object.keys(slots); + expect(days.length).toEqual(5); + expect(slots).toEqual(expectedSlotsUTC); + }); + }); + + it("should get round robin team event with and without fixed hosts slots in UTC with subsetIds for teammateOne(fixed) and teammateThree(not fixed) ", async () => { + return request(app.getHttpServer()) + .get( + `/v2/slots?eventTypeId=${roundRobinEventTypeWithFixedAndNonFixedHostsId}&start=2050-09-05&end=2050-09-09&rrHostSubsetIds[]=${teammateOne.id}&rrHostSubsetIds[]=${teammateThree.id}` + ) + .set(CAL_API_VERSION_HEADER, VERSION_2024_09_04) + .expect(200) + .then(async (response) => { + const responseBody: GetSlotsOutput_2024_09_04 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + const slots = responseBody.data; + + expect(slots).toBeDefined(); + const days = Object.keys(slots); + expect(days.length).toEqual(2); + }); + }); + + it("should get round robin team event with and without fixed hosts slots in UTC with subsetIds for teammateOne(fixed) and teammateTwo(not fixed) ", async () => { + return request(app.getHttpServer()) + .get( + `/v2/slots?eventTypeId=${roundRobinEventTypeWithFixedAndNonFixedHostsId}&start=2050-09-05&end=2050-09-09&rrHostSubsetIds[]=${teammateOne.id}&rrHostSubsetIds[]=${teammateTwo.id}` + ) + .set(CAL_API_VERSION_HEADER, VERSION_2024_09_04) + .expect(200) + .then(async (response) => { + const responseBody: GetSlotsOutput_2024_09_04 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + const slots = responseBody.data; + + expect(slots).toBeDefined(); + const days = Object.keys(slots); + expect(days.length).toEqual(5); + expect(slots).toEqual(expectedSlotsUTC); + }); + }); + it("should not be able reserve a team event type slot with custom duration if no auth is provided", async () => { await request(app.getHttpServer()) .post(`/v2/slots/reservations`) @@ -748,6 +857,7 @@ describe("Slots 2024-09-04 Endpoints", () => { afterAll(async () => { await userRepositoryFixture.deleteByEmail(teammateOne.email); await userRepositoryFixture.deleteByEmail(teammateTwo.email); + await userRepositoryFixture.deleteByEmail(teammateThree.email); await userRepositoryFixture.deleteByEmail(outsiderEmail); await teamRepositoryFixture.delete(team.id); await bookingsRepositoryFixture.deleteById(collectiveBookingId); diff --git a/apps/api/v2/src/modules/slots/slots-2024-09-04/services/slots-input.service.ts b/apps/api/v2/src/modules/slots/slots-2024-09-04/services/slots-input.service.ts index 5d5a5b90f0..e4169f7b3e 100644 --- a/apps/api/v2/src/modules/slots/slots-2024-09-04/services/slots-input.service.ts +++ b/apps/api/v2/src/modules/slots/slots-2024-09-04/services/slots-input.service.ts @@ -30,6 +30,7 @@ export type InternalGetSlotsQuery = { timeZone: string | undefined; orgSlug: string | null | undefined; rescheduleUid: string | null; + rrHostSubsetIds?: number[]; }; export type InternalGetSlotsQueryWithRouting = InternalGetSlotsQuery & { @@ -79,6 +80,7 @@ export class SlotsInputService_2024_09_04 { timeZone, orgSlug, rescheduleUid, + rrHostSubsetIds: query.rrHostSubsetIds, }; } diff --git a/apps/web/test/lib/handleChildrenEventTypes.test.ts b/apps/web/test/lib/handleChildrenEventTypes.test.ts index 57f596d3fa..79db3cd9cf 100644 --- a/apps/web/test/lib/handleChildrenEventTypes.test.ts +++ b/apps/web/test/lib/handleChildrenEventTypes.test.ts @@ -258,6 +258,7 @@ describe("handleChildrenEventTypes", () => { requiresBookerEmailVerification: false, useBookerTimezone: false, restrictionScheduleId: null, + hashedLink: { deleteMany: {}, }, diff --git a/docs/api-reference/v2/openapi.json b/docs/api-reference/v2/openapi.json index 374cdea614..ee89be31c6 100644 --- a/docs/api-reference/v2/openapi.json +++ b/docs/api-reference/v2/openapi.json @@ -3270,6 +3270,19 @@ "type": "string" } }, + { + "name": "rrHostSubsetIds", + "required": false, + "in": "query", + "description": "For round robin event types, filter available slots to only consider the specified subset of host user IDs. This allows you to get availability for specific hosts within a round robin event type.", + "example": [1, 2, 3], + "schema": { + "type": "array", + "items": { + "type": "number" + } + } + }, { "name": "queueResponse", "required": false, @@ -6812,6 +6825,19 @@ "type": "string" } }, + { + "name": "rrHostSubsetIds", + "required": false, + "in": "query", + "description": "For round robin event types, filter available slots to only consider the specified subset of host user IDs. This allows you to get availability for specific hosts within a round robin event type.", + "example": [1, 2, 3], + "schema": { + "type": "array", + "items": { + "type": "number" + } + } + }, { "name": "queueResponse", "required": false, @@ -13318,6 +13344,19 @@ "type": "string" } }, + { + "name": "rrHostSubsetIds", + "required": false, + "in": "query", + "description": "For round robin event types, filter available slots to only consider the specified subset of host user IDs. This allows you to get availability for specific hosts within a round robin event type.", + "example": [1, 2, 3], + "schema": { + "type": "array", + "items": { + "type": "number" + } + } + }, { "name": "routingFormId", "required": true, @@ -13841,7 +13880,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 +14491,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 +19702,10 @@ "rescheduleWithSameRoundRobinHost": { "type": "boolean", "description": "Rescheduled events will be assigned to the same host as initially scheduled." + }, + "rrHostSubsetEnabled": { + "type": "boolean", + "description": "For round robin event types, enable filtering available hosts to only consider a specified subset of host user IDs. This allows you to book with specific hosts within a round robin event type." } }, "required": [ @@ -21676,6 +21719,10 @@ "rescheduleWithSameRoundRobinHost": { "type": "boolean", "description": "Rescheduled events will be assigned to the same host as initially scheduled." + }, + "rrHostSubsetEnabled": { + "type": "boolean", + "description": "For round robin event types, enable filtering available hosts to only consider a specified subset of host user IDs. This allows you to book with specific hosts within a round robin event type." } }, "required": ["lengthInMinutes", "title", "slug", "schedulingType"] @@ -22139,6 +22186,10 @@ "rescheduleWithSameRoundRobinHost": { "type": "boolean", "description": "Rescheduled events will be assigned to the same host as initially scheduled." + }, + "rrHostSubsetEnabled": { + "type": "boolean", + "description": "For round robin event types, enable filtering available hosts to only consider a specified subset of host user IDs. This allows you to book with specific hosts within a round robin event type." } } }, @@ -27951,6 +28002,14 @@ "type": "string", "description": "Email verification code required when event type has email verification enabled.", "example": "123456" + }, + "rrHostSubsetIds": { + "description": "For round robin event types, filter available hosts to only consider the specified subset of host user IDs. This allows you to book with specific hosts within a round robin event type.", + "example": [1, 2, 3], + "type": "array", + "items": { + "type": "number" + } } }, "required": ["start", "attendee"] @@ -28075,6 +28134,14 @@ "description": "Email verification code required when event type has email verification enabled.", "example": "123456" }, + "rrHostSubsetIds": { + "description": "For round robin event types, filter available hosts to only consider the specified subset of host user IDs. This allows you to book with specific hosts within a round robin event type.", + "example": [1, 2, 3], + "type": "array", + "items": { + "type": "number" + } + }, "instant": { "type": "boolean", "description": "Flag indicating if the booking is an instant booking. Only available for team events.", @@ -28203,6 +28270,14 @@ "description": "Email verification code required when event type has email verification enabled.", "example": "123456" }, + "rrHostSubsetIds": { + "description": "For round robin event types, filter available hosts to only consider the specified subset of host user IDs. This allows you to book with specific hosts within a round robin event type.", + "example": [1, 2, 3], + "type": "array", + "items": { + "type": "number" + } + }, "recurrenceCount": { "type": "number", "description": "The number of recurrences. If not provided then event type recurrence count will be used. Can't be more than\n event type recurrence count", @@ -29442,6 +29517,10 @@ "example": "success", "enum": ["success", "error"] }, + "message": { + "type": "string", + "example": "This endpoint will require authentication in a future release." + }, "error": { "type": "object" }, @@ -29469,6 +29548,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/bookingCreateBodySchema.ts b/packages/features/bookings/lib/bookingCreateBodySchema.ts index c30947f343..fa789db5b1 100644 --- a/packages/features/bookings/lib/bookingCreateBodySchema.ts +++ b/packages/features/bookings/lib/bookingCreateBodySchema.ts @@ -28,6 +28,7 @@ export const bookingCreateBodySchema = z.object({ routedTeamMemberIds: z.array(z.number()).nullish(), routingFormResponseId: z.number().optional(), skipContactOwner: z.boolean().optional(), + rrHostSubsetIds: z.array(z.number()).nullish(), crmAppSlug: z.string().nullish().optional(), cfToken: z.string().nullish().optional(), diff --git a/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts b/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts index f0f07a8c41..44db01efdf 100644 --- a/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts +++ b/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts @@ -181,6 +181,7 @@ const getEventTypesFromDBSelect = { name: true, }, }, + rrHostSubsetEnabled: true, instantMeetingExpiryTimeOffsetInSeconds: true, autoTranslateInstantMeetingTitleEnabled: true, } satisfies Prisma.EventTypeSelect; diff --git a/packages/features/bookings/lib/handleNewBooking/loadAndValidateUsers.ts b/packages/features/bookings/lib/handleNewBooking/loadAndValidateUsers.ts index ff28f88ee0..6342cfa8bd 100644 --- a/packages/features/bookings/lib/handleNewBooking/loadAndValidateUsers.ts +++ b/packages/features/bookings/lib/handleNewBooking/loadAndValidateUsers.ts @@ -53,6 +53,7 @@ type EventType = Pick< | "rescheduleWithSameRoundRobinHost" | "teamId" | "includeNoShowInRRCalculation" + | "rrHostSubsetEnabled" >; type InputProps = { @@ -67,6 +68,7 @@ type InputProps = { isPlatform: boolean; hostname: string | undefined; forcedSlug: string | undefined; + rrHostSubsetIds?: number[]; }; const _loadAndValidateUsers = async ({ @@ -81,6 +83,7 @@ const _loadAndValidateUsers = async ({ isPlatform, hostname, forcedSlug, + rrHostSubsetIds, }: InputProps): Promise<{ qualifiedRRUsers: UsersWithDelegationCredentials; additionalFallbackRRUsers: UsersWithDelegationCredentials; @@ -155,6 +158,7 @@ const _loadAndValidateUsers = async ({ rescheduleUid, contactOwnerEmail, routingFormResponse, + rrHostSubsetIds, }); const allQualifiedHostsHashMap = [...qualifiedRRHosts, ...(allFallbackRRHosts ?? []), ...fixedHosts].reduce( (acc, host) => { diff --git a/packages/features/bookings/lib/host-filtering/findQualifiedHostsWithDelegationCredentials.ts b/packages/features/bookings/lib/host-filtering/findQualifiedHostsWithDelegationCredentials.ts index 7bffec2a18..7cab725546 100644 --- a/packages/features/bookings/lib/host-filtering/findQualifiedHostsWithDelegationCredentials.ts +++ b/packages/features/bookings/lib/host-filtering/findQualifiedHostsWithDelegationCredentials.ts @@ -7,7 +7,7 @@ import { import type { EventType } from "@calcom/features/users/lib/getRoutedUsers"; import { withReporting } from "@calcom/lib/sentryWrapper"; import type { SelectedCalendar } from "@calcom/prisma/client"; -import type { SchedulingType } from "@calcom/prisma/enums"; +import { SchedulingType } from "@calcom/prisma/enums"; import type { CredentialForCalendarService, CredentialPayload } from "@calcom/types/Credential"; import { filterHostsByLeadThreshold } from "./filterHostsByLeadThreshold"; @@ -54,6 +54,23 @@ const isFixedHost = (host: T): host is T & { isF return host.isFixed; }; +const isWithinRRHostSubset = ( + host: T, + rrHostSubsetIds: number[], + { + rrHostSubsetEnabled, + schedulingType, + }: { rrHostSubsetEnabled: boolean; schedulingType?: SchedulingType } = { + rrHostSubsetEnabled: false, + schedulingType: undefined, + } +): host is T & { isFixed: false } => { + if (rrHostSubsetIds.length === 0 || !rrHostSubsetEnabled || schedulingType !== SchedulingType.ROUND_ROBIN) { + return true; + } + return rrHostSubsetIds.includes(host.user.id); +}; + export class QualifiedHostsService { constructor(public readonly dependencies: IQualifiedHostsService) {} @@ -70,6 +87,7 @@ export class QualifiedHostsService { routedTeamMemberIds, contactOwnerEmail, routingFormResponse, + rrHostSubsetIds, }: { eventType: { id: number; @@ -80,11 +98,13 @@ export class QualifiedHostsService { isRRWeightsEnabled: boolean; rescheduleWithSameRoundRobinHost: boolean; includeNoShowInRRCalculation: boolean; + rrHostSubsetEnabled?: boolean; } & EventType; rescheduleUid: string | null; routedTeamMemberIds: number[]; contactOwnerEmail: string | null; routingFormResponse: RoutingFormResponse | null; + rrHostSubsetIds?: number[]; }): Promise<{ qualifiedRRHosts: { isFixed: boolean; @@ -120,8 +140,18 @@ export class QualifiedHostsService { return { qualifiedRRHosts: roundRobinHosts, fixedHosts }; } - const fixedHosts = normalizedHosts.filter(isFixedHost); - const roundRobinHosts = normalizedHosts.filter(isRoundRobinHost); + const fixedHosts = normalizedHosts.filter(isFixedHost).filter((host) => + isWithinRRHostSubset(host, rrHostSubsetIds ?? [], { + rrHostSubsetEnabled: eventType.rrHostSubsetEnabled ?? false, + schedulingType: eventType.schedulingType ?? undefined, + }) + ); + const roundRobinHosts = normalizedHosts.filter(isRoundRobinHost).filter((host) => + isWithinRRHostSubset(host, rrHostSubsetIds ?? [], { + rrHostSubsetEnabled: eventType.rrHostSubsetEnabled ?? false, + schedulingType: eventType.schedulingType ?? undefined, + }) + ); // If it is rerouting, we should not force reschedule with same host. const hostsAfterRescheduleWithSameRoundRobinHost = applyFilterWithFallback( diff --git a/packages/features/bookings/lib/service/RegularBookingService.ts b/packages/features/bookings/lib/service/RegularBookingService.ts index 19ec17c877..e6a639970b 100644 --- a/packages/features/bookings/lib/service/RegularBookingService.ts +++ b/packages/features/bookings/lib/service/RegularBookingService.ts @@ -578,6 +578,7 @@ async function handler( routedTeamMemberIds, reroutingFormResponses, routingFormResponseId, + rrHostSubsetIds, _isDryRun: isDryRun = false, ...reqBody } = bookingData; @@ -828,6 +829,7 @@ async function handler( contactOwnerEmail, rescheduleUid: reqBody.rescheduleUid || null, routingFormResponse, + rrHostSubsetIds: rrHostSubsetIds ?? undefined, }); // We filter out users but ensure allHostUsers remain same. diff --git a/packages/features/ee/managed-event-types/lib/handleChildrenEventTypes.ts b/packages/features/ee/managed-event-types/lib/handleChildrenEventTypes.ts index 78e02aef50..0010cc8e1d 100644 --- a/packages/features/ee/managed-event-types/lib/handleChildrenEventTypes.ts +++ b/packages/features/ee/managed-event-types/lib/handleChildrenEventTypes.ts @@ -237,6 +237,7 @@ export default async function handleChildrenEventTypes({ useBookerTimezone: false, allowReschedulingCancelledBookings: managedEventTypeValues.allowReschedulingCancelledBookings ?? false, + rrHostSubsetEnabled: false, }; }); @@ -331,6 +332,7 @@ export default async function handleChildrenEventTypes({ }, data: { ...updatePayloadFiltered, + rrHostSubsetEnabled: false, hidden: children?.find((ch) => ch.owner.id === userId)?.hidden ?? false, ...("schedule" in unlockedFieldProps ? {} : { scheduleId: eventType.scheduleId || null }), restrictionScheduleId: null, diff --git a/packages/features/eventtypes/lib/defaultEvents.ts b/packages/features/eventtypes/lib/defaultEvents.ts index 6be10c54e0..43a85f8d77 100644 --- a/packages/features/eventtypes/lib/defaultEvents.ts +++ b/packages/features/eventtypes/lib/defaultEvents.ts @@ -153,6 +153,7 @@ const commons = { bookingRequiresAuthentication: false, createdAt: null, updatedAt: null, + rrHostSubsetEnabled: false, }; export const dynamicEvent = { diff --git a/packages/features/eventtypes/repositories/eventTypeRepository.ts b/packages/features/eventtypes/repositories/eventTypeRepository.ts index d87ed8a0dc..7bd9bef976 100644 --- a/packages/features/eventtypes/repositories/eventTypeRepository.ts +++ b/packages/features/eventtypes/repositories/eventTypeRepository.ts @@ -2,19 +2,23 @@ import { MembershipRepository } from "@calcom/features/membership/repositories/M import { LookupTarget, ProfileRepository } from "@calcom/features/profile/repositories/ProfileRepository"; import type { UserWithLegacySelectedCalendars } from "@calcom/features/users/repositories/UserRepository"; import { withSelectedCalendars } from "@calcom/features/users/repositories/UserRepository"; +import { ErrorCode } from "@calcom/lib/errorCodes"; +import { ErrorWithCode } from "@calcom/lib/errors"; import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; import { eventTypeSelect } from "@calcom/lib/server/eventTypeSelect"; import type { PrismaClient } from "@calcom/prisma"; -import { prisma, availabilityUserSelect, userSelect as userSelectWithSelectedCalendars } from "@calcom/prisma"; +import { + prisma, + availabilityUserSelect, + userSelect as userSelectWithSelectedCalendars, +} from "@calcom/prisma"; import type { EventType as PrismaEventType } from "@calcom/prisma/client"; import type { Prisma } from "@calcom/prisma/client"; import { MembershipRole } from "@calcom/prisma/enums"; import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential"; import { EventTypeMetaDataSchema, rrSegmentQueryValueSchema } from "@calcom/prisma/zod-utils"; import type { Ensure } from "@calcom/types/utils"; -import { ErrorCode } from "@calcom/lib/errorCodes"; -import { ErrorWithCode } from "@calcom/lib/errors"; const log = logger.getSubLogger({ prefix: ["repository/eventType"] }); @@ -1272,6 +1276,7 @@ export class EventTypeRepository { useEventLevelSelectedCalendars: true, restrictionScheduleId: true, useBookerTimezone: true, + rrHostSubsetEnabled: true, hostGroups: { select: { id: true, @@ -1590,7 +1595,7 @@ export class EventTypeRepository { return { eventTypes, total }; } - /** + /** * List child event types for a given parent. * Supports search, user exclusion, cursor pagination. */ @@ -1611,14 +1616,16 @@ export class EventTypeRepository { const eventTypeWhere = { parentId: parentEventTypeId, ...(excludeUserId ? { userId: { not: excludeUserId } } : {}), - ...(searchTerm ? { - owner: { - OR: [ - { name: { contains: searchTerm, mode: "insensitive" as const } }, - { email: { contains: searchTerm, mode: "insensitive" as const } }, - ], - }, - } : {}), + ...(searchTerm + ? { + owner: { + OR: [ + { name: { contains: searchTerm, mode: "insensitive" as const } }, + { email: { contains: searchTerm, mode: "insensitive" as const } }, + ], + }, + } + : {}), }; // Extract query to preserve type inference @@ -1636,9 +1643,9 @@ export class EventTypeRepository { }, }, }, - take: limit + 1, // over-fetch for nextCursor + take: limit + 1, // over-fetch for nextCursor ...(cursor && { skip: 1, cursor: { id: cursor } }), - orderBy: { id: "asc" }, // deterministic pagination + orderBy: { id: "asc" }, // deterministic pagination }); const [totalCount, rows] = await Promise.all([ @@ -1657,20 +1664,20 @@ export class EventTypeRepository { }; } - async findByIdWithParentAndUserId(eventTypeId: number) { - return this.prismaClient.eventType.findUnique({ - where: { id: eventTypeId }, - select: { - id: true, - parentId: true, - userId: true, - schedulingType: true, - }, - }); - } + async findByIdWithParentAndUserId(eventTypeId: number) { + return this.prismaClient.eventType.findUnique({ + where: { id: eventTypeId }, + select: { + id: true, + parentId: true, + userId: true, + schedulingType: true, + }, + }); + } - async findByIdTargetChildEventType(userId: number, parentId: number) { - return this.prismaClient.eventType.findUnique({ + async findByIdTargetChildEventType(userId: number, parentId: number) { + return this.prismaClient.eventType.findUnique({ where: { userId_parentId: { userId, diff --git a/packages/lib/test/builder.ts b/packages/lib/test/builder.ts index 8094388118..43ca4440c3 100644 --- a/packages/lib/test/builder.ts +++ b/packages/lib/test/builder.ts @@ -165,6 +165,7 @@ export const buildEventType = (eventType?: Partial): EventType => { bookingRequiresAuthentication: false, createdAt: null, updatedAt: null, + rrHostSubsetEnabled: false, ...eventType, }; }; diff --git a/packages/platform/atoms/booker/BookerPlatformWrapper.tsx b/packages/platform/atoms/booker/BookerPlatformWrapper.tsx index 86f9dcc192..a537d2c99b 100644 --- a/packages/platform/atoms/booker/BookerPlatformWrapper.tsx +++ b/packages/platform/atoms/booker/BookerPlatformWrapper.tsx @@ -71,6 +71,7 @@ const BookerPlatformWrapperComponent = ( silentlyHandleCalendarFailures = false, hideEventMetadata = false, defaultPhoneCountry, + rrHostSubsetIds, } = props; const layout = BookerLayouts[view]; @@ -296,6 +297,7 @@ const BookerPlatformWrapperComponent = ( ? { isTeamEvent: props.isTeamEvent, teamId: teamId, + rrHostSubsetIds: rrHostSubsetIds, } : {}), enabled: @@ -444,6 +446,11 @@ const BookerPlatformWrapperComponent = ( locationUrl: props.locationUrl, routingFormSearchParams, isBookingDryRun: isBookingDryRun ?? routingParams?.isBookingDryRun, + ...(props.isTeamEvent + ? { + rrHostSubsetIds: rrHostSubsetIds, + } + : {}), }); const onOverlaySwitchStateChange = useCallback( diff --git a/packages/platform/atoms/booker/types.ts b/packages/platform/atoms/booker/types.ts index 378c3fd594..29c35a6800 100644 --- a/packages/platform/atoms/booker/types.ts +++ b/packages/platform/atoms/booker/types.ts @@ -1,20 +1,20 @@ import type React from "react"; - - import type { BookerProps } from "@calcom/features/bookings/Booker"; import type { BookerStore, CountryCode } from "@calcom/features/bookings/Booker/store"; import type { Timezone, VIEW_TYPE } from "@calcom/features/bookings/Booker/types"; import type { BookingCreateBody } from "@calcom/features/bookings/lib/bookingCreateBodySchema"; import type { BookingResponse } from "@calcom/platform-libraries"; -import type { ApiSuccessResponse, ApiErrorResponse, ApiSuccessResponseWithoutData, RoutingFormSearchParams } from "@calcom/platform-types"; +import type { + ApiSuccessResponse, + ApiErrorResponse, + ApiSuccessResponseWithoutData, + RoutingFormSearchParams, +} from "@calcom/platform-types"; import type { Slot } from "@calcom/trpc/server/routers/viewer/slots/types"; - - import type { UseCreateBookingInput } from "../hooks/bookings/useCreateBooking"; - // Type that includes only the data values from BookerStore (excluding functions) export type BookerStoreValues = Omit< BookerStore, @@ -102,4 +102,5 @@ export type BookerPlatformWrapperAtomPropsForTeam = BookerPlatformWrapperAtomPro isTeamEvent: true; teamId: number; routingFormSearchParams?: RoutingFormSearchParams; + rrHostSubsetIds?: number[]; }; diff --git a/packages/platform/atoms/hooks/bookings/useHandleBookEvent.ts b/packages/platform/atoms/hooks/bookings/useHandleBookEvent.ts index b84021d7d3..f9d6adb60a 100644 --- a/packages/platform/atoms/hooks/bookings/useHandleBookEvent.ts +++ b/packages/platform/atoms/hooks/bookings/useHandleBookEvent.ts @@ -31,6 +31,7 @@ type UseHandleBookingProps = { locationUrl?: string; routingFormSearchParams?: RoutingFormSearchParams; isBookingDryRun?: boolean; + rrHostSubsetIds?: number[]; }; export const useHandleBookEvent = ({ @@ -44,6 +45,7 @@ export const useHandleBookEvent = ({ locationUrl, routingFormSearchParams, isBookingDryRun, + rrHostSubsetIds, }: UseHandleBookingProps) => { const isPlatform = useIsPlatform(); const setFormValues = useBookerStoreContext((state) => state.setFormValues); @@ -115,6 +117,7 @@ export const useHandleBookEvent = ({ routingFormSearchParams, isDryRunProp: isBookingDryRun, verificationCode: verificationCode || undefined, + rrHostSubsetIds, }; const tracking = getUtmTrackingParameters(searchParams); diff --git a/packages/platform/atoms/hooks/useAvailableSlots.ts b/packages/platform/atoms/hooks/useAvailableSlots.ts index 5cafafd640..e2ac30bab5 100644 --- a/packages/platform/atoms/hooks/useAvailableSlots.ts +++ b/packages/platform/atoms/hooks/useAvailableSlots.ts @@ -29,6 +29,7 @@ export const useAvailableSlots = ({ rest.routedTeamMemberIds, rest.skipContactOwner, rest.teamMemberEmail, + rest.rrHostSubsetIds, ], queryFn: () => { return http diff --git a/packages/platform/types/bookings/2024-08-13/inputs/create-booking.input.ts b/packages/platform/types/bookings/2024-08-13/inputs/create-booking.input.ts index 1cd1cc431d..01d1561826 100644 --- a/packages/platform/types/bookings/2024-08-13/inputs/create-booking.input.ts +++ b/packages/platform/types/bookings/2024-08-13/inputs/create-booking.input.ts @@ -1,4 +1,10 @@ -import { ApiExtraModels, ApiProperty, ApiPropertyOptional, getSchemaPath } from "@nestjs/swagger"; +import { + ApiExtraModels, + ApiHideProperty, + ApiProperty, + ApiPropertyOptional, + getSchemaPath, +} from "@nestjs/swagger"; import { Transform, Type } from "class-transformer"; import type { ValidationArguments, ValidationOptions } from "class-validator"; import { @@ -388,6 +394,18 @@ export class CreateBookingInput_2024_08_13 { @IsOptional() @IsString() emailVerificationCode?: string; + + @ApiPropertyOptional({ + type: [Number], + description: + "For round robin event types, filter available hosts to only consider the specified subset of host user IDs. This allows you to book with specific hosts within a round robin event type.", + example: [1, 2, 3], + }) + @ApiHideProperty() + @IsOptional() + @IsArray() + @IsInt({ each: true }) + rrHostSubsetIds?: number[]; } export class CreateInstantBookingInput_2024_08_13 extends CreateBookingInput_2024_08_13 { 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 6c7031052c..54dd1d3624 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 @@ -3,6 +3,7 @@ import { ApiPropertyOptional as DocsPropertyOptional, getSchemaPath, ApiExtraModels, + ApiHideProperty, } from "@nestjs/swagger"; import { Type, Transform, Expose } from "class-transformer"; import { @@ -638,4 +639,13 @@ export class CreateTeamEventTypeInput_2024_06_14 extends BaseCreateEventTypeInpu description: "Rescheduled events will be assigned to the same host as initially scheduled.", }) rescheduleWithSameRoundRobinHost?: boolean; + + @IsBoolean() + @IsOptional() + @DocsPropertyOptional({ + description: + "For round robin event types, enable filtering available hosts to only consider a specified subset of host user IDs. This allows you to book with specific hosts within a round robin event type.", + }) + @ApiHideProperty() + rrHostSubsetEnabled?: boolean; } 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 12e67b972e..a235f12d67 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 @@ -1,4 +1,9 @@ -import { ApiPropertyOptional as DocsPropertyOptional, getSchemaPath, ApiExtraModels } from "@nestjs/swagger"; +import { + ApiPropertyOptional as DocsPropertyOptional, + getSchemaPath, + ApiExtraModels, + ApiHideProperty, +} from "@nestjs/swagger"; import { Type, Transform } from "class-transformer"; import { IsString, @@ -552,4 +557,13 @@ export class UpdateTeamEventTypeInput_2024_06_14 extends BaseUpdateEventTypeInpu description: "Rescheduled events will be assigned to the same host as initially scheduled.", }) rescheduleWithSameRoundRobinHost?: boolean; + + @IsBoolean() + @IsOptional() + @DocsPropertyOptional({ + description: + "For round robin event types, enable filtering available hosts to only consider a specified subset of host user IDs. This allows you to book with specific hosts within a round robin event type.", + }) + @ApiHideProperty() + rrHostSubsetEnabled?: boolean; } 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 240a60f97a..fbfff1a604 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 @@ -3,6 +3,7 @@ import { ApiPropertyOptional, ApiExtraModels, getSchemaPath, + ApiHideProperty, } from "@nestjs/swagger"; import { Type } from "class-transformer"; import { @@ -550,4 +551,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; + + @IsBoolean() + @IsOptional() + @ApiPropertyOptional({ + description: + "For round robin event types, enable filtering available hosts to only consider a specified subset of host user IDs. This allows you to book with specific hosts within a round robin event type.", + }) + @ApiHideProperty() + rrHostSubsetEnabled?: boolean; } diff --git a/packages/platform/types/slots/slots-2024-04-15/inputs/index.ts b/packages/platform/types/slots/slots-2024-04-15/inputs/index.ts index 3fe600534a..d088474650 100644 --- a/packages/platform/types/slots/slots-2024-04-15/inputs/index.ts +++ b/packages/platform/types/slots/slots-2024-04-15/inputs/index.ts @@ -205,6 +205,27 @@ export class GetAvailableSlotsInput_2024_04_15 { @IsOptional() @ApiHideProperty() teamId?: number; + + @Transform(({ value }) => { + if (typeof value === "string") { + return value.split(",").map((id: string) => parseInt(id.trim(), 10)); + } + if (Array.isArray(value)) { + return value.map((id) => (typeof id === "string" ? parseInt(id, 10) : id)); + } + return value; + }) + @IsArray() + @IsNumber({}, { each: true }) + @IsOptional() + @ApiPropertyOptional({ + type: [Number], + description: + "For round robin event types, filter available slots to only consider the specified subset of host user IDs. This allows you to get availability for specific hosts within a round robin event type.", + example: [1, 2, 3], + }) + @ApiHideProperty() + rrHostSubsetIds?: number[]; } export class RemoveSelectedSlotInput_2024_04_15 { diff --git a/packages/platform/types/slots/slots-2024-09-04/inputs/get-slots.input.ts b/packages/platform/types/slots/slots-2024-09-04/inputs/get-slots.input.ts index e22265fc31..1cbdcb3fab 100644 --- a/packages/platform/types/slots/slots-2024-09-04/inputs/get-slots.input.ts +++ b/packages/platform/types/slots/slots-2024-09-04/inputs/get-slots.input.ts @@ -1,4 +1,4 @@ -import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { ApiHideProperty, ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; import { Transform } from "class-transformer"; import { IsDateString, @@ -88,6 +88,27 @@ export class GetAvailableSlotsInput_2024_09_04 { example: "abc123def456", }) bookingUidToReschedule?: string; + + @Transform(({ value }) => { + if (typeof value === "string") { + return value.split(",").map((id: string) => parseInt(id.trim(), 10)); + } + if (Array.isArray(value)) { + return value.map((id) => (typeof id === "string" ? parseInt(id, 10) : id)); + } + return value; + }) + @IsArray() + @IsNumber({}, { each: true }) + @IsOptional() + @ApiPropertyOptional({ + type: [Number], + description: + "For round robin event types, filter available slots to only consider the specified subset of host user IDs. This allows you to get availability for specific hosts within a round robin event type.", + example: [1, 2, 3], + }) + @ApiHideProperty() + rrHostSubsetIds?: number[]; } export const ById_2024_09_04_type = "byEventTypeId"; diff --git a/packages/prisma/migrations/20251205150624_enable_host_subset/migration.sql b/packages/prisma/migrations/20251205150624_enable_host_subset/migration.sql new file mode 100644 index 0000000000..1878c83c58 --- /dev/null +++ b/packages/prisma/migrations/20251205150624_enable_host_subset/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "public"."EventType" ADD COLUMN "rrHostSubsetEnabled" BOOLEAN NOT NULL DEFAULT false; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index dfa785bf36..fb1e9b16fe 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -256,6 +256,7 @@ model EventType { hostGroups HostGroup[] bookingRequiresAuthentication Boolean @default(false) + rrHostSubsetEnabled Boolean @default(false) createdAt DateTime? @default(now()) updatedAt DateTime? @updatedAt diff --git a/packages/trpc/server/routers/viewer/slots/types.ts b/packages/trpc/server/routers/viewer/slots/types.ts index 60cc8ccb38..1654667cb5 100644 --- a/packages/trpc/server/routers/viewer/slots/types.ts +++ b/packages/trpc/server/routers/viewer/slots/types.ts @@ -33,6 +33,7 @@ export const getScheduleSchemaObject = z.object({ teamMemberEmail: z.string().nullish(), routedTeamMemberIds: z.array(z.number()).nullish(), skipContactOwner: z.boolean().nullish(), + rrHostSubsetIds: z.array(z.number()).nullish(), _enableTroubleshooter: z.boolean().optional(), _bypassCalendarBusyTimes: z.boolean().optional(), _silentCalendarFailures: z.boolean().optional(), diff --git a/packages/trpc/server/routers/viewer/slots/util.ts b/packages/trpc/server/routers/viewer/slots/util.ts index fe9b5e73a6..5f9346b11a 100644 --- a/packages/trpc/server/routers/viewer/slots/util.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -28,6 +28,7 @@ import type { IRedisService } from "@calcom/features/redis/IRedisService"; import { buildDateRanges } from "@calcom/features/schedules/lib/date-ranges"; import getSlots from "@calcom/features/schedules/lib/slots"; import type { ScheduleRepository } from "@calcom/features/schedules/repositories/ScheduleRepository"; +import type { NoSlotsNotificationService } from "@calcom/features/slots/handleNotificationWhenNoSlots"; import type { UserRepository } from "@calcom/features/users/repositories/UserRepository"; import { withSelectedCalendars } from "@calcom/features/users/repositories/UserRepository"; import { shouldIgnoreContactOwner } from "@calcom/lib/bookings/routing/utils"; @@ -58,7 +59,6 @@ import type { CredentialForCalendarService } from "@calcom/types/Credential"; import { TRPCError } from "@trpc/server"; import type { TGetScheduleInputSchema } from "./getSchedule.schema"; -import type { NoSlotsNotificationService } from "@calcom/features/slots/handleNotificationWhenNoSlots"; import type { GetScheduleOptions } from "./types"; const log = logger.getSubLogger({ prefix: ["[slots/util]"] }); @@ -148,7 +148,7 @@ function withSlotsCache( } export class AvailableSlotsService { - constructor(public readonly dependencies: IAvailableSlotsService) { } + constructor(public readonly dependencies: IAvailableSlotsService) {} private async _getReservedSlotsAndCleanupExpired({ bookerClientUid, @@ -199,8 +199,8 @@ export class AvailableSlotsService { usernameList: Array.isArray(input.usernameList) ? input.usernameList : input.usernameList - ? [input.usernameList] - : [], + ? [input.usernameList] + : [], }); const usersWithOldSelectedCalendars = usersForDynamicEventType.map((user) => withSelectedCalendars(user)); @@ -790,15 +790,15 @@ export class AvailableSlotsService { const bookingLimits = eventType?.bookingLimits && - typeof eventType?.bookingLimits === "object" && - Object.keys(eventType?.bookingLimits).length > 0 + typeof eventType?.bookingLimits === "object" && + Object.keys(eventType?.bookingLimits).length > 0 ? parseBookingLimit(eventType?.bookingLimits) : null; const durationLimits = eventType?.durationLimits && - typeof eventType?.durationLimits === "object" && - Object.keys(eventType?.durationLimits).length > 0 + typeof eventType?.durationLimits === "object" && + Object.keys(eventType?.durationLimits).length > 0 ? parseDurationLimit(eventType?.durationLimits) : null; @@ -966,9 +966,9 @@ export class AvailableSlotsService { } = input; const orgDetails = input?.orgSlug ? { - currentOrgDomain: input.orgSlug, - isValidOrgDomain: !!input.orgSlug && !RESERVED_SUBDOMAINS.includes(input.orgSlug), - } + currentOrgDomain: input.orgSlug, + isValidOrgDomain: !!input.orgSlug && !RESERVED_SUBDOMAINS.includes(input.orgSlug), + } : orgDomainConfig(ctx?.req); if (process.env.INTEGRATION_TEST_MODE === "true") { @@ -981,7 +981,7 @@ export class AvailableSlotsService { throw new TRPCError({ code: "NOT_FOUND" }); } - const shouldServeCache = false + const shouldServeCache = false; if (isEventTypeLoggingEnabled({ eventTypeId: eventType.id })) { logger.settings.minLevel = 2; } @@ -1042,6 +1042,7 @@ export class AvailableSlotsService { routedTeamMemberIds, contactOwnerEmail, routingFormResponse, + rrHostSubsetIds: input.rrHostSubsetIds ?? undefined, }); const allHosts = [...qualifiedRRHosts, ...fixedHosts]; @@ -1184,10 +1185,10 @@ export class AvailableSlotsService { const travelSchedules = isDefaultSchedule && !eventType.useBookerTimezone ? restrictionSchedule.user.travelSchedules.map((schedule) => ({ - startDate: dayjs(schedule.startDate), - endDate: schedule.endDate ? dayjs(schedule.endDate) : undefined, - timeZone: schedule.timeZone, - })) + startDate: dayjs(schedule.startDate), + endDate: schedule.endDate ? dayjs(schedule.endDate) : undefined, + timeZone: schedule.timeZone, + })) : []; const { dateRanges: restrictionRanges } = buildDateRanges({ @@ -1282,9 +1283,9 @@ export class AvailableSlotsService { ( item: | { - time: dayjs.Dayjs; - userIds?: number[] | undefined; - } + time: dayjs.Dayjs; + userIds?: number[] | undefined; + } | undefined ): item is { time: dayjs.Dayjs; @@ -1453,23 +1454,23 @@ export class AvailableSlotsService { const troubleshooterData = enableTroubleshooter ? { - troubleshooter: { - routedTeamMemberIds: routedTeamMemberIds, - // One that Salesforce asked for - askedContactOwner: contactOwnerEmailFromInput, - // One that we used as per Routing skipContactOwner flag - consideredContactOwner: contactOwnerEmail, - // All hosts that have been checked for availability. If no routedTeamMemberIds are provided, this will be same as hosts. - routedHosts: usersWithCredentials.map((user) => { - return { - userId: user.id, - }; - }), - hostsAfterSegmentMatching: allHosts.map((host) => ({ - userId: host.user.id, - })), - }, - } + troubleshooter: { + routedTeamMemberIds: routedTeamMemberIds, + // One that Salesforce asked for + askedContactOwner: contactOwnerEmailFromInput, + // One that we used as per Routing skipContactOwner flag + consideredContactOwner: contactOwnerEmail, + // All hosts that have been checked for availability. If no routedTeamMemberIds are provided, this will be same as hosts. + routedHosts: usersWithCredentials.map((user) => { + return { + userId: user.id, + }; + }), + hostsAfterSegmentMatching: allHosts.map((host) => ({ + userId: host.user.id, + })), + }, + } : null; return {