Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
7e416b2
refactor(bookings): add round-robin host effective-limits foundation …
manurana26770 Apr 2, 2026
fd96d98
Merge branch 'main' of https://github.com/calcom/cal.com into feat/in…
manurana26770 Apr 2, 2026
a006556
feat(bookings): plumb host limit override fields into round-robin boo…
manurana26770 Apr 2, 2026
4bf1e79
refactor(users): dedupe normalized host projection for routing paths
manurana26770 Apr 2, 2026
9aa3339
Merge branch 'main' of https://github.com/manurana26770/cal.com into …
manurana26770 Apr 11, 2026
cd35eae
Merge branch 'calcom:main' into feat/individual-member-limits
manurana26770 Apr 11, 2026
28864fb
Merge branch 'main' of https://github.com/calcom/cal.com into feat/in…
manurana26770 Apr 12, 2026
5ca647c
feat(event-types): persist per-host round-robin limit overrides
manurana26770 Apr 12, 2026
a06f40a
feat(event-types-ui): add per-host limit controls in assignment flow
manurana26770 Apr 12, 2026
a8c06e9
feat(bookings): load host override limits in booking pipeline
manurana26770 Apr 12, 2026
7666def
feat(round-robin): enforce effective host limits in booking flows
manurana26770 Apr 12, 2026
f094cf4
feat(slots): bucket hosts by effective booking limits
manurana26770 Apr 12, 2026
af540ba
Merge branch 'feat/individual-member-limits' of https://github.com/ma…
manurana26770 Apr 12, 2026
c8d740a
fix(round-robin): address review findings for host overrides and slots
manurana26770 Apr 13, 2026
e0e90e8
Merge branch 'main' of https://github.com/calcom/cal.com into feat/in…
manurana26770 Apr 15, 2026
2cd0a29
fix(round-robin): resolve post-merge review findings
manurana26770 Apr 15, 2026
52840ed
fix(round-robin): retry host selection on limit conflicts
manurana26770 Apr 15, 2026
3b5b741
fix(eventtypes,slots): stabilize UTC date normalization and timezone …
manurana26770 Apr 15, 2026
192c88b
fix(round-robin): recheck recurring availability for retry candidates
manurana26770 Apr 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions apps/web/modules/event-types/components/AddMembersWithSwitch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,17 @@ const CheckedHostField = ({
userId: parseInt(option.value, 10),
priority: option.priority ?? 2,
weight: option.weight ?? 100,
overrideMinimumBookingNotice: option.overrideMinimumBookingNotice,
overrideBeforeEventBuffer: option.overrideBeforeEventBuffer,
overrideAfterEventBuffer: option.overrideAfterEventBuffer,
overrideSlotInterval: option.overrideSlotInterval,
overrideBookingLimits: option.overrideBookingLimits,
overrideDurationLimits: option.overrideDurationLimits,
overridePeriodType: option.overridePeriodType,
overridePeriodStartDate: option.overridePeriodStartDate,
overridePeriodEndDate: option.overridePeriodEndDate,
overridePeriodDays: option.overridePeriodDays,
overridePeriodCountCalendarDays: option.overridePeriodCountCalendarDays,
scheduleId: option.defaultScheduleId,
groupId: option.groupId,
}))
Expand All @@ -106,6 +117,17 @@ const CheckedHostField = ({
isFixed,
weight: host.weight ?? 100,
groupId: host.groupId,
overrideMinimumBookingNotice: host.overrideMinimumBookingNotice,
overrideBeforeEventBuffer: host.overrideBeforeEventBuffer,
overrideAfterEventBuffer: host.overrideAfterEventBuffer,
overrideSlotInterval: host.overrideSlotInterval,
overrideBookingLimits: host.overrideBookingLimits,
overrideDurationLimits: host.overrideDurationLimits,
overridePeriodType: host.overridePeriodType,
overridePeriodStartDate: host.overridePeriodStartDate,
overridePeriodEndDate: host.overridePeriodEndDate,
overridePeriodDays: host.overridePeriodDays,
overridePeriodCountCalendarDays: host.overridePeriodCountCalendarDays,
});

