Skip to content

Commit 6b62557

Browse files
feat: add hostSubsetIds parameter for round robin host filtering (calcom#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 <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>
1 parent 300b9c9 commit 6b62557

33 files changed

Lines changed: 673 additions & 84 deletions

File tree

apps/api/v2/src/ee/bookings/2024-04-15/inputs/create-booking.input.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
ValidateNested,
1212
isEmail,
1313
Validate,
14+
IsInt,
1415
} from "class-validator";
1516
import { ValidationOptions, registerDecorator } from "class-validator";
1617

@@ -26,7 +27,7 @@ function ValidateBookingName(validationOptions?: ValidationOptions) {
2627
propertyName: propertyName,
2728
options: validationOptions,
2829
validator: {
29-
validate(value: any) {
30+
validate(value: string | Record<string, string>): boolean {
3031
if (typeof value === "string") {
3132
return value.trim().length > 0;
3233
}
@@ -226,4 +227,16 @@ export class CreateBookingInput_2024_04_15 {
226227
@IsOptional()
227228
@ApiPropertyOptional()
228229
crmOwnerRecordType?: string;
230+
231+
@ApiPropertyOptional({
232+
type: [Number],
233+
description:
234+
"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.",
235+
example: [1, 2, 3],
236+
})
237+
@ApiHideProperty()
238+
@IsOptional()
239+
@IsArray()
240+
@IsInt({ each: true })
241+
rrHostSubsetIds?: number[];
229242
}

apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/team-bookings.e2e-spec.ts

Lines changed: 206 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,12 +64,14 @@ describe("Bookings Endpoints 2024-08-13", () => {
6464

6565
let team1EventTypeId: number;
6666
let team2EventTypeId: number;
67+
let team2RREventTypeId: number;
6768
let phoneOnlyEventTypeId: number;
6869
let collectiveEventWithoutHostsId: number;
6970
let roundRobinEventWithoutHostsId: number;
7071

7172
const team1EventTypeSlug = `team-bookings-event-type-${randomString()}`;
7273
const team2EventTypeSlug = `team-bookings-event-type-${randomString()}`;
74+
const team2RREventTypeSlug = `team-bookings-rr-event-type-${randomString()}`;
7375
const phoneOnlyEventTypeSlug = `team-bookings-event-type-${randomString()}`;
7476

7577
let phoneBasedBooking: BookingOutput_2024_08_13;
@@ -339,6 +341,50 @@ describe("Bookings Endpoints 2024-08-13", () => {
339341

340342
team2EventTypeId = team2EventType.id;
341343

344+
const team2RREventType = await eventTypesRepositoryFixture.createTeamEventType({
345+
schedulingType: "ROUND_ROBIN",
346+
team: {
347+
connect: { id: team2.id },
348+
},
349+
title: `team-bookings-2024-08-13-event-type-rr-${randomString()}`,
350+
slug: team2RREventTypeSlug,
351+
length: 60,
352+
assignAllTeamMembers: true,
353+
bookingFields: [],
354+
locations: [],
355+
rrHostSubsetEnabled: true,
356+
});
357+
358+
team2RREventTypeId = team2RREventType.id;
359+
360+
await hostsRepositoryFixture.create({
361+
isFixed: false,
362+
user: {
363+
connect: {
364+
id: teamUser2.id,
365+
},
366+
},
367+
eventType: {
368+
connect: {
369+
id: team2RREventType.id,
370+
},
371+
},
372+
});
373+
374+
await hostsRepositoryFixture.create({
375+
isFixed: true,
376+
user: {
377+
connect: {
378+
id: teamUser.id,
379+
},
380+
},
381+
eventType: {
382+
connect: {
383+
id: team2RREventType.id,
384+
},
385+
},
386+
});
387+
342388
await hostsRepositoryFixture.create({
343389
isFixed: true,
344390
user: {
@@ -603,6 +649,163 @@ describe("Bookings Endpoints 2024-08-13", () => {
603649
}
604650
});
605651
});
652+
653+
it("should create a team 2 RR booking and use rrHostSubsetIds to force teamUser2 as host ", async () => {
654+
const body: CreateBookingInput_2024_08_13 = {
655+
start: new Date(Date.UTC(2030, 0, 8, 12, 0, 0)).toISOString(),
656+
eventTypeId: team2RREventTypeId,
657+
attendee: {
658+
name: "bob2",
659+
email: "bob2@gmail.com",
660+
timeZone: "Europe/Rome",
661+
language: "it",
662+
},
663+
meetingUrl: "https://meet.google.com/abc-def-ghi",
664+
rrHostSubsetIds: [teamUser2.id],
665+
};
666+
667+
return request(app.getHttpServer())
668+
.post("/v2/bookings")
669+
.send(body)
670+
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
671+
.expect(201)
672+
.then(async (response) => {
673+
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
674+
expect(responseBody.status).toEqual(SUCCESS_STATUS);
675+
expect(responseBody.data).toBeDefined();
676+
expect(responseDataIsBooking(responseBody.data)).toBe(true);
677+
678+
if (responseDataIsBooking(responseBody.data)) {
679+
const data: BookingOutput_2024_08_13 = responseBody.data;
680+
expect(data.id).toBeDefined();
681+
expect(data.uid).toBeDefined();
682+
expect(data.hosts.length).toEqual(1);
683+
expect(data.hosts[0].id).toEqual(teamUser2.id);
684+
expect(data.status).toEqual("accepted");
685+
expect(data.start).toEqual(body.start);
686+
expect(data.end).toEqual(new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString());
687+
expect(data.duration).toEqual(60);
688+
expect(data.eventTypeId).toEqual(team2RREventTypeId);
689+
expect(data.attendees.length).toEqual(1);
690+
expect(data.attendees[0]).toEqual({
691+
name: body.attendee.name,
692+
email: body.attendee.email,
693+
timeZone: body.attendee.timeZone,
694+
language: body.attendee.language,
695+
absent: false,
696+
});
697+
expect(data.meetingUrl).toEqual(body.meetingUrl);
698+
expect(data.absentHost).toEqual(false);
699+
} else {
700+
throw new Error(
701+
"Invalid response data - expected booking but received array of possibly recurring bookings"
702+
);
703+
}
704+
});
705+
});
706+
707+
it("should create a team 2 RR booking and use rrHostSubsetIds to force teamUser as host ", async () => {
708+
const body: CreateBookingInput_2024_08_13 = {
709+
start: new Date(Date.UTC(2030, 0, 8, 12, 0, 0)).toISOString(),
710+
eventTypeId: team2RREventTypeId,
711+
attendee: {
712+
name: "bob",
713+
email: "bob@gmail.com",
714+
timeZone: "Europe/Rome",
715+
language: "it",
716+
},
717+
meetingUrl: "https://meet.google.com/abc-def-ghi",
718+
rrHostSubsetIds: [teamUser.id],
719+
};
720+
721+
return request(app.getHttpServer())
722+
.post("/v2/bookings")
723+
.send(body)
724+
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
725+
.expect(201)
726+
.then(async (response) => {
727+
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
728+
expect(responseBody.status).toEqual(SUCCESS_STATUS);
729+
expect(responseBody.data).toBeDefined();
730+
expect(responseDataIsBooking(responseBody.data)).toBe(true);
731+
732+
if (responseDataIsBooking(responseBody.data)) {
733+
const data: BookingOutput_2024_08_13 = responseBody.data;
734+
expect(data.id).toBeDefined();
735+
expect(data.uid).toBeDefined();
736+
expect(data.hosts.length).toEqual(1);
737+
expect(data.hosts[0].id).toEqual(teamUser.id);
738+
expect(data.status).toEqual("accepted");
739+
expect(data.start).toEqual(body.start);
740+
expect(data.end).toEqual(new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString());
741+
expect(data.duration).toEqual(60);
742+
expect(data.eventTypeId).toEqual(team2RREventTypeId);
743+
expect(data.attendees.length).toEqual(1);
744+
expect(data.attendees[0]).toEqual({
745+
name: body.attendee.name,
746+
email: body.attendee.email,
747+
timeZone: body.attendee.timeZone,
748+
language: body.attendee.language,
749+
absent: false,
750+
});
751+
expect(data.meetingUrl).toEqual(body.meetingUrl);
752+
expect(data.absentHost).toEqual(false);
753+
} else {
754+
throw new Error(
755+
"Invalid response data - expected booking but received array of possibly recurring bookings"
756+
);
757+
}
758+
});
759+
});
760+
761+
it("should create a team 2 RR booking and use rrHostSubsetIds to force teamUser and teamUser2 as host ", async () => {
762+
const body: CreateBookingInput_2024_08_13 = {
763+
start: new Date(Date.UTC(2030, 0, 8, 14, 0, 0)).toISOString(),
764+
eventTypeId: team2RREventTypeId,
765+
attendee: {
766+
name: "bob",
767+
email: "bob@gmail.com",
768+
timeZone: "Europe/Rome",
769+
language: "it",
770+
},
771+
meetingUrl: "https://meet.google.com/abc-def-ghi",
772+
rrHostSubsetIds: [teamUser.id, teamUser2.id],
773+
};
774+
775+
return request(app.getHttpServer())
776+
.post("/v2/bookings")
777+
.send(body)
778+
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
779+
.expect(201)
780+
.then(async (response) => {
781+
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
782+
expect(responseBody.status).toEqual(SUCCESS_STATUS);
783+
expect(responseBody.data).toBeDefined();
784+
expect(responseDataIsBooking(responseBody.data)).toBe(true);
785+
786+
if (responseDataIsBooking(responseBody.data)) {
787+
const data: BookingOutput_2024_08_13 = responseBody.data;
788+
expect(data.id).toBeDefined();
789+
expect(data.uid).toBeDefined();
790+
expect(data.hosts.length).toEqual(1);
791+
expect(data.hosts[0].id).toEqual(teamUser.id);
792+
expect(data.status).toEqual("accepted");
793+
expect(data.start).toEqual(body.start);
794+
expect(data.end).toEqual(new Date(Date.UTC(2030, 0, 8, 15, 0, 0)).toISOString());
795+
expect(data.duration).toEqual(60);
796+
expect(data.eventTypeId).toEqual(team2RREventTypeId);
797+
expect(data.attendees.length).toEqual(2);
798+
expect(data.attendees.find((a) => a.email === body.attendee.email)).toBeDefined();
799+
expect(data.attendees.find((a) => a.email === teamUser2.email)).toBeDefined();
800+
expect(data.meetingUrl).toEqual(body.meetingUrl);
801+
expect(data.absentHost).toEqual(false);
802+
} else {
803+
throw new Error(
804+
"Invalid response data - expected booking but received array of possibly recurring bookings"
805+
);
806+
}
807+
});
808+
});
606809
});
607810

608811
describe("get team bookings", () => {
@@ -639,7 +842,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
639842
| RecurringBookingOutput_2024_08_13
640843
| GetSeatedBookingOutput_2024_08_13
641844
)[] = responseBody.data;
642-
expect(data.length).toEqual(1);
845+
expect(data.length).toEqual(3);
643846
expect(data[0].eventTypeId).toEqual(team2EventTypeId);
644847
});
645848
});
@@ -684,7 +887,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
684887
| RecurringBookingOutput_2024_08_13
685888
| GetSeatedBookingOutput_2024_08_13
686889
)[] = responseBody.data;
687-
expect(data.length).toEqual(3);
890+
expect(data.length).toEqual(5);
688891
expect(data.find((booking) => booking.eventTypeId === team1EventTypeId)).toBeDefined();
689892
expect(data.find((booking) => booking.eventTypeId === team2EventTypeId)).toBeDefined();
690893
});
@@ -845,6 +1048,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
8451048
await userRepositoryFixture.deleteByEmail(teamUser.email);
8461049
await userRepositoryFixture.deleteByEmail(teamUserEmail2);
8471050
await bookingsRepositoryFixture.deleteAllBookings(teamUser.id, teamUser.email);
1051+
await bookingsRepositoryFixture.deleteAllBookings(teamUser2.id, teamUser2.email);
8481052
await app.close();
8491053
});
8501054
});

