diff --git a/apps/api/v2/src/modules/slots/slots-2024-09-04/controllers/e2e/team-event-type-slots.controller.e2e-spec.ts b/apps/api/v2/src/modules/slots/slots-2024-09-04/controllers/e2e/team-event-type-slots.controller.e2e-spec.ts index 27c4e0b4e7..fba616befd 100644 --- a/apps/api/v2/src/modules/slots/slots-2024-09-04/controllers/e2e/team-event-type-slots.controller.e2e-spec.ts +++ b/apps/api/v2/src/modules/slots/slots-2024-09-04/controllers/e2e/team-event-type-slots.controller.e2e-spec.ts @@ -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); diff --git a/apps/api/v2/src/modules/slots/slots-2024-09-04/services/slots.service.ts b/apps/api/v2/src/modules/slots/slots-2024-09-04/services/slots.service.ts index 6e34882a19..4b1dcad021 100644 --- a/apps/api/v2/src/modules/slots/slots-2024-09-04/services/slots.service.ts +++ b/apps/api/v2/src/modules/slots/slots-2024-09-04/services/slots.service.ts @@ -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( diff --git a/packages/features/ee/round-robin/utils/validateRoundRobinSlotAvailability.ts b/packages/features/ee/round-robin/utils/validateRoundRobinSlotAvailability.ts new file mode 100644 index 0000000000..c83c79dc79 --- /dev/null +++ b/packages/features/ee/round-robin/utils/validateRoundRobinSlotAvailability.ts @@ -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.`, + }); + } +} diff --git a/packages/platform/libraries/slots.ts b/packages/platform/libraries/slots.ts index 2a1e6ebe3d..dc79d4873e 100644 --- a/packages/platform/libraries/slots.ts +++ b/packages/platform/libraries/slots.ts @@ -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 };