diff --git a/apps/web/playwright/organization/booking.e2e.ts b/apps/web/playwright/organization/booking.e2e.ts index 27e034f860..e53186147c 100644 --- a/apps/web/playwright/organization/booking.e2e.ts +++ b/apps/web/playwright/organization/booking.e2e.ts @@ -333,7 +333,7 @@ test.describe("Bookings", () => { // Second booking - should get a different host await page.goto(`/org/${org.slug}/${team.slug}/${teamEvent.slug}`); await selectFirstAvailableTimeSlotNextMonth(page); - await bookTimeSlot(page); + await bookTimeSlot(page, { email: "attendee1@test.com" }); await expect(page.getByTestId("success-page")).toBeVisible(); const secondHost = await page.getByTestId("booking-host-name").textContent(); expect(secondHost).not.toBeNull(); @@ -342,7 +342,7 @@ test.describe("Bookings", () => { // Third booking - should get a different host await page.goto(`/org/${org.slug}/${team.slug}/${teamEvent.slug}`); await selectFirstAvailableTimeSlotNextMonth(page); - await bookTimeSlot(page); + await bookTimeSlot(page, { email: "attendee2@test.com" }); await expect(page.getByTestId("success-page")).toBeVisible(); const thirdHost = await page.getByTestId("booking-host-name").textContent(); expect(thirdHost).not.toBeNull(); @@ -352,7 +352,7 @@ test.describe("Bookings", () => { // Fourth booking - should get a different host await page.goto(`/org/${org.slug}/${team.slug}/${teamEvent.slug}`); await selectFirstAvailableTimeSlotNextMonth(page); - await bookTimeSlot(page); + await bookTimeSlot(page, { email: "attendee3@test.com" }); await expect(page.getByTestId("success-page")).toBeVisible(); const fourthHost = await page.getByTestId("booking-host-name").textContent(); expect(fourthHost).not.toBeNull(); diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index f2bd4d9e02..7003f1a290 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -66,6 +66,7 @@ import { getPiiFreeCalendarEvent, getPiiFreeEventType } from "@calcom/lib/piiFre import { safeStringify } from "@calcom/lib/safeStringify"; import { getLuckyUser } from "@calcom/lib/server/getLuckyUser"; import { getTranslation } from "@calcom/lib/server/i18n"; +import { BookingRepository } from "@calcom/lib/server/repository/booking"; import { WorkflowRepository } from "@calcom/lib/server/repository/workflow"; import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat"; import prisma from "@calcom/prisma"; @@ -110,7 +111,6 @@ import type { IEventTypePaymentCredentialType, Invitee, IsFixedAwareUser } from import { validateBookingTimeIsNotOutOfBounds } from "./handleNewBooking/validateBookingTimeIsNotOutOfBounds"; import { validateEventLength } from "./handleNewBooking/validateEventLength"; import handleSeats from "./handleSeats/handleSeats"; -import { getBookingRequest } from "./requiresConfirmation/getBookingRequest"; const translator = short(); const log = logger.getSubLogger({ prefix: ["[api] book:user"] }); @@ -489,20 +489,24 @@ async function handler( bookerEmail, }); - // If a request already exists for the timeslot, return that request - if (!isConfirmedByDefault && !userReschedulingIsOwner) { - const requestedBooking = await getBookingRequest({ + // For unconfirmed bookings or round robin bookings with the same attendee and timeslot, return the original booking + if ( + (!isConfirmedByDefault && !userReschedulingIsOwner) || + eventType.schedulingType === SchedulingType.ROUND_ROBIN + ) { + const existingBooking = await BookingRepository.getValidBookingFromEventTypeForAttendee({ eventTypeId, bookerEmail, bookerPhoneNumber, startTime: new Date(dayjs(reqBody.start).utc().format()), + filterForUnconfirmed: !isConfirmedByDefault, }); - if (requestedBooking) { + if (existingBooking) { const bookingResponse = { - ...requestedBooking, + ...existingBooking, user: { - ...requestedBooking.user, + ...existingBooking.user, email: null, }, paymentRequired: false, diff --git a/packages/features/bookings/lib/handleNewBooking/test/fresh-booking.test.ts b/packages/features/bookings/lib/handleNewBooking/test/fresh-booking.test.ts index d3b27d4148..4e462adf66 100644 --- a/packages/features/bookings/lib/handleNewBooking/test/fresh-booking.test.ts +++ b/packages/features/bookings/lib/handleNewBooking/test/fresh-booking.test.ts @@ -58,7 +58,7 @@ import { WEBSITE_URL, WEBAPP_URL } from "@calcom/lib/constants"; import { ErrorCode } from "@calcom/lib/errorCodes"; import { resetTestEmails } from "@calcom/lib/testEmails"; import { CreationSource, WatchlistSeverity, WatchlistType } from "@calcom/prisma/enums"; -import { BookingStatus } from "@calcom/prisma/enums"; +import { BookingStatus, SchedulingType } from "@calcom/prisma/enums"; import { test } from "@calcom/web/test/fixtures/fixtures"; export type CustomNextApiRequest = NextApiRequest & Request; @@ -3280,6 +3280,126 @@ describe("handleNewBooking", () => { }); }); + describe("Returning original booking", () => { + test("Return the original booking if a request is made twice by the same attendee when a booking requires confirmation", async () => { + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + }); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + user: organizer.username, + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: "New York" }, + }, + }, + }); + + const scenarioData = getScenarioData({ + eventTypes: [ + { + id: 1, + slotInterval: 30, + length: 30, + requiresConfirmation: true, + users: [ + { + id: 101, + }, + ], + }, + ], + organizer, + apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], + }); + + await createBookingScenario(scenarioData); + + const response = await handleNewBooking({ + bookingData: mockBookingData, + }); + + const secondResponse = await handleNewBooking({ + bookingData: mockBookingData, + }); + + expect(secondResponse.id).toEqual(response.id); + }); + test("Return the original booking if request is made by the same attendee for a round robin event", async () => { + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + }); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + user: organizer.username, + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: "New York" }, + }, + }, + }); + + const scenarioData = getScenarioData({ + eventTypes: [ + { + id: 1, + slotInterval: 30, + length: 30, + requiresConfirmation: true, + schedulingType: SchedulingType.ROUND_ROBIN, + users: [ + { + id: 101, + }, + ], + }, + ], + organizer, + apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], + }); + + await createBookingScenario(scenarioData); + + const response = await handleNewBooking({ + bookingData: mockBookingData, + }); + + const secondResponse = await handleNewBooking({ + bookingData: mockBookingData, + }); + + expect(secondResponse.id).toEqual(response.id); + }); + }); + describe("Blocked users", () => { test("user is locked", async () => { const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; diff --git a/packages/lib/server/repository/booking.ts b/packages/lib/server/repository/booking.ts index 00aad00fdb..3b9708b1fd 100644 --- a/packages/lib/server/repository/booking.ts +++ b/packages/lib/server/repository/booking.ts @@ -779,4 +779,37 @@ export class BookingRepository { ...managedBookings, ]; } + + static async getValidBookingFromEventTypeForAttendee({ + eventTypeId, + bookerEmail, + bookerPhoneNumber, + startTime, + filterForUnconfirmed, + }: { + eventTypeId: number; + bookerEmail?: string; + bookerPhoneNumber?: string; + startTime: Date; + filterForUnconfirmed?: boolean; + }) { + return await prisma.booking.findFirst({ + where: { + eventTypeId, + attendees: { + some: { + email: bookerEmail, + phoneNumber: bookerPhoneNumber, + }, + }, + startTime, + status: filterForUnconfirmed ? BookingStatus.PENDING : BookingStatus.ACCEPTED, + }, + include: { + attendees: true, + references: true, + user: true, + }, + }); + } }