diff --git a/packages/app-store/salesforce/components/EventTypeAppCardInterface.tsx b/packages/app-store/salesforce/components/EventTypeAppCardInterface.tsx index 46dc677fd3f1a6..8fade63933b6a5 100644 --- a/packages/app-store/salesforce/components/EventTypeAppCardInterface.tsx +++ b/packages/app-store/salesforce/components/EventTypeAppCardInterface.tsx @@ -43,6 +43,7 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ const onBookingWriteToRecord = getAppData("onBookingWriteToRecord") ?? false; const onBookingWriteToRecordFields = getAppData("onBookingWriteToRecordFields") ?? {}; const ignoreGuests = getAppData("ignoreGuests") ?? false; + const excludeSalesforceBookingsFromRR = getAppData("excludeSalesforceBookingsFromRR") ?? false; const roundRobinSkipFallbackToLeadOwner = getAppData("roundRobinSkipFallbackToLeadOwner") ?? false; const onCancelWriteToEventRecord = getAppData("onCancelWriteToEventRecord") ?? false; const onCancelWriteToEventRecordFields = getAppData("onCancelWriteToEventRecordFields") ?? {}; @@ -440,6 +441,28 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ ) : null} + {/* Only show this toggle when the event type is Round Robin */} + {eventType.schedulingType === SchedulingType.ROUND_ROBIN && ( + + + { + setAppData("excludeSalesforceBookingsFromRR", checked); + }} + /> + +

+ {t("exclude_salesforce_bookings_from_round_robin_description")} +

+
+ )} + + { }) ); }); + + it("should exclude Salesforce bookings from round robin when excludeSalesforceBookingsFromRR is true", async () => { + const users: GetLuckyUserAvailableUsersType = [ + buildUser({ + id: 1, + username: "test1", + name: "Test User 1", + email: "test1@example.com", + bookings: [ + { + createdAt: new Date("2022-01-25T05:30:00.000Z"), + }, + ], + }), + buildUser({ + id: 2, + username: "test2", + name: "Test User 2", + email: "test2@example.com", + bookings: [ + { + createdAt: new Date("2022-01-25T04:30:00.000Z"), + }, + ], + }), + ]; + + CalendarManagerMock.getBusyCalendarTimes.mockResolvedValue([]); + prismaMock.outOfOfficeEntry.findMany.mockResolvedValue([]); + prismaMock.user.findMany.mockResolvedValue(users); + prismaMock.host.findMany.mockResolvedValue([]); + prismaMock.booking.findMany.mockResolvedValue([]); + + // Mock Salesforce app data with excludeSalesforceBookingsFromRR set to true + const mockEventTypeService = vi.spyOn(EventTypeService, "getEventTypeAppDataFromId"); + mockEventTypeService.mockResolvedValue({ excludeSalesforceBookingsFromRR: true }); + + await getLuckyUser({ + availableUsers: users, + eventType: { + id: 1, + isRRWeightsEnabled: false, + team: { rrResetInterval: RRResetInterval.MONTH }, + }, + allRRHosts: [], + routingFormResponse: null, + }); + + const queryArgs = prismaMock.booking.findMany.mock.calls[0][0]; + + // Verify that the query excludes Salesforce assignments + expect(queryArgs.where?.NOT?.assignmentReason?.some?.reasonEnum).toEqual("SALESFORCE_ASSIGNMENT"); + + mockEventTypeService.mockRestore(); + }); + + it("should include Salesforce bookings in round robin when excludeSalesforceBookingsFromRR is false", async () => { + const users: GetLuckyUserAvailableUsersType = [ + buildUser({ + id: 1, + username: "test1", + name: "Test User 1", + email: "test1@example.com", + bookings: [ + { + createdAt: new Date("2022-01-25T05:30:00.000Z"), + }, + ], + }), + buildUser({ + id: 2, + username: "test2", + name: "Test User 2", + email: "test2@example.com", + bookings: [ + { + createdAt: new Date("2022-01-25T04:30:00.000Z"), + }, + ], + }), + ]; + + CalendarManagerMock.getBusyCalendarTimes.mockResolvedValue([]); + prismaMock.outOfOfficeEntry.findMany.mockResolvedValue([]); + prismaMock.user.findMany.mockResolvedValue(users); + prismaMock.host.findMany.mockResolvedValue([]); + prismaMock.booking.findMany.mockResolvedValue([]); + + // Mock Salesforce app data with excludeSalesforceBookingsFromRR set to false + const mockEventTypeService = vi.spyOn(EventTypeService, "getEventTypeAppDataFromId"); + mockEventTypeService.mockResolvedValue({ excludeSalesforceBookingsFromRR: false }); + + await getLuckyUser({ + availableUsers: users, + eventType: { + id: 1, + isRRWeightsEnabled: false, + team: { rrResetInterval: RRResetInterval.MONTH }, + }, + allRRHosts: [], + routingFormResponse: null, + }); + + const queryArgs = prismaMock.booking.findMany.mock.calls[0][0]; + + // Verify that the query does NOT exclude Salesforce assignments + expect(queryArgs.where?.NOT?.assignmentReason?.some?.reasonEnum).toBeUndefined(); + + mockEventTypeService.mockRestore(); + }); }); describe("get interval times", () => { diff --git a/packages/lib/server/getLuckyUser.ts b/packages/lib/server/getLuckyUser.ts index 25354d539916e1..ea75b4816142a4 100644 --- a/packages/lib/server/getLuckyUser.ts +++ b/packages/lib/server/getLuckyUser.ts @@ -9,6 +9,7 @@ import { acrossQueryValueCompatiblity } from "@calcom/lib/raqb/raqbUtils"; import { raqbQueryValueSchema } from "@calcom/lib/raqb/zod"; import { safeStringify } from "@calcom/lib/safeStringify"; import { BookingRepository } from "@calcom/lib/server/repository/booking"; +import { EventTypeService } from "@calcom/lib/server/service/eventType"; import prisma from "@calcom/prisma"; import type { Booking } from "@calcom/prisma/client"; import type { SelectedCalendar } from "@calcom/prisma/client"; @@ -479,6 +480,7 @@ async function getBookingsOfInterval({ virtualQueuesData, interval, includeNoShowInRRCalculation, + excludeSalesforceBookingsFromRR = false, rrTimestampBasis, meetingStartTime, }: { @@ -487,6 +489,7 @@ async function getBookingsOfInterval({ virtualQueuesData: VirtualQueuesDataType | null; interval: RRResetInterval; includeNoShowInRRCalculation: boolean; + excludeSalesforceBookingsFromRR?: boolean; rrTimestampBasis: RRTimestampBasis; meetingStartTime?: Date; }) { @@ -498,6 +501,7 @@ async function getBookingsOfInterval({ endDate: getIntervalEndDate({ interval, rrTimestampBasis, meetingStartTime }), virtualQueuesData, includeNoShowInRRCalculation, + excludeSalesforceBookingsFromRR, rrTimestampBasis, }); } @@ -630,6 +634,11 @@ async function fetchAllDataNeededForCalculations< const startTime = performance.now(); const { availableUsers, allRRHosts, eventType, meetingStartTime } = getLuckyUserParams; + + // Get Salesforce app data to check excludeSalesforceBookingsFromRR setting + const salesforceAppData = await EventTypeService.getEventTypeAppDataFromId(eventType.id, "salesforce"); + const excludeSalesforceBookingsFromRR = salesforceAppData?.excludeSalesforceBookingsFromRR ?? false; + const notAvailableHosts = (function getNotAvailableHosts() { const availableUserIds = new Set(availableUsers.map((user) => user.id)); return allRRHosts.reduce( @@ -686,6 +695,7 @@ async function fetchAllDataNeededForCalculations< virtualQueuesData: virtualQueuesData ?? null, interval, includeNoShowInRRCalculation: eventType.includeNoShowInRRCalculation, + excludeSalesforceBookingsFromRR, rrTimestampBasis, meetingStartTime, }), @@ -696,6 +706,7 @@ async function fetchAllDataNeededForCalculations< virtualQueuesData: virtualQueuesData ?? null, interval, includeNoShowInRRCalculation: eventType.includeNoShowInRRCalculation, + excludeSalesforceBookingsFromRR, rrTimestampBasis, meetingStartTime, }), @@ -708,6 +719,7 @@ async function fetchAllDataNeededForCalculations< virtualQueuesData: virtualQueuesData ?? null, interval, includeNoShowInRRCalculation: eventType.includeNoShowInRRCalculation, + excludeSalesforceBookingsFromRR, rrTimestampBasis, meetingStartTime, }), diff --git a/packages/lib/server/locales/en/common.json b/packages/lib/server/locales/en/common.json index 547c8139bbf01a..8512ac5a263c39 100644 --- a/packages/lib/server/locales/en/common.json +++ b/packages/lib/server/locales/en/common.json @@ -2804,6 +2804,8 @@ "number_of_options": "{{count}} options", "reschedule_with_same_round_robin_host_title": "Reschedule with same Round-Robin host", "reschedule_with_same_round_robin_host_description": "Rescheduled events will be assigned to the same host as initially scheduled", + "exclude_salesforce_bookings_from_round_robin": "Only count Round-Robin assigned bookings", + "exclude_salesforce_bookings_from_round_robin_description": "When enabled, only bookings assigned via Round-Robin logic count towards Round-Robin calculations. Bookings assigned due to Salesforce ownership will not count.", "disable_input_if_prefilled": "Disable input if the URL identifier is prefilled", "booking_limits": "Booking Limits", "booking_limits_team_description": "Booking limits for team members across all team event types", diff --git a/packages/lib/server/repository/booking.ts b/packages/lib/server/repository/booking.ts index 2de6d33462a609..f115dfcf49cb4c 100644 --- a/packages/lib/server/repository/booking.ts +++ b/packages/lib/server/repository/booking.ts @@ -5,8 +5,7 @@ import { withReporting } from "@calcom/lib/sentryWrapper"; import type { PrismaClient } from "@calcom/prisma"; import { bookingMinimalSelect } from "@calcom/prisma"; import type { Booking } from "@calcom/prisma/client"; -import { RRTimestampBasis } from "@calcom/prisma/enums"; -import { BookingStatus } from "@calcom/prisma/enums"; +import { BookingStatus, AssignmentReasonEnum, RRTimestampBasis } from "@calcom/prisma/enums"; import { UserRepository } from "./user"; @@ -49,6 +48,7 @@ const buildWhereClauseForActiveBookings = ({ users, virtualQueuesData, includeNoShowInRRCalculation = false, + excludeSalesforceBookingsFromRR = false, rrTimestampBasis, }: { eventTypeId: number; @@ -63,6 +63,7 @@ const buildWhereClauseForActiveBookings = ({ }; } | null; includeNoShowInRRCalculation: boolean; + excludeSalesforceBookingsFromRR?: boolean; rrTimestampBasis: RRTimestampBasis; }): Prisma.BookingWhereInput => ({ OR: [ @@ -111,6 +112,17 @@ const buildWhereClauseForActiveBookings = ({ }, } : {}), + ...(excludeSalesforceBookingsFromRR + ? { + NOT: { + assignmentReason: { + some: { + reasonEnum: AssignmentReasonEnum.SALESFORCE_ASSIGNMENT, + }, + }, + }, + } + : {}), }); export class BookingRepository { @@ -304,6 +316,7 @@ export class BookingRepository { endDate, virtualQueuesData, includeNoShowInRRCalculation, + excludeSalesforceBookingsFromRR = false, rrTimestampBasis, }: { users: { id: number; email: string }[]; @@ -318,6 +331,7 @@ export class BookingRepository { }; } | null; includeNoShowInRRCalculation: boolean; + excludeSalesforceBookingsFromRR: boolean; rrTimestampBasis: RRTimestampBasis; }) { const allBookings = await this.prismaClient.booking.findMany({ @@ -328,6 +342,7 @@ export class BookingRepository { users, virtualQueuesData, includeNoShowInRRCalculation, + excludeSalesforceBookingsFromRR, rrTimestampBasis, }), select: { diff --git a/packages/lib/server/repository/eventType.ts b/packages/lib/server/repository/eventType.ts index b725b6b0fc396f..73754e7c8e7071 100644 --- a/packages/lib/server/repository/eventType.ts +++ b/packages/lib/server/repository/eventType.ts @@ -542,6 +542,7 @@ export class EventTypeRepository { rrSegmentQueryValue: true, isRRWeightsEnabled: true, rescheduleWithSameRoundRobinHost: true, + excludeSalesforceBookingsFromRR: true, successRedirectUrl: true, forwardParamsSuccessRedirect: true, currency: true, diff --git a/packages/lib/test/builder.ts b/packages/lib/test/builder.ts index 3860088336a35b..0f2504e2a954bb 100644 --- a/packages/lib/test/builder.ts +++ b/packages/lib/test/builder.ts @@ -138,6 +138,7 @@ export const buildEventType = (eventType?: Partial): EventType => { durationLimits: null, assignAllTeamMembers: false, rescheduleWithSameRoundRobinHost: false, + excludeSalesforceBookingsFromRR: false, price: 0, currency: "usd", slotInterval: null, diff --git a/packages/prisma/migrations/20250617092831_add_exclude_salesforce_bookings_from_rr/migration.sql b/packages/prisma/migrations/20250617092831_add_exclude_salesforce_bookings_from_rr/migration.sql new file mode 100644 index 00000000000000..694cc95fd07205 --- /dev/null +++ b/packages/prisma/migrations/20250617092831_add_exclude_salesforce_bookings_from_rr/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "EventType" ADD COLUMN "excludeSalesforceBookingsFromRR" BOOLEAN NOT NULL DEFAULT false; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 17fafe7e4c6289..66e16f2f29013b 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -199,6 +199,7 @@ model EventType { /// @zod.custom(imports.eventTypeColor) eventTypeColor Json? rescheduleWithSameRoundRobinHost Boolean @default(false) + excludeSalesforceBookingsFromRR Boolean @default(false) secondaryEmailId Int? secondaryEmail SecondaryEmail? @relation(fields: [secondaryEmailId], references: [id], onDelete: Cascade) diff --git a/packages/prisma/zod-utils.ts b/packages/prisma/zod-utils.ts index cdecff651f090c..4676ed9cb6da36 100644 --- a/packages/prisma/zod-utils.ts +++ b/packages/prisma/zod-utils.ts @@ -678,6 +678,7 @@ export const allManagedEventTypeProps: { [k in keyof Omit