diff --git a/packages/features/bookings/lib/conflictChecker/checkForConflicts.test.ts b/packages/features/bookings/lib/conflictChecker/checkForConflicts.test.ts new file mode 100644 index 0000000000..1d347af07c --- /dev/null +++ b/packages/features/bookings/lib/conflictChecker/checkForConflicts.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, it } from "vitest"; + +import type { CurrentSeats } from "@calcom/core/getUserAvailability"; +import dayjs from "@calcom/dayjs"; +import type { EventBusyDate } from "@calcom/types/Calendar"; + +import { checkForConflicts } from "./checkForConflicts"; + +describe("checkForConflicts", () => { + const createTestData = (time: string) => ({ + time: dayjs(time), + eventLength: 30, + busy: [] as EventBusyDate[], + }); + + describe("currentSeats handling", () => { + it("should return false if slot exists in currentSeats", () => { + const currentSeats: CurrentSeats = [ + { + uid: "123", + startTime: dayjs.utc("2023-01-01T09:00:00Z").toDate(), + _count: { attendees: 1 }, + }, + ]; + + const result = checkForConflicts({ + ...createTestData("2023-01-01T09:00:00Z"), + currentSeats, + }); + + expect(result).toBe(false); + }); + }); + + describe("busy time overlap scenarios", () => { + it("should return false when no busy periods", () => { + const result = checkForConflicts(createTestData("2023-01-01T09:00:00Z")); + expect(result).toBe(false); + }); + + it("should return false when busy period ends before slot starts", () => { + const result = checkForConflicts({ + ...createTestData("2023-01-01T09:00:00Z"), + busy: [ + { + start: dayjs.utc("2023-01-01T08:00:00Z").toDate(), + end: dayjs.utc("2023-01-01T08:30:00Z").toDate(), + }, + ], + }); + expect(result).toBe(false); + }); + + it("should return false when busy period starts after slot ends", () => { + const result = checkForConflicts({ + ...createTestData("2023-01-01T09:00:00Z"), + busy: [ + { + start: dayjs.utc("2023-01-01T10:00:00Z").toDate(), + end: dayjs.utc("2023-01-01T10:30:00Z").toDate(), + }, + ], + }); + expect(result).toBe(false); + }); + + it("should return true when slot start falls within busy period", () => { + const result = checkForConflicts({ + ...createTestData("2023-01-01T09:15:00Z"), + busy: [ + { + start: dayjs.utc("2023-01-01T09:00:00Z").toDate(), + end: dayjs.utc("2023-01-01T09:30:00Z").toDate(), + }, + ], + }); + expect(result).toBe(true); + }); + + it("should return true when slot end falls within busy period", () => { + const result = checkForConflicts({ + ...createTestData("2023-01-01T08:45:00Z"), + busy: [ + { + start: dayjs.utc("2023-01-01T09:00:00Z").toDate(), + end: dayjs.utc("2023-01-01T09:30:00Z").toDate(), + }, + ], + }); + expect(result).toBe(true); + }); + + it("should return true when busy period is completely contained within slot", () => { + const result = checkForConflicts({ + ...createTestData("2023-01-01T09:00:00Z"), + eventLength: 60, + busy: [ + { + start: dayjs.utc("2023-01-01T09:15:00Z").toDate(), + end: dayjs.utc("2023-01-01T09:45:00Z").toDate(), + }, + ], + }); + expect(result).toBe(true); + }); + + it("should return true when slot is completely contained within busy period", () => { + const result = checkForConflicts({ + ...createTestData("2023-01-01T09:15:00Z"), + busy: [ + { + start: dayjs.utc("2023-01-01T09:00:00Z").toDate(), + end: dayjs.utc("2023-01-01T10:00:00Z").toDate(), + }, + ], + }); + expect(result).toBe(true); + }); + + it("should return false when multiple non-overlapping busy periods", () => { + const result = checkForConflicts({ + ...createTestData("2023-01-01T09:30:00Z"), + busy: [ + { + start: dayjs.utc("2023-01-01T08:00:00Z").toDate(), + end: dayjs.utc("2023-01-01T09:00:00Z").toDate(), + }, + { + start: dayjs.utc("2023-01-01T10:00:00Z").toDate(), + end: dayjs.utc("2023-01-01T11:00:00Z").toDate(), + }, + ], + }); + expect(result).toBe(false); + }); + + it("should return true if any busy period overlaps", () => { + const result = checkForConflicts({ + ...createTestData("2023-01-01T09:30:00Z"), + busy: [ + { + start: dayjs.utc("2023-01-01T08:00:00Z").toDate(), + end: dayjs.utc("2023-01-01T09:00:00Z").toDate(), + }, + { + start: dayjs.utc("2023-01-01T09:45:00Z").toDate(), + end: dayjs.utc("2023-01-01T10:00:00Z").toDate(), + }, + ], + }); + expect(result).toBe(true); + }); + + it("should handle exact boundary conditions", () => { + const result = checkForConflicts({ + ...createTestData("2023-01-01T09:30:00Z"), + busy: [ + { + start: dayjs.utc("2023-01-01T09:00:00Z").toDate(), + end: dayjs.utc("2023-01-01T09:30:00Z").toDate(), + }, + ], + }); + expect(result).toBe(false); + }); + }); +}); diff --git a/packages/features/bookings/lib/conflictChecker/checkForConflicts.ts b/packages/features/bookings/lib/conflictChecker/checkForConflicts.ts index 5311ef317a..6b1f14fc3e 100644 --- a/packages/features/bookings/lib/conflictChecker/checkForConflicts.ts +++ b/packages/features/bookings/lib/conflictChecker/checkForConflicts.ts @@ -1,45 +1,59 @@ +import type { Dayjs } from "dayjs"; + +import type { CurrentSeats } from "@calcom/core/getUserAvailability"; import dayjs from "@calcom/dayjs"; -import logger from "@calcom/lib/logger"; -import { safeStringify } from "@calcom/lib/safeStringify"; import type { BufferedBusyTime } from "@calcom/types/BufferedBusyTime"; type BufferedBusyTimes = BufferedBusyTime[]; -const log = logger.getSubLogger({ prefix: ["[api] book:user"] }); // if true, there are conflicts. -export function checkForConflicts(busyTimes: BufferedBusyTimes, time: dayjs.ConfigType, length: number) { +export function checkForConflicts({ + busy, + time, + eventLength, + currentSeats, +}: { + busy: BufferedBusyTimes; + time: Dayjs; + eventLength: number; + currentSeats?: CurrentSeats; +}) { // Early return - if (!Array.isArray(busyTimes) || busyTimes.length < 1) { + if (!Array.isArray(busy) || busy.length < 1) { return false; // guaranteed no conflicts when there is no busy times. } - - for (const busyTime of busyTimes) { - const startTime = dayjs(busyTime.start); - const endTime = dayjs(busyTime.end); - // Check if time is between start and end times - if (dayjs(time).isBetween(startTime, endTime, null, "[)")) { - log.error( - `NAUF: start between a busy time slot ${safeStringify({ - ...busyTime, - time: dayjs(time).format(), - })}` - ); - return true; - } - // Check if slot end time is between start and end time - if (dayjs(time).add(length, "minutes").isBetween(startTime, endTime)) { - log.error( - `NAUF: Ends between a busy time slot ${safeStringify({ - ...busyTime, - time: dayjs(time).add(length, "minutes").format(), - })}` - ); - return true; - } - // Check if startTime is between slot - if (startTime.isBetween(dayjs(time), dayjs(time).add(length, "minutes"))) { - return true; - } + // no conflicts if some seats are found for the current time slot + if (currentSeats?.some((booking) => booking.startTime.toISOString() === time.toISOString())) { + return false; } - return false; + const slotStartDate = time.utc().toDate(); + const slotEndDate = time.add(eventLength, "minutes").utc().toDate(); + // handle busy times. + return busy.some((busyTime) => { + const busyStartDate = dayjs.utc(busyTime.start).toDate(); + const busyEndDate = dayjs.utc(busyTime.end).toDate(); + // First check if there's any overlap at all + // If busy period ends before slot starts or starts after slot ends, there's no overlap + if (busyEndDate <= slotStartDate || busyStartDate >= slotEndDate) { + return false; + } + // Now check all possible overlap scenarios: + // 1. Slot start falls within busy period (inclusive start, exclusive end) + if (slotStartDate >= busyStartDate && slotStartDate < busyEndDate) { + return true; + } + // 2. Slot end falls within busy period (exclusive start, inclusive end) + if (slotEndDate > busyStartDate && slotEndDate <= busyEndDate) { + return true; + } + // 3. Busy period completely contained within slot + if (busyStartDate >= slotStartDate && busyEndDate <= slotEndDate) { + return true; + } + // 4. Slot completely contained within busy period + if (busyStartDate <= slotStartDate && busyEndDate >= slotEndDate) { + return true; + } + return false; + }); } diff --git a/packages/features/bookings/lib/handleNewBooking/ensureAvailableUsers.ts b/packages/features/bookings/lib/handleNewBooking/ensureAvailableUsers.ts index 63cd5c6196..b2d7b11a7f 100644 --- a/packages/features/bookings/lib/handleNewBooking/ensureAvailableUsers.ts +++ b/packages/features/bookings/lib/handleNewBooking/ensureAvailableUsers.ts @@ -4,11 +4,11 @@ import { getBusyTimesForLimitChecks } from "@calcom/core/getBusyTimes"; import { getUsersAvailability } from "@calcom/core/getUserAvailability"; import dayjs from "@calcom/dayjs"; import type { Dayjs } from "@calcom/dayjs"; +import { checkForConflicts } from "@calcom/features/bookings/lib/conflictChecker/checkForConflicts"; import { parseBookingLimit, parseDurationLimit } from "@calcom/lib"; import { ErrorCode } from "@calcom/lib/errorCodes"; import { safeStringify } from "@calcom/lib/safeStringify"; -import { checkForConflicts } from "../conflictChecker/checkForConflicts"; import type { getEventTypeResponse } from "./getEventTypesFromDB"; import type { IsFixedAwareUser, BookingType } from "./types"; @@ -135,8 +135,11 @@ export async function ensureAvailableUsers( } try { - const foundConflict = checkForConflicts(bufferedBusyTimes, startDateTimeUtc, duration); - // no conflicts found, add to available users. + const foundConflict = checkForConflicts({ + busy: bufferedBusyTimes, + time: startDateTimeUtc, + eventLength: duration, + }); if (!foundConflict) { availableUsers.push(user); } diff --git a/packages/trpc/server/routers/viewer/slots/util.test.ts b/packages/trpc/server/routers/viewer/slots/util.test.ts index 5db75874a5..b3ede3d45e 100644 --- a/packages/trpc/server/routers/viewer/slots/util.test.ts +++ b/packages/trpc/server/routers/viewer/slots/util.test.ts @@ -1,10 +1,7 @@ import { describe, expect, it } from "vitest"; import type { GetAvailabilityUser } from "@calcom/core/getUserAvailability"; -import dayjs from "@calcom/dayjs"; -import type { EventBusyDate } from "@calcom/types/Calendar"; -import { checkIfIsAvailable } from "./util"; import { getUsersWithCredentialsConsideringContactOwner } from "./util"; describe("getUsersWithCredentialsConsideringContactOwner", () => { @@ -91,163 +88,3 @@ describe("getUsersWithCredentialsConsideringContactOwner", () => { ]); }); }); - -describe("checkIfIsAvailable", () => { - const createTestData = (time: string) => ({ - time: dayjs(time), - eventLength: 30, - busy: [] as EventBusyDate[], - }); - - describe("currentSeats handling", () => { - it("should return true if slot exists in currentSeats", () => { - const currentSeats: CurrentSeats = [ - { - uid: "123", - startTime: dayjs.utc("2023-01-01T09:00:00Z").toDate(), - _count: { attendees: 1 }, - }, - ]; - - const result = checkIfIsAvailable({ - ...createTestData("2023-01-01T09:00:00Z"), - currentSeats, - }); - - expect(result).toBe(true); - }); - }); - - describe("busy time overlap scenarios", () => { - it("should return true when no busy periods", () => { - const result = checkIfIsAvailable(createTestData("2023-01-01T09:00:00Z")); - expect(result).toBe(true); - }); - - it("should return true when busy period ends before slot starts", () => { - const result = checkIfIsAvailable({ - ...createTestData("2023-01-01T09:00:00Z"), - busy: [ - { - start: dayjs.utc("2023-01-01T08:00:00Z").toDate(), - end: dayjs.utc("2023-01-01T08:30:00Z").toDate(), - }, - ], - }); - expect(result).toBe(true); - }); - - it("should return true when busy period starts after slot ends", () => { - const result = checkIfIsAvailable({ - ...createTestData("2023-01-01T09:00:00Z"), - busy: [ - { - start: dayjs.utc("2023-01-01T10:00:00Z").toDate(), - end: dayjs.utc("2023-01-01T10:30:00Z").toDate(), - }, - ], - }); - expect(result).toBe(true); - }); - - it("should return false when slot start falls within busy period", () => { - const result = checkIfIsAvailable({ - ...createTestData("2023-01-01T09:15:00Z"), - busy: [ - { - start: dayjs.utc("2023-01-01T09:00:00Z").toDate(), - end: dayjs.utc("2023-01-01T09:30:00Z").toDate(), - }, - ], - }); - expect(result).toBe(false); - }); - - it("should return false when slot end falls within busy period", () => { - const result = checkIfIsAvailable({ - ...createTestData("2023-01-01T08:45:00Z"), - busy: [ - { - start: dayjs.utc("2023-01-01T09:00:00Z").toDate(), - end: dayjs.utc("2023-01-01T09:30:00Z").toDate(), - }, - ], - }); - expect(result).toBe(false); - }); - - it("should return false when busy period is completely contained within slot", () => { - const result = checkIfIsAvailable({ - ...createTestData("2023-01-01T09:00:00Z"), - eventLength: 60, - busy: [ - { - start: dayjs.utc("2023-01-01T09:15:00Z").toDate(), - end: dayjs.utc("2023-01-01T09:45:00Z").toDate(), - }, - ], - }); - expect(result).toBe(false); - }); - - it("should return false when slot is completely contained within busy period", () => { - const result = checkIfIsAvailable({ - ...createTestData("2023-01-01T09:15:00Z"), - busy: [ - { - start: dayjs.utc("2023-01-01T09:00:00Z").toDate(), - end: dayjs.utc("2023-01-01T10:00:00Z").toDate(), - }, - ], - }); - expect(result).toBe(false); - }); - - it("should return true when multiple non-overlapping busy periods", () => { - const result = checkIfIsAvailable({ - ...createTestData("2023-01-01T09:30:00Z"), - busy: [ - { - start: dayjs.utc("2023-01-01T08:00:00Z").toDate(), - end: dayjs.utc("2023-01-01T09:00:00Z").toDate(), - }, - { - start: dayjs.utc("2023-01-01T10:00:00Z").toDate(), - end: dayjs.utc("2023-01-01T11:00:00Z").toDate(), - }, - ], - }); - expect(result).toBe(true); - }); - - it("should return false if any busy period overlaps", () => { - const result = checkIfIsAvailable({ - ...createTestData("2023-01-01T09:30:00Z"), - busy: [ - { - start: dayjs.utc("2023-01-01T08:00:00Z").toDate(), - end: dayjs.utc("2023-01-01T09:00:00Z").toDate(), - }, - { - start: dayjs.utc("2023-01-01T09:45:00Z").toDate(), - end: dayjs.utc("2023-01-01T10:00:00Z").toDate(), - }, - ], - }); - expect(result).toBe(false); - }); - - it("should handle exact boundary conditions", () => { - const result = checkIfIsAvailable({ - ...createTestData("2023-01-01T09:30:00Z"), - busy: [ - { - start: dayjs.utc("2023-01-01T09:00:00Z").toDate(), - end: dayjs.utc("2023-01-01T09:30:00Z").toDate(), - }, - ], - }); - expect(result).toBe(true); - }); - }); -}); diff --git a/packages/trpc/server/routers/viewer/slots/util.ts b/packages/trpc/server/routers/viewer/slots/util.ts index 7d06464979..2da2a8bbf3 100644 --- a/packages/trpc/server/routers/viewer/slots/util.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -11,6 +11,7 @@ import monitorCallbackAsync, { monitorCallbackSync } from "@calcom/core/sentryWr import type { Dayjs } from "@calcom/dayjs"; import dayjs from "@calcom/dayjs"; import { getSlugOrRequestedSlug, orgDomainConfig } from "@calcom/ee/organizations/lib/orgDomains"; +import { checkForConflicts } from "@calcom/features/bookings/lib/conflictChecker/checkForConflicts"; import { isEventTypeLoggingEnabled } from "@calcom/features/bookings/lib/isEventTypeLoggingEnabled"; import { getShouldServeCache } from "@calcom/features/calendar-cache/lib/getShouldServeCache"; import { parseBookingLimit, parseDurationLimit } from "@calcom/lib"; @@ -47,60 +48,6 @@ import { handleNotificationWhenNoSlots } from "./handleNotificationWhenNoSlots"; const log = logger.getSubLogger({ prefix: ["[slots/util]"] }); -export const checkIfIsAvailable = ({ - time, - busy, - eventLength, - currentSeats, -}: { - time: Dayjs; - busy: EventBusyDate[]; - eventLength: number; - currentSeats?: CurrentSeats; -}): boolean => { - if (currentSeats?.some((booking) => booking.startTime.toISOString() === time.toISOString())) { - return true; - } - - const slotStartDate = time.utc().toDate(); - const slotEndDate = time.add(eventLength, "minutes").utc().toDate(); - - return busy.every((busyTime) => { - const busyStartDate = dayjs.utc(busyTime.start).toDate(); - const busyEndDate = dayjs.utc(busyTime.end).toDate(); - - // First check if there's any overlap at all - // If busy period ends before slot starts or starts after slot ends, there's no overlap - if (busyEndDate <= slotStartDate || busyStartDate >= slotEndDate) { - return true; - } - - // Now check all possible overlap scenarios: - - // 1. Slot start falls within busy period (inclusive start, exclusive end) - if (slotStartDate >= busyStartDate && slotStartDate < busyEndDate) { - return false; - } - - // 2. Slot end falls within busy period (exclusive start, inclusive end) - if (slotEndDate > busyStartDate && slotEndDate <= busyEndDate) { - return false; - } - - // 3. Busy period completely contained within slot - if (busyStartDate >= slotStartDate && busyEndDate <= slotEndDate) { - return false; - } - - // 4. Slot completely contained within busy period - if (busyStartDate <= slotStartDate && busyEndDate >= slotEndDate) { - return false; - } - - return true; - }); -}; - async function getEventTypeId({ slug, eventTypeSlug, @@ -529,7 +476,7 @@ async function _getAvailableSlots({ input, ctx }: GetScheduleOptions): Promise