return acc;
Expand Down
11 changes: 11 additions & 0 deletions apps/web/modules/event-types/components/EventType.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,17 @@ export type Host = {
weight: number;
scheduleId?: number | null;
groupId: string | null;
overrideMinimumBookingNotice?: number | null;
overrideBeforeEventBuffer?: number | null;
overrideAfterEventBuffer?: number | null;
overrideSlotInterval?: number | null;
overrideBookingLimits?: Record<string, number> | null;
overrideDurationLimits?: Record<string, number> | null;
overridePeriodType?: "UNLIMITED" | "ROLLING" | "ROLLING_WINDOW" | "RANGE" | null;
overridePeriodStartDate?: Date | null;
overridePeriodEndDate?: Date | null;
overridePeriodDays?: number | null;
overridePeriodCountCalendarDays?: boolean | null;
};

export type CustomInputParsed = typeof customInputSchema._output;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -174,9 +174,21 @@ const FixedHosts = ({
userId: parseInt(teamMember.value, 10),
priority: host?.priority ?? 2,
weight: host?.weight ?? 100,
overrideMinimumBookingNotice: host?.overrideMinimumBookingNotice,
overrideBeforeEventBuffer: host?.overrideBeforeEventBuffer,
overrideAfterEventBuffer: host?.overrideAfterEventBuffer,
overrideSlotInterval: host?.overrideSlotInterval,
overrideBookingLimits: host?.overrideBookingLimits,
overrideDurationLimits: host?.overrideDurationLimits,
overridePeriodType: host?.overridePeriodType,
overridePeriodStartDate: host?.overridePeriodStartDate,
overridePeriodEndDate: host?.overridePeriodEndDate,
overridePeriodDays: host?.overridePeriodDays,
overridePeriodCountCalendarDays: host?.overridePeriodCountCalendarDays,
// if host was already added, retain scheduleId and groupId
scheduleId: host?.scheduleId || teamMember.defaultScheduleId,
groupId: host?.groupId || null,
location: host?.location ?? null,
};
}),
{ shouldDirty: true }
Expand Down Expand Up @@ -400,9 +412,21 @@ const RoundRobinHosts = ({
userId: parseInt(teamMember.value, 10),
priority: host?.priority ?? 2,
weight: host?.weight ?? 100,
overrideMinimumBookingNotice: host?.overrideMinimumBookingNotice,
overrideBeforeEventBuffer: host?.overrideBeforeEventBuffer,
overrideAfterEventBuffer: host?.overrideAfterEventBuffer,
overrideSlotInterval: host?.overrideSlotInterval,
overrideBookingLimits: host?.overrideBookingLimits,
overrideDurationLimits: host?.overrideDurationLimits,
overridePeriodType: host?.overridePeriodType,
overridePeriodStartDate: host?.overridePeriodStartDate,
overridePeriodEndDate: host?.overridePeriodEndDate,
overridePeriodDays: host?.overridePeriodDays,
overridePeriodCountCalendarDays: host?.overridePeriodCountCalendarDays,
// if host was already added, retain scheduleId and groupId
scheduleId: host?.scheduleId || teamMember.defaultScheduleId,
groupId: host?.groupId || groupId,
location: host?.location ?? null,
};
}),
{ shouldDirty: true }
Expand Down Expand Up @@ -668,6 +692,51 @@ const Hosts = ({
...newValue,
scheduleId: existingHost.scheduleId,
groupId: existingHost.groupId,
location: newValue.location !== undefined ? newValue.location : existingHost.location,
overrideMinimumBookingNotice:
newValue.overrideMinimumBookingNotice !== undefined
? newValue.overrideMinimumBookingNotice
: existingHost.overrideMinimumBookingNotice,
overrideBeforeEventBuffer:
newValue.overrideBeforeEventBuffer !== undefined
? newValue.overrideBeforeEventBuffer
: existingHost.overrideBeforeEventBuffer,
overrideAfterEventBuffer:
newValue.overrideAfterEventBuffer !== undefined
? newValue.overrideAfterEventBuffer
: existingHost.overrideAfterEventBuffer,
overrideSlotInterval:
newValue.overrideSlotInterval !== undefined
? newValue.overrideSlotInterval
: existingHost.overrideSlotInterval,
overrideBookingLimits:
newValue.overrideBookingLimits !== undefined
? newValue.overrideBookingLimits
: existingHost.overrideBookingLimits,
overrideDurationLimits:
newValue.overrideDurationLimits !== undefined
? newValue.overrideDurationLimits
: existingHost.overrideDurationLimits,
overridePeriodType:
newValue.overridePeriodType !== undefined
? newValue.overridePeriodType
: existingHost.overridePeriodType,
overridePeriodStartDate:
newValue.overridePeriodStartDate !== undefined
? newValue.overridePeriodStartDate
: existingHost.overridePeriodStartDate,
overridePeriodEndDate:
newValue.overridePeriodEndDate !== undefined
? newValue.overridePeriodEndDate
: existingHost.overridePeriodEndDate,
overridePeriodDays:
newValue.overridePeriodDays !== undefined
? newValue.overridePeriodDays
: existingHost.overridePeriodDays,
overridePeriodCountCalendarDays:
newValue.overridePeriodCountCalendarDays !== undefined
? newValue.overridePeriodCountCalendarDays
: existingHost.overridePeriodCountCalendarDays,
}
: newValue;
});
Expand Down
129 changes: 107 additions & 22 deletions packages/features/bookings/lib/handleNewBooking/ensureAvailableUsers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@ import type { Logger } from "tslog";
import dayjs from "@calcom/dayjs";
import type { Dayjs } from "@calcom/dayjs";
import { checkForConflicts } from "@calcom/features/bookings/lib/conflictChecker/checkForConflicts";
import {
getRoundRobinHostLimitOverrides,
groupRoundRobinHostsByEffectiveLimits,
} from "@calcom/features/bookings/lib/handleNewBooking/resolveRoundRobinHostEffectiveLimits";
import {
buildEventTypeWithEffectiveLimits,
getEventLevelLimits,
} from "@calcom/features/bookings/lib/handleNewBooking/eventTypeEffectiveLimits";
import { getBusyTimesService } from "@calcom/features/di/containers/BusyTimes";
import { getUserAvailabilityService } from "@calcom/features/di/containers/GetUserAvailability";
import { buildDateRanges } from "@calcom/features/schedules/lib/date-ranges";
Expand All @@ -13,7 +21,9 @@ import { getPiiFreeUser } from "@calcom/lib/piiFreeData";
import { safeStringify } from "@calcom/lib/safeStringify";
import { withReporting } from "@calcom/lib/sentryWrapper";
import prisma from "@calcom/prisma";
import { SchedulingType } from "@calcom/prisma/enums";
import type { CalendarFetchMode } from "@calcom/types/Calendar";
import type { EventBusyDetails } from "@calcom/types/Calendar";

