feat: Round robin, when attendee books same timeslot return original booking (#21455)

This commit is contained in:
Joe Au-Yeung
2025-05-22 12:06:28 -07:00
committed by GitHub
parent 16ea15076d
commit a6d37fd167
4 changed files with 168 additions and 11 deletions
@@ -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();
@@ -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,
@@ -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;
+33
View File
@@ -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,
},
});
}
}