From 7b0321043d0610ebab936c32d20af4cfd01fd538 Mon Sep 17 00:00:00 2001 From: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com> Date: Wed, 10 Apr 2024 16:47:26 -0400 Subject: [PATCH] fix: OOO for team events (#14503) Co-authored-by: CarinaWolli --- packages/core/getAggregateWorkingHours.ts | 2 +- .../core/getAggregatedAvailability/index.ts | 18 +++++- packages/core/getUserAvailability.ts | 28 +++++---- .../features/bookings/lib/handleNewBooking.ts | 2 +- packages/lib/date-ranges.test.ts | 58 +++++++++++++++++-- packages/lib/date-ranges.ts | 33 ++++++++++- .../team/listTeamAvailability.handler.ts | 2 +- .../trpc/server/routers/viewer/slots/util.ts | 9 ++- 8 files changed, 126 insertions(+), 26 deletions(-) diff --git a/packages/core/getAggregateWorkingHours.ts b/packages/core/getAggregateWorkingHours.ts index 6751f3a84f..c72295a2f9 100644 --- a/packages/core/getAggregateWorkingHours.ts +++ b/packages/core/getAggregateWorkingHours.ts @@ -8,7 +8,7 @@ import type { WorkingHours } from "@calcom/types/schedule"; export const getAggregateWorkingHours = ( usersWorkingHoursAndBusySlots: (Omit< Awaited["getUserAvailability"]>>, - "currentSeats" | "dateRanges" + "currentSeats" | "dateRanges" | "oooExcludedDateRanges" > & { user?: { isFixed?: boolean } })[], // eslint-disable-next-line @typescript-eslint/no-unused-vars schedulingType: SchedulingType | null diff --git a/packages/core/getAggregatedAvailability/index.ts b/packages/core/getAggregatedAvailability/index.ts index 58691d2f5b..0f4143a243 100644 --- a/packages/core/getAggregatedAvailability/index.ts +++ b/packages/core/getAggregatedAvailability/index.ts @@ -5,18 +5,30 @@ import { SchedulingType } from "@calcom/prisma/enums"; import { mergeOverlappingDateRanges } from "./date-range-utils/mergeOverlappingDateRanges"; export const getAggregatedAvailability = ( - userAvailability: { dateRanges: DateRange[]; user?: { isFixed?: boolean } }[], + userAvailability: { + dateRanges: DateRange[]; + oooExcludedDateRanges: DateRange[]; + user?: { isFixed?: boolean }; + }[], schedulingType: SchedulingType | null ): DateRange[] => { + const isTeamEvent = + schedulingType === SchedulingType.COLLECTIVE || + schedulingType === SchedulingType.ROUND_ROBIN || + userAvailability.length > 1; const fixedHosts = userAvailability.filter( ({ user }) => !schedulingType || schedulingType === SchedulingType.COLLECTIVE || user?.isFixed ); - const dateRangesToIntersect = fixedHosts.map((s) => s.dateRanges); + const dateRangesToIntersect = fixedHosts.map((s) => + !isTeamEvent ? s.dateRanges : s.oooExcludedDateRanges + ); const unfixedHosts = userAvailability.filter(({ user }) => user?.isFixed !== true); if (unfixedHosts.length) { - dateRangesToIntersect.push(unfixedHosts.flatMap((s) => s.dateRanges)); + dateRangesToIntersect.push( + unfixedHosts.flatMap((s) => (!isTeamEvent ? s.dateRanges : s.oooExcludedDateRanges)) + ); } const availability = intersect(dateRangesToIntersect); diff --git a/packages/core/getUserAvailability.ts b/packages/core/getUserAvailability.ts index 462286b2d4..0df941aaec 100644 --- a/packages/core/getUserAvailability.ts +++ b/packages/core/getUserAvailability.ts @@ -332,18 +332,6 @@ const _getUserAvailability = async function getUsersWorkingHoursLifeTheUniverseA } } - const dateRanges = buildDateRanges({ - dateFrom, - dateTo, - availability, - timeZone, - }); - - const formattedBusyTimes = detailedBusyTimes.map((busy) => ({ - start: dayjs(busy.start), - end: dayjs(busy.end), - })); - const datesOutOfOffice = await getOutOfOfficeDays({ userId: user.id, dateFrom, @@ -351,8 +339,23 @@ const _getUserAvailability = async function getUsersWorkingHoursLifeTheUniverseA availability, }); + const { dateRanges, oooExcludedDateRanges } = buildDateRanges({ + dateFrom, + dateTo, + availability, + timeZone, + outOfOffice: datesOutOfOffice, + }); + + const formattedBusyTimes = detailedBusyTimes.map((busy) => ({ + start: dayjs(busy.start), + end: dayjs(busy.end), + })); + const dateRangesInWhichUserIsAvailable = subtract(dateRanges, formattedBusyTimes); + const dateRangesInWhichUserIsAvailableWithoutOOO = subtract(oooExcludedDateRanges, formattedBusyTimes); + log.debug( `getWorkingHours took ${endGetWorkingHours - startGetWorkingHours}ms for userId ${userId}`, JSON.stringify({ @@ -368,6 +371,7 @@ const _getUserAvailability = async function getUsersWorkingHoursLifeTheUniverseA busy: detailedBusyTimes, timeZone, dateRanges: dateRangesInWhichUserIsAvailable, + oooExcludedDateRanges: dateRangesInWhichUserIsAvailableWithoutOOO, workingHours, dateOverrides, currentSeats, diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index 2094c88472..22d40c6072 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -357,7 +357,7 @@ export async function ensureAvailableUsers( } for (const user of eventType.users) { - const { dateRanges, busy: bufferedBusyTimes } = await getUserAvailability( + const { oooExcludedDateRanges: dateRanges, busy: bufferedBusyTimes } = await getUserAvailability( { ...input, userId: user.id, diff --git a/packages/lib/date-ranges.test.ts b/packages/lib/date-ranges.test.ts index b2346a60d4..722ac4ed20 100644 --- a/packages/lib/date-ranges.test.ts +++ b/packages/lib/date-ranges.test.ts @@ -251,7 +251,7 @@ describe("buildDateRanges", () => { const timeZone = "America/New_York"; - const results = buildDateRanges({ availability: items, timeZone, dateFrom, dateTo }); + const { dateRanges: results } = buildDateRanges({ availability: items, timeZone, dateFrom, dateTo }); // [ // { s: '2023-06-13T10:00:00-04:00', e: '2023-06-13T15:00:00-04:00' }, // { s: '2023-06-14T08:00:00-04:00', e: '2023-06-14T17:00:00-04:00' } @@ -288,7 +288,7 @@ describe("buildDateRanges", () => { const dateFrom = dayjs.tz("2023-08-15", "Europe/Brussels").startOf("day"); const dateTo = dayjs.tz("2023-08-15", "Europe/Brussels").endOf("day"); - const result = buildDateRanges({ availability: item, timeZone, dateFrom, dateTo }); + const { dateRanges: result } = buildDateRanges({ availability: item, timeZone, dateFrom, dateTo }); // this happened only on Europe/Brussels, Europe/Amsterdam was 2023-08-15T17:00:00-10:00 (as it should be) expect(result[0].end.format()).not.toBe("2023-08-14T17:00:00-10:00"); }); @@ -310,7 +310,7 @@ describe("buildDateRanges", () => { const dateFrom = dayjs("2023-06-13T00:00:00Z"); const dateTo = dayjs("2023-06-15T00:00:00Z"); - const results = buildDateRanges({ availability: items, timeZone, dateFrom, dateTo }); + const { dateRanges: results } = buildDateRanges({ availability: items, timeZone, dateFrom, dateTo }); expect(results[0]).toEqual({ start: dayjs("2023-06-14T07:00:00Z").tz(timeZone), @@ -330,7 +330,7 @@ describe("buildDateRanges", () => { const dateFrom = dayjs("2023-06-13T10:00:00Z"); const dateTo = dayjs("2023-06-13T10:30:00Z"); - const results = buildDateRanges({ availability: items, timeZone, dateFrom, dateTo }); + const { dateRanges: results } = buildDateRanges({ availability: items, timeZone, dateFrom, dateTo }); expect(results[0]).toEqual({ start: dayjs("2023-06-13T08:00:00Z").tz(timeZone), @@ -355,7 +355,7 @@ describe("buildDateRanges", () => { const dateFrom = dayjs("2023-06-13T00:00:00Z"); const dateTo = dayjs("2023-06-15T00:00:00Z"); - const results = buildDateRanges({ availability: items, timeZone, dateFrom, dateTo }); + const { dateRanges: results } = buildDateRanges({ availability: items, timeZone, dateFrom, dateTo }); expect(results[0]).toEqual({ start: dayjs("2023-06-14T02:00:00Z").tz(timeZone), @@ -366,6 +366,54 @@ describe("buildDateRanges", () => { end: dayjs("2023-06-14T21:00:00Z").tz(timeZone), }); }); + it("should handle OOO correctly", () => { + const items = [ + { + date: new Date(Date.UTC(2023, 5, 13)), + startTime: new Date(Date.UTC(0, 0, 0, 10, 0)), // 10 AM + endTime: new Date(Date.UTC(0, 0, 0, 15, 0)), // 3 PM + }, + { + days: [1, 2, 3, 4, 5], + startTime: new Date(Date.UTC(2023, 5, 12, 8, 0)), // 8 AM + endTime: new Date(Date.UTC(2023, 5, 12, 17, 0)), // 5 PM + }, + ]; + + const outOfOffice = { + "2023-06-13": { + fromUser: { id: 1, displayName: "Team Free Example" }, + }, + }; + + const dateFrom = dayjs("2023-06-13T00:00:00Z"); // 2023-06-12T20:00:00-04:00 (America/New_York) + const dateTo = dayjs("2023-06-15T00:00:00Z"); + + const timeZone = "America/New_York"; + + const { dateRanges, oooExcludedDateRanges } = buildDateRanges({ + availability: items, + timeZone, + dateFrom, + dateTo, + outOfOffice, + }); + + expect(dateRanges[0]).toEqual({ + start: dayjs("2023-06-13T14:00:00Z").tz(timeZone), + end: dayjs("2023-06-13T19:00:00Z").tz(timeZone), + }); + + expect(dateRanges[1]).toEqual({ + start: dayjs("2023-06-14T12:00:00Z").tz(timeZone), + end: dayjs("2023-06-14T21:00:00Z").tz(timeZone), + }); + expect(oooExcludedDateRanges.length).toBe(1); + expect(oooExcludedDateRanges[0]).toEqual({ + start: dayjs("2023-06-14T12:00:00Z").tz(timeZone), + end: dayjs("2023-06-14T21:00:00Z").tz(timeZone), + }); + }); }); describe("subtract", () => { diff --git a/packages/lib/date-ranges.ts b/packages/lib/date-ranges.ts index 8cc29179dd..0722861247 100644 --- a/packages/lib/date-ranges.ts +++ b/packages/lib/date-ranges.ts @@ -1,3 +1,4 @@ +import type { IOutOfOfficeData } from "@calcom/core/getUserAvailability"; import type { Dayjs } from "@calcom/dayjs"; import dayjs from "@calcom/dayjs"; import type { Availability } from "@calcom/prisma/client"; @@ -103,17 +104,31 @@ export function processDateOverride({ }; } +function processOOO(outOfOffice: Dayjs, timeZone: string) { + const utcOffset = outOfOffice.tz(timeZone).utcOffset(); + const utcDate = outOfOffice.subtract(utcOffset, "minute"); + + const OOOdate = utcDate.tz(timeZone); + + return { + start: OOOdate, + end: OOOdate, + }; +} + export function buildDateRanges({ availability, timeZone /* Organizer timeZone */, dateFrom /* Attendee dateFrom */, dateTo /* `` dateTo */, + outOfOffice, }: { timeZone: string; availability: (DateOverride | WorkingHours)[]; dateFrom: Dayjs; dateTo: Dayjs; -}): DateRange[] { + outOfOffice?: IOutOfOfficeData; +}): { dateRanges: DateRange[]; oooExcludedDateRanges: DateRange[] } { const dateFromOrganizerTZ = dateFrom.tz(timeZone); const groupedWorkingHours = groupByDate( availability.reduce((processed: DateRange[], item) => { @@ -125,6 +140,11 @@ export function buildDateRanges({ return processed; }, []) ); + const OOOdates = outOfOffice + ? Object.keys(outOfOffice).map((outOfOffice) => processOOO(dayjs(outOfOffice), timeZone)) + : []; + + const groupedOOO = groupByDate(OOOdates); const groupedDateOverrides = groupByDate( availability.reduce((processed: DateRange[], item) => { @@ -158,7 +178,16 @@ export function buildDateRanges({ (ranges) => ranges.filter((range) => range.start.valueOf() !== range.end.valueOf()) ); - return dateRanges.flat(); + const oooExcludedDateRanges = Object.values({ + ...groupedWorkingHours, + ...groupedDateOverrides, + ...groupedOOO, + }).map( + // remove 0-length overrides && OOO dates that were kept to cancel out working dates until now. + (ranges) => ranges.filter((range) => range.start.valueOf() !== range.end.valueOf()) + ); + + return { dateRanges: dateRanges.flat(), oooExcludedDateRanges: oooExcludedDateRanges.flat() }; } export function groupByDate(ranges: DateRange[]): { [x: string]: DateRange[] } { diff --git a/packages/trpc/server/routers/viewer/availability/team/listTeamAvailability.handler.ts b/packages/trpc/server/routers/viewer/availability/team/listTeamAvailability.handler.ts index c2f3cf8e2c..6d81942136 100644 --- a/packages/trpc/server/routers/viewer/availability/team/listTeamAvailability.handler.ts +++ b/packages/trpc/server/routers/viewer/availability/team/listTeamAvailability.handler.ts @@ -97,7 +97,7 @@ async function buildMember(member: Member, dateFrom: Dayjs, dateTo: Dayjs) { }); const timeZone = schedule?.timeZone || member.user.timeZone; - const dateRanges = buildDateRanges({ + const { dateRanges } = buildDateRanges({ dateFrom, dateTo, timeZone, diff --git a/packages/trpc/server/routers/viewer/slots/util.ts b/packages/trpc/server/routers/viewer/slots/util.ts index 2598de9d15..71168fd5fc 100644 --- a/packages/trpc/server/routers/viewer/slots/util.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -461,6 +461,7 @@ export async function getAvailableSlots({ input, ctx }: GetScheduleOptions): Pro const { busy, dateRanges, + oooExcludedDateRanges, currentSeats: _currentSeats, timeZone, datesOutOfOffice, @@ -496,6 +497,7 @@ export async function getAvailableSlots({ input, ctx }: GetScheduleOptions): Pro return { timeZone, dateRanges, + oooExcludedDateRanges, busy, user: currentUser, datesOutOfOffice, @@ -523,6 +525,11 @@ export async function getAvailableSlots({ input, ctx }: GetScheduleOptions): Pro const checkForAvailabilityCount = 0; const aggregatedAvailability = getAggregatedAvailability(allUsersAvailability, eventType.schedulingType); + const isTeamEvent = + eventType.schedulingType === SchedulingType.COLLECTIVE || + eventType.schedulingType === SchedulingType.ROUND_ROBIN || + allUsersAvailability.length > 1; + const timeSlots = getSlots({ inviteeDate: startTime, eventLength: input.duration || eventType.length, @@ -532,7 +539,7 @@ export async function getAvailableSlots({ input, ctx }: GetScheduleOptions): Pro frequency: eventType.slotInterval || input.duration || eventType.length, organizerTimeZone: eventType.timeZone || eventType?.schedule?.timeZone || allUsersAvailability?.[0]?.timeZone, - datesOutOfOffice: allUsersAvailability[0]?.datesOutOfOffice, + datesOutOfOffice: !isTeamEvent ? allUsersAvailability[0]?.datesOutOfOffice : undefined, }); let availableTimeSlots: typeof timeSlots = [];