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>
This commit is contained in:
authored and GitHub committed 2026-03-06 14:53:14 -03:00
1 parent 3e874917ec
commit a4621da2be
12 files changed
+722 -58

No files matched your search

@@ -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 (
<div className="h-full [--calendar-dates-sticky-offset:66px]">
@@ -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<EventBusyDetails, "source">;
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<GetUserAvailabilityInitialData, "user">
): Promise<GetUserAvailabilityResult> {
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(
@@ -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<typeof UserAvailabilityService>[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<GetUserAvailabilityInitialData["user"]>>
): NonNullable<GetUserAvailabilityInitialData["user"]> => ({
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<EventType>>): NonNullable<EventType> => ({
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<string, unknown>) => ({
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,
});
});
});
@@ -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;
}
}
@@ -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
);
@@ -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={{
+10 -5
View File
@@ -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<TSourceRange extends DateRange> = Omit<TSourceRange, "start" | "end"> & {
start: TSourceRange["start"];
end: TSourceRange["end"];
};
export function subtract<TSourceRange extends DateRange, TExcludedRange extends DateRange>(
sourceRanges: TSourceRange[],
excludedRanges: TExcludedRange[]
): SubtractedRange<TSourceRange>[] {
const result: SubtractedRange<TSourceRange>[] = [];
const sortedExcludedRanges = [...excludedRanges].sort((a, b) => a.start.valueOf() - b.start.valueOf());
for (const { start: sourceStart, end: sourceEnd, ...passThrough } of sourceRanges) {
+8
View File
@@ -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",
+41 -8
View File
@@ -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<Dayjs["toISOString"]>}`;
/**
* 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<BusyMapKey, EventBusyDate> = new Map();
private busyMap: Map<BusyMapKey, EventBusyDetails> = 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,
});
}
@@ -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<TrpcSessionUser>;
@@ -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,
@@ -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;
}
}
+1 -1
View File
@@ -62,7 +62,7 @@ export type EventBusyDate = {
export type EventBusyDetails = EventBusyDate & {
title?: string;
source?: string | null;
source: string;
userId?: number | null;
};