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
This commit is contained in:
Dhairyashil Shinde
2025-12-15 16:39:10 +00:00
committed by GitHub
parent 18415e35a7
commit 12e07f20d4
41 changed files with 2559 additions and 35 deletions
+8
View File
@@ -127,6 +127,14 @@ NEXT_PUBLIC_HELPSCOUT_KEY=
NEXT_PUBLIC_FRESHCHAT_TOKEN=
NEXT_PUBLIC_FRESHCHAT_HOST=
# For holiday feature:
# Step-by-step: Get a Google Calendar API Key
# 1. Go to Google Cloud Console: https://console.cloud.google.com/
# 2. Select or Create a Project
# 3. Enable Google Calendar API (APIs & Services → Library , Search for Google Calendar API)
# 4. Create the API Key (APIs & Services → Credentials)
GOOGLE_CALENDAR_API_KEY=
# Google OAuth credentials
# To enable Login with Google you need to:
# 1. Set `GOOGLE_API_CREDENTIALS` below
@@ -4,9 +4,11 @@ import { useEffect, useState } from "react";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import OutOfOfficeEntriesList from "@calcom/features/settings/outOfOffice/OutOfOfficeEntriesList";
import { CreateOrEditOutOfOfficeEntryModal } from "@calcom/features/settings/outOfOffice/CreateOrEditOutOfOfficeModal";
import type { BookingRedirectForm } from "@calcom/features/settings/outOfOffice/CreateOrEditOutOfOfficeModal";
import { HolidaysView } from "@calcom/features/settings/outOfOffice/HolidaysView";
import OutOfOfficeEntriesList from "@calcom/features/settings/outOfOffice/OutOfOfficeEntriesList";
import { OutOfOfficeTab } from "@calcom/features/settings/outOfOffice/OutOfOfficeToggleGroup";
export default function OutOfOfficeView() {
const [openModal, setOpenModal] = useState(false);
@@ -15,6 +17,7 @@ export default function OutOfOfficeView() {
const params = useCompatSearchParams();
const openModalOnStart = !!params?.get("om");
const selectedTab = params?.get("type") ?? OutOfOfficeTab.MINE;
useEffect(() => {
if (openModalOnStart) {
@@ -37,6 +40,11 @@ export default function OutOfOfficeView() {
setCurrentlyEditingOutOfOfficeEntry(null);
};
// Show HolidaysView when holidays tab is selected
if (selectedTab === OutOfOfficeTab.HOLIDAYS) {
return <HolidaysView />;
}
return (
<>
<OutOfOfficeEntriesList
@@ -0,0 +1,4 @@
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
import { holidaysRouter } from "@calcom/trpc/server/routers/viewer/holidays/_router";
export default createNextApiHandler(holidaysRouter);
@@ -4110,6 +4110,26 @@
"booking_response": "Booking Response",
"list_view": "List view",
"calendar_view": "Calendar view",
"holidays": "Holidays",
"holidays_description": "We will automatically mark you as unavailable for the selected holidays",
"country_for_holidays": "Country for holidays",
"select_country": "Select country",
"no_holidays": "No holidays",
"holidays_list": "Holidays",
"no_holidays_selected": "No holidays selected",
"select_country_to_see_holidays": "Select a country to see available holidays",
"holiday_settings_updated": "Holiday settings updated",
"holiday_no_availability": "No availability on this holiday",
"holiday_booking_conflict_warning_one": "You have {{count}} booking on holidays",
"holiday_booking_conflict_warning_other": "You have {{count}} bookings on holidays",
"holiday_overlap_info": "Holiday notice",
"holiday_overlap_message_single": "{{date}} is {{holiday}}, which is already blocked as a holiday",
"holiday_overlap_message_multiple": "{{count}} days in this range are holidays ({{holidays}})",
"no_holidays_found_for_country": "No holidays found for this country.",
"booking_count_one": "{{count}} booking",
"booking_count_other": "{{count}} bookings",
"and_more_holidays_with_conflicts_one": "... and {{count}} more holiday with conflicts",
"and_more_holidays_with_conflicts_other": "... and {{count}} more holidays with conflicts",
"assignment_reason": "Assignment Reason",
"saved": "Saved",
"booking_history": "Booking History",
@@ -0,0 +1,337 @@
import prismaMock from "../../../../tests/libs/__mocks__/prismaMock";
import { beforeEach, describe, expect, it, vi } from "vitest";
import dayjs from "@calcom/dayjs";
vi.mock("@calcom/lib/holidays", () => ({
getHolidayService: vi.fn(() => ({
getHolidayDatesInRange: vi.fn(),
})),
}));
import { getHolidayService } from "@calcom/lib/holidays";
import { UserAvailabilityService } from "./getUserAvailability";
const mockDependencies = {
oooRepo: {} as never,
bookingRepo: {} as never,
redisClient: {} as never,
};
describe("UserAvailabilityService.calculateHolidayBlockedDates", () => {
let service: UserAvailabilityService;
let mockHolidayService: { getHolidayDatesInRange: ReturnType<typeof vi.fn> };
beforeEach(() => {
vi.clearAllMocks();
service = new UserAvailabilityService(mockDependencies);
mockHolidayService = {
getHolidayDatesInRange: vi.fn(),
};
vi.mocked(getHolidayService).mockReturnValue(mockHolidayService as never);
});
it("should return empty object when user has no holiday settings", async () => {
prismaMock.userHolidaySettings.findUnique.mockResolvedValue(null);
const result = await service.calculateHolidayBlockedDates(
123,
new Date("2025-01-01"),
new Date("2025-01-31"),
[{ days: [1, 2, 3, 4, 5] }] // Monday to Friday
);
expect(prismaMock.userHolidaySettings.findUnique).toHaveBeenCalledWith({
where: { userId: 123 },
select: {
countryCode: true,
disabledIds: true,
},
});
expect(result).toEqual({});
});
it("should return empty object when user has no country selected", async () => {
prismaMock.userHolidaySettings.findUnique.mockResolvedValue({
id: 1,
userId: 123,
countryCode: null,
disabledIds: [],
createdAt: new Date(),
updatedAt: new Date(),
});
const result = await service.calculateHolidayBlockedDates(
123,
new Date("2025-01-01"),
new Date("2025-01-31"),
[{ days: [1, 2, 3, 4, 5] }]
);
expect(result).toEqual({});
});
it("should return empty object when no holidays in date range", async () => {
prismaMock.userHolidaySettings.findUnique.mockResolvedValue({
id: 1,
userId: 123,
countryCode: "US",
disabledIds: [],
createdAt: new Date(),
updatedAt: new Date(),
});
mockHolidayService.getHolidayDatesInRange.mockResolvedValue([]);
const result = await service.calculateHolidayBlockedDates(
123,
new Date("2025-02-01"),
new Date("2025-02-28"),
[{ days: [1, 2, 3, 4, 5] }]
);
// Dates are expanded to full day range (startOfDay to endOfDay in UTC)
// to ensure holidays stored at midnight UTC are found
expect(mockHolidayService.getHolidayDatesInRange).toHaveBeenCalledWith(
"US",
[],
expect.any(Date),
expect.any(Date)
);
// Verify the date range covers the full days
const [, , startDate, endDate] = mockHolidayService.getHolidayDatesInRange.mock.calls[0];
expect(startDate.toISOString()).toBe("2025-02-01T00:00:00.000Z");
expect(endDate.toISOString()).toBe("2025-02-28T23:59:59.999Z");
expect(result).toEqual({});
});
it("should return holiday data for dates that match user's working days", async () => {
prismaMock.userHolidaySettings.findUnique.mockResolvedValue({
id: 1,
userId: 123,
countryCode: "US",
disabledIds: [],
createdAt: new Date(),
updatedAt: new Date(),
});
// Wednesday, January 1, 2025 - New Year's Day
mockHolidayService.getHolidayDatesInRange.mockResolvedValue([
{
date: "2025-01-01",
holiday: {
id: "new_years_day_2025",
name: "New Year's Day",
date: "2025-01-01",
year: 2025,
},
},
]);
const result = await service.calculateHolidayBlockedDates(
123,
new Date("2025-01-01"),
new Date("2025-01-31"),
[{ days: [1, 2, 3, 4, 5] }] // Monday(1) to Friday(5) - includes Wednesday(3)
);
expect(result).toEqual({
"2025-01-01": {
fromUser: null,
toUser: null,
reason: "New Year's Day",
emoji: "📆",
},
});
});
it("should skip holidays that fall on non-working days", async () => {
prismaMock.userHolidaySettings.findUnique.mockResolvedValue({
id: 1,
userId: 123,
countryCode: "US",
disabledIds: [],
createdAt: new Date(),
updatedAt: new Date(),
});
// Saturday, December 25, 2027 - Christmas Day
const christmasDate = "2027-12-25";
const christmasDayOfWeek = dayjs(christmasDate).day(); // Should be 6 (Saturday)
mockHolidayService.getHolidayDatesInRange.mockResolvedValue([
{
date: christmasDate,
holiday: {
id: "christmas_2027",
name: "Christmas Day",
date: christmasDate,
year: 2027,
},
},
]);
// User only works Monday to Friday (days 1-5), not Saturday (6) or Sunday (0)
const result = await service.calculateHolidayBlockedDates(
123,
new Date("2027-12-01"),
new Date("2027-12-31"),
[{ days: [1, 2, 3, 4, 5] }]
);
// Christmas 2027 is on Saturday (day 6), should be skipped
expect(christmasDayOfWeek).toBe(6);
expect(result).toEqual({});
});
it("should handle multiple holidays correctly", async () => {
prismaMock.userHolidaySettings.findUnique.mockResolvedValue({
id: 1,
userId: 123,
countryCode: "US",
disabledIds: [],
createdAt: new Date(),
updatedAt: new Date(),
});
mockHolidayService.getHolidayDatesInRange.mockResolvedValue([
{
date: "2025-01-01", // Wednesday
holiday: {
id: "new_years_day_2025",
name: "New Year's Day",
date: "2025-01-01",
year: 2025,
},
},
{
date: "2025-01-20", // Monday - MLK Day
holiday: {
id: "mlk_day_2025",
name: "Martin Luther King Jr. Day",
date: "2025-01-20",
year: 2025,
},
},
]);
const result = await service.calculateHolidayBlockedDates(
123,
new Date("2025-01-01"),
new Date("2025-01-31"),
[{ days: [1, 2, 3, 4, 5] }]
);
expect(result).toEqual({
"2025-01-01": {
fromUser: null,
toUser: null,
reason: "New Year's Day",
emoji: "📆",
},
"2025-01-20": {
fromUser: null,
toUser: null,
reason: "Martin Luther King Jr. Day",
emoji: "📆",
},
});
});
it("should handle availability with multiple schedules", async () => {
prismaMock.userHolidaySettings.findUnique.mockResolvedValue({
id: 1,
userId: 123,
countryCode: "US",
disabledIds: [],
createdAt: new Date(),
updatedAt: new Date(),
});
// Thursday, July 4, 2024 - Independence Day
mockHolidayService.getHolidayDatesInRange.mockResolvedValue([
{
date: "2024-07-04",
holiday: {
id: "independence_day_2024",
name: "Independence Day",
date: "2024-07-04",
year: 2024,
},
},
]);
// User has multiple availability schedules, some include Thursday (4)
const result = await service.calculateHolidayBlockedDates(
123,
new Date("2024-07-01"),
new Date("2024-07-31"),
[
{ days: [1, 3] }, // Monday, Wednesday only
{ days: [2, 4] }, // Tuesday, Thursday - includes the holiday
]
);
expect(result).toEqual({
"2024-07-04": {
fromUser: null,
toUser: null,
reason: "Independence Day",
emoji: "📆",
},
});
});
it("should respect disabled holidays", async () => {
prismaMock.userHolidaySettings.findUnique.mockResolvedValue({
id: 1,
userId: 123,
countryCode: "US",
disabledIds: ["christmas_2025"],
createdAt: new Date(),
updatedAt: new Date(),
});
// The holiday service should receive the disabledIds and filter them out
mockHolidayService.getHolidayDatesInRange.mockResolvedValue([
{
date: "2025-01-01",
holiday: {
id: "new_years_day_2025",
name: "New Year's Day",
date: "2025-01-01",
year: 2025,
},
},
// Christmas is NOT returned because it's in disabledIds
]);
const result = await service.calculateHolidayBlockedDates(
123,
new Date("2025-01-01"),
new Date("2025-12-31"),
[{ days: [1, 2, 3, 4, 5] }]
);
// Verify disabledIds were passed to the service
expect(mockHolidayService.getHolidayDatesInRange).toHaveBeenCalledWith(
"US",
["christmas_2025"],
expect.any(Date),
expect.any(Date)
);
// Only New Year's Day should be blocked
expect(result).toEqual({
"2025-01-01": {
fromUser: null,
toUser: null,
reason: "New Year's Day",
emoji: "📆",
},
});
});
});
@@ -18,6 +18,7 @@ import { buildDateRanges, subtract } from "@calcom/features/schedules/lib/date-r
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";
@@ -25,6 +26,7 @@ import { getPeriodStartDatesBetween as getPeriodStartDatesBetweenUtil } from "@c
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,
@@ -558,6 +560,19 @@ export class UserAvailabilityService {
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,
@@ -687,4 +702,68 @@ export class UserAvailabilityService {
}
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;
}
}
@@ -25,6 +25,7 @@ export const OutOfOfficeInSlots = (props: IOutOfOfficeInSlotsProps) => {
fromUser,
toUser,
emoji = "🏝️",
reason,
borderDashed = true,
date,
className,
@@ -35,7 +36,12 @@ export const OutOfOfficeInSlots = (props: IOutOfOfficeInSlotsProps) => {
const router = useRouter();
if (!fromUser) return null;
// Check if this is a holiday (no fromUser but has reason)
const isHoliday = !fromUser && reason;
// For regular OOO, require fromUser (toUser is optional for redirect)
// For holidays, we just need the reason
if (!isHoliday && !fromUser) return null;
return (
<div className={classNames("relative h-full pb-5", className)}>
<div
@@ -47,28 +53,37 @@ export const OutOfOfficeInSlots = (props: IOutOfOfficeInSlotsProps) => {
<span className="m-auto text-center text-lg">{emoji}</span>
</div>
<div className="stack-y-2 max-h-[300px] w-full overflow-y-auto text-center">
<p className="mt-2 text-base font-bold">
{t("ooo_user_is_ooo", { displayName: fromUser.displayName })}
</p>
{isHoliday ? (
<>
<p className="mt-2 text-base font-bold">{reason}</p>
<p className="text-subtle text-center text-sm">{t("holiday_no_availability")}</p>
</>
) : (
<>
<p className="mt-2 text-base font-bold">
{t("ooo_user_is_ooo", { displayName: fromUser?.displayName })}
</p>
{notes && showNotePublicly && (
<p className="text-subtle mt-2 max-h-[120px] overflow-y-auto break-words px-2 text-center text-sm italic">
{notes}
</p>
)}
{notes && showNotePublicly && (
<p className="text-subtle mt-2 max-h-[120px] overflow-y-auto break-words px-2 text-center text-sm italic">
{notes}
</p>
)}
{fromUser?.displayName && toUser?.displayName && (
<p className="text-center text-sm">
<ServerTrans
t={t}
i18nKey="ooo_slots_returning"
values={{ displayName: toUser.displayName }}
components={[<strong key="username">username</strong>]}
/>
</p>
{fromUser?.displayName && toUser?.displayName && (
<p className="text-center text-sm">
<ServerTrans
t={t}
i18nKey="ooo_slots_returning"
values={{ displayName: toUser.displayName }}
components={[<strong key="username">username</strong>]}
/>
</p>
)}
</>
)}
</div>
{toUser?.id && (
{!isHoliday && toUser?.id && (
<Button
className="mt-8 max-w-[90%]"
variant="button"
@@ -474,7 +474,7 @@ function MembersThatWillBeAffectedOnDisablingDelegationCredential({
<div>{t("loading")}</div>
) : affectedMembers?.length ? (
<>
<ul className="list-disc stack-y-1 p-1 pl-5 sm:w-80">
<ul className="stack-y-1 list-disc p-1 pl-5 sm:w-80">
{affectedMembers.slice(0, 5).map((m) => (
<li className="text-muted text-sm" key={m.email}>
{m.name ? `${m.name} (${m.email})` : m.email}
@@ -219,8 +219,8 @@ export function EditMemberSheet({
</Skeleton>
</div>
</SheetHeader>
<SheetBody className="flex flex-col stack-y-4 p-4">
<div className="mb-4 flex flex-col stack-y-4">
<SheetBody className="stack-y-4 flex flex-col p-4">
<div className="stack-y-4 mb-4 flex flex-col">
<h3 className="text-emphasis mb-1 text-base font-semibold">{t("profile")}</h3>
<DisplayInfo label="Cal" value={bookingLink} icon="external-link" />
<DisplayInfo label={t("email")} value={selectedUser.email} icon="at-sign" />
@@ -4,21 +4,21 @@ import React, { useState } from "react";
import { useFormContext, Controller } from "react-hook-form";
import { z } from "zod";
import PhoneInput from "@calcom/features/components/phone-input";
import { getTemplateFieldsSchema } from "@calcom/features/calAIPhone/getTemplateFieldsSchema";
import { templateFieldsMap } from "@calcom/features/calAIPhone/template-fields-map";
import type { TemplateType } from "@calcom/features/calAIPhone/zod-utils";
import PhoneInput from "@calcom/features/components/phone-input";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
import type { EventTypeSetup, FormValues } from "@calcom/features/eventtypes/lib/types";
import { ComponentForField } from "@calcom/features/form-builder/FormBuilderField";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Label } from "@calcom/ui/components/form";
import { Divider } from "@calcom/ui/components/divider";
import { TextField } from "@calcom/ui/components/form";
import { Button } from "@calcom/ui/components/button";
import classNames from "@calcom/ui/classNames";
import { Button } from "@calcom/ui/components/button";
import { Divider } from "@calcom/ui/components/divider";
import { EmptyScreen } from "@calcom/ui/components/empty-screen";
import { Label } from "@calcom/ui/components/form";
import { TextField } from "@calcom/ui/components/form";
import { SettingsToggle } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
import { showToast } from "@calcom/ui/components/toast";
@@ -96,7 +96,7 @@ const ChildrenEventTypesList = ({
} & Omit<Partial<ComponentProps<typeof ChildrenEventTypeSelect>>, "onChange" | "value">) => {
const { t } = useLocale();
return (
<div className={classNames("flex flex-col stack-y-5", customClassNames?.assignToSelect?.container)}>
<div className={classNames("stack-y-5 flex flex-col", customClassNames?.assignToSelect?.container)}>
<div>
<Label className={customClassNames?.assignToSelect?.label}>{t("assign_to")}</Label>
<ChildrenEventTypeSelect
@@ -213,7 +213,7 @@ const FixedHosts = ({
</Label>
<p
className={classNames(
"text-subtle max-w-full wrap-break-word text-sm leading-tight",
"text-subtle wrap-break-word max-w-full text-sm leading-tight",
customClassNames?.description
)}>
<FixedHostHelper t={t} />
@@ -475,7 +475,7 @@ const RoundRobinHosts = ({
</Label>
<p
className={classNames(
"text-subtle max-w-full wrap-break-word text-sm leading-tight",
"text-subtle wrap-break-word max-w-full text-sm leading-tight",
customClassNames?.description
)}>
<LearnMoreLink
@@ -578,7 +578,7 @@ const ChildrenEventTypes = ({
return (
<div
className={classNames(
"border-subtle mt-6 stack-y-5 rounded-lg border px-4 py-6 sm:px-6",
"border-subtle stack-y-5 mt-6 rounded-lg border px-4 py-6 sm:px-6",
customClassNames?.container
)}>
<div className="flex flex-col gap-4">
@@ -830,7 +830,7 @@ export const EventTeamAssignmentTab = ({
</Label>
<p
className={classNames(
"text-subtle max-w-full wrap-break-word text-sm leading-tight",
"text-subtle wrap-break-word max-w-full text-sm leading-tight",
customClassNames?.assignmentType?.description
)}>
{t("assignment_description")}
@@ -865,7 +865,7 @@ export const EventTeamAssignmentTab = ({
<div className="border-subtle mt-4 flex flex-col rounded-md">
<div className="border-subtle rounded-t-md border p-6 pb-5">
<Label className="mb-1 text-sm font-semibold">{t("rr_distribution_method")}</Label>
<p className="text-subtle max-w-full wrap-break-word text-sm leading-tight">
<p className="text-subtle wrap-break-word max-w-full text-sm leading-tight">
{t("rr_distribution_method_description")}
</p>
</div>
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useMemo, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import dayjs from "@calcom/dayjs";
@@ -11,6 +11,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
import classNames from "@calcom/ui/classNames";
import { Alert } from "@calcom/ui/components/alert";
import { UpgradeTeamsBadge } from "@calcom/ui/components/badge";
import { Button } from "@calcom/ui/components/button";
import { DialogContent, DialogFooter, DialogHeader } from "@calcom/ui/components/dialog";
@@ -162,9 +163,28 @@ export const CreateOrEditOutOfOfficeEntryModal = ({
const watchedTeamUserId = watch("toTeamUserId");
const watchForUserId = watch("forUserId");
const watchedDateRange = watch("dateRange");
const watchedNotes = watch("notes");
const hasValidNotes = Boolean(watchedNotes?.trim());
// Fetch user's holiday settings to show warning if OOO dates overlap with holidays
const { data: holidaySettings } = trpc.viewer.holidays.getUserSettings.useQuery({});
// Check if selected dates overlap with any enabled holidays
const overlappingHolidays = useMemo(() => {
if (!holidaySettings?.countryCode || !watchedDateRange?.startDate || !watchedDateRange?.endDate) {
return [];
}
// Filter holidays that are enabled and fall within the date range
const startStr = dayjs(watchedDateRange.startDate).format("YYYY-MM-DD");
const endStr = dayjs(watchedDateRange.endDate).format("YYYY-MM-DD");
return (holidaySettings.holidays || [])
.filter((h) => h.enabled && h.date >= startStr && h.date <= endStr)
.map((h) => ({ date: h.date, holiday: { id: h.id, name: h.name } }));
}, [holidaySettings, watchedDateRange]);
const createOrEditOutOfOfficeEntry = trpc.viewer.ooo.outOfOfficeCreateOrUpdate.useMutation({
onSuccess: () => {
showToast(
@@ -310,6 +330,29 @@ export const CreateOrEditOutOfOfficeEntryModal = ({
)}
/>
</div>
{/* Holiday overlap warning */}
{overlappingHolidays.length > 0 && (
<Alert
className="mt-2"
severity="info"
title={t("holiday_overlap_info")}
message={
overlappingHolidays.length === 1
? t("holiday_overlap_message_single", {
holiday: overlappingHolidays[0].holiday.name,
date: dayjs(overlappingHolidays[0].date).format("D MMM"),
})
: t("holiday_overlap_message_multiple", {
count: overlappingHolidays.length,
holidays: overlappingHolidays
.slice(0, 3)
.map((h) => h.holiday.name)
.join(", "),
})
}
/>
)}
</div>
{/* Reason Select */}
@@ -0,0 +1,312 @@
"use client";
import { memo, useMemo, useCallback } from "react";
import dayjs from "@calcom/dayjs";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import { Alert } from "@calcom/ui/components/alert";
import { Button } from "@calcom/ui/components/button";
import { EmptyScreen } from "@calcom/ui/components/empty-screen";
import { Label, Select } from "@calcom/ui/components/form";
import { Switch } from "@calcom/ui/components/form";
import { SkeletonContainer, SkeletonText } from "@calcom/ui/components/skeleton";
import { showToast } from "@calcom/ui/components/toast";
import { OutOfOfficeToggleGroup } from "./OutOfOfficeToggleGroup";
function HolidaysCTA() {
const { t } = useLocale();
return (
<div className="flex gap-2">
<OutOfOfficeToggleGroup />
<Button color="primary" StartIcon="plus" className="invisible" aria-hidden="true" tabIndex={-1}>
{t("add")}
</Button>
</div>
);
}
type HolidayWithStatus = RouterOutputs["viewer"]["holidays"]["getUserSettings"]["holidays"][number];
type Country = { code: string; name: string };
const CountrySelector = memo(function CountrySelector({
countries,
value,
onChange,
isLoading,
}: {
countries: Country[];
value: string;
onChange: (value: string) => void;
isLoading: boolean;
}) {
const { t } = useLocale();
const options = useMemo(
() => [
{ value: "", label: t("no_holidays") },
...countries.map((country) => ({
value: country.code,
label: country.name,
})),
],
[countries, t]
);
const selectedOption = useMemo(() => {
if (!value) return { value: "", label: t("select_country") };
const country = countries.find((c) => c.code === value);
return { value, label: country?.name || value };
}, [value, countries, t]);
if (isLoading) {
return <SkeletonText className="mt-1 h-10 w-full max-w-xs" />;
}
return (
<Select
className="mt-1 w-full max-w-xs"
value={selectedOption}
onChange={(option) => onChange(option?.value || "")}
options={options}
/>
);
});
const HolidayListItem = memo(function HolidayListItem({
holiday,
onToggle,
isToggling,
}: {
holiday: HolidayWithStatus;
onToggle: (holidayId: string, enabled: boolean) => void;
isToggling: boolean;
}) {
const formattedDate = holiday.date ? dayjs(holiday.date).format("D MMM YYYY") : null;
return (
<div className="border-subtle flex items-center justify-between border-b px-4 py-4 last:border-b-0">
<div className="flex items-center gap-3">
<div className="text-2xl">📆</div>
<div>
<p className="text-emphasis font-medium">{holiday.name}</p>
{formattedDate && <p className="text-subtle text-sm">{formattedDate}</p>}
</div>
</div>
<Switch
checked={holiday.enabled}
onCheckedChange={(checked) => onToggle(holiday.id, checked)}
disabled={isToggling}
/>
</div>
);
});
function HolidaysList({
holidays,
onToggle,
togglingId,
}: {
holidays: HolidayWithStatus[];
onToggle: (holidayId: string, enabled: boolean) => void;
togglingId: string | null;
}) {
const { t } = useLocale();
if (holidays.length === 0) {
return <div className="text-subtle py-8 text-center">{t("no_holidays_found_for_country")}</div>;
}
return (
<div className="border-subtle divide-subtle divide-y rounded-md border">
{holidays.map((holiday) => (
<HolidayListItem
key={holiday.id}
holiday={holiday}
onToggle={onToggle}
isToggling={togglingId === holiday.id}
/>
))}
</div>
);
}
function ConflictWarning({
conflicts,
}: {
conflicts: RouterOutputs["viewer"]["holidays"]["checkConflicts"]["conflicts"];
}) {
const { t } = useLocale();
if (conflicts.length === 0) return null;
const totalBookings = conflicts.reduce((sum, c) => sum + c.bookings.length, 0);
const conflictMessage = (
<ul className="mt-1 space-y-1">
{conflicts.slice(0, 3).map((conflict) => (
<li key={conflict.holidayId}>
{conflict.holidayName} ({dayjs(conflict.date).format("D MMM")}) -{" "}
{t("booking_count", { count: conflict.bookings.length })}
</li>
))}
{conflicts.length > 3 && (
<li>{t("and_more_holidays_with_conflicts", { count: conflicts.length - 3 })}</li>
)}
</ul>
);
return (
<Alert
severity="warning"
title={t("holiday_booking_conflict_warning", { count: totalBookings })}
message={conflictMessage}
className="mb-4"
/>
);
}
export function HolidaysView() {
const { t } = useLocale();
const utils = trpc.useUtils();
const {
data: countries,
isLoading: isLoadingCountries,
error: countriesError,
} = trpc.viewer.holidays.getSupportedCountries.useQuery();
const {
data: settings,
isLoading: isLoadingSettings,
error: settingsError,
} = trpc.viewer.holidays.getUserSettings.useQuery({});
const disabledIds = useMemo(
() => settings?.holidays?.filter((h) => !h.enabled).map((h) => h.id) || [],
[settings?.holidays]
);
const { data: conflictsData } = trpc.viewer.holidays.checkConflicts.useQuery(
{
countryCode: settings?.countryCode || "",
disabledIds,
},
{
enabled: !!settings?.countryCode && !isLoadingSettings,
}
);
const updateSettingsMutation = trpc.viewer.holidays.updateSettings.useMutation({
onSuccess: () => {
utils.viewer.holidays.getUserSettings.invalidate();
utils.viewer.holidays.checkConflicts.invalidate();
showToast(t("holiday_settings_updated"), "success");
},
onError: () => {
showToast(t("error_updating_settings"), "error");
},
});
const toggleHolidayMutation = trpc.viewer.holidays.toggleHoliday.useMutation({
onSuccess: () => {
utils.viewer.holidays.getUserSettings.invalidate();
utils.viewer.holidays.checkConflicts.invalidate();
},
onError: () => {
showToast(t("error_updating_settings"), "error");
},
});
const handleCountryChange = useCallback(
(countryCode: string) => {
updateSettingsMutation.mutate({
countryCode: countryCode || null,
resetDisabledHolidays: true,
});
},
[updateSettingsMutation]
);
const handleToggleHoliday = useCallback(
(holidayId: string, enabled: boolean) => {
toggleHolidayMutation.mutate({ holidayId, enabled });
},
[toggleHolidayMutation]
);
const isLoading = isLoadingCountries || isLoadingSettings;
const hasError = countriesError || settingsError;
if (isLoading) {
return (
<SettingsHeader title={t("holidays")} description={t("holidays_description")} CTA={<HolidaysCTA />}>
<SkeletonContainer>
<div className="space-y-4">
<SkeletonText className="h-10 w-64" />
<SkeletonText className="h-64 w-full" />
</div>
</SkeletonContainer>
</SettingsHeader>
);
}
if (hasError) {
return (
<SettingsHeader title={t("holidays")} description={t("holidays_description")} CTA={<HolidaysCTA />}>
<Alert
severity="error"
title={t("something_went_wrong")}
message={countriesError?.message || settingsError?.message}
/>
</SettingsHeader>
);
}
return (
<SettingsHeader title={t("holidays")} description={t("holidays_description")} CTA={<HolidaysCTA />}>
<div className="space-y-6">
<div className="border-subtle bg-default rounded-md border p-6">
<Label>{t("country_for_holidays")}</Label>
<CountrySelector
countries={countries || []}
value={settings?.countryCode || ""}
onChange={handleCountryChange}
isLoading={isLoadingCountries}
/>
</div>
{conflictsData?.conflicts && conflictsData.conflicts.length > 0 && (
<ConflictWarning conflicts={conflictsData.conflicts} />
)}
{settings?.countryCode ? (
<div>
<div className="mb-4 flex items-center justify-between">
<h3 className="text-emphasis font-medium">
{t("holidays_list")} ({settings.holidays?.filter((h) => h.enabled).length || 0} {t("enabled")}
)
</h3>
</div>
<HolidaysList
holidays={settings.holidays || []}
onToggle={handleToggleHoliday}
togglingId={
toggleHolidayMutation.isPending ? toggleHolidayMutation.variables?.holidayId || null : null
}
/>
</div>
) : (
<EmptyScreen
Icon="calendar"
headline={t("no_holidays_selected")}
description={t("select_country_to_see_holidays")}
/>
)}
</div>
</SettingsHeader>
);
}
@@ -10,6 +10,7 @@ import { ToggleGroup } from "@calcom/ui/components/form";
export enum OutOfOfficeTab {
MINE = "mine",
TEAM = "team",
HOLIDAYS = "holidays",
}
export const OutOfOfficeToggleGroup = () => {
@@ -33,6 +34,7 @@ export const OutOfOfficeToggleGroup = () => {
const toggleGroupOptions = [
{ value: OutOfOfficeTab.MINE, label: t("my_ooo") },
{ value: OutOfOfficeTab.TEAM, label: t("team_ooo") },
{ value: OutOfOfficeTab.HOLIDAYS, label: t("holidays") },
];
return (
@@ -0,0 +1,96 @@
import dayjs from "@calcom/dayjs";
import { GOOGLE_HOLIDAY_CALENDARS } from "./constants";
interface GoogleCalendarEvent {
id: string;
summary: string;
description?: string;
start: {
date?: string;
dateTime?: string;
};
end: {
date?: string;
dateTime?: string;
};
}
interface GoogleCalendarEventsResponse {
items?: GoogleCalendarEvent[];
error?: {
code: number;
message: string;
};
}
export interface GoogleCalendarHoliday {
id: string;
countryCode: string;
eventId: string;
name: string;
date: Date;
year: number;
}
export class GoogleCalendarClient {
private apiKey: string;
constructor(apiKey?: string) {
const key = apiKey || process.env.GOOGLE_CALENDAR_API_KEY;
if (!key) {
throw new Error("GOOGLE_CALENDAR_API_KEY environment variable is not set");
}
this.apiKey = key;
}
async fetchHolidays(countryCode: string, year: number): Promise<GoogleCalendarHoliday[]> {
const calendarConfig = GOOGLE_HOLIDAY_CALENDARS[countryCode];
if (!calendarConfig) {
return [];
}
const calendarId = encodeURIComponent(calendarConfig.calendarId);
const timeMin = dayjs(`${year}-01-01`).startOf("day").toISOString();
const timeMax = dayjs(`${year}-12-31`).endOf("day").toISOString();
const url = `https://www.googleapis.com/calendar/v3/calendars/${calendarId}/events?key=${this.apiKey}&timeMin=${timeMin}&timeMax=${timeMax}&singleEvents=true&orderBy=startTime`;
const response = await fetch(url);
const data: GoogleCalendarEventsResponse = await response.json();
if (data.error) {
console.error(`Google Calendar API error for ${countryCode}:`, data.error);
throw new Error(`Google Calendar API error: ${data.error.message}`);
}
if (!data.items) {
return [];
}
return data.items.map((event) => {
const dateStr = event.start.date || event.start.dateTime?.split("T")[0];
const date = dateStr ? dayjs(dateStr).toDate() : new Date();
return {
id: `${countryCode}_${event.id}`,
countryCode,
eventId: event.id,
name: event.summary,
date,
year,
};
});
}
}
let defaultClient: GoogleCalendarClient | null = null;
export function getGoogleCalendarClient(): GoogleCalendarClient {
if (!defaultClient) {
defaultClient = new GoogleCalendarClient();
}
return defaultClient;
}
@@ -0,0 +1,128 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { GOOGLE_HOLIDAY_CALENDARS } from "./constants";
import type { CachedHoliday } from "./HolidayServiceCachingProxy";
vi.mock("./HolidayServiceCachingProxy", () => ({
getHolidayServiceCachingProxy: vi.fn(() => ({
getHolidaysForCountry: vi.fn(),
getHolidaysInRange: vi.fn(),
})),
HolidayServiceCachingProxy: vi.fn(),
}));
import { getHolidayServiceCachingProxy } from "./HolidayServiceCachingProxy";
import { HolidayService } from "./HolidayService";
const mockHolidays: CachedHoliday[] = [
{
id: "1",
countryCode: "US",
eventId: "new_years_day_2025",
name: "New Year's Day",
date: new Date("2025-01-01"),
year: 2025,
},
{
id: "2",
countryCode: "US",
eventId: "christmas_day_2025",
name: "Christmas Day",
date: new Date("2025-12-25"),
year: 2025,
},
];
describe("HolidayService", () => {
let holidayService: HolidayService;
let mockCachingProxy: ReturnType<typeof getHolidayServiceCachingProxy>;
beforeEach(() => {
vi.clearAllMocks();
mockCachingProxy = {
getHolidaysForCountry: vi.fn(),
getHolidaysInRange: vi.fn(),
} as unknown as ReturnType<typeof getHolidayServiceCachingProxy>;
vi.mocked(getHolidayServiceCachingProxy).mockReturnValue(mockCachingProxy);
holidayService = new HolidayService(mockCachingProxy);
});
describe("getSupportedCountries", () => {
it("should return list of countries from GOOGLE_HOLIDAY_CALENDARS", () => {
const countries = holidayService.getSupportedCountries();
expect(countries.length).toBe(Object.keys(GOOGLE_HOLIDAY_CALENDARS).length);
expect(countries.find((c) => c.code === "US")).toEqual({
code: "US",
name: "United States",
});
});
});
describe("getHolidaysForCountry", () => {
it("should return holidays from caching proxy", async () => {
vi.mocked(mockCachingProxy.getHolidaysForCountry).mockResolvedValue(mockHolidays);
const holidays = await holidayService.getHolidaysForCountry("US", 2025);
expect(mockCachingProxy.getHolidaysForCountry).toHaveBeenCalledWith("US", 2025);
expect(holidays).toHaveLength(2);
expect(holidays[0].name).toBe("New Year's Day");
});
});
describe("getHolidaysWithStatus", () => {
it("should mark holidays as enabled by default", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2025-06-15"));
vi.mocked(mockCachingProxy.getHolidaysForCountry).mockResolvedValue(mockHolidays);
const holidays = await holidayService.getHolidaysWithStatus("US", []);
expect(holidays.find((h) => h.id === "christmas_day_2025")?.enabled).toBe(true);
vi.useRealTimers();
});
it("should mark disabled holidays correctly", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2025-06-15"));
vi.mocked(mockCachingProxy.getHolidaysForCountry).mockResolvedValue(mockHolidays);
const holidays = await holidayService.getHolidaysWithStatus("US", ["christmas_day_2025"]);
expect(holidays.find((h) => h.id === "christmas_day_2025")?.enabled).toBe(false);
vi.useRealTimers();
});
});
describe("getHolidayDatesInRange", () => {
it("should return holidays within date range", async () => {
vi.mocked(mockCachingProxy.getHolidaysInRange).mockResolvedValue([mockHolidays[1]]);
const holidays = await holidayService.getHolidayDatesInRange(
"US",
[],
new Date("2025-12-01"),
new Date("2025-12-31")
);
expect(holidays).toHaveLength(1);
expect(holidays[0].holiday.name).toBe("Christmas Day");
});
it("should exclude disabled holidays", async () => {
vi.mocked(mockCachingProxy.getHolidaysInRange).mockResolvedValue([mockHolidays[1]]);
const holidays = await holidayService.getHolidayDatesInRange(
"US",
["christmas_day_2025"],
new Date("2025-12-01"),
new Date("2025-12-31")
);
expect(holidays).toEqual([]);
});
});
});
+266
View File
@@ -0,0 +1,266 @@
import dayjs from "@calcom/dayjs";
import { HolidayRepository } from "@calcom/lib/server/repository/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");
}
const holidays = await this.getHolidaysForCountry(settings.countryCode);
if (!holidays.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(h.date).startOf("day").toDate(),
end: dayjs(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(holidayDate.date).startOf("day").valueOf();
const holidayEnd = dayjs(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;
}
@@ -0,0 +1,116 @@
import dayjs from "@calcom/dayjs";
import { HolidayRepository } from "@calcom/lib/server/repository/HolidayRepository";
import { GOOGLE_HOLIDAY_CALENDARS, HOLIDAY_CACHE_DAYS } from "./constants";
import { getGoogleCalendarClient, type GoogleCalendarClient, type GoogleCalendarHoliday } from "./GoogleCalendarClient";
export interface CachedHoliday {
id: string;
countryCode: string;
eventId: string;
name: string;
date: Date;
year: number;
}
export class HolidayServiceCachingProxy {
private calendarClient: GoogleCalendarClient;
constructor(calendarClient?: GoogleCalendarClient) {
this.calendarClient = calendarClient || getGoogleCalendarClient();
}
private async isCacheStale(countryCode: string, year: number): Promise<boolean> {
const staleDate = dayjs().subtract(HOLIDAY_CACHE_DAYS, "days").toDate();
const cachedEntry = await HolidayRepository.findFirstCacheEntry({ countryCode, year });
if (!cachedEntry) {
return true;
}
return cachedEntry.updatedAt < staleDate;
}
private async refreshCache(countryCode: string, year: number): Promise<void> {
const calendarConfig = GOOGLE_HOLIDAY_CALENDARS[countryCode];
if (!calendarConfig) {
return;
}
try {
const holidays = await this.calendarClient.fetchHolidays(countryCode, year);
await HolidayRepository.refreshCache({
countryCode,
year,
holidays: holidays.map((h: GoogleCalendarHoliday) => ({
countryCode: h.countryCode,
calendarId: calendarConfig.calendarId,
eventId: h.eventId,
name: h.name,
date: h.date,
year: h.year,
})),
});
} catch (error) {
console.error(`Failed to refresh holiday cache for ${countryCode}:`, error);
}
}
async getHolidaysForCountry(countryCode: string, year: number): Promise<CachedHoliday[]> {
const isStale = await this.isCacheStale(countryCode, year);
if (isStale) {
await this.refreshCache(countryCode, year);
}
const cached = await HolidayRepository.findManyCachedHolidays({ countryCode, year });
return cached.map((h): CachedHoliday => ({
id: h.id,
countryCode: h.countryCode,
eventId: h.eventId,
name: h.name,
date: h.date,
year: h.year,
}));
}
async getHolidaysInRange(countryCode: string, startDate: Date, endDate: Date): Promise<CachedHoliday[]> {
const startYear = dayjs(startDate).year();
const endYear = dayjs(endDate).year();
for (let year = startYear; year <= endYear; year++) {
const isStale = await this.isCacheStale(countryCode, year);
if (isStale) {
await this.refreshCache(countryCode, year);
}
}
const cached = await HolidayRepository.findCachedHolidaysInRange({
countryCode,
startDate,
endDate,
});
return cached.map((h): CachedHoliday => ({
id: h.id,
countryCode: h.countryCode,
eventId: h.eventId,
name: h.name,
date: h.date,
year: h.year,
}));
}
}
let defaultProxy: HolidayServiceCachingProxy | null = null;
export function getHolidayServiceCachingProxy(): HolidayServiceCachingProxy {
if (!defaultProxy) {
defaultProxy = new HolidayServiceCachingProxy();
}
return defaultProxy;
}
+260
View File
@@ -0,0 +1,260 @@
/** Number of months to check for booking conflicts */
export const CONFLICT_CHECK_MONTHS = 3;
/** Number of days to cache holiday data */
export const HOLIDAY_CACHE_DAYS = 7;
/**
* Google Calendar IDs for religious holiday calendars.
* These are separate from country-specific holidays.
*/
export const GOOGLE_RELIGIOUS_HOLIDAY_CALENDARS: Record<string, { name: string; calendarId: string }> = {
CHRISTIAN: { name: "Christian Holidays", calendarId: "en.christian#holiday@group.v.calendar.google.com" },
HINDU: { name: "Hindu Holidays", calendarId: "en.hinduism#holiday@group.v.calendar.google.com" },
JEWISH: { name: "Jewish Holidays", calendarId: "en.judaism#holiday@group.v.calendar.google.com" },
MUSLIM: { name: "Muslim Holidays", calendarId: "en.islamic#holiday@group.v.calendar.google.com" },
ORTHODOX: { name: "Orthodox Holidays", calendarId: "en.orthodox_christianity#holiday@group.v.calendar.google.com" },
};
/**
* Google Calendar IDs for public holiday calendars by country.
* Source: Google Calendar API
*/
export const GOOGLE_HOLIDAY_CALENDARS: Record<string, { name: string; calendarId: string }> = {
...GOOGLE_RELIGIOUS_HOLIDAY_CALENDARS,
// Countries A-Z
AF: { name: "Afghanistan", calendarId: "en.af.official#holiday@group.v.calendar.google.com" },
AL: { name: "Albania", calendarId: "en.al.official#holiday@group.v.calendar.google.com" },
DZ: { name: "Algeria", calendarId: "en.dz.official#holiday@group.v.calendar.google.com" },
AS: { name: "American Samoa", calendarId: "en.as.official#holiday@group.v.calendar.google.com" },
AD: { name: "Andorra", calendarId: "en.ad.official#holiday@group.v.calendar.google.com" },
AO: { name: "Angola", calendarId: "en.ao.official#holiday@group.v.calendar.google.com" },
AI: { name: "Anguilla", calendarId: "en.ai.official#holiday@group.v.calendar.google.com" },
AG: { name: "Antigua & Barbuda", calendarId: "en.ag.official#holiday@group.v.calendar.google.com" },
AR: { name: "Argentina", calendarId: "en.ar.official#holiday@group.v.calendar.google.com" },
AM: { name: "Armenia", calendarId: "en.am.official#holiday@group.v.calendar.google.com" },
AW: { name: "Aruba", calendarId: "en.aw.official#holiday@group.v.calendar.google.com" },
AU: { name: "Australia", calendarId: "en.australian.official#holiday@group.v.calendar.google.com" },
AT: { name: "Austria", calendarId: "en.austrian.official#holiday@group.v.calendar.google.com" },
AZ: { name: "Azerbaijan", calendarId: "en.az.official#holiday@group.v.calendar.google.com" },
BS: { name: "Bahamas", calendarId: "en.bs.official#holiday@group.v.calendar.google.com" },
BH: { name: "Bahrain", calendarId: "en.bh.official#holiday@group.v.calendar.google.com" },
BD: { name: "Bangladesh", calendarId: "en.bd.official#holiday@group.v.calendar.google.com" },
BB: { name: "Barbados", calendarId: "en.bb.official#holiday@group.v.calendar.google.com" },
BY: { name: "Belarus", calendarId: "en.by.official#holiday@group.v.calendar.google.com" },
BE: { name: "Belgium", calendarId: "en.be.official#holiday@group.v.calendar.google.com" },
BZ: { name: "Belize", calendarId: "en.bz.official#holiday@group.v.calendar.google.com" },
BJ: { name: "Benin", calendarId: "en.bj.official#holiday@group.v.calendar.google.com" },
BM: { name: "Bermuda", calendarId: "en.bm.official#holiday@group.v.calendar.google.com" },
BT: { name: "Bhutan", calendarId: "en.bt.official#holiday@group.v.calendar.google.com" },
BO: { name: "Bolivia", calendarId: "en.bo.official#holiday@group.v.calendar.google.com" },
BA: { name: "Bosnia & Herzegovina", calendarId: "en.ba.official#holiday@group.v.calendar.google.com" },
BW: { name: "Botswana", calendarId: "en.bw.official#holiday@group.v.calendar.google.com" },
BR: { name: "Brazil", calendarId: "en.brazilian.official#holiday@group.v.calendar.google.com" },
VG: { name: "British Virgin Islands", calendarId: "en.vg.official#holiday@group.v.calendar.google.com" },
BN: { name: "Brunei", calendarId: "en.bn.official#holiday@group.v.calendar.google.com" },
BG: { name: "Bulgaria", calendarId: "en.bulgarian.official#holiday@group.v.calendar.google.com" },
BF: { name: "Burkina Faso", calendarId: "en.bf.official#holiday@group.v.calendar.google.com" },
BI: { name: "Burundi", calendarId: "en.bi.official#holiday@group.v.calendar.google.com" },
KH: { name: "Cambodia", calendarId: "en.kh.official#holiday@group.v.calendar.google.com" },
CM: { name: "Cameroon", calendarId: "en.cm.official#holiday@group.v.calendar.google.com" },
CA: { name: "Canada", calendarId: "en.canadian.official#holiday@group.v.calendar.google.com" },
CV: { name: "Cape Verde", calendarId: "en.cv.official#holiday@group.v.calendar.google.com" },
KY: { name: "Cayman Islands", calendarId: "en.ky.official#holiday@group.v.calendar.google.com" },
CF: { name: "Central African Republic", calendarId: "en.cf.official#holiday@group.v.calendar.google.com" },
TD: { name: "Chad", calendarId: "en.td.official#holiday@group.v.calendar.google.com" },
CL: { name: "Chile", calendarId: "en.cl.official#holiday@group.v.calendar.google.com" },
CN: { name: "China", calendarId: "en.china.official#holiday@group.v.calendar.google.com" },
CO: { name: "Colombia", calendarId: "en.co.official#holiday@group.v.calendar.google.com" },
KM: { name: "Comoros", calendarId: "en.km.official#holiday@group.v.calendar.google.com" },
CG: { name: "Congo - Brazzaville", calendarId: "en.cg.official#holiday@group.v.calendar.google.com" },
CD: { name: "Congo - Kinshasa", calendarId: "en.cd.official#holiday@group.v.calendar.google.com" },
CK: { name: "Cook Islands", calendarId: "en.ck.official#holiday@group.v.calendar.google.com" },
CR: { name: "Costa Rica", calendarId: "en.cr.official#holiday@group.v.calendar.google.com" },
CI: { name: "Côte d'Ivoire", calendarId: "en.ci.official#holiday@group.v.calendar.google.com" },
HR: { name: "Croatia", calendarId: "en.croatian.official#holiday@group.v.calendar.google.com" },
CU: { name: "Cuba", calendarId: "en.cu.official#holiday@group.v.calendar.google.com" },
CW: { name: "Curaçao", calendarId: "en.cw.official#holiday@group.v.calendar.google.com" },
CY: { name: "Cyprus", calendarId: "en.cy.official#holiday@group.v.calendar.google.com" },
CZ: { name: "Czechia", calendarId: "en.czech.official#holiday@group.v.calendar.google.com" },
DK: { name: "Denmark", calendarId: "en.danish.official#holiday@group.v.calendar.google.com" },
DJ: { name: "Djibouti", calendarId: "en.dj.official#holiday@group.v.calendar.google.com" },
DM: { name: "Dominica", calendarId: "en.dm.official#holiday@group.v.calendar.google.com" },
DO: { name: "Dominican Republic", calendarId: "en.do.official#holiday@group.v.calendar.google.com" },
EC: { name: "Ecuador", calendarId: "en.ec.official#holiday@group.v.calendar.google.com" },
EG: { name: "Egypt", calendarId: "en.eg.official#holiday@group.v.calendar.google.com" },
SV: { name: "El Salvador", calendarId: "en.sv.official#holiday@group.v.calendar.google.com" },
GQ: { name: "Equatorial Guinea", calendarId: "en.gq.official#holiday@group.v.calendar.google.com" },
ER: { name: "Eritrea", calendarId: "en.er.official#holiday@group.v.calendar.google.com" },
EE: { name: "Estonia", calendarId: "en.ee.official#holiday@group.v.calendar.google.com" },
SZ: { name: "Eswatini", calendarId: "en.sz.official#holiday@group.v.calendar.google.com" },
ET: { name: "Ethiopia", calendarId: "en.et.official#holiday@group.v.calendar.google.com" },
FK: { name: "Falkland Islands (Islas Malvinas)", calendarId: "en.fk.official#holiday@group.v.calendar.google.com" },
FO: { name: "Faroe Islands", calendarId: "en.fo.official#holiday@group.v.calendar.google.com" },
FJ: { name: "Fiji", calendarId: "en.fj.official#holiday@group.v.calendar.google.com" },
FI: { name: "Finland", calendarId: "en.finnish.official#holiday@group.v.calendar.google.com" },
FR: { name: "France", calendarId: "en.french.official#holiday@group.v.calendar.google.com" },
GF: { name: "French Guiana", calendarId: "en.gf.official#holiday@group.v.calendar.google.com" },
PF: { name: "French Polynesia", calendarId: "en.pf.official#holiday@group.v.calendar.google.com" },
GA: { name: "Gabon", calendarId: "en.ga.official#holiday@group.v.calendar.google.com" },
GM: { name: "Gambia", calendarId: "en.gm.official#holiday@group.v.calendar.google.com" },
GE: { name: "Georgia", calendarId: "en.ge.official#holiday@group.v.calendar.google.com" },
DE: { name: "Germany", calendarId: "en.german.official#holiday@group.v.calendar.google.com" },
GH: { name: "Ghana", calendarId: "en.gh.official#holiday@group.v.calendar.google.com" },
GI: { name: "Gibraltar", calendarId: "en.gi.official#holiday@group.v.calendar.google.com" },
GR: { name: "Greece", calendarId: "en.greek.official#holiday@group.v.calendar.google.com" },
GL: { name: "Greenland", calendarId: "en.gl.official#holiday@group.v.calendar.google.com" },
GD: { name: "Grenada", calendarId: "en.gd.official#holiday@group.v.calendar.google.com" },
GP: { name: "Guadeloupe", calendarId: "en.gp.official#holiday@group.v.calendar.google.com" },
GU: { name: "Guam", calendarId: "en.gu.official#holiday@group.v.calendar.google.com" },
GT: { name: "Guatemala", calendarId: "en.gt.official#holiday@group.v.calendar.google.com" },
GG: { name: "Guernsey", calendarId: "en.gg.official#holiday@group.v.calendar.google.com" },
GN: { name: "Guinea", calendarId: "en.gn.official#holiday@group.v.calendar.google.com" },
GW: { name: "Guinea-Bissau", calendarId: "en.gw.official#holiday@group.v.calendar.google.com" },
GY: { name: "Guyana", calendarId: "en.gy.official#holiday@group.v.calendar.google.com" },
HT: { name: "Haiti", calendarId: "en.ht.official#holiday@group.v.calendar.google.com" },
HN: { name: "Honduras", calendarId: "en.hn.official#holiday@group.v.calendar.google.com" },
HK: { name: "Hong Kong", calendarId: "en.hong_kong.official#holiday@group.v.calendar.google.com" },
HU: { name: "Hungary", calendarId: "en.hungarian.official#holiday@group.v.calendar.google.com" },
IS: { name: "Iceland", calendarId: "en.is.official#holiday@group.v.calendar.google.com" },
IN: { name: "India", calendarId: "en.indian.official#holiday@group.v.calendar.google.com" },
ID: { name: "Indonesia", calendarId: "en.indonesian.official#holiday@group.v.calendar.google.com" },
IR: { name: "Iran", calendarId: "en.ir.official#holiday@group.v.calendar.google.com" },
IQ: { name: "Iraq", calendarId: "en.iq.official#holiday@group.v.calendar.google.com" },
IE: { name: "Ireland", calendarId: "en.irish.official#holiday@group.v.calendar.google.com" },
IM: { name: "Isle of Man", calendarId: "en.im.official#holiday@group.v.calendar.google.com" },
IL: { name: "Israel", calendarId: "en.jewish.official#holiday@group.v.calendar.google.com" },
IT: { name: "Italy", calendarId: "en.italian.official#holiday@group.v.calendar.google.com" },
JM: { name: "Jamaica", calendarId: "en.jm.official#holiday@group.v.calendar.google.com" },
JP: { name: "Japan", calendarId: "en.japanese.official#holiday@group.v.calendar.google.com" },
JE: { name: "Jersey", calendarId: "en.je.official#holiday@group.v.calendar.google.com" },
JO: { name: "Jordan", calendarId: "en.jo.official#holiday@group.v.calendar.google.com" },
KZ: { name: "Kazakhstan", calendarId: "en.kz.official#holiday@group.v.calendar.google.com" },
KE: { name: "Kenya", calendarId: "en.ke.official#holiday@group.v.calendar.google.com" },
KI: { name: "Kiribati", calendarId: "en.ki.official#holiday@group.v.calendar.google.com" },
XK: { name: "Kosovo", calendarId: "en.xk.official#holiday@group.v.calendar.google.com" },
KW: { name: "Kuwait", calendarId: "en.kw.official#holiday@group.v.calendar.google.com" },
KG: { name: "Kyrgyzstan", calendarId: "en.kg.official#holiday@group.v.calendar.google.com" },
LA: { name: "Laos", calendarId: "en.la.official#holiday@group.v.calendar.google.com" },
LV: { name: "Latvia", calendarId: "en.latvian.official#holiday@group.v.calendar.google.com" },
LB: { name: "Lebanon", calendarId: "en.lb.official#holiday@group.v.calendar.google.com" },
LS: { name: "Lesotho", calendarId: "en.ls.official#holiday@group.v.calendar.google.com" },
LR: { name: "Liberia", calendarId: "en.lr.official#holiday@group.v.calendar.google.com" },
LY: { name: "Libya", calendarId: "en.ly.official#holiday@group.v.calendar.google.com" },
LI: { name: "Liechtenstein", calendarId: "en.li.official#holiday@group.v.calendar.google.com" },
LT: { name: "Lithuania", calendarId: "en.lithuanian.official#holiday@group.v.calendar.google.com" },
LU: { name: "Luxembourg", calendarId: "en.lu.official#holiday@group.v.calendar.google.com" },
MO: { name: "Macao", calendarId: "en.mo.official#holiday@group.v.calendar.google.com" },
MG: { name: "Madagascar", calendarId: "en.mg.official#holiday@group.v.calendar.google.com" },
MW: { name: "Malawi", calendarId: "en.mw.official#holiday@group.v.calendar.google.com" },
MY: { name: "Malaysia", calendarId: "en.malaysia.official#holiday@group.v.calendar.google.com" },
MV: { name: "Maldives", calendarId: "en.mv.official#holiday@group.v.calendar.google.com" },
ML: { name: "Mali", calendarId: "en.ml.official#holiday@group.v.calendar.google.com" },
MT: { name: "Malta", calendarId: "en.mt.official#holiday@group.v.calendar.google.com" },
MH: { name: "Marshall Islands", calendarId: "en.mh.official#holiday@group.v.calendar.google.com" },
MQ: { name: "Martinique", calendarId: "en.mq.official#holiday@group.v.calendar.google.com" },
MR: { name: "Mauritania", calendarId: "en.mr.official#holiday@group.v.calendar.google.com" },
MU: { name: "Mauritius", calendarId: "en.mu.official#holiday@group.v.calendar.google.com" },
YT: { name: "Mayotte", calendarId: "en.yt.official#holiday@group.v.calendar.google.com" },
MX: { name: "Mexico", calendarId: "en.mexican.official#holiday@group.v.calendar.google.com" },
FM: { name: "Micronesia", calendarId: "en.fm.official#holiday@group.v.calendar.google.com" },
MD: { name: "Moldova", calendarId: "en.md.official#holiday@group.v.calendar.google.com" },
MC: { name: "Monaco", calendarId: "en.mc.official#holiday@group.v.calendar.google.com" },
MN: { name: "Mongolia", calendarId: "en.mn.official#holiday@group.v.calendar.google.com" },
ME: { name: "Montenegro", calendarId: "en.me.official#holiday@group.v.calendar.google.com" },
MS: { name: "Montserrat", calendarId: "en.ms.official#holiday@group.v.calendar.google.com" },
MA: { name: "Morocco", calendarId: "en.ma.official#holiday@group.v.calendar.google.com" },
MZ: { name: "Mozambique", calendarId: "en.mz.official#holiday@group.v.calendar.google.com" },
MM: { name: "Myanmar (Burma)", calendarId: "en.mm.official#holiday@group.v.calendar.google.com" },
NA: { name: "Namibia", calendarId: "en.na.official#holiday@group.v.calendar.google.com" },
NR: { name: "Nauru", calendarId: "en.nr.official#holiday@group.v.calendar.google.com" },
NP: { name: "Nepal", calendarId: "en.np.official#holiday@group.v.calendar.google.com" },
NL: { name: "Netherlands", calendarId: "en.dutch.official#holiday@group.v.calendar.google.com" },
NC: { name: "New Caledonia", calendarId: "en.nc.official#holiday@group.v.calendar.google.com" },
NZ: { name: "New Zealand", calendarId: "en.new_zealand.official#holiday@group.v.calendar.google.com" },
NI: { name: "Nicaragua", calendarId: "en.ni.official#holiday@group.v.calendar.google.com" },
NE: { name: "Niger", calendarId: "en.ne.official#holiday@group.v.calendar.google.com" },
NG: { name: "Nigeria", calendarId: "en.ng.official#holiday@group.v.calendar.google.com" },
MP: { name: "Northern Mariana Islands", calendarId: "en.mp.official#holiday@group.v.calendar.google.com" },
KP: { name: "North Korea", calendarId: "en.kp.official#holiday@group.v.calendar.google.com" },
MK: { name: "North Macedonia", calendarId: "en.mk.official#holiday@group.v.calendar.google.com" },
NO: { name: "Norway", calendarId: "en.norwegian.official#holiday@group.v.calendar.google.com" },
OM: { name: "Oman", calendarId: "en.om.official#holiday@group.v.calendar.google.com" },
PK: { name: "Pakistan", calendarId: "en.pk.official#holiday@group.v.calendar.google.com" },
PW: { name: "Palau", calendarId: "en.pw.official#holiday@group.v.calendar.google.com" },
PA: { name: "Panama", calendarId: "en.pa.official#holiday@group.v.calendar.google.com" },
PG: { name: "Papua New Guinea", calendarId: "en.pg.official#holiday@group.v.calendar.google.com" },
PY: { name: "Paraguay", calendarId: "en.py.official#holiday@group.v.calendar.google.com" },
PE: { name: "Peru", calendarId: "en.pe.official#holiday@group.v.calendar.google.com" },
PH: { name: "Philippines", calendarId: "en.philippines.official#holiday@group.v.calendar.google.com" },
PL: { name: "Poland", calendarId: "en.polish.official#holiday@group.v.calendar.google.com" },
PT: { name: "Portugal", calendarId: "en.portuguese.official#holiday@group.v.calendar.google.com" },
PR: { name: "Puerto Rico", calendarId: "en.pr.official#holiday@group.v.calendar.google.com" },
QA: { name: "Qatar", calendarId: "en.qa.official#holiday@group.v.calendar.google.com" },
RE: { name: "Réunion", calendarId: "en.re.official#holiday@group.v.calendar.google.com" },
RO: { name: "Romania", calendarId: "en.romanian.official#holiday@group.v.calendar.google.com" },
RU: { name: "Russia", calendarId: "en.russian.official#holiday@group.v.calendar.google.com" },
RW: { name: "Rwanda", calendarId: "en.rw.official#holiday@group.v.calendar.google.com" },
WS: { name: "Samoa", calendarId: "en.ws.official#holiday@group.v.calendar.google.com" },
SM: { name: "San Marino", calendarId: "en.sm.official#holiday@group.v.calendar.google.com" },
ST: { name: "São Tomé & Príncipe", calendarId: "en.st.official#holiday@group.v.calendar.google.com" },
SA: { name: "Saudi Arabia", calendarId: "en.saudiarabian.official#holiday@group.v.calendar.google.com" },
SN: { name: "Senegal", calendarId: "en.sn.official#holiday@group.v.calendar.google.com" },
RS: { name: "Serbia", calendarId: "en.rs.official#holiday@group.v.calendar.google.com" },
SC: { name: "Seychelles", calendarId: "en.sc.official#holiday@group.v.calendar.google.com" },
SL: { name: "Sierra Leone", calendarId: "en.sl.official#holiday@group.v.calendar.google.com" },
SG: { name: "Singapore", calendarId: "en.singapore.official#holiday@group.v.calendar.google.com" },
SX: { name: "Sint Maarten", calendarId: "en.sx.official#holiday@group.v.calendar.google.com" },
SK: { name: "Slovakia", calendarId: "en.slovak.official#holiday@group.v.calendar.google.com" },
SI: { name: "Slovenia", calendarId: "en.slovenian.official#holiday@group.v.calendar.google.com" },
SB: { name: "Solomon Islands", calendarId: "en.sb.official#holiday@group.v.calendar.google.com" },
SO: { name: "Somalia", calendarId: "en.so.official#holiday@group.v.calendar.google.com" },
ZA: { name: "South Africa", calendarId: "en.sa.official#holiday@group.v.calendar.google.com" },
KR: { name: "South Korea", calendarId: "en.south_korea.official#holiday@group.v.calendar.google.com" },
SS: { name: "South Sudan", calendarId: "en.ss.official#holiday@group.v.calendar.google.com" },
ES: { name: "Spain", calendarId: "en.spain.official#holiday@group.v.calendar.google.com" },
LK: { name: "Sri Lanka", calendarId: "en.lk.official#holiday@group.v.calendar.google.com" },
BL: { name: "St. Barthélemy", calendarId: "en.bl.official#holiday@group.v.calendar.google.com" },
SH: { name: "St. Helena", calendarId: "en.sh.official#holiday@group.v.calendar.google.com" },
KN: { name: "St. Kitts & Nevis", calendarId: "en.kn.official#holiday@group.v.calendar.google.com" },
LC: { name: "St. Lucia", calendarId: "en.lc.official#holiday@group.v.calendar.google.com" },
MF: { name: "St. Martin", calendarId: "en.mf.official#holiday@group.v.calendar.google.com" },
PM: { name: "St. Pierre & Miquelon", calendarId: "en.pm.official#holiday@group.v.calendar.google.com" },
VC: { name: "St. Vincent & Grenadines", calendarId: "en.vc.official#holiday@group.v.calendar.google.com" },
SD: { name: "Sudan", calendarId: "en.sd.official#holiday@group.v.calendar.google.com" },
SR: { name: "Suriname", calendarId: "en.sr.official#holiday@group.v.calendar.google.com" },
SE: { name: "Sweden", calendarId: "en.swedish.official#holiday@group.v.calendar.google.com" },
CH: { name: "Switzerland", calendarId: "en.ch.official#holiday@group.v.calendar.google.com" },
SY: { name: "Syria", calendarId: "en.sy.official#holiday@group.v.calendar.google.com" },
TW: { name: "Taiwan", calendarId: "en.taiwan.official#holiday@group.v.calendar.google.com" },
TJ: { name: "Tajikistan", calendarId: "en.tj.official#holiday@group.v.calendar.google.com" },
TZ: { name: "Tanzania", calendarId: "en.tz.official#holiday@group.v.calendar.google.com" },
TH: { name: "Thailand", calendarId: "en.th.official#holiday@group.v.calendar.google.com" },
TL: { name: "Timor-Leste", calendarId: "en.tl.official#holiday@group.v.calendar.google.com" },
TG: { name: "Togo", calendarId: "en.tg.official#holiday@group.v.calendar.google.com" },
TO: { name: "Tonga", calendarId: "en.to.official#holiday@group.v.calendar.google.com" },
TT: { name: "Trinidad & Tobago", calendarId: "en.tt.official#holiday@group.v.calendar.google.com" },
TN: { name: "Tunisia", calendarId: "en.tn.official#holiday@group.v.calendar.google.com" },
TR: { name: "Turkey", calendarId: "en.turkish.official#holiday@group.v.calendar.google.com" },
TM: { name: "Turkmenistan", calendarId: "en.tm.official#holiday@group.v.calendar.google.com" },
TC: { name: "Turks & Caicos Islands", calendarId: "en.tc.official#holiday@group.v.calendar.google.com" },
TV: { name: "Tuvalu", calendarId: "en.tv.official#holiday@group.v.calendar.google.com" },
VI: { name: "U.S. Virgin Islands", calendarId: "en.vi.official#holiday@group.v.calendar.google.com" },
UG: { name: "Uganda", calendarId: "en.ug.official#holiday@group.v.calendar.google.com" },
UA: { name: "Ukraine", calendarId: "en.ukrainian.official#holiday@group.v.calendar.google.com" },
AE: { name: "United Arab Emirates", calendarId: "en.ae.official#holiday@group.v.calendar.google.com" },
GB: { name: "United Kingdom", calendarId: "en.uk.official#holiday@group.v.calendar.google.com" },
US: { name: "United States", calendarId: "en.usa.official#holiday@group.v.calendar.google.com" },
UY: { name: "Uruguay", calendarId: "en.uy.official#holiday@group.v.calendar.google.com" },
UZ: { name: "Uzbekistan", calendarId: "en.uz.official#holiday@group.v.calendar.google.com" },
VU: { name: "Vanuatu", calendarId: "en.vu.official#holiday@group.v.calendar.google.com" },
VA: { name: "Vatican City", calendarId: "en.va.official#holiday@group.v.calendar.google.com" },
VE: { name: "Venezuela", calendarId: "en.ve.official#holiday@group.v.calendar.google.com" },
VN: { name: "Vietnam", calendarId: "en.vietnamese.official#holiday@group.v.calendar.google.com" },
WF: { name: "Wallis & Futuna", calendarId: "en.wf.official#holiday@group.v.calendar.google.com" },
YE: { name: "Yemen", calendarId: "en.ye.official#holiday@group.v.calendar.google.com" },
ZM: { name: "Zambia", calendarId: "en.zm.official#holiday@group.v.calendar.google.com" },
ZW: { name: "Zimbabwe", calendarId: "en.zw.official#holiday@group.v.calendar.google.com" },
};
+6
View File
@@ -0,0 +1,6 @@
export { HolidayService, getHolidayService } from "./HolidayService";
export { HolidayServiceCachingProxy, getHolidayServiceCachingProxy } from "./HolidayServiceCachingProxy";
export { GoogleCalendarClient, getGoogleCalendarClient } from "./GoogleCalendarClient";
export type { Country, Holiday, HolidayWithStatus } from "./types";
export type { CachedHoliday } from "./HolidayServiceCachingProxy";
export type { GoogleCalendarHoliday } from "./GoogleCalendarClient";
+15
View File
@@ -0,0 +1,15 @@
export interface Holiday {
id: string;
name: string;
date: string; // ISO date string YYYY-MM-DD
year: number;
}
export interface Country {
code: string;
name: string;
}
export interface HolidayWithStatus extends Holiday {
enabled: boolean;
}
@@ -0,0 +1,326 @@
import prismaMock from "../../../../tests/libs/__mocks__/prismaMock";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { HolidayRepository } from "./HolidayRepository";
describe("HolidayRepository", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("findUserSettings", () => {
it("should return user holiday settings when they exist", async () => {
const mockSettings = {
id: 1,
userId: 123,
countryCode: "US",
disabledIds: ["holiday_1", "holiday_2"],
createdAt: new Date(),
updatedAt: new Date(),
};
prismaMock.userHolidaySettings.findUnique.mockResolvedValue(mockSettings);
const result = await HolidayRepository.findUserSettings({ userId: 123 });
expect(prismaMock.userHolidaySettings.findUnique).toHaveBeenCalledWith({
where: { userId: 123 },
select: {
id: true,
userId: true,
countryCode: true,
disabledIds: true,
createdAt: true,
updatedAt: true,
},
});
expect(result).toEqual(mockSettings);
});
it("should return null when user has no holiday settings", async () => {
prismaMock.userHolidaySettings.findUnique.mockResolvedValue(null);
const result = await HolidayRepository.findUserSettings({ userId: 456 });
expect(prismaMock.userHolidaySettings.findUnique).toHaveBeenCalledWith({
where: { userId: 456 },
select: expect.any(Object),
});
expect(result).toBeNull();
});
});
describe("findUserSettingsSelect", () => {
it("should return user settings with custom select", async () => {
const mockSettings = {
countryCode: "GB",
disabledIds: [],
};
prismaMock.userHolidaySettings.findUnique.mockResolvedValue(mockSettings);
const result = await HolidayRepository.findUserSettingsSelect({
userId: 123,
select: {
countryCode: true,
disabledIds: true,
},
});
expect(prismaMock.userHolidaySettings.findUnique).toHaveBeenCalledWith({
where: { userId: 123 },
select: {
countryCode: true,
disabledIds: true,
},
});
expect(result).toEqual(mockSettings);
});
});
describe("upsertUserSettings", () => {
it("should create new settings when user has none", async () => {
const mockSettings = {
id: 1,
userId: 123,
countryCode: "US",
disabledIds: [],
createdAt: new Date(),
updatedAt: new Date(),
};
prismaMock.userHolidaySettings.upsert.mockResolvedValue(mockSettings);
const result = await HolidayRepository.upsertUserSettings({
userId: 123,
countryCode: "US",
});
expect(prismaMock.userHolidaySettings.upsert).toHaveBeenCalledWith({
where: { userId: 123 },
create: {
userId: 123,
countryCode: "US",
disabledIds: [],
},
update: {
countryCode: "US",
},
select: expect.any(Object),
});
expect(result).toEqual(mockSettings);
});
it("should reset disabled holidays when resetDisabledHolidays is true", async () => {
const mockSettings = {
id: 1,
userId: 123,
countryCode: "GB",
disabledIds: [],
createdAt: new Date(),
updatedAt: new Date(),
};
prismaMock.userHolidaySettings.upsert.mockResolvedValue(mockSettings);
const result = await HolidayRepository.upsertUserSettings({
userId: 123,
countryCode: "GB",
resetDisabledHolidays: true,
});
expect(prismaMock.userHolidaySettings.upsert).toHaveBeenCalledWith({
where: { userId: 123 },
create: {
userId: 123,
countryCode: "GB",
disabledIds: [],
},
update: {
countryCode: "GB",
disabledIds: [],
},
select: expect.any(Object),
});
expect(result).toEqual(mockSettings);
});
});
describe("updateDisabledIds", () => {
it("should update disabled holiday IDs", async () => {
const mockSettings = {
id: 1,
userId: 123,
countryCode: "US",
disabledIds: ["christmas_2025", "thanksgiving_2025"],
createdAt: new Date(),
updatedAt: new Date(),
};
prismaMock.userHolidaySettings.update.mockResolvedValue(mockSettings);
const result = await HolidayRepository.updateDisabledIds({
userId: 123,
disabledIds: ["christmas_2025", "thanksgiving_2025"],
});
expect(prismaMock.userHolidaySettings.update).toHaveBeenCalledWith({
where: { userId: 123 },
data: { disabledIds: ["christmas_2025", "thanksgiving_2025"] },
select: expect.any(Object),
});
expect(result).toEqual(mockSettings);
});
});
describe("findManyCachedHolidays", () => {
it("should return cached holidays for country and year", async () => {
const mockHolidays = [
{
id: "1",
countryCode: "US",
calendarId: "en.usa.official#holiday@group.v.calendar.google.com",
eventId: "new_years_day_2025",
name: "New Year's Day",
date: new Date("2025-01-01"),
year: 2025,
createdAt: new Date(),
updatedAt: new Date(),
},
{
id: "2",
countryCode: "US",
calendarId: "en.usa.official#holiday@group.v.calendar.google.com",
eventId: "christmas_2025",
name: "Christmas Day",
date: new Date("2025-12-25"),
year: 2025,
createdAt: new Date(),
updatedAt: new Date(),
},
];
prismaMock.holidayCache.findMany.mockResolvedValue(mockHolidays);
const result = await HolidayRepository.findManyCachedHolidays({
countryCode: "US",
year: 2025,
});
expect(prismaMock.holidayCache.findMany).toHaveBeenCalledWith({
where: {
countryCode: "US",
year: 2025,
},
orderBy: {
date: "asc",
},
select: expect.any(Object),
});
expect(result).toEqual(mockHolidays);
});
it("should return empty array when no holidays found", async () => {
prismaMock.holidayCache.findMany.mockResolvedValue([]);
const result = await HolidayRepository.findManyCachedHolidays({
countryCode: "XX",
year: 2025,
});
expect(result).toEqual([]);
});
});
describe("findFirstCacheEntry", () => {
it("should return first cache entry for staleness check", async () => {
const mockEntry = {
id: "1",
countryCode: "US",
calendarId: "en.usa.official#holiday@group.v.calendar.google.com",
eventId: "new_years_day_2025",
name: "New Year's Day",
date: new Date("2025-01-01"),
year: 2025,
createdAt: new Date(),
updatedAt: new Date("2025-01-15"),
};
prismaMock.holidayCache.findFirst.mockResolvedValue(mockEntry);
const result = await HolidayRepository.findFirstCacheEntry({
countryCode: "US",
year: 2025,
});
expect(prismaMock.holidayCache.findFirst).toHaveBeenCalledWith({
where: {
countryCode: "US",
year: 2025,
},
orderBy: {
updatedAt: "desc",
},
select: expect.any(Object),
});
expect(result).toEqual(mockEntry);
});
it("should return null when no cache entry exists", async () => {
prismaMock.holidayCache.findFirst.mockResolvedValue(null);
const result = await HolidayRepository.findFirstCacheEntry({
countryCode: "US",
year: 2030,
});
expect(result).toBeNull();
});
});
describe("findCachedHolidaysInRange", () => {
it("should return holidays within date range", async () => {
const mockHolidays = [
{
id: "1",
countryCode: "US",
calendarId: "en.usa.official#holiday@group.v.calendar.google.com",
eventId: "christmas_2025",
name: "Christmas Day",
date: new Date("2025-12-25"),
year: 2025,
createdAt: new Date(),
updatedAt: new Date(),
},
];
prismaMock.holidayCache.findMany.mockResolvedValue(mockHolidays);
const startDate = new Date("2025-12-01");
const endDate = new Date("2025-12-31");
const result = await HolidayRepository.findCachedHolidaysInRange({
countryCode: "US",
startDate,
endDate,
});
expect(prismaMock.holidayCache.findMany).toHaveBeenCalledWith({
where: {
countryCode: "US",
date: {
gte: startDate,
lte: endDate,
},
},
orderBy: {
date: "asc",
},
select: expect.any(Object),
});
expect(result).toEqual(mockHolidays);
});
});
});
@@ -0,0 +1,217 @@
import { prisma } from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
import { BookingStatus } from "@calcom/prisma/enums";
const holidayCacheSelect = {
id: true,
countryCode: true,
calendarId: true,
eventId: true,
name: true,
date: true,
year: true,
createdAt: true,
updatedAt: true,
} satisfies Prisma.HolidayCacheSelect;
const userHolidaySettingsSelect = {
id: true,
userId: true,
countryCode: true,
disabledIds: true,
createdAt: true,
updatedAt: true,
} satisfies Prisma.UserHolidaySettingsSelect;
export class HolidayRepository {
static async findFirstCacheEntry({ countryCode, year }: { countryCode: string; year: number }) {
return prisma.holidayCache.findFirst({
where: {
countryCode,
year,
},
orderBy: {
updatedAt: "desc",
},
select: holidayCacheSelect,
});
}
static async findManyCachedHolidays({ countryCode, year }: { countryCode: string; year: number }) {
return prisma.holidayCache.findMany({
where: {
countryCode,
year,
},
orderBy: {
date: "asc",
},
select: holidayCacheSelect,
});
}
static async findCachedHolidaysInRange({
countryCode,
startDate,
endDate,
}: {
countryCode: string;
startDate: Date;
endDate: Date;
}) {
return prisma.holidayCache.findMany({
where: {
countryCode,
date: {
gte: startDate,
lte: endDate,
},
},
orderBy: {
date: "asc",
},
select: holidayCacheSelect,
});
}
static async deleteManyCacheEntries({ countryCode, year }: { countryCode: string; year: number }) {
return prisma.holidayCache.deleteMany({
where: {
countryCode,
year,
},
});
}
static async createManyCacheEntries(data: Prisma.HolidayCacheCreateManyInput[]) {
return prisma.holidayCache.createMany({
data,
skipDuplicates: true,
});
}
static async refreshCache({
countryCode,
year,
holidays,
}: {
countryCode: string;
year: number;
holidays: Prisma.HolidayCacheCreateManyInput[];
}) {
return prisma.$transaction(async (tx) => {
await tx.holidayCache.deleteMany({
where: {
countryCode,
year,
},
});
if (holidays.length > 0) {
await tx.holidayCache.createMany({
data: holidays,
skipDuplicates: true,
});
}
});
}
static async findUserSettings({ userId }: { userId: number }) {
return prisma.userHolidaySettings.findUnique({
where: { userId },
select: userHolidaySettingsSelect,
});
}
static async findUserSettingsSelect<T extends Prisma.UserHolidaySettingsSelect>({
userId,
select,
}: {
userId: number;
select: T;
}) {
return prisma.userHolidaySettings.findUnique({
where: { userId },
select,
});
}
static async upsertUserSettings({
userId,
countryCode,
disabledIds = [],
resetDisabledHolidays = false,
}: {
userId: number;
countryCode: string | null;
disabledIds?: string[];
resetDisabledHolidays?: boolean;
}) {
return prisma.userHolidaySettings.upsert({
where: { userId },
create: {
userId,
countryCode,
disabledIds,
},
update: {
countryCode,
...(resetDisabledHolidays ? { disabledIds: [] } : {}),
},
select: userHolidaySettingsSelect,
});
}
static async updateUserSettings({
userId,
data,
}: {
userId: number;
data: Prisma.UserHolidaySettingsUpdateInput;
}) {
return prisma.userHolidaySettings.update({
where: { userId },
data,
select: userHolidaySettingsSelect,
});
}
static async updateDisabledIds({ userId, disabledIds }: { userId: number; disabledIds: string[] }) {
return prisma.userHolidaySettings.update({
where: { userId },
data: { disabledIds },
select: userHolidaySettingsSelect,
});
}
static async findBookingsInDateRanges({
userId,
dateRanges,
}: {
userId: number;
dateRanges: Array<{ start: Date; end: Date }>;
}) {
if (dateRanges.length === 0) return [];
return prisma.booking.findMany({
where: {
userId,
status: { in: [BookingStatus.ACCEPTED, BookingStatus.PENDING] },
OR: dateRanges.map(({ start, end }) => ({
AND: [{ startTime: { lt: end } }, { endTime: { gt: start } }],
})),
},
select: {
id: true,
title: true,
startTime: true,
endTime: true,
attendees: {
take: 1,
select: { name: true },
},
},
});
}
}
@@ -0,0 +1,17 @@
-- CreateTable
CREATE TABLE "public"."UserHolidaySettings" (
"id" SERIAL NOT NULL,
"userId" INTEGER NOT NULL,
"countryCode" TEXT,
"disabledIds" TEXT[],
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "UserHolidaySettings_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "UserHolidaySettings_userId_key" ON "public"."UserHolidaySettings"("userId");
-- AddForeignKey
ALTER TABLE "public"."UserHolidaySettings" ADD CONSTRAINT "UserHolidaySettings_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,24 @@
-- CreateTable
CREATE TABLE "HolidayCache" (
"id" TEXT NOT NULL,
"countryCode" TEXT NOT NULL,
"calendarId" TEXT NOT NULL,
"eventId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"date" TIMESTAMP(3) NOT NULL,
"year" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "HolidayCache_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "HolidayCache_countryCode_year_idx" ON "HolidayCache"("countryCode", "year");
-- CreateIndex
CREATE INDEX "HolidayCache_countryCode_date_idx" ON "HolidayCache"("countryCode", "date");
-- CreateIndex
CREATE UNIQUE INDEX "HolidayCache_countryCode_eventId_key" ON "HolidayCache"("countryCode", "eventId");
+30
View File
@@ -482,6 +482,7 @@ model User {
whitelistWorkflows Boolean @default(false)
calAiPhoneNumbers CalAiPhoneNumber[]
agents Agent[]
holidaySettings UserHolidaySettings?
@@unique([email])
@@unique([email, username])
@@ -1972,6 +1973,35 @@ model OutOfOfficeReason {
entries OutOfOfficeEntry[]
}
model UserHolidaySettings {
id Int @id @default(autoincrement())
userId Int @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
countryCode String?
disabledIds String[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// Holiday cache for Google Calendar public holidays
model HolidayCache {
id String @id @default(uuid())
countryCode String
calendarId String
eventId String
name String
date DateTime
year Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([countryCode, eventId])
@@index([countryCode, year])
@@index([countryCode, date])
}
// Platform
model PlatformOAuthClient {
id String @id @default(cuid())
+1
View File
@@ -18,6 +18,7 @@ export const ENDPOINTS = [
"eventTypes",
"eventTypesHeavy",
"features",
"holidays",
"i18n",
"insights",
"me",
@@ -24,6 +24,7 @@ import { eventTypesRouter } from "./eventTypes/_router";
import { eventTypesRouter as heavyEventTypesRouter } from "./eventTypes/heavy/_router";
import { filterSegmentsRouter } from "./filterSegments/_router";
import { googleWorkspaceRouter } from "./googleWorkspace/_router";
import { holidaysRouter } from "./holidays/_router";
import { i18nRouter } from "./i18n/_router";
import { insightsRouter } from "./insights/_router";
import { meRouter } from "./me/_router";
@@ -83,6 +84,7 @@ export const viewerRouter = router({
routingForms: routingFormsRouter,
credits: creditsRouter,
ooo: oooRouter,
holidays: holidaysRouter,
travelSchedules: travelSchedulesRouter,
aiVoiceAgent: aiVoiceAgentRouter,
phoneNumber: phoneNumberRouter,
@@ -1,4 +1,5 @@
import type { PrismaClient } from "@calcom/prisma";
import type { TAdminToggleFeatureFlagSchema } from "./toggleFeatureFlag.schema";
type GetOptions = {
@@ -0,0 +1,39 @@
import authedProcedure from "../../../procedures/authedProcedure";
import { router } from "../../../trpc";
import { ZGetUserSettingsSchema } from "./getUserSettings.schema";
import { ZToggleHolidaySchema } from "./toggleHoliday.schema";
import { ZUpdateSettingsSchema } from "./updateSettings.schema";
import { ZCheckConflictsSchema } from "./checkConflicts.schema";
export const holidaysRouter = router({
getSupportedCountries: authedProcedure.query(async () => {
const { getSupportedCountriesHandler } = await import("./getSupportedCountries.handler");
return getSupportedCountriesHandler();
}),
getUserSettings: authedProcedure.input(ZGetUserSettingsSchema).query(async ({ ctx, input }) => {
const { getUserSettingsHandler } = await import("./getUserSettings.handler");
return getUserSettingsHandler({ ctx, input });
}),
updateSettings: authedProcedure.input(ZUpdateSettingsSchema).mutation(async ({ ctx, input }) => {
const { updateSettingsHandler } = await import("./updateSettings.handler");
return updateSettingsHandler({ ctx, input });
}),
toggleHoliday: authedProcedure.input(ZToggleHolidaySchema).mutation(async ({ ctx, input }) => {
const { toggleHolidayHandler } = await import("./toggleHoliday.handler");
return toggleHolidayHandler({ ctx, input });
}),
checkConflicts: authedProcedure.input(ZCheckConflictsSchema).query(async ({ ctx, input }) => {
const { checkConflictsHandler } = await import("./checkConflicts.handler");
return checkConflictsHandler({ ctx, input });
}),
});
@@ -0,0 +1,20 @@
import { getHolidayService } from "@calcom/lib/holidays";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
import type { TCheckConflictsSchema } from "./checkConflicts.schema";
type CheckConflictsOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: TCheckConflictsSchema;
};
export type { ConflictingBooking, HolidayConflict } from "@calcom/lib/holidays/HolidayService";
export async function checkConflictsHandler({ ctx, input }: CheckConflictsOptions) {
const holidayService = getHolidayService();
return holidayService.checkConflicts(ctx.user.id, input.countryCode, input.disabledIds);
}
export default checkConflictsHandler;
@@ -0,0 +1,9 @@
import { z } from "zod";
export const ZCheckConflictsSchema = z.object({
countryCode: z.string(),
disabledIds: z.array(z.string()).optional().default([]),
});
export type TCheckConflictsSchema = z.infer<typeof ZCheckConflictsSchema>;
@@ -0,0 +1,8 @@
import { getHolidayService } from "@calcom/lib/holidays";
export async function getSupportedCountriesHandler() {
const holidayService = getHolidayService();
return holidayService.getSupportedCountries();
}
export default getSupportedCountriesHandler;
@@ -0,0 +1,18 @@
import { getHolidayService } from "@calcom/lib/holidays";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
import type { TGetUserSettingsSchema } from "./getUserSettings.schema";
type GetUserSettingsOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: TGetUserSettingsSchema;
};
export async function getUserSettingsHandler({ ctx }: GetUserSettingsOptions) {
const holidayService = getHolidayService();
return holidayService.getUserSettings(ctx.user.id);
}
export default getUserSettingsHandler;
@@ -0,0 +1,6 @@
import { z } from "zod";
export const ZGetUserSettingsSchema = z.object({}).optional();
export type TGetUserSettingsSchema = z.infer<typeof ZGetUserSettingsSchema>;
@@ -0,0 +1,35 @@
import { getHolidayService } from "@calcom/lib/holidays";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
import { TRPCError } from "@trpc/server";
import type { TToggleHolidaySchema } from "./toggleHoliday.schema";
type ToggleHolidayOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: TToggleHolidaySchema;
};
/**
* Toggle a holiday on/off for a user.
*
* Note: holidayId is the Google Calendar event ID (eventId from HolidayCache),
* NOT the database id. This is stored in UserHolidaySettings.disabledIds[]
* to track which holidays the user has disabled.
*/
export async function toggleHolidayHandler({ ctx, input }: ToggleHolidayOptions) {
const holidayService = getHolidayService();
try {
return await holidayService.toggleHoliday(ctx.user.id, input.holidayId, input.enabled);
} catch (error) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: error instanceof Error ? error.message : "Failed to toggle holiday",
});
}
}
export default toggleHolidayHandler;
@@ -0,0 +1,9 @@
import { z } from "zod";
export const ZToggleHolidaySchema = z.object({
holidayId: z.string(),
enabled: z.boolean(),
});
export type TToggleHolidaySchema = z.infer<typeof ZToggleHolidaySchema>;
@@ -0,0 +1,32 @@
import { getHolidayService } from "@calcom/lib/holidays";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
import { TRPCError } from "@trpc/server";
import type { TUpdateSettingsSchema } from "./updateSettings.schema";
type UpdateSettingsOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: TUpdateSettingsSchema;
};
export async function updateSettingsHandler({ ctx, input }: UpdateSettingsOptions) {
const holidayService = getHolidayService();
try {
return await holidayService.updateSettings(
ctx.user.id,
input.countryCode ?? null,
input.resetDisabledHolidays
);
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: error instanceof Error ? error.message : "Failed to update settings",
});
}
}
export default updateSettingsHandler;
@@ -0,0 +1,10 @@
import { z } from "zod";
export const ZUpdateSettingsSchema = z.object({
countryCode: z.string().nullable(),
// When changing country, optionally reset disabled holidays
resetDisabledHolidays: z.boolean().optional().default(true),
});
export type TUpdateSettingsSchema = z.infer<typeof ZUpdateSettingsSchema>;
+4
View File
@@ -4,6 +4,10 @@ declare namespace NodeJS {
readonly CALENDSO_ENCRYPTION_KEY: string | undefined;
readonly DATABASE_URL: string | undefined;
readonly GOOGLE_API_CREDENTIALS: string | undefined;
/** Google Calendar API key for fetching public holiday calendars */
readonly GOOGLE_CALENDAR_API_KEY: string | undefined;
/** Number of days to cache holiday data from Google Calendar (default: 7) */
readonly HOLIDAY_CACHE_DAYS: string | undefined;
/** @deprecated use `NEXT_PUBLIC_WEBAPP_URL` */
readonly BASE_URL: string | undefined;
/** @deprecated use `NEXT_PUBLIC_WEBAPP_URL` */
+1
View File
@@ -76,6 +76,7 @@
"AVATARAPI_PASSWORD",
"GIPHY_API_KEY",
"GOOGLE_API_CREDENTIALS",
"GOOGLE_CALENDAR_API_KEY",
"GOOGLE_LOGIN_ENABLED",
"GOOGLE_WEBHOOK_TOKEN",
"GOOGLE_WEBHOOK_URL",