diff --git a/apps/web/test/lib/getSchedule.test.ts b/apps/web/test/lib/getSchedule.test.ts index 24b17b9360..9c7025e680 100644 --- a/apps/web/test/lib/getSchedule.test.ts +++ b/apps/web/test/lib/getSchedule.test.ts @@ -183,14 +183,14 @@ type InputEventType = { length?: number; slotInterval?: number; minimumBookingNotice?: number; - users: { id: number }[]; + users?: { id: number }[]; schedulingType?: SchedulingType; beforeEventBuffer?: number; afterEventBuffer?: number; }; type InputBooking = { - userId: number; + userId?: number; eventTypeId: number; startTime: string; endTime: string; @@ -198,6 +198,13 @@ type InputBooking = { status: BookingStatus; }; +type InputHost = { + id: number; + userId: number; + eventTypeId: number; + isFixed: boolean; +}; + const cleanup = async () => { await prisma.eventType.deleteMany(); await prisma.user.deleteMany(); @@ -223,6 +230,7 @@ describe("getSchedule", () => { const { dateString: plus2DateString } = getDate({ dateIncrement: 2 }); const scenarioData = { + hosts: [], eventTypes: [ { id: 1, @@ -337,6 +345,7 @@ describe("getSchedule", () => { endTime: `${plus2DateString}T06:15:00.000Z`, }, ], + hosts: [], }); // Day Plus 2 is completely free - It only has non accepted bookings @@ -434,6 +443,7 @@ describe("getSchedule", () => { schedules: [TestData.schedules.IstWorkHours], }, ], + hosts: [], }); const { dateString: plus1DateString } = getDate({ dateIncrement: 1 }); const { dateString: plus2DateString } = getDate({ dateIncrement: 2 }); @@ -530,6 +540,7 @@ describe("getSchedule", () => { schedules: [TestData.schedules.IstWorkHours], }, ], + hosts: [], }); const { dateString: todayDateString } = getDate(); const { dateString: minus1DateString } = getDate({ dateIncrement: -1 }); @@ -606,6 +617,7 @@ describe("getSchedule", () => { selectedCalendars: [TestData.selectedCalendars.google], }, ], + hosts: [], apps: [TestData.apps.googleCalendar], }; @@ -681,6 +693,7 @@ describe("getSchedule", () => { }, ], apps: [TestData.apps.googleCalendar], + hosts: [], }; createBookingScenario(scenarioData); @@ -740,6 +753,7 @@ describe("getSchedule", () => { schedules: [TestData.schedules.IstWorkHoursWithDateOverride(plus2DateString)], }, ], + hosts: [], }; createBookingScenario(scenarioData); @@ -762,6 +776,87 @@ describe("getSchedule", () => { } ); }); + + test("that a user is considered busy when there's a booking they host", async () => { + const { dateString: plus1DateString } = getDate({ dateIncrement: 1 }); + const { dateString: plus2DateString } = getDate({ dateIncrement: 2 }); + + createBookingScenario({ + eventTypes: [ + // A Collective Event Type hosted by this user + { + id: 1, + slotInterval: 45, + schedulingType: "COLLECTIVE", + }, + // A default Event Type which this user owns + { + id: 2, + slotInterval: 45, + users: [{ id: 101 }], + }, + ], + users: [ + { + ...TestData.users.example, + id: 101, + schedules: [TestData.schedules.IstWorkHours], + }, + ], + bookings: [ + // Create a booking on our Collective Event Type + { + // userId: XX, <- No owner since this is a Collective Event Type + eventTypeId: 1, + status: "ACCEPTED", + startTime: `${plus2DateString}T04:00:00.000Z`, + endTime: `${plus2DateString}T04:15:00.000Z`, + }, + ], + hosts: [ + // This user is a host of our Collective event + { + id: 1, + eventTypeId: 1, + userId: 101, + isFixed: true, + }, + ], + }); + + // Requesting this user's availability for their + // individual Event Type + const thisUserAvailability = await getSchedule( + { + eventTypeId: 2, + eventTypeSlug: "", + startTime: `${plus1DateString}T18:30:00.000Z`, + endTime: `${plus2DateString}T18:29:59.999Z`, + timeZone: Timezones["+5:30"], + }, + ctx + ); + + expect(thisUserAvailability).toHaveTimeSlots( + [ + // `04:00:00.000Z`, // <- This slot should be occupied by the Collective Event + `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`, + `12:15:00.000Z`, + ], + { + dateString: plus2DateString, + } + ); + }); }); describe("Team Event", () => { @@ -826,6 +921,7 @@ describe("getSchedule", () => { endTime: `${plus2DateString}T05:45:00.000Z`, }, ], + hosts: [], }); const scheduleForTeamEventOnADayWithNoBooking = await getSchedule( @@ -963,6 +1059,7 @@ describe("getSchedule", () => { endTime: `${plus3DateString}T04:15:00.000Z`, }, ], + hosts: [], }); const scheduleForTeamEventOnADayWithOneBookingForEachUserButOnDifferentTimeslots = await getSchedule( { @@ -1064,9 +1161,10 @@ function addEventTypes(eventTypes: InputEventType[], usersStore: InputUser[]) { throw new Error(`eventTypes[number]: id ${eventType.id} is not unique`); } foundEvents[eventType.id] = true; - const users = eventType.users.map((userWithJustId) => { - return usersStore.find((user) => user.id === userWithJustId.id); - }); + const users = + eventType.users?.map((userWithJustId) => { + return usersStore.find((user) => user.id === userWithJustId.id); + }) || []; return { ...baseEventType, ...eventType, @@ -1099,11 +1197,32 @@ async function addBookings(bookings: InputBooking[], eventTypes: InputEventType[ bookings // We can improve this filter to support the entire where clause but that isn't necessary yet. So, handle what we know we pass to `findMany` and is needed .filter((booking) => { + /** + * A user is considered busy within a given time period if there + * is a booking they own OR host. This function mocks some of the logic + * for each condition. For details see the following ticket: + * https://github.com/calcom/cal.com/issues/6374 + */ + + // ~~ FIRST CONDITION ensures that this booking is owned by this user + // and that the status is what we want // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - const statusIn = where.status?.in || []; - // Return bookings passing status prisma where - return statusIn.includes(booking.status) && booking.userId === where.userId; + const statusIn = where.OR[0].status?.in || []; + const firstConditionMatches = + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + statusIn.includes(booking.status) && booking.userId === where.OR[0].userId; + + // ~~ SECOND CONDITION checks whether this user is a host of this Event Type + // and that booking.status is a match for the returned query + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + let secondConditionMatches = where.OR[1].eventTypeId.in.includes(booking.eventTypeId); + secondConditionMatches = secondConditionMatches && statusIn.includes(booking.status); + + // We return this booking if either condition is met + return firstConditionMatches || secondConditionMatches; }) .map((booking) => ({ uid: uuidv4(), @@ -1116,6 +1235,10 @@ async function addBookings(bookings: InputBooking[], eventTypes: InputEventType[ }); } +function addHosts(hosts: InputHost[]) { + prismaMock.host.findMany.mockResolvedValue(hosts); +} + function addUsers(users: InputUser[]) { prismaMock.user.findMany.mockResolvedValue( users.map((user) => { @@ -1131,6 +1254,7 @@ type ScenarioData = { // TODO: Support multiple bookings and add tests with that. bookings?: InputBooking[]; users: InputUser[]; + hosts: InputHost[]; credentials?: InputCredential[]; apps?: App[]; selectedCalendars?: InputSelectedCalendar[]; @@ -1146,6 +1270,8 @@ function createBookingScenario(data: ScenarioData) { addUsers(data.users); + addHosts(data.hosts); + const eventType = addEventTypes(data.eventTypes, data.users); if (data.apps) { prismaMock.app.findMany.mockResolvedValue(data.apps as PrismaApp[]); diff --git a/packages/core/getBusyTimes.ts b/packages/core/getBusyTimes.ts index 9a98abc336..a1f5dfc19b 100644 --- a/packages/core/getBusyTimes.ts +++ b/packages/core/getBusyTimes.ts @@ -34,43 +34,97 @@ export async function getBusyTimes(params: { status: BookingStatus.ACCEPTED, })}` ); + + /** + * A user is considered busy within a given time period if there + * is a booking they own OR host. + * + * Therefore this query does the following: + * - Performs a query for all EventType id's where this user is a host + * - Performs a query for all bookings where: + * - The given booking is owned by this user, or.. + * - The given booking's EventType is hosted by this user + * + * See further discussion within this GH issue: + * https://github.com/calcom/cal.com/issues/6374 + * + * NOTE: Changes here will likely require changes to some mocking + * logic within getSchedule.test.ts:addBookings + */ performance.mark("prismaBookingGetStart"); - const busyTimes: EventBusyDetails[] = await prisma.booking - .findMany({ - where: { - userId, - startTime: { gte: new Date(startTime) }, - endTime: { lte: new Date(endTime) }, - status: { - in: [BookingStatus.ACCEPTED], - }, - }, - select: { - id: true, - startTime: true, - endTime: true, - title: true, - eventType: { - select: { - id: true, - afterEventBuffer: true, - beforeEventBuffer: true, + const busyTimes: EventBusyDetails[] = + // Getting all EventTypes ID's hosted by this user + await prisma.host + .findMany({ + where: { + userId: { + equals: userId, }, }, - }, - }) - .then((bookings) => - bookings.map(({ startTime, endTime, title, id, eventType }) => ({ - start: dayjs(startTime) - .subtract((eventType?.beforeEventBuffer || 0) + (afterEventBuffer || 0), "minute") - .toDate(), - end: dayjs(endTime) - .add((eventType?.afterEventBuffer || 0) + (beforeEventBuffer || 0), "minute") - .toDate(), - title, - source: `eventType-${eventType?.id}-booking-${id}`, - })) - ); + select: { + eventTypeId: true, + }, + }) + + // Converting the response object into an array + .then((thisUserHostedEvents) => thisUserHostedEvents.map((e) => e.eventTypeId)) + + // Finding all bookings owned OR hosted by this user + .then((thisUserHostedEventIds) => { + // This gets applied to both conditions + const sharedQuery = { + startTime: { gte: new Date(startTime) }, + endTime: { lte: new Date(endTime) }, + status: { + in: [BookingStatus.ACCEPTED], + }, + }; + + return prisma.booking.findMany({ + where: { + OR: [ + // Bookings owned by this user + { + ...sharedQuery, + userId, + }, + + // Bookings with an EventType ID that's hosted by this user + { + ...sharedQuery, + eventTypeId: { + in: thisUserHostedEventIds, + }, + }, + ], + }, + select: { + id: true, + startTime: true, + endTime: true, + title: true, + eventType: { + select: { + id: true, + afterEventBuffer: true, + beforeEventBuffer: true, + }, + }, + }, + }); + }) + .then((bookings) => + bookings.map(({ startTime, endTime, title, id, eventType }) => ({ + start: dayjs(startTime) + .subtract((eventType?.beforeEventBuffer || 0) + (afterEventBuffer || 0), "minute") + .toDate(), + end: dayjs(endTime) + .add((eventType?.afterEventBuffer || 0) + (beforeEventBuffer || 0), "minute") + .toDate(), + title, + source: `eventType-${eventType?.id}-booking-${id}`, + })) + ); logger.silly(`Busy Time from Cal Bookings ${JSON.stringify(busyTimes)}`); performance.mark("prismaBookingGetEnd"); performance.measure(`prisma booking get took $1'`, "prismaBookingGetStart", "prismaBookingGetEnd");