diff --git a/apps/api/v1/lib/validations/team.ts b/apps/api/v1/lib/validations/team.ts index 948574cea4c295..b32d82f80edb7b 100644 --- a/apps/api/v1/lib/validations/team.ts +++ b/apps/api/v1/lib/validations/team.ts @@ -10,6 +10,7 @@ export const schemaTeamBaseBodyParams = Team.omit({ id: true, createdAt: true }) isPlatform: true, smsLockState: true, smsLockReviewedByAdmin: true, + bookingLimits: true, }); const schemaTeamRequiredParams = z.object({ diff --git a/apps/api/v1/pages/api/teams/[teamId]/_patch.ts b/apps/api/v1/pages/api/teams/[teamId]/_patch.ts index 3815f861595a3e..057c4cb9a8b6e4 100644 --- a/apps/api/v1/pages/api/teams/[teamId]/_patch.ts +++ b/apps/api/v1/pages/api/teams/[teamId]/_patch.ts @@ -123,9 +123,11 @@ export async function patchHandler(req: NextApiRequest) { // TODO: Perhaps there is a better fix for this? const cloneData: typeof data & { metadata: NonNullable | undefined; + bookingLimits: NonNullable | undefined; } = { ...data, smsLockReviewedByAdmin: false, + bookingLimits: data.bookingLimits === null ? {} : data.bookingLimits, metadata: data.metadata === null ? {} : data.metadata || undefined, }; const team = await prisma.team.update({ where: { id: teamId }, data: cloneData }); diff --git a/apps/api/v1/pages/api/teams/_post.ts b/apps/api/v1/pages/api/teams/_post.ts index 12fef369d4a8ed..0a4807fa18467b 100644 --- a/apps/api/v1/pages/api/teams/_post.ts +++ b/apps/api/v1/pages/api/teams/_post.ts @@ -123,9 +123,11 @@ async function postHandler(req: NextApiRequest) { // TODO: Perhaps there is a better fix for this? const cloneData: typeof data & { metadata: NonNullable | undefined; + bookingLimits: NonNullable | undefined; } = { ...data, smsLockReviewedByAdmin: false, + bookingLimits: data.bookingLimits === null ? {} : data.bookingLimits || undefined, metadata: data.metadata === null ? {} : data.metadata || undefined, }; diff --git a/apps/api/v2/src/modules/organizations/inputs/update-organization-team.input.ts b/apps/api/v2/src/modules/organizations/inputs/update-organization-team.input.ts index fdaa2011115d1f..168f823cedafba 100644 --- a/apps/api/v2/src/modules/organizations/inputs/update-organization-team.input.ts +++ b/apps/api/v2/src/modules/organizations/inputs/update-organization-team.input.ts @@ -72,4 +72,8 @@ export class UpdateOrgTeamDto { @IsOptional() @IsString() readonly weekStart?: string = "Sunday"; + + @IsOptional() + @IsString() + readonly bookingLimits?: string; } diff --git a/apps/web/pages/settings/teams/[id]/bookingLimits.tsx b/apps/web/pages/settings/teams/[id]/bookingLimits.tsx new file mode 100644 index 00000000000000..18b98824936246 --- /dev/null +++ b/apps/web/pages/settings/teams/[id]/bookingLimits.tsx @@ -0,0 +1,24 @@ +import TeamBookingLimitsView from "@calcom/features/ee/teams/pages/team-booking-limits-view"; +import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { Meta } from "@calcom/ui"; + +import PageWrapper from "@components/PageWrapper"; + +const Page = () => { + const { t } = useLocale(); + return ( + <> + + + + ); +}; +Page.getLayout = getLayout; +Page.PageWrapper = PageWrapper; + +export default Page; diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index f1bbb87a14bcb5..c0afa5ccc3a642 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -2608,6 +2608,10 @@ "reschedule_with_same_round_robin_host_title": "Reschedule with same Round-Robin host", "reschedule_with_same_round_robin_host_description": "Rescheduled events will be assigned to the same host as initially scheduled", "disable_input_if_prefilled": "Disable input if the URL identifier is prefilled", + "booking_limits": "Booking Limits", + "booking_limits_team_description": "Booking limits for team members across all team event types", + "limit_team_booking_frequency_description": "Limit how many times members can be booked across all team event types (collective and round-robin)", + "booking_limits_updated_successfully": "Booking limits updated successfully", "you_are_unauthorized_to_make_this_change_to_the_booking": "You are unauthorized to make this change to the booking", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/apps/web/test/lib/getSchedule.test.ts b/apps/web/test/lib/getSchedule.test.ts index 41f2f8062d0e68..14f2387f58a9f4 100644 --- a/apps/web/test/lib/getSchedule.test.ts +++ b/apps/web/test/lib/getSchedule.test.ts @@ -16,7 +16,7 @@ import { import { describe, vi, test } from "vitest"; import dayjs from "@calcom/dayjs"; -import type { BookingStatus } from "@calcom/prisma/enums"; +import { SchedulingType, type BookingStatus } from "@calcom/prisma/enums"; import { getAvailableSlots as getSchedule } from "@calcom/trpc/server/routers/viewer/slots/util"; import { expect } from "./getSchedule/expects"; @@ -1232,6 +1232,246 @@ describe("getSchedule", () => { expect(availableSlotsInTz.filter((slot) => slot.format().startsWith(plus2DateString)).length).toBe(0); // 1 booking per day as limit }); + test("global team booking limit block slot if one fixed host reached limit", async () => { + const { dateString: plus1DateString } = getDate({ dateIncrement: 1 }); + const { dateString: plus2DateString } = getDate({ dateIncrement: 2 }); + const { dateString: plus3DateString } = getDate({ dateIncrement: 3 }); + + const scenarioData = { + eventTypes: [ + { + id: 1, + length: 60, + beforeEventBuffer: 0, + afterEventBuffer: 0, + team: { + id: 1, + bookingLimits: { PER_DAY: 1 }, + }, + schedulingType: SchedulingType.COLLECTIVE, + users: [ + { + id: 101, + }, + { + id: 102, + }, + ], + }, + { + id: 2, + length: 60, + beforeEventBuffer: 0, + afterEventBuffer: 0, + team: { + id: 1, + bookingLimits: { PER_DAY: 1 }, + }, + schedulingType: SchedulingType.COLLECTIVE, + users: [ + { + id: 101, + }, + { + id: 102, + }, + ], + }, + { + id: 3, + length: 60, + beforeEventBuffer: 0, + afterEventBuffer: 0, + users: [ + { + id: 101, + }, + ], + }, + ], + users: [ + { + ...TestData.users.example, + id: 101, + schedules: [ + { + id: 1, + name: "All Day available", + availability: [ + { + userId: null, + eventTypeId: null, + days: [0, 1, 2, 3, 4, 5, 6], + startTime: new Date("1970-01-01T00:00:00.000Z"), + endTime: new Date("1970-01-01T23:59:59.999Z"), + date: null, + }, + ], + timeZone: Timezones["+6:00"], + }, + ], + }, + ], + bookings: [ + { + userId: 101, + eventTypeId: 1, + startTime: `${plus2DateString}T08:00:00.000Z`, + endTime: `${plus2DateString}T09:00:00.000Z`, + status: "ACCEPTED" as BookingStatus, + }, + ], + }; + + await createBookingScenario(scenarioData); + + const availabilityEventTypeOne = await getSchedule({ + input: { + eventTypeId: 1, + eventTypeSlug: "", + startTime: `${plus1DateString}T00:00:00.000Z`, + endTime: `${plus3DateString}T23:59:59.999Z`, + timeZone: Timezones["+6:00"], + isTeamEvent: false, + orgSlug: null, + }, + }); + + const availableSlotsInTz: dayjs.Dayjs[] = []; + for (const date in availabilityEventTypeOne.slots) { + availabilityEventTypeOne.slots[date].forEach((timeObj) => { + availableSlotsInTz.push(dayjs(timeObj.time).tz(Timezones["+6:00"])); + }); + } + + expect(availableSlotsInTz.filter((slot) => slot.format().startsWith(plus2DateString)).length).toBe(0); // 1 booking per day as limit + + const availabilityEventTypeTwo = await getSchedule({ + input: { + eventTypeId: 2, + eventTypeSlug: "", + startTime: `${plus1DateString}T00:00:00.000Z`, + endTime: `${plus3DateString}T23:59:59.999Z`, + timeZone: Timezones["+6:00"], + isTeamEvent: false, + orgSlug: null, + }, + }); + + for (const date in availabilityEventTypeTwo.slots) { + availabilityEventTypeTwo.slots[date].forEach((timeObj) => { + availableSlotsInTz.push(dayjs(timeObj.time).tz(Timezones["+6:00"])); + }); + } + + expect(availableSlotsInTz.filter((slot) => slot.format().startsWith(plus2DateString)).length).toBe(0); // 1 booking per day as limit + + const availabilityUserEventType = await getSchedule({ + input: { + eventTypeId: 3, + eventTypeSlug: "", + startTime: `${plus1DateString}T00:00:00.000Z`, + endTime: `${plus3DateString}T23:59:59.999Z`, + timeZone: Timezones["+6:00"], + isTeamEvent: false, + orgSlug: null, + }, + }); + + for (const date in availabilityUserEventType.slots) { + availabilityUserEventType.slots[date].forEach((timeObj) => { + availableSlotsInTz.push(dayjs(timeObj.time).tz(Timezones["+6:00"])); + }); + } + + expect(availableSlotsInTz.filter((slot) => slot.format().startsWith(plus2DateString)).length).toBe(23); + }); + + test("global team booking limit blocks correct slots if attendee and host are in different timezone", async () => { + const { dateString: plus1DateString } = getDate({ dateIncrement: 1 }); + const { dateString: plus2DateString } = getDate({ dateIncrement: 2 }); + const { dateString: plus3DateString } = getDate({ dateIncrement: 3 }); + + const scenarioData = { + eventTypes: [ + { + id: 1, + length: 60, + beforeEventBuffer: 0, + afterEventBuffer: 0, + team: { + id: 1, + bookingLimits: { PER_DAY: 1 }, + }, + schedulingType: SchedulingType.COLLECTIVE, + users: [ + { + id: 101, + }, + { + id: 102, + }, + ], + }, + ], + users: [ + { + ...TestData.users.example, + id: 101, + schedules: [ + { + id: 1, + name: "All Day available", + availability: [ + { + userId: null, + eventTypeId: null, + days: [0, 1, 2, 3, 4, 5, 6], + startTime: new Date("1970-01-01T00:00:00.000Z"), + endTime: new Date("1970-01-01T23:59:59.999Z"), + date: null, + }, + ], + timeZone: Timezones["+6:00"], + }, + ], + }, + ], + bookings: [ + { + userId: 101, + eventTypeId: 1, + startTime: `${plus2DateString}T08:00:00.000Z`, + endTime: `${plus2DateString}T09:00:00.000Z`, + status: "ACCEPTED" as BookingStatus, + }, + ], + }; + + await createBookingScenario(scenarioData); + + const thisUserAvailabilityBookingLimit = await getSchedule({ + input: { + eventTypeId: 1, + eventTypeSlug: "", + startTime: `${plus1DateString}T00:00:00.000Z`, + endTime: `${plus3DateString}T23:59:59.999Z`, + timeZone: Timezones["-11:00"], // attendee timezone + isTeamEvent: false, + orgSlug: null, + }, + }); + + const availableSlotsInTz: dayjs.Dayjs[] = []; + for (const date in thisUserAvailabilityBookingLimit.slots) { + thisUserAvailabilityBookingLimit.slots[date].forEach((timeObj) => { + availableSlotsInTz.push(dayjs(timeObj.time).tz(Timezones["+6:00"])); + }); + } + + expect(availableSlotsInTz.filter((slot) => slot.format().startsWith(plus2DateString)).length).toBe(0); // 1 booking per day as limit + }); + test("a slot counts as being busy when the eventType is requiresConfirmation and requiresConfirmationWillBlockSlot", async () => { const { dateString: plus1DateString } = getDate({ dateIncrement: 1 }); const { dateString: plus2DateString } = getDate({ dateIncrement: 2 }); diff --git a/apps/web/test/utils/bookingScenario/bookingScenario.ts b/apps/web/test/utils/bookingScenario/bookingScenario.ts index aa35ce7b4ada96..6df40668ad5d8f 100644 --- a/apps/web/test/utils/bookingScenario/bookingScenario.ts +++ b/apps/web/test/utils/bookingScenario/bookingScenario.ts @@ -159,6 +159,7 @@ export type InputEventType = { team?: { id?: number | null; parentId?: number | null; + bookingLimits?: IntervalLimit; }; requiresConfirmation?: boolean; destinationCalendar?: Prisma.DestinationCalendarCreateInput; @@ -245,6 +246,7 @@ export async function addEventTypesToDb( // eslint-disable-next-line @typescript-eslint/no-explicit-any schedule?: any; metadata?: any; + team?: { id?: number | null; bookingLimits?: IntervalLimit }; })[] ) { log.silly("TestData: Add EventTypes to DB", JSON.stringify(eventTypes)); @@ -284,6 +286,21 @@ export async function addEventTypesToDb( }, }); } + + if (eventType.team?.id) { + const createdTeam = await prismock.team.create({ + data: { + id: eventType.team?.id, + bookingLimits: eventType.team?.bookingLimits, + name: "", + }, + }); + + await prismock.eventType.update({ + where: { id: eventType.id }, + data: { teamId: createdTeam.id }, + }); + } } /*** * HACK ENDS @@ -1265,8 +1282,9 @@ export function getScenarioData( ...eventType, teamId: eventType.teamId || null, team: { - id: eventType.teamId, + id: eventType.teamId ?? eventType.team?.id, parentId: org ? org.id : null, + bookingLimits: eventType?.team?.bookingLimits, }, title: `Test Event Type - ${index + 1}`, description: `It's a test event type - ${index + 1}`, diff --git a/packages/core/bookingLimits/getBusyTimesFromLimits.ts b/packages/core/bookingLimits/getBusyTimesFromLimits.ts new file mode 100644 index 00000000000000..91b2f8e16a11c2 --- /dev/null +++ b/packages/core/bookingLimits/getBusyTimesFromLimits.ts @@ -0,0 +1,272 @@ +import type { Dayjs } from "@calcom/dayjs"; +import dayjs from "@calcom/dayjs"; +import { descendingLimitKeys, intervalLimitKeyToUnit } from "@calcom/lib/intervalLimit"; +import { checkBookingLimit } from "@calcom/lib/server"; +import { performance } from "@calcom/lib/server/perfObserver"; +import { getTotalBookingDuration } from "@calcom/lib/server/queries"; +import { BookingRepository } from "@calcom/lib/server/repository/booking"; +import type { EventBusyDetails, IntervalLimit } from "@calcom/types/Calendar"; + +import { getStartEndDateforLimitCheck } from "../getBusyTimes"; +import type { EventType } from "../getUserAvailability"; +import { getPeriodStartDatesBetween } from "../getUserAvailability"; +import monitorCallbackAsync from "../sentryWrapper"; +import LimitManager from "./limitManager"; + +export const getBusyTimesFromLimits = async ( + ...args: Parameters +): Promise> => { + return monitorCallbackAsync(_getBusyTimesFromLimits, ...args); +}; + +const _getBusyTimesFromLimits = async ( + bookingLimits: IntervalLimit | null, + durationLimits: IntervalLimit | null, + dateFrom: Dayjs, + dateTo: Dayjs, + duration: number | undefined, + eventType: NonNullable, + bookings: EventBusyDetails[], + rescheduleUid?: string +) => { + performance.mark("limitsStart"); + + // shared amongst limiters to prevent processing known busy periods + const limitManager = new LimitManager(); + + // run this first, as counting bookings should always run faster.. + if (bookingLimits) { + performance.mark("bookingLimitsStart"); + await getBusyTimesFromBookingLimits({ + bookings, + bookingLimits, + dateFrom, + dateTo, + eventTypeId: eventType.id, + limitManager, + rescheduleUid, + }); + performance.mark("bookingLimitsEnd"); + performance.measure(`checking booking limits took $1'`, "bookingLimitsStart", "bookingLimitsEnd"); + } + + // ..than adding up durations (especially for the whole year) + if (durationLimits) { + performance.mark("durationLimitsStart"); + await getBusyTimesFromDurationLimits( + bookings, + durationLimits, + dateFrom, + dateTo, + duration, + eventType, + limitManager, + rescheduleUid + ); + performance.mark("durationLimitsEnd"); + performance.measure(`checking duration limits took $1'`, "durationLimitsStart", "durationLimitsEnd"); + } + + performance.mark("limitsEnd"); + performance.measure(`checking all limits took $1'`, "limitsStart", "limitsEnd"); + + return limitManager.getBusyTimes(); +}; + +const getBusyTimesFromBookingLimits = async ( + ...args: Parameters +): Promise> => { + return monitorCallbackAsync(_getBusyTimesFromBookingLimits, ...args); +}; + +const _getBusyTimesFromBookingLimits = async (params: { + bookings: EventBusyDetails[]; + bookingLimits: IntervalLimit; + dateFrom: Dayjs; + dateTo: Dayjs; + limitManager: LimitManager; + rescheduleUid?: string; + eventTypeId?: number; + teamId?: number; + user?: { id: number; email: string }; +}) => { + const { + bookings, + bookingLimits, + dateFrom, + dateTo, + limitManager, + eventTypeId, + teamId, + user, + rescheduleUid, + } = params; + + for (const key of descendingLimitKeys) { + const limit = bookingLimits?.[key]; + if (!limit) continue; + + const unit = intervalLimitKeyToUnit(key); + const periodStartDates = getPeriodStartDatesBetween(dateFrom, dateTo, unit); + + for (const periodStart of periodStartDates) { + if (limitManager.isAlreadyBusy(periodStart, unit)) continue; + + // special handling of yearly limits to improve performance + if (unit === "year") { + try { + await checkBookingLimit({ + eventStartDate: periodStart.toDate(), + limitingNumber: limit, + eventId: eventTypeId, + key, + teamId, + user, + rescheduleUid, + }); + } catch (_) { + limitManager.addBusyTime(periodStart, unit); + if (periodStartDates.every((start) => limitManager.isAlreadyBusy(start, unit))) { + return; + } + } + continue; + } + + const periodEnd = periodStart.endOf(unit); + let totalBookings = 0; + + for (const booking of bookings) { + // consider booking part of period independent of end date + if (!dayjs(booking.start).isBetween(periodStart, periodEnd)) { + continue; + } + totalBookings++; + if (totalBookings >= limit) { + limitManager.addBusyTime(periodStart, unit); + break; + } + } + } + } +}; + +const getBusyTimesFromDurationLimits = async ( + ...args: Parameters +): Promise> => { + return monitorCallbackAsync(_getBusyTimesFromDurationLimits, ...args); +}; + +const _getBusyTimesFromDurationLimits = async ( + bookings: EventBusyDetails[], + durationLimits: IntervalLimit, + dateFrom: Dayjs, + dateTo: Dayjs, + duration: number | undefined, + eventType: NonNullable, + limitManager: LimitManager, + rescheduleUid?: string +) => { + for (const key of descendingLimitKeys) { + const limit = durationLimits?.[key]; + if (!limit) continue; + + const unit = intervalLimitKeyToUnit(key); + const periodStartDates = getPeriodStartDatesBetween(dateFrom, dateTo, unit); + + for (const periodStart of periodStartDates) { + if (limitManager.isAlreadyBusy(periodStart, unit)) continue; + + const selectedDuration = (duration || eventType.length) ?? 0; + + if (selectedDuration > limit) { + limitManager.addBusyTime(periodStart, unit); + continue; + } + + // special handling of yearly limits to improve performance + if (unit === "year") { + const totalYearlyDuration = await getTotalBookingDuration({ + eventId: eventType.id, + startDate: periodStart.toDate(), + endDate: periodStart.endOf(unit).toDate(), + rescheduleUid, + }); + if (totalYearlyDuration + selectedDuration > limit) { + limitManager.addBusyTime(periodStart, unit); + if (periodStartDates.every((start) => limitManager.isAlreadyBusy(start, unit))) { + return; + } + } + continue; + } + + const periodEnd = periodStart.endOf(unit); + let totalDuration = selectedDuration; + + for (const booking of bookings) { + // consider booking part of period independent of end date + if (!dayjs(booking.start).isBetween(periodStart, periodEnd)) { + continue; + } + totalDuration += dayjs(booking.end).diff(dayjs(booking.start), "minute"); + if (totalDuration > limit) { + limitManager.addBusyTime(periodStart, unit); + break; + } + } + } + } +}; + +export const getBusyTimesFromTeamLimits = async ( + ...args: Parameters +): Promise> => { + return monitorCallbackAsync(_getBusyTimesFromTeamLimits, ...args); +}; + +const _getBusyTimesFromTeamLimits = async ( + user: { id: number; email: string }, + bookingLimits: IntervalLimit, + dateFrom: Dayjs, + dateTo: Dayjs, + teamId: number, + rescheduleUid?: string +) => { + const { limitDateFrom, limitDateTo } = getStartEndDateforLimitCheck( + dateFrom.toISOString(), + dateTo.toISOString(), + bookingLimits + ); + + const bookings = await BookingRepository.getAllAcceptedTeamBookingsOfUser({ + user, + teamId, + startDate: limitDateFrom.toDate(), + endDate: limitDateTo.toDate(), + excludedUid: rescheduleUid, + }); + + const busyTimes = bookings.map(({ id, startTime, endTime, eventTypeId, title, userId }) => ({ + start: dayjs(startTime).toDate(), + end: dayjs(endTime).toDate(), + title, + source: `eventType-${eventTypeId}-booking-${id}`, + userId, + })); + + const limitManager = new LimitManager(); + + await getBusyTimesFromBookingLimits({ + bookings: busyTimes, + bookingLimits, + dateFrom, + dateTo, + limitManager, + rescheduleUid, + teamId, + user, + }); + + return limitManager.getBusyTimes(); +}; diff --git a/packages/core/bookingLimits/limitManager.ts b/packages/core/bookingLimits/limitManager.ts new file mode 100644 index 00000000000000..9f3840c5805a3b --- /dev/null +++ b/packages/core/bookingLimits/limitManager.ts @@ -0,0 +1,63 @@ +import type { Dayjs } from "@calcom/dayjs"; +import type { EventBusyDate, IntervalLimitUnit } from "@calcom/types/Calendar"; + +type BusyMapKey = `${IntervalLimitUnit}-${ReturnType}`; + +/** + * Helps create, check, and return busy times from limits (with parallel support) + */ +export default class LimitManager { + private busyMap: Map = new Map(); + + /** + * Creates a busy map key + */ + private static createKey(start: Dayjs, unit: IntervalLimitUnit): BusyMapKey { + return `${unit}-${start.startOf(unit).toISOString()}`; + } + + /** + * Checks if already marked busy by ancestors or siblings + */ + isAlreadyBusy(start: Dayjs, unit: IntervalLimitUnit) { + if (this.busyMap.has(LimitManager.createKey(start, "year"))) return true; + + if (unit === "month" && this.busyMap.has(LimitManager.createKey(start, "month"))) { + return true; + } else if ( + unit === "week" && + // weeks can be part of two months + ((this.busyMap.has(LimitManager.createKey(start, "month")) && + this.busyMap.has(LimitManager.createKey(start.endOf("week"), "month"))) || + this.busyMap.has(LimitManager.createKey(start, "week"))) + ) { + return true; + } else if ( + unit === "day" && + (this.busyMap.has(LimitManager.createKey(start, "month")) || + this.busyMap.has(LimitManager.createKey(start, "week")) || + this.busyMap.has(LimitManager.createKey(start, "day"))) + ) { + return true; + } else { + return false; + } + } + + /** + * Adds a new busy time + */ + addBusyTime(start: Dayjs, unit: IntervalLimitUnit) { + this.busyMap.set(`${unit}-${start.toISOString()}`, { + start: start.toISOString(), + end: start.endOf(unit).toISOString(), + }); + } + + /** + * Returns all busy times + */ + getBusyTimes() { + return Array.from(this.busyMap.values()); + } +} diff --git a/packages/core/getBusyTimes.ts b/packages/core/getBusyTimes.ts index a4f479a76694e2..7b3db48ad26c4b 100644 --- a/packages/core/getBusyTimes.ts +++ b/packages/core/getBusyTimes.ts @@ -301,6 +301,31 @@ export async function getBusyTimes(params: { return busyTimes; } +export function getStartEndDateforLimitCheck( + startDate: string, + endDate: string, + bookingLimits?: IntervalLimit | null, + durationLimits?: IntervalLimit | null +) { + const startTimeAsDayJs = stringToDayjs(startDate); + const endTimeAsDayJs = stringToDayjs(endDate); + + let limitDateFrom = stringToDayjs(startDate); + let limitDateTo = stringToDayjs(endDate); + + // expand date ranges by absolute minimum required to apply limits + // (yearly limits are handled separately for performance) + for (const key of ["PER_MONTH", "PER_WEEK", "PER_DAY"] as Exclude[]) { + if (bookingLimits?.[key] || durationLimits?.[key]) { + const unit = intervalLimitKeyToUnit(key); + limitDateFrom = dayjs.min(limitDateFrom, startTimeAsDayJs.startOf(unit)); + limitDateTo = dayjs.max(limitDateTo, endTimeAsDayJs.endOf(unit)); + } + } + + return { limitDateFrom, limitDateTo }; +} + export async function getBusyTimesForLimitChecks(params: { userIds: number[]; eventTypeId: number; @@ -311,8 +336,6 @@ export async function getBusyTimesForLimitChecks(params: { durationLimits?: IntervalLimit | null; }) { const { userIds, eventTypeId, startDate, endDate, rescheduleUid, bookingLimits, durationLimits } = params; - const startTimeAsDayJs = stringToDayjs(startDate); - const endTimeAsDayJs = stringToDayjs(endDate); performance.mark("getBusyTimesForLimitChecksStart"); @@ -322,18 +345,13 @@ export async function getBusyTimesForLimitChecks(params: { return busyTimes; } - let limitDateFrom = stringToDayjs(startDate); - let limitDateTo = stringToDayjs(endDate); + const { limitDateFrom, limitDateTo } = getStartEndDateforLimitCheck( + startDate, + endDate, + bookingLimits, + durationLimits + ); - // expand date ranges by absolute minimum required to apply limits - // (yearly limits are handled separately for performance) - for (const key of ["PER_MONTH", "PER_WEEK", "PER_DAY"] as Exclude[]) { - if (bookingLimits?.[key] || durationLimits?.[key]) { - const unit = intervalLimitKeyToUnit(key); - limitDateFrom = dayjs.min(limitDateFrom, startTimeAsDayJs.startOf(unit)); - limitDateTo = dayjs.max(limitDateTo, endTimeAsDayJs.endOf(unit)); - } - } logger.silly( `Fetch limit checks bookings in range ${limitDateFrom} to ${limitDateTo} for input ${JSON.stringify({ eventTypeId, diff --git a/packages/core/getUserAvailability.ts b/packages/core/getUserAvailability.ts index 5c26f139b491f9..a703f732e68cdb 100644 --- a/packages/core/getUserAvailability.ts +++ b/packages/core/getUserAvailability.ts @@ -9,25 +9,18 @@ import type { DateOverride, WorkingHours } from "@calcom/lib/date-ranges"; import { buildDateRanges, subtract } from "@calcom/lib/date-ranges"; import { ErrorCode } from "@calcom/lib/errorCodes"; import { HttpError } from "@calcom/lib/http-error"; -import { descendingLimitKeys, intervalLimitKeyToUnit } from "@calcom/lib/intervalLimit"; import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; -import { checkBookingLimit } from "@calcom/lib/server"; import { performance } from "@calcom/lib/server/perfObserver"; -import { getTotalBookingDuration } from "@calcom/lib/server/queries"; import prisma, { availabilityUserSelect } from "@calcom/prisma"; import { SchedulingType } from "@calcom/prisma/enums"; import { BookingStatus } from "@calcom/prisma/enums"; import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential"; import { EventTypeMetaDataSchema, stringToDayjsZod } from "@calcom/prisma/zod-utils"; -import type { - EventBusyDate, - EventBusyDetails, - IntervalLimit, - IntervalLimitUnit, -} from "@calcom/types/Calendar"; +import type { EventBusyDetails, IntervalLimitUnit } from "@calcom/types/Calendar"; import type { TimeRange } from "@calcom/types/schedule"; +import { getBusyTimesFromLimits, getBusyTimesFromTeamLimits } from "./bookingLimits/getBusyTimesFromLimits"; import { getBusyTimes } from "./getBusyTimes"; import monitorCallbackAsync, { monitorCallbackSync } from "./sentryWrapper"; @@ -60,6 +53,12 @@ const _getEventType = async (id: number) => { id: true, seatsPerTimeSlot: true, bookingLimits: true, + team: { + select: { + id: true, + bookingLimits: true, + }, + }, hosts: { select: { user: { @@ -108,7 +107,7 @@ const _getEventType = async (id: number) => { }; }; -type EventType = Awaited>; +export type EventType = Awaited>; const getUser = async (...args: Parameters): Promise> => { return monitorCallbackAsync(_getUser, ...args); @@ -286,7 +285,22 @@ const _getUserAvailability = async function getUsersWorkingHoursLifeTheUniverseA dateTo.tz(timeZone), duration, eventType, - initialData?.busyTimesFromLimitsBookings ?? [] + initialData?.busyTimesFromLimitsBookings ?? [], + initialData?.rescheduleUid ?? undefined + ) + : []; + + const teamBookingLimits = parseBookingLimit(eventType?.team?.bookingLimits); + + const busyTimesFromTeamLimits = + eventType?.team && teamBookingLimits + ? await getBusyTimesFromTeamLimits( + user, + teamBookingLimits, + dateFrom.tz(timeZone), + dateTo.tz(timeZone), + eventType?.team.id, + initialData?.rescheduleUid ?? undefined ) : []; @@ -320,6 +334,7 @@ const _getUserAvailability = async function getUsersWorkingHoursLifeTheUniverseA source: query.withSource ? a.source : undefined, })), ...busyTimesFromLimits, + ...busyTimesFromTeamLimits, ]; const isDefaultSchedule = userSchedule && userSchedule.id === schedule.id; @@ -433,7 +448,7 @@ const _getUserAvailability = async function getUsersWorkingHoursLifeTheUniverseA }; }; -const getPeriodStartDatesBetween = ( +export const getPeriodStartDatesBetween = ( ...args: Parameters ): ReturnType => { return monitorCallbackSync(_getPeriodStartDatesBetween, ...args); @@ -450,250 +465,6 @@ const _getPeriodStartDatesBetween = (dateFrom: Dayjs, dateTo: Dayjs, period: Int return dates; }; -type BusyMapKey = `${IntervalLimitUnit}-${ReturnType}`; - -/** - * Helps create, check, and return busy times from limits (with parallel support) - */ -class LimitManager { - private busyMap: Map = new Map(); - - /** - * Creates a busy map key - */ - private static createKey(start: Dayjs, unit: IntervalLimitUnit): BusyMapKey { - return `${unit}-${start.startOf(unit).toISOString()}`; - } - - /** - * Checks if already marked busy by ancestors or siblings - */ - isAlreadyBusy(start: Dayjs, unit: IntervalLimitUnit) { - if (this.busyMap.has(LimitManager.createKey(start, "year"))) return true; - - if (unit === "month" && this.busyMap.has(LimitManager.createKey(start, "month"))) { - return true; - } else if ( - unit === "week" && - // weeks can be part of two months - ((this.busyMap.has(LimitManager.createKey(start, "month")) && - this.busyMap.has(LimitManager.createKey(start.endOf("week"), "month"))) || - this.busyMap.has(LimitManager.createKey(start, "week"))) - ) { - return true; - } else if ( - unit === "day" && - (this.busyMap.has(LimitManager.createKey(start, "month")) || - this.busyMap.has(LimitManager.createKey(start, "week")) || - this.busyMap.has(LimitManager.createKey(start, "day"))) - ) { - return true; - } else { - return false; - } - } - - /** - * Adds a new busy time - */ - addBusyTime(start: Dayjs, unit: IntervalLimitUnit) { - this.busyMap.set(`${unit}-${start.toISOString()}`, { - start: start.toISOString(), - end: start.endOf(unit).toISOString(), - }); - } - - /** - * Returns all busy times - */ - getBusyTimes() { - return Array.from(this.busyMap.values()); - } -} - -const getBusyTimesFromLimits = async ( - ...args: Parameters -): Promise> => { - return monitorCallbackAsync(_getBusyTimesFromLimits, ...args); -}; - -const _getBusyTimesFromLimits = async ( - bookingLimits: IntervalLimit | null, - durationLimits: IntervalLimit | null, - dateFrom: Dayjs, - dateTo: Dayjs, - duration: number | undefined, - eventType: NonNullable, - bookings: EventBusyDetails[] -) => { - performance.mark("limitsStart"); - - // shared amongst limiters to prevent processing known busy periods - const limitManager = new LimitManager(); - - // run this first, as counting bookings should always run faster.. - if (bookingLimits) { - performance.mark("bookingLimitsStart"); - await getBusyTimesFromBookingLimits( - bookings, - bookingLimits, - dateFrom, - dateTo, - eventType.id, - limitManager - ); - performance.mark("bookingLimitsEnd"); - performance.measure(`checking booking limits took $1'`, "bookingLimitsStart", "bookingLimitsEnd"); - } - - // ..than adding up durations (especially for the whole year) - if (durationLimits) { - performance.mark("durationLimitsStart"); - await getBusyTimesFromDurationLimits( - bookings, - durationLimits, - dateFrom, - dateTo, - duration, - eventType, - limitManager - ); - performance.mark("durationLimitsEnd"); - performance.measure(`checking duration limits took $1'`, "durationLimitsStart", "durationLimitsEnd"); - } - - performance.mark("limitsEnd"); - performance.measure(`checking all limits took $1'`, "limitsStart", "limitsEnd"); - - return limitManager.getBusyTimes(); -}; - -const getBusyTimesFromBookingLimits = async ( - ...args: Parameters -): Promise> => { - return monitorCallbackAsync(_getBusyTimesFromBookingLimits, ...args); -}; - -const _getBusyTimesFromBookingLimits = async ( - bookings: EventBusyDetails[], - bookingLimits: IntervalLimit, - dateFrom: Dayjs, - dateTo: Dayjs, - eventTypeId: number, - limitManager: LimitManager -) => { - for (const key of descendingLimitKeys) { - const limit = bookingLimits?.[key]; - if (!limit) continue; - - const unit = intervalLimitKeyToUnit(key); - const periodStartDates = getPeriodStartDatesBetween(dateFrom, dateTo, unit); - - for (const periodStart of periodStartDates) { - if (limitManager.isAlreadyBusy(periodStart, unit)) continue; - - // special handling of yearly limits to improve performance - if (unit === "year") { - try { - await checkBookingLimit({ - eventStartDate: periodStart.toDate(), - limitingNumber: limit, - eventId: eventTypeId, - key, - }); - } catch (_) { - limitManager.addBusyTime(periodStart, unit); - if (periodStartDates.every((start) => limitManager.isAlreadyBusy(start, unit))) { - return; - } - } - continue; - } - - const periodEnd = periodStart.endOf(unit); - let totalBookings = 0; - - for (const booking of bookings) { - // consider booking part of period independent of end date - if (!dayjs(booking.start).isBetween(periodStart, periodEnd)) { - continue; - } - totalBookings++; - if (totalBookings >= limit) { - limitManager.addBusyTime(periodStart, unit); - break; - } - } - } - } -}; - -const getBusyTimesFromDurationLimits = async ( - ...args: Parameters -): Promise> => { - return monitorCallbackAsync(_getBusyTimesFromDurationLimits, ...args); -}; - -const _getBusyTimesFromDurationLimits = async ( - bookings: EventBusyDetails[], - durationLimits: IntervalLimit, - dateFrom: Dayjs, - dateTo: Dayjs, - duration: number | undefined, - eventType: NonNullable, - limitManager: LimitManager -) => { - for (const key of descendingLimitKeys) { - const limit = durationLimits?.[key]; - if (!limit) continue; - - const unit = intervalLimitKeyToUnit(key); - const periodStartDates = getPeriodStartDatesBetween(dateFrom, dateTo, unit); - - for (const periodStart of periodStartDates) { - if (limitManager.isAlreadyBusy(periodStart, unit)) continue; - - const selectedDuration = (duration || eventType.length) ?? 0; - - if (selectedDuration > limit) { - limitManager.addBusyTime(periodStart, unit); - continue; - } - - // special handling of yearly limits to improve performance - if (unit === "year") { - const totalYearlyDuration = await getTotalBookingDuration({ - eventId: eventType.id, - startDate: periodStart.toDate(), - endDate: periodStart.endOf(unit).toDate(), - }); - if (totalYearlyDuration + selectedDuration > limit) { - limitManager.addBusyTime(periodStart, unit); - if (periodStartDates.every((start) => limitManager.isAlreadyBusy(start, unit))) { - return; - } - } - continue; - } - - const periodEnd = periodStart.endOf(unit); - let totalDuration = selectedDuration; - - for (const booking of bookings) { - // consider booking part of period independent of end date - if (!dayjs(booking.start).isBetween(periodStart, periodEnd)) { - continue; - } - totalDuration += dayjs(booking.end).diff(dayjs(booking.start), "minute"); - if (totalDuration > limit) { - limitManager.addBusyTime(periodStart, unit); - break; - } - } - } - } -}; - interface GetUserAvailabilityParamsDTO { userId: number; dateFrom: Dayjs; diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index 9b70f4c093ce0d..6f4be8fc2519ab 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -442,7 +442,12 @@ async function handler( ); } if (eventType.durationLimits) { - await checkDurationLimits(eventType.durationLimits as IntervalLimit, startAsDate, eventType.id); + await checkDurationLimits( + eventType.durationLimits as IntervalLimit, + startAsDate, + eventType.id, + rescheduleUid + ); } } diff --git a/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts b/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts index 80f5dd52f54e66..3204e82c7bfc10 100644 --- a/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts +++ b/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts @@ -35,6 +35,7 @@ export const getEventTypesFromDB = async (eventTypeId: number) => { id: true, name: true, parentId: true, + bookingLimits: true, }, }, bookingFields: true, diff --git a/packages/features/bookings/lib/handleNewBooking/global-booking-limits.test.ts b/packages/features/bookings/lib/handleNewBooking/global-booking-limits.test.ts new file mode 100644 index 00000000000000..44cc34066329fa --- /dev/null +++ b/packages/features/bookings/lib/handleNewBooking/global-booking-limits.test.ts @@ -0,0 +1,482 @@ +import { + TestData, + createBookingScenario, + getBooker, + getOrganizer, + getScenarioData, + getGoogleCalendarCredential, + Timezones, +} from "@calcom/web/test/utils/bookingScenario/bookingScenario"; +import { createMockNextJsRequest } from "@calcom/web/test/utils/bookingScenario/createMockNextJsRequest"; +import { getMockRequestDataForBooking } from "@calcom/web/test/utils/bookingScenario/getMockRequestDataForBooking"; +import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAndTeardown"; + +import { describe, expect, vi, beforeAll } from "vitest"; + +import { SchedulingType } from "@calcom/prisma/enums"; +import { BookingStatus } from "@calcom/prisma/enums"; +import { test } from "@calcom/web/test/fixtures/fixtures"; + +// Local test runs sometime gets too slow +const timeout = process.env.CI ? 5000 : 20000; + +const eventLength = 30; + +const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + +const booker = getBooker({ + email: "booker@example.com", + name: "Booker", +}); + +const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], +}); + +const otherTeamMembers = [ + { + name: "Other Team Member 1", + username: "other-team-member-1", + timeZone: Timezones["+5:30"], + // So, that it picks the first schedule from the list + defaultScheduleId: null, + email: "other-team-member-1@example.com", + id: 102, + // Has Evening shift + schedules: [TestData.schedules.IstEveningShift], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "other-team-member-1@google-calendar.com", + }, + }, +]; + +describe( + "handleNewBooking", + () => { + setupAndTeardown(); + beforeAll(async () => { + vi.setSystemTime(new Date("2024-08-05")); //Monday + }); + + describe("Team Booking Limits", () => { + // This test fails on CI as handleNewBooking throws no_available_users_found_error error + // eslint-disable-next-line playwright/no-skipped-test + test(`Booking limits per week + `, async ({}) => { + await createBookingScenario( + getScenarioData({ + eventTypes: [ + { + id: 1, + slotInterval: eventLength, + length: eventLength, + hosts: [ + { + userId: 101, + isFixed: true, + }, + ], + team: { + id: 1, + bookingLimits: { PER_WEEK: 2 }, + }, + schedulingType: SchedulingType.COLLECTIVE, + }, + { + id: 2, + slotInterval: eventLength, + length: eventLength, + hosts: [ + { + userId: 101, + isFixed: true, + }, + ], + teamId: 1, + schedulingType: SchedulingType.COLLECTIVE, + }, + ], + bookings: [ + { + eventTypeId: 2, + userId: 101, + status: BookingStatus.ACCEPTED, + startTime: `2024-08-06T03:30:00.000Z`, + endTime: `2024-08-06T04:00:00.000Z`, + }, + { + eventTypeId: 1, + userId: 102, + status: BookingStatus.ACCEPTED, + startTime: `2024-08-06T04:00:00.000Z`, + endTime: `2024-08-06T04:30:00.000Z`, + }, + { + eventTypeId: 1, + userId: 102, + status: BookingStatus.ACCEPTED, + startTime: `2024-08-06T04:30:00.000Z`, + endTime: `2024-08-06T05:00:00.000Z`, + }, + ], + organizer, + usersApartFromOrganizer: otherTeamMembers, + }) + ); + + const mockBookingData1 = getMockRequestDataForBooking({ + data: { + start: `2024-08-08T04:00:00.000Z`, + end: `2024-08-08T04:30:00.000Z`, + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: "New York" }, + }, + }, + }); + + const { req: req1 } = createMockNextJsRequest({ + method: "POST", + body: mockBookingData1, + }); + + const createdBooking = await handleNewBooking(req1); + + expect(createdBooking.responses).toEqual( + expect.objectContaining({ + email: booker.email, + name: booker.name, + }) + ); + + const mockBookingData2 = getMockRequestDataForBooking({ + data: { + start: `2024-08-08T04:00:00.000Z`, + end: `2024-08-08T04:30:00.000Z`, + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: "New York" }, + }, + }, + }); + + const { req: req2 } = createMockNextJsRequest({ + method: "POST", + body: mockBookingData2, + }); + + // this is the third team booking of this week for user 101, limit reached + await expect(async () => await handleNewBooking(req2)).rejects.toThrowError( + "no_available_users_found_error" + ); + }); + + test(`Booking limits per day`, async ({}) => { + await createBookingScenario( + getScenarioData({ + eventTypes: [ + { + id: 1, + slotInterval: eventLength, + length: eventLength, + hosts: [ + { + userId: 101, + isFixed: true, + }, + ], + team: { + id: 1, + bookingLimits: { PER_DAY: 1 }, + }, + schedulingType: SchedulingType.COLLECTIVE, + }, + { + id: 2, + slotInterval: eventLength, + length: eventLength, + hosts: [ + { + userId: 101, + isFixed: true, + }, + ], + teamId: 1, + schedulingType: SchedulingType.COLLECTIVE, + }, + ], + bookings: [], + organizer, + usersApartFromOrganizer: otherTeamMembers, + }) + ); + + const mockBookingData1 = getMockRequestDataForBooking({ + data: { + start: `2024-08-07T04:30:00.000Z`, + end: `2024-08-07T05:00:00.000Z`, + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: "New York" }, + }, + }, + }); + + const { req: req1 } = createMockNextJsRequest({ + method: "POST", + body: mockBookingData1, + }); + + const createdBooking = await handleNewBooking(req1); + + expect(createdBooking.responses).toEqual( + expect.objectContaining({ + email: booker.email, + name: booker.name, + }) + ); + + const mockBookingData2 = getMockRequestDataForBooking({ + data: { + start: `2024-08-07T04:00:00.000Z`, + end: `2024-08-07T04:30:00.000Z`, + eventTypeId: 2, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: "New York" }, + }, + }, + }); + + const { req: req2 } = createMockNextJsRequest({ + method: "POST", + body: mockBookingData1, + }); + + // this is the second team booking of this days for user 101, limit reached + await expect(async () => await handleNewBooking(req2)).rejects.toThrowError( + "no_available_users_found_error" + ); + }); + + test(`Booking limits per month`, async ({}) => { + await createBookingScenario( + getScenarioData({ + eventTypes: [ + { + id: 1, + slotInterval: eventLength, + length: eventLength, + hosts: [ + { + userId: 101, + isFixed: true, + }, + ], + team: { + id: 1, + bookingLimits: { PER_MONTH: 3 }, + }, + schedulingType: SchedulingType.COLLECTIVE, + }, + { + id: 2, + slotInterval: eventLength, + length: eventLength, + hosts: [ + { + userId: 101, + isFixed: true, + }, + ], + teamId: 1, + schedulingType: SchedulingType.COLLECTIVE, + }, + ], + bookings: [ + { + eventTypeId: 1, + userId: 101, + status: BookingStatus.ACCEPTED, + startTime: `2024-08-03T03:30:00.000Z`, + endTime: `2024-08-03T04:00:00.000Z`, + }, + { + eventTypeId: 2, + userId: 101, + status: BookingStatus.ACCEPTED, + startTime: `2024-08-22T03:30:00.000Z`, + endTime: `2024-08-22T04:00:00.000Z`, + }, + ], + organizer, + usersApartFromOrganizer: otherTeamMembers, + }) + ); + + const mockBookingData1 = getMockRequestDataForBooking({ + data: { + start: `2024-08-29T04:30:00.000Z`, + end: `2024-08-29T05:00:00.000Z`, + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: "New York" }, + }, + }, + }); + + const { req: req1 } = createMockNextJsRequest({ + method: "POST", + body: mockBookingData1, + }); + + const createdBooking = await handleNewBooking(req1); + + expect(createdBooking.responses).toEqual( + expect.objectContaining({ + email: booker.email, + name: booker.name, + }) + ); + + const mockBookingData2 = getMockRequestDataForBooking({ + data: { + start: `2024-08-25T04:00:00.000Z`, + end: `2024-08-25T04:30:00.000Z`, + eventTypeId: 2, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: "New York" }, + }, + }, + }); + + const { req: req2 } = createMockNextJsRequest({ + method: "POST", + body: mockBookingData1, + }); + + // this is the second team booking of this days for user 101, limit reached + await expect(async () => await handleNewBooking(req2)).rejects.toThrowError( + "no_available_users_found_error" + ); + }); + + test(`Booking limits per year`, async ({}) => { + await createBookingScenario( + getScenarioData({ + eventTypes: [ + { + id: 1, + slotInterval: eventLength, + length: eventLength, + hosts: [ + { + userId: 101, + isFixed: true, + }, + ], + team: { + id: 1, + bookingLimits: { PER_YEAR: 2 }, + }, + schedulingType: SchedulingType.COLLECTIVE, + }, + { + id: 2, + slotInterval: eventLength, + length: eventLength, + hosts: [ + { + userId: 101, + isFixed: true, + }, + ], + teamId: 1, + schedulingType: SchedulingType.COLLECTIVE, + }, + ], + bookings: [ + { + eventTypeId: 1, + userId: 101, + status: BookingStatus.ACCEPTED, + startTime: `2024-02-03T03:30:00.000Z`, + endTime: `2024-08-03T04:00:00.000Z`, + }, + ], + organizer, + usersApartFromOrganizer: otherTeamMembers, + }) + ); + + const mockBookingData1 = getMockRequestDataForBooking({ + data: { + start: `2024-08-29T04:30:00.000Z`, + end: `2024-08-29T05:00:00.000Z`, + eventTypeId: 2, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: "New York" }, + }, + }, + }); + + const { req: req1 } = createMockNextJsRequest({ + method: "POST", + body: mockBookingData1, + }); + + const createdBooking = await handleNewBooking(req1); + + expect(createdBooking.responses).toEqual( + expect.objectContaining({ + email: booker.email, + name: booker.name, + }) + ); + + const mockBookingData2 = getMockRequestDataForBooking({ + data: { + start: `2024-11-25T04:00:00.000Z`, + end: `2024-11-25T04:30:00.000Z`, + eventTypeId: 2, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: "New York" }, + }, + }, + }); + + const { req: req2 } = createMockNextJsRequest({ + method: "POST", + body: mockBookingData1, + }); + + // this is the second team booking of this days for user 101, limit reached + await expect(async () => await handleNewBooking(req2)).rejects.toThrowError( + "no_available_users_found_error" + ); + }); + }); + }, + timeout +); diff --git a/packages/features/ee/teams/pages/team-booking-limits-view.tsx b/packages/features/ee/teams/pages/team-booking-limits-view.tsx new file mode 100644 index 00000000000000..5a8be9a29f831d --- /dev/null +++ b/packages/features/ee/teams/pages/team-booking-limits-view.tsx @@ -0,0 +1,157 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useEffect } from "react"; +import { useForm, Controller } from "react-hook-form"; + +import { AppearanceSkeletonLoader } from "@calcom/features/ee/components/CommonSkeletonLoaders"; +import { IntervalLimitsManager } from "@calcom/features/eventtypes/components/tabs/limits/EventLimitsTab"; +import SectionBottomActions from "@calcom/features/settings/SectionBottomActions"; +import { classNames, validateIntervalLimitOrder } from "@calcom/lib"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { useParamsWithFallback } from "@calcom/lib/hooks/useParamsWithFallback"; +import { MembershipRole } from "@calcom/prisma/enums"; +import { trpc } from "@calcom/trpc/react"; +import type { RouterOutputs } from "@calcom/trpc/react"; +import type { IntervalLimit } from "@calcom/types/Calendar"; +import { Button, Form, SettingsToggle, showToast } from "@calcom/ui"; + +type ProfileViewProps = { team: RouterOutputs["viewer"]["teams"]["getMinimal"] }; + +const BookingLimitsView = ({ team }: ProfileViewProps) => { + const { t } = useLocale(); + const utils = trpc.useUtils(); + + const form = useForm<{ bookingLimits?: IntervalLimit }>({ + defaultValues: { + bookingLimits: team?.bookingLimits || undefined, + }, + }); + + const { + formState: { isSubmitting, isDirty }, + reset, + } = form; + + const mutation = trpc.viewer.teams.update.useMutation({ + onError: (err) => { + showToast(err.message, "error"); + }, + async onSuccess(res) { + await utils.viewer.teams.get.invalidate(); + if (res) { + reset({ bookingLimits: res.bookingLimits }); + } + showToast(t("booking_limits_updated_successfully"), "success"); + }, + }); + + const isAdmin = + team && (team.membership.role === MembershipRole.OWNER || team.membership.role === MembershipRole.ADMIN); + + return ( + <> + {isAdmin ? ( + <> +
{ + if (values.bookingLimits) { + const isValid = validateIntervalLimitOrder(values.bookingLimits); + if (!isValid) { + reset(); + throw new Error(t("event_setup_booking_limits_error")); + } + } + mutation.mutate({ ...values, id: team.id }); + }}> + { + const isChecked = Object.keys(value ?? {}).length > 0; + return ( + { + if (active) { + form.setValue("bookingLimits", { + PER_DAY: 1, + }); + } else { + form.setValue("bookingLimits", {}); + } + const bookingLimits = form.getValues("bookingLimits"); + mutation.mutate({ bookingLimits, id: team.id }); + }} + switchContainerClassName={classNames( + "border-subtle mt-6 rounded-lg border py-6 px-4 sm:px-6", + isChecked && "rounded-b-none" + )} + childrenClassName="lg:ml-0"> +
+ +
+ + + +
+ ); + }} + /> + + + ) : ( +
+ {t("only_owner_change")} +
+ )} + + ); +}; + +const BookingLimitsViewWrapper = () => { + const router = useRouter(); + const params = useParamsWithFallback(); + + const { t } = useLocale(); + + const { + data: team, + isPending, + error, + } = trpc.viewer.teams.getMinimal.useQuery( + { teamId: Number(params.id) }, + { + enabled: !!Number(params.id), + } + ); + + useEffect( + function refactorMeWithoutEffect() { + if (error) { + router.replace("/teams"); + } + }, + [error] + ); + + if (isPending) + return ( + + ); + + if (!team) return null; + + return ; +}; + +export default BookingLimitsViewWrapper; diff --git a/packages/features/eventtypes/components/tabs/limits/EventLimitsTab.tsx b/packages/features/eventtypes/components/tabs/limits/EventLimitsTab.tsx index b320369bdbb7a6..ff375da02c9519 100644 --- a/packages/features/eventtypes/components/tabs/limits/EventLimitsTab.tsx +++ b/packages/features/eventtypes/components/tabs/limits/EventLimitsTab.tsx @@ -726,7 +726,7 @@ type IntervalLimitsManagerProps = disabled?: boolean; }; -const IntervalLimitsManager = ({ +export const IntervalLimitsManager = ({ propertyName, defaultLimit, step, diff --git a/packages/features/settings/layouts/SettingsLayout.tsx b/packages/features/settings/layouts/SettingsLayout.tsx index 0c2db47c13a72d..2243cb6b35e0e5 100644 --- a/packages/features/settings/layouts/SettingsLayout.tsx +++ b/packages/features/settings/layouts/SettingsLayout.tsx @@ -372,6 +372,12 @@ const TeamListCollapsible = () => { /> ) : null} + )} diff --git a/packages/lib/server/checkBookingLimits.ts b/packages/lib/server/checkBookingLimits.ts index ca67f4f7fd58e7..65dbc53bde1cd4 100644 --- a/packages/lib/server/checkBookingLimits.ts +++ b/packages/lib/server/checkBookingLimits.ts @@ -7,6 +7,7 @@ import { getErrorFromUnknown } from "../errors"; import { HttpError } from "../http-error"; import { ascendingLimitKeys, intervalLimitKeyToUnit } from "../intervalLimit"; import { parseBookingLimit } from "../isBookingLimits"; +import { BookingRepository } from "./repository/booking"; export async function checkBookingLimits( bookingLimits: IntervalLimit, @@ -44,13 +45,17 @@ export async function checkBookingLimit({ limitingNumber, rescheduleUid, timeZone, + teamId, + user, }: { eventStartDate: Date; - eventId: number; + eventId?: number; key: keyof IntervalLimit; limitingNumber: number | undefined; rescheduleUid?: string | undefined; timeZone?: string | null; + teamId?: number; + user?: { id: number; email: string }; }) { { const eventDateInOrganizerTz = timeZone ? dayjs(eventStartDate).tz(timeZone) : dayjs(eventStartDate); @@ -62,22 +67,35 @@ export async function checkBookingLimit({ const startDate = dayjs(eventDateInOrganizerTz).startOf(unit).toDate(); const endDate = dayjs(eventDateInOrganizerTz).endOf(unit).toDate(); - const bookingsInPeriod = await prisma.booking.count({ - where: { - status: BookingStatus.ACCEPTED, - eventTypeId: eventId, - // FIXME: bookings that overlap on one side will never be counted - startTime: { - gte: startDate, - }, - endTime: { - lte: endDate, - }, - uid: { - not: rescheduleUid, + let bookingsInPeriod; + + if (teamId && user) { + bookingsInPeriod = await BookingRepository.getAllAcceptedTeamBookingsOfUser({ + user: { id: user.id, email: user.email }, + teamId, + startDate: startDate, + endDate: endDate, + returnCount: true, + excludedUid: rescheduleUid, + }); + } else { + bookingsInPeriod = await prisma.booking.count({ + where: { + status: BookingStatus.ACCEPTED, + eventTypeId: eventId, + // FIXME: bookings that overlap on one side will never be counted + startTime: { + gte: startDate, + }, + endTime: { + lte: endDate, + }, + uid: { + not: rescheduleUid, + }, }, - }, - }); + }); + } if (bookingsInPeriod < limitingNumber) return; diff --git a/packages/lib/server/checkDurationLimits.ts b/packages/lib/server/checkDurationLimits.ts index 643a06e355b8ed..89949b9b2d459e 100644 --- a/packages/lib/server/checkDurationLimits.ts +++ b/packages/lib/server/checkDurationLimits.ts @@ -10,14 +10,21 @@ import { getTotalBookingDuration } from "./queries"; export async function checkDurationLimits( durationLimits: IntervalLimit, eventStartDate: Date, - eventId: number + eventId: number, + rescheduleUid?: string ) { const parsedDurationLimits = parseDurationLimit(durationLimits); if (!parsedDurationLimits) return false; // not iterating entries to preserve types const limitCalculations = ascendingLimitKeys.map((key) => - checkDurationLimit({ key, limitingNumber: parsedDurationLimits[key], eventStartDate, eventId }) + checkDurationLimit({ + key, + limitingNumber: parsedDurationLimits[key], + eventStartDate, + eventId, + rescheduleUid, + }) ); try { @@ -32,11 +39,13 @@ export async function checkDurationLimit({ eventId, key, limitingNumber, + rescheduleUid, }: { eventStartDate: Date; eventId: number; key: keyof IntervalLimit; limitingNumber: number | undefined; + rescheduleUid?: string; }) { { if (!limitingNumber) return; @@ -46,7 +55,12 @@ export async function checkDurationLimit({ const startDate = dayjs(eventStartDate).startOf(unit).toDate(); const endDate = dayjs(eventStartDate).endOf(unit).toDate(); - const totalBookingDuration = await getTotalBookingDuration({ eventId, startDate, endDate }); + const totalBookingDuration = await getTotalBookingDuration({ + eventId, + startDate, + endDate, + rescheduleUid, + }); if (totalBookingDuration < limitingNumber) return; diff --git a/packages/lib/server/queries/booking/index.ts b/packages/lib/server/queries/booking/index.ts index 990ca9d7d5a23b..9ae95954e0267d 100644 --- a/packages/lib/server/queries/booking/index.ts +++ b/packages/lib/server/queries/booking/index.ts @@ -4,14 +4,29 @@ export const getTotalBookingDuration = async ({ eventId, startDate, endDate, + rescheduleUid, }: { eventId: number; startDate: Date; endDate: Date; + rescheduleUid?: string; }) => { // Aggregates the total booking time for a given event in a given time period // FIXME: bookings that overlap on one side will never be counted - const [totalBookingTime] = await prisma.$queryRaw<[{ totalMinutes: number | null }]>` + let totalBookingTime; + + if (rescheduleUid) { + [totalBookingTime] = await prisma.$queryRaw<[{ totalMinutes: number | null }]>` + SELECT SUM(EXTRACT(EPOCH FROM ("endTime" - "startTime")) / 60) as "totalMinutes" + FROM "Booking" + WHERE "status" = 'accepted' + AND "eventTypeId" = ${eventId} + AND "startTime" >= ${startDate} + AND "endTime" <= ${endDate} + AND "uid" != ${rescheduleUid}; + `; + } else { + [totalBookingTime] = await prisma.$queryRaw<[{ totalMinutes: number | null }]>` SELECT SUM(EXTRACT(EPOCH FROM ("endTime" - "startTime")) / 60) as "totalMinutes" FROM "Booking" WHERE "status" = 'accepted' @@ -19,6 +34,6 @@ export const getTotalBookingDuration = async ({ AND "startTime" >= ${startDate} AND "endTime" <= ${endDate}; `; - + } return totalBookingTime.totalMinutes ?? 0; }; diff --git a/packages/lib/server/queries/teams/index.ts b/packages/lib/server/queries/teams/index.ts index 97663874f0330b..4d2db9dbf0fc6e 100644 --- a/packages/lib/server/queries/teams/index.ts +++ b/packages/lib/server/queries/teams/index.ts @@ -1,6 +1,7 @@ import { Prisma } from "@prisma/client"; import { getAppFromSlug } from "@calcom/app-store/utils"; +import { parseBookingLimit } from "@calcom/lib"; import prisma, { baseEventTypeSelect } from "@calcom/prisma"; import type { Team } from "@calcom/prisma/client"; import { SchedulingType } from "@calcom/prisma/enums"; @@ -297,6 +298,7 @@ export async function getMinimalTeam(args: { hideBookATeamMember: true, isPrivate: true, metadata: true, + bookingLimits: true, parent: { select: { id: true, @@ -355,6 +357,7 @@ export async function getMinimalTeam(args: { token.expires > new Date(new Date().setHours(24)) ), metadata: restTeamMetadata, + bookingLimits: parseBookingLimit(teamOrOrg.bookingLimits), }; } diff --git a/packages/lib/server/repository/booking.ts b/packages/lib/server/repository/booking.ts index a82c98d47c91b3..72c2af12235307 100644 --- a/packages/lib/server/repository/booking.ts +++ b/packages/lib/server/repository/booking.ts @@ -1,6 +1,7 @@ import type { Prisma } from "@prisma/client"; import prisma, { bookingMinimalSelect } from "@calcom/prisma"; +import type { Booking } from "@calcom/prisma/client"; import { BookingStatus } from "@calcom/prisma/enums"; import { UserRepository } from "./user"; @@ -249,4 +250,90 @@ export class BookingRepository { }, }); } + + static async getAllAcceptedTeamBookingsOfUser(params: { + user: { id: number; email: string }; + teamId: number; + startDate: Date; + endDate: Date; + excludedUid?: string | null; + returnCount: true; + }): Promise; + + static async getAllAcceptedTeamBookingsOfUser(params: { + user: { id: number; email: string }; + teamId: number; + startDate: Date; + endDate: Date; + excludedUid?: string | null; + }): Promise>; + + static async getAllAcceptedTeamBookingsOfUser(params: { + user: { id: number; email: string }; + teamId: number; + startDate: Date; + endDate: Date; + excludedUid?: string | null; + returnCount?: boolean; + }) { + const { user, teamId, startDate, endDate, returnCount, excludedUid } = params; + + const baseWhere: Prisma.BookingWhereInput = { + status: BookingStatus.ACCEPTED, + startTime: { + gte: startDate, + }, + endTime: { + lte: endDate, + }, + ...(excludedUid && { + uid: { + not: excludedUid, + }, + }), + }; + + const whereCollectiveRoundRobinOwner: Prisma.BookingWhereInput = { + ...baseWhere, + userId: user.id, + eventType: { + teamId, + }, + }; + + const whereCollectiveRoundRobinBookingsAttendee: Prisma.BookingWhereInput = { + ...baseWhere, + attendees: { + some: { + email: user.email, + }, + }, + eventType: { + teamId, + }, + }; + + if (returnCount) { + const collectiveRoundRobinBookingsOwner = await prisma.booking.count({ + where: whereCollectiveRoundRobinOwner, + }); + + const collectiveRoundRobinBookingsAttendee = await prisma.booking.count({ + where: whereCollectiveRoundRobinBookingsAttendee, + }); + + const totalNrOfBooking = collectiveRoundRobinBookingsOwner + collectiveRoundRobinBookingsAttendee; + + return totalNrOfBooking; + } + const collectiveRoundRobinBookingsOwner = await prisma.booking.findMany({ + where: whereCollectiveRoundRobinOwner, + }); + + const collectiveRoundRobinBookingsAttendee = await prisma.booking.findMany({ + where: whereCollectiveRoundRobinBookingsAttendee, + }); + + return [...collectiveRoundRobinBookingsOwner, ...collectiveRoundRobinBookingsAttendee]; + } } diff --git a/packages/prisma/migrations/20240912150824_add_booking_limits_to_team/migration.sql b/packages/prisma/migrations/20240912150824_add_booking_limits_to_team/migration.sql new file mode 100644 index 00000000000000..bf5f021b6aec31 --- /dev/null +++ b/packages/prisma/migrations/20240912150824_add_booking_limits_to_team/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Team" ADD COLUMN "bookingLimits" JSONB; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index b4ca7134efe4f4..bf8b4ae56ef65e 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -439,6 +439,9 @@ model Team { smsLockReviewedByAdmin Boolean @default(false) features TeamFeatures[] + /// @zod.custom(imports.intervalLimitsType) + bookingLimits Json? + @@unique([slug, parentId]) @@index([parentId]) } diff --git a/packages/trpc/server/routers/viewer/slots/util.ts b/packages/trpc/server/routers/viewer/slots/util.ts index 960376c25c0639..187666b21ad76d 100644 --- a/packages/trpc/server/routers/viewer/slots/util.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -178,6 +178,22 @@ export async function getEventType( rescheduleWithSameRoundRobinHost: true, periodDays: true, metadata: true, + team: { + select: { + id: true, + bookingLimits: true, + }, + }, + parent: { + select: { + team: { + select: { + id: true, + bookingLimits: true, + }, + }, + }, + }, schedule: { select: { id: true, diff --git a/packages/trpc/server/routers/viewer/teams/update.handler.ts b/packages/trpc/server/routers/viewer/teams/update.handler.ts index e6b1ec9e5db6e7..d75fb4dd5c60df 100644 --- a/packages/trpc/server/routers/viewer/teams/update.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/update.handler.ts @@ -1,6 +1,7 @@ import type { Prisma } from "@prisma/client"; import { getOrgFullOrigin } from "@calcom/ee/organizations/lib/orgDomains"; +import { validateIntervalLimitOrder } from "@calcom/lib"; import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants"; import { uploadLogo } from "@calcom/lib/server/avatar"; import { isTeamAdmin } from "@calcom/lib/server/queries/teams"; @@ -8,6 +9,7 @@ import { closeComUpdateTeam } from "@calcom/lib/sync/SyncServiceManager"; import { prisma } from "@calcom/prisma"; import { RedirectType } from "@calcom/prisma/enums"; import { teamMetadataSchema } from "@calcom/prisma/zod-utils"; +import type { IntervalLimit } from "@calcom/types/Calendar"; import { TRPCError } from "@trpc/server"; @@ -47,6 +49,12 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { if (!prevTeam) throw new TRPCError({ code: "NOT_FOUND", message: "Team not found." }); + if (input.bookingLimits) { + const isValid = validateIntervalLimitOrder(input.bookingLimits); + if (!isValid) + throw new TRPCError({ code: "BAD_REQUEST", message: "Booking limits must be in ascending order." }); + } + const data: Prisma.TeamUpdateArgs["data"] = { name: input.name, bio: input.bio, @@ -56,6 +64,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { brandColor: input.brandColor, darkBrandColor: input.darkBrandColor, theme: input.theme, + bookingLimits: input.bookingLimits ?? undefined, }; if (input.logo && input.logo.startsWith("data:image/png;base64,")) { @@ -137,6 +146,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { theme: updatedTeam.theme, brandColor: updatedTeam.brandColor, darkBrandColor: updatedTeam.darkBrandColor, + bookingLimits: updatedTeam.bookingLimits as IntervalLimit, }; }; diff --git a/packages/trpc/server/routers/viewer/teams/update.schema.ts b/packages/trpc/server/routers/viewer/teams/update.schema.ts index 6db6a0b5b05327..dafa3b252627b3 100644 --- a/packages/trpc/server/routers/viewer/teams/update.schema.ts +++ b/packages/trpc/server/routers/viewer/teams/update.schema.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { resizeBase64Image } from "@calcom/lib/server/resizeBase64Image"; import slugify from "@calcom/lib/slugify"; +import { intervalLimitsType } from "@calcom/prisma/zod-utils"; export const ZUpdateInputSchema = z.object({ id: z.number(), @@ -22,6 +23,7 @@ export const ZUpdateInputSchema = z.object({ brandColor: z.string().optional(), darkBrandColor: z.string().optional(), theme: z.string().optional().nullable(), + bookingLimits: intervalLimitsType.optional(), }); export type TUpdateInputSchema = z.infer;