Skip to content

Commit b286244

Browse files
Ryukemeistersupalarryemrysal
authored
fix: api v2 logic for slots reservation endpoint (calcom#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>
1 parent c46f824 commit b286244

4 files changed

Lines changed: 315 additions & 4 deletions

File tree

apps/api/v2/src/modules/slots/slots-2024-09-04/controllers/e2e/team-event-type-slots.controller.e2e-spec.ts

Lines changed: 205 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,12 @@ import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.
2525
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
2626
import { randomString } from "test/utils/randomString";
2727

28-
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_09_04 } from "@calcom/platform-constants";
28+
import {
29+
CAL_API_VERSION_HEADER,
30+
SUCCESS_STATUS,
31+
VERSION_2024_09_04,
32+
ERROR_STATUS,
33+
} from "@calcom/platform-constants";
2934
import type {
3035
CreateScheduleInput_2024_06_11,
3136
ReserveSlotOutput_2024_09_04 as ReserveSlotOutputData_2024_09_04,
@@ -48,6 +53,7 @@ describe("Slots 2024-09-04 Endpoints", () => {
4853
const teammateEmailOne = `slots-2024-09-04-user-1-team-slots-${randomString()}`;
4954
let teammateApiKeyString: string;
5055
const teammateEmailTwo = `slots-2024-09-04-user-2-team-slots-${randomString()}`;
56+
let teammateTwoApiKeyString: string;
5157

5258
const outsiderEmail = `slots-2024-09-04-unrelated-team-slots-${randomString()}`;
5359
let outsider: User;
@@ -61,6 +67,8 @@ describe("Slots 2024-09-04 Endpoints", () => {
6167
let collectiveEventTypeSlug: string;
6268
let collectiveEventTypeWithoutHostsId: number;
6369
let roundRobinEventTypeId: number;
70+
let roundRobinEventTypeWithoutFixedHostsId: number;
71+
let roundRobinEventTypeWithFixedAndNonFixedHostsId: number;
6472
let collectiveBookingId: number;
6573
let roundRobinBookingId: number;
6674
let fullyBookedRoundRobinBookingIdOne: number;
@@ -115,6 +123,12 @@ describe("Slots 2024-09-04 Endpoints", () => {
115123
const { keyString } = await apiKeysRepositoryFixture.createApiKey(teammateOne.id, null);
116124
teammateApiKeyString = keyString;
117125

126+
const { keyString: keyStringForTeammateTwo } = await apiKeysRepositoryFixture.createApiKey(
127+
teammateTwo.id,
128+
null
129+
);
130+
teammateTwoApiKeyString = keyStringForTeammateTwo;
131+
118132
const { keyString: unrelatedUserKeyString } = await apiKeysRepositoryFixture.createApiKey(
119133
outsider.id,
120134
null
@@ -217,6 +231,67 @@ describe("Slots 2024-09-04 Endpoints", () => {
217231
});
218232
roundRobinEventTypeId = roundRobinEventType.id;
219233

234+
const roundRobinEventTypeWithoutFixedHosts = await eventTypesRepositoryFixture.createTeamEventType({
235+
schedulingType: "ROUND_ROBIN",
236+
team: {
237+
connect: { id: team.id },
238+
},
239+
title: "RR Event Type Without Fixed Hosts",
240+
slug: `slots-2024-09-04-round-robin-event-type-${randomString()}`,
241+
length: 60,
242+
assignAllTeamMembers: true,
243+
bookingFields: [],
244+
locations: [],
245+
users: {
246+
connect: [{ id: teammateOne.id }, { id: teammateTwo.id }],
247+
},
248+
hosts: {
249+
create: [
250+
{
251+
userId: teammateOne.id,
252+
isFixed: false,
253+
},
254+
{
255+
userId: teammateTwo.id,
256+
isFixed: false,
257+
},
258+
],
259+
},
260+
});
261+
262+
roundRobinEventTypeWithoutFixedHostsId = roundRobinEventTypeWithoutFixedHosts.id;
263+
264+
const roundRobinEventTypeWithFixedAndNonFixedHosts =
265+
await eventTypesRepositoryFixture.createTeamEventType({
266+
schedulingType: "ROUND_ROBIN",
267+
team: {
268+
connect: { id: team.id },
269+
},
270+
title: "RR Event Type With Fixed and Non-Fixed Hosts",
271+
slug: `slots-2024-09-04-round-robin-event-type-${randomString()}`,
272+
length: 60,
273+
assignAllTeamMembers: true,
274+
bookingFields: [],
275+
locations: [],
276+
users: {
277+
connect: [{ id: teammateOne.id }, { id: teammateTwo.id }],
278+
},
279+
hosts: {
280+
create: [
281+
{
282+
userId: teammateOne.id,
283+
isFixed: true,
284+
},
285+
{
286+
userId: teammateTwo.id,
287+
isFixed: false,
288+
},
289+
],
290+
},
291+
});
292+
293+
roundRobinEventTypeWithFixedAndNonFixedHostsId = roundRobinEventTypeWithFixedAndNonFixedHosts.id;
294+
220295
const userSchedule: CreateScheduleInput_2024_06_11 = {
221296
name: "working time",
222297
timeZone: "Europe/Rome",
@@ -541,6 +616,135 @@ describe("Slots 2024-09-04 Endpoints", () => {
541616
bookingsRepositoryFixture.deleteById(bookingTwo.id);
542617
});
543618

619+
it("should reserve all available slots for round robin event type with non-fixed hosts", async () => {
620+
const now = "2049-09-05T12:00:00.000Z";
621+
const newDate = DateTime.fromISO(now, { zone: "UTC" }).toJSDate();
622+
advanceTo(newDate);
623+
624+
const slotStartTime = "2050-09-05T10:00:00.000Z";
625+
626+
const reserveResponseOne = await request(app.getHttpServer())
627+
.post(`/v2/slots/reservations`)
628+
.set({ Authorization: `Bearer cal_test_${teammateApiKeyString}` })
629+
.send({
630+
eventTypeId: roundRobinEventTypeWithoutFixedHostsId,
631+
slotStart: slotStartTime,
632+
reservationDuration: 10,
633+
})
634+
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
635+
.expect(201);
636+
637+
const reserveResponseOneBody: ReserveSlotOutputResponse_2024_09_04 = reserveResponseOne.body;
638+
expect(reserveResponseOneBody.status).toEqual(SUCCESS_STATUS);
639+
const responseReservedSlotOne: ReserveSlotOutputData_2024_09_04 = reserveResponseOneBody.data;
640+
expect(responseReservedSlotOne.reservationUid).toBeDefined();
641+
if (!responseReservedSlotOne.reservationUid) {
642+
throw new Error("Reserved slot one uid is undefined");
643+
}
644+
645+
const dbSlotOne = await selectedSlotRepositoryFixture.getByUid(responseReservedSlotOne.reservationUid);
646+
expect(dbSlotOne).toBeDefined();
647+
if (dbSlotOne) {
648+
const dbReleaseAt = DateTime.fromJSDate(dbSlotOne.releaseAt, { zone: "UTC" }).toISO();
649+
const expectedReleaseAt = DateTime.fromISO(now, { zone: "UTC" }).plus({ minutes: 10 }).toISO();
650+
expect(dbReleaseAt).toEqual(expectedReleaseAt);
651+
}
652+
653+
const reserveResponseTwo = await request(app.getHttpServer())
654+
.post(`/v2/slots/reservations`)
655+
.set({ Authorization: `Bearer cal_test_${teammateTwoApiKeyString}` })
656+
.send({
657+
eventTypeId: roundRobinEventTypeWithoutFixedHostsId,
658+
slotStart: slotStartTime,
659+
reservationDuration: 10,
660+
})
661+
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
662+
.expect(201);
663+
664+
const reserveResponseTwoBody: ReserveSlotOutputResponse_2024_09_04 = reserveResponseTwo.body;
665+
expect(reserveResponseTwoBody.status).toEqual(SUCCESS_STATUS);
666+
const responseReservedSlotTwo: ReserveSlotOutputData_2024_09_04 = reserveResponseTwoBody.data;
667+
expect(responseReservedSlotTwo.reservationUid).toBeDefined();
668+
if (!responseReservedSlotTwo.reservationUid) {
669+
throw new Error("Reserved slot two uid is undefined");
670+
}
671+
672+
const dbSlotTwo = await selectedSlotRepositoryFixture.getByUid(responseReservedSlotTwo.reservationUid);
673+
expect(dbSlotTwo).toBeDefined();
674+
if (dbSlotTwo) {
675+
const dbReleaseAt = DateTime.fromJSDate(dbSlotTwo.releaseAt, { zone: "UTC" }).toISO();
676+
const expectedReleaseAt = DateTime.fromISO(now, { zone: "UTC" }).plus({ minutes: 10 }).toISO();
677+
expect(dbReleaseAt).toEqual(expectedReleaseAt);
678+
}
679+
680+
const reserveResponseThree = await request(app.getHttpServer())
681+
.post(`/v2/slots/reservations`)
682+
.set({ Authorization: `Bearer cal_test_${outsiderApiKeyString}` })
683+
.send({
684+
eventTypeId: roundRobinEventTypeWithoutFixedHostsId,
685+
slotStart: slotStartTime,
686+
reservationDuration: 10,
687+
})
688+
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04);
689+
690+
expect(reserveResponseThree.status).toEqual(403);
691+
expect(reserveResponseThree.body.status).toEqual(ERROR_STATUS);
692+
693+
await selectedSlotRepositoryFixture.deleteByUId(responseReservedSlotOne.reservationUid);
694+
await selectedSlotRepositoryFixture.deleteByUId(responseReservedSlotTwo.reservationUid);
695+
clear();
696+
});
697+
698+
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 () => {
699+
const now = "2049-09-05T12:00:00.000Z";
700+
const newDate = DateTime.fromISO(now, { zone: "UTC" }).toJSDate();
701+
advanceTo(newDate);
702+
703+
const slotStartTime = "2050-09-05T10:00:00.000Z";
704+
705+
const reserveResponseOne = await request(app.getHttpServer())
706+
.post(`/v2/slots/reservations`)
707+
.set({ Authorization: `Bearer cal_test_${teammateApiKeyString}` })
708+
.send({
709+
eventTypeId: roundRobinEventTypeWithFixedAndNonFixedHostsId,
710+
slotStart: slotStartTime,
711+
reservationDuration: 10,
712+
})
713+
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
714+
.expect(201);
715+
716+
const reserveResponseBodyOne: ReserveSlotOutputResponse_2024_09_04 = reserveResponseOne.body;
717+
expect(reserveResponseBodyOne.status).toEqual(SUCCESS_STATUS);
718+
const responseReservedSlotOne: ReserveSlotOutputData_2024_09_04 = reserveResponseBodyOne.data;
719+
expect(responseReservedSlotOne.reservationUid).toBeDefined();
720+
if (!responseReservedSlotOne.reservationUid) {
721+
throw new Error("Reserved slot uid is undefined");
722+
}
723+
724+
const dbSlotOne = await selectedSlotRepositoryFixture.getByUid(responseReservedSlotOne.reservationUid);
725+
expect(dbSlotOne).toBeDefined();
726+
if (dbSlotOne) {
727+
const dbReleaseAt = DateTime.fromJSDate(dbSlotOne.releaseAt, { zone: "UTC" }).toISO();
728+
const expectedReleaseAt = DateTime.fromISO(now, { zone: "UTC" }).plus({ minutes: 10 }).toISO();
729+
expect(dbReleaseAt).toEqual(expectedReleaseAt);
730+
}
731+
732+
const reserveResponseTwo = await request(app.getHttpServer())
733+
.post(`/v2/slots/reservations`)
734+
.send({
735+
eventTypeId: roundRobinEventTypeWithFixedAndNonFixedHostsId,
736+
slotStart: slotStartTime,
737+
})
738+
.set({ Authorization: `Bearer cal_test_${teammateTwoApiKeyString}` })
739+
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04);
740+
741+
expect(reserveResponseTwo.status).toEqual(422);
742+
expect(reserveResponseTwo.body.status).toEqual(ERROR_STATUS);
743+
744+
await selectedSlotRepositoryFixture.deleteByUId(responseReservedSlotOne.reservationUid);
745+
clear();
746+
});
747+
544748
afterAll(async () => {
545749
await userRepositoryFixture.deleteByEmail(teammateOne.email);
546750
await userRepositoryFixture.deleteByEmail(teammateTwo.email);

apps/api/v2/src/modules/slots/slots-2024-09-04/services/slots.service.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ import { DateTime } from "luxon";
2323
import { z } from "zod";
2424

2525
import { SlotFormat } from "@calcom/platform-enums";
26+
import { SchedulingType } from "@calcom/platform-libraries";
27+
import { validateRoundRobinSlotAvailability } from "@calcom/platform-libraries/slots";
2628
import type {
2729
GetSlotsInput_2024_09_04,
2830
GetSlotsInputWithRouting_2024_09_04,
@@ -147,13 +149,26 @@ export class SlotsService_2024_09_04 {
147149
}
148150

149151
const nonSeatedEventAlreadyBooked = !eventType.seatsPerTimeSlot && booking;
150-
if (nonSeatedEventAlreadyBooked) {
152+
const isRoundRobinEvent = eventType.schedulingType === SchedulingType.ROUND_ROBIN;
153+
154+
if (nonSeatedEventAlreadyBooked && !isRoundRobinEvent) {
151155
throw new UnprocessableEntityException(`Can't reserve a slot if the event is already booked.`);
152156
}
153157

154-
const reservationDuration = input.reservationDuration ?? DEFAULT_RESERVATION_DURATION;
158+
if (isRoundRobinEvent) {
159+
try {
160+
await validateRoundRobinSlotAvailability(input.eventTypeId, startDate, endDate, eventType.hosts);
161+
} catch (error) {
162+
if (error instanceof Error) {
163+
throw new UnprocessableEntityException(error?.message);
164+
}
165+
throw error;
166+
}
167+
} else {
168+
await this.checkSlotOverlap(input.eventTypeId, startDate.toISO(), endDate.toISO());
169+
}
155170

156-
await this.checkSlotOverlap(input.eventTypeId, startDate.toISO(), endDate.toISO());
171+
const reservationDuration = input.reservationDuration ?? DEFAULT_RESERVATION_DURATION;
157172

158173
if (eventType.userId) {
159174
const slot = await this.slotsRepository.createSlot(
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { DateTime } from "luxon";
2+
3+
import { HttpError } from "@calcom/lib/http-error";
4+
import { prisma } from "@calcom/prisma";
5+
import type { Host } from "@calcom/prisma/client";
6+
7+
export async function validateRoundRobinSlotAvailability(
8+
eventTypeId: number,
9+
startDate: DateTime,
10+
endDate: DateTime,
11+
hosts: Host[]
12+
) {
13+
const fixedHosts = hosts.filter((host) => host.isFixed === true);
14+
const nonFixedHosts = hosts.filter((host) => host.isFixed === false);
15+
16+
if (fixedHosts.length > 0) {
17+
await validateFixedHostsAvailability(eventTypeId, startDate, endDate, fixedHosts);
18+
} else {
19+
await validateNonFixedHostsAvailability(eventTypeId, startDate, endDate, nonFixedHosts);
20+
}
21+
}
22+
23+
async function validateFixedHostsAvailability(
24+
eventTypeId: number,
25+
startDate: DateTime,
26+
endDate: DateTime,
27+
hosts: Host[]
28+
) {
29+
const existingBooking = await prisma.booking.findFirst({
30+
where: {
31+
eventTypeId,
32+
startTime: startDate.toJSDate(),
33+
endTime: endDate.toJSDate(),
34+
},
35+
select: { attendees: true, userId: true, status: true },
36+
});
37+
const existingSlotReservation = await prisma.selectedSlots.count({
38+
where: {
39+
eventTypeId,
40+
slotUtcStartDate: startDate.toISO() ?? "",
41+
slotUtcEndDate: endDate.toISO() ?? "",
42+
// Only consider non-expired reservations
43+
releaseAt: { gt: DateTime.utc().toJSDate() },
44+
},
45+
});
46+
47+
const hasHostAsAttendee = hosts.some(
48+
(host) =>
49+
existingBooking?.attendees.some((attendee) => attendee.id === host.userId) ||
50+
existingBooking?.userId === host.userId
51+
);
52+
53+
if (existingSlotReservation > 0) {
54+
throw new HttpError({
55+
statusCode: 422,
56+
message: `Can't reserve the slot because the round robin event type has no available hosts left at this time slot.`,
57+
});
58+
}
59+
60+
if (hasHostAsAttendee) {
61+
throw new HttpError({
62+
statusCode: 422,
63+
message: `Can't reserve a slot if the event is already booked.`,
64+
});
65+
}
66+
}
67+
68+
async function validateNonFixedHostsAvailability(
69+
eventTypeId: number,
70+
startDate: DateTime,
71+
endDate: DateTime,
72+
hosts: Host[]
73+
) {
74+
const existingSlotReservations = await prisma.selectedSlots.count({
75+
where: {
76+
eventTypeId,
77+
slotUtcStartDate: startDate.toISO() ?? "",
78+
slotUtcEndDate: endDate.toISO() ?? "",
79+
// Only consider non-expired reservations
80+
releaseAt: { gt: DateTime.utc().toJSDate() },
81+
},
82+
});
83+
84+
if (existingSlotReservations === hosts.length) {
85+
throw new HttpError({
86+
statusCode: 422,
87+
message: `Can't reserve the slot because the round robin event type has no available hosts left at this time slot.`,
88+
});
89+
}
90+
}

packages/platform/libraries/slots.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { validateRoundRobinSlotAvailability } from "@calcom/features/ee/round-robin/utils/validateRoundRobinSlotAvailability";
12
import { FilterHostsService } from "@calcom/features/bookings/lib/host-filtering/filterHostsBySameRoundRobinHost";
23
import { QualifiedHostsService } from "@calcom/features/bookings/lib/host-filtering/findQualifiedHostsWithDelegationCredentials";
34
import { BusyTimesService } from "@calcom/lib/getBusyTimes";
@@ -12,3 +13,4 @@ export { QualifiedHostsService };
1213

1314
export { FilterHostsService };
1415
export { NoSlotsNotificationService };
16+
export { validateRoundRobinSlotAvailability };

0 commit comments

Comments
 (0)