diff --git a/apps/web/modules/event-types/components/AddMembersWithSwitch.tsx b/apps/web/modules/event-types/components/AddMembersWithSwitch.tsx index 216487786398d1..30bdd3f9d74bf9 100644 --- a/apps/web/modules/event-types/components/AddMembersWithSwitch.tsx +++ b/apps/web/modules/event-types/components/AddMembersWithSwitch.tsx @@ -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, })) @@ -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; diff --git a/apps/web/modules/event-types/components/EventType.tsx b/apps/web/modules/event-types/components/EventType.tsx index a7e934cb7a3b9b..0efe7f20776f73 100644 --- a/apps/web/modules/event-types/components/EventType.tsx +++ b/apps/web/modules/event-types/components/EventType.tsx @@ -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 | null; + overrideDurationLimits?: Record | 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; diff --git a/apps/web/modules/event-types/components/tabs/assignment/EventTeamAssignmentTab.tsx b/apps/web/modules/event-types/components/tabs/assignment/EventTeamAssignmentTab.tsx index 5c5777ed56c224..fad9f3bf5fee7c 100644 --- a/apps/web/modules/event-types/components/tabs/assignment/EventTeamAssignmentTab.tsx +++ b/apps/web/modules/event-types/components/tabs/assignment/EventTeamAssignmentTab.tsx @@ -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 } @@ -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 } @@ -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; }); diff --git a/packages/features/bookings/lib/handleNewBooking/ensureAvailableUsers.ts b/packages/features/bookings/lib/handleNewBooking/ensureAvailableUsers.ts index 47e628479c441e..399d81dbb417f8 100644 --- a/packages/features/bookings/lib/handleNewBooking/ensureAvailableUsers.ts +++ b/packages/features/bookings/lib/handleNewBooking/ensureAvailableUsers.ts @@ -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"; @@ -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"; @@ -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 > = - eventType && (bookingLimits || durationLimits) + !hasRoundRobinHostLimitOverrides && eventType && (bookingLimits || durationLimits) ? await busyTimesService.getBusyTimesForLimitChecks({ userIds: eventType.users.map((u) => u.id), eventTypeId: eventType.id, @@ -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(); + const busyTimesFromLimitsByUserId = new Map(); + + 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, diff --git a/packages/features/bookings/lib/handleNewBooking/eventTypeEffectiveLimits.ts b/packages/features/bookings/lib/handleNewBooking/eventTypeEffectiveLimits.ts new file mode 100644 index 00000000000000..d16c20c22fa16c --- /dev/null +++ b/packages/features/bookings/lib/handleNewBooking/eventTypeEffectiveLimits.ts @@ -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 & { + users: IsFixedAwareUser[]; +}; + +export const getEventLevelLimits = (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 = (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, + }; +}; diff --git a/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts b/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts index f6130e8627ba82..26ee920ebd238b 100644 --- a/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts +++ b/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts @@ -87,6 +87,7 @@ const getEventTypesFromDBSelect = { seatsShowAvailabilityCount: true, bookingLimits: true, durationLimits: true, + slotInterval: true, rescheduleWithSameRoundRobinHost: true, assignAllTeamMembers: true, isRRWeightsEnabled: true, @@ -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, createdAt: true, groupId: true, location: { diff --git a/packages/features/bookings/lib/handleNewBooking/loadAndValidateUsers.ts b/packages/features/bookings/lib/handleNewBooking/loadAndValidateUsers.ts index 30da714f29bb03..be3cb4ed978796 100644 --- a/packages/features/bookings/lib/handleNewBooking/loadAndValidateUsers.ts +++ b/packages/features/bookings/lib/handleNewBooking/loadAndValidateUsers.ts @@ -5,13 +5,13 @@ import { ProfileRepository } from "@calcom/features/profile/repositories/Profile import { withSelectedCalendars } from "@calcom/features/users/repositories/UserRepository"; import { sentrySpan } from "@calcom/features/watchlist/lib/telemetry"; import { filterBlockedUsers } from "@calcom/features/watchlist/operations/filter-blocked-users.controller"; +import { buildNonDelegationCredentials } from "@calcom/lib/delegationCredential"; import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId"; import { HttpError } from "@calcom/lib/http-error"; import { getPiiFreeUser } from "@calcom/lib/piiFreeData"; import { safeStringify } from "@calcom/lib/safeStringify"; import { withReporting } from "@calcom/lib/sentryWrapper"; import prisma, { userSelect } from "@calcom/prisma"; -import type { Prisma } from "@calcom/prisma/client"; import { SchedulingType } from "@calcom/prisma/enums"; import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential"; import type { CredentialForCalendarService } from "@calcom/types/Credential"; @@ -19,21 +19,13 @@ import type { Logger } from "tslog"; import type { NewBookingEventType } from "./getEventTypesFromDB"; import { loadUsers } from "./loadUsers"; +import type { IsFixedAwareUser } from "./types"; -type Users = (Awaited>[number] & { - isFixed?: boolean; - metadata?: Prisma.JsonValue; - createdAt?: Date; -})[]; +type Users = IsFixedAwareUser[]; -export type UsersWithDelegationCredentials = (Omit< - Awaited>[number], - "credentials" -> & { - isFixed?: boolean; - metadata?: Prisma.JsonValue; - createdAt?: Date; +export type UsersWithDelegationCredentials = (Omit & { credentials: CredentialForCalendarService[]; + createdAt?: Date; })[]; type EventType = Pick< @@ -128,7 +120,13 @@ const _loadAndValidateUsers = async ({ logger.warn({ message: "NewBooking: eventTypeUser.notFound" }); throw new HttpError({ statusCode: 404, message: "eventTypeUser.notFound" }); } - users.push(withSelectedCalendars(eventTypeUser)); + const eventTypeUserWithSelectedCalendars = withSelectedCalendars(eventTypeUser); + const { credentials, ...restEventTypeUser } = eventTypeUserWithSelectedCalendars; + users.push({ + ...restEventTypeUser, + credentials: buildNonDelegationCredentials(credentials), + isFixed: eventType.schedulingType !== SchedulingType.ROUND_ROBIN, + }); } if (!users) throw new HttpError({ statusCode: 404, message: "eventTypeUser.notFound" }); diff --git a/packages/features/bookings/lib/handleNewBooking/loadUsers.ts b/packages/features/bookings/lib/handleNewBooking/loadUsers.ts index 15de5fedf2a551..7deaa91cc98dc1 100644 --- a/packages/features/bookings/lib/handleNewBooking/loadUsers.ts +++ b/packages/features/bookings/lib/handleNewBooking/loadUsers.ts @@ -1,22 +1,40 @@ import { getOrgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains"; import { findMatchingHostsWithEventSegment, - getNormalizedHosts, getRoutedUsersWithContactOwnerAndFixedUsers, } from "@calcom/features/users/lib/getRoutedUsers"; +import type { NormalizedHost } from "@calcom/features/users/lib/getRoutedUsers"; import { UserRepository, withSelectedCalendars } from "@calcom/features/users/repositories/UserRepository"; +import type { DefaultEvent } from "@calcom/features/eventtypes/lib/defaultEvents"; +import { buildNonDelegationCredentials } from "@calcom/lib/delegationCredential"; import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; import { getServerErrorFromUnknown } from "@calcom/lib/server/getServerErrorFromUnknown"; import prisma, { userSelect } from "@calcom/prisma"; import type { Prisma } from "@calcom/prisma/client"; +import { SchedulingType } from "@calcom/prisma/enums"; import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential"; -import type { NewBookingEventType } from "./getEventTypesFromDB"; +import type { CredentialForCalendarService, CredentialPayload } from "@calcom/types/Credential"; + +import type { getEventTypeResponse } from "./getEventTypesFromDB"; +import type { IsFixedAwareUser } from "./types"; const log = logger.getSubLogger({ prefix: ["[loadUsers]:handleNewBooking "] }); -type EventType = Pick< - NewBookingEventType, +type DynamicEventType = Pick< + DefaultEvent, + | "hosts" + | "users" + | "id" + | "schedulingType" + | "team" + | "assignAllTeamMembers" + | "assignRRMembersUsingSegment" + | "rrSegmentQueryValue" +>; + +type PersistedEventType = Pick< + getEventTypeResponse, | "hosts" | "users" | "id" @@ -26,6 +44,17 @@ type EventType = Pick< | "assignRRMembersUsingSegment" | "rrSegmentQueryValue" >; +type EventType = DynamicEventType | PersistedEventType; + +export const withCalendarServiceCredentials = ( + user: TUser +): Omit & { credentials: CredentialForCalendarService[] } => { + const { credentials, ...restUser } = user; + return { + ...restUser, + credentials: buildNonDelegationCredentials(credentials), + }; +}; export const loadUsers = async ({ eventType, @@ -43,17 +72,23 @@ export const loadUsers = async ({ hostname: string; forcedSlug: string | undefined; isPlatform: boolean; -}) => { +}): Promise => { try { const { currentOrgDomain } = getOrgDomainConfig({ hostname, forcedSlug, isPlatform, }); + const schedulingType = eventType.schedulingType; - const users = eventType.id - ? await loadUsersByEventType(eventType) - : await loadDynamicUsers(dynamicUserList, currentOrgDomain); + const users: IsFixedAwareUser[] = + eventType.id > 0 + ? await loadUsersByEventType(eventType as PersistedEventType) + : await loadDynamicUsers({ + dynamicUserList, + currentOrgDomain, + schedulingType, + }); const routedUsers = getRoutedUsersWithContactOwnerAndFixedUsers({ users, @@ -73,25 +108,90 @@ export const loadUsers = async ({ } }; -const loadUsersByEventType = async (eventType: EventType): Promise => { - const { hosts, fallbackHosts } = getNormalizedHosts({ - eventType: { ...eventType, hosts: eventType.hosts.filter(Boolean) }, - }); +const loadUsersByEventType = async (eventType: PersistedEventType): Promise => { + type PersistedUser = PersistedEventType["users"][number]; + const normalizedHosts: NormalizedHost[] = + eventType.hosts.length && eventType.schedulingType + ? eventType.hosts.filter(Boolean).map((host) => ({ + isFixed: host.isFixed, + user: host.user, + priority: host.priority, + weight: host.weight, + 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, + createdAt: host.createdAt, + groupId: host.groupId, + })) + : eventType.users.map((user) => ({ + isFixed: eventType.schedulingType === SchedulingType.COLLECTIVE, + user, + createdAt: null, + groupId: null, + })); + const matchingHosts = await findMatchingHostsWithEventSegment({ eventType, - hosts: hosts ?? fallbackHosts, + hosts: normalizedHosts, }); - return matchingHosts.map(({ user, isFixed, priority, weight, createdAt, groupId }) => ({ - ...user, - isFixed, - priority, - weight, - createdAt, - groupId, - })); + return matchingHosts.map( + ({ + user, + isFixed, + priority, + weight, + overrideMinimumBookingNotice, + overrideBeforeEventBuffer, + overrideAfterEventBuffer, + overrideSlotInterval, + overrideBookingLimits, + overrideDurationLimits, + overridePeriodType, + overridePeriodStartDate, + overridePeriodEndDate, + overridePeriodDays, + overridePeriodCountCalendarDays, + createdAt, + groupId, + }) => ({ + ...withCalendarServiceCredentials(user), + isFixed, + priority: priority ?? undefined, + weight: weight ?? undefined, + overrideMinimumBookingNotice, + overrideBeforeEventBuffer, + overrideAfterEventBuffer, + overrideSlotInterval, + overrideBookingLimits, + overrideDurationLimits, + overridePeriodType, + overridePeriodStartDate, + overridePeriodEndDate, + overridePeriodDays, + overridePeriodCountCalendarDays, + createdAt, + groupId, + }) + ); }; -const loadDynamicUsers = async (dynamicUserList: string[], currentOrgDomain: string | null) => { +const loadDynamicUsers = async ({ + dynamicUserList, + currentOrgDomain, + schedulingType, +}: { + dynamicUserList: string[]; + currentOrgDomain: string | null; + schedulingType: EventType["schedulingType"]; +}): Promise => { if (!Array.isArray(dynamicUserList) || dynamicUserList.length === 0) { throw new Error("dynamicUserList is not properly defined or empty."); } @@ -103,11 +203,16 @@ const loadDynamicUsers = async (dynamicUserList: string[], currentOrgDomain: str // For dynamic group bookings: reorder users to match dynamicUserList order // to ensure the first user in the URL is the organizer/host - return users.sort((a, b) => { - const aIndex = dynamicUserList.indexOf(a.username!); - const bIndex = dynamicUserList.indexOf(b.username!); - return aIndex - bIndex; - }); + return users + .sort((a, b) => { + const aIndex = dynamicUserList.indexOf(a.username!); + const bIndex = dynamicUserList.indexOf(b.username!); + return aIndex - bIndex; + }) + .map((user) => ({ + ...withCalendarServiceCredentials(user), + isFixed: schedulingType !== SchedulingType.ROUND_ROBIN, + })); }; /** @@ -120,32 +225,22 @@ export const findUsersByUsername = async ({ }: { orgSlug: string | null; usernameList: string[]; -}) => { +}): Promise[]> => { log.debug("findUsersByUsername", { usernameList, orgSlug }); - const { where, profiles } = await new UserRepository(prisma)._getWhereClauseForFindingUsersByUsername({ + const { where } = await new UserRepository(prisma)._getWhereClauseForFindingUsersByUsername({ orgSlug, usernameList, }); - return ( - await prisma.user.findMany({ - where, - select: { - ...userSelect, - credentials: { - select: credentialForCalendarServiceSelect, - }, - metadata: true, + return (await prisma.user.findMany({ + where, + select: { + ...userSelect, + credentials: { + select: credentialForCalendarServiceSelect, }, - }) - ).map((_user) => { - const user = withSelectedCalendars(_user); - const profile = profiles?.find((profile) => profile.user.id === user.id) ?? null; - return { - ...user, - organizationId: profile?.organizationId ?? null, - profile, - }; - }); + metadata: true, + }, + })).map((_user) => withCalendarServiceCredentials(withSelectedCalendars(_user))); }; export type LoadedUsers = Awaited>; diff --git a/packages/features/bookings/lib/handleNewBooking/resolveRoundRobinHostEffectiveLimits.ts b/packages/features/bookings/lib/handleNewBooking/resolveRoundRobinHostEffectiveLimits.ts new file mode 100644 index 00000000000000..9acffe37aa6742 --- /dev/null +++ b/packages/features/bookings/lib/handleNewBooking/resolveRoundRobinHostEffectiveLimits.ts @@ -0,0 +1,175 @@ +import type { Prisma } from "@calcom/prisma/client"; +import { PeriodType, SchedulingType } from "@calcom/prisma/enums"; + +type EventLevelLimits = { + minimumBookingNotice: number; + beforeEventBuffer: number; + afterEventBuffer: number; + slotInterval: number | null; + bookingLimits: Prisma.JsonValue | null; + durationLimits: Prisma.JsonValue | null; + periodType: PeriodType; + periodDays: number | null; + periodCountCalendarDays: boolean | null; + periodStartDate: Date | null; + periodEndDate: Date | null; +}; + +export type RoundRobinHostLimitOverrides = { + minimumBookingNotice?: number | null; + beforeEventBuffer?: number | null; + afterEventBuffer?: number | null; + slotInterval?: number | null; + bookingLimits?: Prisma.JsonValue | null; + durationLimits?: Prisma.JsonValue | null; + periodType?: PeriodType | null; + periodDays?: number | null; + periodCountCalendarDays?: boolean | null; + periodStartDate?: Date | null; + periodEndDate?: Date | null; +}; + +export type RoundRobinHostLimitOverrideSource = { + overrideMinimumBookingNotice?: number | null; + overrideBeforeEventBuffer?: number | null; + overrideAfterEventBuffer?: number | null; + overrideSlotInterval?: number | null; + overrideBookingLimits?: Prisma.JsonValue | null; + overrideDurationLimits?: Prisma.JsonValue | null; + overridePeriodType?: PeriodType | null; + overridePeriodDays?: number | null; + overridePeriodCountCalendarDays?: boolean | null; + overridePeriodStartDate?: Date | null; + overridePeriodEndDate?: Date | null; +}; + +export type EffectiveHostLimits = EventLevelLimits; + +export type EffectiveLimitBucket = { + profileKey: string; + effectiveLimits: EffectiveHostLimits; + hosts: THost[]; +}; + +export function resolveRoundRobinHostEffectiveLimits({ + schedulingType, + eventLimits, + hostOverrides, +}: { + schedulingType: SchedulingType | null; + eventLimits: EventLevelLimits; + hostOverrides?: RoundRobinHostLimitOverrides | null; +}): EffectiveHostLimits { + // Contract: overrides apply only to round-robin hosts. + if (schedulingType !== SchedulingType.ROUND_ROBIN || !hostOverrides) { + return { ...eventLimits }; + } + + return { + minimumBookingNotice: hostOverrides.minimumBookingNotice ?? eventLimits.minimumBookingNotice, + beforeEventBuffer: hostOverrides.beforeEventBuffer ?? eventLimits.beforeEventBuffer, + afterEventBuffer: hostOverrides.afterEventBuffer ?? eventLimits.afterEventBuffer, + slotInterval: hostOverrides.slotInterval ?? eventLimits.slotInterval, + bookingLimits: hostOverrides.bookingLimits ?? eventLimits.bookingLimits, + durationLimits: hostOverrides.durationLimits ?? eventLimits.durationLimits, + periodType: hostOverrides.periodType ?? eventLimits.periodType, + periodDays: hostOverrides.periodDays ?? eventLimits.periodDays, + periodCountCalendarDays: + hostOverrides.periodCountCalendarDays ?? eventLimits.periodCountCalendarDays, + periodStartDate: hostOverrides.periodStartDate ?? eventLimits.periodStartDate, + periodEndDate: hostOverrides.periodEndDate ?? eventLimits.periodEndDate, + }; +} + +export function hasAnyRoundRobinHostOverrides( + hostOverrides?: RoundRobinHostLimitOverrides | null +): boolean { + if (!hostOverrides) { + return false; + } + + return Object.values(hostOverrides).some((value) => value !== null && value !== undefined); +} + +export function getRoundRobinHostLimitOverrides( + host: RoundRobinHostLimitOverrideSource +): RoundRobinHostLimitOverrides | null { + const resolvedOverrides: RoundRobinHostLimitOverrides = { + minimumBookingNotice: host.overrideMinimumBookingNotice, + beforeEventBuffer: host.overrideBeforeEventBuffer, + afterEventBuffer: host.overrideAfterEventBuffer, + slotInterval: host.overrideSlotInterval, + bookingLimits: host.overrideBookingLimits, + durationLimits: host.overrideDurationLimits, + periodType: host.overridePeriodType, + periodDays: host.overridePeriodDays, + periodCountCalendarDays: host.overridePeriodCountCalendarDays, + periodStartDate: host.overridePeriodStartDate, + periodEndDate: host.overridePeriodEndDate, + }; + + return hasAnyRoundRobinHostOverrides(resolvedOverrides) ? resolvedOverrides : null; +} + +export function buildEffectiveHostLimitsProfileKey(effectiveLimits: EffectiveHostLimits): string { + return JSON.stringify({ + ...effectiveLimits, + periodStartDate: effectiveLimits.periodStartDate?.toISOString() ?? null, + periodEndDate: effectiveLimits.periodEndDate?.toISOString() ?? null, + }); +} + +export function groupRoundRobinHostsByEffectiveLimits({ + schedulingType, + eventLimits, + hosts, + getHostOverrides, +}: { + schedulingType: SchedulingType | null; + eventLimits: EventLevelLimits; + hosts: THost[]; + getHostOverrides: (host: THost) => RoundRobinHostLimitOverrides | null | undefined; +}): EffectiveLimitBucket[] { + if (schedulingType !== SchedulingType.ROUND_ROBIN) { + const effectiveLimits = resolveRoundRobinHostEffectiveLimits({ + schedulingType, + eventLimits, + hostOverrides: null, + }); + const profileKey = buildEffectiveHostLimitsProfileKey(effectiveLimits); + + return [ + { + profileKey, + effectiveLimits, + hosts, + }, + ]; + } + + const bucketsByKey = new Map>(); + + for (const host of hosts) { + const effectiveLimits = resolveRoundRobinHostEffectiveLimits({ + schedulingType, + eventLimits, + hostOverrides: getHostOverrides(host), + }); + + const profileKey = buildEffectiveHostLimitsProfileKey(effectiveLimits); + const existingBucket = bucketsByKey.get(profileKey); + + if (existingBucket) { + existingBucket.hosts.push(host); + continue; + } + + bucketsByKey.set(profileKey, { + profileKey, + effectiveLimits, + hosts: [host], + }); + } + + return Array.from(bucketsByKey.values()); +} diff --git a/packages/features/bookings/lib/handleNewBooking/test/resolveRoundRobinHostEffectiveLimits.test.ts b/packages/features/bookings/lib/handleNewBooking/test/resolveRoundRobinHostEffectiveLimits.test.ts new file mode 100644 index 00000000000000..9a3c4ecba6ea91 --- /dev/null +++ b/packages/features/bookings/lib/handleNewBooking/test/resolveRoundRobinHostEffectiveLimits.test.ts @@ -0,0 +1,226 @@ +import { describe, expect, it } from "vitest"; + +import { PeriodType, SchedulingType } from "@calcom/prisma/enums"; + +import { + buildEffectiveHostLimitsProfileKey, + getRoundRobinHostLimitOverrides, + groupRoundRobinHostsByEffectiveLimits, + hasAnyRoundRobinHostOverrides, + resolveRoundRobinHostEffectiveLimits, + type RoundRobinHostLimitOverrides, +} from "../resolveRoundRobinHostEffectiveLimits"; + +const baseEventLimits = { + minimumBookingNotice: 120, + beforeEventBuffer: 10, + afterEventBuffer: 15, + slotInterval: 30, + bookingLimits: { PER_DAY: 5 }, + durationLimits: { PER_DAY: 180 }, + periodType: PeriodType.ROLLING, + periodDays: 30, + periodCountCalendarDays: true, + periodStartDate: null, + periodEndDate: null, +}; + +describe("resolveRoundRobinHostEffectiveLimits", () => { + it("uses event-level limits when no overrides are provided", () => { + const resolved = resolveRoundRobinHostEffectiveLimits({ + schedulingType: SchedulingType.ROUND_ROBIN, + eventLimits: baseEventLimits, + }); + + expect(resolved).toEqual(baseEventLimits); + }); + + it("ignores overrides for non-round-robin scheduling", () => { + const resolved = resolveRoundRobinHostEffectiveLimits({ + schedulingType: SchedulingType.COLLECTIVE, + eventLimits: baseEventLimits, + hostOverrides: { + minimumBookingNotice: 20, + beforeEventBuffer: 0, + slotInterval: 15, + }, + }); + + expect(resolved).toEqual(baseEventLimits); + }); + + it("applies host overrides for round-robin hosts", () => { + const resolved = resolveRoundRobinHostEffectiveLimits({ + schedulingType: SchedulingType.ROUND_ROBIN, + eventLimits: baseEventLimits, + hostOverrides: { + minimumBookingNotice: 45, + beforeEventBuffer: 5, + afterEventBuffer: 5, + slotInterval: 20, + bookingLimits: { PER_DAY: 2 }, + durationLimits: { PER_DAY: 60 }, + periodType: PeriodType.RANGE, + periodDays: 10, + periodCountCalendarDays: false, + periodStartDate: new Date("2026-01-01T00:00:00.000Z"), + periodEndDate: new Date("2026-01-31T00:00:00.000Z"), + }, + }); + + expect(resolved.minimumBookingNotice).toBe(45); + expect(resolved.beforeEventBuffer).toBe(5); + expect(resolved.afterEventBuffer).toBe(5); + expect(resolved.slotInterval).toBe(20); + expect(resolved.bookingLimits).toEqual({ PER_DAY: 2 }); + expect(resolved.durationLimits).toEqual({ PER_DAY: 60 }); + expect(resolved.periodType).toBe(PeriodType.RANGE); + expect(resolved.periodDays).toBe(10); + expect(resolved.periodCountCalendarDays).toBe(false); + expect(resolved.periodStartDate).toEqual(new Date("2026-01-01T00:00:00.000Z")); + expect(resolved.periodEndDate).toEqual(new Date("2026-01-31T00:00:00.000Z")); + }); + + it("falls back to event-level value when override field is null", () => { + const resolved = resolveRoundRobinHostEffectiveLimits({ + schedulingType: SchedulingType.ROUND_ROBIN, + eventLimits: baseEventLimits, + hostOverrides: { + minimumBookingNotice: null, + beforeEventBuffer: null, + slotInterval: null, + }, + }); + + expect(resolved.minimumBookingNotice).toBe(baseEventLimits.minimumBookingNotice); + expect(resolved.beforeEventBuffer).toBe(baseEventLimits.beforeEventBuffer); + expect(resolved.slotInterval).toBe(baseEventLimits.slotInterval); + }); +}); + +describe("hasAnyRoundRobinHostOverrides", () => { + it("returns false for missing overrides", () => { + expect(hasAnyRoundRobinHostOverrides()).toBe(false); + expect(hasAnyRoundRobinHostOverrides(null)).toBe(false); + }); + + it("returns false when all override values are null/undefined", () => { + const overrides: RoundRobinHostLimitOverrides = { + minimumBookingNotice: null, + beforeEventBuffer: undefined, + slotInterval: null, + }; + + expect(hasAnyRoundRobinHostOverrides(overrides)).toBe(false); + }); + + it("returns true when at least one override value is present", () => { + expect( + hasAnyRoundRobinHostOverrides({ + minimumBookingNotice: 30, + }) + ).toBe(true); + }); +}); + +describe("buildEffectiveHostLimitsProfileKey", () => { + it("returns the same key for equivalent limits", () => { + const keyA = buildEffectiveHostLimitsProfileKey({ + ...baseEventLimits, + periodStartDate: new Date("2026-03-01T00:00:00.000Z"), + periodEndDate: new Date("2026-03-10T00:00:00.000Z"), + }); + + const keyB = buildEffectiveHostLimitsProfileKey({ + ...baseEventLimits, + periodStartDate: new Date("2026-03-01T00:00:00.000Z"), + periodEndDate: new Date("2026-03-10T00:00:00.000Z"), + }); + + expect(keyA).toBe(keyB); + }); +}); + +describe("groupRoundRobinHostsByEffectiveLimits", () => { + it("creates one bucket when no host overrides exist", () => { + const hosts = [{ id: 1 }, { id: 2 }, { id: 3 }]; + + const buckets = groupRoundRobinHostsByEffectiveLimits({ + schedulingType: SchedulingType.ROUND_ROBIN, + eventLimits: baseEventLimits, + hosts, + getHostOverrides: () => null, + }); + + expect(buckets).toHaveLength(1); + expect(buckets[0].hosts).toEqual(hosts); + expect(buckets[0].effectiveLimits).toEqual(baseEventLimits); + }); + + it("creates separate buckets for different round-robin host overrides", () => { + const hosts = [{ id: 1 }, { id: 2 }, { id: 3 }]; + + const buckets = groupRoundRobinHostsByEffectiveLimits({ + schedulingType: SchedulingType.ROUND_ROBIN, + eventLimits: baseEventLimits, + hosts, + getHostOverrides: (host) => { + if (host.id === 1) { + return { minimumBookingNotice: 30, slotInterval: 15 }; + } + if (host.id === 2) { + return { minimumBookingNotice: 30, slotInterval: 15 }; + } + return { minimumBookingNotice: 60, slotInterval: 30 }; + }, + }); + + expect(buckets).toHaveLength(2); + + const bucketSizes = buckets.map((bucket) => bucket.hosts.length).sort((a, b) => a - b); + expect(bucketSizes).toEqual([1, 2]); + }); + + it("ignores host overrides when scheduling is not round-robin", () => { + const hosts = [{ id: 1 }, { id: 2 }]; + + const buckets = groupRoundRobinHostsByEffectiveLimits({ + schedulingType: SchedulingType.COLLECTIVE, + eventLimits: baseEventLimits, + hosts, + getHostOverrides: (host) => ({ minimumBookingNotice: host.id * 10 }), + }); + + expect(buckets).toHaveLength(1); + expect(buckets[0].effectiveLimits).toEqual(baseEventLimits); + }); +}); + +describe("getRoundRobinHostLimitOverrides", () => { + it("returns null when host has no override fields", () => { + expect(getRoundRobinHostLimitOverrides({})).toBeNull(); + }); + + it("returns mapped override object when at least one override is set", () => { + const overrides = getRoundRobinHostLimitOverrides({ + overrideMinimumBookingNotice: 25, + overrideBeforeEventBuffer: 5, + overrideSlotInterval: 15, + overridePeriodType: PeriodType.ROLLING, + }); + + expect(overrides).toEqual({ + minimumBookingNotice: 25, + beforeEventBuffer: 5, + afterEventBuffer: undefined, + slotInterval: 15, + bookingLimits: undefined, + durationLimits: undefined, + periodType: PeriodType.ROLLING, + periodDays: undefined, + periodCountCalendarDays: undefined, + periodStartDate: undefined, + periodEndDate: undefined, + }); + }); +}); diff --git a/packages/features/bookings/lib/handleNewBooking/types.ts b/packages/features/bookings/lib/handleNewBooking/types.ts index 1f0c87352971b3..e8d97d3f5dee47 100644 --- a/packages/features/bookings/lib/handleNewBooking/types.ts +++ b/packages/features/bookings/lib/handleNewBooking/types.ts @@ -6,6 +6,7 @@ import type { GetUserAvailabilityResult } from "@calcom/features/availability/li import type { userSelect } from "@calcom/prisma"; import type { App } from "@calcom/prisma/client"; import type { Prisma } from "@calcom/prisma/client"; +import type { PeriodType } from "@calcom/prisma/enums"; import type { SelectedCalendar } from "@calcom/prisma/client"; import type { CredentialForCalendarService } from "@calcom/types/Credential"; @@ -39,6 +40,17 @@ export type IsFixedAwareUser = User & { organization?: { slug: string }; priority?: number; weight?: number; + overrideMinimumBookingNotice?: number | null; + overrideBeforeEventBuffer?: number | null; + overrideAfterEventBuffer?: number | null; + overrideSlotInterval?: number | null; + overrideBookingLimits?: Prisma.JsonValue | null; + overrideDurationLimits?: Prisma.JsonValue | null; + overridePeriodType?: PeriodType | null; + overridePeriodStartDate?: Date | null; + overridePeriodEndDate?: Date | null; + overridePeriodDays?: number | null; + overridePeriodCountCalendarDays?: boolean | null; userLevelSelectedCalendars: SelectedCalendar[]; allSelectedCalendars: SelectedCalendar[]; groupId?: string | null; diff --git a/packages/features/bookings/lib/host-filtering/filterHostsByLeadThreshold.ts b/packages/features/bookings/lib/host-filtering/filterHostsByLeadThreshold.ts index 5d10129e9f99f0..457c022fd8493c 100644 --- a/packages/features/bookings/lib/host-filtering/filterHostsByLeadThreshold.ts +++ b/packages/features/bookings/lib/host-filtering/filterHostsByLeadThreshold.ts @@ -18,7 +18,7 @@ type BaseUser = { type BaseHost = { isFixed: boolean; - createdAt: Date; + createdAt: Date | null; priority?: number | null; weight?: number | null; weightAdjustment?: number | null; @@ -36,6 +36,10 @@ type WeightedPerUserData = Omit>(host: T): host is T & { createdAt: Date } => { + return host.createdAt !== null; +}; + function filterHostsByLeadThresholdWithWeights(perUserData: WeightedPerUserData, maxLeadThreshold: number) { const filteredUserIds: number[] = []; // negative shortfall means the host should receive negative bookings, so they are overbooked @@ -116,23 +120,30 @@ export const filterHostsByLeadThreshold = async >({ return hosts; // don't apply filter. } + const hostsWithCreatedAt = hosts.filter(hasCreatedAt); + if (hostsWithCreatedAt.length !== hosts.length) { + log.debug( + "Filtering out round-robin hosts missing createdAt before lead-threshold computation to enforce maxLeadThreshold" + ); + } + + if (hostsWithCreatedAt.length === 0) { + log.debug( + "No round-robin hosts with createdAt available after lead-threshold pre-filtering; returning original hosts to avoid fallback re-enabling" + ); + return hosts; + } + // this needs the routing forms response too, because it needs to know what queue we are in const luckyUserService = getLuckyUserService(); const orderedLuckyUsers = await luckyUserService.getOrderedListOfLuckyUsers({ - availableUsers: [ - { - ...hosts[0].user, - weight: hosts[0].weight ?? null, - priority: hosts[0].priority ?? null, - }, - ...hosts.slice(1).map((host) => ({ + availableUsers: hostsWithCreatedAt.map((host) => ({ ...host.user, weight: host.weight ?? null, priority: host.priority ?? null, })), - ], eventType, - allRRHosts: hosts, + allRRHosts: hostsWithCreatedAt, routingFormResponse, }); @@ -161,6 +172,6 @@ export const filterHostsByLeadThreshold = async >({ filteredUserIds = filterHostsByLeadThresholdWithoutWeights(perUserData, maxLeadThreshold); } - const filteredHosts = hosts.filter((host) => filteredUserIds.includes(host.user.id)); + const filteredHosts = hostsWithCreatedAt.filter((host) => filteredUserIds.includes(host.user.id)); return filteredHosts; }; diff --git a/packages/features/bookings/lib/host-filtering/findQualifiedHostsWithDelegationCredentials.ts b/packages/features/bookings/lib/host-filtering/findQualifiedHostsWithDelegationCredentials.ts index d4b935d31bbf7f..b1bcf9e6fb68d4 100644 --- a/packages/features/bookings/lib/host-filtering/findQualifiedHostsWithDelegationCredentials.ts +++ b/packages/features/bookings/lib/host-filtering/findQualifiedHostsWithDelegationCredentials.ts @@ -6,7 +6,9 @@ import { } from "@calcom/features/users/lib/getRoutedUsers"; import type { EventType } from "@calcom/features/users/lib/getRoutedUsers"; import { withReporting } from "@calcom/lib/sentryWrapper"; +import type { Prisma } from "@calcom/prisma/client"; import type { SelectedCalendar } from "@calcom/prisma/client"; +import type { PeriodType } from "@calcom/prisma/enums"; import { SchedulingType } from "@calcom/prisma/enums"; import type { CredentialForCalendarService, CredentialPayload } from "@calcom/types/Credential"; @@ -20,14 +22,29 @@ export interface IQualifiedHostsService { type Host = { isFixed: boolean; - createdAt: Date; + createdAt: Date | null; priority?: number | null; weight?: number | null; groupId: string | null; + overrideMinimumBookingNotice?: number | null; + overrideBeforeEventBuffer?: number | null; + overrideAfterEventBuffer?: number | null; + overrideSlotInterval?: number | null; + overrideBookingLimits?: Prisma.JsonValue | null; + overrideDurationLimits?: Prisma.JsonValue | null; + overridePeriodType?: PeriodType | null; + overridePeriodStartDate?: Date | null; + overridePeriodEndDate?: Date | null; + overridePeriodDays?: number | null; + overridePeriodCountCalendarDays?: boolean | null; } & { user: T; }; +type QualifiedHost = Omit, "user"> & { + user: Omit & { credentials: CredentialForCalendarService[] }; +}; + // In case we don't have any matching team members, we return all the RR hosts, as we always want the team event to be bookable. // Each filter is filtered down, but we never return 0-length. // TODO: We should notify about it to the organizer somehow. @@ -50,8 +67,8 @@ const isRoundRobinHost = (host: T): host is T & return host.isFixed === false; }; -const isFixedHost = (host: T): host is T & { isFixed: false } => { - return host.isFixed; +const isFixedHost = (host: T): host is T & { isFixed: true } => { + return host.isFixed === true; }; const isWithinRRHostSubset = ( @@ -64,7 +81,7 @@ const isWithinRRHostSubset = { +): boolean => { if (rrHostSubsetIds.length === 0 || !rrHostSubsetEnabled || schedulingType !== SchedulingType.ROUND_ROBIN) { return true; } @@ -107,28 +124,10 @@ export class QualifiedHostsService { routingFormResponse: RoutingFormResponse | null; rrHostSubsetIds?: number[]; }): Promise<{ - qualifiedRRHosts: { - isFixed: boolean; - createdAt: Date | null; - priority?: number | null; - weight?: number | null; - user: Omit & { credentials: CredentialForCalendarService[] }; - }[]; - fixedHosts: { - isFixed: boolean; - createdAt: Date | null; - priority?: number | null; - weight?: number | null; - user: Omit & { credentials: CredentialForCalendarService[] }; - }[]; + qualifiedRRHosts: QualifiedHost[]; + fixedHosts: QualifiedHost[]; // all hosts we want to fallback to including the qualifiedRRHosts (fairness + crm contact owner) - allFallbackRRHosts?: { - isFixed: boolean; - createdAt: Date | null; - priority?: number | null; - weight?: number | null; - user: Omit & { credentials: CredentialForCalendarService[] }; - }[]; + allFallbackRRHosts?: QualifiedHost[]; }> { const { hosts: normalizedHosts, fallbackHosts: fallbackUsers } = await getNormalizedHostsWithDelegationCredentials({ diff --git a/packages/features/bookings/lib/service/RegularBookingService.ts b/packages/features/bookings/lib/service/RegularBookingService.ts index f34a38e88d8825..a34f353de0b198 100644 --- a/packages/features/bookings/lib/service/RegularBookingService.ts +++ b/packages/features/bookings/lib/service/RegularBookingService.ts @@ -36,8 +36,10 @@ import { isEventTypeLoggingEnabled } from "@calcom/features/bookings/lib/isEvent import type { BookingEventHandlerService } from "@calcom/features/bookings/lib/onBookingEvents/BookingEventHandlerService"; import type { BookingRescheduledPayload } from "@calcom/features/bookings/lib/onBookingEvents/types.d"; import type { BookingEmailAndSmsTasker } from "@calcom/features/bookings/lib/tasker/BookingEmailAndSmsTasker"; +import { getBusyTimesFromLimits } from "@calcom/features/busyTimes/lib/getBusyTimesFromLimits"; import type { BuiltCalendarEvent } from "@calcom/features/CalendarEventBuilder"; import { CalendarEventBuilder } from "@calcom/features/CalendarEventBuilder"; +import { getBusyTimesService } from "@calcom/features/di/containers/BusyTimes"; import { getSpamCheckService } from "@calcom/features/di/watchlist/containers/SpamCheckService.container"; import { CreditService } from "@calcom/features/ee/billing/credit-service"; import { getBookerBaseUrl } from "@calcom/features/ee/organizations/lib/getBookerUrlServer"; @@ -79,6 +81,8 @@ import { extractBaseEmail } from "@calcom/lib/extract-base-email"; import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId"; import { getTeamIdFromEventType } from "@calcom/lib/getTeamIdFromEventType"; import { HttpError } from "@calcom/lib/http-error"; +import { parseBookingLimit } from "@calcom/lib/intervalLimits/isBookingLimits"; +import { parseDurationLimit } from "@calcom/lib/intervalLimits/isDurationLimits"; import { criticalLogger } from "@calcom/lib/logger.server"; import { getPiiFreeCalendarEvent, getPiiFreeEventType } from "@calcom/lib/piiFreeData"; import { safeStringify } from "@calcom/lib/safeStringify"; @@ -129,12 +133,21 @@ import { getEventType } from "../handleNewBooking/getEventType"; import type { getEventTypeResponse } from "../handleNewBooking/getEventTypesFromDB"; import { getLocationValuesForDb } from "../handleNewBooking/getLocationValuesForDb"; import { getRequiresConfirmationFlags } from "../handleNewBooking/getRequiresConfirmationFlags"; +import { + getRoundRobinHostLimitOverrides, + groupRoundRobinHostsByEffectiveLimits, + resolveRoundRobinHostEffectiveLimits, +} from "../handleNewBooking/resolveRoundRobinHostEffectiveLimits"; import { getSeatedBooking } from "../handleNewBooking/getSeatedBooking"; import { getVideoCallDetails } from "../handleNewBooking/getVideoCallDetails"; import { handleAppsStatus } from "../handleNewBooking/handleAppsStatus"; import { loadAndValidateUsers } from "../handleNewBooking/loadAndValidateUsers"; import type { BookingType } from "../handleNewBooking/originalRescheduledBookingUtils"; import { getOriginalRescheduledBooking } from "../handleNewBooking/originalRescheduledBookingUtils"; +import { + buildEventTypeWithEffectiveLimits, + getEventLevelLimits, +} from "../handleNewBooking/eventTypeEffectiveLimits"; import { scheduleNoShowTriggers } from "../handleNewBooking/scheduleNoShowTriggers"; import type { IEventTypePaymentCredentialType, Invitee, IsFixedAwareUser } from "../handleNewBooking/types"; import { validateBookingTimeIsNotOutOfBounds } from "../handleNewBooking/validateBookingTimeIsNotOutOfBounds"; @@ -155,6 +168,26 @@ function assertNonEmptyArray(arr: T[]): asserts arr is [T, ...T[]] { } } +const doesBookingConflictWithBusyTimes = ({ + start, + end, + busyTimes, +}: { + start: string; + end: string; + busyTimes: { start: string | Date; end: string | Date; source?: string | null }[]; +}) => { + const bookingStart = dayjs(start); + const bookingEnd = dayjs(end); + + return busyTimes.find((busyTime) => { + const busyStart = dayjs(busyTime.start); + const busyEnd = dayjs(busyTime.end); + + return bookingStart.isBefore(busyEnd) && bookingEnd.isAfter(busyStart); + }); +}; + function getICalSequence(originalRescheduledBooking: BookingType | null) { // If new booking set the sequence to 0 if (!originalRescheduledBooking) { @@ -835,14 +868,6 @@ async function handler( const userSchedule = user?.schedules.find((schedule) => schedule.id === user?.defaultScheduleId); const eventTimeZone = eventType.schedule?.timeZone ?? userSchedule?.timeZone; - await validateBookingTimeIsNotOutOfBounds( - reqBody.start, - reqBody.timeZone, - eventType, - eventTimeZone, - tracingLogger - ); - validateEventLength({ reqBodyStart: reqBody.start, reqBodyEnd: reqBody.end, @@ -906,6 +931,9 @@ async function handler( // We filter out users but ensure allHostUsers remain same. let users = [...qualifiedRRUsers, ...additionalFallbackRRUsers, ...fixedUsers]; + const hasRoundRobinHostLimitOverrides = + eventType.schedulingType === SchedulingType.ROUND_ROBIN && + users.some((currentUser) => getRoundRobinHostLimitOverrides(currentUser)); const firstUser = users[0]; @@ -915,7 +943,17 @@ async function handler( location, }); - if (!skipEventLimitsCheck) { + if (!hasRoundRobinHostLimitOverrides) { + await validateBookingTimeIsNotOutOfBounds( + reqBody.start, + reqBody.timeZone, + eventType, + eventTimeZone, + tracingLogger + ); + } + + if (!skipEventLimitsCheck && !hasRoundRobinHostLimitOverrides) { await deps.checkBookingAndDurationLimitsService.checkBookingAndDurationLimits({ eventType, reqBodyStart: reqBody.start, @@ -926,6 +964,8 @@ async function handler( let luckyUserResponse; let isFirstSeat = true; let availableUsers: IsFixedAwareUser[] = []; + let luckyUserPoolsForRetry: Record | null = null; + const selectedLuckyUsersByGroupId = new Map(); if (eventType.seatsPerTimeSlot) { const booking = await deps.prismaClient.booking.findFirst({ @@ -1100,11 +1140,21 @@ async function handler( } }); + const roundRobinLimitBuckets = groupRoundRobinHostsByEffectiveLimits({ + schedulingType: eventType.schedulingType, + eventLimits: getEventLevelLimits(eventType), + hosts: nonFixedUsers, + getHostOverrides: getRoundRobinHostLimitOverrides, + }); + + const bucketedNonFixedUsers = roundRobinLimitBuckets.flatMap((bucket) => bucket.hosts); + // Group non-fixed users by their group IDs const luckyUserPools = groupHostsByGroupId({ - hosts: nonFixedUsers, + hosts: bucketedNonFixedUsers, hostGroups: eventType.hostGroups, }); + luckyUserPoolsForRetry = luckyUserPools; const notAvailableLuckyUsers: typeof users = []; @@ -1112,6 +1162,10 @@ async function handler( "Computed available users", safeStringify({ availableUsers: availableUsers.map((user) => user.id), + roundRobinLimitBuckets: roundRobinLimitBuckets.map((bucket) => ({ + profileKey: bucket.profileKey, + hostIds: bucket.hosts.map((host) => host.id), + })), luckyUserPools: Object.fromEntries( Object.entries(luckyUserPools).map(([groupId, users]) => [groupId, users.map((user) => user.id)]) ), @@ -1192,6 +1246,7 @@ async function handler( } // if no error, then lucky user is available for the next slots luckyUsers.push(newLuckyUser); + selectedLuckyUsersByGroupId.set(groupId, newLuckyUser); luckUserFound = true; } catch { notAvailableLuckyUsers.push(newLuckyUser); @@ -1201,6 +1256,7 @@ async function handler( } } else { luckyUsers.push(newLuckyUser); + selectedLuckyUsersByGroupId.set(groupId, newLuckyUser); luckUserFound = true; } } @@ -1264,6 +1320,248 @@ async function handler( throw new Error(ErrorCode.RoundRobinHostsUnavailableForBooking); } + if (hasRoundRobinHostLimitOverrides) { + const eventLevelLimits = getEventLevelLimits(eventType); + const busyTimesService = getBusyTimesService(); + const bookingDuration = dayjs(reqBody.end).diff(dayjs(reqBody.start), "minute"); + + const getLimitConflictMessageForUser = async (selectedUser: IsFixedAwareUser) => { + const effectiveLimits = resolveRoundRobinHostEffectiveLimits({ + schedulingType: eventType.schedulingType, + eventLimits: eventLevelLimits, + hostOverrides: getRoundRobinHostLimitOverrides(selectedUser), + }); + const effectiveEventType = buildEventTypeWithEffectiveLimits({ + eventType, + effectiveLimits, + }); + + await validateBookingTimeIsNotOutOfBounds( + reqBody.start, + reqBody.timeZone, + effectiveEventType, + eventTimeZone, + tracingLogger + ); + + if (skipEventLimitsCheck) { + return null; + } + + const parsedBookingLimits = parseBookingLimit(effectiveLimits.bookingLimits); + const parsedDurationLimits = parseDurationLimit(effectiveLimits.durationLimits); + + if (!parsedBookingLimits && !parsedDurationLimits) { + return null; + } + + const limitTimeZone = effectiveEventType.schedule?.timeZone ?? selectedUser.timeZone ?? "UTC"; + const bookingStart = dayjs(reqBody.start).tz(limitTimeZone); + const bookingEnd = dayjs(reqBody.end).tz(limitTimeZone); + const { limitDateFrom, limitDateTo } = busyTimesService.getStartEndDateforLimitCheck( + bookingStart.toISOString(), + bookingEnd.toISOString(), + parsedBookingLimits, + parsedDurationLimits + ); + const busyTimesFromLimitsBookings = await busyTimesService.getBusyTimesForLimitChecks({ + userIds: [selectedUser.id], + eventTypeId: eventType.id, + startDate: limitDateFrom.format(), + endDate: limitDateTo.format(), + rescheduleUid: reqBody.rescheduleUid, + bookingLimits: parsedBookingLimits, + durationLimits: parsedDurationLimits, + }); + const busyTimesFromLimits = await getBusyTimesFromLimits( + parsedBookingLimits, + parsedDurationLimits, + bookingStart, + bookingEnd, + bookingDuration, + effectiveEventType, + busyTimesFromLimitsBookings, + limitTimeZone, + reqBody.rescheduleUid + ); + const conflictingBusyTime = doesBookingConflictWithBusyTimes({ + start: reqBody.start, + end: reqBody.end, + busyTimes: busyTimesFromLimits, + }); + + if (!conflictingBusyTime) { + return null; + } + + return conflictingBusyTime.source?.includes("Duration") === true + ? "duration_limit_reached" + : "booking_limit_reached"; + }; + + const isRecurringRetryCandidateEligible = async (candidate: IsFixedAwareUser) => { + const shouldCheckRecurringAvailability = + input.bookingData.isFirstRecurringSlot && + eventType.schedulingType === SchedulingType.ROUND_ROBIN && + input.bookingData.numSlotsToCheckForAvailability && + input.bookingData.allRecurringDates; + + if (!shouldCheckRecurringAvailability || skipAvailabilityCheck) { + return true; + } + + try { + for ( + let i = 0; + i < input.bookingData.allRecurringDates.length && + i < input.bookingData.numSlotsToCheckForAvailability; + i++ + ) { + const start = input.bookingData.allRecurringDates[i].start; + const end = input.bookingData.allRecurringDates[i].end; + + await ensureAvailableUsers( + { + ...eventType, + users: [candidate], + }, + { + dateFrom: dayjs(start).tz(reqBody.timeZone).format(), + dateTo: dayjs(end).tz(reqBody.timeZone).format(), + timeZone: reqBody.timeZone, + originalRescheduledBooking, + }, + tracingLogger, + calendarFetchMode + ); + } + + return true; + } catch { + tracingLogger.info( + `Round robin host ${candidate.name} rejected in retry path because recurring-slot availability check failed.` + ); + return false; + } + }; + + if (luckyUserPoolsForRetry && selectedLuckyUsersByGroupId.size > 0) { + const fixedUsers = users.filter((user) => user.isFixed); + + for (const fixedUser of fixedUsers) { + const fixedEffectiveLimits = resolveRoundRobinHostEffectiveLimits({ + schedulingType: eventType.schedulingType, + eventLimits: eventLevelLimits, + hostOverrides: getRoundRobinHostLimitOverrides(fixedUser), + }); + const fixedEffectiveEventType = buildEventTypeWithEffectiveLimits({ + eventType, + effectiveLimits: fixedEffectiveLimits, + }); + + try { + await validateBookingTimeIsNotOutOfBounds( + reqBody.start, + reqBody.timeZone, + fixedEffectiveEventType, + eventTimeZone, + tracingLogger + ); + } catch { + let conflictMessage: "duration_limit_reached" | "booking_limit_reached" | null = null; + try { + conflictMessage = await getLimitConflictMessageForUser(fixedUser); + } catch { + // Ignore secondary validation errors and use fallback message. + } + + throw new HttpError({ + statusCode: 403, + message: conflictMessage ?? "booking_limit_reached", + }); + } + + let conflictMessage: "duration_limit_reached" | "booking_limit_reached" | null = null; + try { + conflictMessage = await getLimitConflictMessageForUser(fixedUser); + } catch { + throw new HttpError({ + statusCode: 403, + message: "booking_limit_reached", + }); + } + + if (conflictMessage) { + throw new HttpError({ + statusCode: 403, + message: conflictMessage ?? "booking_limit_reached", + }); + } + } + + const usedUserIds = new Set(fixedUsers.map((user) => user.id)); + const resolvedLuckyUsers: IsFixedAwareUser[] = []; + + for (const [groupId, selectedUser] of selectedLuckyUsersByGroupId.entries()) { + const groupPool = luckyUserPoolsForRetry[groupId] ?? []; + const orderedCandidates = [ + selectedUser, + ...groupPool.filter((candidate) => candidate.id !== selectedUser.id), + ]; + let chosenCandidate: IsFixedAwareUser | null = null; + let lastConflictMessage: "duration_limit_reached" | "booking_limit_reached" | null = null; + + for (const candidate of orderedCandidates) { + if (usedUserIds.has(candidate.id)) { + continue; + } + + if (!(await isRecurringRetryCandidateEligible(candidate))) { + continue; + } + + const conflictMessage = await getLimitConflictMessageForUser(candidate); + if (conflictMessage) { + lastConflictMessage = conflictMessage; + continue; + } + + chosenCandidate = candidate; + break; + } + + if (!chosenCandidate) { + throw new HttpError({ + statusCode: 403, + message: lastConflictMessage ?? "booking_limit_reached", + }); + } + + usedUserIds.add(chosenCandidate.id); + resolvedLuckyUsers.push(chosenCandidate); + } + + users = [...fixedUsers, ...resolvedLuckyUsers]; + luckyUserResponse = { luckyUsers: resolvedLuckyUsers.map((user) => user.id) }; + troubleshooterData = { + ...troubleshooterData, + luckyUsers: resolvedLuckyUsers.map((user) => user.id), + }; + } else { + for (const selectedUser of users) { + const conflictMessage = await getLimitConflictMessageForUser(selectedUser); + if (!conflictMessage) { + continue; + } + + throw new HttpError({ + statusCode: 403, + message: conflictMessage, + }); + } + } + } + // If the team member is requested then they should be the organizer const organizerUser = reqBody.teamMemberEmail ? (users.find((user) => user.email === reqBody.teamMemberEmail) ?? users[0]) diff --git a/packages/features/ee/round-robin/roundRobinManualReassignment.ts b/packages/features/ee/round-robin/roundRobinManualReassignment.ts index 27d1ddf8ec0896..0f33ffe0048483 100644 --- a/packages/features/ee/round-robin/roundRobinManualReassignment.ts +++ b/packages/features/ee/round-robin/roundRobinManualReassignment.ts @@ -113,6 +113,17 @@ export const roundRobinManualReassignment = async ({ isFixed: false, priority: 2, weight: 100, + overrideMinimumBookingNotice: null, + overrideBeforeEventBuffer: null, + overrideAfterEventBuffer: null, + overrideSlotInterval: null, + overrideBookingLimits: null, + overrideDurationLimits: null, + overridePeriodType: null, + overridePeriodStartDate: null, + overridePeriodEndDate: null, + overridePeriodDays: null, + overridePeriodCountCalendarDays: null, schedule: null, createdAt: new Date(0), // use earliest possible date as fallback groupId: null, diff --git a/packages/features/ee/round-robin/roundRobinReassignment.ts b/packages/features/ee/round-robin/roundRobinReassignment.ts index 57c896b69ade8b..4966e3477770a6 100644 --- a/packages/features/ee/round-robin/roundRobinReassignment.ts +++ b/packages/features/ee/round-robin/roundRobinReassignment.ts @@ -111,6 +111,17 @@ export const roundRobinReassignment = async ({ isFixed: false, priority: 2, weight: 100, + overrideMinimumBookingNotice: null, + overrideBeforeEventBuffer: null, + overrideAfterEventBuffer: null, + overrideSlotInterval: null, + overrideBookingLimits: null, + overrideDurationLimits: null, + overridePeriodType: null, + overridePeriodStartDate: null, + overridePeriodEndDate: null, + overridePeriodDays: null, + overridePeriodCountCalendarDays: null, schedule: null, createdAt: new Date(0), // use earliest possible date as fallback groupId: null, diff --git a/packages/features/eventtypes/components/CheckedTeamSelect.tsx b/packages/features/eventtypes/components/CheckedTeamSelect.tsx index 7b27d319b5a0c4..e9f642a8f6e807 100644 --- a/packages/features/eventtypes/components/CheckedTeamSelect.tsx +++ b/packages/features/eventtypes/components/CheckedTeamSelect.tsx @@ -5,7 +5,7 @@ import { useState } from "react"; import type { Options, Props } from "react-select"; import { useIsPlatform } from "@calcom/atoms/hooks/useIsPlatform"; -import type { SelectClassNames } from "@calcom/features/eventtypes/lib/types"; +import type { Host, SelectClassNames } from "@calcom/features/eventtypes/lib/types"; import { getHostsFromOtherGroups } from "@calcom/lib/bookings/hostGroupUtils"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import classNames from "@calcom/ui/classNames"; @@ -16,10 +16,30 @@ import { Icon } from "@calcom/ui/components/icon"; import { Tooltip } from "@calcom/ui/components/tooltip"; import type { + LimitOverridesDialogCustomClassNames, PriorityDialogCustomClassNames, WeightDialogCustomClassNames, } from "@calcom/features/eventtypes/components/dialogs/HostEditDialogs"; -import { PriorityDialog, WeightDialog } from "@calcom/features/eventtypes/components/dialogs/HostEditDialogs"; +import { + LimitOverridesDialog, + PriorityDialog, + WeightDialog, +} from "@calcom/features/eventtypes/components/dialogs/HostEditDialogs"; + +type HostOverrideOptionFields = Pick< + Host, + | "overrideMinimumBookingNotice" + | "overrideBeforeEventBuffer" + | "overrideAfterEventBuffer" + | "overrideSlotInterval" + | "overrideBookingLimits" + | "overrideDurationLimits" + | "overridePeriodType" + | "overridePeriodStartDate" + | "overridePeriodEndDate" + | "overridePeriodDays" + | "overridePeriodCountCalendarDays" +>; export type CheckedSelectOption = { avatar: string; @@ -31,7 +51,7 @@ export type CheckedSelectOption = { disabled?: boolean; defaultScheduleId?: number | null; groupId: string | null; -}; +} & HostOverrideOptionFields; export type CheckedTeamSelectCustomClassNames = { hostsSelect?: SelectClassNames; @@ -43,11 +63,13 @@ export type CheckedTeamSelectCustomClassNames = { name?: string; changePriorityButton?: string; changeWeightButton?: string; + changeLimitsButton?: string; removeButton?: string; }; }; priorityDialog?: PriorityDialogCustomClassNames; weightDialog?: WeightDialogCustomClassNames; + limitsDialog?: LimitOverridesDialogCustomClassNames; }; export const CheckedTeamSelect = ({ options = [], @@ -67,6 +89,7 @@ export const CheckedTeamSelect = ({ const isPlatform = useIsPlatform(); const [priorityDialogOpen, setPriorityDialogOpen] = useState(false); const [weightDialogOpen, setWeightDialogOpen] = useState(false); + const [limitsDialogOpen, setLimitsDialogOpen] = useState(false); const [currentOption, setCurrentOption] = useState(value[0] ?? null); @@ -167,6 +190,18 @@ export const CheckedTeamSelect = ({ ) : ( <> )} + ) : ( <> @@ -203,6 +238,14 @@ export const CheckedTeamSelect = ({ onChange={props.onChange} customClassNames={customClassNames?.weightDialog} /> + ) : ( <> diff --git a/packages/features/eventtypes/components/dialogs/HostEditDialogs.tsx b/packages/features/eventtypes/components/dialogs/HostEditDialogs.tsx index 3d33f830f0b90f..726a38c6698540 100644 --- a/packages/features/eventtypes/components/dialogs/HostEditDialogs.tsx +++ b/packages/features/eventtypes/components/dialogs/HostEditDialogs.tsx @@ -1,9 +1,10 @@ import type { Dispatch, SetStateAction } from "react"; -import { useState } from "react"; -import { useFormContext } from "react-hook-form"; +import { useEffect, useState } from "react"; +import { Controller, FormProvider, useForm, useFormContext } from "react-hook-form"; import type { Options } from "react-select"; import { Dialog } from "@calcom/features/components/controlled-dialog"; +import { IntervalLimitsManager } from "@calcom/features/eventtypes/components/tabs/limits/EventLimitsTab"; import type { FormValues, Host, @@ -13,10 +14,11 @@ import type { import { groupHostsByGroupId, getHostsFromOtherGroups, sortHosts } from "@calcom/lib/bookings/hostGroupUtils"; import { DEFAULT_GROUP_ID } from "@calcom/lib/constants"; import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { PeriodType } from "@calcom/prisma/enums"; import classNames from "@calcom/ui/classNames"; import { Button } from "@calcom/ui/components/button"; import { DialogContent, DialogFooter, DialogClose } from "@calcom/ui/components/dialog"; -import { Label } from "@calcom/ui/components/form"; +import { DateRangePicker, Label } from "@calcom/ui/components/form"; import { Select } from "@calcom/ui/components/form"; import { TextField } from "@calcom/ui/components/form"; @@ -77,6 +79,17 @@ export const PriorityDialog = ( weight: host.weight, groupId: host.groupId, userId: host.userId, + 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, }; }) .sort((a, b) => sortHosts(a, b, isRRWeightsEnabled)); @@ -93,6 +106,17 @@ export const PriorityDialog = ( isFixed: host.isFixed, groupId: host.groupId, userId: host.userId, + 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, }; }); const updatedHosts = [...otherGroupsOptions, ...sortedHostGroup]; @@ -186,6 +210,17 @@ export const WeightDialog = (props: IDialog & { customClassNames?: WeightDialogC weight: host.weight, isFixed: host.isFixed, 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, })); // Preserve hosts from other groups @@ -201,6 +236,17 @@ export const WeightDialog = (props: IDialog & { customClassNames?: WeightDialogC weight: host.weight, isFixed: host.isFixed, 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, }; }); const newFullValue = [...otherGroupsOptions, ...updatedOptions]; @@ -242,3 +288,491 @@ export const WeightDialog = (props: IDialog & { customClassNames?: WeightDialogC ); }; + +export type LimitOverridesDialogCustomClassNames = { + container?: string; + label?: string; + confirmButton?: string; + clearButton?: string; + input?: InputClassNames; +}; + +const toNullableInteger = (value: string) => { + const trimmedValue = value.trim(); + if (!trimmedValue) { + return null; + } + + const parsed = Number.parseInt(trimmedValue, 10); + if (Number.isNaN(parsed)) { + return null; + } + + return parsed; +}; + +const toUtcMidnight = (date: Date | undefined | null) => { + if (!date) { + return null; + } + + return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); +}; + +const buildLimitOverrideDefaults = (option: CheckedSelectOption): Partial => ({ + minimumBookingNotice: option.overrideMinimumBookingNotice ?? undefined, + beforeEventBuffer: option.overrideBeforeEventBuffer ?? undefined, + afterEventBuffer: option.overrideAfterEventBuffer ?? undefined, + slotInterval: option.overrideSlotInterval ?? undefined, + bookingLimits: + option.overrideBookingLimits && Object.keys(option.overrideBookingLimits).length > 0 + ? option.overrideBookingLimits + : undefined, + durationLimits: + option.overrideDurationLimits && Object.keys(option.overrideDurationLimits).length > 0 + ? option.overrideDurationLimits + : undefined, + periodType: option.overridePeriodType ?? undefined, + periodDays: option.overridePeriodDays ?? undefined, + periodCountCalendarDays: option.overridePeriodCountCalendarDays ?? undefined, + periodDates: + option.overridePeriodStartDate || option.overridePeriodEndDate + ? { + startDate: + option.overridePeriodStartDate ?? option.overridePeriodEndDate ?? new Date(), + endDate: option.overridePeriodEndDate ?? option.overridePeriodStartDate ?? new Date(), + } + : undefined, +}); + +export const LimitOverridesDialog = ( + props: IDialog & { customClassNames?: LimitOverridesDialogCustomClassNames } +) => { + const { t } = useLocale(); + const { isOpenDialog, setIsOpenDialog, option, options, onChange, customClassNames } = props; + const { getValues } = useFormContext(); + const limitForm = useForm({ + defaultValues: buildLimitOverrideDefaults(option), + }); + const [minimumBookingNotice, setMinimumBookingNotice] = useState(""); + const [beforeEventBuffer, setBeforeEventBuffer] = useState(""); + const [afterEventBuffer, setAfterEventBuffer] = useState(""); + const [slotInterval, setSlotInterval] = useState(""); + + useEffect(() => { + setMinimumBookingNotice(option.overrideMinimumBookingNotice?.toString() ?? ""); + setBeforeEventBuffer(option.overrideBeforeEventBuffer?.toString() ?? ""); + setAfterEventBuffer(option.overrideAfterEventBuffer?.toString() ?? ""); + setSlotInterval(option.overrideSlotInterval?.toString() ?? ""); + limitForm.reset(buildLimitOverrideDefaults(option)); + }, [option, limitForm]); + + const applyOverrides = ({ + minimumBookingNoticeValue, + beforeEventBufferValue, + afterEventBufferValue, + slotIntervalValue, + }: { + minimumBookingNoticeValue: number | null; + beforeEventBufferValue: number | null; + afterEventBufferValue: number | null; + slotIntervalValue: number | null; + }) => { + const hosts: Host[] = getValues("hosts"); + const isRRWeightsEnabled = getValues("isRRWeightsEnabled"); + const hostGroups = getValues("hostGroups"); + const rrHosts = hosts.filter((host) => !host.isFixed); + + const groupedHosts = groupHostsByGroupId({ hosts: rrHosts, hostGroups }); + const hostGroupToSort = groupedHosts[option.groupId ?? DEFAULT_GROUP_ID]; + const bookingLimitsValue = limitForm.getValues("bookingLimits"); + const durationLimitsValue = limitForm.getValues("durationLimits"); + const periodTypeValue = limitForm.getValues("periodType"); + const periodDatesValue = limitForm.getValues("periodDates"); + const periodDaysValue = limitForm.getValues("periodDays"); + const periodCountCalendarDaysValue = limitForm.getValues("periodCountCalendarDays"); + + const nextBookingLimits = + bookingLimitsValue && Object.keys(bookingLimitsValue).length > 0 ? bookingLimitsValue : null; + const nextDurationLimits = + durationLimitsValue && Object.keys(durationLimitsValue).length > 0 ? durationLimitsValue : null; + const nextPeriodType = periodTypeValue ?? null; + const nextPeriodStartDate = + nextPeriodType === PeriodType.RANGE ? toUtcMidnight(periodDatesValue?.startDate) : null; + const nextPeriodEndDate = + nextPeriodType === PeriodType.RANGE ? toUtcMidnight(periodDatesValue?.endDate) : null; + const nextPeriodDays = + nextPeriodType === PeriodType.ROLLING || nextPeriodType === PeriodType.ROLLING_WINDOW + ? periodDaysValue ?? null + : null; + const nextPeriodCountCalendarDays = + nextPeriodType === PeriodType.ROLLING || nextPeriodType === PeriodType.ROLLING_WINDOW + ? periodCountCalendarDaysValue ?? null + : null; + + const sortedHostGroup = (hostGroupToSort ?? []) + .map((host) => { + const userOption = options.find((opt) => opt.value === host.userId.toString()); + const updatedHost = + host.userId === Number.parseInt(option.value, 10) + ? { + ...host, + overrideMinimumBookingNotice: minimumBookingNoticeValue, + overrideBeforeEventBuffer: beforeEventBufferValue, + overrideAfterEventBuffer: afterEventBufferValue, + overrideSlotInterval: slotIntervalValue, + overrideBookingLimits: nextBookingLimits, + overrideDurationLimits: nextDurationLimits, + overridePeriodType: nextPeriodType, + overridePeriodStartDate: nextPeriodStartDate, + overridePeriodEndDate: nextPeriodEndDate, + overridePeriodDays: nextPeriodDays, + overridePeriodCountCalendarDays: nextPeriodCountCalendarDays, + } + : host; + + return { + ...updatedHost, + avatar: userOption?.avatar ?? "", + label: userOption?.label ?? host.userId.toString(), + }; + }) + .sort((a, b) => sortHosts(a, b, isRRWeightsEnabled)); + + const updatedOptions = sortedHostGroup.map((host) => ({ + avatar: host.avatar, + label: host.label, + value: host.userId.toString(), + priority: host.priority, + weight: host.weight, + isFixed: host.isFixed, + 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, + })); + + const otherGroupsHosts = getHostsFromOtherGroups(rrHosts, option.groupId); + const otherGroupsOptions = otherGroupsHosts.map((host) => { + const userOption = options.find((opt) => opt.value === host.userId.toString()); + return { + avatar: userOption?.avatar ?? "", + label: userOption?.label ?? host.userId.toString(), + value: host.userId.toString(), + priority: host.priority, + weight: host.weight, + isFixed: host.isFixed, + 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, + }; + }); + + const fixedHostsOptions = hosts + .filter((host) => host.isFixed) + .map((host) => { + const userOption = options.find((opt) => opt.value === host.userId.toString()); + + return { + avatar: userOption?.avatar ?? "", + label: userOption?.label ?? host.userId.toString(), + value: host.userId.toString(), + priority: host.priority, + weight: host.weight, + isFixed: true, + 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, + }; + }); + + const nextHostsByUserId = new Map(); + [...fixedHostsOptions, ...otherGroupsOptions, ...updatedOptions].forEach((host) => { + nextHostsByUserId.set(host.value, host); + }); + + onChange(Array.from(nextHostsByUserId.values())); + setIsOpenDialog(false); + }; + + return ( + + + +
+
+ + { + setMinimumBookingNotice(e.target.value); + }} + /> +
+
+ + { + setSlotInterval(e.target.value); + }} + /> +
+
+ + { + setBeforeEventBuffer(e.target.value); + }} + /> +
+
+ + { + setAfterEventBuffer(e.target.value); + }} + /> +
+
+ +
+
+ + +
+ {limitForm.watch("bookingLimits") ? ( + + ) : null} +
+ +
+
+ + +
+ {limitForm.watch("durationLimits") ? ( + + ) : null} +
+ +
+ + ( + onChange(selected?.value ?? true)} + value={ + [ + { label: t("business_days"), value: false }, + { label: t("calendar_days"), value: true }, + ].find((periodOption) => periodOption.value === value) ?? null + } + /> + )} + /> +
+ )} + + {limitForm.watch("periodType") === PeriodType.RANGE && ( +
+ ( + { + onChange({ + startDate: toUtcMidnight(startDate) ?? new Date(), + endDate: toUtcMidnight(endDate) ?? new Date(), + }); + }} + /> + )} + /> +
+ )} + +
+ + + { + setMinimumBookingNotice(""); + setBeforeEventBuffer(""); + setAfterEventBuffer(""); + setSlotInterval(""); + limitForm.reset(buildLimitOverrideDefaults(option)); + }} + /> + + +
+
+ ); +}; diff --git a/packages/features/eventtypes/lib/types.ts b/packages/features/eventtypes/lib/types.ts index c3174fbe460712..9140f6db93cba8 100644 --- a/packages/features/eventtypes/lib/types.ts +++ b/packages/features/eventtypes/lib/types.ts @@ -50,6 +50,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?: IntervalLimit | null; + overrideDurationLimits?: IntervalLimit | null; + overridePeriodType?: PeriodType | null; + overridePeriodStartDate?: Date | null; + overridePeriodEndDate?: Date | null; + overridePeriodDays?: number | null; + overridePeriodCountCalendarDays?: boolean | null; location?: HostLocation | null; }; @@ -275,6 +286,17 @@ export type HostInput = { weight?: number | null; scheduleId?: number | null; groupId?: string | null; + overrideMinimumBookingNotice?: number | null; + overrideBeforeEventBuffer?: number | null; + overrideAfterEventBuffer?: number | null; + overrideSlotInterval?: number | null; + overrideBookingLimits?: IntervalLimit | null; + overrideDurationLimits?: IntervalLimit | null; + overridePeriodType?: PeriodType | null; + overridePeriodStartDate?: Date | null; + overridePeriodEndDate?: Date | null; + overridePeriodDays?: number | null; + overridePeriodCountCalendarDays?: boolean | null; location?: HostLocationInput | null; }; diff --git a/packages/features/eventtypes/repositories/eventTypeRepository.ts b/packages/features/eventtypes/repositories/eventTypeRepository.ts index a620abace16c89..5b05af15b29284 100644 --- a/packages/features/eventtypes/repositories/eventTypeRepository.ts +++ b/packages/features/eventtypes/repositories/eventTypeRepository.ts @@ -723,6 +723,17 @@ export class EventTypeRepository implements IEventTypesRepository { weight: true, scheduleId: true, groupId: true, + overrideMinimumBookingNotice: true, + overrideBeforeEventBuffer: true, + overrideAfterEventBuffer: true, + overrideSlotInterval: true, + overrideBookingLimits: true, + overrideDurationLimits: true, + overridePeriodType: true, + overridePeriodStartDate: true, + overridePeriodEndDate: true, + overridePeriodDays: true, + overridePeriodCountCalendarDays: true, location: { select: { id: true, @@ -1039,6 +1050,17 @@ export class EventTypeRepository implements IEventTypesRepository { priority: true, weight: true, scheduleId: true, + overrideMinimumBookingNotice: true, + overrideBeforeEventBuffer: true, + overrideAfterEventBuffer: true, + overrideSlotInterval: true, + overrideBookingLimits: true, + overrideDurationLimits: true, + overridePeriodType: true, + overridePeriodStartDate: true, + overridePeriodEndDate: true, + overridePeriodDays: true, + overridePeriodCountCalendarDays: true, location: { select: { id: true, @@ -1167,6 +1189,28 @@ export class EventTypeRepository implements IEventTypesRepository { where: { id, }, + select: { + id: true, + userId: true, + teamId: true, + minimumBookingNotice: true, + schedulingType: true, + hosts: { + select: { + overrideMinimumBookingNotice: true, + overrideBeforeEventBuffer: true, + overrideAfterEventBuffer: true, + overrideSlotInterval: true, + overrideBookingLimits: true, + overrideDurationLimits: true, + overridePeriodType: true, + overridePeriodDays: true, + overridePeriodCountCalendarDays: true, + overridePeriodStartDate: true, + overridePeriodEndDate: true, + }, + }, + }, }); } @@ -1410,6 +1454,17 @@ export class EventTypeRepository implements IEventTypesRepository { weight: true, priority: true, groupId: true, + overrideMinimumBookingNotice: true, + overrideBeforeEventBuffer: true, + overrideAfterEventBuffer: true, + overrideSlotInterval: true, + overrideBookingLimits: true, + overrideDurationLimits: true, + overridePeriodType: true, + overridePeriodStartDate: true, + overridePeriodEndDate: true, + overridePeriodDays: true, + overridePeriodCountCalendarDays: true, user: { select: { locked: true, diff --git a/packages/features/users/lib/getRoutedUsers.ts b/packages/features/users/lib/getRoutedUsers.ts index e2b00039740585..733fb5590962ff 100644 --- a/packages/features/users/lib/getRoutedUsers.ts +++ b/packages/features/users/lib/getRoutedUsers.ts @@ -3,8 +3,10 @@ import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId"; import logger from "@calcom/lib/logger"; import type { AttributesQueryValue } from "@calcom/lib/raqb/types"; import { safeStringify } from "@calcom/lib/safeStringify"; +import type { Prisma } from "@calcom/prisma/client"; import type { RRResetInterval } from "@calcom/prisma/client"; import type { RRTimestampBasis } from "@calcom/prisma/enums"; +import type { PeriodType } from "@calcom/prisma/enums"; import { SchedulingType } from "@calcom/prisma/enums"; import type { CredentialPayload } from "@calcom/types/Credential"; @@ -75,14 +77,69 @@ type BaseUser = { type BaseHost = { isFixed: boolean; - createdAt: Date; + createdAt: Date | null; priority?: number | null; weight?: number | null; weightAdjustment?: number | null; + overrideMinimumBookingNotice?: number | null; + overrideBeforeEventBuffer?: number | null; + overrideAfterEventBuffer?: number | null; + overrideSlotInterval?: number | null; + overrideBookingLimits?: Prisma.JsonValue | null; + overrideDurationLimits?: Prisma.JsonValue | null; + overridePeriodType?: PeriodType | null; + overridePeriodStartDate?: Date | null; + overridePeriodEndDate?: Date | null; + overridePeriodDays?: number | null; + overridePeriodCountCalendarDays?: boolean | null; user: User; groupId: string | null; }; +export type NormalizedHost = { + isFixed: boolean; + user: User; + priority?: number | null; + weight?: number | null; + overrideMinimumBookingNotice?: number | null; + overrideBeforeEventBuffer?: number | null; + overrideAfterEventBuffer?: number | null; + overrideSlotInterval?: number | null; + overrideBookingLimits?: Prisma.JsonValue | null; + overrideDurationLimits?: Prisma.JsonValue | null; + overridePeriodType?: PeriodType | null; + overridePeriodStartDate?: Date | null; + overridePeriodEndDate?: Date | null; + overridePeriodDays?: number | null; + overridePeriodCountCalendarDays?: boolean | null; + createdAt: Date | null; + groupId: string | null; +}; + +function normalizeHostProjection>( + host: Host +): NormalizedHost { + return { + isFixed: host.isFixed, + user: host.user, + priority: host.priority, + weight: host.weight, + 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, + createdAt: host.createdAt, + groupId: host.groupId, + }; +} + export type EventType = { assignAllTeamMembers: boolean; assignRRMembersUsingSegment: boolean; @@ -106,14 +163,7 @@ export function getNormalizedHosts ({ - isFixed: host.isFixed, - user: host.user, - priority: host.priority, - weight: host.weight, - createdAt: host.createdAt, - groupId: host.groupId, - })), + hosts: eventType.hosts.map(normalizeHostProjection), fallbackHosts: null, }; } else { @@ -146,14 +196,9 @@ export async function getNormalizedHostsWithDelegationCredentials< }; }) { if (eventType.hosts?.length && eventType.schedulingType) { - const hostsWithoutDelegationCredential = eventType.hosts.map((host) => ({ - isFixed: host.isFixed, - user: host.user, - priority: host.priority, - weight: host.weight, - createdAt: host.createdAt, - groupId: host.groupId, - })); + const hostsWithoutDelegationCredential: NormalizedHost[] = eventType.hosts.map((host) => + normalizeHostProjection(host) + ); const firstHost = hostsWithoutDelegationCredential[0]; const firstUserOrgId = await getOrgIdFromMemberOrTeamId({ memberId: firstHost?.user?.id ?? null, @@ -168,12 +213,12 @@ export async function getNormalizedHostsWithDelegationCredentials< fallbackHosts: null, }; } else { - const hostsWithoutDelegationCredential = eventType.users.map((user) => { + const hostsWithoutDelegationCredential: NormalizedHost[] = eventType.users.map((user) => { return { isFixed: !eventType.schedulingType || eventType.schedulingType === SchedulingType.COLLECTIVE, - email: user.email, - user: user, + user, createdAt: null, + groupId: null, }; }); const firstHost = hostsWithoutDelegationCredential[0]; @@ -199,14 +244,7 @@ export async function findMatchingHostsWithEventSegment({ hosts, }: { eventType: EventType; - hosts: { - isFixed: boolean; - user: User; - priority?: number | null; - weight?: number | null; - createdAt: Date | null; - groupId: string | null; - }[]; + hosts: NormalizedHost[]; }) { const matchingRRTeamMembers = await findMatchingTeamMembersIdsForEventRRSegment({ ...eventType, diff --git a/packages/i18n/locales/en/common.json b/packages/i18n/locales/en/common.json index 515683022c646c..b7c2f2548e8613 100644 --- a/packages/i18n/locales/en/common.json +++ b/packages/i18n/locales/en/common.json @@ -1270,6 +1270,8 @@ "invitees_can_schedule": "Invitees can schedule", "date_range": "Date range", "calendar_days": "calendar days", + "rolling": "Rolling", + "rolling_window": "Rolling window", "nameless_calendar": "Nameless Calendar", "business_days": "business days", "set_address_place": "Set an address or place", diff --git a/packages/prisma/migrations/20260402201939_add_host_round_robin_limit_overrides/migration.sql b/packages/prisma/migrations/20260402201939_add_host_round_robin_limit_overrides/migration.sql new file mode 100644 index 00000000000000..28d244533298a7 --- /dev/null +++ b/packages/prisma/migrations/20260402201939_add_host_round_robin_limit_overrides/migration.sql @@ -0,0 +1,12 @@ +-- AlterTable +ALTER TABLE "public"."Host" ADD COLUMN "overrideAfterEventBuffer" INTEGER, +ADD COLUMN "overrideBeforeEventBuffer" INTEGER, +ADD COLUMN "overrideBookingLimits" JSONB, +ADD COLUMN "overrideDurationLimits" JSONB, +ADD COLUMN "overrideMinimumBookingNotice" INTEGER, +ADD COLUMN "overridePeriodCountCalendarDays" BOOLEAN, +ADD COLUMN "overridePeriodDays" INTEGER, +ADD COLUMN "overridePeriodEndDate" TIMESTAMP(3), +ADD COLUMN "overridePeriodStartDate" TIMESTAMP(3), +ADD COLUMN "overridePeriodType" "public"."PeriodType", +ADD COLUMN "overrideSlotInterval" INTEGER; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 86a550d252913b..e33df96cb0d97f 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -59,23 +59,34 @@ enum CreationSource { } model Host { - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - userId Int - eventType EventType @relation(fields: [eventTypeId], references: [id], onDelete: Cascade) - eventTypeId Int - isFixed Boolean @default(false) - priority Int? - weight Int? + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId Int + eventType EventType @relation(fields: [eventTypeId], references: [id], onDelete: Cascade) + eventTypeId Int + isFixed Boolean @default(false) + priority Int? + weight Int? // weightAdjustment is deprecated. We not calculate the calibratino value on the spot. Plan to drop this column. - weightAdjustment Int? - schedule Schedule? @relation(fields: [scheduleId], references: [id]) - scheduleId Int? - createdAt DateTime @default(now()) - group HostGroup? @relation(fields: [groupId], references: [id]) - groupId String? - memberId Int? - member Membership? @relation(fields: [memberId], references: [id], onDelete: Cascade) - location HostLocation? + weightAdjustment Int? + overrideMinimumBookingNotice Int? + overrideBeforeEventBuffer Int? + overrideAfterEventBuffer Int? + overrideSlotInterval Int? + overrideBookingLimits Json? + overrideDurationLimits Json? + overridePeriodType PeriodType? + overridePeriodStartDate DateTime? + overridePeriodEndDate DateTime? + overridePeriodDays Int? + overridePeriodCountCalendarDays Boolean? + schedule Schedule? @relation(fields: [scheduleId], references: [id]) + scheduleId Int? + createdAt DateTime @default(now()) + group HostGroup? @relation(fields: [groupId], references: [id]) + groupId String? + memberId Int? + member Membership? @relation(fields: [memberId], references: [id], onDelete: Cascade) + location HostLocation? @@id([userId, eventTypeId]) @@index([memberId]) diff --git a/packages/trpc/server/routers/viewer/eventTypes/heavy/duplicate.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/heavy/duplicate.handler.ts index 52a6f2b8c2c556..875ad73149be7f 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/heavy/duplicate.handler.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/heavy/duplicate.handler.ts @@ -8,6 +8,7 @@ import { TRPCError } from "@trpc/server"; import type { TrpcSessionUser } from "../../../../types"; import { setDestinationCalendarHandler } from "../../../viewer/calendars/setDestinationCalendar.handler"; +import { mapHostCreateData } from "./hostDataMapping"; import type { TDuplicateInputSchema } from "./duplicate.schema"; type DuplicateOptions = { @@ -122,7 +123,12 @@ export const duplicateHandler = async ({ ctx, input }: DuplicateOptions) => { hosts: hosts ? { createMany: { - data: hosts.map(({ eventTypeId: _, ...rest }) => rest), + data: hosts.map((host) => + mapHostCreateData({ + host, + schedulingType: eventType.schedulingType, + }) + ), }, } : undefined, diff --git a/packages/trpc/server/routers/viewer/eventTypes/heavy/hostDataMapping.ts b/packages/trpc/server/routers/viewer/eventTypes/heavy/hostDataMapping.ts new file mode 100644 index 00000000000000..c3a6fe155267a1 --- /dev/null +++ b/packages/trpc/server/routers/viewer/eventTypes/heavy/hostDataMapping.ts @@ -0,0 +1,127 @@ +import { Prisma } from "@calcom/prisma/client"; +import { SchedulingType } from "@calcom/prisma/enums"; + +import type { TUpdateInputSchema } from "./update.schema"; + +type HostWithOverridesInput = NonNullable[number]; + +type HostWithOverridesCreateInput = HostWithOverridesInput & { + createdAt?: Date | null; + weightAdjustment?: number | null; + memberId?: number | null; +}; + +type HostCreateData = Prisma.HostUncheckedCreateWithoutEventTypeInput; +type HostUpdateData = Prisma.HostUncheckedUpdateWithoutEventTypeInput; + +const toNullableJsonInput = (value: Prisma.InputJsonValue | null | undefined) => { + if (value === undefined) { + return undefined; + } + + return value === null ? Prisma.JsonNull : value; +}; + +export const mapHostCreateData = ({ + host, + schedulingType, +}: { + host: HostWithOverridesCreateInput; + schedulingType: SchedulingType | null | undefined; +}): HostCreateData => { + const hostData: HostCreateData = { + userId: host.userId, + isFixed: schedulingType === SchedulingType.COLLECTIVE || host.isFixed || false, + priority: host.priority ?? 2, + weight: host.weight ?? 100, + groupId: host.groupId, + scheduleId: host.scheduleId ?? null, + overrideMinimumBookingNotice: host.overrideMinimumBookingNotice, + overrideBeforeEventBuffer: host.overrideBeforeEventBuffer, + overrideAfterEventBuffer: host.overrideAfterEventBuffer, + overrideSlotInterval: host.overrideSlotInterval, + overrideBookingLimits: toNullableJsonInput(host.overrideBookingLimits), + overrideDurationLimits: toNullableJsonInput(host.overrideDurationLimits), + overridePeriodType: host.overridePeriodType, + overridePeriodStartDate: host.overridePeriodStartDate, + overridePeriodEndDate: host.overridePeriodEndDate, + overridePeriodDays: host.overridePeriodDays, + overridePeriodCountCalendarDays: host.overridePeriodCountCalendarDays, + }; + + if (host.createdAt !== undefined) { + hostData.createdAt = host.createdAt; + } + + if (host.weightAdjustment !== undefined) { + hostData.weightAdjustment = host.weightAdjustment; + } + + if (host.memberId !== undefined) { + hostData.memberId = host.memberId; + } + + if (host.location) { + hostData.location = { + create: { + type: host.location.type, + credentialId: host.location.credentialId, + link: host.location.link, + address: host.location.address, + phoneNumber: host.location.phoneNumber, + }, + }; + } + + return hostData; +}; + +export const mapHostUpdateData = ({ + host, + schedulingType, +}: { + host: HostWithOverridesInput; + schedulingType: SchedulingType | null | undefined; +}): HostUpdateData => { + const updateData: HostUpdateData = { + isFixed: schedulingType === SchedulingType.COLLECTIVE || host.isFixed, + priority: host.priority ?? 2, + weight: host.weight ?? 100, + scheduleId: host.scheduleId === undefined ? undefined : host.scheduleId, + groupId: host.groupId, + overrideMinimumBookingNotice: host.overrideMinimumBookingNotice, + overrideBeforeEventBuffer: host.overrideBeforeEventBuffer, + overrideAfterEventBuffer: host.overrideAfterEventBuffer, + overrideSlotInterval: host.overrideSlotInterval, + overrideBookingLimits: toNullableJsonInput(host.overrideBookingLimits), + overrideDurationLimits: toNullableJsonInput(host.overrideDurationLimits), + overridePeriodType: host.overridePeriodType, + overridePeriodStartDate: host.overridePeriodStartDate, + overridePeriodEndDate: host.overridePeriodEndDate, + overridePeriodDays: host.overridePeriodDays, + overridePeriodCountCalendarDays: host.overridePeriodCountCalendarDays, + }; + + if (host.location) { + updateData.location = { + upsert: { + create: { + type: host.location.type, + credentialId: host.location.credentialId, + link: host.location.link, + address: host.location.address, + phoneNumber: host.location.phoneNumber, + }, + update: { + type: host.location.type, + credentialId: host.location.credentialId, + link: host.location.link, + address: host.location.address, + phoneNumber: host.location.phoneNumber, + }, + }, + }; + } + + return updateData; +}; diff --git a/packages/trpc/server/routers/viewer/eventTypes/heavy/update.handler.test.ts b/packages/trpc/server/routers/viewer/eventTypes/heavy/update.handler.test.ts index 36540fad675f08..1557e1e7b72592 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/heavy/update.handler.test.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/heavy/update.handler.test.ts @@ -1,6 +1,9 @@ import { describe, it, expect } from "vitest"; import { Prisma } from "@calcom/prisma/client"; +import { SchedulingType } from "@calcom/prisma/enums"; + +import { mapHostCreateData, mapHostUpdateData } from "./hostDataMapping"; describe("update.handler", () => { describe("bookingFields null to Prisma.DbNull transformation", () => { @@ -56,4 +59,112 @@ describe("update.handler", () => { expect(nullResult).not.toEqual(emptyArrayResult); }); }); + + describe("host override persistence mapping", () => { + it("maps host overrides to create payload and defaults scheduleId to null", () => { + const created = mapHostCreateData({ + schedulingType: SchedulingType.ROUND_ROBIN, + host: { + userId: 101, + isFixed: false, + priority: 3, + weight: 90, + groupId: "group-a", + overrideMinimumBookingNotice: 60, + overrideBeforeEventBuffer: 10, + overrideAfterEventBuffer: 20, + overrideSlotInterval: 15, + overrideBookingLimits: { day: 2 }, + overrideDurationLimits: { day: 90 }, + overridePeriodType: "ROLLING", + overridePeriodStartDate: new Date("2026-04-10T00:00:00.000Z"), + overridePeriodEndDate: new Date("2026-05-10T00:00:00.000Z"), + overridePeriodDays: 30, + overridePeriodCountCalendarDays: true, + }, + }); + + expect(created).toMatchObject({ + userId: 101, + isFixed: false, + scheduleId: null, + overrideMinimumBookingNotice: 60, + overrideBeforeEventBuffer: 10, + overrideAfterEventBuffer: 20, + overrideSlotInterval: 15, + overrideBookingLimits: { day: 2 }, + overrideDurationLimits: { day: 90 }, + overridePeriodType: "ROLLING", + overridePeriodDays: 30, + overridePeriodCountCalendarDays: true, + }); + }); + + it("maps host overrides to update payload and preserves undefined scheduleId", () => { + const updated = mapHostUpdateData({ + schedulingType: SchedulingType.ROUND_ROBIN, + host: { + userId: 202, + isFixed: false, + priority: 2, + weight: 100, + groupId: null, + scheduleId: undefined, + overrideMinimumBookingNotice: null, + overrideBeforeEventBuffer: null, + overrideAfterEventBuffer: null, + overrideSlotInterval: null, + overrideBookingLimits: null, + overrideDurationLimits: null, + overridePeriodType: null, + overridePeriodStartDate: null, + overridePeriodEndDate: null, + overridePeriodDays: null, + overridePeriodCountCalendarDays: null, + }, + }); + + expect(updated.scheduleId).toBeUndefined(); + expect(updated).toMatchObject({ + overrideMinimumBookingNotice: null, + overrideBeforeEventBuffer: null, + overrideAfterEventBuffer: null, + overrideSlotInterval: null, + overrideBookingLimits: Prisma.JsonNull, + overrideDurationLimits: Prisma.JsonNull, + overridePeriodType: null, + overridePeriodStartDate: null, + overridePeriodEndDate: null, + overridePeriodDays: null, + overridePeriodCountCalendarDays: null, + }); + }); + + it("forces isFixed=true for collective scheduling in create and update mappings", () => { + const collectiveCreate = mapHostCreateData({ + schedulingType: SchedulingType.COLLECTIVE, + host: { + userId: 303, + isFixed: false, + priority: 2, + weight: 100, + groupId: null, + }, + }); + + const collectiveUpdate = mapHostUpdateData({ + schedulingType: SchedulingType.COLLECTIVE, + host: { + userId: 303, + isFixed: false, + priority: 2, + weight: 100, + groupId: null, + }, + }); + + expect(collectiveCreate.isFixed).toBe(true); + expect(collectiveUpdate.isFixed).toBe(true); + }); + }); }); diff --git a/packages/trpc/server/routers/viewer/eventTypes/heavy/update.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/heavy/update.handler.ts index 10f4ce1f61b536..35810bd4100705 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/heavy/update.handler.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/heavy/update.handler.ts @@ -37,6 +37,7 @@ import { handleCustomInputs, handlePeriodType, } from "../util"; +import { mapHostCreateData, mapHostUpdateData } from "./hostDataMapping"; import type { TUpdateInputSchema } from "./update.schema"; type SessionUser = NonNullable; @@ -61,6 +62,7 @@ type UpdateOptions = { }; input: TUpdateInputSchema; }; +export { mapHostCreateData, mapHostUpdateData } from "./hostDataMapping"; export type UpdateEventTypeReturn = Awaited>; @@ -112,6 +114,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { seatsPerTimeSlot: true, recurringEvent: true, maxActiveBookingsPerBooker: true, + schedulingType: true, fieldTranslations: { select: { field: true, @@ -475,6 +478,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { let hostLocationDeletions: { userId: number; eventTypeId: number }[] = []; if (teamId && hosts) { + const resolvedSchedulingType = input.schedulingType ?? eventType.schedulingType; // check if all hosts can be assigned (memberships that have accepted invite) const teamMemberIds = await membershipRepo.listAcceptedTeamMemberIds({ teamId }); const teamMemberIdSet = new Set(teamMemberIds); @@ -502,95 +506,10 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { })), }, create: newHosts.map((host) => { - const hostData: { - userId: number; - isFixed: boolean; - priority: number; - weight: number; - groupId: string | null | undefined; - scheduleId?: number | null | undefined; - location?: { - create: { - type: string; - credentialId: number | null | undefined; - link: string | null | undefined; - address: string | null | undefined; - phoneNumber: string | null | undefined; - }; - }; - } = { - userId: host.userId, - isFixed: data.schedulingType === SchedulingType.COLLECTIVE || host.isFixed || false, - priority: host.priority ?? 2, - weight: host.weight ?? 100, - groupId: host.groupId, - scheduleId: host.scheduleId ?? null, - }; - if (host.location) { - hostData.location = { - create: { - type: host.location.type, - credentialId: host.location.credentialId, - link: host.location.link, - address: host.location.address, - phoneNumber: host.location.phoneNumber, - }, - }; - } - return hostData; + return mapHostCreateData({ host, schedulingType: resolvedSchedulingType }); }), update: existingHosts.map((host) => { - const updateData: { - isFixed: boolean | undefined; - priority: number; - weight: number; - scheduleId: number | null | undefined; - groupId: string | null | undefined; - location?: { - upsert: { - create: { - type: string; - credentialId: number | null | undefined; - link: string | null | undefined; - address: string | null | undefined; - phoneNumber: string | null | undefined; - }; - update: { - type: string; - credentialId: number | null | undefined; - link: string | null | undefined; - address: string | null | undefined; - phoneNumber: string | null | undefined; - }; - }; - }; - } = { - isFixed: data.schedulingType === SchedulingType.COLLECTIVE || host.isFixed, - priority: host.priority ?? 2, - weight: host.weight ?? 100, - scheduleId: host.scheduleId === undefined ? undefined : host.scheduleId, - groupId: host.groupId, - }; - if (host.location) { - updateData.location = { - upsert: { - create: { - type: host.location.type, - credentialId: host.location.credentialId, - link: host.location.link, - address: host.location.address, - phoneNumber: host.location.phoneNumber, - }, - update: { - type: host.location.type, - credentialId: host.location.credentialId, - link: host.location.link, - address: host.location.address, - phoneNumber: host.location.phoneNumber, - }, - }, - }; - } + const updateData = mapHostUpdateData({ host, schedulingType: resolvedSchedulingType }); return { where: { userId_eventTypeId: { diff --git a/packages/trpc/server/routers/viewer/eventTypes/types.ts b/packages/trpc/server/routers/viewer/eventTypes/types.ts index 57e4bc20974432..406f21a04fb938 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/types.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/types.ts @@ -84,6 +84,17 @@ const hostSchema: z.ZodType = z.object({ weight: z.number().min(0).optional().nullable(), scheduleId: z.number().optional().nullable(), groupId: z.string().optional().nullable(), + overrideMinimumBookingNotice: z.number().min(0).optional().nullable(), + overrideBeforeEventBuffer: z.number().int().optional().nullable(), + overrideAfterEventBuffer: z.number().int().optional().nullable(), + overrideSlotInterval: z.number().int().optional().nullable(), + overrideBookingLimits: intervalLimitsType.optional().nullable(), + overrideDurationLimits: intervalLimitsType.optional().nullable(), + overridePeriodType: z.enum(["UNLIMITED", "ROLLING", "ROLLING_WINDOW", "RANGE"]).optional().nullable(), + overridePeriodStartDate: z.coerce.date().optional().nullable(), + overridePeriodEndDate: z.coerce.date().optional().nullable(), + overridePeriodDays: z.number().int().optional().nullable(), + overridePeriodCountCalendarDays: z.boolean().optional().nullable(), location: hostLocationSchema.optional().nullable(), }); diff --git a/packages/trpc/server/routers/viewer/slots/isAvailable.handler.ts b/packages/trpc/server/routers/viewer/slots/isAvailable.handler.ts index bf2ce030c3903a..4d3bfcb0821e4b 100644 --- a/packages/trpc/server/routers/viewer/slots/isAvailable.handler.ts +++ b/packages/trpc/server/routers/viewer/slots/isAvailable.handler.ts @@ -1,9 +1,14 @@ import type { NextApiRequest } from "next"; +import { + getRoundRobinHostLimitOverrides, + resolveRoundRobinHostEffectiveLimits, +} from "@calcom/features/bookings/lib/handleNewBooking/resolveRoundRobinHostEffectiveLimits"; import { EventTypeRepository } from "@calcom/features/eventtypes/repositories/eventTypeRepository"; import { PrismaSelectedSlotRepository } from "@calcom/features/selectedSlots/repositories/PrismaSelectedSlotRepository"; import { HttpError } from "@calcom/lib/http-error"; import { getPastTimeAndMinimumBookingNoticeBoundsStatus } from "@calcom/lib/isOutOfBounds"; +import { PeriodType, SchedulingType } from "@calcom/prisma/enums"; import type { PrismaClient } from "@calcom/prisma"; import type { TIsAvailableInputSchema, TIsAvailableOutputSchema } from "./isAvailable.schema"; @@ -39,6 +44,31 @@ export const isAvailableHandler = async ({ throw new HttpError({ statusCode: 404, message: "Event type not found" }); } + const minimumBookingNotice = + eventType.schedulingType === SchedulingType.ROUND_ROBIN && eventType.hosts.length > 0 + ? Math.max( + ...eventType.hosts.map((host) => + resolveRoundRobinHostEffectiveLimits({ + schedulingType: eventType.schedulingType, + eventLimits: { + minimumBookingNotice: eventType.minimumBookingNotice, + beforeEventBuffer: 0, + afterEventBuffer: 0, + slotInterval: null, + bookingLimits: null, + durationLimits: null, + periodType: PeriodType.UNLIMITED, + periodDays: null, + periodCountCalendarDays: null, + periodStartDate: null, + periodEndDate: null, + }, + hostOverrides: getRoundRobinHostLimitOverrides(host), + }).minimumBookingNotice + ) + ) + : eventType.minimumBookingNotice; + // Check each slot's availability // Without uid, we must not check for reserved slots because if uuid isn't set in cookie yet, but it is going to be through reserveSlot request soon, we could consider the slot as reserved accidentally. const slotsRepo = new PrismaSelectedSlotRepository(ctx.prisma); @@ -69,7 +99,7 @@ export const isAvailableHandler = async ({ // Check time bounds const timeStatus = getPastTimeAndMinimumBookingNoticeBoundsStatus({ time: slot.utcStartIso, - minimumBookingNotice: eventType.minimumBookingNotice, + minimumBookingNotice, }); return { diff --git a/packages/trpc/server/routers/viewer/slots/util.ts b/packages/trpc/server/routers/viewer/slots/util.ts index 433672093bbef3..b4c8e8a49f9216 100644 --- a/packages/trpc/server/routers/viewer/slots/util.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -13,6 +13,15 @@ import type { CheckBookingLimitsService } from "@calcom/features/bookings/lib/ch import { checkForConflicts } from "@calcom/features/bookings/lib/conflictChecker/checkForConflicts"; import type { QualifiedHostsService } from "@calcom/features/bookings/lib/host-filtering/findQualifiedHostsWithDelegationCredentials"; import { isEventTypeLoggingEnabled } from "@calcom/features/bookings/lib/isEventTypeLoggingEnabled"; +import { + getRoundRobinHostLimitOverrides, + groupRoundRobinHostsByEffectiveLimits, + resolveRoundRobinHostEffectiveLimits, +} from "@calcom/features/bookings/lib/handleNewBooking/resolveRoundRobinHostEffectiveLimits"; +import type { + EffectiveHostLimits, + RoundRobinHostLimitOverrideSource, +} from "@calcom/features/bookings/lib/handleNewBooking/resolveRoundRobinHostEffectiveLimits"; import type { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository"; import type { BusyTimesService } from "@calcom/features/busyTimes/services/getBusyTimes"; import type { getBusyTimesService } from "@calcom/features/di/containers/BusyTimes"; @@ -23,6 +32,7 @@ import type { FeaturesRepository } from "@calcom/features/flags/features.reposit import type { PrismaOOORepository } from "@calcom/features/ooo/repositories/PrismaOOORepository"; import type { IRedisService } from "@calcom/features/redis/IRedisService"; import { buildDateRanges } from "@calcom/features/schedules/lib/date-ranges"; +import type { DateRange } from "@calcom/features/schedules/lib/date-ranges"; import getSlots from "@calcom/features/schedules/lib/slots"; import type { ScheduleRepository } from "@calcom/features/schedules/repositories/ScheduleRepository"; import type { ISelectedSlotRepository } from "@calcom/features/selectedSlots/repositories/ISelectedSlotRepository"; @@ -31,7 +41,7 @@ import type { UserRepository } from "@calcom/features/users/repositories/UserRep import { withSelectedCalendars } from "@calcom/features/users/repositories/UserRepository"; import { filterBlockedHosts } from "@calcom/features/watchlist/operations/filter-blocked-hosts.controller"; import { shouldIgnoreContactOwner } from "@calcom/lib/bookings/routing/utils"; -import { RESERVED_SUBDOMAINS } from "@calcom/lib/constants"; +import { DEFAULT_GROUP_ID, RESERVED_SUBDOMAINS } from "@calcom/lib/constants"; import { getUTCOffsetByTimezone } from "@calcom/lib/dayjs"; import { descendingLimitKeys, intervalLimitKeyToUnit } from "@calcom/lib/intervalLimits/intervalLimit"; import type { IntervalLimit } from "@calcom/lib/intervalLimits/intervalLimitSchema"; @@ -59,6 +69,7 @@ import type { TGetScheduleInputSchema } from "./getSchedule.schema"; import type { GetScheduleOptions } from "./types"; import type { OrgMembershipLookup } from "@calcom/features/di/modules/OrgMembershipLookup"; import type { IGetAvailableSlots } from "@calcom/features/bookings/Booker/hooks/useAvailableTimeSlots"; +import type { Prisma } from "@calcom/prisma/client"; const log = logger.getSubLogger({ prefix: ["[slots/util]"] }); const DEFAULT_SLOTS_CACHE_TTL = 2000; @@ -67,6 +78,111 @@ type GetAvailabilityUserWithDelegationCredentials = Omit>, + null +>; + +type OverrideAwareAvailabilityUser = GetAvailabilityUserWithDelegationCredentials & { + isFixed?: boolean; + groupId?: string | null; + overrideMinimumBookingNotice?: number | null; + overrideBeforeEventBuffer?: number | null; + overrideAfterEventBuffer?: number | null; + overrideSlotInterval?: number | null; + overrideBookingLimits?: Prisma.JsonValue | null; + overrideDurationLimits?: Prisma.JsonValue | null; + overridePeriodType?: PeriodType | null; + overridePeriodStartDate?: Date | null; + overridePeriodEndDate?: Date | null; + overridePeriodDays?: number | null; + overridePeriodCountCalendarDays?: boolean | null; +}; + +type OverrideAwareHost = RoundRobinHostLimitOverrideSource & { + isFixed?: boolean; + groupId?: string | null; + user: GetAvailabilityUserWithDelegationCredentials; +}; + +type SlotResult = ReturnType[number]; + +const getEventLevelLimits = ( + eventType: Pick< + AvailableSlotsEventType, + | "minimumBookingNotice" + | "beforeEventBuffer" + | "afterEventBuffer" + | "slotInterval" + | "bookingLimits" + | "durationLimits" + | "periodType" + | "periodDays" + | "periodCountCalendarDays" + | "periodStartDate" + | "periodEndDate" + > +): EffectiveHostLimits => ({ + 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, +}); + +const buildEventTypeWithEffectiveLimits = ({ + eventType, + effectiveLimits, +}: { + eventType: AvailableSlotsEventType; + effectiveLimits: EffectiveHostLimits; +}) => ({ + ...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, +}); + +const getSlotKey = (slot: SlotResult) => slot.time.toISOString(); + +const mergeSlotMaps = (slotMaps: Map[]) => { + const mergedSlotMap = new Map(); + + slotMaps.forEach((slotMap) => { + slotMap.forEach((slot, key) => { + mergedSlotMap.set(key, slot); + }); + }); + + return mergedSlotMap; +}; + +const intersectSlotMaps = (baseSlotMap: Map, nextSlotMap: Map) => { + const intersectedSlotMap = new Map(); + + baseSlotMap.forEach((slot, key) => { + if (nextSlotMap.has(key)) { + intersectedSlotMap.set(key, slot); + } + }); + + return intersectedSlotMap; +}; + export type GetAvailableSlotsResponse = Awaited< ReturnType<(typeof AvailableSlotsService)["prototype"]["_getAvailableSlots"]> >; @@ -306,6 +422,10 @@ export class AvailableSlotsService { ); private _getAllDatesWithBookabilityStatus(availableDates: string[]) { + if (availableDates.length === 0) { + return {}; + } + const availableDatesSet = new Set(availableDates); const firstDate = dayjs(availableDates[0]); const lastDate = dayjs(availableDates[availableDates.length - 1]); @@ -835,10 +955,53 @@ export class AvailableSlotsService { hosts: { isFixed?: boolean; groupId?: string | null; + overrideMinimumBookingNotice?: number | null; + overrideBeforeEventBuffer?: number | null; + overrideAfterEventBuffer?: number | null; + overrideSlotInterval?: number | null; + overrideBookingLimits?: Prisma.JsonValue | null; + overrideDurationLimits?: Prisma.JsonValue | null; + overridePeriodType?: PeriodType | null; + overridePeriodStartDate?: Date | null; + overridePeriodEndDate?: Date | null; + overridePeriodDays?: number | null; + overridePeriodCountCalendarDays?: boolean | null; user: GetAvailabilityUserWithDelegationCredentials; }[]; }) { - return hosts.map(({ isFixed, groupId, user }) => ({ isFixed, groupId, ...user })); + return hosts.map( + ({ + isFixed, + groupId, + overrideMinimumBookingNotice, + overrideBeforeEventBuffer, + overrideAfterEventBuffer, + overrideSlotInterval, + overrideBookingLimits, + overrideDurationLimits, + overridePeriodType, + overridePeriodStartDate, + overridePeriodEndDate, + overridePeriodDays, + overridePeriodCountCalendarDays, + user, + }) => ({ + isFixed, + groupId, + overrideMinimumBookingNotice, + overrideBeforeEventBuffer, + overrideAfterEventBuffer, + overrideSlotInterval, + overrideBookingLimits, + overrideDurationLimits, + overridePeriodType, + overridePeriodStartDate, + overridePeriodEndDate, + overridePeriodDays, + overridePeriodCountCalendarDays, + ...user, + }) + ); } private getUsersWithCredentials = withReporting( @@ -847,11 +1010,145 @@ export class AvailableSlotsService { ); private getStartTime(startTimeInput: string, timeZone?: string, minimumBookingNotice?: number) { + const resolvedTimeZone = timeZone ?? "UTC"; const startTimeMin = dayjs.utc().add(minimumBookingNotice || 1, "minutes"); - const startTime = timeZone === "Etc/GMT" ? dayjs.utc(startTimeInput) : dayjs(startTimeInput).tz(timeZone); + const startTime = + resolvedTimeZone === "Etc/GMT" + ? dayjs.utc(startTimeInput) + : dayjs(startTimeInput).tz(resolvedTimeZone); + + return startTimeMin.isAfter(startTime) ? startTimeMin.tz(resolvedTimeZone) : startTime; + } + + private hasRoundRobinHostLimitOverrides(users: OverrideAwareAvailabilityUser[]) { + return ( + users.length > 0 && + users.some((user) => getRoundRobinHostLimitOverrides(user)) && + users.some((user) => user.isFixed !== true) + ); + } + + private getEffectiveMinimumBookingNoticeForHosts({ + eventType, + hosts, + }: { + eventType: AvailableSlotsEventType; + hosts: OverrideAwareHost[]; + }) { + if (eventType.schedulingType !== SchedulingType.ROUND_ROBIN || hosts.length === 0) { + return eventType.minimumBookingNotice; + } + + const eventLevelLimits = getEventLevelLimits(eventType); + + return Math.min( + ...hosts.map((host) => + resolveRoundRobinHostEffectiveLimits({ + schedulingType: eventType.schedulingType, + eventLimits: eventLevelLimits, + hostOverrides: getRoundRobinHostLimitOverrides(host), + }).minimumBookingNotice + ) + ); + } + + private getRoundRobinOverrideAwareSlots({ + input, + eventType, + allUsersAvailability, + effectiveLimitsByUserId, + startTime, + eventTimeZone, + }: { + input: TGetScheduleInputSchema; + eventType: AvailableSlotsEventType; + allUsersAvailability: { + timeZone: string; + dateRanges: DateRange[]; + oooExcludedDateRanges: DateRange[]; + user: OverrideAwareAvailabilityUser; + datesOutOfOffice?: Record; + }[]; + effectiveLimitsByUserId: Map; + startTime: Dayjs; + eventTimeZone: string | undefined; + }) { + const eventLength = input.duration || eventType.length; + const bookerUtcOffset = input.timeZone ? (getUTCOffsetByTimezone(input.timeZone) ?? 0) : 0; + + const slotMapsByUser = allUsersAvailability.map((availability) => { + const effectiveLimits = effectiveLimitsByUserId.get(availability.user.id) ?? getEventLevelLimits(eventType); + const userSlots = getSlots({ + inviteeDate: startTime, + eventLength, + offsetStart: eventType.offsetStart, + dateRanges: availability.oooExcludedDateRanges, + minimumBookingNotice: effectiveLimits.minimumBookingNotice, + frequency: effectiveLimits.slotInterval || input.duration || eventType.length, + showOptimizedSlots: eventType.showOptimizedSlots, + }); + const slotsMappedToDate = userSlots.reduce>((acc, slot) => { + const dateString = slot.time.tz(input.timeZone ?? "UTC").format("YYYY-MM-DD"); + acc[dateString] = { isBookable: true }; + return acc; + }, {}); + const allDatesWithBookabilityStatus = this.getAllDatesWithBookabilityStatus( + Object.keys(slotsMappedToDate) + ); + const periodLimits = calculatePeriodLimits({ + periodType: effectiveLimits.periodType, + periodDays: effectiveLimits.periodDays, + periodCountCalendarDays: effectiveLimits.periodCountCalendarDays, + periodStartDate: effectiveLimits.periodStartDate, + periodEndDate: effectiveLimits.periodEndDate, + allDatesWithBookabilityStatusInBookerTz: allDatesWithBookabilityStatus, + eventUtcOffset: getUTCOffsetByTimezone(eventTimeZone ?? "UTC") ?? 0, + bookerUtcOffset, + }); + const filteredUserSlots = userSlots.filter( + (slot) => + !isTimeViolatingFutureLimit({ + time: slot.time.toISOString(), + periodLimits, + }) + ); + + return { + isFixed: availability.user.isFixed === true, + groupId: availability.user.groupId ?? null, + slotMap: new Map(filteredUserSlots.map((slot) => [getSlotKey(slot), slot])), + }; + }); + + const fixedSlotMaps = slotMapsByUser.filter((slotMap) => slotMap.isFixed).map((slotMap) => slotMap.slotMap); + const roundRobinSlotMapsByGroup = slotMapsByUser + .filter((slotMap) => !slotMap.isFixed) + .reduce( + (groupedSlotMaps, slotMap) => { + const groupId = slotMap.groupId ?? DEFAULT_GROUP_ID; + groupedSlotMaps[groupId] = groupedSlotMaps[groupId] ?? []; + groupedSlotMaps[groupId].push(slotMap.slotMap); + return groupedSlotMaps; + }, + {} as Record[]> + ); + + let mergedSlotMap: Map | null = null; + + if (fixedSlotMaps.length > 0) { + mergedSlotMap = fixedSlotMaps.slice(1).reduce(intersectSlotMaps, fixedSlotMaps[0]); + } - return startTimeMin.isAfter(startTime) ? startTimeMin.tz(timeZone) : startTime; + Object.values(roundRobinSlotMapsByGroup).forEach((slotMaps) => { + const groupSlotMap = mergeSlotMaps(slotMaps); + mergedSlotMap = mergedSlotMap ? intersectSlotMaps(mergedSlotMap, groupSlotMap) : groupSlotMap; + }); + + return Array.from(mergedSlotMap?.values() ?? []).sort((leftSlot, rightSlot) => { + return leftSlot.time.valueOf() - rightSlot.time.valueOf(); + }); } + private async calculateHostsAndAvailabilities({ input, eventType, @@ -868,11 +1165,7 @@ export class AvailableSlotsService { Awaited>, null >; - hosts: { - isFixed?: boolean; - groupId?: string | null; - user: GetAvailabilityUserWithDelegationCredentials; - }[]; + hosts: OverrideAwareHost[]; loggerWithEventDetails: Logger; startTime: ReturnType<(typeof AvailableSlotsService)["prototype"]["getStartTime"]>; endTime: Dayjs; @@ -882,7 +1175,7 @@ export class AvailableSlotsService { }) { const usersWithCredentials = this.getUsersWithCredentials({ hosts, - }); + }) as OverrideAwareAvailabilityUser[]; loggerWithEventDetails.debug("Using users", { usersWithCredentials: usersWithCredentials.map((user) => user.email), @@ -927,12 +1220,14 @@ export class AvailableSlotsService { Object.keys(eventType?.durationLimits).length > 0 ? parseDurationLimit(eventType?.durationLimits) : null; + const eventLevelLimits = getEventLevelLimits(eventType); + const hasRoundRobinHostLimitOverrides = this.hasRoundRobinHostLimitOverrides(usersWithCredentials); let busyTimesFromLimitsBookingsAllUsers: Awaited< ReturnType > = []; - if (eventType && (bookingLimits || durationLimits)) { + if (!hasRoundRobinHostLimitOverrides && eventType && (bookingLimits || durationLimits)) { busyTimesFromLimitsBookingsAllUsers = await this.dependencies.busyTimesService.getBusyTimesForLimitChecks({ userIds: allUserIds, @@ -946,7 +1241,7 @@ export class AvailableSlotsService { } let busyTimesFromLimitsMap: Map | undefined; - if (eventType && (bookingLimits || durationLimits)) { + if (!hasRoundRobinHostLimitOverrides && eventType && (bookingLimits || durationLimits)) { const usersForLimits = usersWithCredentials.map((user) => ({ id: user.id, email: user.email })); const eventTimeZone = eventType.schedule?.timeZone ?? usersWithCredentials[0]?.timeZone ?? "UTC"; busyTimesFromLimitsMap = await this.getBusyTimesFromLimitsForUsers( @@ -1002,32 +1297,136 @@ export class AvailableSlotsService { } const enrichUsersWithData = withReporting(_enrichUsersWithData.bind(this), "enrichUsersWithData"); const users = enrichUsersWithData(); + const effectiveLimitsByUserId = new Map( + users.map((user) => [user.id, eventLevelLimits]) + ); - const premappedUsersAvailability = await this.dependencies.userAvailabilityService.getUsersAvailability({ - users, - query: { - dateFrom: startTime.format(), - dateTo: endTime.format(), - eventTypeId: eventType.id, - afterEventBuffer: eventType.afterEventBuffer, - beforeEventBuffer: eventType.beforeEventBuffer, - duration: input.duration || 0, - returnDateOverrides: false, - bypassBusyCalendarTimes, - silentlyHandleCalendarFailures: silentCalendarFailures, - mode, - }, - initialData: { - eventType, - currentSeats, - rescheduleUid: input.rescheduleUid, - busyTimesFromLimitsBookings: busyTimesFromLimitsBookingsAllUsers, - busyTimesFromLimits: busyTimesFromLimitsMap, - eventTypeForLimits: eventType && (bookingLimits || durationLimits) ? eventType : null, - teamBookingLimits: teamBookingLimitsMap, - teamForBookingLimits: teamForBookingLimits, - }, - }); + const premappedUsersAvailability = hasRoundRobinHostLimitOverrides + ? await (async () => { + const busyTimesFromLimitsByUserId = new Map(); + const limitBuckets = groupRoundRobinHostsByEffectiveLimits({ + schedulingType: eventType.schedulingType, + eventLimits: eventLevelLimits, + hosts: users, + getHostOverrides: getRoundRobinHostLimitOverrides, + }); + + 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 } = + this.dependencies.busyTimesService.getStartEndDateforLimitCheck( + startTime.toISOString(), + endTime.toISOString(), + parsedBucketBookingLimits, + parsedBucketDurationLimits + ); + + bucketBookings = await this.dependencies.busyTimesService.getBusyTimesForLimitChecks({ + userIds: bucket.hosts.map((user) => user.id), + eventTypeId: eventType.id, + startDate: limitDateFrom.format(), + endDate: limitDateTo.format(), + rescheduleUid: input.rescheduleUid, + bookingLimits: parsedBucketBookingLimits, + durationLimits: parsedBucketDurationLimits, + }); + } + + bucket.hosts.forEach((user) => { + effectiveLimitsByUserId.set(user.id, bucket.effectiveLimits); + busyTimesFromLimitsByUserId.set( + user.id, + bucketBookings.filter((booking) => booking.userId === user.id) + ); + }); + } + + const availabilityByUserId = new Map< + number, + Awaited>[number] + >(); + + for (const bucket of limitBuckets) { + const effectiveEventType = buildEventTypeWithEffectiveLimits({ + eventType, + effectiveLimits: bucket.effectiveLimits, + }); + + const bucketBusyTimesFromLimits = new Map(); + bucket.hosts.forEach((user) => { + bucketBusyTimesFromLimits.set(user.id, busyTimesFromLimitsByUserId.get(user.id) ?? []); + }); + + const bucketAvailability = await this.dependencies.userAvailabilityService.getUsersAvailability({ + users: bucket.hosts, + query: { + dateFrom: startTime.format(), + dateTo: endTime.format(), + eventTypeId: eventType.id, + afterEventBuffer: bucket.effectiveLimits.afterEventBuffer, + beforeEventBuffer: bucket.effectiveLimits.beforeEventBuffer, + duration: input.duration || 0, + returnDateOverrides: false, + bypassBusyCalendarTimes, + silentlyHandleCalendarFailures: silentCalendarFailures, + mode, + }, + initialData: { + eventType: effectiveEventType, + currentSeats, + rescheduleUid: input.rescheduleUid, + busyTimesFromLimits: bucketBusyTimesFromLimits, + eventTypeForLimits: effectiveEventType, + teamBookingLimits: teamBookingLimitsMap, + teamForBookingLimits, + }, + }); + + bucket.hosts.forEach((user, index) => { + const availability = bucketAvailability[index]; + if (availability) { + availabilityByUserId.set(user.id, availability); + } + }); + } + + return users.map((user) => { + const availability = availabilityByUserId.get(user.id); + if (!availability) { + throw new Error(`Missing availability for user ${user.id}`); + } + return availability; + }); + })() + : await this.dependencies.userAvailabilityService.getUsersAvailability({ + users, + query: { + dateFrom: startTime.format(), + dateTo: endTime.format(), + eventTypeId: eventType.id, + afterEventBuffer: eventType.afterEventBuffer, + beforeEventBuffer: eventType.beforeEventBuffer, + duration: input.duration || 0, + returnDateOverrides: false, + bypassBusyCalendarTimes, + silentlyHandleCalendarFailures: silentCalendarFailures, + mode, + }, + initialData: { + eventType, + currentSeats, + rescheduleUid: input.rescheduleUid, + busyTimesFromLimitsBookings: busyTimesFromLimitsBookingsAllUsers, + busyTimesFromLimits: busyTimesFromLimitsMap, + eventTypeForLimits: eventType && (bookingLimits || durationLimits) ? eventType : null, + teamBookingLimits: teamBookingLimitsMap, + teamForBookingLimits: teamForBookingLimits, + }, + }); /* We get all users working hours and busy slots */ const allUsersAvailability = premappedUsersAvailability.map( ( @@ -1051,6 +1450,8 @@ export class AvailableSlotsService { allUsersAvailability, usersWithCredentials, currentSeats, + effectiveLimitsByUserId, + hasRoundRobinHostLimitOverrides, }; } @@ -1156,6 +1557,8 @@ export class AvailableSlotsService { throw new TRPCError({ code: "NOT_FOUND" }); } + const eventTimeZone = eventType.timeZone ?? eventType.schedule?.timeZone ?? "UTC"; + // Use "slots" mode to enable cache when available for getting calendar availability const mode: CalendarFetchMode = "slots"; if (isEventTypeLoggingEnabled({ eventTypeId: eventType.id })) { @@ -1179,11 +1582,6 @@ export class AvailableSlotsService { prefix: ["getAvailableSlots", `${eventType.id}:${input.usernameList}/${input.eventTypeSlug}`], }); - const startTime = this.getStartTime( - startTimeAdjustedForRollingWindowComputation, - input.timeZone, - eventType.minimumBookingNotice - ); const endTime = input.timeZone === "Etc/GMT" ? dayjs.utc(input.endTime) : dayjs(input.endTime).utc().tz(input.timeZone); // when an empty array is given we should prefer to have it handled as if this wasn't given at all @@ -1247,12 +1645,32 @@ export class AvailableSlotsService { }; } + const getStartTimeForHosts = ( + startTimeInput: string, + hostsForStartTime: typeof allHosts + ) => + this.getStartTime( + startTimeInput, + input.timeZone, + this.getEffectiveMinimumBookingNoticeForHosts({ + eventType, + hosts: hostsForStartTime, + }) + ); + + const startTime = getStartTimeForHosts(startTimeAdjustedForRollingWindowComputation, allHosts); const twoWeeksFromNow = dayjs().add(2, "week"); const hasFallbackRRHosts = eligibleFallbackRRHosts.length > 0 && eligibleFallbackRRHosts.length > eligibleQualifiedRRHosts.length; - let { allUsersAvailability, usersWithCredentials, currentSeats } = + let { + allUsersAvailability, + usersWithCredentials, + currentSeats, + effectiveLimitsByUserId, + hasRoundRobinHostLimitOverrides, + } = await this.calculateHostsAndAvailabilities({ input, eventType, @@ -1261,19 +1679,87 @@ export class AvailableSlotsService { // adjust start time so we can check for available slots in the first two weeks startTime: hasFallbackRRHosts && startTime.isBefore(twoWeeksFromNow) - ? this.getStartTime(dayjs().format(), input.timeZone, eventType.minimumBookingNotice) + ? getStartTimeForHosts(dayjs().format(), allHosts) : startTime, // adjust end time so we can check for available slots in the first two weeks endTime: hasFallbackRRHosts && endTime.isBefore(twoWeeksFromNow) - ? this.getStartTime(twoWeeksFromNow.format(), input.timeZone, eventType.minimumBookingNotice) + ? getStartTimeForHosts(twoWeeksFromNow.format(), allHosts) : endTime, bypassBusyCalendarTimes, silentCalendarFailures, mode, }); - let aggregatedAvailability = getAggregatedAvailability(allUsersAvailability, eventType.schedulingType); + const generateSlotsForHosts = ({ + allUsersAvailability: availabilityByHost, + effectiveLimitsByUserId: effectiveLimitsByHost, + hasRoundRobinHostLimitOverrides: hasOverrides, + slotsStartTime, + inviteeDateForStandardSlots = slotsStartTime, + includeSingleHostOutOfOffice = true, + }: { + allUsersAvailability: typeof allUsersAvailability; + effectiveLimitsByUserId: typeof effectiveLimitsByUserId; + hasRoundRobinHostLimitOverrides: boolean; + slotsStartTime: dayjs.Dayjs; + inviteeDateForStandardSlots?: dayjs.Dayjs; + includeSingleHostOutOfOffice?: boolean; + }) => { + const aggregatedAvailabilityForHosts = getAggregatedAvailability( + availabilityByHost, + eventType.schedulingType + ); + const eventTimeZoneForHosts = + eventType.timeZone || eventType.schedule?.timeZone || availabilityByHost[0]?.timeZone; + + const timeSlotsForHosts = hasOverrides + ? this.getRoundRobinOverrideAwareSlots({ + input, + eventType, + allUsersAvailability: availabilityByHost, + effectiveLimitsByUserId: effectiveLimitsByHost, + startTime: slotsStartTime, + eventTimeZone: eventTimeZoneForHosts, + }) + : getSlots({ + inviteeDate: inviteeDateForStandardSlots, + eventLength: input.duration || eventType.length, + offsetStart: eventType.offsetStart, + dateRanges: aggregatedAvailabilityForHosts, + minimumBookingNotice: eventType.minimumBookingNotice, + frequency: eventType.slotInterval || input.duration || eventType.length, + datesOutOfOffice: + includeSingleHostOutOfOffice && + eventType.schedulingType !== SchedulingType.COLLECTIVE && + eventType.schedulingType !== SchedulingType.ROUND_ROBIN && + availabilityByHost.length <= 1 + ? availabilityByHost[0]?.datesOutOfOffice + : undefined, + showOptimizedSlots: eventType.showOptimizedSlots, + datesOutOfOfficeTimeZone: + includeSingleHostOutOfOffice && + eventType.schedulingType !== SchedulingType.COLLECTIVE && + eventType.schedulingType !== SchedulingType.ROUND_ROBIN && + availabilityByHost.length <= 1 + ? availabilityByHost[0]?.timeZone + : undefined, + }); + + return { + aggregatedAvailability: aggregatedAvailabilityForHosts, + timeSlots: timeSlotsForHosts, + }; + }; + + let { aggregatedAvailability, timeSlots } = generateSlotsForHosts({ + allUsersAvailability, + effectiveLimitsByUserId, + hasRoundRobinHostLimitOverrides, + slotsStartTime: startTime, + inviteeDateForStandardSlots: startTime, + includeSingleHostOutOfOffice: true, + }); // Fairness and Contact Owner have fallbacks because we check for within 2 weeks if (hasFallbackRRHosts) { @@ -1281,30 +1767,36 @@ export class AvailableSlotsService { if (startTime.isBefore(twoWeeksFromNow)) { //check if first two week have availability diff = - aggregatedAvailability.length > 0 - ? aggregatedAvailability[0].start.diff(twoWeeksFromNow, "day") + timeSlots.length > 0 + ? timeSlots[0].time.diff(twoWeeksFromNow, "day") : 1; // no aggregatedAvailability so we diff to +1 } else { // if start time is not within first two weeks, check if there are any available slots - if (!aggregatedAvailability.length) { + if (!timeSlots.length) { // if no available slots check if first two weeks are available, otherwise fallback + const firstTwoWeeksHosts = [...eligibleQualifiedRRHosts, ...eligibleFixedHosts]; + const firstTwoWeeksStartTime = getStartTimeForHosts(dayjs().format(), firstTwoWeeksHosts); const firstTwoWeeksAvailabilities = await this.calculateHostsAndAvailabilities({ input, eventType, - hosts: [...eligibleQualifiedRRHosts, ...eligibleFixedHosts], + hosts: firstTwoWeeksHosts, loggerWithEventDetails, - startTime: dayjs(), - endTime: twoWeeksFromNow, + startTime: firstTwoWeeksStartTime, + endTime: getStartTimeForHosts(twoWeeksFromNow.format(), firstTwoWeeksHosts), bypassBusyCalendarTimes, silentCalendarFailures, mode, }); - if ( - !getAggregatedAvailability( - firstTwoWeeksAvailabilities.allUsersAvailability, - eventType.schedulingType - ).length - ) { + const { timeSlots: firstTwoWeeksTimeSlots } = generateSlotsForHosts({ + allUsersAvailability: firstTwoWeeksAvailabilities.allUsersAvailability, + effectiveLimitsByUserId: firstTwoWeeksAvailabilities.effectiveLimitsByUserId, + hasRoundRobinHostLimitOverrides: + firstTwoWeeksAvailabilities.hasRoundRobinHostLimitOverrides, + slotsStartTime: firstTwoWeeksStartTime, + inviteeDateForStandardSlots: firstTwoWeeksStartTime, + includeSingleHostOutOfOffice: false, + }); + if (!firstTwoWeeksTimeSlots.length) { diff = 1; } } @@ -1323,39 +1815,40 @@ export class AvailableSlotsService { if (diff > 0) { // if the first available slot is more than 2 weeks from now, round robin as normal - ({ allUsersAvailability, usersWithCredentials, currentSeats } = + const fallbackHosts = [...eligibleFallbackRRHosts, ...eligibleFixedHosts]; + const fallbackSlotsStartTime = getStartTimeForHosts( + startTimeAdjustedForRollingWindowComputation, + fallbackHosts + ); + ({ + allUsersAvailability, + usersWithCredentials, + currentSeats, + effectiveLimitsByUserId, + hasRoundRobinHostLimitOverrides, + } = await this.calculateHostsAndAvailabilities({ input, eventType, - hosts: [...eligibleFallbackRRHosts, ...eligibleFixedHosts], + hosts: fallbackHosts, loggerWithEventDetails, - startTime, + startTime: fallbackSlotsStartTime, endTime, bypassBusyCalendarTimes, silentCalendarFailures, mode, })); - aggregatedAvailability = getAggregatedAvailability(allUsersAvailability, eventType.schedulingType); + ({ aggregatedAvailability, timeSlots } = generateSlotsForHosts({ + allUsersAvailability, + effectiveLimitsByUserId, + hasRoundRobinHostLimitOverrides, + slotsStartTime: fallbackSlotsStartTime, + inviteeDateForStandardSlots: startTime, + includeSingleHostOutOfOffice: false, + })); } } - const isTeamEvent = - eventType.schedulingType === SchedulingType.COLLECTIVE || - eventType.schedulingType === SchedulingType.ROUND_ROBIN || - allUsersAvailability.length > 1; - - const timeSlots = getSlots({ - inviteeDate: startTime, - eventLength: input.duration || eventType.length, - offsetStart: eventType.offsetStart, - dateRanges: aggregatedAvailability, - minimumBookingNotice: eventType.minimumBookingNotice, - frequency: eventType.slotInterval || input.duration || eventType.length, - datesOutOfOffice: !isTeamEvent ? allUsersAvailability[0]?.datesOutOfOffice : undefined, - showOptimizedSlots: eventType.showOptimizedSlots, - datesOutOfOfficeTimeZone: !isTeamEvent ? allUsersAvailability[0]?.timeZone : undefined, - }); - let availableTimeSlots: typeof timeSlots = []; const bookerClientUid = ctx?.req?.cookies?.uid; const isRestrictionScheduleFeatureEnabled = await this.checkRestrictionScheduleEnabled( @@ -1553,86 +2046,75 @@ export class AvailableSlotsService { const mapSlotsToDate = withReporting(_mapSlotsToDate.bind(this), "mapSlotsToDate"); const slotsMappedToDate = mapSlotsToDate(); - const availableDates = Object.keys(slotsMappedToDate); - const allDatesWithBookabilityStatus = this.getAllDatesWithBookabilityStatus(availableDates); + const withinBoundsSlotsMappedToDate = hasRoundRobinHostLimitOverrides + ? slotsMappedToDate + : (() => { + const availableDates = Object.keys(slotsMappedToDate); + const allDatesWithBookabilityStatus = this.getAllDatesWithBookabilityStatus(availableDates); + const eventUtcOffset = getUTCOffsetByTimezone(eventTimeZone) ?? 0; + const bookerUtcOffset = input.timeZone ? (getUTCOffsetByTimezone(input.timeZone) ?? 0) : 0; + const periodLimits = calculatePeriodLimits({ + periodType: eventType.periodType, + periodDays: eventType.periodDays, + periodCountCalendarDays: eventType.periodCountCalendarDays, + periodStartDate: eventType.periodStartDate, + periodEndDate: eventType.periodEndDate, + allDatesWithBookabilityStatusInBookerTz: allDatesWithBookabilityStatus, + eventUtcOffset, + bookerUtcOffset, + }); - // timeZone isn't directly set on eventType now(So, it is legacy) - // schedule is always expected to be set for an eventType now so it must never fallback to allUsersAvailability[0].timeZone(fallback is again legacy behavior) - // TODO: Also, handleNewBooking only seems to be using eventType?.schedule?.timeZone which seems to confirm that we should simplify it as well. - const eventTimeZone = - eventType.timeZone || eventType?.schedule?.timeZone || allUsersAvailability?.[0]?.timeZone; + const mapWithinBoundsSlotsToDate = withReporting(() => { + let foundAFutureLimitViolation = false; + const nextSlotsMappedToDate = {} as typeof slotsMappedToDate; + const doesStartFromToday = this.doesRangeStartFromToday(eventType.periodType); - const eventUtcOffset = getUTCOffsetByTimezone(eventTimeZone) ?? 0; - const bookerUtcOffset = input.timeZone ? (getUTCOffsetByTimezone(input.timeZone) ?? 0) : 0; - const periodLimits = calculatePeriodLimits({ - periodType: eventType.periodType, - periodDays: eventType.periodDays, - periodCountCalendarDays: eventType.periodCountCalendarDays, - periodStartDate: eventType.periodStartDate, - periodEndDate: eventType.periodEndDate, - allDatesWithBookabilityStatusInBookerTz: allDatesWithBookabilityStatus, - eventUtcOffset, - bookerUtcOffset, - }); + for (const date of Object.keys(slotsMappedToDate)) { + const slots = slotsMappedToDate[date] ?? []; - const _mapWithinBoundsSlotsToDate = () => { - let foundAFutureLimitViolation = false; - // This should never happen. Just for type safety, we already check in the upper scope - if (!eventType) throw new TRPCError({ code: "NOT_FOUND" }); + if (foundAFutureLimitViolation && doesStartFromToday) { + break; + } - const withinBoundsSlotsMappedToDate = {} as typeof slotsMappedToDate; - const doesStartFromToday = this.doesRangeStartFromToday(eventType.periodType); + const filteredSlots = slots.filter((slot) => { + const isFutureLimitViolationForTheSlot = isTimeViolatingFutureLimit({ + time: slot.time, + periodLimits, + }); - for (const [date, slots] of Object.entries(slotsMappedToDate)) { - if (foundAFutureLimitViolation && doesStartFromToday) { - break; // Instead of continuing the loop, we can break since all future dates will be skipped - } + let isOutOfBounds = false; + try { + isOutOfBounds = isTimeOutOfBounds({ + time: slot.time, + minimumBookingNotice: eventType.minimumBookingNotice, + }); + } catch (error) { + if (error instanceof BookingDateInPastError) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: error.message, + }); + } + throw error; + } - const filteredSlots = slots.filter((slot) => { - const isFutureLimitViolationForTheSlot = isTimeViolatingFutureLimit({ - time: slot.time, - periodLimits, - }); + if (isFutureLimitViolationForTheSlot) { + foundAFutureLimitViolation = true; + } - let isOutOfBounds = false; - try { - isOutOfBounds = isTimeOutOfBounds({ - time: slot.time, - minimumBookingNotice: eventType.minimumBookingNotice, - }); - } catch (error) { - if (error instanceof BookingDateInPastError) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: error.message, + return !isFutureLimitViolationForTheSlot && !isOutOfBounds; }); - } - throw error; - } - if (isFutureLimitViolationForTheSlot) { - foundAFutureLimitViolation = true; - } - - return ( - !isFutureLimitViolationForTheSlot && - // TODO: Perf Optimization: Slots calculation logic already seems to consider the minimum booking notice and past booking time and thus there shouldn't be need to filter out slots here. - !isOutOfBounds - ); - }); + if (filteredSlots.length) { + nextSlotsMappedToDate[date] = filteredSlots; + } + } - if (filteredSlots.length) { - withinBoundsSlotsMappedToDate[date] = filteredSlots; - } - } + return nextSlotsMappedToDate; + }, "mapWithinBoundsSlotsToDate"); - return withinBoundsSlotsMappedToDate; - }; - const mapWithinBoundsSlotsToDate = withReporting( - _mapWithinBoundsSlotsToDate.bind(this), - "mapWithinBoundsSlotsToDate" - ); - const withinBoundsSlotsMappedToDate = mapWithinBoundsSlotsToDate(); + return mapWithinBoundsSlotsToDate(); + })(); const filteredSlotsMappedToDate = this.filterSlotsByRequestedDateRange({ slotsMappedToDate: withinBoundsSlotsMappedToDate,