apps/api/v2/src/ee/bookings/2024-08-13/services/input.service.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,7 @@ export class InputBookingsService_2024_08_13 {
188188
location,
189189
},
190190
...this.getRoutingFormData(inputBooking.routing),
191+
rrHostSubsetIds: inputBooking.rrHostSubsetIds,
191192
};
192193
}
193194

@@ -484,6 +485,7 @@ export class InputBookingsService_2024_08_13 {
484485
},
485486
schedulingType: eventType.schedulingType,
486487
...this.getRoutingFormData(inputBooking.routing),
488+
rrHostSubsetIds: inputBooking.rrHostSubsetIds,
487489
});
488490

489491
switch (timeBetween) {

apps/api/v2/src/ee/event-types/event-types_2024_06_14/controllers/event-types.controller.e2e-spec.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1486,6 +1486,7 @@ describe("Event types Endpoints", () => {
14861486
locations: [],
14871487
schedulingType: "COLLECTIVE",
14881488
team: { connect: { id: team.id } },
1489+
rrHostSubsetEnabled: true,
14891490
});
14901491

14911492
return request(app.getHttpServer())
@@ -1498,6 +1499,7 @@ describe("Event types Endpoints", () => {
14981499
expect(responseBody.status).toEqual(SUCCESS_STATUS);
14991500
expect(responseBody.data.id).toEqual(teamEventType.id);
15001501
expect(responseBody.data.teamId).toEqual(team.id);
1502+
expect(responseBody.data.rrHostSubsetEnabled).toEqual(true);
15011503
await teamRepositoryFixture.delete(team.id);
15021504
});
15031505
});

