From 6fffe3377d8fd650bb8082304aa17dd231e7f657 Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com> Date: Thu, 1 May 2025 19:46:19 -0400 Subject: [PATCH] refactor: Replace attendee email with user id in idempotency key (#21058) * Use userId instead of attendee email * Only write if booking is accepted * Create `getBookingRequest` function * Return already requested booking * Type fixes * Type fix * Type fix * Pass empty seatReferenceId * Handle no userId * Update test --- apps/web/playwright/booking-pages.e2e.ts | 9 ++- .../features/bookings/lib/handleNewBooking.ts | 71 ++++++++++++++----- .../requiresConfirmation/getBookingRequest.ts | 35 +++++++++ .../extensions/booking-idempotency-key.ts | 23 +++--- 4 files changed, 101 insertions(+), 37 deletions(-) create mode 100644 packages/features/bookings/lib/requiresConfirmation/getBookingRequest.ts diff --git a/apps/web/playwright/booking-pages.e2e.ts b/apps/web/playwright/booking-pages.e2e.ts index b6b385bcc2..c7abd8f340 100644 --- a/apps/web/playwright/booking-pages.e2e.ts +++ b/apps/web/playwright/booking-pages.e2e.ts @@ -287,7 +287,10 @@ test.describe("pro user", () => { await expect(page.locator("[data-testid=success-page]")).toBeVisible(); }); - test("cannot book an unconfirmed event multiple times with the same email", async ({ page, users }) => { + test("booking an unconfirmed event with the same email brings you to the original request", async ({ + page, + users, + }) => { await page.locator('[data-testid="event-type-link"]:has-text("Opt in")').click(); await selectFirstAvailableTimeSlotNextMonth(page); @@ -297,8 +300,8 @@ test.describe("pro user", () => { // go back to the booking page to re-book. await page.goto(pageUrl); - await bookTimeSlot(page, { expectedStatusCode: 409 }); - await expect(page.getByText("Could not book the meeting.")).toBeVisible(); + await bookTimeSlot(page); + await expect(page.locator("[data-testid=success-page]")).toBeVisible(); }); test("can book with multiple guests", async ({ page, users }) => { diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index ce193db7a2..5ba74c5dcb 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -110,6 +110,7 @@ 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"] }); @@ -413,15 +414,63 @@ async function handler( }); } - const shouldServeCache = await getShouldServeCache(_shouldServeCache, eventType.team?.id); + const bookingSeat = reqBody.rescheduleUid ? await getSeatedBooking(reqBody.rescheduleUid) : null; + const rescheduleUid = bookingSeat ? bookingSeat.booking.uid : reqBody.rescheduleUid; - const isTeamEventType = - !!eventType.schedulingType && ["COLLECTIVE", "ROUND_ROBIN"].includes(eventType.schedulingType); + let originalRescheduledBooking = rescheduleUid + ? await getOriginalRescheduledBooking(rescheduleUid, !!eventType.seatsPerTimeSlot) + : null; const paymentAppData = getPaymentAppData({ ...eventType, metadata: eventTypeMetaDataSchemaWithTypedApps.parse(eventType.metadata), }); + + const { userReschedulingIsOwner, isConfirmedByDefault } = await getRequiresConfirmationFlags({ + eventType, + bookingStartTime: reqBody.start, + userId, + originalRescheduledBookingOrganizerId: originalRescheduledBooking?.user?.id, + paymentAppData, + bookerEmail, + }); + + // If a request already exists for the timeslot, return that request + if (!isConfirmedByDefault && !userReschedulingIsOwner) { + const requestedBooking = await getBookingRequest({ + eventTypeId, + bookerEmail, + bookerPhoneNumber, + startTime: new Date(dayjs(reqBody.start).utc().format()), + }); + + if (requestedBooking) { + const bookingResponse = { + ...requestedBooking, + user: { + ...requestedBooking.user, + email: null, + }, + paymentRequired: false, + seatReferenceUid: "", + }; + + return { + ...bookingResponse, + luckyUsers: bookingResponse.userId ? [bookingResponse.userId] : [], + isDryRun, + ...(isDryRun ? { troubleshooterData } : {}), + paymentUid: undefined, + paymentId: undefined, + }; + } + } + + const shouldServeCache = await getShouldServeCache(_shouldServeCache, eventType.team?.id); + + const isTeamEventType = + !!eventType.schedulingType && ["COLLECTIVE", "ROUND_ROBIN"].includes(eventType.schedulingType); + loggerWithEventDetails.info( `Booking eventType ${eventTypeId} started`, safeStringify({ @@ -536,13 +585,6 @@ async function handler( reqBodyRescheduleUid: reqBody.rescheduleUid, }); - const bookingSeat = reqBody.rescheduleUid ? await getSeatedBooking(reqBody.rescheduleUid) : null; - const rescheduleUid = bookingSeat ? bookingSeat.booking.uid : reqBody.rescheduleUid; - - let originalRescheduledBooking = rescheduleUid - ? await getOriginalRescheduledBooking(rescheduleUid, !!eventType.seatsPerTimeSlot) - : null; - let luckyUserResponse; let isFirstSeat = true; @@ -804,15 +846,6 @@ async function handler( const tOrganizer = await getTranslation(organizerUser?.locale ?? "en", "common"); const allCredentials = await getAllCredentials(organizerUser, eventType); - const { userReschedulingIsOwner, isConfirmedByDefault } = await getRequiresConfirmationFlags({ - eventType, - bookingStartTime: reqBody.start, - userId, - originalRescheduledBookingOrganizerId: originalRescheduledBooking?.user?.id, - paymentAppData, - bookerEmail, - }); - // If the Organizer himself is rescheduling, the booker should be sent the communication in his timezone and locale. const attendeeInfoOnReschedule = userReschedulingIsOwner && originalRescheduledBooking diff --git a/packages/features/bookings/lib/requiresConfirmation/getBookingRequest.ts b/packages/features/bookings/lib/requiresConfirmation/getBookingRequest.ts new file mode 100644 index 0000000000..2b2eb82ef6 --- /dev/null +++ b/packages/features/bookings/lib/requiresConfirmation/getBookingRequest.ts @@ -0,0 +1,35 @@ +import prisma from "@calcom/prisma"; +import { BookingStatus } from "@calcom/prisma/enums"; + +export const getBookingRequest = async ({ + bookerEmail, + bookerPhoneNumber, + startTime, + eventTypeId, +}: { + bookerEmail: string; + bookerPhoneNumber: string | undefined; + startTime: Date; + eventTypeId: number; +}) => { + // See if there's already an existing request + const booking = await prisma.booking.findFirst({ + where: { + eventTypeId, + startTime, + attendees: { + some: { + email: bookerEmail, + phoneNumber: bookerPhoneNumber, + }, + }, + status: BookingStatus.PENDING, + }, + include: { + attendees: true, + references: true, + user: true, + }, + }); + return booking; +}; diff --git a/packages/prisma/extensions/booking-idempotency-key.ts b/packages/prisma/extensions/booking-idempotency-key.ts index 7a602757cc..b9a05911dc 100644 --- a/packages/prisma/extensions/booking-idempotency-key.ts +++ b/packages/prisma/extensions/booking-idempotency-key.ts @@ -8,22 +8,15 @@ export function bookingIdempotencyKeyExtension() { query: { booking: { async create({ args, query }) { - const uniqueEmailJoinInput: string[] = []; - if (args.data.attendees?.create && !Array.isArray(args.data.attendees?.create)) { - uniqueEmailJoinInput.push(args.data.attendees?.create.email); + if (args.data.status === BookingStatus.ACCEPTED) { + const idempotencyKey = uuidv5( + `${ + args.data.eventType?.connect?.id + }.${args.data.startTime.valueOf()}.${args.data.endTime.valueOf()}.${args.data?.userId}`, + uuidv5.URL + ); + args.data.idempotencyKey = idempotencyKey; } - if (args.data.attendees?.createMany && Array.isArray(args.data.attendees?.createMany.data)) { - uniqueEmailJoinInput.push(...args.data.attendees?.createMany.data.map((record) => record.email)); - } - const idempotencyKey = uuidv5( - `${ - args.data.eventType?.connect?.id - }.${args.data.startTime.valueOf()}.${args.data.endTime.valueOf()}.${uniqueEmailJoinInput.join( - "," - )}`, - uuidv5.URL - ); - args.data.idempotencyKey = idempotencyKey; return query(args); }, async update({ args, query }) {