diff --git a/apps/web/modules/troubleshooter/components/LargeCalendar.tsx b/apps/web/modules/troubleshooter/components/LargeCalendar.tsx index 757f1bdcfc..a36884c6d0 100644 --- a/apps/web/modules/troubleshooter/components/LargeCalendar.tsx +++ b/apps/web/modules/troubleshooter/components/LargeCalendar.tsx @@ -6,6 +6,7 @@ import { useAvailableTimeSlots } from "@calcom/features/bookings/Booker/hooks/us import { useTimePreferences } from "@calcom/features/bookings/lib/timePreferences"; import { Calendar } from "@calcom/features/calendars/weeklyview/components/Calendar"; import { useTroubleshooterStore } from "@calcom/features/troubleshooter/store"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; import { BookingStatus } from "@calcom/prisma/enums"; import { trpc } from "@calcom/trpc/react"; @@ -14,6 +15,7 @@ import { useSchedule } from "~/schedules/hooks/useSchedule"; import { OutOfOfficeInSlots } from "../../bookings/components/OutOfOfficeInSlots"; export const LargeCalendar = ({ extraDays }: { extraDays: number }) => { + const { t } = useLocale(); const { timezone } = useTimePreferences(); const selectedDate = useTroubleshooterStore((state) => state.selectedDate); const event = useTroubleshooterStore((state) => state.event); @@ -34,7 +36,8 @@ export const LargeCalendar = ({ extraDays }: { extraDays: number }) => { withSource: true, }, { - enabled: !!session?.user?.username, + // Busy Times need eventTypeId to correctly have all busy times. event.id check here also avoids sending double requests for availability.user + enabled: !!session?.user?.username && !!event?.id, } ); @@ -68,11 +71,13 @@ export const LargeCalendar = ({ extraDays }: { extraDays: number }) => { // .toDate(), const calendarEvents = busyEvents?.busy.map((event, idx) => { + const translatedTitle = event.title ? t(event.title) : t("busy_time.busy"); return { id: idx, - title: event.title ?? `Busy`, + title: translatedTitle, start: new Date(event.start), end: new Date(event.end), + source: event.source, options: { color: event.source && calendarToColorMap[event.source] ? calendarToColorMap[event.source] : undefined, @@ -104,6 +109,7 @@ export const LargeCalendar = ({ extraDays }: { extraDays: number }) => { title: "Date Override", start: dateOverrideStart.add(workingHoursForDay.startTime, "minutes").toDate(), end: dateOverrideEnd.add(workingHoursForDay.endTime, "minutes").toDate(), + source: "date-override", options: { color: "black", status: BookingStatus.ACCEPTED, @@ -113,7 +119,7 @@ export const LargeCalendar = ({ extraDays }: { extraDays: number }) => { }); } return calendarEvents; - }, [busyEvents, calendarToColorMap]); + }, [busyEvents, calendarToColorMap, t]); return (
diff --git a/packages/features/availability/lib/getUserAvailability.ts b/packages/features/availability/lib/getUserAvailability.ts index d044b78e73..eb94e638d0 100644 --- a/packages/features/availability/lib/getUserAvailability.ts +++ b/packages/features/availability/lib/getUserAvailability.ts @@ -40,6 +40,7 @@ import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; import type { CalendarFetchMode, EventBusyDetails, IntervalLimitUnit } from "@calcom/types/Calendar"; import type { CredentialForCalendarService } from "@calcom/types/Credential"; import type { TimeRange, WorkingHours as WorkingHoursWithUserId } from "@calcom/types/schedule"; +import type { Ensure, Optional } from "@calcom/types/utils"; import * as Sentry from "@sentry/nextjs"; import { z } from "zod"; import { detectEventTypeScheduleForUser } from "./detectEventTypeScheduleForUser"; @@ -167,8 +168,10 @@ export type CurrentSeats = Awaited< ReturnType<(typeof UserAvailabilityService)["prototype"]["_getCurrentSeats"]> >; +type UserAvailabilityBusyDetails = Optional; + export type GetUserAvailabilityResult = { - busy: EventBusyDetails[]; + busy: UserAvailabilityBusyDetails[]; timeZone: string; dateRanges: { start: dayjs.Dayjs; @@ -338,6 +341,22 @@ export class UserAvailabilityService { getCurrentSeats = withReporting(this._getCurrentSeats.bind(this), "getCurrentSeats"); + private async parseLimits(eventType: { durationLimits?: unknown; bookingLimits?: unknown } | null) { + const bookingLimits = + eventType?.bookingLimits && + typeof eventType.bookingLimits === "object" && + Object.keys(eventType.bookingLimits).length > 0 + ? parseBookingLimit(eventType.bookingLimits) + : null; + const durationLimits = + eventType?.durationLimits && + typeof eventType.durationLimits === "object" && + Object.keys(eventType.durationLimits).length > 0 + ? parseDurationLimit(eventType.durationLimits) + : null; + return { bookingLimits, durationLimits }; + } + /** This should be called getUsersWorkingHoursAndBusySlots (...and remaining seats, and final timezone) */ async _getUserAvailability( params: GetUserAvailabilityParams, @@ -356,6 +375,7 @@ export class UserAvailabilityService { bypassBusyCalendarTimes = false, silentlyHandleCalendarFailures = false, mode = "none", + withSource = false, } = params; log.debug( @@ -371,7 +391,6 @@ export class UserAvailabilityService { let eventType: EventType | null = initialData?.eventType || null; if (!eventType && eventTypeId) eventType = await this.getEventType(eventTypeId); - /* Current logic is if a booking is in a time slot mark it as busy, but seats can have more than one attendee so grab current bookings with a seats event type and display them on the calendar, even if they are full */ let currentSeats: CurrentSeats | null = initialData?.currentSeats || null; @@ -510,19 +529,7 @@ export class UserAvailabilityService { datesOutOfOffice: undefined, }; - const bookingLimits = - eventType?.bookingLimits && - typeof eventType.bookingLimits === "object" && - Object.keys(eventType.bookingLimits).length > 0 - ? parseBookingLimit(eventType.bookingLimits) - : null; - - const durationLimits = - eventType?.durationLimits && - typeof eventType.durationLimits === "object" && - Object.keys(eventType.durationLimits).length > 0 - ? parseDurationLimit(eventType.durationLimits) - : null; + const { bookingLimits, durationLimits } = await this.parseLimits(eventType); // TODO: only query what we need after applying limits (shrink date range) const getBusyTimesStart = dateFrom.toISOString(); @@ -533,11 +540,9 @@ export class UserAvailabilityService { : user.userLevelSelectedCalendars; let busyTimesFromLimits: EventBusyDetails[] = []; - if (initialData?.busyTimesFromLimits && initialData?.eventTypeForLimits) { busyTimesFromLimits = initialData.busyTimesFromLimits.get(user.id) || []; } else if (eventType && (bookingLimits || durationLimits)) { - // Fall back to individual query if not available in initialData busyTimesFromLimits = await getBusyTimesFromLimits( bookingLimits, durationLimits, @@ -612,18 +617,22 @@ export class UserAvailabilityService { }; } - const detailedBusyTimes: EventBusyDetails[] = [ + const detailedBusyTimesWithSource: EventBusyDetails[] = [ ...busyTimes.map((a) => ({ ...a, start: dayjs(a.start).toISOString(), end: dayjs(a.end).toISOString(), title: a.title, - source: params.withSource ? a.source : undefined, + source: a.source, })), ...busyTimesFromLimits, ...busyTimesFromTeamLimits, ]; + const detailedBusyTimes: UserAvailabilityBusyDetails[] = withSource + ? detailedBusyTimesWithSource + : detailedBusyTimesWithSource.map(({ source, ...rest }) => rest); + log.debug( `EventType: ${eventTypeId} | User: ${username} (ID: ${userId}) - usingSchedule: ${safeStringify({ chosenSchedule: schedule, @@ -657,6 +666,56 @@ export class UserAvailabilityService { return result; } + /** + * This fn mainly uses _getUserAvailability but sends busy times from limits too to _getUserAvailability as expected by _getUserAvailability. + */ + async getUserAvailabilityIncludingBusyTimesFromLimits( + params: GetUserAvailabilityParams, + initialData: Ensure + ): Promise { + const { user } = initialData || {}; + const { dateFrom, dateTo, eventTypeId } = params; + let eventType: EventType | null = initialData?.eventType || null; + if (!eventType && eventTypeId) eventType = await this.getEventType(eventTypeId); + + const { bookingLimits, durationLimits } = await this.parseLimits(eventType); + + let busyTimesFromLimitsBookings: EventBusyDetails[] | undefined = + initialData?.busyTimesFromLimitsBookings; + if (!busyTimesFromLimitsBookings && eventType && (bookingLimits || durationLimits)) { + const busyTimesService = getBusyTimesService(); + const { limitDateFrom, limitDateTo } = busyTimesService.getStartEndDateforLimitCheck( + dateFrom.toISOString(), + dateTo.toISOString(), + bookingLimits, + durationLimits + ); + + // For team events with booking/duration limits, fetch bookings for all team members + // to accurately calculate if limits are reached for the event + const userIdsForLimitCheck = + eventType.hosts && eventType.hosts.length > 0 + ? eventType.hosts.map((host) => host.user.id) + : [user.id]; + + busyTimesFromLimitsBookings = await busyTimesService.getBusyTimesForLimitChecks({ + userIds: userIdsForLimitCheck, + eventTypeId: eventType.id, + startDate: limitDateFrom.format(), + endDate: limitDateTo.format(), + rescheduleUid: initialData?.rescheduleUid ?? undefined, + bookingLimits, + durationLimits, + }); + } + const result = await this._getUserAvailability(params, { + ...initialData, + ...(eventType ? { eventType } : {}), + ...(busyTimesFromLimitsBookings ? { busyTimesFromLimitsBookings } : {}), + }); + return result; + } + getUserAvailability = withReporting(this._getUserAvailability.bind(this), "getUserAvailability"); getPeriodStartDatesBetween = withReporting( diff --git a/packages/features/availability/lib/getUserAvailabilityIncludingBusyTimesFromLimits.test.ts b/packages/features/availability/lib/getUserAvailabilityIncludingBusyTimesFromLimits.test.ts new file mode 100644 index 0000000000..8e565d6e5c --- /dev/null +++ b/packages/features/availability/lib/getUserAvailabilityIncludingBusyTimesFromLimits.test.ts @@ -0,0 +1,452 @@ +import dayjs from "@calcom/dayjs"; +import type { EventBusyDetails } from "@calcom/types/Calendar"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { EventType, GetUserAvailabilityInitialData } from "./getUserAvailability"; +import { UserAvailabilityService } from "./getUserAvailability"; + +vi.mock("@calcom/features/di/containers/BusyTimes", () => ({ + getBusyTimesService: vi.fn(() => mockBusyTimesService), +})); + +vi.mock("@calcom/features/busyTimes/lib/getBusyTimesFromLimits", () => ({ + getBusyTimesFromLimits: vi.fn().mockResolvedValue([]), + getBusyTimesFromTeamLimits: vi.fn().mockResolvedValue([]), +})); + +vi.mock("@calcom/app-store/_utils/getCalendar", () => ({ + getCalendar: vi.fn(), +})); + +vi.mock("@calcom/lib/holidays", () => ({ + getHolidayService: vi.fn(() => ({ + getHolidayDatesInRange: vi.fn().mockResolvedValue([]), + })), +})); + +const mockBusyTimesService = { + getStartEndDateforLimitCheck: vi.fn().mockReturnValue({ + limitDateFrom: dayjs("2025-01-01T00:00:00Z"), + limitDateTo: dayjs("2025-01-31T23:59:59Z"), + }), + getBusyTimesForLimitChecks: vi.fn().mockResolvedValue([]), + getBusyTimes: vi.fn().mockResolvedValue([]), +}; + +const mockDependencies: ConstructorParameters[0] = { + oooRepo: { + findUserOOODays: vi.fn().mockResolvedValue([]), + }, + bookingRepo: { + findAcceptedBookingByEventTypeId: vi.fn().mockResolvedValue([]), + }, + redisClient: { + get: vi.fn().mockResolvedValue(null), + set: vi.fn(), + }, + eventTypeRepo: { + findByIdForUserAvailability: vi.fn().mockResolvedValue(null), + findForSlots: vi.fn().mockResolvedValue(null), + }, + holidayRepo: { + findUserSettingsSelect: vi.fn().mockResolvedValue(null), + }, +}; + +const DATE_FROM = "2025-01-06T00:00:00Z"; +const DATE_TO = "2025-01-10T23:59:59Z"; +const DATE_FROM_ISO = "2025-01-06T00:00:00.000Z"; +const DATE_TO_ISO = "2025-01-10T23:59:59.000Z"; +const LIMIT_DATE_FROM_FORMATTED = "2025-01-01T00:00:00+00:00"; +const LIMIT_DATE_TO_FORMATTED = "2025-01-31T23:59:59+00:00"; + +const createMockUser = ( + overrides?: Partial> +): NonNullable => ({ + id: 1, + username: "testuser", + email: "test@example.com", + bufferTime: 0, + timeZone: "UTC", + availability: [ + { + id: 1, + userId: 1, + eventTypeId: null, + scheduleId: 1, + days: [1, 2, 3, 4, 5], + startTime: new Date("1970-01-01T09:00:00Z"), + endTime: new Date("1970-01-01T17:00:00Z"), + date: null, + }, + ], + timeFormat: 12, + defaultScheduleId: 1, + isPlatformManaged: false, + schedules: [ + { + id: 1, + availability: [ + { + days: [1, 2, 3, 4, 5], + startTime: new Date("1970-01-01T09:00:00Z"), + endTime: new Date("1970-01-01T17:00:00Z"), + date: null, + }, + ], + timeZone: "UTC", + }, + ], + credentials: [], + allSelectedCalendars: [], + userLevelSelectedCalendars: [], + travelSchedules: [], + ...overrides, +}); + +const createMockEventType = (overrides?: Partial>): NonNullable => ({ + id: 100, + length: 30, + seatsPerTimeSlot: null, + timeZone: null, + schedule: null, + availability: [], + hosts: [], + bookingLimits: null, + durationLimits: null, + metadata: null, + useEventLevelSelectedCalendars: false, + team: null, + parent: null, + schedulingType: null, + assignAllTeamMembers: false, + ...overrides, +}); + +const createParams = (overrides?: Record) => ({ + dateFrom: dayjs(DATE_FROM), + dateTo: dayjs(DATE_TO), + returnDateOverrides: false, + ...overrides, +}); + +describe("UserAvailabilityService.getUserAvailabilityIncludingBusyTimesFromLimits", () => { + let service: UserAvailabilityService; + + beforeEach(() => { + vi.clearAllMocks(); + service = new UserAvailabilityService(mockDependencies); + }); + + it("skips fetching limit busy times when busyTimesFromLimitsBookings is already in initialData", async () => { + const user = createMockUser(); + const eventType = createMockEventType({ bookingLimits: { PER_DAY: 3 } }); + const existingBusyTimes: EventBusyDetails[] = [ + { + start: "2025-01-06T10:00:00Z", + end: "2025-01-06T23:59:59Z", + source: "Event Booking Limit", + }, + ]; + + const result = await service.getUserAvailabilityIncludingBusyTimesFromLimits(createParams(), { + user, + eventType, + busyTimesFromLimitsBookings: existingBusyTimes, + }); + + expect(mockBusyTimesService.getBusyTimesForLimitChecks).not.toHaveBeenCalled(); + expect(result).toBeDefined(); + expect(result.busy).toBeDefined(); + }); + + it("preserves pre-existing busyTimesFromLimitsBookings from initialData instead of overwriting with empty array", async () => { + const user = createMockUser(); + const eventType = createMockEventType({ bookingLimits: { PER_DAY: 3 } }); + const existingBusyTimes: EventBusyDetails[] = [ + { + start: "2025-01-06T10:00:00Z", + end: "2025-01-06T23:59:59Z", + source: "Event Booking Limit for User: 3 per day", + }, + ]; + + const getUserAvailabilitySpy = vi.spyOn(service, "_getUserAvailability" as keyof typeof service); + + await service.getUserAvailabilityIncludingBusyTimesFromLimits(createParams(), { + user, + eventType, + busyTimesFromLimitsBookings: existingBusyTimes, + }); + + // Verify that the pre-existing busyTimesFromLimitsBookings is passed through + // and NOT overwritten with an empty array + expect(getUserAvailabilitySpy).toHaveBeenCalledWith( + createParams(), + expect.objectContaining({ + user, + eventType, + busyTimesFromLimitsBookings: existingBusyTimes, + }) + ); + }); + + it("fetches busy times when eventType has booking limits", async () => { + const user = createMockUser(); + const eventType = createMockEventType({ bookingLimits: { PER_DAY: 3 } }); + + await service.getUserAvailabilityIncludingBusyTimesFromLimits(createParams({ eventTypeId: 100 }), { + user, + eventType, + }); + + expect(mockBusyTimesService.getStartEndDateforLimitCheck).toHaveBeenCalledWith( + DATE_FROM_ISO, + DATE_TO_ISO, + { PER_DAY: 3 }, + null + ); + expect(mockBusyTimesService.getBusyTimesForLimitChecks).toHaveBeenCalledWith({ + userIds: [1], + eventTypeId: 100, + startDate: LIMIT_DATE_FROM_FORMATTED, + endDate: LIMIT_DATE_TO_FORMATTED, + rescheduleUid: undefined, + bookingLimits: { PER_DAY: 3 }, + durationLimits: null, + }); + }); + + it("fetches busy times when eventType has duration limits", async () => { + const user = createMockUser(); + const eventType = createMockEventType({ durationLimits: { PER_WEEK: 120 } }); + + await service.getUserAvailabilityIncludingBusyTimesFromLimits(createParams(), { + user, + eventType, + }); + + expect(mockBusyTimesService.getStartEndDateforLimitCheck).toHaveBeenCalledWith( + DATE_FROM_ISO, + DATE_TO_ISO, + null, + { PER_WEEK: 120 } + ); + expect(mockBusyTimesService.getBusyTimesForLimitChecks).toHaveBeenCalledWith({ + userIds: [1], + eventTypeId: 100, + startDate: LIMIT_DATE_FROM_FORMATTED, + endDate: LIMIT_DATE_TO_FORMATTED, + rescheduleUid: undefined, + bookingLimits: null, + durationLimits: { PER_WEEK: 120 }, + }); + }); + + it("passes both bookingLimits and durationLimits separately to getStartEndDateforLimitCheck", async () => { + const user = createMockUser(); + const eventType = createMockEventType({ + bookingLimits: { PER_DAY: 3 }, + durationLimits: { PER_WEEK: 120 }, + }); + + await service.getUserAvailabilityIncludingBusyTimesFromLimits(createParams(), { + user, + eventType, + }); + + expect(mockBusyTimesService.getStartEndDateforLimitCheck).toHaveBeenCalledWith( + DATE_FROM_ISO, + DATE_TO_ISO, + { PER_DAY: 3 }, + { PER_WEEK: 120 } + ); + }); + + it("skips fetching limit busy times when eventType has no limits", async () => { + const user = createMockUser(); + const eventType = createMockEventType(); + + await service.getUserAvailabilityIncludingBusyTimesFromLimits(createParams(), { + user, + eventType, + }); + + expect(mockBusyTimesService.getStartEndDateforLimitCheck).not.toHaveBeenCalled(); + expect(mockBusyTimesService.getBusyTimesForLimitChecks).not.toHaveBeenCalled(); + }); + + it("skips fetching limit busy times when no eventType exists", async () => { + const user = createMockUser(); + + await service.getUserAvailabilityIncludingBusyTimesFromLimits(createParams(), { + user, + }); + + expect(mockBusyTimesService.getBusyTimesForLimitChecks).not.toHaveBeenCalled(); + }); + + it("uses host user IDs for team events with booking limits", async () => { + const user = createMockUser(); + const eventType = createMockEventType({ + bookingLimits: { PER_DAY: 5 }, + hosts: [ + { user: { id: 1, email: "host1@test.com" }, schedule: null }, + { user: { id: 2, email: "host2@test.com" }, schedule: null }, + { user: { id: 3, email: "host3@test.com" }, schedule: null }, + ], + }); + + await service.getUserAvailabilityIncludingBusyTimesFromLimits(createParams(), { + user, + eventType, + }); + + expect(mockBusyTimesService.getBusyTimesForLimitChecks).toHaveBeenCalledWith({ + userIds: [1, 2, 3], + eventTypeId: 100, + startDate: LIMIT_DATE_FROM_FORMATTED, + endDate: LIMIT_DATE_TO_FORMATTED, + rescheduleUid: undefined, + bookingLimits: { PER_DAY: 5 }, + durationLimits: null, + }); + }); + + it("uses only the user ID for solo events with booking limits", async () => { + const user = createMockUser({ id: 42 }); + const eventType = createMockEventType({ + bookingLimits: { PER_DAY: 2 }, + hosts: [], + }); + + await service.getUserAvailabilityIncludingBusyTimesFromLimits(createParams(), { + user, + eventType, + }); + + expect(mockBusyTimesService.getBusyTimesForLimitChecks).toHaveBeenCalledWith({ + userIds: [42], + eventTypeId: 100, + startDate: LIMIT_DATE_FROM_FORMATTED, + endDate: LIMIT_DATE_TO_FORMATTED, + rescheduleUid: undefined, + bookingLimits: { PER_DAY: 2 }, + durationLimits: null, + }); + }); + + it("fetches eventType by eventTypeId when not provided in initialData", async () => { + const user = createMockUser(); + const fetchedEventType = createMockEventType({ bookingLimits: { PER_DAY: 1 } }); + + vi.mocked(mockDependencies.eventTypeRepo.findByIdForUserAvailability).mockResolvedValueOnce( + fetchedEventType + ); + + await service.getUserAvailabilityIncludingBusyTimesFromLimits(createParams({ eventTypeId: 100 }), { + user, + }); + + expect(mockDependencies.eventTypeRepo.findByIdForUserAvailability).toHaveBeenCalledWith({ id: 100 }); + expect(mockBusyTimesService.getBusyTimesForLimitChecks).toHaveBeenCalledWith({ + userIds: [1], + eventTypeId: 100, + startDate: LIMIT_DATE_FROM_FORMATTED, + endDate: LIMIT_DATE_TO_FORMATTED, + rescheduleUid: undefined, + bookingLimits: { PER_DAY: 1 }, + durationLimits: null, + }); + }); + + it("passes fetched busyTimesFromLimitsBookings to _getUserAvailability", async () => { + const user = createMockUser(); + const eventType = createMockEventType({ bookingLimits: { PER_DAY: 3 } }); + const fetchedBusyTimes: EventBusyDetails[] = [ + { + start: "2025-01-06T09:00:00Z", + end: "2025-01-06T10:00:00Z", + source: "eventType-100-booking-1", + title: "Test Booking", + }, + ]; + + mockBusyTimesService.getBusyTimesForLimitChecks.mockResolvedValueOnce(fetchedBusyTimes); + + const getUserAvailabilitySpy = vi.spyOn(service, "_getUserAvailability" as keyof typeof service); + + await service.getUserAvailabilityIncludingBusyTimesFromLimits(createParams(), { + user, + eventType, + }); + + expect(getUserAvailabilitySpy).toHaveBeenCalledWith( + createParams(), + expect.objectContaining({ + user, + eventType, + busyTimesFromLimitsBookings: fetchedBusyTimes, + }) + ); + }); + + it("does not pass busyTimesFromLimitsBookings when no limits and no initialData bookings", async () => { + const user = createMockUser(); + const eventType = createMockEventType(); // no limits + + const getUserAvailabilitySpy = vi.spyOn(service, "_getUserAvailability" as keyof typeof service); + + await service.getUserAvailabilityIncludingBusyTimesFromLimits(createParams(), { + user, + eventType, + }); + + // When there are no limits and no pre-existing busyTimesFromLimitsBookings, + // the property should not be spread into the call + const callArgs = getUserAvailabilitySpy.mock.calls[0]; + expect(callArgs[1]).not.toHaveProperty("busyTimesFromLimitsBookings"); + }); + + it("passes fetched eventType to _getUserAvailability to avoid duplicate DB query", async () => { + const user = createMockUser(); + const fetchedEventType = createMockEventType({ bookingLimits: { PER_DAY: 1 } }); + + vi.mocked(mockDependencies.eventTypeRepo.findByIdForUserAvailability).mockResolvedValueOnce( + fetchedEventType + ); + + const getUserAvailabilitySpy = vi.spyOn(service, "_getUserAvailability" as keyof typeof service); + + await service.getUserAvailabilityIncludingBusyTimesFromLimits(createParams({ eventTypeId: 100 }), { + user, + }); + + expect(getUserAvailabilitySpy).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + eventType: fetchedEventType, + }) + ); + }); + + it("passes rescheduleUid to getBusyTimesForLimitChecks when provided", async () => { + const user = createMockUser(); + const eventType = createMockEventType({ bookingLimits: { PER_DAY: 3 } }); + + await service.getUserAvailabilityIncludingBusyTimesFromLimits(createParams(), { + user, + eventType, + rescheduleUid: "reschedule-uid-123", + }); + + expect(mockBusyTimesService.getBusyTimesForLimitChecks).toHaveBeenCalledWith({ + userIds: [1], + eventTypeId: 100, + startDate: LIMIT_DATE_FROM_FORMATTED, + endDate: LIMIT_DATE_TO_FORMATTED, + rescheduleUid: "reschedule-uid-123", + bookingLimits: { PER_DAY: 3 }, + durationLimits: null, + }); + }); +}); diff --git a/packages/features/busyTimes/lib/getBusyTimesFromLimits.ts b/packages/features/busyTimes/lib/getBusyTimesFromLimits.ts index d0b16d783e..8320ae4fce 100644 --- a/packages/features/busyTimes/lib/getBusyTimesFromLimits.ts +++ b/packages/features/busyTimes/lib/getBusyTimesFromLimits.ts @@ -6,7 +6,7 @@ import { getCheckBookingLimitsService } from "@calcom/features/di/containers/Boo import { getBusyTimesService } from "@calcom/features/di/containers/BusyTimes"; import { descendingLimitKeys, intervalLimitKeyToUnit } from "@calcom/lib/intervalLimits/intervalLimit"; import type { IntervalLimit } from "@calcom/lib/intervalLimits/intervalLimitSchema"; -import LimitManager from "@calcom/lib/intervalLimits/limitManager"; +import LimitManager, { LimitSources } from "@calcom/lib/intervalLimits/limitManager"; import { isBookingWithinPeriod } from "@calcom/lib/intervalLimits/utils"; import { getPeriodStartDatesBetween } from "@calcom/lib/intervalLimits/utils/getPeriodStartDatesBetween"; import { withReporting } from "@calcom/lib/sentryWrapper"; @@ -108,6 +108,10 @@ const _getBusyTimesFromBookingLimits = async (params: { for (const periodStart of periodStartDates) { if (limitManager.isAlreadyBusy(periodStart, unit)) continue; + const { title, source } = teamId + ? LimitSources.teamBookingLimit({ limit, unit }) + : LimitSources.eventBookingLimit({ limit, unit }); + // special handling of yearly limits to improve performance if (unit === "year") { try { @@ -124,7 +128,12 @@ const _getBusyTimesFromBookingLimits = async (params: { timeZone, }); } catch (_) { - limitManager.addBusyTime(periodStart, unit); + limitManager.addBusyTime({ + start: periodStart, + unit, + title, + source, + }); if (periodStartDates.every((start) => limitManager.isAlreadyBusy(start, unit))) { return; } @@ -142,7 +151,12 @@ const _getBusyTimesFromBookingLimits = async (params: { } totalBookings++; if (totalBookings >= limit) { - limitManager.addBusyTime(periodStart, unit); + limitManager.addBusyTime({ + start: periodStart, + unit, + title, + source, + }); break; } } @@ -172,8 +186,15 @@ const _getBusyTimesFromDurationLimits = async ( const selectedDuration = (duration || eventType.length) ?? 0; + const { title, source } = LimitSources.eventDurationLimit({ limit, unit }); + if (selectedDuration > limit) { - limitManager.addBusyTime(periodStart, unit); + limitManager.addBusyTime({ + start: periodStart, + unit, + title, + source, + }); continue; } @@ -187,7 +208,12 @@ const _getBusyTimesFromDurationLimits = async ( rescheduleUid, }); if (totalYearlyDuration + selectedDuration > limit) { - limitManager.addBusyTime(periodStart, unit); + limitManager.addBusyTime({ + start: periodStart, + unit, + title, + source, + }); if (periodStartDates.every((start) => limitManager.isAlreadyBusy(start, unit))) { return; } @@ -205,7 +231,12 @@ const _getBusyTimesFromDurationLimits = async ( } totalDuration += dayjs(booking.end).diff(dayjs(booking.start), "minute"); if (totalDuration > limit) { - limitManager.addBusyTime(periodStart, unit); + limitManager.addBusyTime({ + start: periodStart, + unit, + title, + source, + }); break; } } diff --git a/packages/features/busyTimes/services/getBusyTimes.ts b/packages/features/busyTimes/services/getBusyTimes.ts index d3d442261b..3981466bbc 100644 --- a/packages/features/busyTimes/services/getBusyTimes.ts +++ b/packages/features/busyTimes/services/getBusyTimes.ts @@ -150,13 +150,17 @@ export class BusyTimesService { if (minutesToBlockBeforeEvent) { aggregate.push({ start: dayjs(startTime).subtract(minutesToBlockBeforeEvent, "minute").toDate(), - end: dayjs(startTime).toDate(), // The event starts after the buffer + end: dayjs(startTime).toDate(), + title: "busy_time.buffer_time", + source: "Buffer Time for seated event (before)", }); } if (minutesToBlockAfterEvent) { aggregate.push({ - start: dayjs(endTime).toDate(), // The event ends before the buffer + start: dayjs(endTime).toDate(), end: dayjs(endTime).add(minutesToBlockAfterEvent, "minute").toDate(), + title: "busy_time.buffer_time", + source: "Buffer Time for seated event (after)", }); } return aggregate; @@ -251,6 +255,7 @@ export class BusyTimesService { ...value, end: dayjs(value.end), start: dayjs(value.start), + source: value.source ?? "busy_time.calendar", })), openSeatsDateRanges ); diff --git a/packages/features/calendars/weeklyview/components/event/EventList.tsx b/packages/features/calendars/weeklyview/components/event/EventList.tsx index e173bd2857..d8785086c9 100644 --- a/packages/features/calendars/weeklyview/components/event/EventList.tsx +++ b/packages/features/calendars/weeklyview/components/event/EventList.tsx @@ -107,6 +107,7 @@ export function EventList({ day }: Props) { )} data-testid={event.options?.["data-test-id"]} data-calendar-event-id={event.id} + data-calendar-event-source={event.source} onMouseEnter={() => setHoveredEventId(event.id)} onMouseLeave={() => setHoveredEventId(null)} style={{ diff --git a/packages/features/schedules/lib/date-ranges.ts b/packages/features/schedules/lib/date-ranges.ts index be3d6b566e..7fdc167b37 100644 --- a/packages/features/schedules/lib/date-ranges.ts +++ b/packages/features/schedules/lib/date-ranges.ts @@ -415,11 +415,16 @@ export function intersect(ranges: DateRange[][]): DateRange[] { return commonAvailability.map(({ start, end }) => ({ start, end })); } -export function subtract( - sourceRanges: (DateRange & { [x: string]: unknown })[], - excludedRanges: DateRange[] -) { - const result = []; +type SubtractedRange = Omit & { + start: TSourceRange["start"]; + end: TSourceRange["end"]; +}; + +export function subtract( + sourceRanges: TSourceRange[], + excludedRanges: TExcludedRange[] +): SubtractedRange[] { + const result: SubtractedRange[] = []; const sortedExcludedRanges = [...excludedRanges].sort((a, b) => a.start.valueOf() - b.start.valueOf()); for (const { start: sourceStart, end: sourceEnd, ...passThrough } of sourceRanges) { diff --git a/packages/i18n/locales/en/common.json b/packages/i18n/locales/en/common.json index a23c38047e..fc6ce420a4 100644 --- a/packages/i18n/locales/en/common.json +++ b/packages/i18n/locales/en/common.json @@ -4653,6 +4653,14 @@ "error_enabling_feature": "Error enabling feature. Please try again.", "set_organizer_as_contact_owner": "Set booking organizer as contact owner", "overwrite_existing_contact_owner": "Overwrite existing contact owner", + "busy_time": { + "busy": "Busy", + "event_booking_limit": "Event Booking Limit", + "event_duration_limit": "Event Duration Limit", + "team_booking_limit": "Team Booking Limit", + "buffer_time": "Buffer Time", + "calendar": "Calendar" + }, "invoice_number": "Invoice #", "payment_method": "Payment Method", "download_pdf": "Download PDF", diff --git a/packages/lib/intervalLimits/limitManager.ts b/packages/lib/intervalLimits/limitManager.ts index 7940184395..fa043ddac4 100644 --- a/packages/lib/intervalLimits/limitManager.ts +++ b/packages/lib/intervalLimits/limitManager.ts @@ -1,15 +1,35 @@ import type { Dayjs } from "@calcom/dayjs"; -import type { EventBusyDate } from "@calcom/types/Calendar"; - +import type { EventBusyDetails } from "@calcom/types/Calendar"; import type { IntervalLimitUnit } from "./intervalLimitSchema"; type BusyMapKey = `${IntervalLimitUnit}-${ReturnType}`; +/** + * Limit sources with both title and source information + * Returns user-facing title (translation key) and internal debug string + */ +export const LimitSources = { + eventBookingLimit: (params: { limit: number; unit: IntervalLimitUnit }) => ({ + title: "busy_time.event_booking_limit", + source: `Event Booking Limit for User: ${params.limit} per ${params.unit}`, + }), + + eventDurationLimit: (params: { limit: number; unit: IntervalLimitUnit }) => ({ + title: "busy_time.event_duration_limit", + source: `Event Duration Limit for User: ${params.limit} minutes per ${params.unit}`, + }), + + teamBookingLimit: (params: { limit: number; unit: IntervalLimitUnit }) => ({ + title: "busy_time.team_booking_limit", + source: `Team Booking Limit: ${params.limit} per ${params.unit}`, + }), +} as const; + /** * Helps create, check, and return busy times from limits (with parallel support) */ export default class LimitManager { - private busyMap: Map = new Map(); + private busyMap: Map = new Map(); /** * Creates a busy map key @@ -48,13 +68,26 @@ export default class LimitManager { } /** - * Adds a new busy time + * Adds a new busy time with title and source + * @param params.start - Start time of the busy period + * @param params.unit - Interval unit (day, week, month, year) + * @param params.timeZone - Optional timezone + * @param params.title - User-facing translation key (displayed in calendars) + * @param params.source - Internal debug string (for logging/troubleshooting) */ - addBusyTime(start: Dayjs, unit: IntervalLimitUnit, timeZone?: string) { - const tzStart = timeZone ? start.tz(timeZone) : start; - this.busyMap.set(`${unit}-${tzStart.toISOString()}`, { + addBusyTime(params: { + start: Dayjs; + unit: IntervalLimitUnit; + timeZone?: string; + title: string; + source: string; + }) { + const tzStart = params.timeZone ? params.start.tz(params.timeZone) : params.start; + this.busyMap.set(`${params.unit}-${tzStart.toISOString()}`, { start: tzStart.toISOString(), - end: tzStart.endOf(unit).toISOString(), + end: tzStart.endOf(params.unit).toISOString(), + title: params.title, + source: params.source, }); } diff --git a/packages/trpc/server/routers/viewer/availability/user.handler.ts b/packages/trpc/server/routers/viewer/availability/user.handler.ts index 1daa0531a0..78ce3e9a96 100644 --- a/packages/trpc/server/routers/viewer/availability/user.handler.ts +++ b/packages/trpc/server/routers/viewer/availability/user.handler.ts @@ -1,10 +1,9 @@ +import { findUsersForAvailabilityCheck } from "@calcom/features/availability/lib/findUsersForAvailabilityCheck"; import { getUserAvailabilityService } from "@calcom/features/di/containers/GetUserAvailability"; - +import { TRPCError } from "@trpc/server"; import type { TrpcSessionUser } from "../../../types"; import type { TUserInputSchema } from "./user.schema"; -import { findUsersForAvailabilityCheck } from "@calcom/features/availability/lib/findUsersForAvailabilityCheck"; - type UserOptions = { ctx: { user: NonNullable; @@ -23,7 +22,8 @@ function getUser(username: string) { export const userHandler = async ({ input }: UserOptions) => { const userAvailabilityService = getUserAvailabilityService(); const user = await getUser(input.username); - return userAvailabilityService.getUserAvailability( + if (!user) throw new TRPCError({ code: "NOT_FOUND", message: "User not found" }); + return userAvailabilityService.getUserAvailabilityIncludingBusyTimesFromLimits( { returnDateOverrides: true, bypassBusyCalendarTimes: false, ...input }, { user, diff --git a/packages/trpc/server/routers/viewer/slots/util.ts b/packages/trpc/server/routers/viewer/slots/util.ts index 14fba5d1da..433672093b 100644 --- a/packages/trpc/server/routers/viewer/slots/util.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -37,7 +37,7 @@ import { descendingLimitKeys, intervalLimitKeyToUnit } from "@calcom/lib/interva import type { IntervalLimit } from "@calcom/lib/intervalLimits/intervalLimitSchema"; import { parseBookingLimit } from "@calcom/lib/intervalLimits/isBookingLimits"; import { parseDurationLimit } from "@calcom/lib/intervalLimits/isDurationLimits"; -import LimitManager from "@calcom/lib/intervalLimits/limitManager"; +import LimitManager, { LimitSources } from "@calcom/lib/intervalLimits/limitManager"; import { isBookingWithinPeriod } from "@calcom/lib/intervalLimits/utils"; import { BookingDateInPastError, @@ -444,6 +444,8 @@ export class AvailableSlotsService { const periodEnd = periodStart.endOf(unit); let totalBookings = 0; + const { title, source } = LimitSources.eventBookingLimit({ limit, unit }); + for (const booking of busyTimesFromLimitsBookings) { if (!isBookingWithinPeriod(booking, periodStart, periodEnd, timeZone)) { continue; @@ -451,7 +453,13 @@ export class AvailableSlotsService { totalBookings++; if (totalBookings >= limit) { - globalLimitManager.addBusyTime(periodStart, unit, timeZone); + globalLimitManager.addBusyTime({ + start: periodStart, + unit, + timeZone, + title, + source, + }); break; } } @@ -503,6 +511,8 @@ export class AvailableSlotsService { for (const periodStart of periodStartDates) { if (limitManager.isAlreadyBusy(periodStart, unit, timeZone)) continue; + const { title, source } = LimitSources.eventBookingLimit({ limit, unit }); + if (unit === "year") { try { await this.dependencies.checkBookingLimitsService.checkBookingLimit({ @@ -515,7 +525,13 @@ export class AvailableSlotsService { timeZone, }); } catch { - limitManager.addBusyTime(periodStart, unit, timeZone); + limitManager.addBusyTime({ + start: periodStart, + unit, + timeZone, + title, + source, + }); if ( periodStartDates.every((start: Dayjs) => limitManager.isAlreadyBusy(start, unit, timeZone)) ) { @@ -535,7 +551,13 @@ export class AvailableSlotsService { totalBookings++; if (totalBookings >= limit) { - limitManager.addBusyTime(periodStart, unit, timeZone); + limitManager.addBusyTime({ + start: periodStart, + unit, + timeZone, + title, + source, + }); break; } } @@ -561,8 +583,16 @@ export class AvailableSlotsService { const selectedDuration = (duration || eventType.length) ?? 0; + const { title, source } = LimitSources.eventDurationLimit({ limit, unit }); + if (selectedDuration > limit) { - limitManager.addBusyTime(periodStart, unit, timeZone); + limitManager.addBusyTime({ + start: periodStart, + unit, + timeZone, + title, + source, + }); continue; } @@ -581,7 +611,13 @@ export class AvailableSlotsService { } const totalYearlyDuration = yearlyDurationTotals.get(yearKey) ?? 0; if (totalYearlyDuration + selectedDuration > limit) { - limitManager.addBusyTime(periodStart, unit, timeZone); + limitManager.addBusyTime({ + start: periodStart, + unit, + timeZone, + title, + source, + }); if ( periodStartDates.every((start: Dayjs) => limitManager.isAlreadyBusy(start, unit, timeZone)) ) { @@ -600,7 +636,13 @@ export class AvailableSlotsService { } totalDuration += dayjs(booking.end).diff(dayjs(booking.start), "minute"); if (totalDuration > limit) { - limitManager.addBusyTime(periodStart, unit, timeZone); + limitManager.addBusyTime({ + start: periodStart, + unit, + timeZone, + title, + source, + }); break; } } @@ -673,6 +715,8 @@ export class AvailableSlotsService { const periodEnd = periodStart.endOf(unit); let totalBookings = 0; + const { title, source } = LimitSources.teamBookingLimit({ limit, unit }); + for (const booking of busyTimes) { if (!isBookingWithinPeriod(booking, periodStart, periodEnd, timeZone)) { continue; @@ -680,7 +724,13 @@ export class AvailableSlotsService { totalBookings++; if (totalBookings >= limit) { - globalLimitManager.addBusyTime(periodStart, unit, timeZone); + globalLimitManager.addBusyTime({ + start: periodStart, + unit, + timeZone, + title, + source, + }); break; } } @@ -710,6 +760,8 @@ export class AvailableSlotsService { for (const periodStart of periodStartDates) { if (limitManager.isAlreadyBusy(periodStart, unit, timeZone)) continue; + const { title, source } = LimitSources.teamBookingLimit({ limit, unit }); + if (unit === "year") { try { await this.dependencies.checkBookingLimitsService.checkBookingLimit({ @@ -723,7 +775,13 @@ export class AvailableSlotsService { timeZone, }); } catch { - limitManager.addBusyTime(periodStart, unit, timeZone); + limitManager.addBusyTime({ + start: periodStart, + unit, + timeZone, + title, + source, + }); if ( periodStartDates.every((start: Dayjs) => limitManager.isAlreadyBusy(start, unit, timeZone)) ) { @@ -743,7 +801,13 @@ export class AvailableSlotsService { totalBookings++; if (totalBookings >= limit) { - limitManager.addBusyTime(periodStart, unit, timeZone); + limitManager.addBusyTime({ + start: periodStart, + unit, + timeZone, + title, + source, + }); break; } } diff --git a/packages/types/Calendar.d.ts b/packages/types/Calendar.d.ts index 8438c5c36d..eb9ac6db1b 100644 --- a/packages/types/Calendar.d.ts +++ b/packages/types/Calendar.d.ts @@ -62,7 +62,7 @@ export type EventBusyDate = { export type EventBusyDetails = EventBusyDate & { title?: string; - source?: string | null; + source: string; userId?: number | null; };