Files
calendar/packages/features/busyTimes/lib/getBusyTimesFromLimits.ts
T
Hariom BalharaGitHubbot_apkDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>Udit Takkar
a4621da2be feat: make source required on EventBusyDetails for Troubleshooter display (#27088)
* feat: make source required on EventBusyDetails for Troubleshooter display

- Make source a required property on EventBusyDetails type
- Update LimitManager to accept and store source when adding busy times
- Add user-friendly source names for all busy time types:
  - 'Booking Limit' for booking limit busy times
  - 'Duration Limit' for duration limit busy times
  - 'Team Booking Limit' for team booking limit busy times
  - 'Buffer Time' for seated event buffer times
  - 'Calendar' for external calendar busy times
- Ensure all entries in detailedBusyTimes have source set
- Cover includeManagedEventsInLimits and teamBookingLimits branches

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* feat: add limit value and unit metadata to busy time sources

- Include limit value and unit in source strings for Troubleshooter display
- Booking Limit: shows as 'Booking Limit: 5 per day'
- Duration Limit: shows as 'Duration Limit: 120 min per week'
- Team Booking Limit: shows as 'Team Booking Limit: 10 per month'
- Preserves existing calendar sources (e.g., 'google-calendar')

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* feat: enhance busy time management with new limit sources

- Introduced new limit sources for busy times, including event booking and duration limits, with user-friendly titles for display.
- Updated LimitManager to accept and store detailed busy time information, including title and source.
- Refactored busy time addition logic across various services to utilize the new structure, improving clarity and maintainability.

* fixes

* feat: conditionally include source and translate busy time titles

- Add withSource parameter to conditionally include/exclude source from response
- Translate busy time titles on frontend using useLocale hook
- Source is only included when withSource=true (for Troubleshooter display)

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* feat: enhance user availability service and busy time handling

- Updated LargeCalendar component to include event ID check for enabling busy times.
- Added translation for "busy" in common.json for better user experience.
- Refactored getUserAvailability service to include new method for fetching user availability with busy times from limits.
- Introduced parseLimits function to streamline booking and duration limit parsing.
- Improved error handling in user handler for better user feedback.

* refactor: remove unnecessary timeZone: undefined from addBusyTime calls

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* feat: add buffer_time and calendar translation keys for busy times

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* feat: add event source to calendar components and improve busy time handling

- Updated EventList component to include event source in data attributes for better tracking.
- Enhanced LargeCalendar component to pass event

* fix: add missing source property to Date Override calendar event

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* fix: use 'date-override' as source for Date Override calendar events

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* Avoid type assertion

* fix: pass both bookingLimits and durationLimits to getStartEndDateforLimitCheck (#27898)

* test: add unit tests for getUserAvailabilityIncludingBusyTimesFromLimits

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* fix: pass both bookingLimits and durationLimits to getStartEndDateforLimitCheck

- Fix bug where bookingLimits || durationLimits was passed as single param
- Skip getBusyTimesForLimitChecks when eventType has no limits
- Remove as never casts, use proper typing for mock dependencies
- Replace expect.any(String) with exact ISO date assertions

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* refactor: replace loose assertions with exact values in tests

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix: address review feedback on busy time sources

- Fix t("busy") fallback to t("busy_time.busy") for correct translation lookup
- Add missing title property to buffer time entries for Troubleshooter display
- Use descriptive debug strings for buffer time source field
- Fix import ordering in LargeCalendar.tsx

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-Authored-By: unknown <>

* fix: preserve pre-existing busyTimesFromLimitsBookings from initialData

Address review feedback from @hariombalhara (comment #30, #31):

- Initialize busyTimesFromLimitsBookings from initialData instead of []
  to avoid silently overwriting pre-existing data with an empty array
- Use conditional spread to only include busyTimesFromLimitsBookings
  when it has a value
- Add test verifying pre-existing busyTimesFromLimitsBookings is
  preserved and passed through to _getUserAvailability
- Add test verifying busyTimesFromLimitsBookings is not passed when
  there are no limits and no initialData bookings

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-Authored-By: bot_apk <apk@cognition.ai>

* fix: pass fetched eventType to _getUserAvailability to avoid duplicate DB query

Addresses Devin Review comment r2863593564: the wrapper method fetches
eventType but wasn't passing it through, causing _getUserAvailability to
re-fetch the same eventType from the database.

Also adds a test verifying eventType is forwarded correctly.

Co-Authored-By: bot_apk <apk@cognition.ai>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: bot_apk <apk@cognition.ai>
2026-03-06 14:53:14 -03:00

316 lines
9.2 KiB
TypeScript

import type { Dayjs } from "@calcom/dayjs";
import dayjs from "@calcom/dayjs";
import type { EventType } from "@calcom/features/availability/lib/getUserAvailability";
import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository";
import { getCheckBookingLimitsService } from "@calcom/features/di/containers/BookingLimits";
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, { 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";
import { performance } from "@calcom/lib/server/perfObserver";
import prisma from "@calcom/prisma";
import type { EventBusyDetails } from "@calcom/types/Calendar";
const _getBusyTimesFromLimits = async (
bookingLimits: IntervalLimit | null,
durationLimits: IntervalLimit | null,
dateFrom: Dayjs,
dateTo: Dayjs,
duration: number | undefined,
eventType: NonNullable<EventType>,
bookings: EventBusyDetails[],
timeZone: string,
rescheduleUid?: string
) => {
performance.mark("limitsStart");
// shared amongst limiters to prevent processing known busy periods
const limitManager = new LimitManager();
// run this first, as counting bookings should always run faster..
if (bookingLimits) {
performance.mark("bookingLimitsStart");
await getBusyTimesFromBookingLimits({
bookings,
bookingLimits,
dateFrom,
dateTo,
eventTypeId: eventType.id,
limitManager,
rescheduleUid,
timeZone,
});
performance.mark("bookingLimitsEnd");
performance.measure(`checking booking limits took $1'`, "bookingLimitsStart", "bookingLimitsEnd");
}
// ..than adding up durations (especially for the whole year)
if (durationLimits) {
performance.mark("durationLimitsStart");
await getBusyTimesFromDurationLimits(
bookings,
durationLimits,
dateFrom,
dateTo,
duration,
eventType,
limitManager,
timeZone,
rescheduleUid
);
performance.mark("durationLimitsEnd");
performance.measure(`checking duration limits took $1'`, "durationLimitsStart", "durationLimitsEnd");
}
performance.mark("limitsEnd");
performance.measure(`checking all limits took $1'`, "limitsStart", "limitsEnd");
return limitManager.getBusyTimes();
};
const _getBusyTimesFromBookingLimits = async (params: {
bookings: EventBusyDetails[];
bookingLimits: IntervalLimit;
dateFrom: Dayjs;
dateTo: Dayjs;
limitManager: LimitManager;
rescheduleUid?: string;
eventTypeId?: number;
teamId?: number;
user?: { id: number; email: string };
includeManagedEvents?: boolean;
timeZone?: string | null;
}) => {
const {
bookings,
bookingLimits,
dateFrom,
dateTo,
limitManager,
eventTypeId,
teamId,
user,
rescheduleUid,
includeManagedEvents = false,
timeZone,
} = params;
for (const key of descendingLimitKeys) {
const limit = bookingLimits?.[key];
if (!limit) continue;
const unit = intervalLimitKeyToUnit(key);
const periodStartDates = getPeriodStartDatesBetween(dateFrom, dateTo, unit);
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 {
const checkBookingLimitsService = getCheckBookingLimitsService();
await checkBookingLimitsService.checkBookingLimit({
eventStartDate: periodStart.toDate(),
limitingNumber: limit,
eventId: eventTypeId,
key,
teamId,
user,
rescheduleUid,
includeManagedEvents,
timeZone,
});
} catch (_) {
limitManager.addBusyTime({
start: periodStart,
unit,
title,
source,
});
if (periodStartDates.every((start) => limitManager.isAlreadyBusy(start, unit))) {
return;
}
}
continue;
}
const periodEnd = periodStart.endOf(unit);
let totalBookings = 0;
for (const booking of bookings) {
// consider booking part of period independent of end date
if (!isBookingWithinPeriod(booking, periodStart, periodEnd, timeZone || "UTC")) {
continue;
}
totalBookings++;
if (totalBookings >= limit) {
limitManager.addBusyTime({
start: periodStart,
unit,
title,
source,
});
break;
}
}
}
}
};
const _getBusyTimesFromDurationLimits = async (
bookings: EventBusyDetails[],
durationLimits: IntervalLimit,
dateFrom: Dayjs,
dateTo: Dayjs,
duration: number | undefined,
eventType: NonNullable<EventType>,
limitManager: LimitManager,
timeZone: string,
rescheduleUid?: string
) => {
for (const key of descendingLimitKeys) {
const limit = durationLimits?.[key];
if (!limit) continue;
const unit = intervalLimitKeyToUnit(key);
const periodStartDates = getPeriodStartDatesBetween(dateFrom, dateTo, unit);
for (const periodStart of periodStartDates) {
if (limitManager.isAlreadyBusy(periodStart, unit)) continue;
const selectedDuration = (duration || eventType.length) ?? 0;
const { title, source } = LimitSources.eventDurationLimit({ limit, unit });
if (selectedDuration > limit) {
limitManager.addBusyTime({
start: periodStart,
unit,
title,
source,
});
continue;
}
// special handling of yearly limits to improve performance
if (unit === "year") {
const bookingRepo = new BookingRepository(prisma);
const totalYearlyDuration = await bookingRepo.getTotalBookingDuration({
eventId: eventType.id,
startDate: periodStart.toDate(),
endDate: periodStart.endOf(unit).toDate(),
rescheduleUid,
});
if (totalYearlyDuration + selectedDuration > limit) {
limitManager.addBusyTime({
start: periodStart,
unit,
title,
source,
});
if (periodStartDates.every((start) => limitManager.isAlreadyBusy(start, unit))) {
return;
}
}
continue;
}
const periodEnd = periodStart.endOf(unit);
let totalDuration = selectedDuration;
for (const booking of bookings) {
// consider booking part of period independent of end date
if (!isBookingWithinPeriod(booking, periodStart, periodEnd, timeZone || "UTC")) {
continue;
}
totalDuration += dayjs(booking.end).diff(dayjs(booking.start), "minute");
if (totalDuration > limit) {
limitManager.addBusyTime({
start: periodStart,
unit,
title,
source,
});
break;
}
}
}
}
};
const getBusyTimesFromDurationLimits = withReporting(
_getBusyTimesFromDurationLimits,
"getBusyTimesFromDurationLimits"
);
const _getBusyTimesFromTeamLimits = async (
user: { id: number; email: string },
bookingLimits: IntervalLimit,
dateFrom: Dayjs,
dateTo: Dayjs,
teamId: number,
includeManagedEvents: boolean,
timeZone: string,
rescheduleUid?: string
) => {
const busyTimesService = getBusyTimesService();
const { limitDateFrom, limitDateTo } = busyTimesService.getStartEndDateforLimitCheck(
dateFrom.toISOString(),
dateTo.toISOString(),
bookingLimits
);
const bookingRepo = new BookingRepository(prisma);
const bookings = await bookingRepo.getAllAcceptedTeamBookingsOfUser({
user,
teamId,
startDate: limitDateFrom.toDate(),
endDate: limitDateTo.toDate(),
excludedUid: rescheduleUid,
includeManagedEvents,
});
const busyTimes = bookings.map(({ id, startTime, endTime, eventTypeId, title, userId }) => ({
start: dayjs(startTime).toDate(),
end: dayjs(endTime).toDate(),
title,
source: `eventType-${eventTypeId}-booking-${id}`,
userId,
}));
const limitManager = new LimitManager();
await getBusyTimesFromBookingLimits({
bookings: busyTimes,
bookingLimits,
dateFrom,
dateTo,
limitManager,
rescheduleUid,
teamId,
user,
includeManagedEvents,
timeZone,
});
return limitManager.getBusyTimes();
};
export const getBusyTimesFromLimits = withReporting(_getBusyTimesFromLimits, "getBusyTimesFromLimits");
export const getBusyTimesFromBookingLimits = withReporting(
_getBusyTimesFromBookingLimits,
"getBusyTimesFromBookingLimits"
);
export const getBusyTimesFromTeamLimits = withReporting(
_getBusyTimesFromTeamLimits,
"getBusyTimesFromTeamLimits"
);