From f883dee08496d8857323874316b28c15364225d0 Mon Sep 17 00:00:00 2001 From: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> Date: Tue, 24 Feb 2026 22:55:33 +0530 Subject: [PATCH] perf: improve getTotalBookingDuration query (#27383) * perf: improve getTotalBookingDuration query * perf: improve getTotalBookingDuration query * test: add unit tests for getTotalBookingDurationForUsers and yearly duration pre-fetching Co-Authored-By: udit@cal.com * fix: integration tests * fix: update tests * fix: use vi.stubEnv instead of direct process.env modification for test isolation Replace direct deletion of process.env variables with vi.stubEnv() to ensure automatic restoration after tests, preventing flaky tests and side effects in parallel execution. Issue identified by Cubic AI (confidence: 9/10) Co-Authored-By: unknown <> * chore: add log * refactor: imports --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../durationLimits.integration-test.ts | 213 ++++++++++++++++ .../repositories/BookingRepository.test.ts | 61 +++++ .../repositories/BookingRepository.ts | 1 + .../getBusyTimesFromLimitsForUsers.test.ts | 232 ++++++++++++++++++ .../trpc/server/routers/viewer/slots/util.ts | 41 +++- 5 files changed, 542 insertions(+), 6 deletions(-) create mode 100644 apps/web/test/lib/getSchedule/durationLimits.integration-test.ts create mode 100644 packages/features/bookings/repositories/BookingRepository.test.ts create mode 100644 packages/trpc/server/routers/viewer/slots/getBusyTimesFromLimitsForUsers.test.ts diff --git a/apps/web/test/lib/getSchedule/durationLimits.integration-test.ts b/apps/web/test/lib/getSchedule/durationLimits.integration-test.ts new file mode 100644 index 0000000000..18db56fb6c --- /dev/null +++ b/apps/web/test/lib/getSchedule/durationLimits.integration-test.ts @@ -0,0 +1,213 @@ +import { afterAll, beforeAll, describe, test, vi } from "vitest"; + +import { getAvailableSlotsService } from "@calcom/features/di/containers/AvailableSlots"; +import { prisma } from "@calcom/prisma"; +import type { EventType, Schedule, Team, User } from "@calcom/prisma/client"; +import { BookingStatus, MembershipRole, SchedulingType } from "@calcom/prisma/enums"; + +import { expect } from "./expects"; + +describe("getSchedule duration limits (integration)", () => { + let user1: User; + let user2: User; + let team: Team; + let schedule1: Schedule; + let schedule2: Schedule; + let eventType: EventType; + const createdBookingIds: number[] = []; + + beforeAll(async () => { + const timestamp = Date.now(); + + vi.stubEnv("UPSTASH_REDIS_REST_URL", undefined); + vi.stubEnv("UPSTASH_REDIS_REST_TOKEN", undefined); + + [user1, user2] = await Promise.all([ + prisma.user.create({ + data: { + username: `dur-limit-user-1-${timestamp}`, + name: "Duration Limit User 1", + email: `dur-limit-user-1-${timestamp}@example.com`, + }, + }), + prisma.user.create({ + data: { + username: `dur-limit-user-2-${timestamp}`, + name: "Duration Limit User 2", + email: `dur-limit-user-2-${timestamp}@example.com`, + }, + }), + ]); + + [schedule1, schedule2] = await Promise.all([ + prisma.schedule.create({ + data: { + name: `Schedule 1 ${timestamp}`, + userId: user1.id, + timeZone: "UTC", + }, + }), + prisma.schedule.create({ + data: { + name: `Schedule 2 ${timestamp}`, + userId: user2.id, + timeZone: "UTC", + }, + }), + ]); + + await prisma.availability.createMany({ + data: [ + { + scheduleId: schedule1.id, + days: [0, 1, 2, 3, 4, 5, 6], + startTime: new Date("1970-01-01T09:00:00.000Z"), + endTime: new Date("1970-01-01T17:00:00.000Z"), + }, + { + scheduleId: schedule2.id, + days: [0, 1, 2, 3, 4, 5, 6], + startTime: new Date("1970-01-01T09:00:00.000Z"), + endTime: new Date("1970-01-01T17:00:00.000Z"), + }, + ], + }); + + await Promise.all([ + prisma.user.update({ + where: { id: user1.id }, + data: { defaultScheduleId: schedule1.id }, + }), + prisma.user.update({ + where: { id: user2.id }, + data: { defaultScheduleId: schedule2.id }, + }), + ]); + + team = await prisma.team.create({ + data: { + name: `Duration Limit Team ${timestamp}`, + slug: `duration-limit-team-${timestamp}`, + }, + }); + + await prisma.membership.createMany({ + data: [ + { + userId: user1.id, + teamId: team.id, + role: MembershipRole.ADMIN, + accepted: true, + }, + { + userId: user2.id, + teamId: team.id, + role: MembershipRole.MEMBER, + accepted: true, + }, + ], + }); + + eventType = await prisma.eventType.create({ + data: { + title: "Duration Limit Event", + slug: `duration-limit-event-${timestamp}`, + length: 30, + slotInterval: 30, + teamId: team.id, + userId: user1.id, + schedulingType: SchedulingType.ROUND_ROBIN, + durationLimits: { PER_YEAR: 480 }, + users: { + connect: [{ id: user1.id }, { id: user2.id }], + }, + hosts: { + createMany: { + data: [ + { userId: user1.id, isFixed: false }, + { userId: user2.id, isFixed: false }, + ], + }, + }, + }, + }); + + const [booking1, booking2] = await Promise.all([ + prisma.booking.create({ + data: { + uid: `dur-limit-booking-1-${timestamp}`, + title: "Duration Limit Booking 1", + status: BookingStatus.ACCEPTED, + userId: user1.id, + eventTypeId: eventType.id, + startTime: new Date("2026-01-10T00:00:00.000Z"), + endTime: new Date("2026-01-10T06:40:00.000Z"), // 400 minutes + }, + }), + prisma.booking.create({ + data: { + uid: `dur-limit-booking-2-${timestamp}`, + title: "Duration Limit Booking 2", + status: BookingStatus.ACCEPTED, + userId: user2.id, + eventTypeId: eventType.id, + startTime: new Date("2026-01-11T00:00:00.000Z"), + endTime: new Date("2026-01-11T06:40:00.000Z"), // 400 minutes + }, + }), + ]); + + createdBookingIds.push(booking1.id, booking2.id); + }); + + afterAll(async () => { + vi.useRealTimers(); + if (createdBookingIds.length > 0) { + await prisma.booking.deleteMany({ + where: { id: { in: createdBookingIds } }, + }); + } + if (eventType?.id) { + await prisma.eventType.delete({ + where: { id: eventType.id }, + }); + } + await prisma.availability.deleteMany({ + where: { scheduleId: { in: [schedule1?.id, schedule2?.id].filter(Boolean) } }, + }); + await prisma.schedule.deleteMany({ + where: { id: { in: [schedule1?.id, schedule2?.id].filter(Boolean) } }, + }); + await prisma.membership.deleteMany({ + where: { teamId: team?.id }, + }); + if (team?.id) { + await prisma.team.delete({ + where: { id: team.id }, + }); + } + await prisma.user.deleteMany({ + where: { id: { in: [user1?.id, user2?.id].filter(Boolean) } }, + }); + }); + + test("yearly duration limit is enforced event-type-wide", async () => { + vi.setSystemTime("2026-02-01T00:00:00Z"); + const dateString = "2026-02-10"; + + const availableSlotsService = getAvailableSlotsService(); + const scheduleForEvent = await availableSlotsService.getAvailableSlots({ + input: { + eventTypeId: eventType.id, + eventTypeSlug: "", + startTime: `${dateString}T00:00:00.000Z`, + endTime: `${dateString}T23:59:59.999Z`, + timeZone: "UTC", + isTeamEvent: true, + orgSlug: null, + }, + }); + + expect(scheduleForEvent).toHaveDateDisabled({ dateString }); + }); +}); diff --git a/packages/features/bookings/repositories/BookingRepository.test.ts b/packages/features/bookings/repositories/BookingRepository.test.ts new file mode 100644 index 0000000000..965673ad00 --- /dev/null +++ b/packages/features/bookings/repositories/BookingRepository.test.ts @@ -0,0 +1,61 @@ +import type { PrismaClient } from "@calcom/prisma"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { BookingRepository } from "./BookingRepository"; + +describe("BookingRepository", () => { + let repository: BookingRepository; + let mockPrismaClient: { + $queryRaw: ReturnType; + }; + + beforeEach(() => { + vi.clearAllMocks(); + + mockPrismaClient = { + $queryRaw: vi.fn(), + }; + + repository = new BookingRepository(mockPrismaClient as unknown as PrismaClient); + }); + + describe("getTotalBookingDuration", () => { + it("should return total minutes from the database result", async () => { + mockPrismaClient.$queryRaw.mockResolvedValue([{ totalMinutes: 120 }]); + + const result = await repository.getTotalBookingDuration({ + eventId: 52, + startDate: new Date("2026-01-01"), + endDate: new Date("2026-12-31"), + }); + + expect(result).toBe(120); + expect(mockPrismaClient.$queryRaw).toHaveBeenCalledTimes(1); + }); + + it("should return 0 when totalMinutes is null", async () => { + mockPrismaClient.$queryRaw.mockResolvedValue([{ totalMinutes: null }]); + + const result = await repository.getTotalBookingDuration({ + eventId: 52, + startDate: new Date("2026-01-01"), + endDate: new Date("2026-12-31"), + }); + + expect(result).toBe(0); + }); + + it("should call query when rescheduleUid is provided", async () => { + mockPrismaClient.$queryRaw.mockResolvedValue([{ totalMinutes: 90 }]); + + const result = await repository.getTotalBookingDuration({ + eventId: 52, + startDate: new Date("2026-01-01"), + endDate: new Date("2026-12-31"), + rescheduleUid: "existing-booking-uid", + }); + + expect(result).toBe(90); + expect(mockPrismaClient.$queryRaw).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/features/bookings/repositories/BookingRepository.ts b/packages/features/bookings/repositories/BookingRepository.ts index 8afb0278a9..6de5476b73 100644 --- a/packages/features/bookings/repositories/BookingRepository.ts +++ b/packages/features/bookings/repositories/BookingRepository.ts @@ -1497,6 +1497,7 @@ export class BookingRepository implements IBookingRepository { AND "endTime" <= ${endDate}; `; } + return totalBookingTime.totalMinutes ?? 0; } diff --git a/packages/trpc/server/routers/viewer/slots/getBusyTimesFromLimitsForUsers.test.ts b/packages/trpc/server/routers/viewer/slots/getBusyTimesFromLimitsForUsers.test.ts new file mode 100644 index 0000000000..c888148f1e --- /dev/null +++ b/packages/trpc/server/routers/viewer/slots/getBusyTimesFromLimitsForUsers.test.ts @@ -0,0 +1,232 @@ +import dayjs from "@calcom/dayjs"; +import type { IntervalLimit } from "@calcom/lib/intervalLimits/intervalLimitSchema"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { IAvailableSlotsService } from "./util"; +import { AvailableSlotsService } from "./util"; + +describe("AvailableSlotsService - _getBusyTimesFromLimitsForUsers", () => { + type BusyTimesFromLimitsForUsers = typeof AvailableSlotsService.prototype._getBusyTimesFromLimitsForUsers; + let service: AvailableSlotsService; + let mockDependencies: { + bookingRepo: { + getTotalBookingDuration: ReturnType; + }; + busyTimesService: { + getStartEndDateforLimitCheck: ReturnType; + getBusyTimesForLimitChecks: ReturnType; + }; + userAvailabilityService: { + getPeriodStartDatesBetween: ReturnType; + }; + checkBookingLimitsService: { + checkBookingLimit: ReturnType; + }; + }; + + beforeEach(() => { + vi.clearAllMocks(); + + mockDependencies = { + bookingRepo: { + getTotalBookingDuration: vi.fn(), + }, + busyTimesService: { + getStartEndDateforLimitCheck: vi.fn().mockReturnValue({ + limitDateFrom: dayjs("2026-01-01"), + limitDateTo: dayjs("2026-12-31"), + }), + getBusyTimesForLimitChecks: vi.fn().mockResolvedValue([]), + }, + userAvailabilityService: { + getPeriodStartDatesBetween: vi.fn().mockReturnValue([dayjs("2026-01-01")]), + }, + checkBookingLimitsService: { + checkBookingLimit: vi.fn(), + }, + }; + + service = new AvailableSlotsService(mockDependencies as unknown as IAvailableSlotsService); + }); + + describe("yearly duration limit pre-fetching optimization", () => { + const users = [ + { id: 29, email: "user29@example.com" }, + { id: 31, email: "user31@example.com" }, + ]; + const eventType = { id: 52, length: 30 }; + const dateFrom = dayjs("2026-01-01"); + const dateTo = dayjs("2026-12-31"); + const timeZone = "UTC"; + + it("should call getTotalBookingDuration once for yearly duration limits instead of N times", async () => { + const durationLimits: IntervalLimit = { PER_YEAR: 480 }; + mockDependencies.bookingRepo.getTotalBookingDuration.mockResolvedValue(180); + + await ( + service as unknown as { _getBusyTimesFromLimitsForUsers: BusyTimesFromLimitsForUsers } + )._getBusyTimesFromLimitsForUsers( + users, + null, + durationLimits, + dateFrom, + dateTo, + 30, + eventType, + timeZone + ); + + expect(mockDependencies.bookingRepo.getTotalBookingDuration).toHaveBeenCalledTimes(1); + expect(mockDependencies.bookingRepo.getTotalBookingDuration).toHaveBeenCalledWith({ + eventId: 52, + startDate: expect.any(Date), + endDate: expect.any(Date), + rescheduleUid: undefined, + }); + }); + + it("should not call getTotalBookingDuration when no PER_YEAR duration limit", async () => { + const durationLimits: IntervalLimit = { PER_DAY: 120 }; + + await ( + service as unknown as { _getBusyTimesFromLimitsForUsers: BusyTimesFromLimitsForUsers } + )._getBusyTimesFromLimitsForUsers( + users, + null, + durationLimits, + dateFrom, + dateTo, + 30, + eventType, + timeZone + ); + + expect(mockDependencies.bookingRepo.getTotalBookingDuration).not.toHaveBeenCalled(); + }); + + it("should not call getTotalBookingDuration when no duration limits at all", async () => { + await ( + service as unknown as { _getBusyTimesFromLimitsForUsers: BusyTimesFromLimitsForUsers } + )._getBusyTimesFromLimitsForUsers(users, null, null, dateFrom, dateTo, 30, eventType, timeZone); + + expect(mockDependencies.bookingRepo.getTotalBookingDuration).not.toHaveBeenCalled(); + }); + + it("should pass rescheduleUid to getTotalBookingDuration when provided", async () => { + const durationLimits: IntervalLimit = { PER_YEAR: 480 }; + const rescheduleUid = "existing-booking-uid"; + + mockDependencies.bookingRepo.getTotalBookingDuration.mockResolvedValue(0); + + await ( + service as unknown as { _getBusyTimesFromLimitsForUsers: BusyTimesFromLimitsForUsers } + )._getBusyTimesFromLimitsForUsers( + users, + null, + durationLimits, + dateFrom, + dateTo, + 30, + eventType, + timeZone, + rescheduleUid + ); + + expect(mockDependencies.bookingRepo.getTotalBookingDuration).toHaveBeenCalledWith( + expect.objectContaining({ + rescheduleUid: "existing-booking-uid", + }) + ); + }); + + it("should call getTotalBookingDuration for each year when date range spans multiple years", async () => { + const durationLimits: IntervalLimit = { PER_YEAR: 480 }; + const multiYearDateFrom = dayjs("2025-12-01"); + const multiYearDateTo = dayjs("2026-02-28"); + + mockDependencies.userAvailabilityService.getPeriodStartDatesBetween.mockReturnValue([ + dayjs("2025-01-01"), + dayjs("2026-01-01"), + ]); + + mockDependencies.bookingRepo.getTotalBookingDuration.mockResolvedValue(0); + + await ( + service as unknown as { _getBusyTimesFromLimitsForUsers: BusyTimesFromLimitsForUsers } + )._getBusyTimesFromLimitsForUsers( + users, + null, + durationLimits, + multiYearDateFrom, + multiYearDateTo, + 30, + eventType, + timeZone + ); + + expect(mockDependencies.bookingRepo.getTotalBookingDuration).toHaveBeenCalledTimes(2); + }); + + it("should mark user as busy when yearly duration limit is exceeded", async () => { + const durationLimits: IntervalLimit = { PER_YEAR: 100 }; + mockDependencies.bookingRepo.getTotalBookingDuration.mockResolvedValue(90); + + const result = await ( + service as unknown as { _getBusyTimesFromLimitsForUsers: BusyTimesFromLimitsForUsers } + )._getBusyTimesFromLimitsForUsers( + users, + null, + durationLimits, + dateFrom, + dateTo, + 30, + eventType, + timeZone + ); + + expect(result.get(29)).toBeDefined(); + expect(result.get(29)?.length).toBeGreaterThan(0); + }); + + it("should not mark user as busy when yearly duration limit is not exceeded", async () => { + const durationLimits: IntervalLimit = { PER_YEAR: 500 }; + mockDependencies.bookingRepo.getTotalBookingDuration.mockResolvedValue(120); + + const result = await ( + service as unknown as { _getBusyTimesFromLimitsForUsers: BusyTimesFromLimitsForUsers } + )._getBusyTimesFromLimitsForUsers( + users, + null, + durationLimits, + dateFrom, + dateTo, + 30, + eventType, + timeZone + ); + + expect(result.get(29) ?? []).toHaveLength(0); + expect(result.get(31) ?? []).toHaveLength(0); + }); + + it("should handle users with no bookings (0 duration) correctly", async () => { + const durationLimits: IntervalLimit = { PER_YEAR: 100 }; + mockDependencies.bookingRepo.getTotalBookingDuration.mockResolvedValue(0); + + const result = await ( + service as unknown as { _getBusyTimesFromLimitsForUsers: BusyTimesFromLimitsForUsers } + )._getBusyTimesFromLimitsForUsers( + users, + null, + durationLimits, + dateFrom, + dateTo, + 30, + eventType, + timeZone + ); + + expect(result.get(29) ?? []).toHaveLength(0); + expect(result.get(31) ?? []).toHaveLength(0); + }); + }); +}); diff --git a/packages/trpc/server/routers/viewer/slots/util.ts b/packages/trpc/server/routers/viewer/slots/util.ts index 0260b13d8b..14fba5d1da 100644 --- a/packages/trpc/server/routers/viewer/slots/util.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -459,6 +459,28 @@ export class AvailableSlotsService { } } + // Pre-fetch yearly duration totals per event type to avoid N+1 queries + const yearlyDurationTotals = new Map(); + const missingYearlyDurationTotals = new Set(); + if (durationLimits?.PER_YEAR) { + const yearPeriodStartDates = this.dependencies.userAvailabilityService.getPeriodStartDatesBetween( + dateFrom, + dateTo, + "year", + timeZone + ); + for (const periodStart of yearPeriodStartDates) { + const yearKey = periodStart.format("YYYY"); + const totalDurationForYear = await this.dependencies.bookingRepo.getTotalBookingDuration({ + eventId: eventType.id, + startDate: periodStart.toDate(), + endDate: periodStart.endOf("year").toDate(), + rescheduleUid, + }); + yearlyDurationTotals.set(yearKey, totalDurationForYear); + } + } + for (const user of users) { const userBookings = busyTimesFromLimitsBookings.filter((booking) => booking.userId === user.id); const limitManager = new LimitManager(); @@ -545,12 +567,19 @@ export class AvailableSlotsService { } if (unit === "year") { - const totalYearlyDuration = await this.dependencies.bookingRepo.getTotalBookingDuration({ - eventId: eventType.id, - startDate: periodStart.toDate(), - endDate: periodStart.endOf(unit).toDate(), - rescheduleUid, - }); + const yearKey = periodStart.format("YYYY"); + if (!yearlyDurationTotals.has(yearKey) && !missingYearlyDurationTotals.has(yearKey)) { + missingYearlyDurationTotals.add(yearKey); + log.warn("[DURATION LIMIT CACHE MISS] Missing pre-fetched yearly duration total", { + eventTypeId: eventType.id, + durationLimitKey: key, + yearKey, + dateFrom: dateFrom.toISOString(), + dateTo: dateTo.toISOString(), + timeZone, + }); + } + const totalYearlyDuration = yearlyDurationTotals.get(yearKey) ?? 0; if (totalYearlyDuration + selectedDuration > limit) { limitManager.addBusyTime(periodStart, unit, timeZone); if (