diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index eba725cd7b..1cc6b5b464 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -3189,6 +3189,7 @@ "routing_form_next_in_queue": "{{count}} next in queue", "routing_form_select_members_to_email": "Send email responses to", "routing_incomplete_booking_tab": "Incomplete Bookings", + "include_no_show_in_rr_calculation": "Include no show bookings in round robin calculations", "matching": "Matching", "event_redirect": "Event Redirect", "reset_form": "Reset Form", diff --git a/apps/web/test/lib/handleChildrenEventTypes.test.ts b/apps/web/test/lib/handleChildrenEventTypes.test.ts index 281a37d8db..54f7499f03 100644 --- a/apps/web/test/lib/handleChildrenEventTypes.test.ts +++ b/apps/web/test/lib/handleChildrenEventTypes.test.ts @@ -114,6 +114,8 @@ describe("handleChildrenEventTypes", () => { useEventTypeDestinationCalendarEmail, secondaryEmailId, autoTranslateDescriptionEnabled, + includeNoShowInRRCalculation, + instantMeetingScheduleId, ...evType } = mockFindFirstEventType({ id: 123, @@ -137,7 +139,6 @@ describe("handleChildrenEventTypes", () => { users: { connect: [{ id: 4 }] }, lockTimeZoneToggleOnBookingPage: false, requiresBookerEmailVerification: false, - instantMeetingScheduleId: undefined, bookingLimits: undefined, durationLimits: undefined, recurringEvent: undefined, @@ -170,6 +171,9 @@ describe("handleChildrenEventTypes", () => { lockTimeZoneToggleOnBookingPage, useEventTypeDestinationCalendarEmail, secondaryEmailId, + assignRRMembersUsingSegment, + includeNoShowInRRCalculation, + instantMeetingScheduleId, ...evType } = mockFindFirstEventType({ metadata: { managedEventConfig: {} }, @@ -191,7 +195,6 @@ describe("handleChildrenEventTypes", () => { expect(prismaMock.eventType.update).toHaveBeenCalledWith({ data: { ...rest, - assignRRMembersUsingSegment: undefined, useEventLevelSelectedCalendars: undefined, customReplyToEmail: null, rrSegmentQueryValue: undefined, @@ -275,6 +278,9 @@ describe("handleChildrenEventTypes", () => { useEventTypeDestinationCalendarEmail, secondaryEmailId, autoTranslateDescriptionEnabled, + includeNoShowInRRCalculation, + instantMeetingScheduleId, + assignRRMembersUsingSegment, ...evType } = mockFindFirstEventType({ id: 123, @@ -307,7 +313,6 @@ describe("handleChildrenEventTypes", () => { requiresBookerEmailVerification: false, userId: 4, workflows: undefined, - hashedLink: undefined, rrSegmentQueryValue: undefined, assignRRMembersUsingSegment: false, }, @@ -332,6 +337,11 @@ describe("handleChildrenEventTypes", () => { lockTimeZoneToggleOnBookingPage, useEventTypeDestinationCalendarEmail, secondaryEmailId, + includeNoShowInRRCalculation, + instantMeetingScheduleId, + assignRRMembersUsingSegment, + rrSegmentQueryValue, + useEventLevelSelectedCalendars, ...evType } = mockFindFirstEventType({ metadata: { managedEventConfig: {} }, @@ -354,9 +364,6 @@ describe("handleChildrenEventTypes", () => { expect(prismaMock.eventType.update).toHaveBeenCalledWith({ data: { ...rest, - assignRRMembersUsingSegment: undefined, - useEventLevelSelectedCalendars: undefined, - rrSegmentQueryValue: undefined, customReplyToEmail: null, locations: [], hashedLink: { @@ -364,7 +371,6 @@ describe("handleChildrenEventTypes", () => { }, lockTimeZoneToggleOnBookingPage: false, requiresBookerEmailVerification: false, - instantMeetingScheduleId: undefined, }, where: { userId_parentId: { @@ -396,6 +402,9 @@ describe("handleChildrenEventTypes", () => { useEventTypeDestinationCalendarEmail, secondaryEmailId, autoTranslateDescriptionEnabled, + includeNoShowInRRCalculation, + instantMeetingScheduleId, + assignRRMembersUsingSegment, ...evType } = mockFindFirstEventType({ metadata: { managedEventConfig: {} }, @@ -418,10 +427,14 @@ describe("handleChildrenEventTypes", () => { schedulingType: SchedulingType.MANAGED, requiresBookerEmailVerification: false, lockTimeZoneToggleOnBookingPage: false, + lockedTimeZone: "Europe/London", useEventTypeDestinationCalendarEmail: false, workflows: [], parentId: 1, locations: [], + instantMeetingScheduleId: null, + assignRRMembersUsingSegment: false, + includeNoShowInRRCalculation: false, ...evType, }; @@ -449,7 +462,6 @@ describe("handleChildrenEventTypes", () => { recurringEvent: undefined, eventTypeColor: undefined, customReplyToEmail: null, - instantMeetingScheduleId: undefined, locations: [], lockTimeZoneToggleOnBookingPage: false, requiresBookerEmailVerification: false, @@ -461,28 +473,24 @@ describe("handleChildrenEventTypes", () => { workflows: { create: [{ workflowId: 11 }], }, - hashedLink: undefined, rrSegmentQueryValue: undefined, assignRRMembersUsingSegment: false, useEventLevelSelectedCalendars: false, }, }); - const { profileId, ...rest } = evType; + const { profileId, rrSegmentQueryValue, ...rest } = evType; if ("workflows" in rest) delete rest.workflows; expect(prismaMock.eventType.update).toHaveBeenCalledWith({ data: { ...rest, locations: [], - assignRRMembersUsingSegment: undefined, useEventLevelSelectedCalendars: undefined, customReplyToEmail: null, - rrSegmentQueryValue: undefined, lockTimeZoneToggleOnBookingPage: false, requiresBookerEmailVerification: false, hashedLink: { deleteMany: {}, }, - instantMeetingScheduleId: undefined, }, where: { userId_parentId: { diff --git a/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts b/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts index 277fc2451f..475c7bb9ec 100644 --- a/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts +++ b/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts @@ -62,6 +62,7 @@ export const getEventTypesFromDB = async (eventTypeId: number) => { requiresConfirmationForFreeEmail: true, requiresBookerEmailVerification: true, maxLeadThreshold: true, + includeNoShowInRRCalculation: true, minimumBookingNotice: true, userId: true, price: true, diff --git a/packages/features/bookings/lib/handleNewBooking/loadAndValidateUsers.ts b/packages/features/bookings/lib/handleNewBooking/loadAndValidateUsers.ts index 7dbe84b51d..7881746fcb 100644 --- a/packages/features/bookings/lib/handleNewBooking/loadAndValidateUsers.ts +++ b/packages/features/bookings/lib/handleNewBooking/loadAndValidateUsers.ts @@ -50,6 +50,7 @@ type EventType = Pick< | "isRRWeightsEnabled" | "rescheduleWithSameRoundRobinHost" | "teamId" + | "includeNoShowInRRCalculation" >; type InputProps = { diff --git a/packages/features/eventtypes/components/tabs/assignment/EventTeamAssignmentTab.tsx b/packages/features/eventtypes/components/tabs/assignment/EventTeamAssignmentTab.tsx index c5acb735dc..88b5954439 100644 --- a/packages/features/eventtypes/components/tabs/assignment/EventTeamAssignmentTab.tsx +++ b/packages/features/eventtypes/components/tabs/assignment/EventTeamAssignmentTab.tsx @@ -621,7 +621,7 @@ export const EventTeamAssignmentTab = ({ ); }); const isManagedEventType = eventType.schedulingType === SchedulingType.MANAGED; - const { getValues, setValue } = useFormContext(); + const { getValues, setValue, control } = useFormContext(); const [assignAllTeamMembers, setAssignAllTeamMembers] = useState( getValues("assignAllTeamMembers") ?? false ); @@ -632,6 +632,11 @@ export const EventTeamAssignmentTab = ({ setAssignAllTeamMembers(false); }; + const schedulingType = useWatch({ + control, + name: "schedulingType", + }); + return (
{team && !isManagedEventType && ( @@ -682,44 +687,61 @@ export const EventTeamAssignmentTab = ({ />
-
-
- -

- {t("rr_distribution_method_description")} -

+ {schedulingType === "ROUND_ROBIN" && ( +
+
+ +

+ {t("rr_distribution_method_description")} +

+
+
+ ( + { + if (val === "loadBalancing") onChange(3); + else onChange(null); + }} + className="mt-1 flex flex-col gap-4"> + + + {t("rr_distribution_method_availability_title")} + +

{t("rr_distribution_method_availability_description")}

+
+ + {t("rr_distribution_method_balanced_title")} +

{t("rr_distribution_method_balanced_description")}

+
+
+ )} + /> +
+ ( + onChange(val)} + /> + )} + /> +
+
-
- ( - { - if (val === "loadBalancing") onChange(3); - else onChange(null); - }} - className="mt-1 flex flex-col gap-4"> - - {t("rr_distribution_method_availability_title")} -

{t("rr_distribution_method_availability_description")}

-
- - {t("rr_distribution_method_balanced_title")} -

{t("rr_distribution_method_balanced_description")}

-
-
- )} - /> -
-
+ )} >({ parentId?: number | null; rrResetInterval: RRResetInterval | null; } | null; + includeNoShowInRRCalculation: boolean; }; routingFormResponse: RoutingFormResponse | null; }) => { diff --git a/packages/lib/bookings/findQualifiedHostsWithDelegationCredentials.ts b/packages/lib/bookings/findQualifiedHostsWithDelegationCredentials.ts index 91ee3ef0e2..762f288032 100644 --- a/packages/lib/bookings/findQualifiedHostsWithDelegationCredentials.ts +++ b/packages/lib/bookings/findQualifiedHostsWithDelegationCredentials.ts @@ -68,6 +68,7 @@ export const findQualifiedHostsWithDelegationCredentials = async < schedulingType: SchedulingType | null; isRRWeightsEnabled: boolean; rescheduleWithSameRoundRobinHost: boolean; + includeNoShowInRRCalculation: boolean; } & EventType; rescheduleUid: string | null; routedTeamMemberIds: number[]; diff --git a/packages/lib/defaultEvents.ts b/packages/lib/defaultEvents.ts index 2201f054fe..c46be1f102 100644 --- a/packages/lib/defaultEvents.ts +++ b/packages/lib/defaultEvents.ts @@ -127,6 +127,7 @@ const commons = { autoTranslateDescriptionEnabled: false, fieldTranslations: [], maxLeadThreshold: null, + includeNoShowInRRCalculation: false, useEventLevelSelectedCalendars: false, rrResetInterval: null, customReplyToEmail: null, diff --git a/packages/lib/server/getLuckyUser.ts b/packages/lib/server/getLuckyUser.ts index e078169be3..2cd350e30a 100644 --- a/packages/lib/server/getLuckyUser.ts +++ b/packages/lib/server/getLuckyUser.ts @@ -67,6 +67,7 @@ interface GetLuckyUserParams { id: number; isRRWeightsEnabled: boolean; team: { parentId?: number | null; rrResetInterval: RRResetInterval | null } | null; + includeNoShowInRRCalculation: boolean; }; // all routedTeamMemberIds or all hosts of event types allRRHosts: { @@ -423,11 +424,13 @@ async function getBookingsOfInterval({ users, virtualQueuesData, interval, + includeNoShowInRRCalculation, }: { eventTypeId: number; users: { id: number; email: string }[]; virtualQueuesData: VirtualQueuesDataType | null; interval: RRResetInterval; + includeNoShowInRRCalculation: boolean; }) { return await BookingRepository.getAllBookingsForRoundRobin({ eventTypeId: eventTypeId, @@ -435,6 +438,7 @@ async function getBookingsOfInterval({ startDate: getIntervalStartDate(interval), endDate: new Date(), virtualQueuesData, + includeNoShowInRRCalculation, }); } @@ -611,6 +615,7 @@ async function fetchAllDataNeededForCalculations< }), virtualQueuesData: virtualQueuesData ?? null, interval, + includeNoShowInRRCalculation: eventType.includeNoShowInRRCalculation, }), getBookingsOfInterval({ @@ -618,6 +623,7 @@ async function fetchAllDataNeededForCalculations< users: notAvailableHosts, virtualQueuesData: virtualQueuesData ?? null, interval, + includeNoShowInRRCalculation: eventType.includeNoShowInRRCalculation, }), getBookingsOfInterval({ @@ -627,6 +633,7 @@ async function fetchAllDataNeededForCalculations< }), virtualQueuesData: virtualQueuesData ?? null, interval, + includeNoShowInRRCalculation: eventType.includeNoShowInRRCalculation, }), prisma.host.findMany({ diff --git a/packages/lib/server/repository/booking.test.ts b/packages/lib/server/repository/booking.test.ts new file mode 100644 index 0000000000..f403225f02 --- /dev/null +++ b/packages/lib/server/repository/booking.test.ts @@ -0,0 +1,192 @@ +import prismaMock from "../../../../tests/libs/__mocks__/prisma"; + +import { describe, it, expect, beforeEach, vi } from "vitest"; + +import { BookingStatus } from "@calcom/prisma/enums"; + +import { BookingRepository } from "./booking"; + +const createAttendeeNoShowTestBookings = async () => { + await Promise.all([ + prismaMock.booking.create({ + data: { + userId: 1, + uid: "123", + eventTypeId: 1, + status: BookingStatus.ACCEPTED, + attendees: { + create: { + email: "test1@example.com", + noShow: false, + name: "Test 1", + timeZone: "America/Toronto", + }, + }, + startTime: new Date("2025-05-01T00:00:00.000Z"), + endTime: new Date("2025-05-01T01:00:00.000Z"), + title: "Test Event", + }, + }), + prismaMock.booking.create({ + data: { + userId: 1, + uid: "123", + eventTypeId: 1, + status: BookingStatus.ACCEPTED, + attendees: { + create: { + email: "test1@example.com", + noShow: true, + name: "Test 1", + timeZone: "America/Toronto", + }, + }, + startTime: new Date("2025-05-01T00:00:00.000Z"), + endTime: new Date("2025-05-01T01:00:00.000Z"), + title: "Test Event", + }, + }), + ]); +}; + +const createHostNoShowTestBookings = async () => { + await Promise.all([ + prismaMock.booking.create({ + data: { + userId: 1, + uid: "123", + eventTypeId: 1, + status: BookingStatus.ACCEPTED, + attendees: { + create: { + email: "test1@example.com", + noShow: false, + name: "Test 1", + timeZone: "America/Toronto", + }, + }, + startTime: new Date("2025-05-01T00:00:00.000Z"), + endTime: new Date("2025-05-01T01:00:00.000Z"), + title: "Test Event", + noShowHost: false, + }, + }), + prismaMock.booking.create({ + data: { + userId: 1, + uid: "123", + eventTypeId: 1, + status: BookingStatus.ACCEPTED, + attendees: { + create: { + email: "test1@example.com", + noShow: false, + name: "Test 1", + timeZone: "America/Toronto", + }, + }, + startTime: new Date("2025-05-01T00:00:00.000Z"), + endTime: new Date("2025-05-01T01:00:00.000Z"), + title: "Test Event", + noShowHost: true, + }, + }), + ]); +}; + +describe("BookingRepository", () => { + beforeEach(() => { + vi.setSystemTime(new Date("2025-05-01T00:00:00.000Z")); + vi.resetAllMocks(); + }); + describe("getAllBookingsForRoundRobin", () => { + describe("includeNoShowInRRCalculation", () => { + it("it should not include bookings where the attendee is a no show", async () => { + await createAttendeeNoShowTestBookings(); + + const bookings = await BookingRepository.getAllBookingsForRoundRobin({ + users: [{ id: 1, email: "organizer1@example.com" }], + eventTypeId: 1, + startDate: new Date(), + endDate: new Date(), + includeNoShowInRRCalculation: false, + virtualQueuesData: null, + }); + + expect(bookings).toHaveLength(1); + }); + it("it should include bookings where the attendee is a no show", async () => { + await createAttendeeNoShowTestBookings(); + + const bookings = await BookingRepository.getAllBookingsForRoundRobin({ + users: [{ id: 1, email: "organizer1@example.com" }], + eventTypeId: 1, + startDate: new Date(), + endDate: new Date(), + includeNoShowInRRCalculation: true, + virtualQueuesData: null, + }); + + expect(bookings).toHaveLength(2); + }); + it("it should not include bookings where the host is a no show", async () => { + await createHostNoShowTestBookings(); + + const bookings = await BookingRepository.getAllBookingsForRoundRobin({ + users: [{ id: 1, email: "organizer1@example.com" }], + eventTypeId: 1, + startDate: new Date(), + endDate: new Date(), + includeNoShowInRRCalculation: false, + virtualQueuesData: null, + }); + + expect(bookings).toHaveLength(1); + }); + it("it should include bookings where the host is a no show", async () => { + await createHostNoShowTestBookings(); + + const bookings = await BookingRepository.getAllBookingsForRoundRobin({ + users: [{ id: 1, email: "organizer1@example.com" }], + eventTypeId: 1, + startDate: new Date(), + endDate: new Date(), + includeNoShowInRRCalculation: true, + virtualQueuesData: null, + }); + + expect(bookings).toHaveLength(2); + }); + it("it should not include bookings where the host or an attendee is a no show", async () => { + await createHostNoShowTestBookings(); + await createAttendeeNoShowTestBookings(); + + const bookings = await BookingRepository.getAllBookingsForRoundRobin({ + users: [{ id: 1, email: "organizer1@example.com" }], + eventTypeId: 1, + startDate: new Date(), + endDate: new Date(), + includeNoShowInRRCalculation: false, + virtualQueuesData: null, + }); + + expect(bookings).toHaveLength(2); + }); + it("it should include bookings where the host or an attendee is a no show", async () => { + await createHostNoShowTestBookings(); + await createAttendeeNoShowTestBookings(); + + const bookings = await BookingRepository.getAllBookingsForRoundRobin({ + users: [{ id: 1, email: "organizer1@example.com" }], + eventTypeId: 1, + startDate: new Date(), + endDate: new Date(), + includeNoShowInRRCalculation: true, + virtualQueuesData: null, + }); + + expect(bookings).toHaveLength(4); + }); + }); + }); +}); diff --git a/packages/lib/server/repository/booking.ts b/packages/lib/server/repository/booking.ts index 0f6e1fe95c..d6ceee51ed 100644 --- a/packages/lib/server/repository/booking.ts +++ b/packages/lib/server/repository/booking.ts @@ -45,6 +45,7 @@ const buildWhereClauseForActiveBookings = ({ endDate, users, virtualQueuesData, + includeNoShowInRRCalculation = false, }: { eventTypeId: number; startDate?: Date; @@ -57,20 +58,18 @@ const buildWhereClauseForActiveBookings = ({ selectedOptionIds: string | number | string[]; }; } | null; + includeNoShowInRRCalculation: boolean; }): Prisma.BookingWhereInput => ({ OR: [ { userId: { in: users.map((user) => user.id), }, - OR: [ - { - noShowHost: false, - }, - { - noShowHost: null, - }, - ], + ...(!includeNoShowInRRCalculation + ? { + OR: [{ noShowHost: false }, { noShowHost: null }], + } + : {}), }, { attendees: { @@ -82,7 +81,7 @@ const buildWhereClauseForActiveBookings = ({ }, }, ], - attendees: { some: { noShow: false } }, + ...(!includeNoShowInRRCalculation ? { attendees: { some: { noShow: false } } } : {}), status: BookingStatus.ACCEPTED, eventTypeId, ...(startDate || endDate @@ -166,29 +165,6 @@ export class BookingRepository { }); } - static async groupByActiveBookingCounts({ - users, - eventTypeId, - startDate, - }: { - users: { id: number; email: string }[]; - eventTypeId: number; - startDate?: Date; - }) { - return await prisma.booking.groupBy({ - by: ["userId"], - where: buildWhereClauseForActiveBookings({ - users, - eventTypeId, - startDate, - virtualQueuesData: null, - }), - _count: { - _all: true, - }, - }); - } - static async findAllExistingBookingsForEventTypeBetween({ eventTypeId, seatedEvent = false, @@ -307,6 +283,7 @@ export class BookingRepository { startDate, endDate, virtualQueuesData, + includeNoShowInRRCalculation, }: { users: { id: number; email: string }[]; eventTypeId: number; @@ -319,6 +296,7 @@ export class BookingRepository { selectedOptionIds: string | number | string[]; }; } | null; + includeNoShowInRRCalculation: boolean; }) { const allBookings = await prisma.booking.findMany({ where: buildWhereClauseForActiveBookings({ @@ -327,6 +305,7 @@ export class BookingRepository { endDate, users, virtualQueuesData, + includeNoShowInRRCalculation, }), select: { id: true, diff --git a/packages/lib/server/repository/eventType.ts b/packages/lib/server/repository/eventType.ts index b30aa7af8d..15a102a23d 100644 --- a/packages/lib/server/repository/eventType.ts +++ b/packages/lib/server/repository/eventType.ts @@ -674,6 +674,7 @@ export class EventTypeRepository { }, secondaryEmailId: true, maxLeadThreshold: true, + includeNoShowInRRCalculation: true, useEventLevelSelectedCalendars: true, }); @@ -811,6 +812,7 @@ export class EventTypeRepository { rrSegmentQueryValue: true, isRRWeightsEnabled: true, maxLeadThreshold: true, + includeNoShowInRRCalculation: true, useEventLevelSelectedCalendars: true, team: { select: { diff --git a/packages/lib/test/builder.ts b/packages/lib/test/builder.ts index 7640d0eba6..c53c693eb3 100644 --- a/packages/lib/test/builder.ts +++ b/packages/lib/test/builder.ts @@ -127,6 +127,7 @@ export const buildEventType = (eventType?: Partial): EventType => { disableRescheduling: false, seatsShowAvailabilityCount: null, maxLeadThreshold: null, + includeNoShowInRRCalculation: false, schedulingType: null, scheduleId: null, bookingLimits: null, diff --git a/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts b/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts index a8fe9f9669..a07b6d4d42 100644 --- a/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts +++ b/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts @@ -127,6 +127,7 @@ export const useEventTypeForm = ({ }, isRRWeightsEnabled: eventType.isRRWeightsEnabled, maxLeadThreshold: eventType.maxLeadThreshold, + includeNoShowInRRCalculation: eventType.includeNoShowInRRCalculation, useEventLevelSelectedCalendars: eventType.useEventLevelSelectedCalendars, customReplyToEmail: eventType.customReplyToEmail || null, }; diff --git a/packages/prisma/migrations/20250430031041_add_include_no_show_in_rr_calculation/migration.sql b/packages/prisma/migrations/20250430031041_add_include_no_show_in_rr_calculation/migration.sql new file mode 100644 index 0000000000..19f56103a0 --- /dev/null +++ b/packages/prisma/migrations/20250430031041_add_include_no_show_in_rr_calculation/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "EventType" ADD COLUMN "includeNoShowInRRCalculation" BOOLEAN NOT NULL DEFAULT false; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 50a23f2744..199b5d9bb7 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -169,6 +169,7 @@ model EventType { isRRWeightsEnabled Boolean @default(false) fieldTranslations EventTypeTranslation[] maxLeadThreshold Int? + includeNoShowInRRCalculation Boolean @default(false) selectedCalendars SelectedCalendar[] allowReschedulingPastBookings Boolean @default(false) hideOrganizerEmail Boolean @default(false)