From 7e416b21496c80649876f6e4bf1dec5fbee05290 Mon Sep 17 00:00:00 2001 From: Manu Rana Date: Thu, 2 Apr 2026 16:19:54 +0530 Subject: [PATCH 01/13] refactor(bookings): add round-robin host effective-limits foundation and flow wiring --- .../resolveRoundRobinHostEffectiveLimits.ts | 158 ++++++++++++ ...solveRoundRobinHostEffectiveLimits.test.ts | 226 ++++++++++++++++++ .../lib/service/RegularBookingService.ts | 33 ++- 3 files changed, 416 insertions(+), 1 deletion(-) create mode 100644 packages/features/bookings/lib/handleNewBooking/resolveRoundRobinHostEffectiveLimits.ts create mode 100644 packages/features/bookings/lib/handleNewBooking/test/resolveRoundRobinHostEffectiveLimits.test.ts diff --git a/packages/features/bookings/lib/handleNewBooking/resolveRoundRobinHostEffectiveLimits.ts b/packages/features/bookings/lib/handleNewBooking/resolveRoundRobinHostEffectiveLimits.ts new file mode 100644 index 00000000000000..fa10ddbd69c297 --- /dev/null +++ b/packages/features/bookings/lib/handleNewBooking/resolveRoundRobinHostEffectiveLimits.ts @@ -0,0 +1,158 @@ +import type { JsonValue } from "@calcom/prisma/client/runtime/library"; +import { PeriodType, SchedulingType } from "@calcom/prisma/enums"; + +type EventLevelLimits = { + minimumBookingNotice: number; + beforeEventBuffer: number; + afterEventBuffer: number; + slotInterval: number | null; + bookingLimits: JsonValue | null; + durationLimits: 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?: JsonValue | null; + durationLimits?: 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?: JsonValue | null; + overrideDurationLimits?: 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[] { + 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 [...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/service/RegularBookingService.ts b/packages/features/bookings/lib/service/RegularBookingService.ts index f34a38e88d8825..2ab6fc4a7380e0 100644 --- a/packages/features/bookings/lib/service/RegularBookingService.ts +++ b/packages/features/bookings/lib/service/RegularBookingService.ts @@ -129,6 +129,10 @@ import { getEventType } from "../handleNewBooking/getEventType"; import type { getEventTypeResponse } from "../handleNewBooking/getEventTypesFromDB"; import { getLocationValuesForDb } from "../handleNewBooking/getLocationValuesForDb"; import { getRequiresConfirmationFlags } from "../handleNewBooking/getRequiresConfirmationFlags"; +import { + getRoundRobinHostLimitOverrides, + groupRoundRobinHostsByEffectiveLimits, +} from "../handleNewBooking/resolveRoundRobinHostEffectiveLimits"; import { getSeatedBooking } from "../handleNewBooking/getSeatedBooking"; import { getVideoCallDetails } from "../handleNewBooking/getVideoCallDetails"; import { handleAppsStatus } from "../handleNewBooking/handleAppsStatus"; @@ -1100,9 +1104,32 @@ async function handler( } }); + // Foundation for per-host limits: currently no host-specific overrides are persisted, + // so this groups all hosts into a single bucket and preserves current behavior. + const roundRobinLimitBuckets = groupRoundRobinHostsByEffectiveLimits({ + schedulingType: eventType.schedulingType, + eventLimits: { + 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, + }, + 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, }); @@ -1112,6 +1139,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)]) ), From a00655616ccfb2b4e8be59eef796d71eef036261 Mon Sep 17 00:00:00 2001 From: Manu Rana Date: Fri, 3 Apr 2026 03:51:35 +0530 Subject: [PATCH 02/13] feat(bookings): plumb host limit override fields into round-robin booking flow --- .../handleNewBooking/getEventTypesFromDB.ts | 11 +++++ .../lib/handleNewBooking/loadUsers.ts | 47 +++++++++++++++---- .../bookings/lib/handleNewBooking/types.ts | 12 +++++ packages/features/users/lib/getRoutedUsers.ts | 46 ++++++++++++++++++ .../migration.sql | 12 +++++ packages/prisma/schema.prisma | 43 ++++++++++------- 6 files changed, 147 insertions(+), 24 deletions(-) create mode 100644 packages/prisma/migrations/20260402201939_add_host_round_robin_limit_overrides/migration.sql diff --git a/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts b/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts index f6130e8627ba82..d4417f02befb15 100644 --- a/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts +++ b/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts @@ -141,6 +141,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/loadUsers.ts b/packages/features/bookings/lib/handleNewBooking/loadUsers.ts index 15de5fedf2a551..c61568a7f8d5c8 100644 --- a/packages/features/bookings/lib/handleNewBooking/loadUsers.ts +++ b/packages/features/bookings/lib/handleNewBooking/loadUsers.ts @@ -81,14 +81,45 @@ const loadUsersByEventType = async (eventType: EventType): Promise ({ - ...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, + }) => ({ + ...user, + isFixed, + priority, + weight, + overrideMinimumBookingNotice, + overrideBeforeEventBuffer, + overrideAfterEventBuffer, + overrideSlotInterval, + overrideBookingLimits, + overrideDurationLimits, + overridePeriodType, + overridePeriodStartDate, + overridePeriodEndDate, + overridePeriodDays, + overridePeriodCountCalendarDays, + createdAt, + groupId, + }) + ); }; const loadDynamicUsers = async (dynamicUserList: string[], currentOrgDomain: string | null) => { 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/users/lib/getRoutedUsers.ts b/packages/features/users/lib/getRoutedUsers.ts index e2b00039740585..7adfa44f54db54 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"; @@ -79,6 +81,17 @@ type BaseHost = { 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; }; @@ -111,6 +124,17 @@ export function getNormalizedHosts({ 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; }[]; 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]) From 4bf1e7921a85ba3403a0944bfcc3461f2a271756 Mon Sep 17 00:00:00 2001 From: Manu Rana Date: Fri, 3 Apr 2026 04:15:44 +0530 Subject: [PATCH 03/13] refactor(users): dedupe normalized host projection for routing paths --- packages/features/users/lib/getRoutedUsers.ts | 104 ++++++++---------- 1 file changed, 47 insertions(+), 57 deletions(-) diff --git a/packages/features/users/lib/getRoutedUsers.ts b/packages/features/users/lib/getRoutedUsers.ts index 7adfa44f54db54..92b893c24d1de5 100644 --- a/packages/features/users/lib/getRoutedUsers.ts +++ b/packages/features/users/lib/getRoutedUsers.ts @@ -96,6 +96,50 @@ type BaseHost = { groupId: string | null; }; +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; @@ -119,25 +163,7 @@ export function getNormalizedHosts ({ - 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, - })), + hosts: eventType.hosts.map(normalizeHostProjection), fallbackHosts: null, }; } else { @@ -170,25 +196,7 @@ 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, - 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, - })); + const hostsWithoutDelegationCredential = eventType.hosts.map(normalizeHostProjection); const firstHost = hostsWithoutDelegationCredential[0]; const firstUserOrgId = await getOrgIdFromMemberOrTeamId({ memberId: firstHost?.user?.id ?? null, @@ -234,25 +242,7 @@ export async function findMatchingHostsWithEventSegment({ hosts, }: { eventType: EventType; - hosts: { - 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; - }[]; + hosts: NormalizedHost[]; }) { const matchingRRTeamMembers = await findMatchingTeamMembersIdsForEventRRSegment({ ...eventType, From 5ca647cd5a9cb12184a4652aea45fe5fec3af53c Mon Sep 17 00:00:00 2001 From: Manu Rana Date: Mon, 13 Apr 2026 02:38:48 +0530 Subject: [PATCH 04/13] feat(event-types): persist per-host round-robin limit overrides --- packages/features/eventtypes/lib/types.ts | 22 ++ .../repositories/eventTypeRepository.ts | 45 ++++ .../eventTypes/heavy/duplicate.handler.ts | 25 ++- .../eventTypes/heavy/update.handler.test.ts | 111 ++++++++++ .../viewer/eventTypes/heavy/update.handler.ts | 196 ++++++++++-------- .../server/routers/viewer/eventTypes/types.ts | 11 + 6 files changed, 322 insertions(+), 88 deletions(-) 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..2783c1896c71f4 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,18 @@ export class EventTypeRepository implements IEventTypesRepository { where: { id, }, + select: { + id: true, + userId: true, + teamId: true, + minimumBookingNotice: true, + schedulingType: true, + hosts: { + select: { + overrideMinimumBookingNotice: true, + }, + }, + }, }); } @@ -1410,6 +1444,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/trpc/server/routers/viewer/eventTypes/heavy/duplicate.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/heavy/duplicate.handler.ts index 52a6f2b8c2c556..6050a679646616 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/heavy/duplicate.handler.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/heavy/duplicate.handler.ts @@ -122,7 +122,30 @@ export const duplicateHandler = async ({ ctx, input }: DuplicateOptions) => { hosts: hosts ? { createMany: { - data: hosts.map(({ eventTypeId: _, ...rest }) => rest), + data: hosts.map((host) => ({ + userId: host.userId, + createdAt: host.createdAt, + scheduleId: host.scheduleId, + isFixed: host.isFixed, + priority: host.priority, + weight: host.weight, + weightAdjustment: host.weightAdjustment, + overrideMinimumBookingNotice: host.overrideMinimumBookingNotice, + overrideBeforeEventBuffer: host.overrideBeforeEventBuffer, + overrideAfterEventBuffer: host.overrideAfterEventBuffer, + overrideSlotInterval: host.overrideSlotInterval, + overrideBookingLimits: + host.overrideBookingLimits === null ? Prisma.JsonNull : host.overrideBookingLimits, + overrideDurationLimits: + host.overrideDurationLimits === null ? Prisma.JsonNull : host.overrideDurationLimits, + overridePeriodType: host.overridePeriodType, + overridePeriodStartDate: host.overridePeriodStartDate, + overridePeriodEndDate: host.overridePeriodEndDate, + overridePeriodDays: host.overridePeriodDays, + overridePeriodCountCalendarDays: host.overridePeriodCountCalendarDays, + groupId: host.groupId, + memberId: host.memberId, + })), }, } : undefined, 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..49d78d70f5f9f6 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 "./update.handler"; 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: null, + overrideDurationLimits: null, + 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..dadb13edb75964 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/heavy/update.handler.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/heavy/update.handler.ts @@ -62,6 +62,111 @@ type UpdateOptions = { input: TUpdateInputSchema; }; +type HostWithOverridesInput = NonNullable[number]; + +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: HostWithOverridesInput; + 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.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; +}; + export type UpdateEventTypeReturn = Awaited>; export const updateHandler = async ({ ctx, input }: UpdateOptions) => { @@ -112,6 +217,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { seatsPerTimeSlot: true, recurringEvent: true, maxActiveBookingsPerBooker: true, + schedulingType: true, fieldTranslations: { select: { field: true, @@ -475,6 +581,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 +609,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(), }); From a06f40a4be8552b631045387dc8b85c7d59d41e1 Mon Sep 17 00:00:00 2001 From: Manu Rana Date: Mon, 13 Apr 2026 02:40:10 +0530 Subject: [PATCH 05/13] feat(event-types-ui): add per-host limit controls in assignment flow --- .../components/AddMembersWithSwitch.tsx | 22 + .../event-types/components/EventType.tsx | 11 + .../assignment/EventTeamAssignmentTab.tsx | 66 +++ .../components/CheckedTeamSelect.tsx | 49 +- .../components/dialogs/HostEditDialogs.tsx | 507 +++++++++++++++++- packages/i18n/locales/en/common.json | 2 + 6 files changed, 651 insertions(+), 6 deletions(-) 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..26defe9d9ba2d1 100644 --- a/apps/web/modules/event-types/components/tabs/assignment/EventTeamAssignmentTab.tsx +++ b/apps/web/modules/event-types/components/tabs/assignment/EventTeamAssignmentTab.tsx @@ -174,6 +174,17 @@ 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, @@ -400,6 +411,17 @@ 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, @@ -668,6 +690,50 @@ const Hosts = ({ ...newValue, scheduleId: existingHost.scheduleId, groupId: existingHost.groupId, + 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/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..6d31d7f2dfc0fe 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,458 @@ 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.getFullYear(), date.getMonth(), date.getDate())); +}; + +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 ?? PeriodType.UNLIMITED, + periodDays: option.overridePeriodDays ?? 30, + periodCountCalendarDays: option.overridePeriodCountCalendarDays ?? true, + periodDates: { + startDate: option.overridePeriodStartDate ?? new Date(), + endDate: option.overridePeriodEndDate ?? new Date(), + }, +}); + +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") ?? PeriodType.UNLIMITED; + 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 === PeriodType.UNLIMITED ? null : periodTypeValue; + 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, + }; + }); + + onChange([...otherGroupsOptions, ...updatedOptions]); + 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/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", From a8c06e9f83b0b37cad8b41c78c7de77a8664550e Mon Sep 17 00:00:00 2001 From: Manu Rana Date: Mon, 13 Apr 2026 02:40:42 +0530 Subject: [PATCH 06/13] feat(bookings): load host override limits in booking pipeline --- .../handleNewBooking/getEventTypesFromDB.ts | 1 + .../handleNewBooking/loadAndValidateUsers.ts | 26 ++- .../lib/handleNewBooking/loadUsers.ts | 150 +++++++++++++----- packages/features/users/lib/getRoutedUsers.ts | 14 +- 4 files changed, 128 insertions(+), 63 deletions(-) diff --git a/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts b/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts index d4417f02befb15..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, 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 c61568a7f8d5c8..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,13 +108,39 @@ 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( ({ @@ -101,10 +162,10 @@ const loadUsersByEventType = async (eventType: EventType): Promise ({ - ...user, + ...withCalendarServiceCredentials(user), isFixed, - priority, - weight, + priority: priority ?? undefined, + weight: weight ?? undefined, overrideMinimumBookingNotice, overrideBeforeEventBuffer, overrideAfterEventBuffer, @@ -122,7 +183,15 @@ const loadUsersByEventType = async (eventType: EventType): Promise { +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."); } @@ -134,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, + })); }; /** @@ -151,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/users/lib/getRoutedUsers.ts b/packages/features/users/lib/getRoutedUsers.ts index 92b893c24d1de5..733fb5590962ff 100644 --- a/packages/features/users/lib/getRoutedUsers.ts +++ b/packages/features/users/lib/getRoutedUsers.ts @@ -77,7 +77,7 @@ type BaseUser = { type BaseHost = { isFixed: boolean; - createdAt: Date; + createdAt: Date | null; priority?: number | null; weight?: number | null; weightAdjustment?: number | null; @@ -96,7 +96,7 @@ type BaseHost = { groupId: string | null; }; -type NormalizedHost = { +export type NormalizedHost = { isFixed: boolean; user: User; priority?: number | null; @@ -196,7 +196,9 @@ export async function getNormalizedHostsWithDelegationCredentials< }; }) { if (eventType.hosts?.length && eventType.schedulingType) { - const hostsWithoutDelegationCredential = eventType.hosts.map(normalizeHostProjection); + const hostsWithoutDelegationCredential: NormalizedHost[] = eventType.hosts.map((host) => + normalizeHostProjection(host) + ); const firstHost = hostsWithoutDelegationCredential[0]; const firstUserOrgId = await getOrgIdFromMemberOrTeamId({ memberId: firstHost?.user?.id ?? null, @@ -211,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]; From 7666def1ff5ae2423e1b3f302311a302caa57d31 Mon Sep 17 00:00:00 2001 From: Manu Rana Date: Mon, 13 Apr 2026 02:41:11 +0530 Subject: [PATCH 07/13] feat(round-robin): enforce effective host limits in booking flows --- .../handleNewBooking/ensureAvailableUsers.ts | 177 ++++++++++++++--- .../resolveRoundRobinHostEffectiveLimits.ts | 33 +++- .../filterHostsByLeadThreshold.ts | 16 +- ...QualifiedHostsWithDelegationCredentials.ts | 49 +++-- .../lib/service/RegularBookingService.ts | 185 ++++++++++++++++-- .../roundRobinManualReassignment.ts | 11 ++ .../ee/round-robin/roundRobinReassignment.ts | 11 ++ 7 files changed, 413 insertions(+), 69 deletions(-) diff --git a/packages/features/bookings/lib/handleNewBooking/ensureAvailableUsers.ts b/packages/features/bookings/lib/handleNewBooking/ensureAvailableUsers.ts index 47e628479c441e..a2acb5279257cf 100644 --- a/packages/features/bookings/lib/handleNewBooking/ensureAvailableUsers.ts +++ b/packages/features/bookings/lib/handleNewBooking/ensureAvailableUsers.ts @@ -3,6 +3,10 @@ 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 { 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 +17,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"; @@ -54,6 +60,58 @@ const hasDateRangeForBooking = ( return dateRangeForBooking; }; +const getEventLevelLimits = ( + eventType: Pick< + getEventTypeResponse, + | "minimumBookingNotice" + | "beforeEventBuffer" + | "afterEventBuffer" + | "slotInterval" + | "bookingLimits" + | "durationLimits" + | "periodType" + | "periodDays" + | "periodCountCalendarDays" + | "periodStartDate" + | "periodEndDate" + > +) => ({ + 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: Omit & { + users: IsFixedAwareUser[]; + }; + effectiveLimits: ReturnType; +}) => ({ + ...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 _ensureAvailableUsers = async ( eventType: Omit & { users: IsFixedAwareUser[]; @@ -74,12 +132,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 +152,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/resolveRoundRobinHostEffectiveLimits.ts b/packages/features/bookings/lib/handleNewBooking/resolveRoundRobinHostEffectiveLimits.ts index fa10ddbd69c297..9acffe37aa6742 100644 --- a/packages/features/bookings/lib/handleNewBooking/resolveRoundRobinHostEffectiveLimits.ts +++ b/packages/features/bookings/lib/handleNewBooking/resolveRoundRobinHostEffectiveLimits.ts @@ -1,4 +1,4 @@ -import type { JsonValue } from "@calcom/prisma/client/runtime/library"; +import type { Prisma } from "@calcom/prisma/client"; import { PeriodType, SchedulingType } from "@calcom/prisma/enums"; type EventLevelLimits = { @@ -6,8 +6,8 @@ type EventLevelLimits = { beforeEventBuffer: number; afterEventBuffer: number; slotInterval: number | null; - bookingLimits: JsonValue | null; - durationLimits: JsonValue | null; + bookingLimits: Prisma.JsonValue | null; + durationLimits: Prisma.JsonValue | null; periodType: PeriodType; periodDays: number | null; periodCountCalendarDays: boolean | null; @@ -20,8 +20,8 @@ export type RoundRobinHostLimitOverrides = { beforeEventBuffer?: number | null; afterEventBuffer?: number | null; slotInterval?: number | null; - bookingLimits?: JsonValue | null; - durationLimits?: JsonValue | null; + bookingLimits?: Prisma.JsonValue | null; + durationLimits?: Prisma.JsonValue | null; periodType?: PeriodType | null; periodDays?: number | null; periodCountCalendarDays?: boolean | null; @@ -34,8 +34,8 @@ export type RoundRobinHostLimitOverrideSource = { overrideBeforeEventBuffer?: number | null; overrideAfterEventBuffer?: number | null; overrideSlotInterval?: number | null; - overrideBookingLimits?: JsonValue | null; - overrideDurationLimits?: JsonValue | null; + overrideBookingLimits?: Prisma.JsonValue | null; + overrideDurationLimits?: Prisma.JsonValue | null; overridePeriodType?: PeriodType | null; overridePeriodDays?: number | null; overridePeriodCountCalendarDays?: boolean | null; @@ -130,6 +130,23 @@ export function groupRoundRobinHostsByEffectiveLimits({ 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) { @@ -154,5 +171,5 @@ export function groupRoundRobinHostsByEffectiveLimits({ }); } - return [...bucketsByKey.values()]; + return Array.from(bucketsByKey.values()); } diff --git a/packages/features/bookings/lib/host-filtering/filterHostsByLeadThreshold.ts b/packages/features/bookings/lib/host-filtering/filterHostsByLeadThreshold.ts index 5d10129e9f99f0..f9dd6ad282b209 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,6 +120,12 @@ export const filterHostsByLeadThreshold = async >({ return hosts; // don't apply filter. } + const hostsWithCreatedAt = hosts.filter(hasCreatedAt); + if (hostsWithCreatedAt.length !== hosts.length) { + log.debug("Skipping lead-threshold filtering because one or more round-robin hosts have no createdAt"); + 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({ @@ -132,7 +142,7 @@ export const filterHostsByLeadThreshold = async >({ })), ], eventType, - allRRHosts: hosts, + allRRHosts: hostsWithCreatedAt, routingFormResponse, }); @@ -161,6 +171,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 2ab6fc4a7380e0..074b58aa250d5e 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"; @@ -132,6 +136,7 @@ import { getRequiresConfirmationFlags } from "../handleNewBooking/getRequiresCon import { getRoundRobinHostLimitOverrides, groupRoundRobinHostsByEffectiveLimits, + resolveRoundRobinHostEffectiveLimits, } from "../handleNewBooking/resolveRoundRobinHostEffectiveLimits"; import { getSeatedBooking } from "../handleNewBooking/getSeatedBooking"; import { getVideoCallDetails } from "../handleNewBooking/getVideoCallDetails"; @@ -159,6 +164,77 @@ function assertNonEmptyArray(arr: T[]): asserts arr is [T, ...T[]] { } } +type EventLimitFields = Pick< + getEventTypeResponse, + | "minimumBookingNotice" + | "beforeEventBuffer" + | "afterEventBuffer" + | "slotInterval" + | "bookingLimits" + | "durationLimits" + | "periodType" + | "periodDays" + | "periodCountCalendarDays" + | "periodStartDate" + | "periodEndDate" +>; + +const getEventLevelLimits = (eventType: 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, +}); + +const buildEventTypeWithEffectiveLimits = (params: { + eventType: T; + effectiveLimits: ReturnType; +}) => { + 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, + }; +}; + +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) { @@ -839,14 +915,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, @@ -910,6 +978,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]; @@ -919,7 +990,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, @@ -1104,8 +1185,6 @@ async function handler( } }); - // Foundation for per-host limits: currently no host-specific overrides are persisted, - // so this groups all hosts into a single bucket and preserves current behavior. const roundRobinLimitBuckets = groupRoundRobinHostsByEffectiveLimits({ schedulingType: eventType.schedulingType, eventLimits: { @@ -1295,6 +1374,90 @@ 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"); + + for (const selectedUser of users) { + 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) { + continue; + } + + const parsedBookingLimits = parseBookingLimit(effectiveLimits.bookingLimits); + const parsedDurationLimits = parseDurationLimit(effectiveLimits.durationLimits); + + if (!parsedBookingLimits && !parsedDurationLimits) { + continue; + } + + 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) { + continue; + } + + throw new HttpError({ + statusCode: 403, + message: + conflictingBusyTime.source?.includes("Duration") === true + ? "duration_limit_reached" + : "booking_limit_reached", + }); + } + } + // 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, From f094cf4ed6b277504549bb8ca39352723b3d12b8 Mon Sep 17 00:00:00 2001 From: Manu Rana Date: Mon, 13 Apr 2026 02:41:28 +0530 Subject: [PATCH 08/13] feat(slots): bucket hosts by effective booking limits --- .../viewer/slots/isAvailable.handler.ts | 32 +- .../trpc/server/routers/viewer/slots/util.ts | 750 ++++++++++++++---- 2 files changed, 637 insertions(+), 145 deletions(-) diff --git a/packages/trpc/server/routers/viewer/slots/isAvailable.handler.ts b/packages/trpc/server/routers/viewer/slots/isAvailable.handler.ts index bf2ce030c3903a..4e2ac23fe00800 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.min( + ...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..63943202b39f17 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; + } - return startTimeMin.isAfter(startTime) ? startTimeMin.tz(timeZone) : startTime; + 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]); + } + + 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,112 @@ 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) + ); + }); + } + + return await Promise.all( + users.map(async (user) => { + const effectiveLimits = effectiveLimitsByUserId.get(user.id) ?? eventLevelLimits; + + return await this.dependencies.userAvailabilityService.getUserAvailability( + { + dateFrom: startTime, + dateTo: endTime, + eventTypeId: eventType.id, + afterEventBuffer: effectiveLimits.afterEventBuffer, + beforeEventBuffer: effectiveLimits.beforeEventBuffer, + duration: input.duration || 0, + returnDateOverrides: false, + bypassBusyCalendarTimes, + silentlyHandleCalendarFailures: silentCalendarFailures, + mode, + }, + { + user, + eventType: buildEventTypeWithEffectiveLimits({ + eventType, + effectiveLimits, + }), + currentSeats, + rescheduleUid: input.rescheduleUid, + busyTimesFromLimitsBookings: busyTimesFromLimitsByUserId.get(user.id) ?? [], + teamBookingLimits: teamBookingLimitsMap, + teamForBookingLimits: teamForBookingLimits, + } + ); + }) + ); + })() + : 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 +1426,8 @@ export class AvailableSlotsService { allUsersAvailability, usersWithCredentials, currentSeats, + effectiveLimitsByUserId, + hasRoundRobinHostLimitOverrides, }; } @@ -1179,11 +1556,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 +1619,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,12 +1653,12 @@ 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, @@ -1274,6 +1666,38 @@ export class AvailableSlotsService { }); let aggregatedAvailability = getAggregatedAvailability(allUsersAvailability, eventType.schedulingType); + const eventTimeZone = + eventType.timeZone || eventType?.schedule?.timeZone || allUsersAvailability?.[0]?.timeZone; + let timeSlots = hasRoundRobinHostLimitOverrides + ? this.getRoundRobinOverrideAwareSlots({ + input, + eventType, + allUsersAvailability, + effectiveLimitsByUserId, + startTime, + eventTimeZone, + }) + : getSlots({ + inviteeDate: startTime, + eventLength: input.duration || eventType.length, + offsetStart: eventType.offsetStart, + dateRanges: aggregatedAvailability, + minimumBookingNotice: eventType.minimumBookingNotice, + frequency: eventType.slotInterval || input.duration || eventType.length, + datesOutOfOffice: + eventType.schedulingType === SchedulingType.COLLECTIVE || + eventType.schedulingType === SchedulingType.ROUND_ROBIN || + allUsersAvailability.length > 1 + ? undefined + : allUsersAvailability[0]?.datesOutOfOffice, + showOptimizedSlots: eventType.showOptimizedSlots, + datesOutOfOfficeTimeZone: + eventType.schedulingType === SchedulingType.COLLECTIVE || + eventType.schedulingType === SchedulingType.ROUND_ROBIN || + allUsersAvailability.length > 1 + ? undefined + : allUsersAvailability[0]?.timeZone, + }); // Fairness and Contact Owner have fallbacks because we check for within 2 weeks if (hasFallbackRRHosts) { @@ -1281,30 +1705,63 @@ 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 firstTwoWeeksAvailabilities = await this.calculateHostsAndAvailabilities({ input, eventType, hosts: [...eligibleQualifiedRRHosts, ...eligibleFixedHosts], loggerWithEventDetails, - startTime: dayjs(), - endTime: twoWeeksFromNow, + startTime: getStartTimeForHosts(dayjs().format(), [ + ...eligibleQualifiedRRHosts, + ...eligibleFixedHosts, + ]), + endTime: getStartTimeForHosts(twoWeeksFromNow.format(), [ + ...eligibleQualifiedRRHosts, + ...eligibleFixedHosts, + ]), bypassBusyCalendarTimes, silentCalendarFailures, mode, }); - if ( - !getAggregatedAvailability( - firstTwoWeeksAvailabilities.allUsersAvailability, - eventType.schedulingType - ).length - ) { + const firstTwoWeeksTimeSlots = firstTwoWeeksAvailabilities.hasRoundRobinHostLimitOverrides + ? this.getRoundRobinOverrideAwareSlots({ + input, + eventType, + allUsersAvailability: firstTwoWeeksAvailabilities.allUsersAvailability, + effectiveLimitsByUserId: firstTwoWeeksAvailabilities.effectiveLimitsByUserId, + startTime: getStartTimeForHosts(dayjs().format(), [ + ...eligibleQualifiedRRHosts, + ...eligibleFixedHosts, + ]), + eventTimeZone: + eventType.timeZone || + eventType.schedule?.timeZone || + firstTwoWeeksAvailabilities.allUsersAvailability[0]?.timeZone, + }) + : getSlots({ + inviteeDate: getStartTimeForHosts(dayjs().format(), [ + ...eligibleQualifiedRRHosts, + ...eligibleFixedHosts, + ]), + eventLength: input.duration || eventType.length, + offsetStart: eventType.offsetStart, + dateRanges: getAggregatedAvailability( + firstTwoWeeksAvailabilities.allUsersAvailability, + eventType.schedulingType + ), + minimumBookingNotice: eventType.minimumBookingNotice, + frequency: eventType.slotInterval || input.duration || eventType.length, + datesOutOfOffice: undefined, + showOptimizedSlots: eventType.showOptimizedSlots, + datesOutOfOfficeTimeZone: undefined, + }); + if (!firstTwoWeeksTimeSlots.length) { diff = 1; } } @@ -1323,39 +1780,55 @@ 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 } = + ({ + allUsersAvailability, + usersWithCredentials, + currentSeats, + effectiveLimitsByUserId, + hasRoundRobinHostLimitOverrides, + } = await this.calculateHostsAndAvailabilities({ input, eventType, hosts: [...eligibleFallbackRRHosts, ...eligibleFixedHosts], loggerWithEventDetails, - startTime, + startTime: getStartTimeForHosts(startTimeAdjustedForRollingWindowComputation, [ + ...eligibleFallbackRRHosts, + ...eligibleFixedHosts, + ]), endTime, bypassBusyCalendarTimes, silentCalendarFailures, mode, })); aggregatedAvailability = getAggregatedAvailability(allUsersAvailability, eventType.schedulingType); + timeSlots = hasRoundRobinHostLimitOverrides + ? this.getRoundRobinOverrideAwareSlots({ + input, + eventType, + allUsersAvailability, + effectiveLimitsByUserId, + startTime: getStartTimeForHosts(startTimeAdjustedForRollingWindowComputation, [ + ...eligibleFallbackRRHosts, + ...eligibleFixedHosts, + ]), + eventTimeZone: + eventType.timeZone || eventType.schedule?.timeZone || allUsersAvailability?.[0]?.timeZone, + }) + : getSlots({ + inviteeDate: startTime, + eventLength: input.duration || eventType.length, + offsetStart: eventType.offsetStart, + dateRanges: aggregatedAvailability, + minimumBookingNotice: eventType.minimumBookingNotice, + frequency: eventType.slotInterval || input.duration || eventType.length, + datesOutOfOffice: undefined, + showOptimizedSlots: eventType.showOptimizedSlots, + datesOutOfOfficeTimeZone: undefined, + }); } } - 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 +2026,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, From c8d740a537b88b99b825d0936bd1654a27c49216 Mon Sep 17 00:00:00 2001 From: Manu Rana Date: Mon, 13 Apr 2026 00:26:54 +0000 Subject: [PATCH 09/13] fix(round-robin): address review findings for host overrides and slots --- .../assignment/EventTeamAssignmentTab.tsx | 1 + .../handleNewBooking/ensureAvailableUsers.ts | 56 +---- .../eventTypeEffectiveLimits.ts | 57 +++++ .../filterHostsByLeadThreshold.ts | 19 +- .../lib/service/RegularBookingService.ts | 55 +---- .../components/dialogs/HostEditDialogs.tsx | 35 +-- .../repositories/eventTypeRepository.ts | 10 + .../eventTypes/heavy/duplicate.handler.ts | 31 +-- .../eventTypes/heavy/hostDataMapping.ts | 127 +++++++++++ .../eventTypes/heavy/update.handler.test.ts | 4 +- .../viewer/eventTypes/heavy/update.handler.ts | 107 +--------- .../viewer/slots/isAvailable.handler.ts | 2 +- .../trpc/server/routers/viewer/slots/util.ts | 202 +++++++++--------- 13 files changed, 340 insertions(+), 366 deletions(-) create mode 100644 packages/features/bookings/lib/handleNewBooking/eventTypeEffectiveLimits.ts create mode 100644 packages/trpc/server/routers/viewer/eventTypes/heavy/hostDataMapping.ts 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 26defe9d9ba2d1..72bbc498bf4a8f 100644 --- a/apps/web/modules/event-types/components/tabs/assignment/EventTeamAssignmentTab.tsx +++ b/apps/web/modules/event-types/components/tabs/assignment/EventTeamAssignmentTab.tsx @@ -690,6 +690,7 @@ const Hosts = ({ ...newValue, scheduleId: existingHost.scheduleId, groupId: existingHost.groupId, + location: newValue.location !== undefined ? newValue.location : existingHost.location, overrideMinimumBookingNotice: newValue.overrideMinimumBookingNotice !== undefined ? newValue.overrideMinimumBookingNotice diff --git a/packages/features/bookings/lib/handleNewBooking/ensureAvailableUsers.ts b/packages/features/bookings/lib/handleNewBooking/ensureAvailableUsers.ts index a2acb5279257cf..399d81dbb417f8 100644 --- a/packages/features/bookings/lib/handleNewBooking/ensureAvailableUsers.ts +++ b/packages/features/bookings/lib/handleNewBooking/ensureAvailableUsers.ts @@ -7,6 +7,10 @@ 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"; @@ -60,58 +64,6 @@ const hasDateRangeForBooking = ( return dateRangeForBooking; }; -const getEventLevelLimits = ( - eventType: Pick< - getEventTypeResponse, - | "minimumBookingNotice" - | "beforeEventBuffer" - | "afterEventBuffer" - | "slotInterval" - | "bookingLimits" - | "durationLimits" - | "periodType" - | "periodDays" - | "periodCountCalendarDays" - | "periodStartDate" - | "periodEndDate" - > -) => ({ - 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: Omit & { - users: IsFixedAwareUser[]; - }; - effectiveLimits: ReturnType; -}) => ({ - ...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 _ensureAvailableUsers = async ( eventType: Omit & { users: IsFixedAwareUser[]; 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/host-filtering/filterHostsByLeadThreshold.ts b/packages/features/bookings/lib/host-filtering/filterHostsByLeadThreshold.ts index f9dd6ad282b209..5b698297356889 100644 --- a/packages/features/bookings/lib/host-filtering/filterHostsByLeadThreshold.ts +++ b/packages/features/bookings/lib/host-filtering/filterHostsByLeadThreshold.ts @@ -122,25 +122,24 @@ export const filterHostsByLeadThreshold = async >({ const hostsWithCreatedAt = hosts.filter(hasCreatedAt); if (hostsWithCreatedAt.length !== hosts.length) { - log.debug("Skipping lead-threshold filtering because one or more round-robin hosts have no createdAt"); - return hosts; + 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"); + return []; } // 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: hostsWithCreatedAt, routingFormResponse, diff --git a/packages/features/bookings/lib/service/RegularBookingService.ts b/packages/features/bookings/lib/service/RegularBookingService.ts index 074b58aa250d5e..6f5fa7d2809b7d 100644 --- a/packages/features/bookings/lib/service/RegularBookingService.ts +++ b/packages/features/bookings/lib/service/RegularBookingService.ts @@ -144,6 +144,10 @@ 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"; @@ -164,57 +168,6 @@ function assertNonEmptyArray(arr: T[]): asserts arr is [T, ...T[]] { } } -type EventLimitFields = Pick< - getEventTypeResponse, - | "minimumBookingNotice" - | "beforeEventBuffer" - | "afterEventBuffer" - | "slotInterval" - | "bookingLimits" - | "durationLimits" - | "periodType" - | "periodDays" - | "periodCountCalendarDays" - | "periodStartDate" - | "periodEndDate" ->; - -const getEventLevelLimits = (eventType: 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, -}); - -const buildEventTypeWithEffectiveLimits = (params: { - eventType: T; - effectiveLimits: ReturnType; -}) => { - 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, - }; -}; - const doesBookingConflictWithBusyTimes = ({ start, end, diff --git a/packages/features/eventtypes/components/dialogs/HostEditDialogs.tsx b/packages/features/eventtypes/components/dialogs/HostEditDialogs.tsx index 6d31d7f2dfc0fe..4f784ea20cd064 100644 --- a/packages/features/eventtypes/components/dialogs/HostEditDialogs.tsx +++ b/packages/features/eventtypes/components/dialogs/HostEditDialogs.tsx @@ -332,13 +332,17 @@ const buildLimitOverrideDefaults = (option: CheckedSelectOption): Partial 0 ? option.overrideDurationLimits : undefined, - periodType: option.overridePeriodType ?? PeriodType.UNLIMITED, - periodDays: option.overridePeriodDays ?? 30, - periodCountCalendarDays: option.overridePeriodCountCalendarDays ?? true, - periodDates: { - startDate: option.overridePeriodStartDate ?? new Date(), - endDate: option.overridePeriodEndDate ?? new Date(), - }, + 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 = ( @@ -383,7 +387,7 @@ export const LimitOverridesDialog = ( const hostGroupToSort = groupedHosts[option.groupId ?? DEFAULT_GROUP_ID]; const bookingLimitsValue = limitForm.getValues("bookingLimits"); const durationLimitsValue = limitForm.getValues("durationLimits"); - const periodTypeValue = limitForm.getValues("periodType") ?? PeriodType.UNLIMITED; + const periodTypeValue = limitForm.getValues("periodType"); const periodDatesValue = limitForm.getValues("periodDates"); const periodDaysValue = limitForm.getValues("periodDays"); const periodCountCalendarDaysValue = limitForm.getValues("periodCountCalendarDays"); @@ -392,7 +396,7 @@ export const LimitOverridesDialog = ( bookingLimitsValue && Object.keys(bookingLimitsValue).length > 0 ? bookingLimitsValue : null; const nextDurationLimits = durationLimitsValue && Object.keys(durationLimitsValue).length > 0 ? durationLimitsValue : null; - const nextPeriodType = periodTypeValue === PeriodType.UNLIMITED ? null : periodTypeValue; + const nextPeriodType = periodTypeValue ?? null; const nextPeriodStartDate = nextPeriodType === PeriodType.RANGE ? toUtcMidnight(periodDatesValue?.startDate) : null; const nextPeriodEndDate = @@ -613,7 +617,7 @@ export const LimitOverridesDialog = ( { label: t("rolling_window"), value: PeriodType.ROLLING_WINDOW }, { label: t("within_date_range"), value: PeriodType.RANGE }, ]} - onChange={(selected) => onChange(selected?.value ?? PeriodType.UNLIMITED)} + onChange={(selected) => onChange(selected?.value)} value={ [ { label: t("unlimited"), value: PeriodType.UNLIMITED }, @@ -700,13 +704,10 @@ export const LimitOverridesDialog = ( limitForm.reset({ bookingLimits: undefined, durationLimits: undefined, - periodType: PeriodType.UNLIMITED, - periodDays: 30, - periodCountCalendarDays: true, - periodDates: { - startDate: new Date(), - endDate: new Date(), - }, + periodType: undefined, + periodDays: undefined, + periodCountCalendarDays: undefined, + periodDates: undefined, }); applyOverrides({ minimumBookingNoticeValue: null, diff --git a/packages/features/eventtypes/repositories/eventTypeRepository.ts b/packages/features/eventtypes/repositories/eventTypeRepository.ts index 2783c1896c71f4..5b05af15b29284 100644 --- a/packages/features/eventtypes/repositories/eventTypeRepository.ts +++ b/packages/features/eventtypes/repositories/eventTypeRepository.ts @@ -1198,6 +1198,16 @@ export class EventTypeRepository implements IEventTypesRepository { hosts: { select: { overrideMinimumBookingNotice: true, + overrideBeforeEventBuffer: true, + overrideAfterEventBuffer: true, + overrideSlotInterval: true, + overrideBookingLimits: true, + overrideDurationLimits: true, + overridePeriodType: true, + overridePeriodDays: true, + overridePeriodCountCalendarDays: true, + overridePeriodStartDate: true, + overridePeriodEndDate: true, }, }, }, 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 6050a679646616..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,30 +123,12 @@ export const duplicateHandler = async ({ ctx, input }: DuplicateOptions) => { hosts: hosts ? { createMany: { - data: hosts.map((host) => ({ - userId: host.userId, - createdAt: host.createdAt, - scheduleId: host.scheduleId, - isFixed: host.isFixed, - priority: host.priority, - weight: host.weight, - weightAdjustment: host.weightAdjustment, - overrideMinimumBookingNotice: host.overrideMinimumBookingNotice, - overrideBeforeEventBuffer: host.overrideBeforeEventBuffer, - overrideAfterEventBuffer: host.overrideAfterEventBuffer, - overrideSlotInterval: host.overrideSlotInterval, - overrideBookingLimits: - host.overrideBookingLimits === null ? Prisma.JsonNull : host.overrideBookingLimits, - overrideDurationLimits: - host.overrideDurationLimits === null ? Prisma.JsonNull : host.overrideDurationLimits, - overridePeriodType: host.overridePeriodType, - overridePeriodStartDate: host.overridePeriodStartDate, - overridePeriodEndDate: host.overridePeriodEndDate, - overridePeriodDays: host.overridePeriodDays, - overridePeriodCountCalendarDays: host.overridePeriodCountCalendarDays, - groupId: host.groupId, - memberId: host.memberId, - })), + 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 49d78d70f5f9f6..f6b46bd5ce2dfd 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 @@ -130,8 +130,8 @@ describe("update.handler", () => { overrideBeforeEventBuffer: null, overrideAfterEventBuffer: null, overrideSlotInterval: null, - overrideBookingLimits: null, - overrideDurationLimits: null, + overrideBookingLimits: Prisma.JsonNull, + overrideDurationLimits: Prisma.JsonNull, overridePeriodType: null, overridePeriodStartDate: null, overridePeriodEndDate: null, 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 dadb13edb75964..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,111 +62,7 @@ type UpdateOptions = { }; input: TUpdateInputSchema; }; - -type HostWithOverridesInput = NonNullable[number]; - -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: HostWithOverridesInput; - 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.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; -}; +export { mapHostCreateData, mapHostUpdateData } from "./hostDataMapping"; export type UpdateEventTypeReturn = Awaited>; diff --git a/packages/trpc/server/routers/viewer/slots/isAvailable.handler.ts b/packages/trpc/server/routers/viewer/slots/isAvailable.handler.ts index 4e2ac23fe00800..4d3bfcb0821e4b 100644 --- a/packages/trpc/server/routers/viewer/slots/isAvailable.handler.ts +++ b/packages/trpc/server/routers/viewer/slots/isAvailable.handler.ts @@ -46,7 +46,7 @@ export const isAvailableHandler = async ({ const minimumBookingNotice = eventType.schedulingType === SchedulingType.ROUND_ROBIN && eventType.hosts.length > 0 - ? Math.min( + ? Math.max( ...eventType.hosts.map((host) => resolveRoundRobinHostEffectiveLimits({ schedulingType: eventType.schedulingType, diff --git a/packages/trpc/server/routers/viewer/slots/util.ts b/packages/trpc/server/routers/viewer/slots/util.ts index 63943202b39f17..2b1aaf55a40c92 100644 --- a/packages/trpc/server/routers/viewer/slots/util.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -1665,39 +1665,75 @@ export class AvailableSlotsService { mode, }); - let aggregatedAvailability = getAggregatedAvailability(allUsersAvailability, eventType.schedulingType); - const eventTimeZone = - eventType.timeZone || eventType?.schedule?.timeZone || allUsersAvailability?.[0]?.timeZone; - let timeSlots = hasRoundRobinHostLimitOverrides - ? this.getRoundRobinOverrideAwareSlots({ - input, - eventType, - allUsersAvailability, - effectiveLimitsByUserId, - startTime, - eventTimeZone, - }) - : getSlots({ - inviteeDate: startTime, - eventLength: input.duration || eventType.length, - offsetStart: eventType.offsetStart, - dateRanges: aggregatedAvailability, - minimumBookingNotice: eventType.minimumBookingNotice, - frequency: eventType.slotInterval || input.duration || eventType.length, - datesOutOfOffice: - eventType.schedulingType === SchedulingType.COLLECTIVE || - eventType.schedulingType === SchedulingType.ROUND_ROBIN || - allUsersAvailability.length > 1 - ? undefined - : allUsersAvailability[0]?.datesOutOfOffice, - showOptimizedSlots: eventType.showOptimizedSlots, - datesOutOfOfficeTimeZone: - eventType.schedulingType === SchedulingType.COLLECTIVE || - eventType.schedulingType === SchedulingType.ROUND_ROBIN || - allUsersAvailability.length > 1 - ? undefined - : allUsersAvailability[0]?.timeZone, - }); + 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) { @@ -1712,55 +1748,28 @@ export class AvailableSlotsService { // if start time is not within first two weeks, check if there are any available slots 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: getStartTimeForHosts(dayjs().format(), [ - ...eligibleQualifiedRRHosts, - ...eligibleFixedHosts, - ]), - endTime: getStartTimeForHosts(twoWeeksFromNow.format(), [ - ...eligibleQualifiedRRHosts, - ...eligibleFixedHosts, - ]), + startTime: firstTwoWeeksStartTime, + endTime: getStartTimeForHosts(twoWeeksFromNow.format(), firstTwoWeeksHosts), bypassBusyCalendarTimes, silentCalendarFailures, mode, }); - const firstTwoWeeksTimeSlots = firstTwoWeeksAvailabilities.hasRoundRobinHostLimitOverrides - ? this.getRoundRobinOverrideAwareSlots({ - input, - eventType, - allUsersAvailability: firstTwoWeeksAvailabilities.allUsersAvailability, - effectiveLimitsByUserId: firstTwoWeeksAvailabilities.effectiveLimitsByUserId, - startTime: getStartTimeForHosts(dayjs().format(), [ - ...eligibleQualifiedRRHosts, - ...eligibleFixedHosts, - ]), - eventTimeZone: - eventType.timeZone || - eventType.schedule?.timeZone || - firstTwoWeeksAvailabilities.allUsersAvailability[0]?.timeZone, - }) - : getSlots({ - inviteeDate: getStartTimeForHosts(dayjs().format(), [ - ...eligibleQualifiedRRHosts, - ...eligibleFixedHosts, - ]), - eventLength: input.duration || eventType.length, - offsetStart: eventType.offsetStart, - dateRanges: getAggregatedAvailability( - firstTwoWeeksAvailabilities.allUsersAvailability, - eventType.schedulingType - ), - minimumBookingNotice: eventType.minimumBookingNotice, - frequency: eventType.slotInterval || input.duration || eventType.length, - datesOutOfOffice: undefined, - showOptimizedSlots: eventType.showOptimizedSlots, - datesOutOfOfficeTimeZone: undefined, - }); + const { timeSlots: firstTwoWeeksTimeSlots } = generateSlotsForHosts({ + allUsersAvailability: firstTwoWeeksAvailabilities.allUsersAvailability, + effectiveLimitsByUserId: firstTwoWeeksAvailabilities.effectiveLimitsByUserId, + hasRoundRobinHostLimitOverrides: + firstTwoWeeksAvailabilities.hasRoundRobinHostLimitOverrides, + slotsStartTime: firstTwoWeeksStartTime, + inviteeDateForStandardSlots: firstTwoWeeksStartTime, + includeSingleHostOutOfOffice: false, + }); if (!firstTwoWeeksTimeSlots.length) { diff = 1; } @@ -1780,6 +1789,11 @@ export class AvailableSlotsService { if (diff > 0) { // if the first available slot is more than 2 weeks from now, round robin as normal + const fallbackHosts = [...eligibleFallbackRRHosts, ...eligibleFixedHosts]; + const fallbackSlotsStartTime = getStartTimeForHosts( + startTimeAdjustedForRollingWindowComputation, + fallbackHosts + ); ({ allUsersAvailability, usersWithCredentials, @@ -1790,42 +1804,22 @@ export class AvailableSlotsService { await this.calculateHostsAndAvailabilities({ input, eventType, - hosts: [...eligibleFallbackRRHosts, ...eligibleFixedHosts], + hosts: fallbackHosts, loggerWithEventDetails, - startTime: getStartTimeForHosts(startTimeAdjustedForRollingWindowComputation, [ - ...eligibleFallbackRRHosts, - ...eligibleFixedHosts, - ]), + startTime: fallbackSlotsStartTime, endTime, bypassBusyCalendarTimes, silentCalendarFailures, mode, })); - aggregatedAvailability = getAggregatedAvailability(allUsersAvailability, eventType.schedulingType); - timeSlots = hasRoundRobinHostLimitOverrides - ? this.getRoundRobinOverrideAwareSlots({ - input, - eventType, - allUsersAvailability, - effectiveLimitsByUserId, - startTime: getStartTimeForHosts(startTimeAdjustedForRollingWindowComputation, [ - ...eligibleFallbackRRHosts, - ...eligibleFixedHosts, - ]), - eventTimeZone: - eventType.timeZone || eventType.schedule?.timeZone || allUsersAvailability?.[0]?.timeZone, - }) - : getSlots({ - inviteeDate: startTime, - eventLength: input.duration || eventType.length, - offsetStart: eventType.offsetStart, - dateRanges: aggregatedAvailability, - minimumBookingNotice: eventType.minimumBookingNotice, - frequency: eventType.slotInterval || input.duration || eventType.length, - datesOutOfOffice: undefined, - showOptimizedSlots: eventType.showOptimizedSlots, - datesOutOfOfficeTimeZone: undefined, - }); + ({ aggregatedAvailability, timeSlots } = generateSlotsForHosts({ + allUsersAvailability, + effectiveLimitsByUserId, + hasRoundRobinHostLimitOverrides, + slotsStartTime: fallbackSlotsStartTime, + inviteeDateForStandardSlots: startTime, + includeSingleHostOutOfOffice: false, + })); } } From 2cd0a29b2b53d19d19dac1b1b87140879968c005 Mon Sep 17 00:00:00 2001 From: Manu Rana Date: Wed, 15 Apr 2026 10:27:40 +0000 Subject: [PATCH 10/13] fix(round-robin): resolve post-merge review findings --- .../assignment/EventTeamAssignmentTab.tsx | 2 + .../filterHostsByLeadThreshold.ts | 6 +- .../lib/service/RegularBookingService.ts | 14 +-- .../components/dialogs/HostEditDialogs.tsx | 34 ++++++- .../eventTypes/heavy/update.handler.test.ts | 2 +- .../trpc/server/routers/viewer/slots/util.ts | 88 ++++++++++++------- 6 files changed, 97 insertions(+), 49 deletions(-) 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 72bbc498bf4a8f..fad9f3bf5fee7c 100644 --- a/apps/web/modules/event-types/components/tabs/assignment/EventTeamAssignmentTab.tsx +++ b/apps/web/modules/event-types/components/tabs/assignment/EventTeamAssignmentTab.tsx @@ -188,6 +188,7 @@ const FixedHosts = ({ // if host was already added, retain scheduleId and groupId scheduleId: host?.scheduleId || teamMember.defaultScheduleId, groupId: host?.groupId || null, + location: host?.location ?? null, }; }), { shouldDirty: true } @@ -425,6 +426,7 @@ const RoundRobinHosts = ({ // if host was already added, retain scheduleId and groupId scheduleId: host?.scheduleId || teamMember.defaultScheduleId, groupId: host?.groupId || groupId, + location: host?.location ?? null, }; }), { shouldDirty: true } diff --git a/packages/features/bookings/lib/host-filtering/filterHostsByLeadThreshold.ts b/packages/features/bookings/lib/host-filtering/filterHostsByLeadThreshold.ts index 5b698297356889..457c022fd8493c 100644 --- a/packages/features/bookings/lib/host-filtering/filterHostsByLeadThreshold.ts +++ b/packages/features/bookings/lib/host-filtering/filterHostsByLeadThreshold.ts @@ -128,8 +128,10 @@ export const filterHostsByLeadThreshold = async >({ } if (hostsWithCreatedAt.length === 0) { - log.debug("No round-robin hosts with createdAt available after lead-threshold pre-filtering"); - return []; + 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 diff --git a/packages/features/bookings/lib/service/RegularBookingService.ts b/packages/features/bookings/lib/service/RegularBookingService.ts index 6f5fa7d2809b7d..942cba81d7d6f8 100644 --- a/packages/features/bookings/lib/service/RegularBookingService.ts +++ b/packages/features/bookings/lib/service/RegularBookingService.ts @@ -1140,19 +1140,7 @@ async function handler( const roundRobinLimitBuckets = groupRoundRobinHostsByEffectiveLimits({ schedulingType: eventType.schedulingType, - eventLimits: { - 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, - }, + eventLimits: getEventLevelLimits(eventType), hosts: nonFixedUsers, getHostOverrides: getRoundRobinHostLimitOverrides, }); diff --git a/packages/features/eventtypes/components/dialogs/HostEditDialogs.tsx b/packages/features/eventtypes/components/dialogs/HostEditDialogs.tsx index 4f784ea20cd064..e57873c1f37cb6 100644 --- a/packages/features/eventtypes/components/dialogs/HostEditDialogs.tsx +++ b/packages/features/eventtypes/components/dialogs/HostEditDialogs.tsx @@ -485,7 +485,39 @@ export const LimitOverridesDialog = ( }; }); - onChange([...otherGroupsOptions, ...updatedOptions]); + 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); }; 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 f6b46bd5ce2dfd..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 @@ -3,7 +3,7 @@ import { describe, it, expect } from "vitest"; import { Prisma } from "@calcom/prisma/client"; import { SchedulingType } from "@calcom/prisma/enums"; -import { mapHostCreateData, mapHostUpdateData } from "./update.handler"; +import { mapHostCreateData, mapHostUpdateData } from "./hostDataMapping"; describe("update.handler", () => { describe("bookingFields null to Prisma.DbNull transformation", () => { diff --git a/packages/trpc/server/routers/viewer/slots/util.ts b/packages/trpc/server/routers/viewer/slots/util.ts index 2b1aaf55a40c92..84dfe70baca750 100644 --- a/packages/trpc/server/routers/viewer/slots/util.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -1345,38 +1345,62 @@ export class AvailableSlotsService { }); } - return await Promise.all( - users.map(async (user) => { - const effectiveLimits = effectiveLimitsByUserId.get(user.id) ?? eventLevelLimits; - - return await this.dependencies.userAvailabilityService.getUserAvailability( - { - dateFrom: startTime, - dateTo: endTime, - eventTypeId: eventType.id, - afterEventBuffer: effectiveLimits.afterEventBuffer, - beforeEventBuffer: effectiveLimits.beforeEventBuffer, - duration: input.duration || 0, - returnDateOverrides: false, - bypassBusyCalendarTimes, - silentlyHandleCalendarFailures: silentCalendarFailures, - mode, - }, - { - user, - eventType: buildEventTypeWithEffectiveLimits({ - eventType, - effectiveLimits, - }), - currentSeats, - rescheduleUid: input.rescheduleUid, - busyTimesFromLimitsBookings: busyTimesFromLimitsByUserId.get(user.id) ?? [], - teamBookingLimits: teamBookingLimitsMap, - teamForBookingLimits: teamForBookingLimits, - } - ); - }) - ); + 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, From 52840edfc94179a5a3757452caa1272044ab4136 Mon Sep 17 00:00:00 2001 From: Manu Rana Date: Wed, 15 Apr 2026 10:48:31 +0000 Subject: [PATCH 11/13] fix(round-robin): retry host selection on limit conflicts --- .../lib/service/RegularBookingService.ts | 83 ++++++++++++++++--- 1 file changed, 72 insertions(+), 11 deletions(-) diff --git a/packages/features/bookings/lib/service/RegularBookingService.ts b/packages/features/bookings/lib/service/RegularBookingService.ts index 942cba81d7d6f8..62cbc025505a01 100644 --- a/packages/features/bookings/lib/service/RegularBookingService.ts +++ b/packages/features/bookings/lib/service/RegularBookingService.ts @@ -964,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({ @@ -1152,6 +1154,7 @@ async function handler( hosts: bucketedNonFixedUsers, hostGroups: eventType.hostGroups, }); + luckyUserPoolsForRetry = luckyUserPools; const notAvailableLuckyUsers: typeof users = []; @@ -1243,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); @@ -1252,6 +1256,7 @@ async function handler( } } else { luckyUsers.push(newLuckyUser); + selectedLuckyUsersByGroupId.set(groupId, newLuckyUser); luckUserFound = true; } } @@ -1320,7 +1325,7 @@ async function handler( const busyTimesService = getBusyTimesService(); const bookingDuration = dayjs(reqBody.end).diff(dayjs(reqBody.start), "minute"); - for (const selectedUser of users) { + const getLimitConflictMessageForUser = async (selectedUser: IsFixedAwareUser) => { const effectiveLimits = resolveRoundRobinHostEffectiveLimits({ schedulingType: eventType.schedulingType, eventLimits: eventLevelLimits, @@ -1340,14 +1345,14 @@ async function handler( ); if (skipEventLimitsCheck) { - continue; + return null; } const parsedBookingLimits = parseBookingLimit(effectiveLimits.bookingLimits); const parsedDurationLimits = parseDurationLimit(effectiveLimits.durationLimits); if (!parsedBookingLimits && !parsedDurationLimits) { - continue; + return null; } const limitTimeZone = effectiveEventType.schedule?.timeZone ?? selectedUser.timeZone ?? "UTC"; @@ -1386,16 +1391,72 @@ async function handler( }); if (!conflictingBusyTime) { - continue; + return null; } - throw new HttpError({ - statusCode: 403, - message: - conflictingBusyTime.source?.includes("Duration") === true - ? "duration_limit_reached" - : "booking_limit_reached", - }); + return conflictingBusyTime.source?.includes("Duration") === true + ? "duration_limit_reached" + : "booking_limit_reached"; + }; + + if (luckyUserPoolsForRetry && selectedLuckyUsersByGroupId.size > 0) { + const fixedUsers = users.filter((user) => user.isFixed); + 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; + } + + 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, + }); + } } } From 3b5b74154054e149a930380baad3848639aa1bb3 Mon Sep 17 00:00:00 2001 From: Manu Rana Date: Wed, 15 Apr 2026 10:54:13 +0000 Subject: [PATCH 12/13] fix(eventtypes,slots): stabilize UTC date normalization and timezone scope --- .../features/eventtypes/components/dialogs/HostEditDialogs.tsx | 2 +- packages/trpc/server/routers/viewer/slots/util.ts | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/features/eventtypes/components/dialogs/HostEditDialogs.tsx b/packages/features/eventtypes/components/dialogs/HostEditDialogs.tsx index e57873c1f37cb6..726a38c6698540 100644 --- a/packages/features/eventtypes/components/dialogs/HostEditDialogs.tsx +++ b/packages/features/eventtypes/components/dialogs/HostEditDialogs.tsx @@ -316,7 +316,7 @@ const toUtcMidnight = (date: Date | undefined | null) => { return null; } - return new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())); + return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); }; const buildLimitOverrideDefaults = (option: CheckedSelectOption): Partial => ({ diff --git a/packages/trpc/server/routers/viewer/slots/util.ts b/packages/trpc/server/routers/viewer/slots/util.ts index 84dfe70baca750..b4c8e8a49f9216 100644 --- a/packages/trpc/server/routers/viewer/slots/util.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -1557,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 })) { From 192c88b0ac073f4dc609b43687721188816e1863 Mon Sep 17 00:00:00 2001 From: Manu Rana Date: Wed, 15 Apr 2026 11:12:48 +0000 Subject: [PATCH 13/13] fix(round-robin): recheck recurring availability for retry candidates --- .../lib/service/RegularBookingService.ts | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) diff --git a/packages/features/bookings/lib/service/RegularBookingService.ts b/packages/features/bookings/lib/service/RegularBookingService.ts index 62cbc025505a01..a34f353de0b198 100644 --- a/packages/features/bookings/lib/service/RegularBookingService.ts +++ b/packages/features/bookings/lib/service/RegularBookingService.ts @@ -1399,8 +1399,106 @@ async function handler( : "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[] = []; @@ -1418,6 +1516,10 @@ async function handler( continue; } + if (!(await isRecurringRetryCandidateEligible(candidate))) { + continue; + } + const conflictMessage = await getLimitConflictMessageForUser(candidate); if (conflictMessage) { lastConflictMessage = conflictMessage;