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:
co-authored by
supalarry
Alex van Andel
parent
c46f824e3f
commit
b286244aab
+205
-1
@@ -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.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user