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>
This commit is contained in:
co-authored by
Devin AI
parent
a6a428c8cb
commit
a78a3ffe39
+258
@@ -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" },
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user