diff --git a/apps/api/v1/lib/validations/team.ts b/apps/api/v1/lib/validations/team.ts index b32d82f80edb7b..9a993272520779 100644 --- a/apps/api/v1/lib/validations/team.ts +++ b/apps/api/v1/lib/validations/team.ts @@ -11,6 +11,7 @@ export const schemaTeamBaseBodyParams = Team.omit({ id: true, createdAt: true }) smsLockState: true, smsLockReviewedByAdmin: true, bookingLimits: true, + includeManagedEventsInLimits: true, }); const schemaTeamRequiredParams = z.object({ 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 168f823cedafba..081c241f4d2b42 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 @@ -76,4 +76,8 @@ export class UpdateOrgTeamDto { @IsOptional() @IsString() readonly bookingLimits?: string; + + @IsOptional() + @IsBoolean() + readonly includeManagedEventsInLimits?: boolean; } diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index a95f8f683d45d8..2653ac698d0b16 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -1525,6 +1525,7 @@ "workflow_example_4": "Send email reminder 1 hour before events starts to attendee", "workflow_example_5": "Send custom email when event is rescheduled to host", "workflow_example_6": "Send custom SMS when new event is booked to host", + "count_managed_to_limit": "Include booking counts from managed event types", "welcome_to_cal_header": "Welcome to {{appName}}!", "edit_form_later_subtitle": "You’ll be able to edit this later.", "connect_calendar_later": "I'll connect my calendar later", @@ -2622,7 +2623,7 @@ "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)", + "limit_team_booking_frequency_description": "Limit how many times members can be booked across all team event types", "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", "hide_calendar_event_details": "Hide calendar event details on shared calendars", diff --git a/apps/web/test/utils/bookingScenario/bookingScenario.ts b/apps/web/test/utils/bookingScenario/bookingScenario.ts index 6df40668ad5d8f..63625e3d99d673 100644 --- a/apps/web/test/utils/bookingScenario/bookingScenario.ts +++ b/apps/web/test/utils/bookingScenario/bookingScenario.ts @@ -153,6 +153,7 @@ export type InputEventType = { users?: { id: number }[]; hosts?: InputHost[]; schedulingType?: SchedulingType; + parent?: { id: number }; beforeEventBuffer?: number; afterEventBuffer?: number; teamId?: number | null; @@ -160,6 +161,7 @@ export type InputEventType = { id?: number | null; parentId?: number | null; bookingLimits?: IntervalLimit; + includeManagedEventsInLimits?: boolean; }; requiresConfirmation?: boolean; destinationCalendar?: Prisma.DestinationCalendarCreateInput; @@ -246,7 +248,7 @@ export async function addEventTypesToDb( // eslint-disable-next-line @typescript-eslint/no-explicit-any schedule?: any; metadata?: any; - team?: { id?: number | null; bookingLimits?: IntervalLimit }; + team?: { id?: number | null; bookingLimits?: IntervalLimit; includeManagedEventsInLimits?: boolean }; })[] ) { log.silly("TestData: Add EventTypes to DB", JSON.stringify(eventTypes)); @@ -292,6 +294,7 @@ export async function addEventTypesToDb( data: { id: eventType.team?.id, bookingLimits: eventType.team?.bookingLimits, + includeManagedEventsInLimits: eventType.team?.includeManagedEventsInLimits, name: "", }, }); @@ -323,6 +326,7 @@ export async function addEventTypes(eventTypes: InputEventType[], usersStore: In beforeEventBuffer: 0, afterEventBuffer: 0, bookingLimits: {}, + includeManagedEventsInLimits: false, schedulingType: null, length: 15, //TODO: What is the purpose of periodStartDate and periodEndDate? Test these? @@ -378,6 +382,7 @@ export async function addEventTypes(eventTypes: InputEventType[], usersStore: In : eventType.schedule, owner: eventType.owner ? { connect: { id: eventType.owner } } : undefined, schedulingType: eventType.schedulingType, + parent: eventType.parent ? { connect: { id: eventType.parent.id } } : undefined, rescheduleWithSameRoundRobinHost: eventType.rescheduleWithSameRoundRobinHost, }; }); @@ -1285,6 +1290,7 @@ export function getScenarioData( id: eventType.teamId ?? eventType.team?.id, parentId: org ? org.id : null, bookingLimits: eventType?.team?.bookingLimits, + includeManagedEventsInLimits: eventType?.team?.includeManagedEventsInLimits, }, 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 index 91b2f8e16a11c2..2053d33bbff3bc 100644 --- a/packages/core/bookingLimits/getBusyTimesFromLimits.ts +++ b/packages/core/bookingLimits/getBusyTimesFromLimits.ts @@ -27,6 +27,7 @@ const _getBusyTimesFromLimits = async ( duration: number | undefined, eventType: NonNullable, bookings: EventBusyDetails[], + timeZone: string, rescheduleUid?: string ) => { performance.mark("limitsStart"); @@ -45,6 +46,7 @@ const _getBusyTimesFromLimits = async ( eventTypeId: eventType.id, limitManager, rescheduleUid, + timeZone, }); performance.mark("bookingLimitsEnd"); performance.measure(`checking booking limits took $1'`, "bookingLimitsStart", "bookingLimitsEnd"); @@ -89,6 +91,8 @@ const _getBusyTimesFromBookingLimits = async (params: { eventTypeId?: number; teamId?: number; user?: { id: number; email: string }; + includeManagedEvents?: boolean; + timeZone?: string | null; }) => { const { bookings, @@ -100,6 +104,8 @@ const _getBusyTimesFromBookingLimits = async (params: { teamId, user, rescheduleUid, + includeManagedEvents = false, + timeZone, } = params; for (const key of descendingLimitKeys) { @@ -123,6 +129,8 @@ const _getBusyTimesFromBookingLimits = async (params: { teamId, user, rescheduleUid, + includeManagedEvents, + timeZone, }); } catch (_) { limitManager.addBusyTime(periodStart, unit); @@ -231,6 +239,8 @@ const _getBusyTimesFromTeamLimits = async ( dateFrom: Dayjs, dateTo: Dayjs, teamId: number, + includeManagedEvents: boolean, + timeZone: string, rescheduleUid?: string ) => { const { limitDateFrom, limitDateTo } = getStartEndDateforLimitCheck( @@ -245,6 +255,7 @@ const _getBusyTimesFromTeamLimits = async ( startDate: limitDateFrom.toDate(), endDate: limitDateTo.toDate(), excludedUid: rescheduleUid, + includeManagedEvents, }); const busyTimes = bookings.map(({ id, startTime, endTime, eventTypeId, title, userId }) => ({ @@ -266,6 +277,8 @@ const _getBusyTimesFromTeamLimits = async ( rescheduleUid, teamId, user, + includeManagedEvents, + timeZone, }); return limitManager.getBusyTimes(); diff --git a/packages/core/getUserAvailability.ts b/packages/core/getUserAvailability.ts index a703f732e68cdb..4562bb90080148 100644 --- a/packages/core/getUserAvailability.ts +++ b/packages/core/getUserAvailability.ts @@ -53,10 +53,22 @@ const _getEventType = async (id: number) => { id: true, seatsPerTimeSlot: true, bookingLimits: true, + parent: { + select: { + team: { + select: { + id: true, + bookingLimits: true, + includeManagedEventsInLimits: true, + }, + }, + }, + }, team: { select: { id: true, bookingLimits: true, + includeManagedEventsInLimits: true, }, }, hosts: { @@ -286,20 +298,27 @@ const _getUserAvailability = async function getUsersWorkingHoursLifeTheUniverseA duration, eventType, initialData?.busyTimesFromLimitsBookings ?? [], + timeZone, initialData?.rescheduleUid ?? undefined ) : []; - const teamBookingLimits = parseBookingLimit(eventType?.team?.bookingLimits); + const teamForBookingLimits = + eventType?.team ?? + (eventType?.parent?.team?.includeManagedEventsInLimits ? eventType?.parent?.team : null); + + const teamBookingLimits = parseBookingLimit(teamForBookingLimits?.bookingLimits); const busyTimesFromTeamLimits = - eventType?.team && teamBookingLimits + teamForBookingLimits && teamBookingLimits ? await getBusyTimesFromTeamLimits( user, teamBookingLimits, dateFrom.tz(timeZone), dateTo.tz(timeZone), - eventType?.team.id, + teamForBookingLimits.id, + teamForBookingLimits.includeManagedEventsInLimits, + timeZone, initialData?.rescheduleUid ?? undefined ) : []; @@ -458,6 +477,7 @@ const _getPeriodStartDatesBetween = (dateFrom: Dayjs, dateTo: Dayjs, period: Int const dates = []; let startDate = dayjs(dateFrom).startOf(period); const endDate = dayjs(dateTo).endOf(period); + while (startDate.isBefore(endDate)) { dates.push(startDate); startDate = startDate.add(1, period); diff --git a/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts b/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts index 246b6febd0300a..68b9f31bf224d7 100644 --- a/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts +++ b/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts @@ -36,6 +36,7 @@ export const getEventTypesFromDB = async (eventTypeId: number) => { name: true, parentId: true, bookingLimits: true, + includeManagedEventsInLimits: true, }, }, bookingFields: true, @@ -73,6 +74,13 @@ export const getEventTypesFromDB = async (eventTypeId: number) => { parent: { select: { teamId: true, + team: { + select: { + id: true, + bookingLimits: true, + includeManagedEventsInLimits: true, + }, + }, }, }, useEventTypeDestinationCalendarEmail: 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 index 44cc34066329fa..c37181e28d81bb 100644 --- a/packages/features/bookings/lib/handleNewBooking/global-booking-limits.test.ts +++ b/packages/features/bookings/lib/handleNewBooking/global-booking-limits.test.ts @@ -85,6 +85,7 @@ describe( team: { id: 1, bookingLimits: { PER_WEEK: 2 }, + includeManagedEventsInLimits: false, }, schedulingType: SchedulingType.COLLECTIVE, }, @@ -101,6 +102,27 @@ describe( teamId: 1, schedulingType: SchedulingType.COLLECTIVE, }, + { + id: 3, + slotInterval: eventLength, + length: eventLength, + hosts: [ + { + userId: 101, + }, + ], + teamId: 1, + schedulingType: SchedulingType.MANAGED, + }, + { + id: 4, + slotInterval: eventLength, + length: eventLength, + userId: 101, + parent: { + id: 3, + }, + }, ], bookings: [ { @@ -124,13 +146,21 @@ describe( startTime: `2024-08-06T04:30:00.000Z`, endTime: `2024-08-06T05:00:00.000Z`, }, + { + // managed event type doesn't count, includeManagedEventsInLimits is false + eventTypeId: 4, + userId: 101, + status: BookingStatus.ACCEPTED, + startTime: `2024-08-07T04:30:00.000Z`, + endTime: `2024-08-07T05:00:00.000Z`, + }, ], organizer, usersApartFromOrganizer: otherTeamMembers, }) ); - const mockBookingData1 = getMockRequestDataForBooking({ + const mockBookingWithinLimit = getMockRequestDataForBooking({ data: { start: `2024-08-08T04:00:00.000Z`, end: `2024-08-08T04:30:00.000Z`, @@ -145,7 +175,7 @@ describe( const { req: req1 } = createMockNextJsRequest({ method: "POST", - body: mockBookingData1, + body: mockBookingWithinLimit, }); const createdBooking = await handleNewBooking(req1); @@ -157,7 +187,7 @@ describe( }) ); - const mockBookingData2 = getMockRequestDataForBooking({ + const mockBookingAboveLimit = getMockRequestDataForBooking({ data: { start: `2024-08-08T04:00:00.000Z`, end: `2024-08-08T04:30:00.000Z`, @@ -172,7 +202,7 @@ describe( const { req: req2 } = createMockNextJsRequest({ method: "POST", - body: mockBookingData2, + body: mockBookingAboveLimit, }); // this is the third team booking of this week for user 101, limit reached @@ -221,7 +251,7 @@ describe( }) ); - const mockBookingData1 = getMockRequestDataForBooking({ + const mockBookingWithinLimit = getMockRequestDataForBooking({ data: { start: `2024-08-07T04:30:00.000Z`, end: `2024-08-07T05:00:00.000Z`, @@ -236,7 +266,7 @@ describe( const { req: req1 } = createMockNextJsRequest({ method: "POST", - body: mockBookingData1, + body: mockBookingWithinLimit, }); const createdBooking = await handleNewBooking(req1); @@ -248,7 +278,7 @@ describe( }) ); - const mockBookingData2 = getMockRequestDataForBooking({ + const mockBookingAboveLimit = getMockRequestDataForBooking({ data: { start: `2024-08-07T04:00:00.000Z`, end: `2024-08-07T04:30:00.000Z`, @@ -263,10 +293,10 @@ describe( const { req: req2 } = createMockNextJsRequest({ method: "POST", - body: mockBookingData1, + body: mockBookingAboveLimit, }); - // this is the second team booking of this days for user 101, limit reached + // this is the second team booking of this day for user 101, limit reached await expect(async () => await handleNewBooking(req2)).rejects.toThrowError( "no_available_users_found_error" ); @@ -288,7 +318,8 @@ describe( ], team: { id: 1, - bookingLimits: { PER_MONTH: 3 }, + bookingLimits: { PER_MONTH: 4 }, + includeManagedEventsInLimits: true, }, schedulingType: SchedulingType.COLLECTIVE, }, @@ -305,6 +336,27 @@ describe( teamId: 1, schedulingType: SchedulingType.COLLECTIVE, }, + { + id: 3, + slotInterval: eventLength, + length: eventLength, + hosts: [ + { + userId: 101, + }, + ], + teamId: 1, + schedulingType: SchedulingType.MANAGED, + }, + { + id: 4, + slotInterval: eventLength, + length: eventLength, + userId: 101, + parent: { + id: 3, + }, + }, ], bookings: [ { @@ -321,13 +373,21 @@ describe( startTime: `2024-08-22T03:30:00.000Z`, endTime: `2024-08-22T04:00:00.000Z`, }, + { + //managed event type also counts towards limits + eventTypeId: 4, + userId: 101, + status: BookingStatus.ACCEPTED, + startTime: `2024-08-15T03:30:00.000Z`, + endTime: `2024-08-15T04:00:00.000Z`, + }, ], organizer, usersApartFromOrganizer: otherTeamMembers, }) ); - const mockBookingData1 = getMockRequestDataForBooking({ + const mockBookingWithinLimit = getMockRequestDataForBooking({ data: { start: `2024-08-29T04:30:00.000Z`, end: `2024-08-29T05:00:00.000Z`, @@ -342,7 +402,7 @@ describe( const { req: req1 } = createMockNextJsRequest({ method: "POST", - body: mockBookingData1, + body: mockBookingWithinLimit, }); const createdBooking = await handleNewBooking(req1); @@ -354,7 +414,7 @@ describe( }) ); - const mockBookingData2 = getMockRequestDataForBooking({ + const mockBookingAboveLimit = getMockRequestDataForBooking({ data: { start: `2024-08-25T04:00:00.000Z`, end: `2024-08-25T04:30:00.000Z`, @@ -369,10 +429,10 @@ describe( const { req: req2 } = createMockNextJsRequest({ method: "POST", - body: mockBookingData1, + body: mockBookingAboveLimit, }); - // this is the second team booking of this days for user 101, limit reached + // this is the firth team booking (incl. managed) of this month for user 101, limit reached await expect(async () => await handleNewBooking(req2)).rejects.toThrowError( "no_available_users_found_error" ); @@ -394,7 +454,8 @@ describe( ], team: { id: 1, - bookingLimits: { PER_YEAR: 2 }, + bookingLimits: { PER_YEAR: 3 }, + includeManagedEventsInLimits: true, }, schedulingType: SchedulingType.COLLECTIVE, }, @@ -411,6 +472,27 @@ describe( teamId: 1, schedulingType: SchedulingType.COLLECTIVE, }, + { + id: 3, + slotInterval: eventLength, + length: eventLength, + hosts: [ + { + userId: 101, + }, + ], + teamId: 1, + schedulingType: SchedulingType.MANAGED, + }, + { + id: 4, + slotInterval: eventLength, + length: eventLength, + userId: 101, + parent: { + id: 3, + }, + }, ], bookings: [ { @@ -418,6 +500,13 @@ describe( userId: 101, status: BookingStatus.ACCEPTED, startTime: `2024-02-03T03:30:00.000Z`, + endTime: `2024-02-03T04:00:00.000Z`, + }, + { + eventTypeId: 4, + userId: 101, + status: BookingStatus.ACCEPTED, + startTime: `2024-08-03T03:30:00.000Z`, endTime: `2024-08-03T04:00:00.000Z`, }, ], @@ -426,7 +515,7 @@ describe( }) ); - const mockBookingData1 = getMockRequestDataForBooking({ + const mockBookingWithinLimit = getMockRequestDataForBooking({ data: { start: `2024-08-29T04:30:00.000Z`, end: `2024-08-29T05:00:00.000Z`, @@ -441,7 +530,7 @@ describe( const { req: req1 } = createMockNextJsRequest({ method: "POST", - body: mockBookingData1, + body: mockBookingWithinLimit, }); const createdBooking = await handleNewBooking(req1); @@ -453,7 +542,7 @@ describe( }) ); - const mockBookingData2 = getMockRequestDataForBooking({ + const mockBookingAboveLimit = getMockRequestDataForBooking({ data: { start: `2024-11-25T04:00:00.000Z`, end: `2024-11-25T04:30:00.000Z`, @@ -468,10 +557,9 @@ describe( const { req: req2 } = createMockNextJsRequest({ method: "POST", - body: mockBookingData1, + body: mockBookingAboveLimit, }); - // 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" ); diff --git a/packages/features/ee/teams/pages/team-booking-limits-view.tsx b/packages/features/ee/teams/pages/team-booking-limits-view.tsx index 5a8be9a29f831d..892bde1c9ea743 100644 --- a/packages/features/ee/teams/pages/team-booking-limits-view.tsx +++ b/packages/features/ee/teams/pages/team-booking-limits-view.tsx @@ -14,7 +14,7 @@ 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"; +import { Button, CheckboxField, Form, SettingsToggle, showToast } from "@calcom/ui"; type ProfileViewProps = { team: RouterOutputs["viewer"]["teams"]["getMinimal"] }; @@ -22,9 +22,10 @@ const BookingLimitsView = ({ team }: ProfileViewProps) => { const { t } = useLocale(); const utils = trpc.useUtils(); - const form = useForm<{ bookingLimits?: IntervalLimit }>({ + const form = useForm<{ bookingLimits?: IntervalLimit; includeManagedEventsInLimits: boolean }>({ defaultValues: { bookingLimits: team?.bookingLimits || undefined, + includeManagedEventsInLimits: team?.includeManagedEventsInLimits ?? false, }, }); @@ -40,7 +41,10 @@ const BookingLimitsView = ({ team }: ProfileViewProps) => { async onSuccess(res) { await utils.viewer.teams.get.invalidate(); if (res) { - reset({ bookingLimits: res.bookingLimits }); + reset({ + bookingLimits: res.bookingLimits, + includeManagedEventsInLimits: res.includeManagedEventsInLimits, + }); } showToast(t("booking_limits_updated_successfully"), "success"); }, @@ -83,9 +87,12 @@ const BookingLimitsView = ({ team }: ProfileViewProps) => { }); } else { form.setValue("bookingLimits", {}); + form.setValue("includeManagedEventsInLimits", false); } const bookingLimits = form.getValues("bookingLimits"); - mutation.mutate({ bookingLimits, id: team.id }); + const includeManagedEventsInLimits = form.getValues("includeManagedEventsInLimits"); + + mutation.mutate({ bookingLimits, includeManagedEventsInLimits, id: team.id }); }} switchContainerClassName={classNames( "border-subtle mt-6 rounded-lg border py-6 px-4 sm:px-6", @@ -93,7 +100,21 @@ const BookingLimitsView = ({ team }: ProfileViewProps) => { )} childrenClassName="lg:ml-0">
- + ( + onChange(e)} + checked={value} + /> + )} + /> + +
+ +