import type { getEventTypeResponse } from "./getEventTypesFromDB";
import type { BookingType } from "./originalRescheduledBookingUtils";
Expand Down Expand Up @@ -74,12 +84,15 @@ const _ensureAvailableUsers = async (

const bookingLimits = parseBookingLimit(eventType?.bookingLimits);
const durationLimits = parseDurationLimit(eventType?.durationLimits);
const hasRoundRobinHostLimitOverrides =
eventType.schedulingType === SchedulingType.ROUND_ROBIN &&
eventType.users.some((user) => getRoundRobinHostLimitOverrides(user));

const busyTimesService = getBusyTimesService();
const busyTimesFromLimitsBookingsAllUsers: Awaited<
ReturnType<typeof busyTimesService.getBusyTimesForLimitChecks>
> =
eventType && (bookingLimits || durationLimits)
!hasRoundRobinHostLimitOverrides && eventType && (bookingLimits || durationLimits)
? await busyTimesService.getBusyTimesForLimitChecks({
userIds: eventType.users.map((u) => u.id),
eventTypeId: eventType.id,
Expand All @@ -91,27 +104,99 @@ const _ensureAvailableUsers = async (
})
: [];

const usersAvailability = await userAvailabilityService.getUsersAvailability({
users: eventType.users,
query: {
...input,
eventTypeId: eventType.id,
duration: originalBookingDuration,
returnDateOverrides: false,
dateFrom: startDateTimeUtc.format(),
dateTo: endDateTimeUtc.format(),
beforeEventBuffer: eventType.beforeEventBuffer,
afterEventBuffer: eventType.afterEventBuffer,
bypassBusyCalendarTimes: false,
mode,
withSource: true,
},
initialData: {
eventType,
rescheduleUid: input.originalRescheduledBooking?.uid ?? null,
busyTimesFromLimitsBookings: busyTimesFromLimitsBookingsAllUsers,
},
});
const usersAvailability = hasRoundRobinHostLimitOverrides
? await (async () => {
const eventLevelLimits = getEventLevelLimits(eventType);
const limitBuckets = groupRoundRobinHostsByEffectiveLimits({
schedulingType: eventType.schedulingType,
eventLimits: eventLevelLimits,
hosts: eventType.users,
getHostOverrides: getRoundRobinHostLimitOverrides,
});
const effectiveLimitsByUserId = new Map<number, typeof eventLevelLimits>();
const busyTimesFromLimitsByUserId = new Map<number, EventBusyDetails[]>();

for (const bucket of limitBuckets) {
const parsedBucketBookingLimits = parseBookingLimit(bucket.effectiveLimits.bookingLimits);
const parsedBucketDurationLimits = parseDurationLimit(bucket.effectiveLimits.durationLimits);

let bucketBookings: EventBusyDetails[] = [];
if (parsedBucketBookingLimits || parsedBucketDurationLimits) {
const { limitDateFrom, limitDateTo } = busyTimesService.getStartEndDateforLimitCheck(
startDateTimeUtc.toISOString(),
endDateTimeUtc.toISOString(),
parsedBucketBookingLimits,
parsedBucketDurationLimits
);

bucketBookings = await busyTimesService.getBusyTimesForLimitChecks({
userIds: bucket.hosts.map((user) => user.id),
eventTypeId: eventType.id,
startDate: limitDateFrom.format(),
endDate: limitDateTo.format(),
rescheduleUid: input.originalRescheduledBooking?.uid ?? null,
bookingLimits: parsedBucketBookingLimits,
durationLimits: parsedBucketDurationLimits,
});
}

for (const user of bucket.hosts) {
effectiveLimitsByUserId.set(user.id, bucket.effectiveLimits);
busyTimesFromLimitsByUserId.set(
user.id,
bucketBookings.filter((booking) => booking.userId === user.id)
);
}
}

return await Promise.all(
eventType.users.map(async (user) => {
const effectiveLimits = effectiveLimitsByUserId.get(user.id) ?? eventLevelLimits;
return await userAvailabilityService.getUserAvailability(
{
...input,
eventTypeId: eventType.id,
duration: originalBookingDuration,
returnDateOverrides: false,
dateFrom: startDateTimeUtc,
dateTo: endDateTimeUtc,
beforeEventBuffer: effectiveLimits.beforeEventBuffer,
afterEventBuffer: effectiveLimits.afterEventBuffer,
bypassBusyCalendarTimes: false,
mode,
withSource: true,
},
{
user,
eventType: buildEventTypeWithEffectiveLimits({ eventType, effectiveLimits }),
rescheduleUid: input.originalRescheduledBooking?.uid ?? null,
busyTimesFromLimitsBookings: busyTimesFromLimitsByUserId.get(user.id) ?? [],
}
);
})
);
})()
: await userAvailabilityService.getUsersAvailability({
users: eventType.users,
query: {
...input,
eventTypeId: eventType.id,
duration: originalBookingDuration,
returnDateOverrides: false,
dateFrom: startDateTimeUtc.format(),
dateTo: endDateTimeUtc.format(),
beforeEventBuffer: eventType.beforeEventBuffer,
afterEventBuffer: eventType.afterEventBuffer,
bypassBusyCalendarTimes: false,
mode,
withSource: true,
},
initialData: {
eventType,
rescheduleUid: input.originalRescheduledBooking?.uid ?? null,
busyTimesFromLimitsBookings: busyTimesFromLimitsBookingsAllUsers,
},
});

const piiFreeInputDataForLogging = safeStringify({
startDateTimeUtc,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import type { getEventTypeResponse } from "./getEventTypesFromDB";
import type { IsFixedAwareUser } from "./types";

export type EventLimitFields = Pick<
getEventTypeResponse,
| "minimumBookingNotice"
| "beforeEventBuffer"
| "afterEventBuffer"
| "slotInterval"
| "bookingLimits"
| "durationLimits"
| "periodType"
| "periodDays"
| "periodCountCalendarDays"
| "periodStartDate"
| "periodEndDate"
>;

export type EventTypeResponseWithIsFixedAwareUsers = Omit<getEventTypeResponse, "users"> & {
users: IsFixedAwareUser[];
};

export const getEventLevelLimits = <T extends EventLimitFields>(eventType: T): EventLimitFields => ({
minimumBookingNotice: eventType.minimumBookingNotice,
beforeEventBuffer: eventType.beforeEventBuffer,
afterEventBuffer: eventType.afterEventBuffer,
slotInterval: eventType.slotInterval,
bookingLimits: eventType.bookingLimits,
durationLimits: eventType.durationLimits,
periodType: eventType.periodType,
periodDays: eventType.periodDays,
periodCountCalendarDays: eventType.periodCountCalendarDays,
periodStartDate: eventType.periodStartDate,
periodEndDate: eventType.periodEndDate,
});

export const buildEventTypeWithEffectiveLimits = <T extends EventLimitFields>(params: {
eventType: T;
effectiveLimits: EventLimitFields;
}): T => {
const { eventType, effectiveLimits } = params;

return {
...eventType,
minimumBookingNotice: effectiveLimits.minimumBookingNotice,
beforeEventBuffer: effectiveLimits.beforeEventBuffer,
afterEventBuffer: effectiveLimits.afterEventBuffer,
slotInterval: effectiveLimits.slotInterval,
bookingLimits: effectiveLimits.bookingLimits,
durationLimits: effectiveLimits.durationLimits,
periodType: effectiveLimits.periodType,
periodDays: effectiveLimits.periodDays,
periodCountCalendarDays: effectiveLimits.periodCountCalendarDays,
periodStartDate: effectiveLimits.periodStartDate,
periodEndDate: effectiveLimits.periodEndDate,
};
};
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ const getEventTypesFromDBSelect = {
seatsShowAvailabilityCount: true,
bookingLimits: true,
durationLimits: true,
slotInterval: true,
rescheduleWithSameRoundRobinHost: true,
assignAllTeamMembers: true,
isRRWeightsEnabled: true,
Expand Down Expand Up @@ -141,6 +142,17 @@ const getEventTypesFromDBSelect = {
isFixed: true,
priority: true,
weight: true,
overrideMinimumBookingNotice: true,
overrideBeforeEventBuffer: true,
overrideAfterEventBuffer: true,
overrideSlotInterval: true,
overrideBookingLimits: true,
overrideDurationLimits: true,
overridePeriodType: true,
overridePeriodStartDate: true,
overridePeriodEndDate: true,
overridePeriodDays: true,
overridePeriodCountCalendarDays: true,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
createdAt: true,
groupId: true,
location: {
Expand Down
Loading
Loading