From e29b7e83b8eae19114a39ca1d6aa6a21408c1094 Mon Sep 17 00:00:00 2001 From: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com> Date: Fri, 8 Aug 2025 13:56:13 +0200 Subject: [PATCH] feat: Round Robin groups (#22296) * add Add Group button * add host groups to schema * UI for host groups * raname groups to hostGroups * schema update * show groups in assignment tab * add no group hosts to Group 1 * add dummy group for non group hosts * fix type errors * use two dimensional array for luckyUserPools * fix empty array * group RR hosts in handleNewBooking * improve logic for grouping lucky users * find all lucky users of all groups * allow several RR hosts on booking * clean up migrations * create helper function * group hosts for slots logic * add group logic to loading available slots * adding hosts to groups * add groupId to hostSchema * disable hosts from other groups * handle groups in checkedTeamSelect * fix adding hosts to groups * remove and add groups * show hosts if there are no groups * fixing adding first group with existing hosts * show groups empty groups correctly * UI upddate fixes * fix adding hosts to existing first host group * small fixes + code clean up * add availability fix with test * create new round-robin test file * disable reassignment * fix losing fixed hosts * fix updating weights and priorities * disable load balancing with Round Robin Groups * automatically disable load balancing in update handler * allRRHosts should only include hosts from same group * fix type errors * fix type error * fix tests * fix type error * remove undefined from groupId type * type changes * add tests for hostGroups * add tests for host groups * fixes * fix type errors with undefined groupId * remove seperate host groups prop * fix editing weights * remove console.log * code clean up * improve getAggregatedAvailability tests * throw error when no available hosts in a group * add fixme comment * create constant for DEFAULT_GROUP_ID * clean up code * mock default_group_id for unit tests * don't show fixed hosts in edit weights side bar * add DEFAULT_GROUP_ID to mock test-setup * remove unused index variable * code clean up * fix updating host groups * fix imports * add default_group_id to mocks * add uuid() to zod schema * remove unused code * fix singular translation key * remove unnessary !! * Revert formatting changes * add additional tests for bookingActions * use createMany * import DEFAULT_GROUP_ID for mocks * fix mocks * clean up EventTeamAssignmentTab * fix type errors in tests * fix mocks * remove constants.example.test.ts * fix type error * add missing groupId * fix margin * clean up empty host groups * fix constants mock * useCalback * use reduce * extract handlers into seperate functions * fix handler functions * fix border radius * fix type error in CheckForEmptyAssignment * fix type error --------- Co-authored-by: CarinaWolli --- .../components/booking/BookingListItem.tsx | 10 +- .../components/booking/bookingActions.test.ts | 57 +- apps/web/components/booking/bookingActions.ts | 5 +- apps/web/public/static/locales/en/common.json | 5 +- apps/web/test/lib/getSchedule.test.ts | 11 +- .../utils/bookingScenario/bookingScenario.ts | 22 + docs/api-reference/v2/openapi.json | 4351 ++++------------- .../src/rules/avoid-web-storage.ts | 14 +- .../features/bookings/lib/handleNewBooking.ts | 168 +- .../handleNewBooking/getEventTypesFromDB.ts | 8 + .../lib/handleNewBooking/loadUsers.ts | 3 +- .../test/buildEventForTeamEventType.test.ts | 2 +- .../test/team-bookings/round-robin.test.ts | 652 +++ .../team-bookings/seatedRoundRobin.test.ts | 172 - .../bookings/lib/handleNewBooking/types.ts | 1 + .../roundRobinManualReassignment.ts | 8 + .../ee/round-robin/roundRobinReassignment.ts | 8 + .../components/AddMembersWithSwitch.tsx | 42 +- .../components/CheckedTeamSelect.tsx | 40 +- .../EditWeightsForAllTeamMembers.tsx | 10 +- .../eventtypes/components/EventType.tsx | 1 + .../eventtypes/components/HostEditDialogs.tsx | 133 +- .../assignment/EventTeamAssignmentTab.tsx | 400 +- packages/features/eventtypes/lib/types.ts | 5 + .../schedules/components/Schedule.tsx | 1 - packages/lib/__mocks__/constants.ts | 4 +- .../filterHostsBySameRoundRobinHost.test.ts | 62 + .../filterHostsBySameRoundRobinHost.ts | 19 +- ...fiedHostsWithDelegationCredentials.test.ts | 3 + ...QualifiedHostsWithDelegationCredentials.ts | 14 +- packages/lib/bookings/getRoutedUsers.ts | 5 + packages/lib/bookings/hostGroupUtils.ts | 39 + packages/lib/constants.ts | 2 + packages/lib/defaultEvents.ts | 1 + packages/lib/errorCodes.ts | 2 +- .../lib/getAggregatedAvailability.test.ts | 260 + packages/lib/getAggregatedAvailability.ts | 26 +- .../server/repository/eventTypeRepository.ts | 20 + .../event-types/hooks/useEventTypeForm.ts | 1 + .../wrappers/EventTypeWebWrapper.tsx | 7 +- .../migration.sql | 25 + packages/prisma/schema.prisma | 37 +- .../routers/viewer/bookings/get.handler.ts | 6 + .../server/routers/viewer/eventTypes/types.ts | 7 + .../viewer/eventTypes/update.handler.ts | 78 +- .../server/routers/viewer/eventTypes/util.ts | 1 + .../trpc/server/routers/viewer/slots/util.ts | 4 +- .../viewer/teams/hasTeamPlan.handler.ts | 1 - 48 files changed, 2761 insertions(+), 3992 deletions(-) create mode 100644 packages/features/bookings/lib/handleNewBooking/test/team-bookings/round-robin.test.ts delete mode 100644 packages/features/bookings/lib/handleNewBooking/test/team-bookings/seatedRoundRobin.test.ts create mode 100644 packages/lib/bookings/hostGroupUtils.ts create mode 100644 packages/prisma/migrations/20250704081718_add_host_groups/migration.sql diff --git a/apps/web/components/booking/BookingListItem.tsx b/apps/web/components/booking/BookingListItem.tsx index f172d15721..b5c97a7bbb 100644 --- a/apps/web/components/booking/BookingListItem.tsx +++ b/apps/web/components/booking/BookingListItem.tsx @@ -546,9 +546,7 @@ function BookingListItem(booking: BookingItemProps) { -
+
{/* Time and Badges for mobile */}
@@ -893,7 +891,7 @@ const FirstAttendee = ({ ) : ( e.stopPropagation()}> {user.name || user.email} @@ -1081,7 +1079,7 @@ const GroupedAttendees = (groupedAttendeeProps: GroupedAttendeeProps) => { /> ))} -
+
+
+ +
+ ); + })} + + )}
); @@ -507,16 +653,18 @@ const Hosts = ({ ); }, [schedulingType, setValue, getValues, submitCount]); - // To ensure existing host do not loose its scheduleId property, whenever a new host of same type is added. + // To ensure existing host do not loose its scheduleId and groupId properties, whenever a new host of same type is added. // This is because the host is created from list option in CheckedHostField component. const updatedHosts = (changedHosts: Host[]) => { const existingHosts = getValues("hosts"); return changedHosts.map((newValue) => { const existingHost = existingHosts.find((host: Host) => host.userId === newValue.userId); + return existingHost ? { ...newValue, scheduleId: existingHost.scheduleId, + groupId: existingHost.groupId, } : newValue; }); @@ -560,7 +708,7 @@ const Hosts = ({ teamMembers={teamMembers} value={value} onChange={(changeValue) => { - const hosts = [...value.filter((host: Host) => host.isFixed), ...updatedHosts(changeValue)]; + const hosts = [...value.filter((host: Host) => host.isFixed), ...changeValue]; onChange(hosts); }} assignAllTeamMembers={assignAllTeamMembers} @@ -633,11 +781,34 @@ export const EventTeamAssignmentTab = ({ setAssignAllTeamMembers(false); }; + const handleSchedulingTypeChange = useCallback( + (schedulingType: SchedulingType | undefined, onChange: (value: SchedulingType | undefined) => void) => { + if (schedulingType) { + onChange(schedulingType); + resetRROptions(); + } + }, + [setValue, setAssignAllTeamMembers] + ); + + const handleMaxLeadThresholdChange = (val: string, onChange: (value: number | null) => void) => { + if (val === "loadBalancing") { + onChange(3); + } else { + onChange(null); + } + }; + const schedulingType = useWatch({ control, name: "schedulingType", }); + const hostGroups = useWatch({ + control, + name: "hostGroups", + }); + return (
{team && !isManagedEventType && ( @@ -679,10 +850,7 @@ export const EventTeamAssignmentTab = ({ customClassNames?.assignmentType?.schedulingTypeSelect?.select )} innerClassNames={customClassNames?.assignmentType?.schedulingTypeSelect?.innerClassNames} - onChange={(val) => { - onChange(val?.value); - resetRROptions(); - }} + onChange={(val) => handleSchedulingTypeChange(val?.value, onChange)} /> )} /> @@ -701,10 +869,7 @@ export const EventTeamAssignmentTab = ({ name="maxLeadThreshold" render={({ field: { value, onChange } }) => ( { - if (val === "loadBalancing") onChange(3); - else onChange(null); - }} + onValueChange={(val) => handleMaxLeadThresholdChange(val, onChange)} className="mt-1 flex flex-col gap-4">

{t("rr_distribution_method_availability_description")}

- {!!( - eventType.team?.rrTimestampBasis && - eventType.team?.rrTimestampBasis !== RRTimestampBasis.CREATED_AT - ) ? ( - + {(eventType.team?.rrTimestampBasis && + eventType.team?.rrTimestampBasis !== RRTimestampBasis.CREATED_AT) || + hostGroups?.length > 1 ? ( +
; availability?: AvailabilityOption; bookerLayouts: BookerLayoutSettings; diff --git a/packages/features/schedules/components/Schedule.tsx b/packages/features/schedules/components/Schedule.tsx index 388de8f32d..06fa8994e1 100644 --- a/packages/features/schedules/components/Schedule.tsx +++ b/packages/features/schedules/components/Schedule.tsx @@ -20,7 +20,6 @@ import { useLocale } from "@calcom/lib/hooks/useLocale"; import { weekdayNames } from "@calcom/lib/weekday"; import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery"; import type { TimeRange } from "@calcom/types/schedule"; - import cn from "@calcom/ui/classNames"; import { Button } from "@calcom/ui/components/button"; import { Dropdown, DropdownMenuContent, DropdownMenuTrigger } from "@calcom/ui/components/dropdown"; diff --git a/packages/lib/__mocks__/constants.ts b/packages/lib/__mocks__/constants.ts index 9cb3f84239..47ac4b76eb 100644 --- a/packages/lib/__mocks__/constants.ts +++ b/packages/lib/__mocks__/constants.ts @@ -15,13 +15,13 @@ const initialConstants = { IS_SELF_HOSTED: false, SEO_IMG_DEFAULT: "https://cal.com/og-image.png", SEO_IMG_OGIMG: "https://cal.com/og-image-wide.png", - SEO_IMG_LOGO: "https://cal.com/logo.png", CURRENT_TIMEZONE: "Europe/London", APP_NAME: "Cal.com", BOOKER_NUMBER_OF_DAYS_TO_LOAD: 14, PUBLIC_QUICK_AVAILABILITY_ROLLOUT: 100, SINGLE_ORG_SLUG: "", -} as typeof constants; + DEFAULT_GROUP_ID: "default_group_id", +} as Partial; export const mockedConstants = { ...initialConstants }; diff --git a/packages/lib/bookings/filterHostsBySameRoundRobinHost.test.ts b/packages/lib/bookings/filterHostsBySameRoundRobinHost.test.ts index cd246856b8..86be937c1b 100644 --- a/packages/lib/bookings/filterHostsBySameRoundRobinHost.test.ts +++ b/packages/lib/bookings/filterHostsBySameRoundRobinHost.test.ts @@ -64,4 +64,66 @@ describe("filterHostsBySameRoundRobinHost", () => { }) ).resolves.toStrictEqual([hosts[0]]); }); + + // Tests for bookings that have more than one host + describe("Fixed hosts and round robin groups support", () => { + it("should return organizer and attendee hosts", async () => { + prismaMock.booking.findFirst.mockResolvedValue({ + userId: 1, + attendees: [ + { email: "host2@acme.com" }, + { email: "host3@acme.com" }, + { email: "attendee@example.com" }, // Non-host attendee + ], + }); + + const hosts = [ + { isFixed: false as const, createdAt: new Date(), user: { id: 1, email: "host1@acme.com" } }, + { isFixed: false as const, createdAt: new Date(), user: { id: 2, email: "host2@acme.com" } }, + { isFixed: false as const, createdAt: new Date(), user: { id: 3, email: "host3@acme.com" } }, + { isFixed: false as const, createdAt: new Date(), user: { id: 4, email: "host4@acme.com" } }, + ]; + + const result = await filterHostsBySameRoundRobinHost({ + hosts, + rescheduleUid: "some-uid", + rescheduleWithSameRoundRobinHost: true, + routedTeamMemberIds: null, + }); + + // Should return organizer host (id: 1) and attendee hosts (ids: 2, 3) + expect(result).toHaveLength(3); + expect(result).toEqual([ + expect.objectContaining({ user: { id: 1, email: "host1@acme.com" } }), // organizer + expect.objectContaining({ user: { id: 2, email: "host2@acme.com" } }), // attendee + expect.objectContaining({ user: { id: 3, email: "host3@acme.com" } }), // attendee + ]); + }); + + it("should return only organizer host when no attendees match current hosts", async () => { + prismaMock.booking.findFirst.mockResolvedValue({ + userId: 1, + attendees: [ + { email: "attendee1@example.com" }, // Non-host attendee + { email: "attendee2@example.com" }, // Non-host attendee + ], + }); + + const hosts = [ + { isFixed: false as const, createdAt: new Date(), user: { id: 1, email: "host1@acme.com" } }, + { isFixed: false as const, createdAt: new Date(), user: { id: 2, email: "host2@acme.com" } }, + ]; + + const result = await filterHostsBySameRoundRobinHost({ + hosts, + rescheduleUid: "some-uid", + rescheduleWithSameRoundRobinHost: true, + routedTeamMemberIds: null, + }); + + // Should return only organizer host + expect(result).toHaveLength(1); + expect(result[0]).toEqual(expect.objectContaining({ user: { id: 1, email: "host1@acme.com" } })); + }); + }); }); diff --git a/packages/lib/bookings/filterHostsBySameRoundRobinHost.ts b/packages/lib/bookings/filterHostsBySameRoundRobinHost.ts index 76208c0427..001c44ca80 100644 --- a/packages/lib/bookings/filterHostsBySameRoundRobinHost.ts +++ b/packages/lib/bookings/filterHostsBySameRoundRobinHost.ts @@ -6,7 +6,7 @@ import { isRerouting } from "./routing/utils"; export const filterHostsBySameRoundRobinHost = async < T extends { isFixed: false; // ensure no fixed hosts are passed. - user: { id: number }; + user: { id: number; email: string }; } >({ hosts, @@ -35,8 +35,23 @@ export const filterHostsBySameRoundRobinHost = async < }, select: { userId: true, + attendees: { + select: { + email: true, + }, + }, }, }); - return hosts.filter((host) => host.user.id === originalRescheduledBooking?.userId || 0); + if (!originalRescheduledBooking) { + return hosts; + } + + const attendeeEmails = originalRescheduledBooking.attendees?.map((attendee) => attendee.email) || []; + + return hosts.filter((host) => { + const isOrganizer = host.user.id === originalRescheduledBooking.userId; + const isAttendee = attendeeEmails.includes(host.user.email); + return isOrganizer || isAttendee; + }); }; diff --git a/packages/lib/bookings/findQualifiedHostsWithDelegationCredentials.test.ts b/packages/lib/bookings/findQualifiedHostsWithDelegationCredentials.test.ts index 7e6229ee4a..90b534df4f 100644 --- a/packages/lib/bookings/findQualifiedHostsWithDelegationCredentials.test.ts +++ b/packages/lib/bookings/findQualifiedHostsWithDelegationCredentials.test.ts @@ -35,6 +35,7 @@ describe("findQualifiedHostsWithDelegationCredentials", async () => { }, priority: undefined, weight: undefined, + groupId: null, }, { isFixed: false, @@ -47,6 +48,7 @@ describe("findQualifiedHostsWithDelegationCredentials", async () => { }, priority: undefined, weight: undefined, + groupId: null, }, { isFixed: false, @@ -59,6 +61,7 @@ describe("findQualifiedHostsWithDelegationCredentials", async () => { }, priority: undefined, weight: undefined, + groupId: null, }, ]; diff --git a/packages/lib/bookings/findQualifiedHostsWithDelegationCredentials.ts b/packages/lib/bookings/findQualifiedHostsWithDelegationCredentials.ts index 3061292c18..b2c5bab219 100644 --- a/packages/lib/bookings/findQualifiedHostsWithDelegationCredentials.ts +++ b/packages/lib/bookings/findQualifiedHostsWithDelegationCredentials.ts @@ -17,6 +17,7 @@ type Host = { createdAt: Date; priority?: number | null; weight?: number | null; + groupId: string | null; } & { user: T; }; @@ -81,6 +82,7 @@ const _findQualifiedHostsWithDelegationCredentials = async < createdAt: Date | null; priority?: number | null; weight?: number | null; + groupId?: string | null; user: Omit & { credentials: CredentialForCalendarService[] }; }[]; fixedHosts: { @@ -88,6 +90,7 @@ const _findQualifiedHostsWithDelegationCredentials = async < createdAt: Date | null; priority?: number | null; weight?: number | null; + groupId?: string | null; user: Omit & { credentials: CredentialForCalendarService[] }; }[]; // all hosts we want to fallback to including the qualifiedRRHosts (fairness + crm contact owner) @@ -96,6 +99,7 @@ const _findQualifiedHostsWithDelegationCredentials = async < createdAt: Date | null; priority?: number | null; weight?: number | null; + groupId?: string | null; user: Omit & { credentials: CredentialForCalendarService[] }; }[]; }> => { @@ -132,11 +136,11 @@ const _findQualifiedHostsWithDelegationCredentials = async < } const hostsAfterSegmentMatching = applyFilterWithFallback( - roundRobinHosts, + hostsAfterRescheduleWithSameRoundRobinHost, (await findMatchingHostsWithEventSegment({ eventType, - hosts: roundRobinHosts, - })) as typeof roundRobinHosts + hosts: hostsAfterRescheduleWithSameRoundRobinHost, + })) as typeof hostsAfterRescheduleWithSameRoundRobinHost ); if (hostsAfterSegmentMatching.length === 1) { @@ -147,7 +151,9 @@ const _findQualifiedHostsWithDelegationCredentials = async < } //if segment matching doesn't return any hosts we fall back to all round robin hosts - const officalRRHosts = hostsAfterSegmentMatching.length ? hostsAfterSegmentMatching : roundRobinHosts; + const officalRRHosts = hostsAfterSegmentMatching.length + ? hostsAfterSegmentMatching + : hostsAfterRescheduleWithSameRoundRobinHost; const hostsAfterContactOwnerMatching = applyFilterWithFallback( officalRRHosts, diff --git a/packages/lib/bookings/getRoutedUsers.ts b/packages/lib/bookings/getRoutedUsers.ts index 86533a2bd5..7511caf5ff 100644 --- a/packages/lib/bookings/getRoutedUsers.ts +++ b/packages/lib/bookings/getRoutedUsers.ts @@ -76,6 +76,7 @@ type BaseHost = { weight?: number | null; weightAdjustment?: number | null; user: User; + groupId: string | null; }; export type EventType = { @@ -107,6 +108,7 @@ export function getNormalizedHosts({ priority?: number | null; weight?: number | null; createdAt: Date | null; + groupId: string | null; }[]; }) { const matchingRRTeamMembers = await findMatchingTeamMembersIdsForEventRRSegment({ diff --git a/packages/lib/bookings/hostGroupUtils.ts b/packages/lib/bookings/hostGroupUtils.ts new file mode 100644 index 0000000000..71d75057aa --- /dev/null +++ b/packages/lib/bookings/hostGroupUtils.ts @@ -0,0 +1,39 @@ +import { DEFAULT_GROUP_ID } from "@calcom/lib/constants"; + +export function groupHostsByGroupId({ + hosts, + hostGroups, +}: { + hosts: T[]; + hostGroups?: { id: string }[]; +}): Record { + const groups: Record = {}; + + const hasGroups = hostGroups && hostGroups.length > 0; + + if (hasGroups) { + hostGroups.forEach((group) => { + groups[group.id] = []; + }); + } else { + groups[DEFAULT_GROUP_ID] = []; + } + + hosts.forEach((host) => { + const groupId = hasGroups && host.groupId ? host.groupId : DEFAULT_GROUP_ID; + if (groups[groupId]) { + groups[groupId].push(host); + } + }); + + return groups; +} + +export function getHostsFromOtherGroups( + hosts: readonly T[], + groupId: string | null +): T[] { + return hosts.filter( + (host) => (groupId && (!host.groupId || host.groupId !== groupId)) || (!groupId && host.groupId) + ); +} diff --git a/packages/lib/constants.ts b/packages/lib/constants.ts index 56b578625c..ce54e31f3e 100644 --- a/packages/lib/constants.ts +++ b/packages/lib/constants.ts @@ -230,3 +230,5 @@ export const IS_SMS_CREDITS_ENABLED = export const DATABASE_CHUNK_SIZE = parseInt(process.env.DATABASE_CHUNK_SIZE || "25", 10); export const NEXTJS_CACHE_TTL = 3600; // 1 hour + +export const DEFAULT_GROUP_ID = "default_group_id"; diff --git a/packages/lib/defaultEvents.ts b/packages/lib/defaultEvents.ts index 7f90985e92..f8f5f3c212 100644 --- a/packages/lib/defaultEvents.ts +++ b/packages/lib/defaultEvents.ts @@ -145,6 +145,7 @@ const commons = { instantMeetingScheduleId: null, instantMeetingParameters: [], eventTypeColor: null, + hostGroups: [], }; export const dynamicEvent = { diff --git a/packages/lib/errorCodes.ts b/packages/lib/errorCodes.ts index 058c05f050..b015ae9164 100644 --- a/packages/lib/errorCodes.ts +++ b/packages/lib/errorCodes.ts @@ -5,7 +5,7 @@ export enum ErrorCode { RequestBodyWithouEnd = "request_body_end_time_internal_error", AlreadySignedUpForBooking = "already_signed_up_for_this_booking_error", FixedHostsUnavailableForBooking = "fixed_hosts_unavailable_for_booking", - RoundRobinHostsUnavailableForBooking = "round_robin_hosts_unavailable_for_booking", + RoundRobinHostsUnavailableForBooking = "round_robin_host_unavailable_for_booking", EventTypeNotFound = "event_type_not_found_error", BookingNotFound = "booking_not_found_error", BookingSeatsFull = "booking_seats_full_error", diff --git a/packages/lib/getAggregatedAvailability.test.ts b/packages/lib/getAggregatedAvailability.test.ts index 8062c3654e..539c0100ec 100644 --- a/packages/lib/getAggregatedAvailability.test.ts +++ b/packages/lib/getAggregatedAvailability.test.ts @@ -209,4 +209,264 @@ describe("getAggregatedAvailability", () => { expect(isAvailable(result, timeRangeToCheckAvailable)).toBe(true); expect(result.length).toBe(1); }); + + it("requires at least one RR host from each group to be available", () => { + // Test scenario with two groups: + // Group 1: Host A (available 11:00-11:30), Host B (available 12:00-12:30) + // Group 2: Host C (available 11:15-11:45), Host D (available 12:15-12:45) + // Fixed host: available 11:00-13:00 + // Expected: Only slots where at least one host from each group is available + const userAvailability = [ + { + dateRanges: [], + oooExcludedDateRanges: [ + { start: dayjs("2025-01-23T11:00:00.000Z"), end: dayjs("2025-01-23T13:00:00.000Z") }, + ], + user: { isFixed: true }, + }, + { + dateRanges: [], + oooExcludedDateRanges: [ + { start: dayjs("2025-01-23T11:00:00.000Z"), end: dayjs("2025-01-23T11:30:00.000Z") }, + ], + user: { isFixed: false, groupId: "group1" }, + }, + { + dateRanges: [], + oooExcludedDateRanges: [ + { start: dayjs("2025-01-23T12:00:00.000Z"), end: dayjs("2025-01-23T12:30:00.000Z") }, + ], + user: { isFixed: false, groupId: "group1" }, + }, + { + dateRanges: [], + oooExcludedDateRanges: [ + { start: dayjs("2025-01-23T11:15:00.000Z"), end: dayjs("2025-01-23T11:45:00.000Z") }, + ], + user: { isFixed: false, groupId: "group2" }, + }, + { + dateRanges: [], + oooExcludedDateRanges: [ + { start: dayjs("2025-01-23T12:15:00.000Z"), end: dayjs("2025-01-23T12:45:00.000Z") }, + ], + user: { isFixed: false, groupId: "group2" }, + }, + ]; + + const result = getAggregatedAvailability(userAvailability, "ROUND_ROBIN"); + + const timeRangeAvailable = { + start: dayjs("2025-01-23T11:15:00.000Z"), + end: dayjs("2025-01-23T11:30:00.000Z"), + }; + expect(isAvailable(result, timeRangeAvailable)).toBe(true); + + const timeRangeAvailable2 = { + start: dayjs("2025-01-23T12:15:00.000Z"), + end: dayjs("2025-01-23T12:30:00.000Z"), + }; + expect(isAvailable(result, timeRangeAvailable2)).toBe(true); + + // Should NOT be available when only one group has hosts available + const timeRangeNotAvailable = { + start: dayjs("2025-01-23T11:00:00.000Z"), + end: dayjs("2025-01-23T11:15:00.000Z"), + }; + expect(isAvailable(result, timeRangeNotAvailable)).toBe(false); + + // Should NOT be available when only one group has hosts available + const timeRangeNotAvailable2 = { + start: dayjs("2025-01-23T12:30:00.000Z"), + end: dayjs("2025-01-23T12:45:00.000Z"), + }; + expect(isAvailable(result, timeRangeNotAvailable2)).toBe(false); + }); + + it("handles single group with multiple RR hosts (union behavior)", () => { + // Test that when all RR hosts are in the same group, we get union behavior + // Host A: available 11:00-11:20, 16:10-16:30 + // Host B: available 11:15-11:30, 13:20-13:30 + // Expected: Union of both hosts' availability + const userAvailability = [ + { + dateRanges: [], + oooExcludedDateRanges: [ + { start: dayjs("2025-01-23T11:00:00.000Z"), end: dayjs("2025-01-23T11:20:00.000Z") }, + { start: dayjs("2025-01-23T16:10:00.000Z"), end: dayjs("2025-01-23T16:30:00.000Z") }, + ], + user: { isFixed: false }, + }, + { + dateRanges: [], + oooExcludedDateRanges: [ + { start: dayjs("2025-01-23T11:15:00.000Z"), end: dayjs("2025-01-23T11:30:00.000Z") }, + { start: dayjs("2025-01-23T13:20:00.000Z"), end: dayjs("2025-01-23T13:30:00.000Z") }, + ], + user: { isFixed: false }, + }, + ]; + + const result = getAggregatedAvailability(userAvailability, "ROUND_ROBIN"); + + // Should be available when either host is available + const timeRangeAvailable = { + start: dayjs("2025-01-23T11:00:00.000Z"), + end: dayjs("2025-01-23T11:20:00.000Z"), + }; + expect(isAvailable(result, timeRangeAvailable)).toBe(true); + + const timeRangeAvailable2 = { + start: dayjs("2025-01-23T13:20:00.000Z"), + end: dayjs("2025-01-23T13:30:00.000Z"), + }; + expect(isAvailable(result, timeRangeAvailable2)).toBe(true); + + // Should NOT be available when neither host is available + const timeRangeNotAvailable = { + start: dayjs("2025-01-23T11:00:00.000Z"), + end: dayjs("2025-01-23T11:30:00.000Z"), + }; + expect(isAvailable(result, timeRangeNotAvailable)).toBe(false); + }); + + it("handles mixed groups with some hosts having groupId and others not", () => { + // Test scenario: + // Group 1: Host A (available 11:00-11:45) + // Group 2: Host B (available 11:15-12:00) + // Default group: Host C (available 11:30-12:30), Host D (available 12:15-12:45) + // Fixed host: available 11:00-13:00 + + const userAvailability = [ + { + dateRanges: [], + oooExcludedDateRanges: [ + { start: dayjs("2025-01-23T11:00:00.000Z"), end: dayjs("2025-01-23T13:00:00.000Z") }, + ], + user: { isFixed: true }, + }, + { + dateRanges: [], + oooExcludedDateRanges: [ + { start: dayjs("2025-01-23T11:00:00.000Z"), end: dayjs("2025-01-23T11:45:00.000Z") }, + ], + user: { isFixed: false, groupId: "group1" }, + }, + { + dateRanges: [], + oooExcludedDateRanges: [ + { start: dayjs("2025-01-23T11:15:00.000Z"), end: dayjs("2025-01-23T12:00:00.000Z") }, + ], + user: { isFixed: false, groupId: "group2" }, + }, + { + dateRanges: [], + oooExcludedDateRanges: [ + { start: dayjs("2025-01-23T11:30:00.000Z"), end: dayjs("2025-01-23T12:30:00.000Z") }, + ], + user: { isFixed: false }, + }, + { + dateRanges: [], + oooExcludedDateRanges: [ + { start: dayjs("2025-01-23T12:15:00.000Z"), end: dayjs("2025-01-23T12:45:00.000Z") }, + ], + user: { isFixed: false }, + }, + ]; + + const result = getAggregatedAvailability(userAvailability, "ROUND_ROBIN"); + + // Should be available when all groups have at least one host available + // Fixed host + Group 1 + Group 2 + Default group all available in this time range + const timeRangeAvailable = { + start: dayjs("2025-01-23T11:30:00.000Z"), + end: dayjs("2025-01-23T11:45:00.000Z"), + }; + expect(isAvailable(result, timeRangeAvailable)).toBe(true); + + // Should NOT be available when not all groups have hosts available + // Only Group 1, Group 2, and fixed host available, but Default group not available + const timeRangeNotAvailable = { + start: dayjs("2025-01-23T11:15:00.000Z"), + end: dayjs("2025-01-23T11:30:00.000Z"), + }; + expect(isAvailable(result, timeRangeNotAvailable)).toBe(false); + + // Should NOT be available when not all groups have hosts available + // Only Group 1 and fixed host available, Group 2 and Default group not available + const timeRangeNotAvailable2 = { + start: dayjs("2025-01-23T11:00:00.000Z"), + end: dayjs("2025-01-23T11:15:00.000Z"), + }; + expect(isAvailable(result, timeRangeNotAvailable2)).toBe(false); + + // Should NOT be available when not all groups have hosts available + // Only Group 2 and fixed host available, Group 1 and Default group not available + const timeRangeNotAvailable3 = { + start: dayjs("2025-01-23T11:45:00.000Z"), + end: dayjs("2025-01-23T12:00:00.000Z"), + }; + expect(isAvailable(result, timeRangeNotAvailable3)).toBe(false); + }); + + it("handles empty groups gracefully", () => { + // Test scenario with empty groups + const userAvailability = [ + { + dateRanges: [], + oooExcludedDateRanges: [ + { start: dayjs("2025-01-23T11:00:00.000Z"), end: dayjs("2025-01-23T11:30:00.000Z") }, + ], + user: { isFixed: true }, + }, + ]; + + const result = getAggregatedAvailability(userAvailability, "ROUND_ROBIN"); + + // Should only have fixed host availability + const timeRangeAvailable = { + start: dayjs("2025-01-23T11:00:00.000Z"), + end: dayjs("2025-01-23T11:30:00.000Z"), + }; + expect(isAvailable(result, timeRangeAvailable)).toBe(true); + expect(result.length).toBe(1); + }); + + it("handles scenario where one group has no available hosts", () => { + // Test scenario where one group has no available hosts + // Group 1: Host A (available 11:00-11:30) + // Group 2: Host B (not available at all) + // Fixed host: available 11:00-13:00 + const userAvailability = [ + { + dateRanges: [], + oooExcludedDateRanges: [ + { start: dayjs("2025-01-23T11:00:00.000Z"), end: dayjs("2025-01-23T13:00:00.000Z") }, + ], + user: { isFixed: true }, + }, + { + dateRanges: [], + oooExcludedDateRanges: [ + { start: dayjs("2025-01-23T11:00:00.000Z"), end: dayjs("2025-01-23T11:30:00.000Z") }, + ], + user: { isFixed: false, groupId: "group1" }, + }, + { + dateRanges: [], + oooExcludedDateRanges: [], // No availability + user: { isFixed: false, groupId: "group2" }, + }, + ]; + + const result = getAggregatedAvailability(userAvailability, "ROUND_ROBIN"); + + // Should NOT be available when one group has no hosts available + const timeRangeNotAvailable = { + start: dayjs("2025-01-23T11:00:00.000Z"), + end: dayjs("2025-01-23T11:30:00.000Z"), + }; + expect(isAvailable(result, timeRangeNotAvailable)).toBe(false); + }); }); diff --git a/packages/lib/getAggregatedAvailability.ts b/packages/lib/getAggregatedAvailability.ts index 6f3f9fa8bd..e78f5d1982 100644 --- a/packages/lib/getAggregatedAvailability.ts +++ b/packages/lib/getAggregatedAvailability.ts @@ -1,3 +1,4 @@ +import { DEFAULT_GROUP_ID } from "@calcom/lib/constants"; import type { DateRange } from "@calcom/lib/date-ranges"; import { intersect } from "@calcom/lib/date-ranges"; import { SchedulingType } from "@calcom/prisma/enums"; @@ -25,7 +26,7 @@ export const getAggregatedAvailability = ( userAvailability: { dateRanges: DateRange[]; oooExcludedDateRanges: DateRange[]; - user?: { isFixed?: boolean }; + user?: { isFixed?: boolean; groupId?: string | null }; }[], schedulingType: SchedulingType | null ): DateRange[] => { @@ -44,10 +45,27 @@ export const getAggregatedAvailability = ( const dateRangesToIntersect = !!fixedDateRanges.length ? [fixedDateRanges] : []; const roundRobinHosts = userAvailability.filter(({ user }) => user?.isFixed !== true); if (roundRobinHosts.length) { - dateRangesToIntersect.push( - roundRobinHosts.flatMap((s) => (!isTeamEvent ? s.dateRanges : s.oooExcludedDateRanges)) - ); + // Group round robin hosts by their groupId + const hostsByGroup = roundRobinHosts.reduce((groups, host) => { + const groupId = host.user?.groupId || DEFAULT_GROUP_ID; + if (!groups[groupId]) { + groups[groupId] = []; + } + groups[groupId].push(host); + return groups; + }, {} as Record); + + // at least one host from each group needs to be available + Object.values(hostsByGroup).forEach((groupHosts) => { + if (groupHosts.length > 0) { + const groupDateRanges = groupHosts.flatMap((s) => + !isTeamEvent ? s.dateRanges : s.oooExcludedDateRanges + ); + dateRangesToIntersect.push(groupDateRanges ?? []); + } + }); } + const availability = intersect(dateRangesToIntersect); const uniqueRanges = uniqueAndSortedDateRanges(availability); diff --git a/packages/lib/server/repository/eventTypeRepository.ts b/packages/lib/server/repository/eventTypeRepository.ts index 2c58050292..25611493c2 100644 --- a/packages/lib/server/repository/eventTypeRepository.ts +++ b/packages/lib/server/repository/eventTypeRepository.ts @@ -606,6 +606,12 @@ export class EventTypeRepository { }, }, teamId: true, + hostGroups: { + select: { + id: true, + name: true, + }, + }, team: { select: { id: true, @@ -672,6 +678,7 @@ export class EventTypeRepository { priority: true, weight: true, scheduleId: true, + groupId: true, user: { select: { timeZone: true, @@ -895,6 +902,12 @@ export class EventTypeRepository { }, }, teamId: true, + hostGroups: { + select: { + id: true, + name: true, + }, + }, team: { select: { id: true, @@ -1197,6 +1210,12 @@ export class EventTypeRepository { useEventLevelSelectedCalendars: true, restrictionScheduleId: true, useBookerTimezone: true, + hostGroups: { + select: { + id: true, + name: true, + }, + }, team: { select: { id: true, @@ -1246,6 +1265,7 @@ export class EventTypeRepository { createdAt: true, weight: true, priority: true, + groupId: true, user: { select: { credentials: { select: credentialForCalendarServiceSelect }, diff --git a/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts b/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts index 0bca6f53f7..3134d221c1 100644 --- a/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts +++ b/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts @@ -101,6 +101,7 @@ export const useEventTypeForm = ({ hideOrganizerEmail: eventType.hideOrganizerEmail, metadata: eventType.metadata, hosts: eventType.hosts.sort((a, b) => sortHosts(a, b, eventType.isRRWeightsEnabled)), + hostGroups: eventType.hostGroups || [], successRedirectUrl: eventType.successRedirectUrl || "", forwardParamsSuccessRedirect: eventType.forwardParamsSuccessRedirect, users: eventType.users, diff --git a/packages/platform/atoms/event-types/wrappers/EventTypeWebWrapper.tsx b/packages/platform/atoms/event-types/wrappers/EventTypeWebWrapper.tsx index b59d876e51..bc97a50282 100644 --- a/packages/platform/atoms/event-types/wrappers/EventTypeWebWrapper.tsx +++ b/packages/platform/atoms/event-types/wrappers/EventTypeWebWrapper.tsx @@ -108,7 +108,12 @@ export const EventTypeWebWrapper = ({ id, data: serverFetchedData }: EventTypeWe return ; }; -const EventTypeWeb = ({ id, ...rest }: EventTypeSetupProps & { id: number }) => { +const EventTypeWeb = ({ + id, + ...rest +}: EventTypeSetupProps & { + id: number; +}) => { const { t } = useLocale(); const utils = trpc.useUtils(); const pathname = usePathname(); diff --git a/packages/prisma/migrations/20250704081718_add_host_groups/migration.sql b/packages/prisma/migrations/20250704081718_add_host_groups/migration.sql new file mode 100644 index 0000000000..2ca7d8fd05 --- /dev/null +++ b/packages/prisma/migrations/20250704081718_add_host_groups/migration.sql @@ -0,0 +1,25 @@ +-- AlterTable +ALTER TABLE "Host" ADD COLUMN "groupId" TEXT; + +-- CreateTable +CREATE TABLE "HostGroup" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "eventTypeId" INTEGER, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "HostGroup_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "HostGroup_name_idx" ON "HostGroup"("name"); + +-- CreateIndex +CREATE INDEX "HostGroup_eventTypeId_idx" ON "HostGroup"("eventTypeId"); + +-- AddForeignKey +ALTER TABLE "Host" ADD CONSTRAINT "Host_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "HostGroup"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "HostGroup" ADD CONSTRAINT "HostGroup_eventTypeId_fkey" FOREIGN KEY ("eventTypeId") REFERENCES "EventType"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 14c2e73086..a41165ed33 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -49,18 +49,20 @@ enum CreationSource { } model Host { - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) userId Int - eventType EventType @relation(fields: [eventTypeId], references: [id], onDelete: Cascade) + eventType EventType @relation(fields: [eventTypeId], references: [id], onDelete: Cascade) eventTypeId Int - isFixed Boolean @default(false) + 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]) + schedule Schedule? @relation(fields: [scheduleId], references: [id]) scheduleId Int? - createdAt DateTime @default(now()) + createdAt DateTime @default(now()) + group HostGroup? @relation(fields: [groupId], references: [id]) + groupId String? @@id([userId, eventTypeId]) @@index([userId]) @@ -68,6 +70,19 @@ model Host { @@index([scheduleId]) } +model HostGroup { + id String @id @default(uuid()) + name String + hosts Host[] + eventType EventType? @relation(fields: [eventTypeId], references: [id], onDelete: Cascade) + eventTypeId Int? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([name]) + @@index([eventTypeId]) +} + model CalVideoSettings { eventTypeId Int @id eventType EventType @relation(fields: [eventTypeId], references: [id], onDelete: Cascade) @@ -99,9 +114,10 @@ model EventType { offsetStart Int @default(0) hidden Boolean @default(false) hosts Host[] - users User[] @relation("user_eventtype") - owner User? @relation("owner", fields: [userId], references: [id], onDelete: Cascade) - userId Int? + + users User[] @relation("user_eventtype") + owner User? @relation("owner", fields: [userId], references: [id], onDelete: Cascade) + userId Int? profileId Int? profile Profile? @relation(fields: [profileId], references: [id]) @@ -204,9 +220,10 @@ model EventType { secondaryEmailId Int? secondaryEmail SecondaryEmail? @relation(fields: [secondaryEmailId], references: [id], onDelete: Cascade) - useBookerTimezone Boolean @default(false) + useBookerTimezone Boolean @default(false) restrictionScheduleId Int? - restrictionSchedule Schedule? @relation("restrictionSchedule", fields: [restrictionScheduleId], references: [id]) + restrictionSchedule Schedule? @relation("restrictionSchedule", fields: [restrictionScheduleId], references: [id]) + hostGroups HostGroup[] @@unique([userId, slug]) @@unique([teamId, slug]) diff --git a/packages/trpc/server/routers/viewer/bookings/get.handler.ts b/packages/trpc/server/routers/viewer/bookings/get.handler.ts index 2a08b5e4e7..56e96e9aa6 100644 --- a/packages/trpc/server/routers/viewer/bookings/get.handler.ts +++ b/packages/trpc/server/routers/viewer/bookings/get.handler.ts @@ -524,6 +524,12 @@ export async function getBookings({ .select(["Team.id", "Team.name", "Team.slug"]) .whereRef("EventType.teamId", "=", "Team.id") ).as("team"), + jsonArrayFrom( + eb + .selectFrom("HostGroup") + .select(["HostGroup.id", "HostGroup.name"]) + .whereRef("HostGroup.eventTypeId", "=", "EventType.id") + ).as("hostGroups"), ]) .whereRef("EventType.id", "=", "Booking.eventTypeId") ).as("eventType"), diff --git a/packages/trpc/server/routers/viewer/eventTypes/types.ts b/packages/trpc/server/routers/viewer/eventTypes/types.ts index c0cdedec03..3e11a20a5c 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/types.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/types.ts @@ -54,6 +54,12 @@ const hostSchema = z.object({ priority: z.number().min(0).max(4).optional().nullable(), weight: z.number().min(0).optional().nullable(), scheduleId: z.number().optional().nullable(), + groupId: z.string().optional().nullable(), +}); + +const hostGroupSchema = z.object({ + id: z.string().uuid(), + name: z.string(), }); const childSchema = z.object({ @@ -96,6 +102,7 @@ const BaseEventTypeUpdateInput = _EventTypeModel rrSegmentQueryValue: rrSegmentQueryValueSchema.optional(), useEventLevelSelectedCalendars: z.boolean().optional(), seatsPerTimeSlot: z.number().min(1).max(MAX_SEATS_PER_TIME_SLOT).nullable().optional(), + hostGroups: z.array(hostGroupSchema).optional(), }) .partial() .extend(_EventTypeModel.pick({ id: true }).shape); diff --git a/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts index 260a830cd2..b4d5bb8650 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts @@ -94,6 +94,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { seatsPerTimeSlot, restrictionScheduleId, calVideoSettings, + hostGroups, ...rest } = input; @@ -149,6 +150,12 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { workflowId: true, }, }, + hostGroups: { + select: { + id: true, + name: true, + }, + }, team: { select: { id: true, @@ -210,6 +217,12 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { ); } + const isLoadBalancingDisabled = !!( + (eventType.team?.rrTimestampBasis && eventType.team?.rrTimestampBasis !== RRTimestampBasis.CREATED_AT) || + (hostGroups && hostGroups.length > 1) || + (!hostGroups && eventType.hostGroups && eventType.hostGroups.length > 1) + ); + const data: Prisma.EventTypeUpdateInput = { ...rest, // autoTranslate feature is allowed for org users only @@ -224,10 +237,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { eventTypeColor: eventTypeColor === null ? Prisma.DbNull : (eventTypeColor as Prisma.InputJsonObject), disableGuests: guestsField?.hidden ?? false, seatsPerTimeSlot, - maxLeadThreshold: - eventType.team?.rrTimestampBasis && eventType.team?.rrTimestampBasis !== RRTimestampBasis.CREATED_AT - ? null - : rest.maxLeadThreshold, + maxLeadThreshold: isLoadBalancingDisabled ? null : rest.maxLeadThreshold, }; data.locations = locations ?? undefined; @@ -393,6 +403,48 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { }; } + // Handle hostGroups updates + if (hostGroups !== undefined) { + const existingHostGroups = await ctx.prisma.hostGroup.findMany({ + where: { + eventTypeId: id, + }, + select: { + id: true, + name: true, + }, + }); + + await Promise.all( + hostGroups.map(async (group) => { + await ctx.prisma.hostGroup.upsert({ + where: { id: group.id }, + update: { name: group.name }, + create: { + id: group.id, + name: group.name, + eventTypeId: id, + }, + }); + }) + ); + + const newGroupsMap = new Map(hostGroups.map((group) => [group.id, group])); + + // Delete groups that are no longer in the new list + const groupsToDelete = existingHostGroups.filter((existingGroup) => !newGroupsMap.has(existingGroup.id)); + + if (groupsToDelete.length > 0) { + await ctx.prisma.hostGroup.deleteMany({ + where: { + id: { + in: groupsToDelete.map((group) => group.id), + }, + }, + }); + } + } + if (teamId && hosts) { // check if all hosts can be assigned (memberships that have accepted invite) const teamMemberIds = await membershipRepo.listAcceptedTeamMemberIds({ teamId }); @@ -405,10 +457,6 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { }); } - // weights were already enabled or are enabled now - const isWeightsEnabled = - isRRWeightsEnabled || (typeof isRRWeightsEnabled === "undefined" && eventType.isRRWeightsEnabled); - const oldHostsSet = new Set(eventType.hosts.map((oldHost) => oldHost.userId)); const newHostsSet = new Set(hosts.map((oldHost) => oldHost.userId)); @@ -429,6 +477,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { isFixed: data.schedulingType === SchedulingType.COLLECTIVE || host.isFixed, priority: host.priority ?? 2, weight: host.weight ?? 100, + groupId: host.groupId, }; }), update: existingHosts.map((host) => ({ @@ -443,6 +492,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { priority: host.priority ?? 2, weight: host.weight ?? 100, scheduleId: host.scheduleId ?? null, + groupId: host.groupId, }, })), }; @@ -641,6 +691,18 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { updatedValues, }); + // Clean up empty host groups + if (hostGroups !== undefined || hosts) { + await ctx.prisma.hostGroup.deleteMany({ + where: { + eventTypeId: id, + hosts: { + none: {}, + }, + }, + }); + } + const res = ctx.res as NextApiResponse; if (typeof res?.revalidate !== "undefined") { try { diff --git a/packages/trpc/server/routers/viewer/eventTypes/util.ts b/packages/trpc/server/routers/viewer/eventTypes/util.ts index 72debf6609..5f32253a33 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/util.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/util.ts @@ -190,6 +190,7 @@ type Host = { priority?: number | null | undefined; weight?: number | null | undefined; scheduleId?: number | null | undefined; + groupId: string | null; }; type User = { diff --git a/packages/trpc/server/routers/viewer/slots/util.ts b/packages/trpc/server/routers/viewer/slots/util.ts index 2c260668e8..5fb155c03b 100644 --- a/packages/trpc/server/routers/viewer/slots/util.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -709,10 +709,11 @@ export class AvailableSlotsService { }: { hosts: { isFixed?: boolean; + groupId?: string | null; user: GetAvailabilityUserWithDelegationCredentials; }[]; }) { - return hosts.map(({ isFixed, user }) => ({ isFixed, ...user })); + return hosts.map(({ isFixed, groupId, user }) => ({ isFixed, groupId, ...user })); } private getUsersWithCredentials = withReporting( @@ -743,6 +744,7 @@ export class AvailableSlotsService { >; hosts: { isFixed?: boolean; + groupId?: string | null; user: GetAvailabilityUserWithDelegationCredentials; }[]; loggerWithEventDetails: Logger; diff --git a/packages/trpc/server/routers/viewer/teams/hasTeamPlan.handler.ts b/packages/trpc/server/routers/viewer/teams/hasTeamPlan.handler.ts index 161fd0e2a8..29b1421a61 100644 --- a/packages/trpc/server/routers/viewer/teams/hasTeamPlan.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/hasTeamPlan.handler.ts @@ -1,5 +1,4 @@ import { MembershipRepository } from "@calcom/lib/server/repository/membership"; -import type { TrpcSessionUser } from "@calcom/trpc/server/types"; type HasTeamPlanOptions = { ctx: {