feat: Introduce a 'slot no longer available' message (#18935)
* feat: Don't allow unavailable slot's booking form to be visible - Send back to slots listing * Improvements * feat: Add env variables and strategies to prevent booking failures - Added new environment variables for configuring slot reservation and availability checks - Implemented strategies to handle concurrent bookings and slot availability - Updated Booker component and related files to support new reservation and slot checking mechanisms - Added README with detailed explanation of booking prevention strategies - Configured dynamic intervals for slot and reservation queries * docs: Update Booker README with detailed slot reservation and availability explanation - Clarified slot reservation behavior when multiple users access the same booking page - Added details about `getSchedule` refetching strategies and timing - Explained how slot availability is detected and communicated to users * Support for skip confirmation form flow * feat: Enhance slot availability and reservation mechanism - Refactored slot availability checking to support multiple slots - Added tentative selected timeslots to improve booking UX - Updated trpc handler to check availability for multiple slots - Introduced new store methods for managing tentative slot selections - Improved slot reservation and availability status tracking * refactor: Improve slot availability quick check mechanism - Renamed and restructured slot availability check parameters - Extracted quick availability checks into a separate hook - Updated type definitions for slot status and availability checks - Simplified slot availability logic in Booker and related components - Maintained cached state for quick availability checks * Support minimu booking check too * change data-testid * Rneame * Fix race condition with uid cookie not set * fix tests * Remove unsded variable --------- Co-authored-by: Hariom Balhara <hariombalhara@gmgmail.com> Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
This commit is contained in:
co-authored by
Hariom Balhara
Udit Takkar
parent
a6c6a46076
commit
07789ba697
@@ -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
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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</2>",
|
||||
"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",
|
||||
|
||||
@@ -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<typeof getScheduleSchema> => ({
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<HTMLDivElement | null>(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}
|
||||
|
||||
@@ -29,6 +29,7 @@ type BookEventFormProps = {
|
||||
extraOptions: Record<string, string | string[]>;
|
||||
isPlatform?: boolean;
|
||||
isVerificationCodeSending: boolean;
|
||||
isTimeslotUnavailable: boolean;
|
||||
shouldRenderCaptcha?: boolean;
|
||||
};
|
||||
|
||||
@@ -46,6 +47,7 @@ export const BookEventForm = ({
|
||||
extraOptions,
|
||||
isVerificationCodeSending,
|
||||
isPlatform = false,
|
||||
isTimeslotUnavailable,
|
||||
shouldRenderCaptcha,
|
||||
}: Omit<BookEventFormProps, "event"> & {
|
||||
eventQuery: {
|
||||
@@ -113,7 +115,7 @@ export const BookEventForm = ({
|
||||
rescheduleUid={rescheduleUid || undefined}
|
||||
bookingData={bookingData}
|
||||
/>
|
||||
{(errors.hasFormErrors || errors.hasDataErrors) && (
|
||||
{errors.hasFormErrors || errors.hasDataErrors ? (
|
||||
<div data-testid="booking-fail">
|
||||
<Alert
|
||||
ref={errorRef}
|
||||
@@ -123,7 +125,23 @@ export const BookEventForm = ({
|
||||
message={getError(errors.formErrors, errors.dataErrors, t, responseVercelIdHeader)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
) : isTimeslotUnavailable ? (
|
||||
<div data-testid="slot-not-allowed-to-book">
|
||||
<Alert
|
||||
severity="info"
|
||||
title={t("unavailable_timeslot_title")}
|
||||
message={
|
||||
<Trans i18nKey="timeslot_unavailable_book_a_new_time">
|
||||
The selected time slot is no longer available.{" "}
|
||||
<button type="button" className="underline" onClick={onCancel}>
|
||||
Please select a new time
|
||||
</button>
|
||||
</Trans>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isPlatform && (
|
||||
<div className="text-subtle my-3 w-full text-xs">
|
||||
<Trans
|
||||
@@ -185,7 +203,7 @@ export const BookEventForm = ({
|
||||
<Button
|
||||
type="submit"
|
||||
color="primary"
|
||||
disabled={!!shouldRenderCaptcha && !watchedCfToken}
|
||||
disabled={(!!shouldRenderCaptcha && !watchedCfToken) || isTimeslotUnavailable}
|
||||
loading={
|
||||
loadingStates.creatingBooking ||
|
||||
loadingStates.creatingRecurringBooking ||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { shallow } from "zustand/shallow";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
@@ -6,19 +6,84 @@ import { useBookerStore } from "@calcom/features/bookings/Booker/store";
|
||||
import { useSlotReservationId } from "@calcom/features/bookings/Booker/useSlotReservationId";
|
||||
import { isBookingDryRun } from "@calcom/features/bookings/Booker/utils/isBookingDryRun";
|
||||
import type { BookerEvent } from "@calcom/features/bookings/types";
|
||||
import { MINUTES_TO_BOOK } from "@calcom/lib/constants";
|
||||
import {
|
||||
MINUTES_TO_BOOK,
|
||||
PUBLIC_QUERY_RESERVATION_INTERVAL_SECONDS,
|
||||
PUBLIC_QUERY_RESERVATION_STALE_TIME_SECONDS,
|
||||
} from "@calcom/lib/constants";
|
||||
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import type { TIsAvailableOutputSchema } from "@calcom/trpc/server/routers/viewer/slots/isAvailable.schema";
|
||||
|
||||
export type QuickAvailabilityCheck = TIsAvailableOutputSchema["slots"][number];
|
||||
|
||||
const useQuickAvailabilityChecks = ({
|
||||
eventTypeId,
|
||||
eventDuration,
|
||||
timeslotsAsISOString,
|
||||
slotReservationId,
|
||||
}: {
|
||||
eventTypeId: number | undefined;
|
||||
eventDuration: number;
|
||||
timeslotsAsISOString: string[];
|
||||
slotReservationId: string | undefined | null;
|
||||
}) => {
|
||||
// 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<QuickAvailabilityCheck[]>([]);
|
||||
|
||||
// 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<typeof useSlots>;
|
||||
|
||||
export const useSlots = (event: { data?: Pick<BookerEvent, "id" | "length"> | 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<BookerEvent, "id" | "length"> | 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<BookerEvent, "id" | "length"> | 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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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<BookerStore>((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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, { time: string }[]>;
|
||||
} | 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;
|
||||
};
|
||||
@@ -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<SlotItemProps, "slot">;
|
||||
|
||||
type SlotItemProps = {
|
||||
@@ -41,6 +56,7 @@ type SlotItemProps = {
|
||||
seatsPerTimeSlot?: number | null;
|
||||
selectedSlots?: string[];
|
||||
onTimeSelect: TOnTimeSelect;
|
||||
onTentativeTimeSelect?: TOnTentativeTimeSelect;
|
||||
showAvailableSeatsCount?: boolean | null;
|
||||
event: {
|
||||
data?: Pick<BookerEvent, "length" | "bookingFields" | "price" | "currency" | "metadata"> | 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 (
|
||||
<AnimatePresence>
|
||||
<div className="flex gap-2">
|
||||
@@ -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");
|
||||
})()}
|
||||
</Button>
|
||||
</m.div>
|
||||
</HoverCard.Trigger>
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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` */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -91,5 +91,7 @@ export const useSlots = (
|
||||
slotReservationId,
|
||||
handleReserveSlot,
|
||||
handleRemoveSlot,
|
||||
// TODO: implement slot no longer available feature
|
||||
allSelectedTimeslots: [],
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<TIsAvailableOutputSchema> => {
|
||||
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,
|
||||
};
|
||||
};
|
||||
@@ -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<typeof ZIsAvailableInputSchema>;
|
||||
|
||||
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<typeof ZIsAvailableOutputSchema>;
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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<Prisma.SelectedSlotsDefaultArgs>()({
|
||||
select: {
|
||||
id: true,
|
||||
slotUtcStartDate: true,
|
||||
slotUtcEndDate: true,
|
||||
userId: true,
|
||||
isSeat: true,
|
||||
eventTypeId: true,
|
||||
uid: true,
|
||||
},
|
||||
});
|
||||
|
||||
type SelectedSlots = Prisma.SelectedSlotsGetPayload<typeof selectSelectedSlots>;
|
||||
|
||||
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<Prisma.SelectedSlotsDefaultArgs>()({
|
||||
select: {
|
||||
id: true,
|
||||
slotUtcStartDate: true,
|
||||
slotUtcEndDate: true,
|
||||
userId: true,
|
||||
isSeat: true,
|
||||
eventTypeId: true,
|
||||
},
|
||||
});
|
||||
|
||||
type SelectedSlots = Prisma.SelectedSlotsGetPayload<typeof selectSelectedSlots>;
|
||||
|
||||
const groupTimeSlotsByDay = (timeSlots: { time: Dayjs }[]) => {
|
||||
return timeSlots.reduce((acc: Record<string, string[]>, { time }) => {
|
||||
const day = time.format("YYYY-MM-DD"); // Group by date
|
||||
@@ -498,18 +536,12 @@ async function _getAvailableSlots({ input, ctx }: GetScheduleOptions): Promise<I
|
||||
}
|
||||
|
||||
let availableTimeSlots: typeof timeSlots = [];
|
||||
// Load cached busy slots
|
||||
const selectedSlots =
|
||||
/* FIXME: For some reason this returns undefined while testing in Jest */
|
||||
(await prisma.selectedSlots.findMany({
|
||||
where: {
|
||||
userId: { in: usersWithCredentials.map((user) => 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<I
|
||||
currentSeats,
|
||||
};
|
||||
|
||||
if (selectedSlots?.length > 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<I
|
||||
}
|
||||
availableTimeSlots = availableTimeSlots
|
||||
.map((slot) => {
|
||||
const busy = selectedSlots.reduce<EventBusyDate[]>((r, c) => {
|
||||
const busySlotsFromReservedSlots = reservedSlots.reduce<EventBusyDate[]>((r, c) => {
|
||||
if (!c.isSeat) {
|
||||
r.push({ start: c.slotUtcStartDate, end: c.slotUtcEndDate });
|
||||
}
|
||||
@@ -563,7 +595,7 @@ async function _getAvailableSlots({ input, ctx }: GetScheduleOptions): Promise<I
|
||||
if (
|
||||
!checkForConflicts({
|
||||
time: slot.time,
|
||||
busy,
|
||||
busy: busySlotsFromReservedSlots,
|
||||
...availabilityCheckProps,
|
||||
})
|
||||
) {
|
||||
|
||||
@@ -443,6 +443,10 @@
|
||||
"EMBED_PUBLIC_VERCEL_URL",
|
||||
"EMBED_PUBLIC_EMBED_LIB_URL",
|
||||
"NEXT_PUBLIC_ENABLE_PROFILE_SWITCHER",
|
||||
"NEXT_PUBLIC_QUERY_AVAILABLE_SLOTS_INTERVAL_SECONDS",
|
||||
"NEXT_PUBLIC_QUERY_RESERVATION_INTERVAL_SECONDS",
|
||||
"NEXT_PUBLIC_QUERY_RESERVATION_STALE_TIME_SECONDS",
|
||||
"NEXT_PUBLIC_INVALIDATE_AVAILABLE_SLOTS_ON_BOOKING_FORM",
|
||||
"NEXT_PUBLIC_HEAD_SCRIPTS",
|
||||
"NEXT_PUBLIC_BODY_SCRIPTS",
|
||||
"NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED",
|
||||
|
||||
Reference in New Issue
Block a user