From d7c3132fa56d7737f27847ffae0eb0a1b50397bb Mon Sep 17 00:00:00 2001 From: Vijay Date: Thu, 28 Sep 2023 02:24:22 +0530 Subject: [PATCH] fix: #10789 Prevent Double Booking (for requests that require confirmation) (#10882) Co-authored-by: Peer Richelsen Co-authored-by: alannnc Co-authored-by: zomars --- .../components/booking/BookingListItem.tsx | 10 +++-- apps/web/playwright/booking-pages.e2e.ts | 41 +++++++++++++++++++ .../viewer/bookings/confirm.handler.ts | 24 +++++++++++ 3 files changed, 71 insertions(+), 4 deletions(-) diff --git a/apps/web/components/booking/BookingListItem.tsx b/apps/web/components/booking/BookingListItem.tsx index 4cba1243c5..931c53e30d 100644 --- a/apps/web/components/booking/BookingListItem.tsx +++ b/apps/web/components/booking/BookingListItem.tsx @@ -26,11 +26,11 @@ import { DialogFooter, MeetingTimeInTimezones, showToast, - Tooltip, TableActions, TextAreaField, + Tooltip, } from "@calcom/ui"; -import { Check, Clock, MapPin, RefreshCcw, Send, Ban, X, CreditCard } from "@calcom/ui/components/icon"; +import { Ban, Check, Clock, CreditCard, MapPin, RefreshCcw, Send, X } from "@calcom/ui/components/icon"; import useMeQuery from "@lib/hooks/useMeQuery"; @@ -74,8 +74,10 @@ function BookingListItem(booking: BookingItemProps) { } utils.viewer.bookings.invalidate(); }, - onError: () => { - showToast(t("booking_confirmation_failed"), "error"); + onError: (e) => { + let message = t("booking_confirmation_failed"); + if ("message" in e) message = e.message; + showToast(message, "error"); utils.viewer.bookings.invalidate(); }, }); diff --git a/apps/web/playwright/booking-pages.e2e.ts b/apps/web/playwright/booking-pages.e2e.ts index 87ac1dcf51..8ca687e82e 100644 --- a/apps/web/playwright/booking-pages.e2e.ts +++ b/apps/web/playwright/booking-pages.e2e.ts @@ -137,6 +137,8 @@ test.describe("pro user", () => { page.click('[data-testid="confirm"]'), page.waitForResponse((response) => response.url().includes("/api/trpc/bookings/confirm")), ]); + + await page.goto("/bookings/unconfirmed"); // This is the only booking in there that needed confirmation and now it should be empty screen await expect(page.locator('[data-testid="empty-screen"]')).toBeVisible(); }); @@ -227,6 +229,45 @@ test.describe("pro user", () => { const firstSlotAvailableText = await firstSlotAvailable.innerText(); expect(firstSlotAvailableText).toContain("9:00"); }); + + test("Cannot confirm booking for a slot, if another confirmed booking already exists for same slot.", async ({ + page, + users, + }) => { + // First booking done for first available time slot in next month + await bookOptinEvent(page); + + const [pro] = users.get(); + await page.goto(`/${pro.username}`); + + // Second booking done for same time slot + await bookOptinEvent(page); + + await pro.apiLogin(); + + await page.goto("/bookings/unconfirmed"); + + // Confirm first booking + await Promise.all([ + page.click('[data-testid="confirm"]'), + page.waitForResponse((response) => response.url().includes("/api/trpc/bookings/confirm")), + ]); + + await Promise.all([ + page.goto("/bookings/unconfirmed"), + page.waitForResponse((response) => response.url().includes("/api/trpc/bookings/get")), + ]); + + // Confirm second booking + await page.click('[data-testid="confirm"]'); + const response = await page.waitForResponse( + (response) => response.url().includes("/api/trpc/bookings/confirm") && response.status() !== 200 + ); + const responseObj = await response.json(); + + expect(responseObj[0]?.error?.json?.data?.code).toEqual("BAD_REQUEST"); + expect(responseObj[0]?.error?.json?.message).toEqual("Slot already confirmed for other booking"); + }); }); test.describe("prefill", () => { diff --git a/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts b/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts index d3182bdfc0..d73f47bc03 100644 --- a/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts +++ b/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts @@ -7,6 +7,7 @@ import { handleConfirmation } from "@calcom/features/bookings/lib/handleConfirma import { handleWebhookTrigger } from "@calcom/features/bookings/lib/handleWebhookTrigger"; import type { EventTypeInfo } from "@calcom/features/webhooks/lib/sendPayload"; import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib"; +import { getUsernameList } from "@calcom/lib/defaultEvents"; import { getTeamIdFromEventType } from "@calcom/lib/getTeamIdFromEventType"; import { getTranslation } from "@calcom/lib/server"; import { getUsersCredentials } from "@calcom/lib/server/getUsersCredentials"; @@ -19,6 +20,7 @@ import type { IAbstractPaymentService, PaymentApp } from "@calcom/types/PaymentS import { TRPCError } from "@trpc/server"; import type { TrpcSessionUser } from "../../../trpc"; +import { getAvailableSlots } from "../slots/util"; import type { TConfirmInputSchema } from "./confirm.schema"; import type { BookingsProcedureContext } from "./util"; @@ -55,6 +57,7 @@ export const confirmHandler = async ({ ctx, input }: ConfirmOptions) => { teamId: true, recurringEvent: true, title: true, + slug: true, requiresConfirmation: true, currency: true, length: true, @@ -113,6 +116,27 @@ export const confirmHandler = async ({ ctx, input }: ConfirmOptions) => { throw new TRPCError({ code: "BAD_REQUEST", message: "Booking already confirmed" }); } + // Check for user's slot availability for current booking start and end time + if (confirmed) { + const slotsAvailable = await getAvailableSlots({ + input: { + startTime: booking?.startTime.toISOString(), + endTime: booking?.endTime.toISOString(), + eventTypeId: booking?.eventType?.id, + eventTypeSlug: booking?.eventType?.slug, + timeZone: user?.timeZone, + usernameList: getUsernameList(user?.username ?? ""), + isTeamEvent: !!booking?.eventType?.teamId || false, + }, + }); + + // If no free slot available with current booking request's start time and + // end time, then the slot is booked for other booking ,throw error + if (slotsAvailable?.slots && Object.keys(slotsAvailable.slots).length === 0) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Slot already confirmed for other booking" }); + } + } + // If booking requires payment and is not paid, we don't allow confirmation if (confirmed && booking.payment.length > 0 && !booking.paid) { await prisma.booking.update({