fix: No available users found (#13738)
* fix: No available users found * Update getUserAvailability.ts * Update getUserAvailability.ts * Update handleNewBooking.ts * Reusing the parsed dates throughout the full stack * Fixed typo * Added unit tests for stringToDayjs * Fixing types * Reverted the getSchedule utc change since that endpoint takes in UTC formatted dates but with a given timezone * chore: Add test replicating 'no available users' error * Test is passing * Name clarifications * Test clean up * Update packages/features/bookings/lib/handleNewBooking.ts Co-authored-by: Omar López <zomars@me.com> * Update handleNewBooking.ts * Fixed issue where date overrides ending at midnight don't allow bookings * Removed comment --------- Co-authored-by: Omar López <zomars@me.com>
This commit is contained in:
co-authored by
Omar López
parent
008e0e43a2
commit
71574488c5
@@ -315,6 +315,14 @@ export async function ensureAvailableUsers(
|
||||
loggerWithEventDetails: Logger<unknown>
|
||||
) {
|
||||
const availableUsers: IsFixedAwareUser[] = [];
|
||||
const getStartDateTimeUtc = (startDateTimeInput: string, timeZone?: string) => {
|
||||
return timeZone === "Etc/GMT" ? dayjs.utc(startDateTimeInput) : dayjs(startDateTimeInput).tz(timeZone).utc();
|
||||
};
|
||||
|
||||
const startDateTimeUtc = getStartDateTimeUtc(input.dateFrom, input.timeZone);
|
||||
const endDateTimeUtc =
|
||||
input.timeZone === "Etc/GMT" ? dayjs.utc(input.dateTo) : dayjs(input.dateTo).tz(input.timeZone).utc();
|
||||
|
||||
const duration = dayjs(input.dateTo).diff(input.dateFrom, "minute");
|
||||
const originalBookingDuration = input.originalRescheduledBooking
|
||||
? dayjs(input.originalRescheduledBooking.endTime).diff(
|
||||
@@ -331,8 +339,8 @@ export async function ensureAvailableUsers(
|
||||
busyTimesFromLimitsBookingsAllUsers = await getBusyTimesForLimitChecks({
|
||||
userIds: eventType.users.map((u) => u.id),
|
||||
eventTypeId: eventType.id,
|
||||
startDate: input.dateFrom,
|
||||
endDate: input.dateTo,
|
||||
startDate: startDateTimeUtc.format(),
|
||||
endDate: endDateTimeUtc.format(),
|
||||
rescheduleUid: input.originalRescheduledBooking?.uid ?? null,
|
||||
bookingLimits,
|
||||
durationLimits,
|
||||
@@ -343,11 +351,13 @@ export async function ensureAvailableUsers(
|
||||
for (const user of eventType.users) {
|
||||
const { dateRanges, busy: bufferedBusyTimes } = await getUserAvailability(
|
||||
{
|
||||
...input,
|
||||
userId: user.id,
|
||||
eventTypeId: eventType.id,
|
||||
duration: originalBookingDuration,
|
||||
returnDateOverrides: false,
|
||||
...input,
|
||||
dateFrom: startDateTimeUtc.format(),
|
||||
dateTo: endDateTimeUtc.format(),
|
||||
},
|
||||
{
|
||||
user,
|
||||
@@ -374,9 +384,8 @@ export async function ensureAvailableUsers(
|
||||
//check if event time is within the date range
|
||||
for (const dateRange of dateRanges) {
|
||||
if (
|
||||
(dayjs.utc(input.dateFrom).isAfter(dateRange.start) ||
|
||||
dayjs.utc(input.dateFrom).isSame(dateRange.start)) &&
|
||||
(dayjs.utc(input.dateTo).isBefore(dateRange.end) || dayjs.utc(input.dateTo).isSame(dateRange.end))
|
||||
(startDateTimeUtc.isAfter(dateRange.start) || startDateTimeUtc.isSame(dateRange.start)) &&
|
||||
(endDateTimeUtc.isBefore(dateRange.end) || endDateTimeUtc.isSame(dateRange.end))
|
||||
) {
|
||||
dateRangeForBooking = true;
|
||||
break;
|
||||
@@ -388,7 +397,7 @@ export async function ensureAvailableUsers(
|
||||
}
|
||||
|
||||
try {
|
||||
foundConflict = checkForConflicts(bufferedBusyTimes, input.dateFrom, duration);
|
||||
foundConflict = checkForConflicts(bufferedBusyTimes, startDateTimeUtc, duration);
|
||||
} catch {
|
||||
log.debug({
|
||||
message: "Unable set isAvailableToBeBooked. Using true. ",
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
import type { Request, Response } from "express";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { describe, expect } from "vitest";
|
||||
|
||||
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
import { test } from "@calcom/web/test/fixtures/fixtures";
|
||||
import {
|
||||
createBookingScenario,
|
||||
getGoogleCalendarCredential,
|
||||
TestData,
|
||||
getDate,
|
||||
getOrganizer,
|
||||
getBooker,
|
||||
getScenarioData,
|
||||
mockSuccessfulVideoMeetingCreation,
|
||||
mockCalendarToHaveNoBusySlots,
|
||||
BookingLocations,
|
||||
} from "@calcom/web/test/utils/bookingScenario/bookingScenario";
|
||||
import { createMockNextJsRequest } from "@calcom/web/test/utils/bookingScenario/createMockNextJsRequest";
|
||||
import {
|
||||
expectSuccessfulBookingCreationEmails,
|
||||
expectBookingToBeInDatabase,
|
||||
expectSuccessfulCalendarEventCreationInCalendar,
|
||||
expectICalUIDAsString,
|
||||
} from "@calcom/web/test/utils/bookingScenario/expects";
|
||||
import { getMockRequestDataForBooking } from "@calcom/web/test/utils/bookingScenario/getMockRequestDataForBooking";
|
||||
import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAndTeardown";
|
||||
|
||||
export const Timezones = {
|
||||
"-05:00": "America/New_York",
|
||||
"00:00": "Europe/London",
|
||||
};
|
||||
|
||||
export type CustomNextApiRequest = NextApiRequest & Request;
|
||||
|
||||
export type CustomNextApiResponse = NextApiResponse & Response;
|
||||
// Local test runs sometime gets too slow
|
||||
const timeout = process.env.CI ? 5000 : 20000;
|
||||
|
||||
describe("handleNewBooking", () => {
|
||||
setupAndTeardown();
|
||||
|
||||
describe("Booking for slot only available by date override:", () => {
|
||||
test(
|
||||
`should be able to create a booking for the exact slot overridden`,
|
||||
async ({ emails }) => {
|
||||
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
|
||||
const newYorkTimeZone = Timezones["-05:00"];
|
||||
const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default;
|
||||
const booker = getBooker({
|
||||
email: "booker@example.com",
|
||||
name: "Booker",
|
||||
});
|
||||
|
||||
// Using .endOf("day") here to ensure our date doesn't change when we set the time zone
|
||||
const startDateTimeOrganizerTz = dayjs(plus1DateString)
|
||||
.endOf("day")
|
||||
.tz(newYorkTimeZone)
|
||||
.hour(11)
|
||||
.minute(0)
|
||||
.second(0);
|
||||
|
||||
const endDateTimeOrganizerTz = dayjs(plus1DateString)
|
||||
.endOf("day")
|
||||
.tz(newYorkTimeZone)
|
||||
.hour(12)
|
||||
.minute(0)
|
||||
.second(0);
|
||||
|
||||
const overrideSchedule = {
|
||||
name: "11:00AM to 12PM in New York",
|
||||
availability: [
|
||||
{
|
||||
days: [],
|
||||
startTime: dayjs("1970-01-01").utc().hour(11).toDate(), // These times are stored with Z offset
|
||||
endTime: dayjs("1970-01-01").utc().hour(12).toDate(), // These times are stored with Z offset
|
||||
date: plus1DateString,
|
||||
},
|
||||
],
|
||||
timeZone: newYorkTimeZone,
|
||||
};
|
||||
|
||||
const organizer = getOrganizer({
|
||||
name: "Organizer",
|
||||
email: "organizer@example.com",
|
||||
id: 101,
|
||||
schedules: [overrideSchedule],
|
||||
credentials: [getGoogleCalendarCredential()],
|
||||
selectedCalendars: [TestData.selectedCalendars.google],
|
||||
});
|
||||
|
||||
await createBookingScenario(
|
||||
getScenarioData({
|
||||
eventTypes: [
|
||||
{
|
||||
id: 1,
|
||||
slotInterval: 60,
|
||||
length: 60,
|
||||
users: [
|
||||
{
|
||||
id: 101,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
organizer,
|
||||
apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
|
||||
})
|
||||
);
|
||||
|
||||
mockSuccessfulVideoMeetingCreation({
|
||||
metadataLookupKey: "dailyvideo",
|
||||
videoMeetingData: {
|
||||
id: "MOCK_ID",
|
||||
password: "MOCK_PASS",
|
||||
url: `http://mock-dailyvideo.example.com/meeting-1`,
|
||||
},
|
||||
});
|
||||
|
||||
// Mock a Scenario where iCalUID isn't returned by Google Calendar in which case booking UID is used as the ics UID
|
||||
const calendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", {
|
||||
create: {
|
||||
id: "GOOGLE_CALENDAR_EVENT_ID",
|
||||
uid: "MOCK_ID",
|
||||
},
|
||||
});
|
||||
|
||||
const mockBookingData = getMockRequestDataForBooking({
|
||||
data: {
|
||||
eventTypeId: 1,
|
||||
responses: {
|
||||
email: booker.email,
|
||||
name: booker.name,
|
||||
location: { optionValue: "", value: BookingLocations.CalVideo },
|
||||
},
|
||||
start: startDateTimeOrganizerTz.format(),
|
||||
end: endDateTimeOrganizerTz.format(),
|
||||
timeZone: Timezones["-05:00"],
|
||||
},
|
||||
});
|
||||
|
||||
const { req } = createMockNextJsRequest({
|
||||
method: "POST",
|
||||
body: mockBookingData,
|
||||
});
|
||||
|
||||
const createdBooking = await handleNewBooking(req);
|
||||
expect(createdBooking.responses).toContain({
|
||||
email: booker.email,
|
||||
name: booker.name,
|
||||
});
|
||||
|
||||
expect(createdBooking).toContain({
|
||||
location: BookingLocations.CalVideo,
|
||||
});
|
||||
|
||||
await expectBookingToBeInDatabase({
|
||||
description: "",
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
uid: createdBooking.uid!,
|
||||
eventTypeId: mockBookingData.eventTypeId,
|
||||
status: BookingStatus.ACCEPTED,
|
||||
references: [
|
||||
{
|
||||
type: appStoreMetadata.dailyvideo.type,
|
||||
uid: "MOCK_ID",
|
||||
meetingId: "MOCK_ID",
|
||||
meetingPassword: "MOCK_PASS",
|
||||
meetingUrl: "http://mock-dailyvideo.example.com/meeting-1",
|
||||
},
|
||||
{
|
||||
type: appStoreMetadata.googlecalendar.type,
|
||||
uid: "GOOGLE_CALENDAR_EVENT_ID",
|
||||
meetingId: "GOOGLE_CALENDAR_EVENT_ID",
|
||||
meetingPassword: "MOCK_PASSWORD",
|
||||
meetingUrl: "https://UNUSED_URL",
|
||||
},
|
||||
],
|
||||
iCalUID: createdBooking.iCalUID,
|
||||
});
|
||||
|
||||
expectSuccessfulCalendarEventCreationInCalendar(calendarMock, {
|
||||
videoCallUrl: "http://mock-dailyvideo.example.com/meeting-1",
|
||||
// We won't be sending evt.destinationCalendar in this case.
|
||||
// Google Calendar in this case fallbacks to the "primary" calendar - https://github.com/calcom/cal.com/blob/7d5dad7fea78ff24dddbe44f1da5d7e08e1ff568/packages/app-store/googlecalendar/lib/CalendarService.ts#L217
|
||||
// Not sure if it's the correct behaviour. Right now, it isn't possible to have an organizer with connected calendar but no destination calendar - As soon as the Google Calendar app is installed, a destination calendar is created.
|
||||
calendarId: null,
|
||||
});
|
||||
|
||||
const iCalUID = expectICalUIDAsString(createdBooking.iCalUID);
|
||||
|
||||
expectSuccessfulBookingCreationEmails({
|
||||
booking: {
|
||||
uid: createdBooking.uid!,
|
||||
},
|
||||
booker,
|
||||
organizer,
|
||||
emails,
|
||||
iCalUID,
|
||||
});
|
||||
},
|
||||
timeout
|
||||
);
|
||||
|
||||
test(
|
||||
`should be able to book the last slot before midnight`,
|
||||
async ({ emails }) => {
|
||||
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
|
||||
const newYorkTimeZone = Timezones["-05:00"];
|
||||
const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default;
|
||||
const booker = getBooker({
|
||||
email: "booker@example.com",
|
||||
name: "Booker",
|
||||
});
|
||||
|
||||
// Using .endOf("day") here to ensure our date doesn't change when we set the time zone
|
||||
const startDateTimeOrganizerTz = dayjs(plus1DateString)
|
||||
.endOf("day")
|
||||
.tz(newYorkTimeZone)
|
||||
.hour(23)
|
||||
.minute(0)
|
||||
.second(0);
|
||||
|
||||
const endDateTimeOrganizerTz = dayjs(plus1DateString)
|
||||
.endOf("day")
|
||||
.tz(newYorkTimeZone)
|
||||
.startOf("day")
|
||||
.add(1, "day");
|
||||
|
||||
const overrideSchedule = {
|
||||
name: "11:00PM to 11:59PM in New York",
|
||||
availability: [
|
||||
{
|
||||
days: [],
|
||||
startTime: dayjs("1970-01-01").utc().hour(23).toDate(), // These times are stored with Z offset
|
||||
endTime: dayjs("1970-01-01").utc().hour(23).minute(59).toDate(), // These times are stored with Z offset
|
||||
date: plus1DateString,
|
||||
},
|
||||
],
|
||||
timeZone: newYorkTimeZone,
|
||||
};
|
||||
|
||||
const organizer = getOrganizer({
|
||||
name: "Organizer",
|
||||
email: "organizer@example.com",
|
||||
id: 101,
|
||||
schedules: [overrideSchedule],
|
||||
credentials: [getGoogleCalendarCredential()],
|
||||
selectedCalendars: [TestData.selectedCalendars.google],
|
||||
});
|
||||
|
||||
await createBookingScenario(
|
||||
getScenarioData({
|
||||
eventTypes: [
|
||||
{
|
||||
id: 1,
|
||||
slotInterval: 60,
|
||||
length: 60,
|
||||
users: [
|
||||
{
|
||||
id: 101,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
organizer,
|
||||
apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
|
||||
})
|
||||
);
|
||||
|
||||
mockSuccessfulVideoMeetingCreation({
|
||||
metadataLookupKey: "dailyvideo",
|
||||
videoMeetingData: {
|
||||
id: "MOCK_ID",
|
||||
password: "MOCK_PASS",
|
||||
url: `http://mock-dailyvideo.example.com/meeting-1`,
|
||||
},
|
||||
});
|
||||
|
||||
// Mock a Scenario where iCalUID isn't returned by Google Calendar in which case booking UID is used as the ics UID
|
||||
const calendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", {
|
||||
create: {
|
||||
id: "GOOGLE_CALENDAR_EVENT_ID",
|
||||
uid: "MOCK_ID",
|
||||
},
|
||||
});
|
||||
|
||||
const mockBookingData = getMockRequestDataForBooking({
|
||||
data: {
|
||||
eventTypeId: 1,
|
||||
responses: {
|
||||
email: booker.email,
|
||||
name: booker.name,
|
||||
location: { optionValue: "", value: BookingLocations.CalVideo },
|
||||
},
|
||||
start: startDateTimeOrganizerTz.format(),
|
||||
end: endDateTimeOrganizerTz.format(),
|
||||
timeZone: Timezones["-05:00"],
|
||||
},
|
||||
});
|
||||
|
||||
const { req } = createMockNextJsRequest({
|
||||
method: "POST",
|
||||
body: mockBookingData,
|
||||
});
|
||||
|
||||
const createdBooking = await handleNewBooking(req);
|
||||
expect(createdBooking.responses).toContain({
|
||||
email: booker.email,
|
||||
name: booker.name,
|
||||
});
|
||||
|
||||
expect(createdBooking).toContain({
|
||||
location: BookingLocations.CalVideo,
|
||||
});
|
||||
|
||||
await expectBookingToBeInDatabase({
|
||||
description: "",
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
uid: createdBooking.uid!,
|
||||
eventTypeId: mockBookingData.eventTypeId,
|
||||
status: BookingStatus.ACCEPTED,
|
||||
references: [
|
||||
{
|
||||
type: appStoreMetadata.dailyvideo.type,
|
||||
uid: "MOCK_ID",
|
||||
meetingId: "MOCK_ID",
|
||||
meetingPassword: "MOCK_PASS",
|
||||
meetingUrl: "http://mock-dailyvideo.example.com/meeting-1",
|
||||
},
|
||||
{
|
||||
type: appStoreMetadata.googlecalendar.type,
|
||||
uid: "GOOGLE_CALENDAR_EVENT_ID",
|
||||
meetingId: "GOOGLE_CALENDAR_EVENT_ID",
|
||||
meetingPassword: "MOCK_PASSWORD",
|
||||
meetingUrl: "https://UNUSED_URL",
|
||||
},
|
||||
],
|
||||
iCalUID: createdBooking.iCalUID,
|
||||
});
|
||||
|
||||
expectSuccessfulCalendarEventCreationInCalendar(calendarMock, {
|
||||
videoCallUrl: "http://mock-dailyvideo.example.com/meeting-1",
|
||||
// We won't be sending evt.destinationCalendar in this case.
|
||||
// Google Calendar in this case fallbacks to the "primary" calendar - https://github.com/calcom/cal.com/blob/7d5dad7fea78ff24dddbe44f1da5d7e08e1ff568/packages/app-store/googlecalendar/lib/CalendarService.ts#L217
|
||||
// Not sure if it's the correct behaviour. Right now, it isn't possible to have an organizer with connected calendar but no destination calendar - As soon as the Google Calendar app is installed, a destination calendar is created.
|
||||
calendarId: null,
|
||||
});
|
||||
|
||||
const iCalUID = expectICalUIDAsString(createdBooking.iCalUID);
|
||||
|
||||
expectSuccessfulBookingCreationEmails({
|
||||
booking: {
|
||||
uid: createdBooking.uid!,
|
||||
},
|
||||
booker,
|
||||
organizer,
|
||||
emails,
|
||||
iCalUID,
|
||||
});
|
||||
},
|
||||
timeout
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -77,11 +77,19 @@ export function processDateOverride({
|
||||
.second(0)
|
||||
.tz(timeZone, true);
|
||||
|
||||
const endDate = itemDateStartOfDay
|
||||
.add(item.endTime.getUTCHours(), "hours")
|
||||
.add(item.endTime.getUTCMinutes(), "minutes")
|
||||
.second(0)
|
||||
.tz(timeZone, true);
|
||||
let endDate = itemDateStartOfDay;
|
||||
const endTimeHours = item.endTime.getUTCHours();
|
||||
const endTimeMinutes = item.endTime.getUTCMinutes();
|
||||
|
||||
if (endTimeHours === 23 && endTimeMinutes === 59) {
|
||||
endDate = endDate.add(1, "day").tz(timeZone, true);
|
||||
} else {
|
||||
endDate = itemDateStartOfDay
|
||||
.add(endTimeHours, "hours")
|
||||
.add(endTimeMinutes, "minutes")
|
||||
.second(0)
|
||||
.tz(timeZone, true);
|
||||
}
|
||||
|
||||
return {
|
||||
start: startDate,
|
||||
@@ -116,7 +124,19 @@ export function buildDateRanges({
|
||||
availability.reduce((processed: DateRange[], item) => {
|
||||
if ("date" in item && !!item.date) {
|
||||
const itemDateAsUtc = dayjs.utc(item.date);
|
||||
if (itemDateAsUtc.isBetween(dateFrom.startOf("day"), dateTo.endOf("day"), null, "[]")) {
|
||||
// TODO: Remove the .subtract(1, "day") and .add(1, "day") part and
|
||||
// refactor this to actually work with correct dates.
|
||||
// As of 2024-02-20, there are mismatches between local and UTC dates for overrides
|
||||
// and the dateFrom and dateTo fields, resulting in this if not returning true, which
|
||||
// results in "no available users found" errors.
|
||||
if (
|
||||
itemDateAsUtc.isBetween(
|
||||
dateFrom.subtract(1, "day").startOf("day"),
|
||||
dateTo.add(1, "day").endOf("day"),
|
||||
null,
|
||||
"[]"
|
||||
)
|
||||
) {
|
||||
processed.push(processDateOverride({ item, itemDateAsUtc, timeZone }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it, beforeAll, vi } from "vitest";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { stringToDayjs } from "@calcom/prisma/zod-utils";
|
||||
|
||||
beforeAll(() => {
|
||||
vi.setSystemTime(dayjs.utc("2021-06-20T11:59:59Z").toDate());
|
||||
});
|
||||
|
||||
describe("Tests the parsing logic", () => {
|
||||
it("when supplied with no timezone data", async () => {
|
||||
const date = stringToDayjs("2024-02-27T17:00:00");
|
||||
expect(date.toISOString()).toBe("2024-02-27T17:00:00.000Z");
|
||||
});
|
||||
|
||||
it("when supplied with UTC", async () => {
|
||||
const date = stringToDayjs("2024-02-27T17:00:00Z");
|
||||
expect(date.year()).toBe(2024);
|
||||
expect(date.month()).toBe(1);
|
||||
expect(date.date()).toBe(27);
|
||||
expect(date.hour()).toBe(17);
|
||||
expect(date.minute()).toBe(0);
|
||||
expect(date.second()).toBe(0);
|
||||
expect(date.utcOffset()).toBe(0);
|
||||
});
|
||||
|
||||
it("when supplied with UTC- timezone", async () => {
|
||||
const date = stringToDayjs("2024-02-27T17:00:00-05:00");
|
||||
expect(date.year()).toBe(2024);
|
||||
expect(date.month()).toBe(1);
|
||||
expect(date.date()).toBe(27);
|
||||
expect(date.hour()).toBe(17);
|
||||
expect(date.minute()).toBe(0);
|
||||
expect(date.second()).toBe(0);
|
||||
expect(date.utcOffset()).toBe(-300);
|
||||
expect(date.toISOString()).toBe("2024-02-27T22:00:00.000Z");
|
||||
});
|
||||
|
||||
it("when supplied with UTC+ timezone", async () => {
|
||||
const date = stringToDayjs("2024-02-27T17:00:00+05:00");
|
||||
expect(date.year()).toBe(2024);
|
||||
expect(date.month()).toBe(1);
|
||||
expect(date.date()).toBe(27);
|
||||
expect(date.hour()).toBe(17);
|
||||
expect(date.minute()).toBe(0);
|
||||
expect(date.second()).toBe(0);
|
||||
expect(date.utcOffset()).toBe(300);
|
||||
expect(date.toISOString()).toBe("2024-02-27T12:00:00.000Z");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user