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 (