From a6daf179097f1cd8983357ef30a73cfa8d93f291 Mon Sep 17 00:00:00 2001 From: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com> Date: Thu, 10 Aug 2023 15:07:57 -0400 Subject: [PATCH] fix: allow new booking to overlap old one when rescheduling (#10424) Co-authored-by: CarinaWolli --- apps/web/playwright/reschedule.e2e.ts | 24 ++++++ packages/core/getBusyTimes.ts | 28 ++++++- packages/core/getUserAvailability.ts | 3 + .../features/bookings/Booker/utils/event.ts | 4 + .../features/bookings/lib/handleNewBooking.ts | 75 +++++++++++-------- .../schedules/lib/use-schedule/useSchedule.ts | 3 + .../trpc/server/routers/viewer/slots/types.ts | 1 + .../trpc/server/routers/viewer/slots/util.ts | 6 +- 8 files changed, 107 insertions(+), 37 deletions(-) diff --git a/apps/web/playwright/reschedule.e2e.ts b/apps/web/playwright/reschedule.e2e.ts index 834443b9a2..b33b3d57b6 100644 --- a/apps/web/playwright/reschedule.e2e.ts +++ b/apps/web/playwright/reschedule.e2e.ts @@ -1,5 +1,6 @@ import { expect } from "@playwright/test"; +import dayjs from "@calcom/dayjs"; import prisma from "@calcom/prisma"; import { BookingStatus } from "@calcom/prisma/enums"; @@ -239,4 +240,27 @@ test.describe("Reschedule Tests", async () => { expect(newBooking).not.toBeNull(); expect(newBooking?.status).toBe(BookingStatus.ACCEPTED); }); + + test("Should be able to book slot that overlaps with original rescheduled booking", async ({ + page, + users, + bookings, + }) => { + const user = await users.create(); + const eventType = user.eventTypes[0]; + const startTime = dayjs().add(1, "day").set("hour", 10).set("minute", 0).toDate(); + const endTime = dayjs().add(1, "day").set("hour", 10).set("minute", 30).toDate(); + + const booking = await bookings.create(user.id, user.username, eventType.id, {}, startTime, endTime); + + await page.goto(`/reschedule/${booking.uid}`); + + //book same slot again + page.getByRole("button", { name: dayjs(startTime).format("D"), exact: true }).click(); + + page.getByRole("button", { name: dayjs(startTime).format("h:mmA") }).click(); + + await page.locator('[data-testid="confirm-reschedule-button"]').click(); + await expect(page).toHaveURL(/.*booking/); + }); }); diff --git a/packages/core/getBusyTimes.ts b/packages/core/getBusyTimes.ts index c132d08c51..297c64e0d1 100644 --- a/packages/core/getBusyTimes.ts +++ b/packages/core/getBusyTimes.ts @@ -21,6 +21,8 @@ export async function getBusyTimes(params: { endTime: string; selectedCalendars: SelectedCalendar[]; seatedEvent?: boolean; + rescheduleUid?: string | null; + duration?: number | null; }) { const { credentials, @@ -33,6 +35,8 @@ export async function getBusyTimes(params: { afterEventBuffer, selectedCalendars, seatedEvent, + rescheduleUid, + duration, } = params; logger.silly( `Checking Busy time from Cal Bookings in range ${startTime} to ${endTime} for input ${JSON.stringify({ @@ -67,9 +71,14 @@ export async function getBusyTimes(params: { */ performance.mark("prismaBookingGetStart"); + const startTimeDate = + rescheduleUid && duration ? dayjs(startTime).subtract(duration, "minute").toDate() : new Date(startTime); + const endTimeDate = + rescheduleUid && duration ? dayjs(endTime).add(duration, "minute").toDate() : new Date(endTime); + const sharedQuery = { - startTime: { gte: new Date(startTime) }, - endTime: { lte: new Date(endTime) }, + startTime: { gte: startTimeDate }, + endTime: { lte: endTimeDate }, status: { in: [BookingStatus.ACCEPTED], }, @@ -96,6 +105,7 @@ export async function getBusyTimes(params: { }, select: { id: true, + uid: true, startTime: true, endTime: true, title: true, @@ -138,6 +148,9 @@ export async function getBusyTimes(params: { // doing this allows using the map later to remove the ranges from calendar busy times. delete bookingSeatCountMap[bookedAt]; } + if (rest.uid === rescheduleUid) { + return aggregate; + } aggregate.push({ start: dayjs(startTime) .subtract((eventType?.beforeEventBuffer || 0) + (afterEventBuffer || 0), "minute") @@ -180,6 +193,17 @@ export async function getBusyTimes(params: { }; }); + if (rescheduleUid) { + const originalRescheduleBooking = bookings.find((booking) => booking.uid === rescheduleUid); + // calendar busy time from original rescheduled booking should not be blocked + if (originalRescheduleBooking) { + openSeatsDateRanges.push({ + start: dayjs(originalRescheduleBooking.startTime), + end: dayjs(originalRescheduleBooking.endTime), + }); + } + } + const result = subtract( calendarBusyTimes.map((value) => ({ ...value, diff --git a/packages/core/getUserAvailability.ts b/packages/core/getUserAvailability.ts index 9d70fcc93b..3617679d9c 100644 --- a/packages/core/getUserAvailability.ts +++ b/packages/core/getUserAvailability.ts @@ -127,6 +127,7 @@ export const getUserAvailability = async function getUsersWorkingHoursLifeTheUni user?: User; eventType?: EventType; currentSeats?: CurrentSeats; + rescheduleUid?: string | null; } ) { const { username, userId, dateFrom, dateTo, eventTypeId, afterEventBuffer, beforeEventBuffer, duration } = @@ -184,6 +185,8 @@ export const getUserAvailability = async function getUsersWorkingHoursLifeTheUni afterEventBuffer, selectedCalendars: user.selectedCalendars, seatedEvent: !!eventType?.seatsPerTimeSlot, + rescheduleUid: initialData?.rescheduleUid || null, + duration, }); const detailedBusyTimes: EventBusyDetails[] = [ diff --git a/packages/features/bookings/Booker/utils/event.ts b/packages/features/bookings/Booker/utils/event.ts index 9a266a1e5b..d996ba67fd 100644 --- a/packages/features/bookings/Booker/utils/event.ts +++ b/packages/features/bookings/Booker/utils/event.ts @@ -1,3 +1,4 @@ +import { useSearchParams } from "next/navigation"; import { shallow } from "zustand/shallow"; import { useSchedule } from "@calcom/features/schedules"; @@ -58,6 +59,8 @@ export const useScheduleForEvent = ({ (state) => [state.username, state.eventSlug, state.month, state.selectedDuration], shallow ); + const serachParams = useSearchParams(); + const rescheduleUid = serachParams.get("rescheduleUid"); return useSchedule({ username: usernameFromStore ?? username, @@ -65,6 +68,7 @@ export const useScheduleForEvent = ({ eventId: event.data?.id ?? eventId, timezone, prefetchNextMonth, + rescheduleUid, month: monthFromStore ?? month, duration: durationFromStore ?? duration, }); diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index c20f990797..2698feda2f 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -94,6 +94,7 @@ const log = logger.getChildLogger({ prefix: ["[api] book:user"] }); type User = Prisma.UserGetPayload; type BufferedBusyTimes = BufferedBusyTime[]; +type BookingType = Prisma.PromiseReturnType; interface IEventTypePaymentCredentialType { appId: EventTypeAppsList; @@ -357,22 +358,35 @@ async function ensureAvailableUsers( eventType: Awaited> & { users: IsFixedAwareUser[]; }, - input: { dateFrom: string; dateTo: string; timeZone: string }, + input: { dateFrom: string; dateTo: string; timeZone: string; originalRescheduledBooking?: BookingType }, recurringDatesInfo?: { allRecurringDates: string[] | undefined; currentRecurringIndex: number | undefined; } ) { const availableUsers: IsFixedAwareUser[] = []; + + const orginalBookingDuration = input.originalRescheduledBooking + ? dayjs(input.originalRescheduledBooking.endTime).diff( + dayjs(input.originalRescheduledBooking.startTime), + "minutes" + ) + : undefined; + /** Let's start checking for availability */ for (const user of eventType.users) { const { dateRanges, busy: bufferedBusyTimes } = await getUserAvailability( { userId: user.id, eventTypeId: eventType.id, + duration: orginalBookingDuration, ...input, }, - { user, eventType } + { + user, + eventType, + rescheduleUid: input.originalRescheduledBooking?.uid ?? null, + } ); if (!dateRanges.length) { @@ -806,6 +820,34 @@ async function handler( } } + let rescheduleUid = reqBody.rescheduleUid; + let bookingSeat: Prisma.BookingSeatGetPayload<{ include: { booking: true; attendee: true } }> | null = null; + + let originalRescheduledBooking: BookingType = null; + + if (rescheduleUid) { + // rescheduleUid can be bookingUid and bookingSeatUid + bookingSeat = await prisma.bookingSeat.findUnique({ + where: { + referenceUid: rescheduleUid, + }, + include: { + booking: true, + attendee: true, + }, + }); + if (bookingSeat) { + rescheduleUid = bookingSeat.booking.uid; + } + originalRescheduledBooking = await getOriginalRescheduledBooking( + rescheduleUid, + !!eventType.seatsPerTimeSlot + ); + if (!originalRescheduledBooking) { + throw new HttpError({ statusCode: 404, message: "Could not find original booking" }); + } + } + if (!eventType.seatsPerTimeSlot) { const availableUsers = await ensureAvailableUsers( { @@ -822,6 +864,7 @@ async function handler( dateFrom: reqBody.start, dateTo: reqBody.end, timeZone: reqBody.timeZone, + originalRescheduledBooking, }, { allRecurringDates, @@ -860,34 +903,6 @@ async function handler( const allCredentials = await getAllCredentials(organizerUser, eventType); - let rescheduleUid = reqBody.rescheduleUid; - let bookingSeat: Prisma.BookingSeatGetPayload<{ include: { booking: true; attendee: true } }> | null = null; - type BookingType = Prisma.PromiseReturnType; - let originalRescheduledBooking: BookingType = null; - - if (rescheduleUid) { - // rescheduleUid can be bookingUid and bookingSeatUid - bookingSeat = await prisma.bookingSeat.findUnique({ - where: { - referenceUid: rescheduleUid, - }, - include: { - booking: true, - attendee: true, - }, - }); - if (bookingSeat) { - rescheduleUid = bookingSeat.booking.uid; - } - originalRescheduledBooking = await getOriginalRescheduledBooking( - rescheduleUid, - !!eventType.seatsPerTimeSlot - ); - if (!originalRescheduledBooking) { - throw new HttpError({ statusCode: 404, message: "Could not find original booking" }); - } - } - const isOrganizerRescheduling = organizerUser.id === userId; const attendeeInfoOnReschedule = diff --git a/packages/features/schedules/lib/use-schedule/useSchedule.ts b/packages/features/schedules/lib/use-schedule/useSchedule.ts index 898fce6e66..c302a81f8e 100644 --- a/packages/features/schedules/lib/use-schedule/useSchedule.ts +++ b/packages/features/schedules/lib/use-schedule/useSchedule.ts @@ -10,6 +10,7 @@ type UseScheduleWithCacheArgs = { timezone?: string | null; prefetchNextMonth?: boolean; duration?: number | null; + rescheduleUid?: string | null; }; export const useSchedule = ({ @@ -20,6 +21,7 @@ export const useSchedule = ({ eventId, prefetchNextMonth, duration, + rescheduleUid, }: UseScheduleWithCacheArgs) => { const monthDayjs = month ? dayjs(month) : dayjs(); const nextMonthDayjs = monthDayjs.add(1, "month"); @@ -40,6 +42,7 @@ export const useSchedule = ({ endTime: (prefetchNextMonth ? nextMonthDayjs : monthDayjs).endOf("month").toISOString(), timeZone: timezone!, duration: duration ? `${duration}` : undefined, + rescheduleUid, }, { trpc: { diff --git a/packages/trpc/server/routers/viewer/slots/types.ts b/packages/trpc/server/routers/viewer/slots/types.ts index 402f845271..4c9696ece5 100644 --- a/packages/trpc/server/routers/viewer/slots/types.ts +++ b/packages/trpc/server/routers/viewer/slots/types.ts @@ -20,6 +20,7 @@ export const getScheduleSchema = z .string() .optional() .transform((val) => val && parseInt(val)), + rescheduleUid: z.string().optional().nullable(), }) .transform((val) => { // Need this so we can pass a single username in the query string form public API diff --git a/packages/trpc/server/routers/viewer/slots/util.ts b/packages/trpc/server/routers/viewer/slots/util.ts index ea013879c5..378a658578 100644 --- a/packages/trpc/server/routers/viewer/slots/util.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -279,11 +279,7 @@ export async function getAvailableSlots(input: TGetScheduleInputSchema) { beforeEventBuffer: eventType.beforeEventBuffer, duration: input.duration || 0, }, - { - user: currentUser, - eventType, - currentSeats, - } + { user: currentUser, eventType, currentSeats, rescheduleUid: input.rescheduleUid } ); if (!currentSeats && _currentSeats) currentSeats = _currentSeats; return {