fix: #10789 Prevent Double Booking (for requests that require confirmation) (#10882)

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: alannnc <alannnc@gmail.com>
Co-authored-by: zomars <zomars@me.com>
pull/11431/head^2
Vijay 2023-09-28 02:24:22 +05:30 committed by GitHub
parent a2df323cf8
commit d7c3132fa5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 71 additions and 4 deletions

View File

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

View File

@ -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", () => {

View File

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