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:
Joe Au-Yeung
2025-05-02 00:46:19 +01:00
committed by GitHub
parent eedef35d02
commit 6fffe3377d
4 changed files with 101 additions and 37 deletions
+6 -3
View File
@@ -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 }) {