fix: api v2 logic for slots reservation endpoint (#23222)

* init logic for handling reservation for round robin event

* remove unused comments

* add function to get already reserved slots

* fixup: add correct logic for round robin slots reservation

* fixup: implement PR feedback

* fixup: update logic to handle fixed and non fixed round robin hosts

* update slots repository

* fixup: implement PR feedback

* fixup: implement PR feedback

* add tests for round robin slot reservation with non fixed hosts

* add tests for round robin slot reservation with fixed and non fixed hosts

* fix: implement PR feedback

* fix: e2e test

* fix: move validateRoundRobinSlotAvailability to core libraries

* fix: merge conflicts

* fix: merge conflicts

* update slots.ts for platform libraries

* fix: import logic from platform libraries

* cleanup

* fix: import path

* fix: missing import

* fix: test code and handle error thrown

---------

Co-authored-by: supalarry <laurisskraucis@gmail.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
This commit is contained in:
Rajiv Sahal
2025-10-10 12:35:21 +02:00
committed by GitHub
co-authored by supalarry Alex van Andel
parent c46f824e3f
commit b286244aab
4 changed files with 315 additions and 4 deletions
@@ -25,7 +25,12 @@ import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { randomString } from "test/utils/randomString";
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_09_04 } from "@calcom/platform-constants";
import {
CAL_API_VERSION_HEADER,
SUCCESS_STATUS,
VERSION_2024_09_04,
ERROR_STATUS,
} from "@calcom/platform-constants";
import type {
CreateScheduleInput_2024_06_11,
ReserveSlotOutput_2024_09_04 as ReserveSlotOutputData_2024_09_04,
@@ -48,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()}`;
let teammateTwoApiKeyString: string;
const outsiderEmail = `slots-2024-09-04-unrelated-team-slots-${randomString()}`;
let outsider: User;
@@ -61,6 +67,8 @@ describe("Slots 2024-09-04 Endpoints", () => {
let collectiveEventTypeSlug: string;
let collectiveEventTypeWithoutHostsId: number;
let roundRobinEventTypeId: number;
let roundRobinEventTypeWithoutFixedHostsId: number;
let roundRobinEventTypeWithFixedAndNonFixedHostsId: number;
let collectiveBookingId: number;
let roundRobinBookingId: number;
let fullyBookedRoundRobinBookingIdOne: number;
@@ -115,6 +123,12 @@ describe("Slots 2024-09-04 Endpoints", () => {
const { keyString } = await apiKeysRepositoryFixture.createApiKey(teammateOne.id, null);
teammateApiKeyString = keyString;
const { keyString: keyStringForTeammateTwo } = await apiKeysRepositoryFixture.createApiKey(
teammateTwo.id,
null
);
teammateTwoApiKeyString = keyStringForTeammateTwo;
const { keyString: unrelatedUserKeyString } = await apiKeysRepositoryFixture.createApiKey(
outsider.id,
null
@@ -217,6 +231,67 @@ describe("Slots 2024-09-04 Endpoints", () => {
});
roundRobinEventTypeId = roundRobinEventType.id;
const roundRobinEventTypeWithoutFixedHosts = await eventTypesRepositoryFixture.createTeamEventType({
schedulingType: "ROUND_ROBIN",
team: {
connect: { id: team.id },
},
title: "RR Event Type Without Fixed Hosts",
slug: `slots-2024-09-04-round-robin-event-type-${randomString()}`,
length: 60,
assignAllTeamMembers: true,
bookingFields: [],
locations: [],
users: {
connect: [{ id: teammateOne.id }, { id: teammateTwo.id }],
},
hosts: {
create: [
{
userId: teammateOne.id,
isFixed: false,
},
{
userId: teammateTwo.id,
isFixed: false,
},
],
},
});
roundRobinEventTypeWithoutFixedHostsId = roundRobinEventTypeWithoutFixedHosts.id;
const roundRobinEventTypeWithFixedAndNonFixedHosts =
await eventTypesRepositoryFixture.createTeamEventType({
schedulingType: "ROUND_ROBIN",
team: {
connect: { id: team.id },
},
title: "RR Event Type With Fixed and Non-Fixed Hosts",
slug: `slots-2024-09-04-round-robin-event-type-${randomString()}`,
length: 60,
assignAllTeamMembers: true,
bookingFields: [],
locations: [],
users: {
connect: [{ id: teammateOne.id }, { id: teammateTwo.id }],
},
hosts: {
create: [
{
userId: teammateOne.id,
isFixed: true,
},
{
userId: teammateTwo.id,
isFixed: false,
},
],
},
});
roundRobinEventTypeWithFixedAndNonFixedHostsId = roundRobinEventTypeWithFixedAndNonFixedHosts.id;
const userSchedule: CreateScheduleInput_2024_06_11 = {
name: "working time",
timeZone: "Europe/Rome",
@@ -541,6 +616,135 @@ describe("Slots 2024-09-04 Endpoints", () => {
bookingsRepositoryFixture.deleteById(bookingTwo.id);
});
it("should reserve all available slots for round robin event type with non-fixed hosts", async () => {
const now = "2049-09-05T12:00:00.000Z";
const newDate = DateTime.fromISO(now, { zone: "UTC" }).toJSDate();
advanceTo(newDate);
const slotStartTime = "2050-09-05T10:00:00.000Z";
const reserveResponseOne = await request(app.getHttpServer())
.post(`/v2/slots/reservations`)
.set({ Authorization: `Bearer cal_test_${teammateApiKeyString}` })
.send({
eventTypeId: roundRobinEventTypeWithoutFixedHostsId,
slotStart: slotStartTime,
reservationDuration: 10,
})
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
.expect(201);
const reserveResponseOneBody: ReserveSlotOutputResponse_2024_09_04 = reserveResponseOne.body;
expect(reserveResponseOneBody.status).toEqual(SUCCESS_STATUS);
const responseReservedSlotOne: ReserveSlotOutputData_2024_09_04 = reserveResponseOneBody.data;
expect(responseReservedSlotOne.reservationUid).toBeDefined();
if (!responseReservedSlotOne.reservationUid) {
throw new Error("Reserved slot one uid is undefined");
}
const dbSlotOne = await selectedSlotRepositoryFixture.getByUid(responseReservedSlotOne.reservationUid);
expect(dbSlotOne).toBeDefined();
if (dbSlotOne) {
const dbReleaseAt = DateTime.fromJSDate(dbSlotOne.releaseAt, { zone: "UTC" }).toISO();
const expectedReleaseAt = DateTime.fromISO(now, { zone: "UTC" }).plus({ minutes: 10 }).toISO();
expect(dbReleaseAt).toEqual(expectedReleaseAt);
}
const reserveResponseTwo = await request(app.getHttpServer())
.post(`/v2/slots/reservations`)
.set({ Authorization: `Bearer cal_test_${teammateTwoApiKeyString}` })
.send({
eventTypeId: roundRobinEventTypeWithoutFixedHostsId,
slotStart: slotStartTime,
reservationDuration: 10,
})
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
.expect(201);
const reserveResponseTwoBody: ReserveSlotOutputResponse_2024_09_04 = reserveResponseTwo.body;
expect(reserveResponseTwoBody.status).toEqual(SUCCESS_STATUS);
const responseReservedSlotTwo: ReserveSlotOutputData_2024_09_04 = reserveResponseTwoBody.data;
expect(responseReservedSlotTwo.reservationUid).toBeDefined();
if (!responseReservedSlotTwo.reservationUid) {
throw new Error("Reserved slot two uid is undefined");
}
const dbSlotTwo = await selectedSlotRepositoryFixture.getByUid(responseReservedSlotTwo.reservationUid);
expect(dbSlotTwo).toBeDefined();
if (dbSlotTwo) {
const dbReleaseAt = DateTime.fromJSDate(dbSlotTwo.releaseAt, { zone: "UTC" }).toISO();
const expectedReleaseAt = DateTime.fromISO(now, { zone: "UTC" }).plus({ minutes: 10 }).toISO();
expect(dbReleaseAt).toEqual(expectedReleaseAt);
}
const reserveResponseThree = await request(app.getHttpServer())
.post(`/v2/slots/reservations`)
.set({ Authorization: `Bearer cal_test_${outsiderApiKeyString}` })
.send({
eventTypeId: roundRobinEventTypeWithoutFixedHostsId,
slotStart: slotStartTime,
reservationDuration: 10,
})
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04);
expect(reserveResponseThree.status).toEqual(403);
expect(reserveResponseThree.body.status).toEqual(ERROR_STATUS);
await selectedSlotRepositoryFixture.deleteByUId(responseReservedSlotOne.reservationUid);
await selectedSlotRepositoryFixture.deleteByUId(responseReservedSlotTwo.reservationUid);
clear();
});
it("should reserve available slot for round robin event type with fixed and non-fixed hosts and should not be able to reserve another slot", async () => {
const now = "2049-09-05T12:00:00.000Z";
const newDate = DateTime.fromISO(now, { zone: "UTC" }).toJSDate();
advanceTo(newDate);
const slotStartTime = "2050-09-05T10:00:00.000Z";
const reserveResponseOne = await request(app.getHttpServer())
.post(`/v2/slots/reservations`)
.set({ Authorization: `Bearer cal_test_${teammateApiKeyString}` })
.send({
eventTypeId: roundRobinEventTypeWithFixedAndNonFixedHostsId,
slotStart: slotStartTime,
reservationDuration: 10,
})
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
.expect(201);
const reserveResponseBodyOne: ReserveSlotOutputResponse_2024_09_04 = reserveResponseOne.body;
expect(reserveResponseBodyOne.status).toEqual(SUCCESS_STATUS);
const responseReservedSlotOne: ReserveSlotOutputData_2024_09_04 = reserveResponseBodyOne.data;
expect(responseReservedSlotOne.reservationUid).toBeDefined();
if (!responseReservedSlotOne.reservationUid) {
throw new Error("Reserved slot uid is undefined");
}
const dbSlotOne = await selectedSlotRepositoryFixture.getByUid(responseReservedSlotOne.reservationUid);
expect(dbSlotOne).toBeDefined();
if (dbSlotOne) {
const dbReleaseAt = DateTime.fromJSDate(dbSlotOne.releaseAt, { zone: "UTC" }).toISO();
const expectedReleaseAt = DateTime.fromISO(now, { zone: "UTC" }).plus({ minutes: 10 }).toISO();
expect(dbReleaseAt).toEqual(expectedReleaseAt);
}
const reserveResponseTwo = await request(app.getHttpServer())
.post(`/v2/slots/reservations`)
.send({
eventTypeId: roundRobinEventTypeWithFixedAndNonFixedHostsId,
slotStart: slotStartTime,
})
.set({ Authorization: `Bearer cal_test_${teammateTwoApiKeyString}` })
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04);
expect(reserveResponseTwo.status).toEqual(422);
expect(reserveResponseTwo.body.status).toEqual(ERROR_STATUS);
await selectedSlotRepositoryFixture.deleteByUId(responseReservedSlotOne.reservationUid);
clear();
});
afterAll(async () => {
await userRepositoryFixture.deleteByEmail(teammateOne.email);
await userRepositoryFixture.deleteByEmail(teammateTwo.email);
@@ -23,6 +23,8 @@ import { DateTime } from "luxon";
import { z } from "zod";
import { SlotFormat } from "@calcom/platform-enums";
import { SchedulingType } from "@calcom/platform-libraries";
import { validateRoundRobinSlotAvailability } from "@calcom/platform-libraries/slots";
import type {
GetSlotsInput_2024_09_04,
GetSlotsInputWithRouting_2024_09_04,
@@ -147,13 +149,26 @@ export class SlotsService_2024_09_04 {
}
const nonSeatedEventAlreadyBooked = !eventType.seatsPerTimeSlot && booking;
if (nonSeatedEventAlreadyBooked) {
const isRoundRobinEvent = eventType.schedulingType === SchedulingType.ROUND_ROBIN;
if (nonSeatedEventAlreadyBooked && !isRoundRobinEvent) {
throw new UnprocessableEntityException(`Can't reserve a slot if the event is already booked.`);
}
const reservationDuration = input.reservationDuration ?? DEFAULT_RESERVATION_DURATION;
if (isRoundRobinEvent) {
try {
await validateRoundRobinSlotAvailability(input.eventTypeId, startDate, endDate, eventType.hosts);
} catch (error) {
if (error instanceof Error) {
throw new UnprocessableEntityException(error?.message);
}
throw error;
}
} else {
await this.checkSlotOverlap(input.eventTypeId, startDate.toISO(), endDate.toISO());
}
await this.checkSlotOverlap(input.eventTypeId, startDate.toISO(), endDate.toISO());
const reservationDuration = input.reservationDuration ?? DEFAULT_RESERVATION_DURATION;
if (eventType.userId) {
const slot = await this.slotsRepository.createSlot(
@@ -0,0 +1,90 @@
import { DateTime } from "luxon";
import { HttpError } from "@calcom/lib/http-error";
import { prisma } from "@calcom/prisma";
import type { Host } from "@calcom/prisma/client";
export async function validateRoundRobinSlotAvailability(
eventTypeId: number,
startDate: DateTime,
endDate: DateTime,
hosts: Host[]
) {
const fixedHosts = hosts.filter((host) => host.isFixed === true);
const nonFixedHosts = hosts.filter((host) => host.isFixed === false);
if (fixedHosts.length > 0) {
await validateFixedHostsAvailability(eventTypeId, startDate, endDate, fixedHosts);
} else {
await validateNonFixedHostsAvailability(eventTypeId, startDate, endDate, nonFixedHosts);
}
}
async function validateFixedHostsAvailability(
eventTypeId: number,
startDate: DateTime,
endDate: DateTime,
hosts: Host[]
) {
const existingBooking = await prisma.booking.findFirst({
where: {
eventTypeId,
startTime: startDate.toJSDate(),
endTime: endDate.toJSDate(),
},
select: { attendees: true, userId: true, status: true },
});
const existingSlotReservation = await prisma.selectedSlots.count({
where: {
eventTypeId,
slotUtcStartDate: startDate.toISO() ?? "",
slotUtcEndDate: endDate.toISO() ?? "",
// Only consider non-expired reservations
releaseAt: { gt: DateTime.utc().toJSDate() },
},
});
const hasHostAsAttendee = hosts.some(
(host) =>
existingBooking?.attendees.some((attendee) => attendee.id === host.userId) ||
existingBooking?.userId === host.userId
);
if (existingSlotReservation > 0) {
throw new HttpError({
statusCode: 422,
message: `Can't reserve the slot because the round robin event type has no available hosts left at this time slot.`,
});
}
if (hasHostAsAttendee) {
throw new HttpError({
statusCode: 422,
message: `Can't reserve a slot if the event is already booked.`,
});
}
}
async function validateNonFixedHostsAvailability(
eventTypeId: number,
startDate: DateTime,
endDate: DateTime,
hosts: Host[]
) {
const existingSlotReservations = await prisma.selectedSlots.count({
where: {
eventTypeId,
slotUtcStartDate: startDate.toISO() ?? "",
slotUtcEndDate: endDate.toISO() ?? "",
// Only consider non-expired reservations
releaseAt: { gt: DateTime.utc().toJSDate() },
},
});
if (existingSlotReservations === hosts.length) {
throw new HttpError({
statusCode: 422,
message: `Can't reserve the slot because the round robin event type has no available hosts left at this time slot.`,
});
}
}
+2
View File
@@ -1,3 +1,4 @@
import { validateRoundRobinSlotAvailability } from "@calcom/features/ee/round-robin/utils/validateRoundRobinSlotAvailability";
import { FilterHostsService } from "@calcom/features/bookings/lib/host-filtering/filterHostsBySameRoundRobinHost";
import { QualifiedHostsService } from "@calcom/features/bookings/lib/host-filtering/findQualifiedHostsWithDelegationCredentials";
import { BusyTimesService } from "@calcom/lib/getBusyTimes";
@@ -12,3 +13,4 @@ export { QualifiedHostsService };
export { FilterHostsService };
export { NoSlotsNotificationService };
export { validateRoundRobinSlotAvailability };