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 <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>
This commit is contained in:
Morgan
2025-12-10 10:37:39 +00:00
committed by GitHub
co-authored by morgan@cal.com Devin AI
parent 300b9c916d
commit 6b62557a92
33 changed files with 673 additions and 84 deletions
@@ -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<string, string>): 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[];
}
@@ -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();
});
});
@@ -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) {
@@ -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);
});
});
@@ -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;
});
@@ -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,
};
}
@@ -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);
@@ -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,
};
}
@@ -258,6 +258,7 @@ describe("handleChildrenEventTypes", () => {
requiresBookerEmailVerification: false,
useBookerTimezone: false,
restrictionScheduleId: null,
hashedLink: {
deleteMany: {},
},
+85 -2
View File
@@ -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"
}
@@ -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(),
@@ -181,6 +181,7 @@ const getEventTypesFromDBSelect = {
name: true,
},
},
rrHostSubsetEnabled: true,
instantMeetingExpiryTimeOffsetInSeconds: true,
autoTranslateInstantMeetingTitleEnabled: true,
} satisfies Prisma.EventTypeSelect;
@@ -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) => {
@@ -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 = <T extends { isFixed: boolean }>(host: T): host is T & { isF
return host.isFixed;
};
const isWithinRRHostSubset = <T extends { isFixed: boolean; user: { id: number } }>(
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(
@@ -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.
@@ -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,
@@ -153,6 +153,7 @@ const commons = {
bookingRequiresAuthentication: false,
createdAt: null,
updatedAt: null,
rrHostSubsetEnabled: false,
};
export const dynamicEvent = {
@@ -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,
+1
View File
@@ -165,6 +165,7 @@ export const buildEventType = (eventType?: Partial<EventType>): EventType => {
bookingRequiresAuthentication: false,
createdAt: null,
updatedAt: null,
rrHostSubsetEnabled: false,
...eventType,
};
};
@@ -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(
+7 -6
View File
@@ -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[];
};
@@ -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);
@@ -29,6 +29,7 @@ export const useAvailableSlots = ({
rest.routedTeamMemberIds,
rest.skipContactOwner,
rest.teamMemberEmail,
rest.rrHostSubsetIds,
],
queryFn: () => {
return http
@@ -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 {
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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 {
@@ -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";
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "public"."EventType" ADD COLUMN "rrHostSubsetEnabled" BOOLEAN NOT NULL DEFAULT false;
+1
View File
@@ -256,6 +256,7 @@ model EventType {
hostGroups HostGroup[]
bookingRequiresAuthentication Boolean @default(false)
rrHostSubsetEnabled Boolean @default(false)
createdAt DateTime? @default(now())
updatedAt DateTime? @updatedAt
@@ -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(),
@@ -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 {