apps/api/v2/src/modules/organizations/event-types/organizations-member-team-admin-event-types.e2e-spec.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,6 +389,7 @@ describe("Organizations Event Types Endpoints", () => {
389389
expect(data.hideOrganizerEmail).toEqual(body.hideOrganizerEmail);
390390
expect(data.lockTimeZoneToggleOnBookingPage).toEqual(body.lockTimeZoneToggleOnBookingPage);
391391
expect(data.color).toEqual(body.color);
392+
expect(data.rrHostSubsetEnabled).toEqual(false);
392393
expect(data.successRedirectUrl).toEqual("https://masterchief.com/argentina/flan/video/1234");
393394
expect(data.emailSettings).toEqual(body.emailSettings);
394395
collectiveEventType = responseBody.data;
@@ -537,6 +538,7 @@ describe("Organizations Event Types Endpoints", () => {
537538
const data = responseBody.data;
538539
expect(data.title).toEqual(collectiveEventType.title);
539540
expect(data.hosts.length).toEqual(2);
541+
expect(data.rrHostSubsetEnabled).toEqual(false);
540542
evaluateHost(collectiveEventType.hosts[0], data.hosts[0]);
541543
evaluateHost(collectiveEventType.hosts[1], data.hosts[1]);
542544

@@ -1142,6 +1144,7 @@ describe("Organizations Event Types Endpoints", () => {
11421144
hideCalendarEventDetails: true,
11431145
hideOrganizerEmail: true,
11441146
lockTimeZoneToggleOnBookingPage: true,
1147+
rrHostSubsetEnabled: true,
11451148
color: {
11461149
darkThemeHex: "#292929",
11471150
lightThemeHex: "#fafafa",
@@ -1175,6 +1178,7 @@ describe("Organizations Event Types Endpoints", () => {
11751178
expect(data.hideOrganizerEmail).toEqual(body.hideOrganizerEmail);
11761179
expect(data.lockTimeZoneToggleOnBookingPage).toEqual(body.lockTimeZoneToggleOnBookingPage);
11771180
expect(data.color).toEqual(body.color);
1181+
expect(data.rrHostSubsetEnabled).toEqual(true);
11781182
expect(data.successRedirectUrl).toEqual("https://masterchief.com/argentina/flan/video/1234");
11791183
collectiveEventType = responseBody.data;
11801184
});

apps/api/v2/src/modules/organizations/event-types/services/output.service.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ type Input = Pick<
8888
| "rescheduleWithSameRoundRobinHost"
8989
| "maxActiveBookingPerBookerOfferReschedule"
9090
| "maxActiveBookingsPerBooker"
91+
| "rrHostSubsetEnabled"
9192
>;
9293

9394
@Injectable()
@@ -103,8 +104,14 @@ export class OutputOrganizationsEventTypesService {
103104

104105
const emailSettings = this.transformEmailSettings(metadata);
105106

106-
const { teamId, userId, parentId, assignAllTeamMembers, rescheduleWithSameRoundRobinHost } =
107-
databaseEventType;
107+
const {
108+
teamId,
109+
userId,
110+
parentId,
111+
assignAllTeamMembers,
112+
rescheduleWithSameRoundRobinHost,
113+
rrHostSubsetEnabled,
114+
} = databaseEventType;
108115
// eslint-disable-next-line @typescript-eslint/no-unused-vars
109116
const { ownerId, users, ...rest } = this.outputEventTypesService.getResponseEventType(
110117
0,
@@ -139,6 +146,7 @@ export class OutputOrganizationsEventTypesService {
139146
theme: databaseEventType?.team?.theme,
140147
},
141148
rescheduleWithSameRoundRobinHost,
149+
rrHostSubsetEnabled,
142150
};
143151
}
144152

0 commit comments

Comments
 (0)