Files
calendar/packages/features/availability/lib/getUserAvailability.ts
T
Dhairyashil ShindeandGitHub 12e07f20d4 feat: Add Holidays feature to block availability on public holidays (#25561)
* feat: add holidays feature for automatic availability blocking- Add UserHolidaySettings model for storing user preferences- Generate static holiday data for 20 countries using date-holidays- Create HolidayService for runtime holiday queries- Add TRPC router with endpoints for country selection and holiday toggles- Create Holidays tab UI in Availability page with conflict warnings- Integrate holiday blocking into getUserAvailability calculation- Show holiday indicator on blocked dates in booker page- Add warning in OOO modal when dates overlap with holidays

* feat: add optimizations, tests, and code quality improvements

- Add memoization/caching to HolidayService for better performance
- Optimize checkConflicts DB query with OR conditions for specific dates
- Add HolidayService unit tests (10 tests)
- Add error handling with Alert component for failed queries
- Memoize HolidayListItem component to prevent unnecessary re-renders
- Extract magic numbers into constants.ts
- Use TRPCError consistently in handlers
- Add missing i18n keys for error messages
- Update handlers to follow Cal.com patterns (default exports, minimal comments)
- Add regeneration instructions in constants

* refactor: replace static JSON with Google Calendar API integration

- Add GoogleHolidayService to fetch holidays from Google Calendar public calendars
- Add HolidayCache Prisma model for caching API responses
- Add GOOGLE_CALENDAR_API_KEY and HOLIDAY_CACHE_DAYS env variables
- Support 38 countries via Google Calendar holiday calendars
- Update HolidayService methods to async with database caching
- Update all TRPC handlers for async holiday methods
- Fix UI to display holiday dates correctly
- Remove static holidays.json and generate script

* use we instead of calcom in i18n message

* address cubics comments

* move holidays from availability to ooo

* public holidays filter for holidays

* follow i18n _one and _other pattern

* remove holiday feature flag

* revert lint command code change

* revert lint command code change 2.0

* revert lint command code change 3.0

* bye bye my christmas emoji :crying-emoji

* remove comments

* refactor(holidays): add repository pattern, split services, and add tests

- Create HolidayRepository for database operations
- Split GoogleHolidayService into GoogleCalendarClient and HolidayCacheService
- Add dependency injection to HolidayService and HolidayCacheService
- Update TRPC handlers to use HolidayRepository
- Add tests for HolidayRepository and calculateHolidayBlockedDates
- Update calendar IDs to use official holiday format (244 countries + religions)

* fix: address PR review feedback

- Remove unused date-holidays package
- Add pluralization for and_more_holidays_with_conflicts translation
- DRY: spread GOOGLE_RELIGIOUS_HOLIDAY_CALENDARS into GOOGLE_HOLIDAY_CALENDARS
- Add select to userHolidaySettings query in getUserAvailability
- Optimize checkConflicts with pre-computed timestamps

* refactor(holidays): apply proxy pattern and move logic to service

- Rename HolidayCacheService to HolidayServiceCachingProxy (proxy pattern)
- Remove HOLIDAY_CACHE_DAYS env var, use constant directly
- Add isSupportedCountry() method to HolidayService
- Add getUserSettings() and updateSettings() to HolidayService
- Move toggleHoliday logic from handler to service
- Move checkConflicts logic from handler to service
- Add findBookingsInDateRanges() to HolidayRepository
- Simplify all handlers to just call service methods

* feat(bookings): add backend validation to prevent booking on holidays

Adds explicit holiday conflict validation during booking creation to
handle the race condition where a host enables a holiday after a guest
selects a date but before they submit the booking.

Changes:
- Add checkHolidayConflict validation with HolidayRepository integration
- Integrate ensureNoHolidayConflict in RegularBookingService
- Add BookingOnHoliday error code with proper HTTP 400 response
- Handle holiday error display in BookEventForm with name interpolation
- Hide trace ID for expected validation errors
- Add unit tests for holiday conflict validation

* fix failing type check

* fix: address PR review comments for holiday feature

- Change error code from BAD_REQUEST to INTERNAL_SERVER_ERROR in
  toggleHoliday handler (errors are internal failures, not bad input)
- Refactor ensureNoHolidayConflict to use Promise.all for parallel
  user checking instead of sequential loop

* refactor: use Promise.all with logging in loop for holiday check

- Check all users in parallel using Promise.all
- Log conflicts inside the loop as they are detected
- Wait for all checks to complete before throwing error

* fix(holidays): use host timezone for holiday conflict checks

The holiday feature was checking booking dates against holidays using
server/local timezone instead of the host's timezone. This caused
bookings near midnight boundaries to incorrectly pass or fail the
holiday check.

Example: A booking at Dec 24th 8PM UTC (which is Dec 25th in IST)
was not being blocked for an Indian host with Christmas as a holiday.

Changes:
- Add .utc() to holiday date formatting for consistency
- Fetch host's timezone in checkHolidayConflict
- Convert booking time to host's timezone before comparison
- Add findUserSettingsWithTimezone to HolidayRepository
- Update error message to clarify it's the host's local time
- Add timezone edge case tests

* fix(holidays): align holiday blocking with OOO pattern for consistent timezone handling

- Simplify calculateHolidayBlockedDates to match OOO pattern using dayjs.utc()
- Fix date range query to use full day bounds (startOfDay/endOfDay) so holidays
  stored at midnight UTC are correctly found during booking validation
- Remove separate checkHolidayConflict booking-time validation - holidays now
  block through oooExcludedDateRanges like OOO does
- Remove getHolidayOnDate from HolidayService (no longer needed)
- Remove findUserSettingsWithTimezone from HolidayRepository (no longer needed)
- Clean up related tests

Holiday blocking now works exactly like OOO:
1. Holidays are added to datesOutOfOffice in calculateHolidayBlockedDates
2. buildDateRanges processes them via processOOO with .tz(timeZone, true)
3. oooExcludedDateRanges excludes those dates from availability
4. ensureAvailableUsers uses oooExcludedDateRanges to block bookings

This ensures consistent timezone handling where Dec 25th blocks all hours
of Dec 25th in the host's timezone, regardless of booker's timezone.

* update test

* update .env.example file
2025-12-15 16:39:10 +00:00

770 lines
26 KiB
TypeScript

import * as Sentry from "@sentry/nextjs";
import { z } from "zod";
import { getCalendar } from "@calcom/app-store/_utils/getCalendar";
import type { Dayjs } from "@calcom/dayjs";
import dayjs from "@calcom/dayjs";
import type { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository";
import {
getBusyTimesFromLimits,
getBusyTimesFromTeamLimits,
} from "@calcom/features/busyTimes/lib/getBusyTimesFromLimits";
import { getBusyTimesService } from "@calcom/features/di/containers/BusyTimes";
import { EventTypeRepository } from "@calcom/features/eventtypes/repositories/eventTypeRepository";
import type { PrismaOOORepository } from "@calcom/features/ooo/repositories/PrismaOOORepository";
import type { IRedisService } from "@calcom/features/redis/IRedisService";
import type { DateOverride, WorkingHours } from "@calcom/features/schedules/lib/date-ranges";
import { buildDateRanges, subtract } from "@calcom/features/schedules/lib/date-ranges";
import { getWorkingHours } from "@calcom/lib/availability";
import { stringToDayjsZod } from "@calcom/lib/dayjs";
import { ErrorCode } from "@calcom/lib/errorCodes";
import { getHolidayService } from "@calcom/lib/holidays";
import { HttpError } from "@calcom/lib/http-error";
import { parseBookingLimit } from "@calcom/lib/intervalLimits/isBookingLimits";
import { parseDurationLimit } from "@calcom/lib/intervalLimits/isDurationLimits";
import { getPeriodStartDatesBetween as getPeriodStartDatesBetweenUtil } from "@calcom/lib/intervalLimits/utils/getPeriodStartDatesBetween";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { withReporting } from "@calcom/lib/sentryWrapper";
import prisma from "@calcom/prisma";
import type {
Booking,
Prisma,
OutOfOfficeEntry,
OutOfOfficeReason,
User,
EventType as PrismaEventType,
} from "@calcom/prisma/client";
import { SchedulingType } from "@calcom/prisma/enums";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import type { EventBusyDetails, IntervalLimitUnit } from "@calcom/types/Calendar";
import type { TimeRange } from "@calcom/types/schedule";
import { findUsersForAvailabilityCheck } from "./findUsersForAvailabilityCheck";
const log = logger.getSubLogger({ prefix: ["getUserAvailability"] });
const availabilitySchema = z
.object({
dateFrom: stringToDayjsZod,
dateTo: stringToDayjsZod,
eventTypeId: z.number().optional(),
username: z.string().optional(),
userId: z.number().optional(),
afterEventBuffer: z.number().optional(),
beforeEventBuffer: z.number().optional(),
duration: z.number().optional(),
withSource: z.boolean().optional(),
returnDateOverrides: z.boolean(),
bypassBusyCalendarTimes: z.boolean().optional(),
silentlyHandleCalendarFailures: z.boolean().optional(),
shouldServeCache: z.boolean().optional(),
})
.refine((data) => !!data.username || !!data.userId, "Either username or userId should be filled in.");
export type EventType = Awaited<ReturnType<(typeof UserAvailabilityService)["prototype"]["_getEventType"]>>;
type GetUser = Awaited<ReturnType<(typeof UserAvailabilityService)["prototype"]["_getUser"]>>;
export type GetUserAvailabilityInitialData = {
user?: GetUser;
eventType?: EventType;
currentSeats?: CurrentSeats;
rescheduleUid?: string | null;
currentBookings?: (Pick<Booking, "id" | "uid" | "userId" | "startTime" | "endTime" | "title"> & {
eventType: Pick<
PrismaEventType,
"id" | "beforeEventBuffer" | "afterEventBuffer" | "seatsPerTimeSlot"
> | null;
_count?: {
seatsReferences: number;
};
})[];
outOfOfficeDays?: (Pick<OutOfOfficeEntry, "id" | "start" | "end" | "notes" | "showNotePublicly"> & {
user: Pick<User, "id" | "name">;
toUser: Pick<User, "id" | "username" | "name"> | null;
reason: Pick<OutOfOfficeReason, "id" | "emoji" | "reason"> | null;
})[];
busyTimesFromLimitsBookings: EventBusyDetails[];
busyTimesFromLimits?: Map<number, EventBusyDetails[]>;
eventTypeForLimits?: {
id: number;
bookingLimits?: unknown;
durationLimits?: unknown;
} | null;
teamBookingLimits?: Map<number, EventBusyDetails[]>;
teamForBookingLimits?: {
id: number;
bookingLimits?: unknown;
includeManagedEventsInLimits: boolean;
} | null;
};
export type GetAvailabilityUser = NonNullable<GetUserAvailabilityInitialData["user"]>;
type GetUserAvailabilityQuery = {
withSource?: boolean;
username?: string;
userId?: number;
dateFrom: string;
dateTo: string;
eventTypeId?: number;
afterEventBuffer?: number;
beforeEventBuffer?: number;
duration?: number;
returnDateOverrides: boolean;
bypassBusyCalendarTimes: boolean;
silentlyHandleCalendarFailures?: boolean;
shouldServeCache?: boolean;
};
export type CurrentSeats = Awaited<
ReturnType<(typeof UserAvailabilityService)["prototype"]["_getCurrentSeats"]>
>;
export type GetUserAvailabilityResult = Awaited<
ReturnType<(typeof UserAvailabilityService)["prototype"]["_getUserAvailability"]>
>;
interface GetUserAvailabilityParamsDTO {
availability: (DateOverride | WorkingHours)[];
}
export interface IFromUser {
id: number;
displayName: string | null;
}
export interface IToUser {
id: number;
username: string | null;
displayName: string | null;
}
export interface IOutOfOfficeData {
[key: string]: {
fromUser: IFromUser | null;
toUser?: IToUser | null;
reason?: string | null;
emoji?: string | null;
notes?: string | null;
showNotePublicly?: boolean;
};
}
type GetUsersAvailabilityProps = {
users: (GetAvailabilityUser & {
currentBookings?: GetUserAvailabilityInitialData["currentBookings"];
outOfOfficeDays?: GetUserAvailabilityInitialData["outOfOfficeDays"];
})[];
query: Omit<GetUserAvailabilityQuery, "userId" | "username">;
initialData?: Omit<GetUserAvailabilityInitialData, "user">;
};
export interface IUserAvailabilityService {
eventTypeRepo: EventTypeRepository;
oooRepo: PrismaOOORepository;
bookingRepo: BookingRepository;
redisClient: IRedisService;
}
export class UserAvailabilityService {
constructor(public readonly dependencies: IUserAvailabilityService) {}
// Fetch timezones from outlook or google using delegated credentials (formely known as domain wide delegatiion)
async getTimezoneFromDelegatedCalendars(user: GetAvailabilityUser): Promise<string | null> {
if (!user.credentials || user.credentials.length === 0) {
return null;
}
const delegatedCredentials = user.credentials.filter(
(credential) => credential.type.endsWith("_calendar") && Boolean(credential.delegatedToId)
);
if (!delegatedCredentials || delegatedCredentials.length === 0) {
return null;
}
const cacheKey = `user-timezone:${user.id}`;
try {
const cachedTimezone = await this.dependencies.redisClient.get<string>(cacheKey);
if (cachedTimezone) {
log.debug(`Got timezone ${cachedTimezone} from Redis cache for user ${user.id}`);
return cachedTimezone;
}
} catch (error) {
log.warn(`Failed to get timezone from Redis cache for user ${user.id}:`, error);
}
if (delegatedCredentials.length === 0) {
return null;
}
for (const credential of delegatedCredentials) {
try {
const calendar = await getCalendar(credential);
if (calendar && "getMainTimeZone" in calendar && typeof calendar.getMainTimeZone === "function") {
const timezone = await calendar.getMainTimeZone();
if (timezone && timezone !== "UTC") {
log.debug(`Got timezone ${timezone} from calendar service ${credential.type}`);
try {
await this.dependencies.redisClient.set<string>(cacheKey, timezone, { ttl: 3600 * 6 * 1000 }); // 6 hours ttl in ms;
log.debug(`Cached timezone ${timezone} in Redis for user ${user.id}`);
} catch (error) {
log.warn(`Failed to set timezone in Redis cache for user ${user.id}:`, error);
}
return timezone;
}
}
} catch (error) {
log.warn(`Failed to get timezone from calendar service ${credential.type}:`, error);
}
}
return null;
}
async _getEventType(id: number) {
const eventType = await this.dependencies.eventTypeRepo.findByIdForUserAvailability({ id });
if (!eventType) {
return eventType;
}
return {
...eventType,
metadata: EventTypeMetaDataSchema.parse(eventType.metadata),
};
}
getEventType = withReporting(this._getEventType.bind(this), "getEventType");
async _getUser(where: Prisma.UserWhereInput) {
return findUsersForAvailabilityCheck({ where });
}
getUser = withReporting(this._getUser.bind(this), "getUser");
async _getCurrentSeats(
eventType: {
id?: number;
schedulingType?: SchedulingType | null;
hosts?: {
user: {
email: string;
};
}[];
},
dateFrom: Dayjs,
dateTo: Dayjs
) {
const { schedulingType, hosts, id } = eventType;
const hostEmails = hosts?.map((host) => host.user.email);
const isTeamEvent =
schedulingType === SchedulingType.MANAGED ||
schedulingType === SchedulingType.ROUND_ROBIN ||
schedulingType === SchedulingType.COLLECTIVE;
const bookings = await this.dependencies.bookingRepo.findAcceptedBookingByEventTypeId({
eventTypeId: id,
dateFrom: dateFrom.format(),
dateTo: dateTo.format(),
});
return bookings.map((booking) => {
const attendees = isTeamEvent
? booking.attendees.filter((attendee) => !hostEmails?.includes(attendee.email))
: booking.attendees;
return {
uid: booking.uid,
startTime: booking.startTime,
_count: {
attendees: attendees.length,
},
};
});
}
getCurrentSeats = withReporting(this._getCurrentSeats.bind(this), "getCurrentSeats");
/** This should be called getUsersWorkingHoursAndBusySlots (...and remaining seats, and final timezone) */
async _getUserAvailability(query: GetUserAvailabilityQuery, initialData?: GetUserAvailabilityInitialData) {
const {
username,
userId,
dateFrom,
dateTo,
eventTypeId,
afterEventBuffer,
beforeEventBuffer,
duration,
returnDateOverrides,
bypassBusyCalendarTimes = false,
silentlyHandleCalendarFailures = false,
shouldServeCache,
} = availabilitySchema.parse(query);
log.debug(
`EventType: ${eventTypeId} | User: ${username} (ID: ${userId}) - Called with: ${safeStringify({
query,
})}`
);
if (!dateFrom.isValid() || !dateTo.isValid()) {
throw new HttpError({ statusCode: 400, message: "Invalid time range given." });
}
const where: Prisma.UserWhereInput = {};
if (username) where.username = username;
if (userId) where.id = userId;
const user = initialData?.user || (await this.getUser(where));
if (!user) {
throw new HttpError({ statusCode: 404, message: "No user found in getUserAvailability" });
}
let eventType: EventType | null = initialData?.eventType || null;
if (!eventType && eventTypeId) eventType = await this.getEventType(eventTypeId);
/* Current logic is if a booking is in a time slot mark it as busy, but seats can have more than one attendee so grab
current bookings with a seats event type and display them on the calendar, even if they are full */
let currentSeats: CurrentSeats | null = initialData?.currentSeats || null;
if (!currentSeats && eventType?.seatsPerTimeSlot) {
currentSeats = await this.getCurrentSeats(eventType, dateFrom, dateTo);
}
const userSchedule = user.schedules.filter(
(schedule) => !user?.defaultScheduleId || schedule.id === user?.defaultScheduleId
)[0];
const hostSchedule = eventType?.hosts?.find((host) => host.user.id === user.id)?.schedule;
// TODO: It uses default timezone of user. Should we use timezone of team ?
const fallbackTimezoneIfScheduleIsMissing = eventType?.timeZone || user.timeZone;
const fallbackSchedule = {
availability: [
{
startTime: new Date("1970-01-01T09:00:00Z"),
endTime: new Date("1970-01-01T17:00:00Z"),
days: [1, 2, 3, 4, 5], // Monday to Friday
date: null,
},
],
id: 0,
timeZone: fallbackTimezoneIfScheduleIsMissing,
};
// possible timezones that have been set by or for a user
const potentialSchedule = eventType?.schedule
? eventType.schedule
: hostSchedule
? hostSchedule
: userSchedule;
// if no schedules set by or for a user, use fallbackSchedule
const schedule = potentialSchedule ?? fallbackSchedule;
const bookingLimits =
eventType?.bookingLimits &&
typeof eventType.bookingLimits === "object" &&
Object.keys(eventType.bookingLimits).length > 0
? parseBookingLimit(eventType.bookingLimits)
: null;
const durationLimits =
eventType?.durationLimits &&
typeof eventType.durationLimits === "object" &&
Object.keys(eventType.durationLimits).length > 0
? parseDurationLimit(eventType.durationLimits)
: null;
// TODO: only query what we need after applying limits (shrink date range)
const getBusyTimesStart = dateFrom.toISOString();
const getBusyTimesEnd = dateTo.toISOString();
const selectedCalendars = eventType?.useEventLevelSelectedCalendars
? EventTypeRepository.getSelectedCalendarsFromUser({ user, eventTypeId: eventType.id })
: user.userLevelSelectedCalendars;
const isTimezoneSet = Boolean(potentialSchedule && potentialSchedule.timeZone !== null);
// this timezone is synced with google/outlook calendars timezone usingg delegated credentials
// it's a fallback for delegated credentials users who want to sync their timezone with third party calendars
const calendarTimezone = !isTimezoneSet ? await this.getTimezoneFromDelegatedCalendars(user) : null;
const finalTimezone =
!isTimezoneSet && calendarTimezone
? calendarTimezone
: schedule?.timeZone || fallbackTimezoneIfScheduleIsMissing;
let busyTimesFromLimits: EventBusyDetails[] = [];
if (initialData?.busyTimesFromLimits && initialData?.eventTypeForLimits) {
busyTimesFromLimits = initialData.busyTimesFromLimits.get(user.id) || [];
} else if (eventType && (bookingLimits || durationLimits)) {
// Fall back to individual query if not available in initialData
busyTimesFromLimits = await getBusyTimesFromLimits(
bookingLimits,
durationLimits,
dateFrom.tz(finalTimezone),
dateTo.tz(finalTimezone),
duration,
eventType,
initialData?.busyTimesFromLimitsBookings ?? [],
finalTimezone,
initialData?.rescheduleUid ?? undefined
);
}
const teamForBookingLimits =
initialData?.teamForBookingLimits ??
eventType?.team ??
(eventType?.parent?.team?.includeManagedEventsInLimits ? eventType?.parent?.team : null);
const teamBookingLimits = parseBookingLimit(teamForBookingLimits?.bookingLimits);
let busyTimesFromTeamLimits: EventBusyDetails[] = [];
if (initialData?.teamBookingLimits && teamForBookingLimits) {
busyTimesFromTeamLimits = initialData.teamBookingLimits.get(user.id) || [];
} else if (teamForBookingLimits && teamBookingLimits) {
// Fall back to individual query if not available in initialData
busyTimesFromTeamLimits = await getBusyTimesFromTeamLimits(
user,
teamBookingLimits,
dateFrom.tz(finalTimezone),
dateTo.tz(finalTimezone),
teamForBookingLimits.id,
teamForBookingLimits.includeManagedEventsInLimits,
finalTimezone,
initialData?.rescheduleUid ?? undefined
);
}
let busyTimes = [];
try {
const busyTimesService = getBusyTimesService();
busyTimes = await busyTimesService.getBusyTimes({
credentials: user.credentials,
startTime: getBusyTimesStart,
endTime: getBusyTimesEnd,
eventTypeId,
userId: user.id,
userEmail: user.email,
username: `${user.username}`,
beforeEventBuffer,
afterEventBuffer,
selectedCalendars,
seatedEvent: !!eventType?.seatsPerTimeSlot,
rescheduleUid: initialData?.rescheduleUid || null,
duration,
currentBookings: initialData?.currentBookings,
bypassBusyCalendarTimes,
silentlyHandleCalendarFailures,
shouldServeCache,
});
} catch (error) {
log.error(`Error fetching busy times for user ${username}:`, error);
return {
busy: [],
timeZone: finalTimezone,
dateRanges: [],
oooExcludedDateRanges: [],
workingHours: [],
dateOverrides: [],
currentSeats: [],
datesOutOfOffice: undefined,
};
}
const detailedBusyTimes: EventBusyDetails[] = [
...busyTimes.map((a) => ({
...a,
start: dayjs(a.start).toISOString(),
end: dayjs(a.end).toISOString(),
title: a.title,
source: query.withSource ? a.source : undefined,
})),
...busyTimesFromLimits,
...busyTimesFromTeamLimits,
];
const isDefaultSchedule = userSchedule && userSchedule.id === schedule?.id;
log.debug(
`EventType: ${eventTypeId} | User: ${username} (ID: ${userId}) - usingSchedule: ${safeStringify({
chosenSchedule: schedule,
eventTypeSchedule: eventType?.schedule,
userSchedule: userSchedule,
hostSchedule: hostSchedule,
})}`
);
if (
!(
schedule?.availability ||
(eventType?.availability.length ? eventType.availability : user.availability)
)
) {
throw new HttpError({ statusCode: 400, message: ErrorCode.AvailabilityNotFoundInSchedule });
}
const availability = (
schedule?.availability || (eventType?.availability.length ? eventType.availability : user.availability)
).map((a) => ({
...a,
userId: user.id,
}));
const workingHours = getWorkingHours({ timeZone: finalTimezone }, availability);
const dateOverrides: TimeRange[] = [];
// NOTE: getSchedule is currently calling this function for every user in a team event
// but not using these values at all, wasting CPU. Adding this check here temporarily to avoid a larger refactor
// since other callers do using this data.
if (returnDateOverrides) {
const calculateDateOverridesSpan = Sentry.startInactiveSpan({ name: "calculateDateOverrides" });
const availabilityWithDates = availability.filter((availability) => !!availability.date);
for (let i = 0; i < availabilityWithDates.length; i++) {
const override = availabilityWithDates[i];
const startTime = dayjs.utc(override.startTime);
const endTime = dayjs.utc(override.endTime);
const overrideStartDate = dayjs.utc(override.date).hour(startTime.hour()).minute(startTime.minute());
const overrideEndDate = dayjs.utc(override.date).hour(endTime.hour()).minute(endTime.minute());
if (
overrideStartDate.isBetween(dateFrom, dateTo, null, "[]") ||
overrideEndDate.isBetween(dateFrom, dateTo, null, "[]")
) {
dateOverrides.push({
start: overrideStartDate.toDate(),
end: overrideEndDate.toDate(),
});
}
}
calculateDateOverridesSpan.end();
}
const outOfOfficeDays =
initialData?.outOfOfficeDays ??
(await this.dependencies.oooRepo.findUserOOODays({
userId: user.id,
dateFrom: dateFrom.toISOString(),
dateTo: dateTo.toISOString(),
}));
const datesOutOfOffice: IOutOfOfficeData = this.calculateOutOfOfficeRanges(outOfOfficeDays, availability);
const holidayBlockedDates = await this.calculateHolidayBlockedDates(
user.id,
dateFrom.toDate(),
dateTo.toDate(),
availability
);
for (const [date, holidayData] of Object.entries(holidayBlockedDates)) {
if (!datesOutOfOffice[date]) {
datesOutOfOffice[date] = holidayData;
}
}
const { dateRanges, oooExcludedDateRanges } = buildDateRanges({
dateFrom,
dateTo,
availability,
timeZone: finalTimezone,
travelSchedules: isDefaultSchedule
? user.travelSchedules.map((schedule) => {
return {
startDate: dayjs(schedule.startDate),
endDate: schedule.endDate ? dayjs(schedule.endDate) : undefined,
timeZone: schedule.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);
const result = {
busy: detailedBusyTimes,
timeZone: finalTimezone,
dateRanges: dateRangesInWhichUserIsAvailable,
oooExcludedDateRanges: dateRangesInWhichUserIsAvailableWithoutOOO,
workingHours,
dateOverrides,
currentSeats,
datesOutOfOffice,
};
log.debug(
`EventType: ${eventTypeId} | User: ${username} (ID: ${userId}) - Result: ${safeStringify(result)}`
);
return result;
}
getUserAvailability = withReporting(this._getUserAvailability.bind(this), "getUserAvailability");
getPeriodStartDatesBetween = withReporting(
(dateFrom: Dayjs, dateTo: Dayjs, period: IntervalLimitUnit, timeZone?: string) =>
getPeriodStartDatesBetweenUtil(dateFrom, dateTo, period, timeZone),
"getPeriodStartDatesBetween"
);
calculateOutOfOfficeRanges(
outOfOfficeDays: GetUserAvailabilityInitialData["outOfOfficeDays"],
availability: GetUserAvailabilityParamsDTO["availability"]
): IOutOfOfficeData {
if (!outOfOfficeDays || outOfOfficeDays.length === 0) {
return {};
}
return outOfOfficeDays.reduce(
(acc: IOutOfOfficeData, { start, end, toUser, user, reason, notes, showNotePublicly }) => {
// here we should use startDate or today if start is before today
// consider timezone in start and end date range
const startDateRange = dayjs(start).utc().isBefore(dayjs().startOf("day").utc())
? dayjs().utc().startOf("day")
: dayjs(start).utc().startOf("day");
// get number of day in the week and see if it's on the availability
const flattenDays = Array.from(
new Set(availability.flatMap((a) => ("days" in a ? a.days : [])))
).sort((a, b) => a - b);
const endDateRange = dayjs(end).utc().endOf("day");
for (let date = startDateRange; date.isBefore(endDateRange); date = date.add(1, "day")) {
const dayNumberOnWeek = date.day();
if (!flattenDays?.includes(dayNumberOnWeek)) {
continue; // Skip to the next iteration if day not found in flattenDays
}
acc[date.format("YYYY-MM-DD")] = {
// @TODO: would be good having start and end availability time here, but for now should be good
// you can obtain that from user availability defined outside of here
fromUser: { id: user.id, displayName: user.name },
// optional chaining destructuring toUser
toUser: toUser ? { id: toUser.id, displayName: toUser.name, username: toUser.username } : null,
reason: reason ? reason.reason : null,
emoji: reason ? reason.emoji : null,
notes: showNotePublicly ? notes || null : null,
showNotePublicly: showNotePublicly ?? false,
};
}
return acc;
},
{}
);
}
async _getUsersAvailability({ users, query, initialData }: GetUsersAvailabilityProps) {
if (users.length >= 50) {
const userIds = users.map(({ id }) => id).join(", ");
log.warn(
`High-load warning: Attempting to fetch availability for ${users.length} users. User IDs: [${userIds}], EventTypeId: [${query.eventTypeId}]`
);
}
return await Promise.all(
users.map((user) =>
this._getUserAvailability(
{
...query,
userId: user.id,
username: user.username || "",
},
initialData
? {
...initialData,
user,
currentBookings: user.currentBookings,
outOfOfficeDays: user.outOfOfficeDays,
}
: undefined
)
)
);
}
getUsersAvailability = withReporting(this._getUsersAvailability.bind(this), "getUsersAvailability");
async calculateHolidayBlockedDates(
userId: number,
startDate: Date,
endDate: Date,
availability: GetUserAvailabilityParamsDTO["availability"]
): Promise<IOutOfOfficeData> {
const holidaySettings = await prisma.userHolidaySettings.findUnique({
where: { userId },
select: {
countryCode: true,
disabledIds: true,
},
});
if (!holidaySettings || !holidaySettings.countryCode) {
return {};
}
// Holidays are stored as midnight UTC (e.g., 2025-12-25T00:00:00Z).
// When checking availability for a specific booking slot (e.g., 10:00-11:00),
// we need to expand the date range to include the full day so holidays are found.
// Otherwise, a booking at 10:00 wouldn't find a holiday stored at 00:00.
const startOfDay = dayjs(startDate).utc().startOf("day").toDate();
const endOfDay = dayjs(endDate).utc().endOf("day").toDate();
const holidayService = getHolidayService();
const holidayDates = await holidayService.getHolidayDatesInRange(
holidaySettings.countryCode,
holidaySettings.disabledIds,
startOfDay,
endOfDay
);
if (holidayDates.length === 0) {
return {};
}
// Match OOO pattern: get working days from availability
const flattenDays = Array.from(new Set(availability.flatMap((a) => ("days" in a ? a.days : [])))).sort(
(a, b) => a - b
);
const result: IOutOfOfficeData = {};
for (const { date, holiday } of holidayDates) {
// Match OOO pattern: use dayjs.utc() to parse date string and get day of week
const dayOfWeek = dayjs.utc(date).day();
if (!flattenDays.includes(dayOfWeek)) {
continue;
}
// Match OOO pattern: key by the date string (already in YYYY-MM-DD UTC format)
result[date] = {
fromUser: null,
toUser: null,
reason: holiday.name,
emoji: "📆",
};
}
return result;
}
}