Files
calendar/packages/lib/holidays/HolidayService.ts
T
Benny JooGitHubDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
96bec9f7b9 refactor: Move repositories from @calcom/lib to @calcom/features domain folders (#27570)
* refactor: move repositories from lib to features domain folders

- Move HolidayRepository to features/holidays/repositories
- Move PrismaTrackingRepository to features/bookings/repositories
- Move PrismaBookingPaymentRepository to features/bookings/repositories
- Move PrismaRoutingFormResponseRepository to features/routing-forms/repositories
- Move PrismaAssignmentReasonRepository to features/assignment-reason/repositories
- Move VerificationTokenRepository to features/auth/repositories
- Move WorkspacePlatformRepository to features/workspace-platform/repositories
- Move DTO files to their respective feature domains
- Merge lib DestinationCalendarRepository into features version
- Merge lib SelectedCalendarRepository into features version
- Update all import paths across the codebase

This follows the vertical slice architecture pattern by organizing
repositories by domain rather than by technical layer.

Co-Authored-By: benny@cal.com <sldisek783@gmail.com>

* fix: update VerificationTokenService import path to new location

Co-Authored-By: benny@cal.com <sldisek783@gmail.com>

* fix: update test file imports to use new repository locations

Co-Authored-By: benny@cal.com <sldisek783@gmail.com>

* mv

* fix structure

* fix

* refactor: merge unit tests for SelectedCalendarRepository into single file

Co-Authored-By: benny@cal.com <sldisek783@gmail.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-02-09 08:41:04 -03:00

275 lines
8.6 KiB
TypeScript

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<Holiday[]> {
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<HolidayWithStatus[]> {
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<Array<{ date: string; holiday: Holiday }>> {
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<boolean> {
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;
}