* 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>
316 lines
9.2 KiB
TypeScript
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"
|
|
);
|