diff --git a/.env.example b/.env.example index 60c9537af3..c92bd759fc 100644 --- a/.env.example +++ b/.env.example @@ -412,3 +412,13 @@ DIRECTORY_IDS_TO_LOG= # Set this when Cal.com is used to serve only one organization's booking pages # Read more about it in the README.md NEXT_PUBLIC_SINGLE_ORG_SLUG= + +## Env variables related to avoiding booking failures +# Request for checking reservation would be attempted to send every these seconds if the request is stale at that time +NEXT_PUBLIC_QUERY_RESERVATION_INTERVAL_SECONDS= +# Request for checking reservation of same slot would be sent only after this time has passed. It must be lower than PUBLIC_QUERY_RESERVATION_INTERVAL_SECONDS +NEXT_PUBLIC_QUERY_RESERVATION_STALE_TIME_SECONDS= +# Query available slots interval - Should be kept high in minutes as it could cause significant load on the system +NEXT_PUBLIC_QUERY_AVAILABLE_SLOTS_INTERVAL_SECONDS= +# Used to invalidate available slots when navigating to booking form +NEXT_PUBLIC_INVALIDATE_AVAILABLE_SLOTS_ON_BOOKING_FORM=0 \ No newline at end of file diff --git a/apps/web/playwright/booking-limits.e2e.ts b/apps/web/playwright/booking-limits.e2e.ts index 2089dcefca..d26dcf61e3 100644 --- a/apps/web/playwright/booking-limits.e2e.ts +++ b/apps/web/playwright/booking-limits.e2e.ts @@ -13,7 +13,12 @@ import { entries } from "@calcom/prisma/zod-utils"; import type { IntervalLimit } from "@calcom/types/Calendar"; import { test } from "./lib/fixtures"; -import { bookTimeSlot, confirmReschedule, createUserWithLimits } from "./lib/testUtils"; +import { + bookTimeSlot, + confirmReschedule, + createUserWithLimits, + expectSlotNotAllowedToBook, +} from "./lib/testUtils"; test.describe.configure({ mode: "parallel" }); test.afterEach(async ({ users }) => { @@ -131,9 +136,7 @@ test.describe("Booking limits", () => { // try to book directly via form page await page.goto(slotUrl); - await bookTimeSlot(page, { expectedStatusCode: 401 }); - - await expect(page.getByTestId("booking-fail")).toBeVisible({ timeout: 1000 }); + await expectSlotNotAllowedToBook(page); }); await test.step("but can reschedule", async () => { @@ -266,9 +269,8 @@ test.describe("Booking limits", () => { // try to book directly via form page await page.goto(slotUrl); - await bookTimeSlot(page, { expectedStatusCode: 401 }); - await expect(page.getByTestId("booking-fail")).toBeVisible({ timeout: 5000 }); + await expectSlotNotAllowedToBook(page); }); await test.step(`month after booking`, async () => { @@ -357,9 +359,7 @@ test.describe("Duration limits", () => { // try to book directly via form page await page.goto(slotUrl); - await bookTimeSlot(page, { expectedStatusCode: 401 }); - - await expect(page.getByTestId("booking-fail")).toBeVisible({ timeout: 1000 }); + await expectSlotNotAllowedToBook(page); }); await test.step(`month after booking`, async () => { @@ -452,9 +452,7 @@ test.describe("Duration limits", () => { // try to book directly via form page await page.goto(slotUrl); - await bookTimeSlot(page, { expectedStatusCode: 401 }); - - await expect(page.getByTestId("booking-fail")).toBeVisible({ timeout: 1000 }); + await expectSlotNotAllowedToBook(page); }); await test.step(`month after booking`, async () => { diff --git a/apps/web/playwright/booking-pages.e2e.ts b/apps/web/playwright/booking-pages.e2e.ts index c5637246d5..80ee5d2c0b 100644 --- a/apps/web/playwright/booking-pages.e2e.ts +++ b/apps/web/playwright/booking-pages.e2e.ts @@ -13,6 +13,7 @@ import { bookTimeSlot, confirmBooking, confirmReschedule, + expectSlotNotAllowedToBook, selectFirstAvailableTimeSlotNextMonth, testEmail, testName, @@ -126,10 +127,7 @@ test.describe("free user", () => { await page.goto(bookingUrl); - // book same time spot again - await bookTimeSlot(page, { expectedStatusCode: 409 }); - - await page.locator("[data-testid=booking-fail]").waitFor({ state: "visible" }); + await expectSlotNotAllowedToBook(page); }); }); @@ -322,23 +320,24 @@ test.describe("pro user", () => { await Promise.all(promises); }); - test("Time slots should be reserved when selected", async ({ context, page }) => { + test("Time slots should be reserved when selected", async ({ context, page, browser }) => { const initialUrl = page.url(); await page.locator('[data-testid="event-type-link"]').first().click(); await selectFirstAvailableTimeSlotNextMonth(page); - const pageTwo = await context.newPage(); - await pageTwo.goto(initialUrl); - await pageTwo.waitForURL(initialUrl); - await pageTwo.locator('[data-testid="event-type-link"]').first().click(); + const newContext = await browser.newContext(); + const pageTwoInNewContext = await newContext.newPage(); + await pageTwoInNewContext.goto(initialUrl); + await pageTwoInNewContext.waitForURL(initialUrl); + await pageTwoInNewContext.locator('[data-testid="event-type-link"]').first().click(); - await pageTwo.locator('[data-testid="incrementMonth"]').waitFor(); - await pageTwo.click('[data-testid="incrementMonth"]'); - await pageTwo.locator('[data-testid="day"][data-disabled="false"]').nth(0).waitFor(); - await pageTwo.locator('[data-testid="day"][data-disabled="false"]').nth(0).click(); + await pageTwoInNewContext.locator('[data-testid="incrementMonth"]').waitFor(); + await pageTwoInNewContext.click('[data-testid="incrementMonth"]'); + await pageTwoInNewContext.locator('[data-testid="day"][data-disabled="false"]').nth(0).waitFor(); + await pageTwoInNewContext.locator('[data-testid="day"][data-disabled="false"]').nth(0).click(); // 9:30 should be the first available time slot - await pageTwo.locator('[data-testid="time"]').nth(0).waitFor(); - const firstSlotAvailable = pageTwo.locator('[data-testid="time"]').nth(0); + await pageTwoInNewContext.locator('[data-testid="time"]').nth(0).waitFor(); + const firstSlotAvailable = pageTwoInNewContext.locator('[data-testid="time"]').nth(0); // Find text inside the element const firstSlotAvailableText = await firstSlotAvailable.innerText(); expect(firstSlotAvailableText).toContain("9:30"); diff --git a/apps/web/playwright/lib/testUtils.ts b/apps/web/playwright/lib/testUtils.ts index d8742c20ad..1b02564d4d 100644 --- a/apps/web/playwright/lib/testUtils.ts +++ b/apps/web/playwright/lib/testUtils.ts @@ -171,6 +171,13 @@ export const bookTimeSlot = async ( }); }; +export async function expectSlotNotAllowedToBook(page: Page) { + await page.waitForResponse((response) => { + return response.url().includes("/slots/isAvailable"); + }); + await expect(page.locator("[data-testid=slot-not-allowed-to-book]")).toBeVisible(); +} + // Provide an standalone localize utility not managed by next-i18n export async function localize(locale: string) { const localeModule = `../../public/static/locales/${locale}/common.json`; diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 89125970b2..e2468711ed 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -85,6 +85,9 @@ "payment_not_created_error": "Payment could not be created", "couldnt_charge_card_error": "Could not charge card for Payment", "no_available_users_found_error": "No available users found. Could you try another time slot?", + "timeslot_unavailable_book_a_new_time": "The selected time slot is no longer available. <2>Please select a new time", + "timeslot_unavailable_short": "Taken", + "unavailable_timeslot_title": "Unavailable timeslot", "booking_time_out_of_bounds_error": "The event type cannot be booked at this time. Could you try another time slot?", "request_body_end_time_internal_error": "Internal Error. Request body does not contain end time", "create_calendar_event_error": "Unable to create Calendar event in Organizer's calendar", diff --git a/apps/web/test/lib/getSchedule/selectedSlots.test.ts b/apps/web/test/lib/getSchedule/selectedSlots.test.ts new file mode 100644 index 0000000000..dbbc2a1709 --- /dev/null +++ b/apps/web/test/lib/getSchedule/selectedSlots.test.ts @@ -0,0 +1,393 @@ +import { + getGoogleCalendarCredential, + createBookingScenario, + Timezones, + TestData, + type ScenarioData, +} from "../../utils/bookingScenario/bookingScenario"; + +import type { IncomingMessage } from "http"; +import { describe, test, beforeEach, vi } from "vitest"; +import type { z } from "zod"; + +import { prisma } from "@calcom/prisma"; +import type { GetScheduleOptions } from "@calcom/trpc/server/routers/viewer/slots/getSchedule.handler"; +import type { getScheduleSchema } from "@calcom/trpc/server/routers/viewer/slots/types"; +import { getAvailableSlots as getSchedule } from "@calcom/trpc/server/routers/viewer/slots/util"; + +import { expect } from "./expects"; +import { setupAndTeardown } from "./setupAndTeardown"; + +type ScheduleScenario = { + eventTypes: ScenarioData["eventTypes"]; + users: ScenarioData["users"]; + apps: ScenarioData["apps"]; + selectedSlots?: ScenarioData["selectedSlots"]; +}; + +const getTestScheduleInput = ({ + yesterdayDateString, + plus5DateString, +}: { + yesterdayDateString: string; + plus5DateString: string; +}): z.infer => ({ + eventTypeId: 1, + eventTypeSlug: "", + startTime: `${yesterdayDateString}T18:30:00.000Z`, + endTime: `${plus5DateString}T18:29:59.999Z`, + timeZone: Timezones["+5:30"], + isTeamEvent: false, + orgSlug: null, +}); + +const getBaseScenarioData = (): ScheduleScenario => ({ + eventTypes: [ + { + id: 1, + slotInterval: 45, + length: 45, + users: [{ id: 101 }], + }, + ], + users: [ + { + ...TestData.users.example, + id: 101, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + }, + ], + apps: [TestData.apps["google-calendar"]], +}); + +describe("getSchedule", () => { + setupAndTeardown(); + + describe("Reserved Slots", () => { + beforeEach(async () => { + // Clear any existing selected slots before each test + await prisma.selectedSlots.deleteMany({}); + }); + + test("should block slot from being available when reserved by another user", async () => { + // In IST timezone, it is 2024-05-31T07:00:00 + vi.setSystemTime("2024-05-31T01:30:00Z"); + const yesterdayDateString = "2024-05-30"; + const plus2DateString = "2024-06-02"; + const plus5DateString = "2024-06-05"; + + const scenarioData: ScheduleScenario = { + ...getBaseScenarioData(), + selectedSlots: [ + { + eventTypeId: 1, + userId: 101, + slotUtcStartDate: new Date(`${plus2DateString}T04:00:00.000Z`), + slotUtcEndDate: new Date(`${plus2DateString}T04:45:00.000Z`), + uid: "other-user-uid", + releaseAt: new Date(Date.now() + 1000 * 60 * 60), // 1 hour from now + }, + ], + }; + + await createBookingScenario(scenarioData); + + const schedule = await getSchedule({ + input: getTestScheduleInput({ yesterdayDateString, plus5DateString }), + }); + + // The 4:00 slot should not be available as it's reserved by another user + expect(schedule).not.toHaveTimeSlots([`04:00:00.000Z`], { + dateString: plus2DateString, + }); + }); + + test("should keep all slots available when slot is reserved by the same user", async () => { + // In IST timezone, it is 2024-05-31T07:00:00 + vi.setSystemTime("2024-05-31T01:30:00Z"); + const yesterdayDateString = "2024-05-30"; + const plus2DateString = "2024-06-02"; + const plus5DateString = "2024-06-05"; + + const bookerClientUid = "same-user-uid"; + + const scenarioData: ScheduleScenario = { + ...getBaseScenarioData(), + selectedSlots: [ + { + eventTypeId: 1, + userId: 101, + slotUtcStartDate: new Date(`${plus2DateString}T04:00:00.000Z`), + slotUtcEndDate: new Date(`${plus2DateString}T04:45:00.000Z`), + uid: bookerClientUid, + releaseAt: new Date(Date.now() + 1000 * 60 * 60), // 1 hour from now + }, + ], + }; + + await createBookingScenario(scenarioData); + + const schedule = await getSchedule({ + input: getTestScheduleInput({ yesterdayDateString, plus5DateString }), + ctx: { + req: { + cookies: { + uid: bookerClientUid, + }, + } as IncomingMessage & { cookies: { uid: string } }, + }, + } satisfies GetScheduleOptions); + + // All slots should be available as they're reserved by the same user + expect(schedule).toHaveTimeSlots( + [ + "04:00:00.000Z", + "04:45:00.000Z", + "05:30:00.000Z", + "06:15:00.000Z", + "07:00:00.000Z", + "07:45:00.000Z", + "08:30:00.000Z", + "09:15:00.000Z", + "10:00:00.000Z", + "10:45:00.000Z", + "11:30:00.000Z", + ], + { + dateString: plus2DateString, + } + ); + }); + + test("should make all slots available when reservation is expired", async () => { + // In IST timezone, it is 2024-05-31T07:00:00 + vi.setSystemTime("2024-05-31T01:30:00Z"); + const yesterdayDateString = "2024-05-30"; + const plus2DateString = "2024-06-02"; + const plus5DateString = "2024-06-05"; + + const scenarioData: ScheduleScenario = { + ...getBaseScenarioData(), + selectedSlots: [ + { + eventTypeId: 1, + userId: 101, + slotUtcStartDate: new Date(`${plus2DateString}T04:00:00.000Z`), + slotUtcEndDate: new Date(`${plus2DateString}T04:45:00.000Z`), + uid: "expired-slot-uid", + releaseAt: new Date(Date.now() - 1000 * 60 * 60), // 1 hour ago (expired) + }, + ], + }; + + await createBookingScenario(scenarioData); + + const schedule = await getSchedule({ + input: getTestScheduleInput({ yesterdayDateString, plus5DateString }), + }); + + // All slots should be available as the reservation is expired + expect(schedule).toHaveTimeSlots( + [ + "04:00:00.000Z", + "04:45:00.000Z", + "05:30:00.000Z", + "06:15:00.000Z", + "07:00:00.000Z", + "07:45:00.000Z", + "08:30:00.000Z", + "09:15:00.000Z", + "10:00:00.000Z", + "10:45:00.000Z", + "11:30:00.000Z", + ], + { + dateString: plus2DateString, + } + ); + }); + + test("should show correct attendee count as per reserved slots", async () => { + // In IST timezone, it is 2024-05-31T07:00:00 + vi.setSystemTime("2024-05-31T01:30:00Z"); + const yesterdayDateString = "2024-05-30"; + const plus2DateString = "2024-06-02"; + const plus5DateString = "2024-06-05"; + + const scenarioData: ScheduleScenario = { + ...getBaseScenarioData(), + eventTypes: [ + { + ...getBaseScenarioData().eventTypes[0], + seatsPerTimeSlot: 5, // Total seats available + }, + ], + selectedSlots: [ + { + eventTypeId: 1, + userId: 101, + slotUtcStartDate: new Date(`${plus2DateString}T04:00:00.000Z`), + slotUtcEndDate: new Date(`${plus2DateString}T04:45:00.000Z`), + uid: "other-user-uid", + releaseAt: new Date(Date.now() + 1000 * 60 * 60), // 1 hour from now + isSeat: true, + }, + ], + }; + + await createBookingScenario(scenarioData); + + const schedule = await getSchedule({ + input: getTestScheduleInput({ yesterdayDateString, plus5DateString }), + }); + + // The 4:00 slot should show 1 seat taken + const slot = schedule.slots[plus2DateString].find( + (slot) => slot.time === `${plus2DateString}T04:00:00.000Z` + ); + expect(slot).toBeDefined(); + expect(slot?.attendees).toBe(1); + }); + + test("should block slots even when reservation is for a different event type", async () => { + // In IST timezone, it is 2024-05-31T07:00:00 + vi.setSystemTime("2024-05-31T01:30:00Z"); + const yesterdayDateString = "2024-05-30"; + const plus2DateString = "2024-06-02"; + const plus5DateString = "2024-06-05"; + const differentEventTypeId = 2; + const scenarioData: ScheduleScenario = { + ...getBaseScenarioData(), + eventTypes: [ + { + id: 1, + slotInterval: 45, + length: 45, + users: [{ id: 101 }], + }, + { + id: differentEventTypeId, + slotInterval: 45, + length: 45, + users: [{ id: 101 }], + }, + ], + selectedSlots: [ + { + eventTypeId: differentEventTypeId, // Different event type + userId: 101, + slotUtcStartDate: new Date(`${plus2DateString}T04:00:00.000Z`), + slotUtcEndDate: new Date(`${plus2DateString}T04:45:00.000Z`), + uid: "other-user-uid", + releaseAt: new Date(Date.now() + 1000 * 60 * 60), // 1 hour from now + }, + ], + }; + + await createBookingScenario(scenarioData); + + const schedule = await getSchedule({ + input: getTestScheduleInput({ yesterdayDateString, plus5DateString }), + }); + + // The 4:00 slot should still be unavailable even when the reservation is for a different event type + expect(schedule).toHaveTimeSlots( + [ + // "04:00:00.000Z", + "04:45:00.000Z", + "05:30:00.000Z", + "06:15:00.000Z", + "07:00:00.000Z", + "07:45:00.000Z", + "08:30:00.000Z", + "09:15:00.000Z", + "10:00:00.000Z", + "10:45:00.000Z", + "11:30:00.000Z", + ], + { + dateString: plus2DateString, + } + ); + }); + + test("should clean up only expired slots", async () => { + // In IST timezone, it is 2024-05-31T07:00:00 + vi.setSystemTime("2024-05-31T01:30:00Z"); + const yesterdayDateString = "2024-05-30"; + const plus2DateString = "2024-06-02"; + const plus5DateString = "2024-06-05"; + + const bookerClientUid = "same-user-uid"; + const expiredTime = new Date(Date.now() - 1000 * 60 * 60); // 1 hour ago (expired) + const notExpiredTime = new Date(Date.now() + 1000 * 60 * 60); // 1 hour from now (not expired) + const scenarioData: ScheduleScenario = { + ...getBaseScenarioData(), + selectedSlots: [ + // Will be deleted + { + eventTypeId: 1, + userId: 101, + slotUtcStartDate: new Date(`${plus2DateString}T04:00:00.000Z`), + slotUtcEndDate: new Date(`${plus2DateString}T04:45:00.000Z`), + uid: bookerClientUid, + releaseAt: expiredTime, + }, + // Will be not deleted + { + eventTypeId: 1, + userId: 101, + slotUtcStartDate: new Date(`${plus2DateString}T05:00:00.000Z`), + slotUtcEndDate: new Date(`${plus2DateString}T05:45:00.000Z`), + uid: bookerClientUid, + releaseAt: notExpiredTime, + }, + // Will be not deleted + { + eventTypeId: 1, + userId: 101, + slotUtcStartDate: new Date(`${plus2DateString}T06:00:00.000Z`), + slotUtcEndDate: new Date(`${plus2DateString}T06:45:00.000Z`), + uid: "another-user-uid", + releaseAt: notExpiredTime, + }, + // Will be deleted + { + eventTypeId: 1, + userId: 101, + slotUtcStartDate: new Date(`${plus2DateString}T07:00:00.000Z`), + slotUtcEndDate: new Date(`${plus2DateString}T07:45:00.000Z`), + uid: "another-user-uid", + releaseAt: expiredTime, + }, + ], + }; + + await createBookingScenario(scenarioData); + + await getSchedule({ + input: getTestScheduleInput({ yesterdayDateString, plus5DateString }), + ctx: { + req: { + cookies: { + uid: bookerClientUid, + }, + } as IncomingMessage & { cookies: { uid: string } }, + }, + } satisfies GetScheduleOptions); + + // Verify that both slots are cleaned up from DB for this event type + const remainingSlots = await prisma.selectedSlots.findMany({ + where: { + userId: 101, + eventTypeId: 1, + }, + }); + + // There are two unexpired slots + expect(remainingSlots).toHaveLength(2); + }); + }); +}); diff --git a/apps/web/test/utils/bookingScenario/bookingScenario.ts b/apps/web/test/utils/bookingScenario/bookingScenario.ts index 07bfcdf85c..a94606e9a5 100644 --- a/apps/web/test/utils/bookingScenario/bookingScenario.ts +++ b/apps/web/test/utils/bookingScenario/bookingScenario.ts @@ -104,6 +104,17 @@ type InputHost = { isFixed?: boolean; scheduleId?: number | null; }; + +type InputSelectedSlot = { + eventTypeId: number; + userId: number; + slotUtcStartDate: Date; + slotUtcEndDate: Date; + uid: string; + releaseAt: Date; + isSeat?: boolean; +}; + /** * Data to be mocked */ @@ -124,6 +135,7 @@ export type ScenarioData = { webhooks?: InputWebhook[]; workflows?: InputWorkflow[]; payment?: InputPayment[]; + selectedSlots?: InputSelectedSlot[]; }; type InputCredential = typeof TestData.credentials.google & { @@ -836,6 +848,16 @@ async function addAppsToDb(apps: any[]) { const allApps = await prismock.app.findMany(); log.silly("TestData: Apps as in DB", JSON.stringify({ apps: allApps })); } + +async function addSelectedSlotsToDb(selectedSlots: InputSelectedSlot[]) { + log.silly("TestData: Creating Selected Slots", JSON.stringify(selectedSlots)); + await prismock.selectedSlots.createMany({ + data: selectedSlots, + }); + const allSelectedSlots = await prismock.selectedSlots.findMany(); + log.silly("TestData: Selected Slots as in DB", JSON.stringify({ selectedSlots: allSelectedSlots })); +} + export async function createBookingScenario(data: ScenarioData) { log.silly("TestData: Creating Scenario", JSON.stringify({ data })); await addUsers(data.users); @@ -859,6 +881,10 @@ export async function createBookingScenario(data: ScenarioData) { const workflows = await addWorkflows(data.workflows || []); await addPaymentToDb(data.payment || []); + if (data.selectedSlots) { + await addSelectedSlotsToDb(data.selectedSlots); + } + return { eventTypes, workflows, diff --git a/packages/features/bookings/Booker/Booker.tsx b/packages/features/bookings/Booker/Booker.tsx index 66383fa85c..b5c4398542 100644 --- a/packages/features/bookings/Booker/Booker.tsx +++ b/packages/features/bookings/Booker/Booker.tsx @@ -12,6 +12,7 @@ import useSkipConfirmStep from "@calcom/features/bookings/Booker/components/hook import { getQueryParam } from "@calcom/features/bookings/Booker/utils/query-param"; import { useNonEmptyScheduleDays } from "@calcom/features/schedules"; import classNames from "@calcom/lib/classNames"; +import { PUBLIC_INVALIDATE_AVAILABLE_SLOTS_ON_BOOKING_FORM } from "@calcom/lib/constants"; import { CLOUDFLARE_SITE_ID, CLOUDFLARE_USE_TURNSTILE_IN_BOOKER } from "@calcom/lib/constants"; import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams"; import { BookerLayouts } from "@calcom/prisma/zod-utils"; @@ -34,6 +35,7 @@ import { fadeInLeft, getBookerSizeClassNames, useBookerResizeAnimation } from ". import { useBookerStore } from "./store"; import type { BookerProps, WrappedBookerProps } from "./types"; import { isBookingDryRun } from "./utils/isBookingDryRun"; +import { isTimeSlotAvailable } from "./utils/isTimeslotAvailable"; const TurnstileCaptcha = dynamic(() => import("@calcom/features/auth/Turnstile"), { ssr: false }); @@ -101,8 +103,7 @@ const BookerComponent = ({ (state) => [state.seatedEventData, state.setSeatedEventData], shallow ); - const { selectedTimeslot, setSelectedTimeslot } = slots; - + const { selectedTimeslot, setSelectedTimeslot, allSelectedTimeslots } = slots; const [dayCount, setDayCount] = useBookerStore((state) => [state.dayCount, state.setDayCount], shallow); const nonEmptyScheduleDays = useNonEmptyScheduleDays(schedule?.data?.slots).filter( @@ -188,6 +189,14 @@ const BookerComponent = ({ return setBookerState("booking"); }, [event, selectedDate, selectedTimeslot, setBookerState, skipConfirmStep, layout, isInstantMeeting]); + const unavailableTimeSlots = allSelectedTimeslots.filter((slot) => { + return !isTimeSlotAvailable({ + scheduleData: schedule?.data ?? null, + slotToCheckInIso: slot, + quickAvailabilityChecks: slots.quickAvailabilityChecks, + }); + }); + const slot = getQueryParam("slot"); useEffect(() => { @@ -205,6 +214,11 @@ const BookerComponent = ({ shouldRenderCaptcha={shouldRenderCaptcha} onCancel={() => { setSelectedTimeslot(null); + // Temporarily allow disabling it, till we are sure that it doesn't cause any significant load on the system + if (PUBLIC_INVALIDATE_AVAILABLE_SLOTS_ON_BOOKING_FORM) { + // Ensures that user has latest available slots when they want to re-choose from the slots + schedule?.invalidate(); + } if (seatedEventData.bookingUid) { setSeatedEventData({ ...seatedEventData, bookingUid: undefined, attendees: undefined }); } @@ -212,6 +226,7 @@ const BookerComponent = ({ onSubmit={() => (renderConfirmNotVerifyEmailButtonCond ? handleBookEvent() : handleVerifyEmail())} errorRef={bookerFormErrorRef} errors={{ ...formErrors, ...errors }} + isTimeslotUnavailable={unavailableTimeSlots.includes(selectedTimeslot || "")} loadingStates={loadingStates} renderConfirmNotVerifyEmailButtonCond={renderConfirmNotVerifyEmailButtonCond} bookingForm={bookingForm} @@ -260,6 +275,7 @@ const BookerComponent = ({ isPlatform, shouldRenderCaptcha, isVerificationCodeSending, + unavailableTimeSlots, ]); /** @@ -449,9 +465,10 @@ const BookerComponent = ({ customClassNames={customClassNames?.availableTimeSlotsCustomClassNames} extraDays={extraDays} limitHeight={layout === BookerLayouts.MONTH_VIEW} - schedule={schedule?.data} + schedule={schedule} isLoading={schedule.isPending} seatsPerTimeSlot={event.data?.seatsPerTimeSlot} + unavailableTimeSlots={unavailableTimeSlots} showAvailableSeatsCount={event.data?.seatsShowAvailabilityCount} event={event} loadingStates={loadingStates} diff --git a/packages/features/bookings/Booker/README.md b/packages/features/bookings/Booker/README.md new file mode 100644 index 0000000000..e2d5761e88 --- /dev/null +++ b/packages/features/bookings/Booker/README.md @@ -0,0 +1,24 @@ +## Strategies to prevent error during booking +- The `getSchedule` call, fetches only those slots that are bookable including not showing the slots that are reserved by someone else. + - The booking call and the get scheduled call share the same availability checking logic to avoid scenario where `getSchedule` considers a slot as available but Booking call doesn't consider it available. +- Even though a slot might be available, multiple people might have opened the same booking page and might want to book the same slot. + - We have a reservation system in place that avoid showing a slot that is reserved by someone to other people. + - The `getSchedule` call itself considers the reservations and doesn't show those slots. + - When two persons open the same booking page at the same time, they might will see the same slots. + - If Person1 selects slot1, it will be reserved by Person1. Person2 still sees the slot(because `getSchedule` is refetched after a long time(in minutes)). But when Person2 selects the same slot1, he will go to "Slot no-longer available" state. + - Also when the Person2, comes back to the slots listing page, he will see the slot as unavailable now. It works because getSchedule would have been fetched by that time which happened when he selected the slot earlier. +- We don't check for reservation during the confirm booking call, because a reservation has lower priority then the actual booking. + + + +### "Slot no-longer available" state +- When a slot is no longer available, we disable the "Confirm" button which does the booking. +- We show a message to the user that he should select a different slot. +- To ensure that we can go to this state, whenever applicable, we do the following: + - `getSchedule` fetching. It prevents the unavailable slot to not appear in the list. + - Fetched on window focus to handle the users who have the page opened for some time then come back to it + - Fetched every 5 mins(configurable by .env) as well to handle users who are just there on the page for a long time. + - Fetched on selecting a slot. + - `isReservation` query. It prevents the unavailable slot to be not bookable by the User + - Fetched on regular quick intervals(currently every 10s(configurable by .env)). Even though it doesn't detect actual busy/booked slots, it still avoids the attempted booking of a reserved slot. + diff --git a/packages/features/bookings/Booker/__tests__/utils/isSlotEquivalent.test.ts b/packages/features/bookings/Booker/__tests__/utils/isSlotEquivalent.test.ts new file mode 100644 index 0000000000..f586c3271f --- /dev/null +++ b/packages/features/bookings/Booker/__tests__/utils/isSlotEquivalent.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; + +import { isSlotEquivalent } from "../../utils/isSlotEquivalent"; + +describe("isSlotEquivalent", () => { + it("should return true for exactly matching slots", () => { + const slotTimeInIso = "2024-02-08T10:30:00.000Z"; + const slotToCheckInIso = "2024-02-08T10:30:00.000Z"; + expect(isSlotEquivalent({ slotTimeInIso, slotToCheckInIso })).toBe(true); + }); + + it("should return true when only seconds differ", () => { + const slotTimeInIso = "2024-02-08T10:30:00.000Z"; + const slotToCheckInIso = "2024-02-08T10:30:45.000Z"; + expect(isSlotEquivalent({ slotTimeInIso, slotToCheckInIso })).toBe(true); + }); + + it("should return false for different minutes", () => { + const slotTimeInIso = "2024-02-08T10:30:00.000Z"; + const slotToCheckInIso = "2024-02-08T10:31:00.000Z"; + expect(isSlotEquivalent({ slotTimeInIso, slotToCheckInIso })).toBe(false); + }); + + it("should return false for different hours", () => { + const slotTimeInIso = "2024-02-08T10:30:00.000Z"; + const slotToCheckInIso = "2024-02-08T11:30:00.000Z"; + expect(isSlotEquivalent({ slotTimeInIso, slotToCheckInIso })).toBe(false); + }); + + it("should return false for different dates", () => { + const slotTimeInIso = "2024-02-08T10:30:00.000Z"; + const slotToCheckInIso = "2024-02-09T10:30:00.000Z"; + expect(isSlotEquivalent({ slotTimeInIso, slotToCheckInIso })).toBe(false); + }); + + it("should return true for invalid ISO string formats", () => { + const slotTimeInIso = "2024-2-8T10:30:00.000Z"; + const slotToCheckInIso = "2024-02-08T10:30:00.000Z"; + expect(isSlotEquivalent({ slotTimeInIso, slotToCheckInIso })).toBe(true); + }); + + it("should return true when both inputs are invalid", () => { + const slotTimeInIso = "invalid-date-1"; + const slotToCheckInIso = "invalid-date-2"; + expect(isSlotEquivalent({ slotTimeInIso, slotToCheckInIso })).toBe(true); + }); +}); diff --git a/packages/features/bookings/Booker/components/AvailableTimeSlots.tsx b/packages/features/bookings/Booker/components/AvailableTimeSlots.tsx index 4ea3044f76..cd5d87a546 100644 --- a/packages/features/bookings/Booker/components/AvailableTimeSlots.tsx +++ b/packages/features/bookings/Booker/components/AvailableTimeSlots.tsx @@ -8,6 +8,7 @@ import { useNonEmptyScheduleDays } from "@calcom/features/schedules"; import type { Slot } from "@calcom/features/schedules"; import { useSlotsForAvailableDates } from "@calcom/features/schedules/lib/use-schedule/useSlotsForDate"; import { classNames } from "@calcom/lib"; +import { PUBLIC_INVALIDATE_AVAILABLE_SLOTS_ON_BOOKING_FORM } from "@calcom/lib/constants"; import { BookerLayouts } from "@calcom/prisma/zod-utils"; import { AvailableTimesHeader } from "../../components/AvailableTimesHeader"; @@ -18,7 +19,7 @@ import { getQueryParam } from "../utils/query-param"; type AvailableTimeSlotsProps = { extraDays?: number; limitHeight?: boolean; - schedule?: useScheduleForEventReturnType["data"]; + schedule?: useScheduleForEventReturnType; isLoading: boolean; seatsPerTimeSlot?: number | null; showAvailableSeatsCount?: boolean | null; @@ -39,6 +40,10 @@ type AvailableTimeSlotsProps = { skipConfirmStep: boolean; shouldRenderCaptcha?: boolean; watchedCfToken?: string; + /** + * This is the list of time slots that are unavailable to book + */ + unavailableTimeSlots: string[]; }; /** @@ -59,6 +64,7 @@ export const AvailableTimeSlots = ({ skipConfirmStep, seatsPerTimeSlot, onSubmit, + unavailableTimeSlots, ...props }: AvailableTimeSlotsProps) => { const selectedDate = useBookerStore((state) => state.selectedDate); @@ -69,8 +75,33 @@ export const AvailableTimeSlots = ({ const [layout] = useBookerStore((state) => [state.layout]); const isColumnView = layout === BookerLayouts.COLUMN_VIEW; const containerRef = useRef(null); + const { setTentativeSelectedTimeslots, tentativeSelectedTimeslots } = useBookerStore((state) => ({ + setTentativeSelectedTimeslots: state.setTentativeSelectedTimeslots, + tentativeSelectedTimeslots: state.tentativeSelectedTimeslots, + })); - const nonEmptyScheduleDays = useNonEmptyScheduleDays(schedule?.slots); + const onTentativeTimeSelect = ({ + time, + attendees: _attendees, + seatsPerTimeSlot: _seatsPerTimeSlot, + bookingUid: _bookingUid, + }: { + time: string; + attendees: number; + seatsPerTimeSlot?: number | null; + bookingUid?: string; + }) => { + // We don't intentionally invalidate schedule here because that could remove the slot itself that was clicked, causing a bad UX. + // We could start doing that after we fix this behaviour. + // schedule?.invalidate(); + + // Earlier we had multiple tentative slots, but now we can only have one tentative slot. + setTentativeSelectedTimeslots([time]); + }; + + const scheduleData = schedule?.data; + + const nonEmptyScheduleDays = useNonEmptyScheduleDays(scheduleData?.slots); const nonEmptyScheduleDaysFromSelectedDate = nonEmptyScheduleDays.filter( (slot) => dayjs(selectedDate).diff(slot, "day") <= 0 ); @@ -85,13 +116,19 @@ export const AvailableTimeSlots = ({ return []; }, [date, extraDays, nonEmptyScheduleDaysFromSelectedDate]); - const { slotsPerDay, toggleConfirmButton } = useSlotsForAvailableDates(dates, schedule?.slots); + const { slotsPerDay, toggleConfirmButton } = useSlotsForAvailableDates(dates, scheduleData?.slots); const overlayCalendarToggled = getQueryParam("overlayCalendar") === "true" || localStorage.getItem("overlayCalendarSwitchDefault"); const onTimeSelect = useCallback( (time: string, attendees: number, seatsPerTimeSlot?: number | null, bookingUid?: string) => { + // Temporarily allow disabling it, till we are sure that it doesn't cause any significant load on the system + if (PUBLIC_INVALIDATE_AVAILABLE_SLOTS_ON_BOOKING_FORM) { + // Ensures that user has latest available slots when they are about to confirm the booking by filling up the details + schedule?.invalidate(); + } + setTentativeSelectedTimeslots([]); setSelectedTimeslot(time); if (seatsPerTimeSlot) { setSeatedEventData({ @@ -101,12 +138,22 @@ export const AvailableTimeSlots = ({ showAvailableSeatsCount, }); } - if (skipConfirmStep) { + const isTimeSlotAvailable = !unavailableTimeSlots.includes(time); + if (skipConfirmStep && isTimeSlotAvailable) { onSubmit(time); } return; }, - [onSubmit, setSeatedEventData, setSelectedTimeslot, skipConfirmStep, showAvailableSeatsCount] + [ + onSubmit, + setSeatedEventData, + setSelectedTimeslot, + skipConfirmStep, + showAvailableSeatsCount, + unavailableTimeSlots, + schedule, + setTentativeSelectedTimeslots, + ] ); const handleSlotClick = useCallback( @@ -170,6 +217,8 @@ export const AvailableTimeSlots = ({ customClassNames={customClassNames?.availableTimes} showTimeFormatToggle={!isColumnView} onTimeSelect={onTimeSelect} + onTentativeTimeSelect={onTentativeTimeSelect} + unavailableTimeSlots={unavailableTimeSlots} slots={slots.slots} showAvailableSeatsCount={showAvailableSeatsCount} skipConfirmStep={skipConfirmStep} diff --git a/packages/features/bookings/Booker/components/BookEventForm/BookEventForm.tsx b/packages/features/bookings/Booker/components/BookEventForm/BookEventForm.tsx index ead2e24b10..0275f1bea7 100644 --- a/packages/features/bookings/Booker/components/BookEventForm/BookEventForm.tsx +++ b/packages/features/bookings/Booker/components/BookEventForm/BookEventForm.tsx @@ -29,6 +29,7 @@ type BookEventFormProps = { extraOptions: Record; isPlatform?: boolean; isVerificationCodeSending: boolean; + isTimeslotUnavailable: boolean; shouldRenderCaptcha?: boolean; }; @@ -46,6 +47,7 @@ export const BookEventForm = ({ extraOptions, isVerificationCodeSending, isPlatform = false, + isTimeslotUnavailable, shouldRenderCaptcha, }: Omit & { eventQuery: { @@ -113,7 +115,7 @@ export const BookEventForm = ({ rescheduleUid={rescheduleUid || undefined} bookingData={bookingData} /> - {(errors.hasFormErrors || errors.hasDataErrors) && ( + {errors.hasFormErrors || errors.hasDataErrors ? (
- )} + ) : isTimeslotUnavailable ? ( +
+ + The selected time slot is no longer available.{" "} + + + } + /> +
+ ) : null} + {!isPlatform && (
{ + // Maintain a cache to ensure previous state is maintained as the request is fetched + // It is important because tentatively selecting a new timeslot will cause a new request which is uncached. + const cachedQuickAvailabilityChecksRef = useRef([]); + + // Create array of slots with their UTC start and end dates + const slotsToCheck = timeslotsAsISOString.map((slot) => { + const slotDayjs = dayjs(slot); + return { + utcStartIso: slotDayjs.utc().toISOString(), + utcEndIso: slotDayjs.add(eventDuration, "minutes").utc().toISOString(), + }; + }); + + const isAvailableQuery = trpc.viewer.slots.isAvailable.useQuery( + { + slots: slotsToCheck, + // enabled flag can't be true if eventTypeId is nullish + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + eventTypeId: eventTypeId!, + }, + { + refetchInterval: PUBLIC_QUERY_RESERVATION_INTERVAL_SECONDS * 1000, + refetchOnWindowFocus: true, + // We must have slotReservationId because it is possible that slotReservationId is set right after isAvailable request is made and we accidentally could consider the slot as unavailable. + // isAvailable request also prevents querying reserved slots if uid is not set. We are safe from both sides. + // TODO: We should move to creating uuid client side for reservation and not waiting for server to set uid cookie. + enabled: !!(eventTypeId && timeslotsAsISOString.length > 0 && slotReservationId), + staleTime: PUBLIC_QUERY_RESERVATION_STALE_TIME_SECONDS * 1000, + } + ); + + const quickAvailabilityChecks = isAvailableQuery.data?.slots; + + // Only valid slots response should override the cache. In worst case, we let the actual booking flow(book/event call) decide whether to go ahead with the booking or not. + if (quickAvailabilityChecks && quickAvailabilityChecks.length > 0) { + cachedQuickAvailabilityChecksRef.current = quickAvailabilityChecks; + } + + return quickAvailabilityChecks || cachedQuickAvailabilityChecksRef.current; +}; export type UseSlotsReturnType = ReturnType; export const useSlots = (event: { data?: Pick | null }) => { const selectedDuration = useBookerStore((state) => state.selectedDuration); const searchParams = useCompatSearchParams(); - const [selectedTimeslot, setSelectedTimeslot] = useBookerStore( - (state) => [state.selectedTimeslot, state.setSelectedTimeslot], - shallow - ); + const [selectedTimeslot, setSelectedTimeslot, tentativeSelectedTimeslots, setTentativeSelectedTimeslots] = + useBookerStore( + (state) => [ + state.selectedTimeslot, + state.setSelectedTimeslot, + state.tentativeSelectedTimeslots, + state.setTentativeSelectedTimeslots, + ], + shallow + ); const [slotReservationId, setSlotReservationId] = useSlotReservationId(); const reserveSlotMutation = trpc.viewer.public.slots.reserveSlot.useMutation({ trpc: { @@ -39,22 +104,36 @@ export const useSlots = (event: { data?: Pick | nu removeSelectedSlot.mutate({ uid: slotReservationId }); } }; + + const eventTypeId = event.data?.id; + const eventDuration = selectedDuration || event.data?.length || 0; + const allSelectedTimeslots = [...tentativeSelectedTimeslots, selectedTimeslot].filter( + (slot): slot is string => slot !== null + ); + + const allUniqueSelectedTimeslots = Array.from(new Set(allSelectedTimeslots)); + + const quickAvailabilityChecks = useQuickAvailabilityChecks({ + eventTypeId, + eventDuration, + timeslotsAsISOString: allUniqueSelectedTimeslots, + slotReservationId, + }); + + // In case of skipConfirm flow selectedTimeslot would never be set and instead we could have multiple tentatively selected timeslots, so we pick the latest one from it. + const timeSlotToBeBooked = selectedTimeslot ?? allSelectedTimeslots.at(-1); + const handleReserveSlot = () => { - if (event?.data?.id && selectedTimeslot && (selectedDuration || event?.data?.length)) { + if (eventTypeId && timeSlotToBeBooked && eventDuration) { reserveSlotMutation.mutate({ - slotUtcStartDate: dayjs(selectedTimeslot).utc().format(), - eventTypeId: event.data.id, - slotUtcEndDate: dayjs(selectedTimeslot) - .utc() - .add(selectedDuration || event.data.length, "minutes") - .format(), + slotUtcStartDate: dayjs(timeSlotToBeBooked).utc().toISOString(), + eventTypeId, + slotUtcEndDate: dayjs(timeSlotToBeBooked).utc().add(eventDuration, "minutes").toISOString(), _isDryRun: isBookingDryRun(searchParams), }); } }; - const timeslot = useBookerStore((state) => state.selectedTimeslot); - useEffect(() => { handleReserveSlot(); @@ -67,14 +146,18 @@ export const useSlots = (event: { data?: Pick | nu clearInterval(interval); }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [event?.data?.id, timeslot]); + }, [event?.data?.id, timeSlotToBeBooked]); return { - selectedTimeslot, setSelectedTimeslot, - setSlotReservationId, + setTentativeSelectedTimeslots, + selectedTimeslot, + tentativeSelectedTimeslots, slotReservationId, - handleReserveSlot, - handleRemoveSlot, + allSelectedTimeslots, + /** + * Faster but not that accurate as getSchedule, but doesn't give false positive, so it is usable + */ + quickAvailabilityChecks, }; }; diff --git a/packages/features/bookings/Booker/store.ts b/packages/features/bookings/Booker/store.ts index 7cddc54b2d..591544cf79 100644 --- a/packages/features/bookings/Booker/store.ts +++ b/packages/features/bookings/Booker/store.ts @@ -102,6 +102,8 @@ export type BookerStore = { */ selectedTimeslot: string | null; setSelectedTimeslot: (timeslot: string | null) => void; + tentativeSelectedTimeslots: string[]; + setTentativeSelectedTimeslots: (slots: string[]) => void; /** * Number of recurring events to create. */ @@ -388,6 +390,10 @@ export const useBookerStore = create((set, get) => ({ bookingData: null, bookingUid: null, selectedTimeslot: getQueryParam("slot") || null, + tentativeSelectedTimeslots: [], + setTentativeSelectedTimeslots: (tentativeSelectedTimeslots: string[]) => { + set({ tentativeSelectedTimeslots }); + }, setSelectedTimeslot: (selectedTimeslot: string | null) => { set({ selectedTimeslot }); updateQueryParam("slot", selectedTimeslot ?? "", false); diff --git a/packages/features/bookings/Booker/utils/event.ts b/packages/features/bookings/Booker/utils/event.ts index b81a40487b..eea28b3b7b 100644 --- a/packages/features/bookings/Booker/utils/event.ts +++ b/packages/features/bookings/Booker/utils/event.ts @@ -70,7 +70,6 @@ export const useScheduleForEvent = ({ selectedDate, orgSlug, teamMemberEmail, - fromRedirectOfNonOrgLink, isTeamEvent, }: { prefetchNextMonth?: boolean; @@ -119,5 +118,6 @@ export const useScheduleForEvent = ({ isError: schedule?.isError, isSuccess: schedule?.isSuccess, isLoading: schedule?.isLoading, + invalidate: schedule?.invalidate, }; }; diff --git a/packages/features/bookings/Booker/utils/isSlotEquivalent.ts b/packages/features/bookings/Booker/utils/isSlotEquivalent.ts new file mode 100644 index 0000000000..085d58c7ea --- /dev/null +++ b/packages/features/bookings/Booker/utils/isSlotEquivalent.ts @@ -0,0 +1,43 @@ +// Basic ISO format validation using string checks +// Without RegExp check, to keep it fast and simple +export const isValidISOFormat = (dateStr: string) => { + if (dateStr.length < 16) return false; + + // Check for required separators + return dateStr[4] === "-" && dateStr[7] === "-" && dateStr[10] === "T" && dateStr[13] === ":"; +}; + +/** + * Compares two time slots for equality, optionally ignoring seconds + * @param slotTimeInIso - The available time slot to check + * @param slotToCheckInIso - The slot to compare against + * @returns boolean indicating if the slots match + */ +export const isSlotEquivalent = ({ + slotTimeInIso, + slotToCheckInIso, +}: { + slotTimeInIso: string; + slotToCheckInIso: string; +}): boolean => { + // String comparison is faster than date comparison. So, compare string equality first + if (slotTimeInIso === slotToCheckInIso) { + return true; + } + + if (!isValidISOFormat(slotTimeInIso) || !isValidISOFormat(slotToCheckInIso)) { + console.log("Invalid ISO string format detected", { slotTimeInIso, slotToCheckInIso }); + // Consider slots equivalent + return true; + } + + // We ignore seconds and see if the minutes match + // For very short duration slots, we seem to have some bug that creates timeslots with seconds in them which isn't normally the case + // Till, we handle that, we ignore seconds explicitly. It could be better to have this even after that. + // It allows "confirm" button to be not disabled in such case. + // Actual booking confirmation, can still reject the booking if we allow a non-bookable slot to be considered available here. + // 2025-02-08T14:23:45Z -> 2025-02-08T14:23 + const availableSlotIgnoringSeconds = slotTimeInIso.slice(0, 16); + const slotToCheckIgnoringSeconds = slotToCheckInIso.slice(0, 16); + return availableSlotIgnoringSeconds === slotToCheckIgnoringSeconds; +}; diff --git a/packages/features/bookings/Booker/utils/isTimeslotAvailable.test.ts b/packages/features/bookings/Booker/utils/isTimeslotAvailable.test.ts new file mode 100644 index 0000000000..2b142b4d1d --- /dev/null +++ b/packages/features/bookings/Booker/utils/isTimeslotAvailable.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from "vitest"; + +import type { QuickAvailabilityCheck } from "../components/hooks/useSlots"; +import { isTimeSlotAvailable } from "./isTimeslotAvailable"; + +describe("isTimeSlotAvailable", () => { + // Test when a slot is unavailable according to quick availability checks + it("should return false when slot is unavailable according to quick availability checks", () => { + const slotToCheckInIso = "2024-02-08T10:30:00.000Z"; + const quickAvailabilityChecks: QuickAvailabilityCheck[] = [ + { + utcStartIso: slotToCheckInIso, + status: "unavailable", + }, + ]; + + const result = isTimeSlotAvailable({ + scheduleData: { + slots: { + "2024-02-08": [{ time: slotToCheckInIso }], + }, + }, + slotToCheckInIso, + quickAvailabilityChecks, + }); + + expect(result).toBe(false); + }); + + it("should return true when scheduleData is null", () => { + const slotToCheckInIso = "2024-02-08T10:30:00.000Z"; + const quickAvailabilityChecks: QuickAvailabilityCheck[] = []; + + const result = isTimeSlotAvailable({ + scheduleData: null, + slotToCheckInIso, + quickAvailabilityChecks, + }); + + expect(result).toBe(true); + }); + + it("should return true when the date doesn't exist in the schedule data", () => { + const slotToCheckInIso = "2024-02-08T10:30:00.000Z"; + const quickAvailabilityChecks: QuickAvailabilityCheck[] = []; + + const result = isTimeSlotAvailable({ + scheduleData: { + slots: { + "2024-02-09": [{ time: "2024-02-09T10:30:00.000Z" }], + }, + }, + slotToCheckInIso, + quickAvailabilityChecks, + }); + + expect(result).toBe(false); + }); + + it("should return true when the slot exists in the schedule data", () => { + const slotToCheckInIso = "2024-02-08T10:30:00.000Z"; + const quickAvailabilityChecks: QuickAvailabilityCheck[] = []; + + const result = isTimeSlotAvailable({ + scheduleData: { + slots: { + "2024-02-08": [{ time: slotToCheckInIso }], + }, + }, + slotToCheckInIso, + quickAvailabilityChecks, + }); + + expect(result).toBe(true); + }); + + // Test when the slot doesn't exist in the schedule data + it("should return false when the slot doesn't exist in the schedule data", () => { + const slotToCheckInIso = "2024-02-08T10:30:00.000Z"; + const differentSlotTime = "2024-02-08T11:30:00.000Z"; + const quickAvailabilityChecks: QuickAvailabilityCheck[] = []; + + const result = isTimeSlotAvailable({ + scheduleData: { + slots: { + "2024-02-08": [{ time: differentSlotTime }], + }, + }, + slotToCheckInIso, + quickAvailabilityChecks, + }); + + expect(result).toBe(false); + }); + + // Test when the slot is available according to quick availability checks + it("should check schedule data when slot is available according to quick availability checks", () => { + const slotToCheckInIso = "2024-02-08T10:30:00.000Z"; + const quickAvailabilityChecks: QuickAvailabilityCheck[] = [ + { + utcStartIso: slotToCheckInIso, + status: "available", + }, + ]; + + const result = isTimeSlotAvailable({ + scheduleData: { + slots: { + "2024-02-08": [{ time: slotToCheckInIso }], + }, + }, + slotToCheckInIso, + quickAvailabilityChecks, + }); + + expect(result).toBe(true); + }); + + // Test with seconds difference in time slots + it("should return true when slots differ only by seconds", () => { + const slotToCheckInIso = "2024-02-08T10:30:00.000Z"; + const slotWithSeconds = "2024-02-08T10:30:45.000Z"; + const quickAvailabilityChecks: QuickAvailabilityCheck[] = []; + + const result = isTimeSlotAvailable({ + scheduleData: { + slots: { + "2024-02-08": [{ time: slotWithSeconds }], + }, + }, + slotToCheckInIso, + quickAvailabilityChecks, + }); + + expect(result).toBe(true); + }); + + it("should return true(give a false positive) when the date string is not in the expected format", () => { + // Invalid ISO format -> Correct is "2024-02-08T10:30:00.000Z" + const slotToCheckInIso = "2024-02-8T10:30:00.000Z"; + const quickAvailabilityChecks: QuickAvailabilityCheck[] = []; + + const result = isTimeSlotAvailable({ + scheduleData: { + slots: { + "2024-02-08": [{ time: "2024-02-08T12:00:00.000Z" }], + }, + }, + slotToCheckInIso, + quickAvailabilityChecks, + }); + + expect(result).toBe(true); + }); +}); diff --git a/packages/features/bookings/Booker/utils/isTimeslotAvailable.ts b/packages/features/bookings/Booker/utils/isTimeslotAvailable.ts new file mode 100644 index 0000000000..2bc7ec9e1f --- /dev/null +++ b/packages/features/bookings/Booker/utils/isTimeslotAvailable.ts @@ -0,0 +1,49 @@ +import type { QuickAvailabilityCheck } from "../components/hooks/useSlots"; +import { isSlotEquivalent, isValidISOFormat } from "./isSlotEquivalent"; + +/** + * Checks if a given time slot is available in the schedule + * It should never give false negative, false positives are fine. + * It could be unavailable for any number of reasons including the slot being reserved and not actually booked + * @returns boolean - true if the slot is available, false otherwise. + */ +export const isTimeSlotAvailable = ({ + scheduleData, + slotToCheckInIso, + quickAvailabilityChecks, +}: { + scheduleData: { + slots: Record; + } | null; + slotToCheckInIso: string; + quickAvailabilityChecks: QuickAvailabilityCheck[]; +}) => { + const isUnavailableAsPerQuickCheck = + quickAvailabilityChecks && + quickAvailabilityChecks.some( + (slot) => slot.utcStartIso === slotToCheckInIso && slot.status !== "available" + ); + + if (isUnavailableAsPerQuickCheck) return false; + + // If schedule is not loaded or other variables are unavailable consider the slot available + if (!scheduleData) { + return true; + } + + const dateInIso = isValidISOFormat(slotToCheckInIso) ? slotToCheckInIso.split("T")[0] : null; + // If the date is not in ISO format, we could errorneously consider the slot unavailable, so be on the safe side and consider it available + // Though this could be a false positive, it's better to consider the slot available than unavailable + if (!dateInIso) return true; + + // Get the slots for this date + const slotsForDateInIso = scheduleData.slots[dateInIso]; + if (!slotsForDateInIso) return false; + + // Check if the exact time slot exists in the available slots + const matchFound = slotsForDateInIso.some((slot) => { + return isSlotEquivalent({ slotTimeInIso: slot.time, slotToCheckInIso }); + }); + + return matchFound; +}; diff --git a/packages/features/bookings/components/AvailableTimes.tsx b/packages/features/bookings/components/AvailableTimes.tsx index 1123ba7cdc..b9f84e37ee 100644 --- a/packages/features/bookings/components/AvailableTimes.tsx +++ b/packages/features/bookings/components/AvailableTimes.tsx @@ -30,10 +30,25 @@ type TOnTimeSelect = ( bookingUid?: string ) => void; +type TOnTentativeTimeSelect = ({ + time, + attendees, + seatsPerTimeSlot, + bookingUid, +}: { + time: string; + attendees: number; + seatsPerTimeSlot?: number | null; + bookingUid?: string; +}) => void; + export type AvailableTimesProps = { slots: IGetAvailableSlots["slots"][string]; showTimeFormatToggle?: boolean; className?: string; + // It is called when a slot is selected, but it is not a confirmation and a confirm button will be shown besides it. + onTentativeTimeSelect?: TOnTentativeTimeSelect; + unavailableTimeSlots?: string[]; } & Omit; type SlotItemProps = { @@ -41,6 +56,7 @@ type SlotItemProps = { seatsPerTimeSlot?: number | null; selectedSlots?: string[]; onTimeSelect: TOnTimeSelect; + onTentativeTimeSelect?: TOnTentativeTimeSelect; showAvailableSeatsCount?: boolean | null; event: { data?: Pick | null; @@ -52,6 +68,7 @@ type SlotItemProps = { skipConfirmStep?: boolean; shouldRenderCaptcha?: boolean; watchedCfToken?: string; + unavailableTimeSlots?: string[]; handleSlotClick?: (slot: Slot, isOverlapping: boolean) => void; }; @@ -70,6 +87,8 @@ const SlotItem = ({ shouldRenderCaptcha, watchedCfToken, handleSlotClick, + onTentativeTimeSelect, + unavailableTimeSlots = [], }: SlotItemProps) => { const { t } = useLocale(); @@ -108,6 +127,20 @@ const SlotItem = ({ offset, }); + const onButtonClick = () => { + if (handleSlotClick) { + handleSlotClick(slot, isOverlapping); + } + if (onTentativeTimeSelect) { + onTentativeTimeSelect({ + time: slot.time, + attendees: slot.attendees || 0, + seatsPerTimeSlot, + }); + } + }; + + const isTimeslotUnavailable = unavailableTimeSlots.includes(slot.time); return (
@@ -120,12 +153,13 @@ const SlotItem = ({ loadingStates?.creatingRecurringBooking || isVerificationCodeSending || loadingStates?.creatingInstantBooking || - (skipConfirmStep && !!shouldRenderCaptcha && !watchedCfToken) + (skipConfirmStep && !!shouldRenderCaptcha && !watchedCfToken) || + isTimeslotUnavailable } data-testid="time" data-disabled={bookingFull} data-time={slot.time} - onClick={() => handleSlotClick && handleSlotClick(slot, isOverlapping)} + onClick={onButtonClick} className={classNames( `hover:border-brand-default min-h-9 mb-2 flex h-auto w-full flex-grow flex-col justify-center py-2`, selectedSlots?.includes(slot.time) && "border-brand-default", @@ -171,6 +205,7 @@ const SlotItem = ({ } data-testid="skip-confirm-book-button" disabled={ + isTimeslotUnavailable || (!!shouldRenderCaptcha && !watchedCfToken) || loadingStates?.creatingBooking || loadingStates?.creatingRecurringBooking || @@ -184,13 +219,12 @@ const SlotItem = ({ isVerificationCodeSending || loadingStates?.creatingInstantBooking }> - {layout == "column_view" - ? "" - : renderConfirmNotVerifyEmailButtonCond - ? isPaidEvent - ? t("pay_and_book") - : t("confirm") - : t("verify_email_email_button")} + {(() => { + if (layout === "column_view") return ""; + if (isTimeslotUnavailable) return t("timeslot_unavailable_short"); + if (!renderConfirmNotVerifyEmailButtonCond) return t("verify_email_email_button"); + return isPaidEvent ? t("pay_and_book") : t("confirm"); + })()} diff --git a/packages/features/schedules/lib/use-schedule/useSchedule.ts b/packages/features/schedules/lib/use-schedule/useSchedule.ts index 7670df5863..aa2b528b18 100644 --- a/packages/features/schedules/lib/use-schedule/useSchedule.ts +++ b/packages/features/schedules/lib/use-schedule/useSchedule.ts @@ -2,6 +2,7 @@ import { useSearchParams } from "next/navigation"; import { useTimesForSchedule } from "@calcom/features/schedules/lib/use-schedule/useTimesForSchedule"; import { getRoutedTeamMemberIdsFromSearchParams } from "@calcom/lib/bookings/getRoutedTeamMemberIdsFromSearchParams"; +import { PUBLIC_QUERY_AVAILABLE_SLOTS_INTERVAL_SECONDS } from "@calcom/lib/constants"; import { getUsernameList } from "@calcom/lib/defaultEvents"; import { trpc } from "@calcom/trpc/react"; @@ -50,6 +51,7 @@ export const useSchedule = ({ const skipContactOwner = searchParams ? searchParams.get("cal.skipContactOwner") === "true" : false; const _cacheParam = searchParams?.get("cal.cache"); const shouldServeCache = _cacheParam ? _cacheParam === "true" : undefined; + const utils = trpc.useUtils(); const routingFormResponseIdParam = searchParams?.get("cal.routingFormResponseId"); const email = searchParams?.get("email"); @@ -87,7 +89,12 @@ export const useSchedule = ({ skipBatch: true, }, }, - refetchOnWindowFocus: false, + // It allows people who might not have the tab in focus earlier, to get latest available slots + // It might not work correctly in iframes, so we have refetchInterval to take care of that. + // But where it works, it should give user latest availability even if they come back to tab before refetchInterval. + refetchOnWindowFocus: true, + // It allows long sitting users to get latest available slots + refetchInterval: PUBLIC_QUERY_AVAILABLE_SLOTS_INTERVAL_SECONDS * 1000, enabled: Boolean(username) && Boolean(month) && @@ -96,9 +103,19 @@ export const useSchedule = ({ (Boolean(eventSlug) || Boolean(eventId) || eventId === 0), }; + let schedule; if (isTeamEvent) { - return trpc.viewer.highPerf.getTeamSchedule.useQuery(input, options); + schedule = trpc.viewer.highPerf.getTeamSchedule.useQuery(input, options); + } else { + schedule = trpc.viewer.public.slots.getSchedule.useQuery(input, options); } - - return trpc.viewer.public.slots.getSchedule.useQuery(input, options); + return { + ...schedule, + /** + * Invalidates the request and resends it regardless of any other configuration including staleTime + */ + invalidate: () => { + return utils.viewer.public.slots.getSchedule.invalidate(input); + }, + }; }; diff --git a/packages/lib/constants.ts b/packages/lib/constants.ts index 4b70fa2f2d..198bcb1377 100644 --- a/packages/lib/constants.ts +++ b/packages/lib/constants.ts @@ -65,6 +65,17 @@ export const HOSTED_CAL_FEATURES = process.env.NEXT_PUBLIC_HOSTED_CAL_FEATURES | export const IS_EUROPE = dayjs.tz.guess()?.indexOf("Europe") !== -1; +export const PUBLIC_QUERY_RESERVATION_INTERVAL_SECONDS = + parseInt(process.env.NEXT_PUBLIC_QUERY_RESERVATION_INTERVAL_SECONDS ?? "", 10) || 30; + +// Must be lower than PUBLIC_QUERY_RESERVATION_INTERVAL_SECONDS +export const PUBLIC_QUERY_RESERVATION_STALE_TIME_SECONDS = + parseInt(process.env.NEXT_PUBLIC_QUERY_RESERVATION_STALE_TIME_SECONDS ?? "", 10) || 20; +export const PUBLIC_QUERY_AVAILABLE_SLOTS_INTERVAL_SECONDS = + parseInt(process.env.NEXT_PUBLIC_QUERY_AVAILABLE_SLOTS_INTERVAL_SECONDS ?? "", 10) || 5 * 60; +export const PUBLIC_INVALIDATE_AVAILABLE_SLOTS_ON_BOOKING_FORM = + process.env.NEXT_PUBLIC_INVALIDATE_AVAILABLE_SLOTS_ON_BOOKING_FORM === "1"; + export const CURRENT_TIMEZONE = dayjs.tz.guess() !== "Etc/Unknown" ? dayjs.tz.guess() : "Europe/London"; /** @deprecated use `WEBAPP_URL` */ diff --git a/packages/lib/isOutOfBounds.tsx b/packages/lib/isOutOfBounds.tsx index a9cd6f75f9..db2956fed5 100644 --- a/packages/lib/isOutOfBounds.tsx +++ b/packages/lib/isOutOfBounds.tsx @@ -204,6 +204,7 @@ export function getRollingWindowEndDate({ /** * To be used when we work on Timeslots(and not Dates) to check boundaries * It ensures that the time isn't in the past and also checks if the time is within the minimum booking notice. + * Note: It throws error that needs to be caught by caller. */ export function isTimeOutOfBounds({ time, @@ -226,6 +227,37 @@ export function isTimeOutOfBounds({ return false; } +/** + * Wrapper over isTimeOutOfBounds to return a status object. + * Note: It doesn't throw any error and can be safely used + */ +export function getPastTimeAndMinimumBookingNoticeBoundsStatus({ + time, + minimumBookingNotice, +}: { + time: dayjs.ConfigType; + minimumBookingNotice?: number; +}): { + isOutOfBounds: boolean; + reason: "minBookNoticeViolation" | "slotInPast" | null; +} { + try { + const isOutOfBounds = isTimeOutOfBounds({ time, minimumBookingNotice }); + return { + isOutOfBounds, + reason: isOutOfBounds ? "minBookNoticeViolation" : null, + }; + } catch (error) { + if (error instanceof BookingDateInPastError) { + return { + isOutOfBounds: true, + reason: "slotInPast", + }; + } + throw error; + } +} + type PeriodLimits = { endOfRollingPeriodEndDayInBookerTz: dayjs.Dayjs | null; startOfRangeStartDayInEventTz: dayjs.Dayjs | null; diff --git a/packages/lib/server/repository/selectedSlots.ts b/packages/lib/server/repository/selectedSlots.ts new file mode 100644 index 0000000000..09385e3806 --- /dev/null +++ b/packages/lib/server/repository/selectedSlots.ts @@ -0,0 +1,73 @@ +import type { Prisma } from "@prisma/client"; + +import { prisma } from "@calcom/prisma"; + +type WhereCondition = { + slotUtcStartDate?: Date | string; + slotUtcEndDate?: Date | string; + eventTypeId?: number; + uid?: string | { not: string }; + releaseAt?: { gt: Date }; +}; + +export type FindManyArgs = { + where?: WhereCondition & { + OR?: WhereCondition[]; + }; + select?: Prisma.SelectedSlotsSelect; +}; + +export type TimeSlot = { + utcStartIso: string; + utcEndIso: string; +}; + +export class SelectedSlotsRepository { + static async findMany({ where, select }: FindManyArgs) { + return await prisma.selectedSlots.findMany({ where, select }); + } + + static async findFirst({ where }: { where: Prisma.SelectedSlotsWhereInput }) { + return await prisma.selectedSlots.findFirst({ + where, + }); + } + + static async findReservedByOthers({ + slot, + eventTypeId, + uid, + }: { + slot: TimeSlot; + eventTypeId: number; + uid: string; + }) { + return await this.findFirst({ + where: { + slotUtcStartDate: slot.utcStartIso, + slotUtcEndDate: slot.utcEndIso, + eventTypeId, + uid: { not: uid }, + releaseAt: { gt: new Date() }, + }, + }); + } + + static async findManyReservedByOthers(slots: TimeSlot[], eventTypeId: number, uid: string) { + return await this.findMany({ + where: { + OR: slots.map((slot) => ({ + slotUtcStartDate: slot.utcStartIso, + slotUtcEndDate: slot.utcEndIso, + eventTypeId, + uid: { not: uid }, + releaseAt: { gt: new Date() }, + })), + }, + select: { + slotUtcStartDate: true, + slotUtcEndDate: true, + }, + }); + } +} diff --git a/packages/platform/atoms/hooks/useSlots.ts b/packages/platform/atoms/hooks/useSlots.ts index 0ea05530db..60d15d2fba 100644 --- a/packages/platform/atoms/hooks/useSlots.ts +++ b/packages/platform/atoms/hooks/useSlots.ts @@ -91,5 +91,7 @@ export const useSlots = ( slotReservationId, handleReserveSlot, handleRemoveSlot, + // TODO: implement slot no longer available feature + allSelectedTimeslots: [], }; }; diff --git a/packages/trpc/server/routers/viewer/slots/_router.tsx b/packages/trpc/server/routers/viewer/slots/_router.tsx index 2d37ad3eab..df8c6f28c6 100644 --- a/packages/trpc/server/routers/viewer/slots/_router.tsx +++ b/packages/trpc/server/routers/viewer/slots/_router.tsx @@ -3,12 +3,14 @@ import type { NextApiRequest, NextApiResponse } from "next"; import publicProcedure from "../../../procedures/publicProcedure"; import { router } from "../../../trpc"; import { ZGetScheduleInputSchema } from "./getSchedule.schema"; +import { ZIsAvailableInputSchema, ZIsAvailableOutputSchema } from "./isAvailable.schema"; import { ZRemoveSelectedSlotInputSchema } from "./removeSelectedSlot.schema"; import { ZReserveSlotInputSchema } from "./reserveSlot.schema"; type SlotsRouterHandlerCache = { getSchedule?: typeof import("./getSchedule.handler").getScheduleHandler; reserveSlot?: typeof import("./reserveSlot.handler").reserveSlotHandler; + isAvailable?: typeof import("./isAvailable.handler").isAvailableHandler; }; const UNSTABLE_HANDLER_CACHE: SlotsRouterHandlerCache = {}; @@ -49,6 +51,26 @@ export const slotsRouter = router({ input, }); }), + isAvailable: publicProcedure + .input(ZIsAvailableInputSchema) + .output(ZIsAvailableOutputSchema) + .query(async ({ input, ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.isAvailable) { + UNSTABLE_HANDLER_CACHE.isAvailable = await import("./isAvailable.handler").then( + (mod) => mod.isAvailableHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.isAvailable) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.isAvailable({ + ctx: { ...ctx, req: ctx.req as NextApiRequest }, + input, + }); + }), // This endpoint has no dependencies, it doesn't need its own file removeSelectedSlotMark: publicProcedure .input(ZRemoveSelectedSlotInputSchema) diff --git a/packages/trpc/server/routers/viewer/slots/isAvailable.handler.ts b/packages/trpc/server/routers/viewer/slots/isAvailable.handler.ts new file mode 100644 index 0000000000..697f7fc59e --- /dev/null +++ b/packages/trpc/server/routers/viewer/slots/isAvailable.handler.ts @@ -0,0 +1,77 @@ +import type { NextApiRequest } from "next"; + +import { HttpError } from "@calcom/lib/http-error"; +import { getPastTimeAndMinimumBookingNoticeBoundsStatus } from "@calcom/lib/isOutOfBounds"; +import { EventTypeRepository } from "@calcom/lib/server/repository/eventType"; +import { SelectedSlotsRepository } from "@calcom/lib/server/repository/selectedSlots"; +import type { PrismaClient } from "@calcom/prisma"; + +import type { TIsAvailableInputSchema, TIsAvailableOutputSchema } from "./isAvailable.schema"; + +interface IsAvailableOptions { + ctx: { + prisma: PrismaClient; + req?: NextApiRequest | undefined; + }; + input: TIsAvailableInputSchema; +} + +/** + * It does a super quick check whether that slot is bookable or not. + * It doesn't consider slow things like querying the bookings, checking the calendars. + * + * getSchedule call is the only(but very slow) way to know if a slot is bookable + */ +export const isAvailableHandler = async ({ + ctx, + input, +}: IsAvailableOptions): Promise => { + const { req } = ctx; + const uid = req?.cookies?.uid; + + const { slots, eventTypeId } = input; + + // Get event type details for time bounds validation + const eventType = await EventTypeRepository.findByIdMinimal({ id: eventTypeId }); + + if (!eventType) { + throw new HttpError({ statusCode: 404, message: "Event type not found" }); + } + + // Check each slot's availability + // Without uid, we must not check for reserved slots because if uuid isn't set in cookie yet, but it is going to be through reserveSlot request soon, we could consider the slot as reserved accidentally. + const reservedSlots = uid + ? await SelectedSlotsRepository.findManyReservedByOthers(slots, eventTypeId, uid) + : []; + + // Map all slots to their availability status + const slotsWithStatus: TIsAvailableOutputSchema["slots"] = slots.map((slot) => { + const isReserved = reservedSlots.some( + (reservedSlot) => + reservedSlot.slotUtcStartDate.toISOString() === slot.utcStartIso && + reservedSlot.slotUtcEndDate.toISOString() === slot.utcEndIso + ); + + if (isReserved) { + return { + ...slot, + status: "reserved" as const, + }; + } + + // Check time bounds + const timeStatus = getPastTimeAndMinimumBookingNoticeBoundsStatus({ + time: slot.utcStartIso, + minimumBookingNotice: eventType.minimumBookingNotice, + }); + + return { + ...slot, + status: timeStatus.reason ?? "available", + }; + }); + + return { + slots: slotsWithStatus, + }; +}; diff --git a/packages/trpc/server/routers/viewer/slots/isAvailable.schema.ts b/packages/trpc/server/routers/viewer/slots/isAvailable.schema.ts new file mode 100644 index 0000000000..379b69d24a --- /dev/null +++ b/packages/trpc/server/routers/viewer/slots/isAvailable.schema.ts @@ -0,0 +1,25 @@ +import { z } from "zod"; + +export const ZIsAvailableInputSchema = z.object({ + slots: z.array( + z.object({ + utcStartIso: z.string(), + utcEndIso: z.string(), + }) + ), + eventTypeId: z.number().int(), +}); + +export type TIsAvailableInputSchema = z.infer; + +export const ZIsAvailableOutputSchema = z.object({ + slots: z.array( + z.object({ + utcStartIso: z.string(), + utcEndIso: z.string(), + status: z.enum(["available", "reserved", "minBookNoticeViolation", "slotInPast"]), + }) + ), +}); + +export type TIsAvailableOutputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/slots/reserveSlot.handler.ts b/packages/trpc/server/routers/viewer/slots/reserveSlot.handler.ts index 9ee0ba5259..51ecf0cb92 100644 --- a/packages/trpc/server/routers/viewer/slots/reserveSlot.handler.ts +++ b/packages/trpc/server/routers/viewer/slots/reserveSlot.handler.ts @@ -4,6 +4,7 @@ import { v4 as uuid } from "uuid"; import dayjs from "@calcom/dayjs"; import { MINUTES_TO_BOOK } from "@calcom/lib/constants"; +import { SelectedSlotsRepository } from "@calcom/lib/server/repository/selectedSlots"; import type { PrismaClient } from "@calcom/prisma"; import { TRPCError } from "@trpc/server"; @@ -54,9 +55,22 @@ export const reserveSlotHandler = async ({ ctx, input }: ReserveSlotOptions) => } } - if (eventType && shouldReserveSlot && !_isDryRun) { + // Check for existing reservations for the same slot + const reservedBySomeoneElse = await SelectedSlotsRepository.findReservedByOthers({ + slot: { + utcStartIso: slotUtcStartDate, + utcEndIso: slotUtcEndDate, + }, + eventTypeId, + uid, + }); + + if (eventType && shouldReserveSlot && !reservedBySomeoneElse && !_isDryRun) { try { await Promise.all( + // FIXME: In case of team event, users doesn't have assignees, those are in hosts. users just have the creator of the event which is wrong. + // Also, we must not block all the users' slots, we must use routedTeamMemberIds if set like we do in getSchedule. + // We could even improve it by identifying the next person being booked now that we have a queue of assignees. eventType.users.map((user) => prisma.selectedSlots.upsert({ where: { selectedSlotUnique: { userId: user.id, slotUtcStartDate, slotUtcEndDate, uid } }, diff --git a/packages/trpc/server/routers/viewer/slots/util.ts b/packages/trpc/server/routers/viewer/slots/util.ts index c06b27759a..30e52df6d6 100644 --- a/packages/trpc/server/routers/viewer/slots/util.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -46,6 +46,19 @@ import type { TGetScheduleInputSchema } from "./getSchedule.schema"; import { handleNotificationWhenNoSlots } from "./handleNotificationWhenNoSlots"; const log = logger.getSubLogger({ prefix: ["[slots/util]"] }); +const selectSelectedSlots = Prisma.validator()({ + select: { + id: true, + slotUtcStartDate: true, + slotUtcEndDate: true, + userId: true, + isSeat: true, + eventTypeId: true, + uid: true, + }, +}); + +type SelectedSlots = Prisma.SelectedSlotsGetPayload; async function getEventTypeId({ slug, @@ -89,6 +102,44 @@ async function getEventTypeId({ return eventType?.id; } +async function _getReservedSlotsAndCleanupExpired({ + bookerClientUid, + usersWithCredentials, + eventTypeId, +}: { + bookerClientUid: string | undefined; + usersWithCredentials: GetAvailabilityUser[]; + eventTypeId: number; +}) { + const currentTimeInUtc = dayjs.utc().format(); + + const unexpiredSelectedSlots = + (await prisma.selectedSlots.findMany({ + where: { + userId: { in: usersWithCredentials.map((user) => user.id) }, + releaseAt: { gt: currentTimeInUtc }, + }, + ...selectSelectedSlots, + })) || []; + + const slotsSelectedByOtherUsers = unexpiredSelectedSlots.filter((slot) => slot.uid !== bookerClientUid); + + await _cleanupExpiredSlots({ eventTypeId }); + + const reservedSlots = slotsSelectedByOtherUsers; + + return reservedSlots; + + async function _cleanupExpiredSlots({ eventTypeId }: { eventTypeId: number }) { + await prisma.selectedSlots.deleteMany({ + where: { + eventTypeId: { equals: eventTypeId }, + releaseAt: { lt: currentTimeInUtc }, + }, + }); + } +} + export async function getEventType( input: TGetScheduleInputSchema, organizationDetails: { currentOrgDomain: string | null; isValidOrgDomain: boolean } @@ -169,19 +220,6 @@ export async function getRegularOrDynamicEventType( : await getEventType(input, organizationDetails); } -const selectSelectedSlots = Prisma.validator()({ - select: { - id: true, - slotUtcStartDate: true, - slotUtcEndDate: true, - userId: true, - isSeat: true, - eventTypeId: true, - }, -}); - -type SelectedSlots = Prisma.SelectedSlotsGetPayload; - const groupTimeSlotsByDay = (timeSlots: { time: Dayjs }[]) => { return timeSlots.reduce((acc: Record, { time }) => { const day = time.format("YYYY-MM-DD"); // Group by date @@ -498,18 +536,12 @@ async function _getAvailableSlots({ input, ctx }: GetScheduleOptions): Promise user.id) }, - releaseAt: { gt: dayjs.utc().format() }, - }, - ...selectSelectedSlots, - })) || []; - await prisma.selectedSlots.deleteMany({ - where: { eventTypeId: { equals: eventType.id }, id: { notIn: selectedSlots.map((item) => item.id) } }, + const bookerClientUid = ctx?.req?.cookies?.uid; + + const reservedSlots = await _getReservedSlotsAndCleanupExpired({ + bookerClientUid, + eventTypeId: eventType.id, + usersWithCredentials, }); availableTimeSlots = timeSlots; @@ -519,8 +551,8 @@ async function _getAvailableSlots({ input, ctx }: GetScheduleOptions): Promise 0) { - let occupiedSeats: typeof selectedSlots = selectedSlots.filter( + if (reservedSlots?.length > 0) { + let occupiedSeats: typeof reservedSlots = reservedSlots.filter( (item) => item.isSeat && item.eventTypeId === eventType.id ); if (occupiedSeats?.length) { @@ -553,7 +585,7 @@ async function _getAvailableSlots({ input, ctx }: GetScheduleOptions): Promise { - const busy = selectedSlots.reduce((r, c) => { + const busySlotsFromReservedSlots = reservedSlots.reduce((r, c) => { if (!c.isSeat) { r.push({ start: c.slotUtcStartDate, end: c.slotUtcEndDate }); } @@ -563,7 +595,7 @@ async function _getAvailableSlots({ input, ctx }: GetScheduleOptions): Promise