From a78a3ffe393f962ad39f127d50c5bef4d2e03af4 Mon Sep 17 00:00:00 2001 From: Alex van Andel Date: Wed, 18 Feb 2026 11:47:30 +0000 Subject: [PATCH] refactor: optimize team booking limits query with in-app filtering (#28021) Replace 3 sequential queries (with expensive attendee email IN clauses) with 2 parallel queries using indexed eventType.teamId filtering and in-application user filtering with Sets for O(1) lookups. Fix managed events booking-limit behavior: managed bookings now only match by organizer userId (not attendee email), preserving the original query semantics where managed bookings used userId-only matching. Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- ....getAllAcceptedTeamBookingsOfUsers.test.ts | 258 ++++++++++++++++++ .../repositories/BookingRepository.ts | 127 ++++----- 2 files changed, 304 insertions(+), 81 deletions(-) create mode 100644 packages/features/bookings/repositories/BookingRepository.getAllAcceptedTeamBookingsOfUsers.test.ts diff --git a/packages/features/bookings/repositories/BookingRepository.getAllAcceptedTeamBookingsOfUsers.test.ts b/packages/features/bookings/repositories/BookingRepository.getAllAcceptedTeamBookingsOfUsers.test.ts new file mode 100644 index 0000000000..17e9816154 --- /dev/null +++ b/packages/features/bookings/repositories/BookingRepository.getAllAcceptedTeamBookingsOfUsers.test.ts @@ -0,0 +1,258 @@ +import type { PrismaClient } from "@calcom/prisma"; +import { BookingStatus } from "@calcom/prisma/enums"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { BookingRepository } from "./BookingRepository"; + +function makeBooking(overrides: { + id: number; + userId: number | null; + startTime?: Date; + endTime?: Date; + eventTypeId?: number; + title?: string; + attendees?: { email: string }[]; +}) { + return { + id: overrides.id, + uid: `uid-${overrides.id}`, + userId: overrides.userId, + startTime: overrides.startTime ?? new Date("2026-03-01T10:00:00Z"), + endTime: overrides.endTime ?? new Date("2026-03-01T11:00:00Z"), + eventTypeId: overrides.eventTypeId ?? 100, + title: overrides.title ?? `Booking ${overrides.id}`, + status: BookingStatus.ACCEPTED, + attendees: overrides.attendees ?? [], + }; +} + +describe("BookingRepository.getAllAcceptedTeamBookingsOfUsers", () => { + let repository: BookingRepository; + + const mockFindMany = vi.fn(); + const mockPrisma = { + booking: { + findMany: mockFindMany, + }, + } as unknown as PrismaClient; + + const baseParams = { + teamId: 1, + startDate: new Date("2026-03-01T00:00:00Z"), + endDate: new Date("2026-03-31T23:59:59Z"), + excludedUid: undefined, + includeManagedEvents: false, + }; + + const users = [ + { id: 10, email: "alice@example.com" }, + { id: 20, email: "bob@example.com" }, + ]; + + beforeEach(() => { + vi.clearAllMocks(); + repository = new BookingRepository(mockPrisma); + }); + + it("returns team bookings where user is the organizer (userId match)", async () => { + const teamBooking = makeBooking({ id: 1, userId: 10, attendees: [{ email: "external@test.com" }] }); + + mockFindMany.mockResolvedValueOnce([teamBooking]); + mockFindMany.mockResolvedValueOnce([]); + + const result = await repository.getAllAcceptedTeamBookingsOfUsers({ + ...baseParams, + users, + includeManagedEvents: true, + }); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe(1); + }); + + it("returns team bookings where user is an attendee (email match)", async () => { + const teamBooking = makeBooking({ + id: 2, + userId: 999, + attendees: [{ email: "alice@example.com" }], + }); + + mockFindMany.mockResolvedValueOnce([teamBooking]); + mockFindMany.mockResolvedValueOnce([]); + + const result = await repository.getAllAcceptedTeamBookingsOfUsers({ + ...baseParams, + users, + includeManagedEvents: true, + }); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe(2); + }); + + it("excludes team bookings where user is neither organizer nor attendee", async () => { + const irrelevantBooking = makeBooking({ + id: 3, + userId: 999, + attendees: [{ email: "stranger@test.com" }], + }); + + mockFindMany.mockResolvedValueOnce([irrelevantBooking]); + mockFindMany.mockResolvedValueOnce([]); + + const result = await repository.getAllAcceptedTeamBookingsOfUsers({ + ...baseParams, + users, + includeManagedEvents: true, + }); + + expect(result).toHaveLength(0); + }); + + it("returns managed bookings only when user is the organizer (not by attendee email)", async () => { + const managedOrganizerBooking = makeBooking({ id: 4, userId: 10 }); + const managedAttendeeBooking = makeBooking({ id: 5, userId: 999 }); + + mockFindMany.mockResolvedValueOnce([]); + mockFindMany.mockResolvedValueOnce([managedOrganizerBooking, managedAttendeeBooking]); + + const result = await repository.getAllAcceptedTeamBookingsOfUsers({ + ...baseParams, + users, + includeManagedEvents: true, + }); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe(4); + }); + + it("does not query managed bookings when includeManagedEvents is false", async () => { + mockFindMany.mockResolvedValueOnce([]); + + await repository.getAllAcceptedTeamBookingsOfUsers({ + ...baseParams, + users, + includeManagedEvents: false, + }); + + expect(mockFindMany).toHaveBeenCalledTimes(1); + expect(mockFindMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + eventType: { teamId: 1 }, + }), + }) + ); + }); + + it("runs team and managed queries in parallel via Promise.all", async () => { + const callOrder: string[] = []; + + mockFindMany.mockImplementation((args) => { + if (args.where.eventType?.teamId) { + callOrder.push("team"); + return Promise.resolve([]); + } + if (args.where.eventType?.parent) { + callOrder.push("managed"); + return Promise.resolve([]); + } + return Promise.resolve([]); + }); + + await repository.getAllAcceptedTeamBookingsOfUsers({ + ...baseParams, + users, + includeManagedEvents: true, + }); + + expect(mockFindMany).toHaveBeenCalledTimes(2); + }); + + it("returns count when shouldReturnCount is true", async () => { + const teamBooking = makeBooking({ id: 1, userId: 10, attendees: [{ email: "ext@test.com" }] }); + const managedBooking = makeBooking({ id: 2, userId: 20 }); + + mockFindMany.mockResolvedValueOnce([teamBooking]); + mockFindMany.mockResolvedValueOnce([managedBooking]); + + const result = await repository.getAllAcceptedTeamBookingsOfUsers({ + ...baseParams, + users, + includeManagedEvents: true, + shouldReturnCount: true, + }); + + expect(result).toBe(2); + }); + + it("handles bookings with null userId correctly", async () => { + const nullUserBooking = makeBooking({ + id: 6, + userId: null, + attendees: [{ email: "bob@example.com" }], + }); + + mockFindMany.mockResolvedValueOnce([nullUserBooking]); + mockFindMany.mockResolvedValueOnce([]); + + const result = await repository.getAllAcceptedTeamBookingsOfUsers({ + ...baseParams, + users, + includeManagedEvents: true, + }); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe(6); + }); + + it("handles managed booking with null userId (excluded)", async () => { + const managedNullUser = makeBooking({ id: 7, userId: null }); + + mockFindMany.mockResolvedValueOnce([]); + mockFindMany.mockResolvedValueOnce([managedNullUser]); + + const result = await repository.getAllAcceptedTeamBookingsOfUsers({ + ...baseParams, + users, + includeManagedEvents: true, + }); + + expect(result).toHaveLength(0); + }); + + it("combines team and managed bookings in results", async () => { + const teamBooking = makeBooking({ id: 10, userId: 10, attendees: [{ email: "ext@test.com" }] }); + const managedBooking = makeBooking({ id: 11, userId: 20 }); + + mockFindMany.mockResolvedValueOnce([teamBooking]); + mockFindMany.mockResolvedValueOnce([managedBooking]); + + const result = await repository.getAllAcceptedTeamBookingsOfUsers({ + ...baseParams, + users, + includeManagedEvents: true, + }); + + expect(result).toHaveLength(2); + expect(result.map((b: { id: number }) => b.id)).toEqual([10, 11]); + }); + + it("applies excludedUid to query where clause", async () => { + mockFindMany.mockResolvedValue([]); + + await repository.getAllAcceptedTeamBookingsOfUsers({ + ...baseParams, + users, + excludedUid: "reschedule-uid-123", + includeManagedEvents: true, + }); + + expect(mockFindMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + uid: { not: "reschedule-uid-123" }, + }), + }) + ); + }); +}); diff --git a/packages/features/bookings/repositories/BookingRepository.ts b/packages/features/bookings/repositories/BookingRepository.ts index f5a935725e..085b4c936f 100644 --- a/packages/features/bookings/repositories/BookingRepository.ts +++ b/packages/features/bookings/repositories/BookingRepository.ts @@ -8,14 +8,13 @@ import { bookingMinimalSelect, } from "@calcom/prisma/selects/booking"; import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential"; - -import type { - BookingWhereInput, - IBookingRepository, - BookingUpdateData, - BookingWhereUniqueInput, -} from "./IBookingRepository"; import { workflowSelect } from "../../ee/workflows/lib/getAllWorkflows"; +import type { + BookingUpdateData, + BookingWhereInput, + BookingWhereUniqueInput, + IBookingRepository, +} from "./IBookingRepository"; const workflowReminderSelect = { id: true, @@ -1315,89 +1314,51 @@ export class BookingRepository implements IBookingRepository { }), }; - const userIds = users.map((user) => user.id); - const userEmails = users.map((user) => user.email); + const userIdSet = new Set(users.map((user) => user.id)); + const userEmailSet = new Set(users.map((user) => user.email)); - const whereCollectiveRoundRobinOwner: Prisma.BookingWhereInput = { - ...baseWhere, - userId: { - in: userIds, + const teamBookingsQuery = this.prismaClient.booking.findMany({ + where: { + ...baseWhere, + eventType: { teamId }, }, - eventType: { - teamId, + include: { + attendees: { + select: { email: true }, + }, }, - }; + }); - const whereCollectiveRoundRobinBookingsAttendee: Prisma.BookingWhereInput = { - ...baseWhere, - attendees: { - some: { - email: { - in: userEmails, + const managedBookingsQuery = includeManagedEvents + ? this.prismaClient.booking.findMany({ + where: { + ...baseWhere, + eventType: { parent: { teamId } }, }, - }, - }, - eventType: { - teamId, - }, - }; + }) + : Promise.resolve([] as Booking[]); - const whereManagedBookings: Prisma.BookingWhereInput = { - ...baseWhere, - userId: { - in: userIds, - }, - eventType: { - parent: { - teamId, - }, - }, - }; + const [allTeamBookings, allManagedBookings] = await Promise.all([ + teamBookingsQuery, + managedBookingsQuery, + ]); + + const relevantTeamBookings = allTeamBookings.filter((booking) => { + if (booking.userId !== null && userIdSet.has(booking.userId)) { + return true; + } + return booking.attendees.some((attendee) => userEmailSet.has(attendee.email)); + }); + + const relevantManagedBookings = allManagedBookings.filter( + (booking) => booking.userId !== null && userIdSet.has(booking.userId) + ); if (shouldReturnCount) { - const collectiveRoundRobinBookingsOwner = await this.prismaClient.booking.count({ - where: whereCollectiveRoundRobinOwner, - }); - - const collectiveRoundRobinBookingsAttendee = await this.prismaClient.booking.count({ - where: whereCollectiveRoundRobinBookingsAttendee, - }); - - let managedBookings = 0; - - if (includeManagedEvents) { - managedBookings = await this.prismaClient.booking.count({ - where: whereManagedBookings, - }); - } - - const totalNrOfBooking = - collectiveRoundRobinBookingsOwner + collectiveRoundRobinBookingsAttendee + managedBookings; - - return totalNrOfBooking; + return relevantTeamBookings.length + relevantManagedBookings.length; } - const collectiveRoundRobinBookingsOwner = await this.prismaClient.booking.findMany({ - where: whereCollectiveRoundRobinOwner, - }); - - const collectiveRoundRobinBookingsAttendee = await this.prismaClient.booking.findMany({ - where: whereCollectiveRoundRobinBookingsAttendee, - }); - - let managedBookings: typeof collectiveRoundRobinBookingsAttendee = []; - - if (includeManagedEvents) { - managedBookings = await this.prismaClient.booking.findMany({ - where: whereManagedBookings, - }); - } - - return [ - ...collectiveRoundRobinBookingsOwner, - ...collectiveRoundRobinBookingsAttendee, - ...managedBookings, - ]; + return [...relevantTeamBookings, ...relevantManagedBookings]; } async getValidBookingFromEventTypeForAttendee({ @@ -2142,7 +2103,11 @@ export class BookingRepository implements IBookingRepository { }); } - async findByUidIncludeUserAndEventTypeTeamAndAttendeesAndAssignmentReason({ bookingUid }: { bookingUid: string }) { + async findByUidIncludeUserAndEventTypeTeamAndAttendeesAndAssignmentReason({ + bookingUid, + }: { + bookingUid: string; + }) { return await this.prismaClient.booking.findUnique({ where: { uid: bookingUid }, select: {