import dayjs from "@calcom/dayjs"; import { HolidayRepository } from "@calcom/features/holidays/repositories/HolidayRepository"; import { getHolidayServiceCachingProxy, type CachedHoliday, type HolidayServiceCachingProxy, } from "./HolidayServiceCachingProxy"; import { CONFLICT_CHECK_MONTHS, GOOGLE_HOLIDAY_CALENDARS } from "./constants"; import type { Country, Holiday, HolidayWithStatus } from "./types"; export interface ConflictingBooking { id: number; title: string; startTime: Date; endTime: Date; attendeeName: string | null; } export interface HolidayConflict { holidayId: string; holidayName: string; date: string; bookings: ConflictingBooking[]; } export class HolidayService { private cachingProxy: HolidayServiceCachingProxy; private countriesCache: Country[] | null = null; constructor(cachingProxy?: HolidayServiceCachingProxy) { this.cachingProxy = cachingProxy || getHolidayServiceCachingProxy(); } getSupportedCountries(): Country[] { if (this.countriesCache) { return this.countriesCache; } this.countriesCache = Object.entries(GOOGLE_HOLIDAY_CALENDARS).map(([code, config]) => ({ code, name: config.name, })); return this.countriesCache; } isSupportedCountry(countryCode: string): boolean { return countryCode in GOOGLE_HOLIDAY_CALENDARS; } async getUserSettings( userId: number ): Promise<{ countryCode: string | null; holidays: HolidayWithStatus[] }> { const settings = await HolidayRepository.findUserSettingsSelect({ userId, select: { countryCode: true, disabledIds: true }, }); if (!settings?.countryCode) { return { countryCode: null, holidays: [] }; } const holidays = await this.getHolidaysWithStatus(settings.countryCode, settings.disabledIds); return { countryCode: settings.countryCode, holidays }; } async updateSettings( userId: number, countryCode: string | null, resetDisabledHolidays: boolean ): Promise<{ countryCode: string | null; holidays: HolidayWithStatus[] }> { if (countryCode && !this.isSupportedCountry(countryCode)) { throw new Error("Invalid country code"); } const settings = await HolidayRepository.upsertUserSettings({ userId, countryCode, resetDisabledHolidays, }); if (settings.countryCode) { const holidays = await this.getHolidaysWithStatus(settings.countryCode, settings.disabledIds); return { countryCode: settings.countryCode, holidays }; } return { countryCode: null, holidays: [] }; } private cachedHolidayToHoliday(cached: CachedHoliday): Holiday { return { id: cached.eventId, name: cached.name, // Use UTC to ensure consistent date formatting regardless of server timezone // Holiday dates are stored as UTC midnight (e.g., 2025-12-25T00:00:00.000Z) date: dayjs(cached.date).utc().format("YYYY-MM-DD"), year: cached.year, }; } async getHolidaysForCountry(countryCode: string, year?: number): Promise { const targetYear = year || dayjs().year(); const cached = await this.cachingProxy.getHolidaysForCountry(countryCode, targetYear); return cached.map((h) => this.cachedHolidayToHoliday(h)); } async getHolidaysWithStatus(countryCode: string, disabledIds: string[]): Promise { const currentYear = dayjs().year(); const nextYear = currentYear + 1; const [currentYearHolidays, nextYearHolidays] = await Promise.all([ this.cachingProxy.getHolidaysForCountry(countryCode, currentYear), this.cachingProxy.getHolidaysForCountry(countryCode, nextYear), ]); const allHolidays = [...currentYearHolidays, ...nextYearHolidays]; const disabledSet = new Set(disabledIds); const today = dayjs().utc().startOf("day"); return allHolidays .filter((h) => dayjs(h.date).utc().isAfter(today) || dayjs(h.date).utc().isSame(today)) .map((h) => ({ id: h.eventId, name: h.name, // Use UTC for consistent date formatting date: dayjs(h.date).utc().format("YYYY-MM-DD"), year: h.year, enabled: !disabledSet.has(h.eventId), })) .sort((a, b) => a.date.localeCompare(b.date)); } async getHolidayDatesInRange( countryCode: string, disabledIds: string[], startDate: Date, endDate: Date ): Promise> { const disabledSet = new Set(disabledIds); const holidays = await this.cachingProxy.getHolidaysInRange(countryCode, startDate, endDate); return holidays .filter((h) => !disabledSet.has(h.eventId)) .map((h) => ({ // Use UTC for consistent date formatting date: dayjs(h.date).utc().format("YYYY-MM-DD"), holiday: this.cachedHolidayToHoliday(h), })) .sort((a, b) => a.date.localeCompare(b.date)); } async hasHolidaysInRange( countryCode: string, disabledIds: string[], startDate: Date, endDate: Date ): Promise { const holidays = await this.getHolidayDatesInRange(countryCode, disabledIds, startDate, endDate); return holidays.length > 0; } async toggleHoliday( userId: number, holidayId: string, enabled: boolean ): Promise<{ countryCode: string; holidays: HolidayWithStatus[] }> { const settings = await HolidayRepository.findUserSettingsSelect({ userId, select: { countryCode: true, disabledIds: true }, }); if (!settings?.countryCode) { throw new Error("No holiday country selected"); } // Validate against both current and next year holidays (matching getHolidaysWithStatus) const currentYear = dayjs().year(); const nextYear = currentYear + 1; const [currentYearHolidays, nextYearHolidays] = await Promise.all([ this.getHolidaysForCountry(settings.countryCode, currentYear), this.getHolidaysForCountry(settings.countryCode, nextYear), ]); const allHolidays = [...currentYearHolidays, ...nextYearHolidays]; if (!allHolidays.some((h) => h.id === holidayId)) { throw new Error("Holiday not found for this country"); } let disabledIds = [...settings.disabledIds]; if (enabled) { disabledIds = disabledIds.filter((id) => id !== holidayId); } else if (!disabledIds.includes(holidayId)) { disabledIds.push(holidayId); } await HolidayRepository.updateDisabledIds({ userId, disabledIds }); const updatedHolidays = await this.getHolidaysWithStatus(settings.countryCode, disabledIds); return { countryCode: settings.countryCode, holidays: updatedHolidays }; } async checkConflicts( userId: number, countryCode: string, disabledIds: string[] ): Promise<{ conflicts: HolidayConflict[] }> { if (!countryCode) { return { conflicts: [] }; } const startDate = new Date(); const endDate = dayjs().add(CONFLICT_CHECK_MONTHS, "months").toDate(); const holidayDates = await this.getHolidayDatesInRange(countryCode, disabledIds, startDate, endDate); if (holidayDates.length === 0) { return { conflicts: [] }; } const dateRanges = holidayDates.map((h) => ({ start: dayjs.utc(h.date).startOf("day").toDate(), end: dayjs.utc(h.date).endOf("day").toDate(), })); const bookings = await HolidayRepository.findBookingsInDateRanges({ userId, dateRanges }); if (bookings.length === 0) { return { conflicts: [] }; } const bookingsWithTimestamps = bookings.map((b) => ({ ...b, startTimestamp: b.startTime.getTime(), endTimestamp: b.endTime.getTime(), })); const conflicts: HolidayConflict[] = []; for (const holidayDate of holidayDates) { const holidayStart = dayjs.utc(holidayDate.date).startOf("day").valueOf(); const holidayEnd = dayjs.utc(holidayDate.date).endOf("day").valueOf(); const conflictingBookings = bookingsWithTimestamps.filter( (booking) => booking.startTimestamp < holidayEnd && booking.endTimestamp > holidayStart ); if (conflictingBookings.length > 0) { conflicts.push({ holidayId: holidayDate.holiday.id, holidayName: holidayDate.holiday.name, date: holidayDate.date, bookings: conflictingBookings.map((b) => ({ id: b.id, title: b.title, startTime: b.startTime, endTime: b.endTime, attendeeName: b.attendees[0]?.name || null, })), }); } } return { conflicts }; } } let defaultService: HolidayService | null = null; export function getHolidayService(): HolidayService { if (!defaultService) { defaultService = new HolidayService(); } return defaultService; }