fix: allow new booking to overlap old one when rescheduling (#10424)

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
This commit is contained in:
Carina Wollendorfer 2023-08-10 15:07:57 -04:00 committed by GitHub
parent c7dfa7bc89
commit a6daf17909
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 107 additions and 37 deletions

View File

@ -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/);
});
});

View File

@ -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,

View File

@ -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[] = [

View File

@ -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,
});

View File

@ -94,6 +94,7 @@ const log = logger.getChildLogger({ prefix: ["[api] book:user"] });
type User = Prisma.UserGetPayload<typeof userSelect>;
type BufferedBusyTimes = BufferedBusyTime[];
type BookingType = Prisma.PromiseReturnType<typeof getOriginalRescheduledBooking>;
interface IEventTypePaymentCredentialType {
appId: EventTypeAppsList;
@ -357,22 +358,35 @@ async function ensureAvailableUsers(
eventType: Awaited<ReturnType<typeof getEventTypesFromDB>> & {
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<typeof getOriginalRescheduledBooking>;
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 =

View File

@ -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: {

View File

@ -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

View File

@ -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 {