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:
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(
|
||||
|
||||
+452
@@ -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={{
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+1
-1
@@ -62,7 +62,7 @@ export type EventBusyDate = {
|
||||
|
||||
export type EventBusyDetails = EventBusyDate & {
|
||||
title?: string;
|
||||
source?: string | null;
|
||||
source: string;
|
||||
userId?: number | null;
|
||||
};
|
||||
|
||||
|
||||
Reference in new issue
Block a user