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
This commit is contained in:
@@ -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 }) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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 }) {
|
||||
|
||||
Reference in New Issue
Block a user