From 3a3330c07951868adf18fd70232d302c52fd2320 Mon Sep 17 00:00:00 2001 From: CarinaWolli Date: Mon, 9 Sep 2024 15:33:41 -0400 Subject: [PATCH 01/41] add booking booking to team settings --- .../settings/teams/[id]/bookingLimits.tsx | 9 + apps/web/public/static/locales/en/common.json | 4 +- .../teams/pages/team-booking-limits-view.tsx | 157 ++++++++++++++++++ .../settings/layouts/SettingsLayout.tsx | 6 + packages/lib/server/queries/teams/index.ts | 3 + packages/prisma/schema.prisma | 1 + 6 files changed, 179 insertions(+), 1 deletion(-) create mode 100644 apps/web/pages/settings/teams/[id]/bookingLimits.tsx create mode 100644 packages/features/ee/teams/pages/team-booking-limits-view.tsx 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..f9eda053ab8cd8 --- /dev/null +++ b/apps/web/pages/settings/teams/[id]/bookingLimits.tsx @@ -0,0 +1,9 @@ +import TeamBookingLimitsView from "@calcom/features/ee/teams/pages/team-booking-limits-view"; + +import type { CalPageWrapper } from "@components/PageWrapper"; +import PageWrapper from "@components/PageWrapper"; + +const Page = TeamBookingLimitsView as CalPageWrapper; +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 57177a75218c54..66907756b81c3f 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -2596,6 +2596,8 @@ "number_of_options":"{{count}} options", "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", + "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", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } 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..6e352de030538a --- /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 } 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, Meta, SettingsToggle } from "@calcom/ui"; + +import { getLayout } from "../../../settings/layouts/SettingsLayout"; + +type BrandColorsFormValues = { + brandColor: string; + darkBrandColor: string; +}; + +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: isThemeSubmitting, isDirty: isThemeDirty }, + reset: resetTheme, + } = form; + + const isAdmin = + team && (team.membership.role === MembershipRole.OWNER || team.membership.role === MembershipRole.ADMIN); + + return ( + <> + + {isAdmin ? ( + <> +
{ + console.log("update booking limits"); + }}> + { + const isChecked = Object.keys(value ?? {}).length > 0; + return ( + { + if (active) { + form.setValue( + "bookingLimits", + { + PER_DAY: 1, + }, + { shouldDirty: true } + ); + } else { + form.setValue("bookingLimits", {}, { shouldDirty: true }); + } + }} + 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 ; +}; + +BookingLimitsViewWrapper.getLayout = getLayout; + +export default BookingLimitsViewWrapper; diff --git a/packages/features/settings/layouts/SettingsLayout.tsx b/packages/features/settings/layouts/SettingsLayout.tsx index 36bb7f3e444787..089510dd3b1af9 100644 --- a/packages/features/settings/layouts/SettingsLayout.tsx +++ b/packages/features/settings/layouts/SettingsLayout.tsx @@ -369,6 +369,12 @@ const TeamListCollapsible = () => { /> ) : null} + )} 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/prisma/schema.prisma b/packages/prisma/schema.prisma index a7af4dbc7f8248..4872ff1d3b59f0 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -436,6 +436,7 @@ model Team { activeOrgWorkflows WorkflowsOnTeams[] attributes Attribute[] smsLockReviewedByAdmin Boolean @default(false) + bookingLimits Json? @@unique([slug, parentId]) @@index([parentId]) From 694447fe2d70659c562e57005a2fd1eb232ebae3 Mon Sep 17 00:00:00 2001 From: CarinaWolli Date: Mon, 9 Sep 2024 18:19:18 -0400 Subject: [PATCH 02/41] add update mutation --- apps/web/public/static/locales/en/common.json | 2 ++ .../teams/pages/team-booking-limits-view.tsx | 36 ++++++++++--------- packages/prisma/schema.prisma | 4 ++- .../routers/viewer/teams/update.handler.ts | 1 + .../routers/viewer/teams/update.schema.ts | 2 ++ 5 files changed, 28 insertions(+), 17 deletions(-) diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 66907756b81c3f..6c1a093cb92a4c 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -2599,5 +2599,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 time members can be booked across all team event types", + "booking_limits_updated_successfully": "Booking limits updated successfully", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } 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 6e352de030538a..dc660af7937523 100644 --- a/packages/features/ee/teams/pages/team-booking-limits-view.tsx +++ b/packages/features/ee/teams/pages/team-booking-limits-view.tsx @@ -14,21 +14,26 @@ 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, Meta, SettingsToggle } from "@calcom/ui"; +import { Button, Form, Meta, SettingsToggle, showToast } from "@calcom/ui"; import { getLayout } from "../../../settings/layouts/SettingsLayout"; -type BrandColorsFormValues = { - brandColor: string; - darkBrandColor: string; -}; - type ProfileViewProps = { team: RouterOutputs["viewer"]["teams"]["getMinimal"] }; const BookingLimitsView = ({ team }: ProfileViewProps) => { const { t } = useLocale(); const utils = trpc.useUtils(); + const mutation = trpc.viewer.teams.update.useMutation({ + onError: (err) => { + showToast(err.message, "error"); + }, + async onSuccess(res) { + await utils.viewer.teams.get.invalidate(); + showToast(t("booking_limits_updated_successfully"), "success"); + }, + }); + const form = useForm<{ bookingLimits?: IntervalLimit }>({ defaultValues: { bookingLimits: team?.bookingLimits || undefined, @@ -55,7 +60,8 @@ const BookingLimitsView = ({ team }: ProfileViewProps) => {
{ - console.log("update booking limits"); + console.log("test test"); + mutation.mutate({ ...values, id: team.id }); }}> { toggleSwitchAtTheEnd={true} labelClassName="text-sm" title={t("limit_booking_frequency")} - description={t("limit_booking_frequency_description")} + description={t("limit_team_booking_frequency_description")} checked={isChecked} onCheckedChange={(active) => { if (active) { - form.setValue( - "bookingLimits", - { - PER_DAY: 1, - }, - { shouldDirty: true } - ); + form.setValue("bookingLimits", { + PER_DAY: 1, + }); } else { - form.setValue("bookingLimits", {}, { shouldDirty: true }); + 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", diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 4872ff1d3b59f0..8a3f14a7db0d95 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -436,7 +436,9 @@ model Team { activeOrgWorkflows WorkflowsOnTeams[] attributes Attribute[] smsLockReviewedByAdmin Boolean @default(false) - bookingLimits Json? + + /// @zod.custom(imports.intervalLimitsType) + bookingLimits Json? @@unique([slug, parentId]) @@index([parentId]) diff --git a/packages/trpc/server/routers/viewer/teams/update.handler.ts b/packages/trpc/server/routers/viewer/teams/update.handler.ts index e6b1ec9e5db6e7..417cee2b60317e 100644 --- a/packages/trpc/server/routers/viewer/teams/update.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/update.handler.ts @@ -56,6 +56,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { brandColor: input.brandColor, darkBrandColor: input.darkBrandColor, theme: input.theme, + bookingLimits: input.bookingLimits, }; if (input.logo && input.logo.startsWith("data:image/png;base64,")) { 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; From 5a786fb0445c8528e2ca6cad6139eaab113b4244 Mon Sep 17 00:00:00 2001 From: CarinaWolli Date: Mon, 9 Sep 2024 18:21:45 -0400 Subject: [PATCH 03/41] add missing export --- .../eventtypes/components/tabs/limits/EventLimitsTab.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/features/eventtypes/components/tabs/limits/EventLimitsTab.tsx b/packages/features/eventtypes/components/tabs/limits/EventLimitsTab.tsx index 83b7e58551a883..453aa5f7e1ad96 100644 --- a/packages/features/eventtypes/components/tabs/limits/EventLimitsTab.tsx +++ b/packages/features/eventtypes/components/tabs/limits/EventLimitsTab.tsx @@ -724,7 +724,7 @@ type IntervalLimitsManagerProps = disabled?: boolean; }; -const IntervalLimitsManager = ({ +export const IntervalLimitsManager = ({ propertyName, defaultLimit, step, From a9cbeb458b91c9cd91508d4beb23b19d857e9c39 Mon Sep 17 00:00:00 2001 From: CarinaWolli Date: Tue, 10 Sep 2024 12:48:58 -0400 Subject: [PATCH 04/41] fix dirty state --- .../teams/pages/team-booking-limits-view.tsx | 31 +++++++++---------- .../routers/viewer/teams/update.handler.ts | 2 ++ 2 files changed, 17 insertions(+), 16 deletions(-) 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 dc660af7937523..4f1552ee415d6c 100644 --- a/packages/features/ee/teams/pages/team-booking-limits-view.tsx +++ b/packages/features/ee/teams/pages/team-booking-limits-view.tsx @@ -24,27 +24,30 @@ 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 form = useForm<{ bookingLimits?: IntervalLimit }>({ - defaultValues: { - bookingLimits: team?.bookingLimits || undefined, - }, - }); - - const { - formState: { isSubmitting: isThemeSubmitting, isDirty: isThemeDirty }, - reset: resetTheme, - } = form; - const isAdmin = team && (team.membership.role === MembershipRole.OWNER || team.membership.role === MembershipRole.ADMIN); @@ -94,11 +97,7 @@ const BookingLimitsView = ({ team }: ProfileViewProps) => { - diff --git a/packages/trpc/server/routers/viewer/teams/update.handler.ts b/packages/trpc/server/routers/viewer/teams/update.handler.ts index 417cee2b60317e..18a0ef4c4e3f07 100644 --- a/packages/trpc/server/routers/viewer/teams/update.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/update.handler.ts @@ -8,6 +8,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"; @@ -138,6 +139,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { theme: updatedTeam.theme, brandColor: updatedTeam.brandColor, darkBrandColor: updatedTeam.darkBrandColor, + bookingLimits: updatedTeam.bookingLimits as IntervalLimit, }; }; From e9ea093a59e13a3606af439de32a13244b4d8635 Mon Sep 17 00:00:00 2001 From: CarinaWolli Date: Wed, 11 Sep 2024 13:24:27 -0400 Subject: [PATCH 05/41] first version of global team limits in getUserAvailability --- packages/core/getUserAvailability.ts | 156 ++++++++++++++++++ .../handleNewBooking/getEventTypesFromDB.ts | 7 + .../trpc/server/routers/viewer/slots/util.ts | 16 ++ 3 files changed, 179 insertions(+) diff --git a/packages/core/getUserAvailability.ts b/packages/core/getUserAvailability.ts index e79a1d4c2d19ed..51105523affb70 100644 --- a/packages/core/getUserAvailability.ts +++ b/packages/core/getUserAvailability.ts @@ -60,6 +60,22 @@ const _getEventType = async (id: number) => { id: true, seatsPerTimeSlot: true, bookingLimits: true, + team: { + select: { + id: true, + bookingLimits: true, + }, + }, + parent: { + select: { + team: { + select: { + id: true, + bookingLimits: true, + }, + }, + }, + }, hosts: { select: { user: { @@ -281,6 +297,13 @@ const _getUserAvailability = async function getUsersWorkingHoursLifeTheUniverseA initialData?.busyTimesFromLimitsBookings ?? [] ) : []; + const teamOfEventType = eventType?.team ?? eventType?.parent?.team; + const teamBookingLimits = parseBookingLimit(teamOfEventType?.bookingLimits); + + const busyTimesFromTeamLimits = + teamBookingLimits && teamOfEventType + ? await getBusyTimesFromTeamLimits(user, teamBookingLimits, dateFrom, dateTo, teamOfEventType.id) + : []; // TODO: only query what we need after applying limits (shrink date range) const getBusyTimesStart = dateFrom.toISOString(); @@ -312,6 +335,7 @@ const _getUserAvailability = async function getUsersWorkingHoursLifeTheUniverseA source: query.withSource ? a.source : undefined, })), ...busyTimesFromLimits, + ...busyTimesFromTeamLimits, ]; const userSchedule = user.schedules.filter( @@ -511,6 +535,138 @@ class LimitManager { } } +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 +) => { + performance.mark("teamLimitsStart"); + + const startDate = dayjs(dateFrom).startOf("week").toDate(); + const endDate = dayjs(dateTo).endOf("week").toDate(); + // maybe I already have them and can filter ? + const collectiveRoundRobinBookings = await prisma.booking.findMany({ + where: { + OR: [ + //use union instead + { + userId: user.id, + }, + { + attendees: { + some: { + email: user.email, + }, + }, + }, + ], + status: BookingStatus.ACCEPTED, + eventType: { + teamId, + }, + startTime: { + gte: startDate, + }, + endTime: { + lte: endDate, + }, + }, + }); + + const managedBookings = await prisma.booking.findMany({ + where: { + userId: user.id, + status: BookingStatus.ACCEPTED, + eventType: { + parent: { + teamId, + }, + }, + startTime: { + gte: startDate, + }, + endTime: { + lte: endDate, + }, + }, + }); + + const teamBookings = [...collectiveRoundRobinBookings, ...managedBookings]; + + const limitManager = new 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; + // check if an entry already exists with th + const periodEnd = periodStart.endOf(unit); + + if (unit === "year") { + const bookingsInPeriod = await prisma.booking.count({ + where: { + userId: user.id, + status: BookingStatus.ACCEPTED, + eventType: { + OR: [ + { teamId }, + { + parent: { + teamId, + }, + }, + ], + }, + startTime: { + gte: periodStart.toDate(), + }, + endTime: { + lte: periodEnd.toDate(), + }, + uid: { + not: rescheduleUid, + }, + }, + }); + if (bookingsInPeriod >= limit) { + limitManager.addBusyTime(periodStart, unit); + } + } else { + let totalBookings = 0; + for (const booking of teamBookings) { + // consider booking part of period independent of end date + if (!dayjs(booking.startTime).isBetween(periodStart, periodEnd)) { + continue; + } + totalBookings++; + if (totalBookings >= limit) { + limitManager.addBusyTime(periodStart, unit); + break; + } + } + } + } + } + + performance.mark("teamLimitsEnd"); + performance.measure(`checking all team limits took $1'`, "teamLimitsStart", "teamLimitsEnd"); + return limitManager.getBusyTimes(); +}; + const getBusyTimesFromLimits = async ( ...args: Parameters ): Promise> => { diff --git a/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts b/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts index ccd0841b7872a2..cfd75a9ced0e5e 100644 --- a/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts +++ b/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts @@ -30,6 +30,7 @@ export const getEventTypesFromDB = async (eventTypeId: number) => { id: true, name: true, parentId: true, + bookingLimits: true, }, }, bookingFields: true, @@ -66,6 +67,12 @@ export const getEventTypesFromDB = async (eventTypeId: number) => { parent: { select: { teamId: true, + team: { + select: { + id: true, + bookingLimits: true, + }, + }, }, }, useEventTypeDestinationCalendarEmail: true, diff --git a/packages/trpc/server/routers/viewer/slots/util.ts b/packages/trpc/server/routers/viewer/slots/util.ts index 73c3732d21ede9..0058769984ef16 100644 --- a/packages/trpc/server/routers/viewer/slots/util.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -175,6 +175,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, From ae8ac75ca09d6010b28133978783a5b4b33b5021 Mon Sep 17 00:00:00 2001 From: CarinaWolli Date: Thu, 12 Sep 2024 10:02:23 -0400 Subject: [PATCH 06/41] add test setup --- .../utils/bookingScenario/bookingScenario.ts | 19 ++- .../test/booking-limits.test.ts | 130 +++++++++++++++++- 2 files changed, 146 insertions(+), 3 deletions(-) diff --git a/apps/web/test/utils/bookingScenario/bookingScenario.ts b/apps/web/test/utils/bookingScenario/bookingScenario.ts index 5a98727ae0400d..4768e4fdd8125d 100644 --- a/apps/web/test/utils/bookingScenario/bookingScenario.ts +++ b/apps/web/test/utils/bookingScenario/bookingScenario.ts @@ -155,6 +155,7 @@ export type InputEventType = { team?: { id?: number | null; parentId?: number | null; + bookingLimits?: IntervalLimit; }; requiresConfirmation?: boolean; destinationCalendar?: Prisma.DestinationCalendarCreateInput; @@ -244,7 +245,9 @@ export async function addEventTypesToDb( ) { log.silly("TestData: Add EventTypes to DB", JSON.stringify(eventTypes)); await prismock.eventType.createMany({ - data: eventTypes, + data: eventTypes.map((et) => ({ + ...et, + })), }); const allEventTypes = await prismock.eventType.findMany({ include: { @@ -279,6 +282,17 @@ export async function addEventTypesToDb( }, }); } + + if (eventType.team) { + const createdTeam = await prismock.team.create({ + data: { id: eventType.team.id, bookingLimits: eventType.team.bookingLimits }, + }); + + await prismock.eventType.update({ + where: { id: eventType.id }, + data: { teamId: createdTeam.id }, + }); + } } /*** * HACK ENDS @@ -1261,8 +1275,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/features/bookings/lib/handleNewBooking/test/booking-limits.test.ts b/packages/features/bookings/lib/handleNewBooking/test/booking-limits.test.ts index a9f2322ee67a7e..4b25e48843b834 100644 --- a/packages/features/bookings/lib/handleNewBooking/test/booking-limits.test.ts +++ b/packages/features/bookings/lib/handleNewBooking/test/booking-limits.test.ts @@ -19,6 +19,7 @@ import { BookingLocations, mockSuccessfulVideoMeetingCreation, mockCalendarToHaveNoBusySlots, + Timezones, } from "@calcom/web/test/utils/bookingScenario/bookingScenario"; import { createMockNextJsRequest } from "@calcom/web/test/utils/bookingScenario/createMockNextJsRequest"; import { expectBookingToBeInDatabase } from "@calcom/web/test/utils/bookingScenario/expects"; @@ -27,7 +28,7 @@ import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAn import { describe, expect, vi } from "vitest"; -import { PeriodType } from "@calcom/prisma/enums"; +import { PeriodType, SchedulingType } from "@calcom/prisma/enums"; import { BookingStatus } from "@calcom/prisma/enums"; import { test } from "@calcom/web/test/fixtures/fixtures"; @@ -469,6 +470,125 @@ describe("handleNewBooking", () => { timeout ); + describe( + "Global Team Booking Limits", + () => { + test(`should fail a booking if yearly bookings limits are already reached (1 host) + 1. year with limits reached: should fail to book + 2. following year without bookings: should create a booking in the database + `, async ({}) => { + 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", + }, + }, + ]; + + const yearlyDurationLimit = 2 * eventLength; + + const { dateString: nextYearDateString } = getDate({ yearIncrement: 1 }); + const { dateString: nextYearDateStringNextDay } = getDate({ yearIncrement: 1, dateIncrement: 1 }); + + await createBookingScenario( + getScenarioData({ + eventTypes: [ + { + id: 1, + slotInterval: eventLength, + length: eventLength, + hosts: [ + { + userId: 101, + isFixed: true, + }, + ], + team: { + id: 1, + bookingLimits: { PER_WEEK: 1 }, + }, + schedulingType: SchedulingType.COLLECTIVE, + }, + ], + bookings: [ + { + eventTypeId: 1, + userId: 101, + status: BookingStatus.ACCEPTED, + startTime: `${nextYearDateString}T03:30:00.000Z`, + endTime: `${nextYearDateString}T04:00:00.000Z`, + }, + { + eventTypeId: 1, + userId: 102, + status: BookingStatus.ACCEPTED, + startTime: `${nextYearDateStringNextDay}T04:00:00.000Z`, + endTime: `${nextYearDateStringNextDay}T04:30:00.000Z`, + }, + { + eventTypeId: 1, + userId: 102, + status: BookingStatus.ACCEPTED, + startTime: `${nextYearDateStringNextDay}T04:30:00.000Z`, + endTime: `${nextYearDateStringNextDay}T05:00:00.000Z`, + }, + ], + organizer, + usersApartFromOrganizer: otherTeamMembers, + }) + ); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + start: `${nextYearDateString}T04:00:00.000Z`, + end: `${nextYearDateString}T04:30:00.000Z`, + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: "New York" }, + }, + }, + }); + + const { req } = createMockNextJsRequest({ + method: "POST", + body: mockBookingData, + }); + await expect(async () => await handleNewBooking(req)).rejects.toThrowError( + "no_available_users_found_error" + ); + }); + }, + timeout + ); + describe("Buffers", () => { test(`should throw error when booking is within a before event buffer of an existing booking `, async ({}) => { @@ -511,6 +631,14 @@ describe("handleNewBooking", () => { startTime: `${nextDayDateString}T05:00:00.000Z`, endTime: `${nextDayDateString}T05:15:00.000Z`, }, + { + eventTypeId: 1, + userId: 102, + attendees: [{ email: "organizer@example.com" }], + status: BookingStatus.ACCEPTED, + startTime: `${nextDayDateString}T05:00:00.000Z`, + endTime: `${nextDayDateString}T05:15:00.000Z`, + }, ], organizer, }) From b781c9fd41a7c7cbce3928efcbf98c12e3548e5e Mon Sep 17 00:00:00 2001 From: CarinaWolli Date: Thu, 12 Sep 2024 11:57:36 -0400 Subject: [PATCH 07/41] create seperate test file for global limits --- .../utils/bookingScenario/bookingScenario.ts | 2 +- .../global-booking-limits.test.ts | 195 ++++++++++++++++++ .../test/booking-limits.test.ts | 122 +---------- 3 files changed, 197 insertions(+), 122 deletions(-) create mode 100644 packages/features/bookings/lib/handleNewBooking/global-booking-limits.test.ts diff --git a/apps/web/test/utils/bookingScenario/bookingScenario.ts b/apps/web/test/utils/bookingScenario/bookingScenario.ts index 399466d1b0370c..4fba412f1b78f1 100644 --- a/apps/web/test/utils/bookingScenario/bookingScenario.ts +++ b/apps/web/test/utils/bookingScenario/bookingScenario.ts @@ -1281,7 +1281,7 @@ export function getScenarioData( team: { id: eventType.teamId ?? eventType.team.id, parentId: org ? org.id : null, - bookingLimits: eventType.team.bookingLimits, + bookingLimits: eventType?.team?.bookingLimits, }, title: `Test Event Type - ${index + 1}`, description: `It's a test event type - ${index + 1}`, 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..ed71f7c73d6fab --- /dev/null +++ b/packages/features/bookings/lib/handleNewBooking/global-booking-limits.test.ts @@ -0,0 +1,195 @@ +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 } 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(); + + 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 ({}) => { + vi.setSystemTime(new Date("2024-09-02")); //Monday + + 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-09-03T03:30:00.000Z`, + endTime: `2024-09-03T04:00:00.000Z`, + }, + { + eventTypeId: 1, + userId: 102, + status: BookingStatus.ACCEPTED, + startTime: `2024-09-03T04:00:00.000Z`, + endTime: `2024-09-03T04:30:00.000Z`, + }, + { + eventTypeId: 1, + userId: 102, + status: BookingStatus.ACCEPTED, + startTime: `2024-09-03T04:30:00.000Z`, + endTime: `2024-09-03T05:00:00.000Z`, + }, + ], + organizer, + usersApartFromOrganizer: otherTeamMembers, + }) + ); + + const mockBookingData1 = getMockRequestDataForBooking({ + data: { + start: `2024-09-05T04:00:00.000Z`, + end: `2024-09-05T04: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).toContain({ + email: booker.email, + name: booker.name, + }); + + const mockBookingData2 = getMockRequestDataForBooking({ + data: { + start: `2024-09-06T04:00:00.000Z`, + end: `2024-09-06T04: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 ({}) => { + console.log("todo"); + }); + + test(`Booking limits per month`, async ({}) => { + console.log("todo"); + }); + + test(`Booking limits per year`, async ({}) => { + console.log("todo"); + }); + }); + }, + timeout +); diff --git a/packages/features/bookings/lib/handleNewBooking/test/booking-limits.test.ts b/packages/features/bookings/lib/handleNewBooking/test/booking-limits.test.ts index 4b25e48843b834..ecd79c32740e0c 100644 --- a/packages/features/bookings/lib/handleNewBooking/test/booking-limits.test.ts +++ b/packages/features/bookings/lib/handleNewBooking/test/booking-limits.test.ts @@ -19,7 +19,6 @@ import { BookingLocations, mockSuccessfulVideoMeetingCreation, mockCalendarToHaveNoBusySlots, - Timezones, } from "@calcom/web/test/utils/bookingScenario/bookingScenario"; import { createMockNextJsRequest } from "@calcom/web/test/utils/bookingScenario/createMockNextJsRequest"; import { expectBookingToBeInDatabase } from "@calcom/web/test/utils/bookingScenario/expects"; @@ -28,7 +27,7 @@ import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAn import { describe, expect, vi } from "vitest"; -import { PeriodType, SchedulingType } from "@calcom/prisma/enums"; +import { PeriodType } from "@calcom/prisma/enums"; import { BookingStatus } from "@calcom/prisma/enums"; import { test } from "@calcom/web/test/fixtures/fixtures"; @@ -470,125 +469,6 @@ describe("handleNewBooking", () => { timeout ); - describe( - "Global Team Booking Limits", - () => { - test(`should fail a booking if yearly bookings limits are already reached (1 host) - 1. year with limits reached: should fail to book - 2. following year without bookings: should create a booking in the database - `, async ({}) => { - 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", - }, - }, - ]; - - const yearlyDurationLimit = 2 * eventLength; - - const { dateString: nextYearDateString } = getDate({ yearIncrement: 1 }); - const { dateString: nextYearDateStringNextDay } = getDate({ yearIncrement: 1, dateIncrement: 1 }); - - await createBookingScenario( - getScenarioData({ - eventTypes: [ - { - id: 1, - slotInterval: eventLength, - length: eventLength, - hosts: [ - { - userId: 101, - isFixed: true, - }, - ], - team: { - id: 1, - bookingLimits: { PER_WEEK: 1 }, - }, - schedulingType: SchedulingType.COLLECTIVE, - }, - ], - bookings: [ - { - eventTypeId: 1, - userId: 101, - status: BookingStatus.ACCEPTED, - startTime: `${nextYearDateString}T03:30:00.000Z`, - endTime: `${nextYearDateString}T04:00:00.000Z`, - }, - { - eventTypeId: 1, - userId: 102, - status: BookingStatus.ACCEPTED, - startTime: `${nextYearDateStringNextDay}T04:00:00.000Z`, - endTime: `${nextYearDateStringNextDay}T04:30:00.000Z`, - }, - { - eventTypeId: 1, - userId: 102, - status: BookingStatus.ACCEPTED, - startTime: `${nextYearDateStringNextDay}T04:30:00.000Z`, - endTime: `${nextYearDateStringNextDay}T05:00:00.000Z`, - }, - ], - organizer, - usersApartFromOrganizer: otherTeamMembers, - }) - ); - - const mockBookingData = getMockRequestDataForBooking({ - data: { - start: `${nextYearDateString}T04:00:00.000Z`, - end: `${nextYearDateString}T04:30:00.000Z`, - eventTypeId: 1, - responses: { - email: booker.email, - name: booker.name, - location: { optionValue: "", value: "New York" }, - }, - }, - }); - - const { req } = createMockNextJsRequest({ - method: "POST", - body: mockBookingData, - }); - await expect(async () => await handleNewBooking(req)).rejects.toThrowError( - "no_available_users_found_error" - ); - }); - }, - timeout - ); - describe("Buffers", () => { test(`should throw error when booking is within a before event buffer of an existing booking `, async ({}) => { From 6a457f3b2633a7e26677842b94112914617405e4 Mon Sep 17 00:00:00 2001 From: CarinaWolli Date: Thu, 12 Sep 2024 12:48:00 -0400 Subject: [PATCH 08/41] add tests for all units --- .../global-booking-limits.test.ts | 311 +++++++++++++++++- 1 file changed, 295 insertions(+), 16 deletions(-) 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 ed71f7c73d6fab..b7d303063f29d9 100644 --- a/packages/features/bookings/lib/handleNewBooking/global-booking-limits.test.ts +++ b/packages/features/bookings/lib/handleNewBooking/global-booking-limits.test.ts @@ -11,7 +11,7 @@ import { createMockNextJsRequest } from "@calcom/web/test/utils/bookingScenario/ import { getMockRequestDataForBooking } from "@calcom/web/test/utils/bookingScenario/getMockRequestDataForBooking"; import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAndTeardown"; -import { describe, expect, vi } from "vitest"; +import { describe, expect, vi, beforeAll } from "vitest"; import { SchedulingType } from "@calcom/prisma/enums"; import { BookingStatus } from "@calcom/prisma/enums"; @@ -60,14 +60,15 @@ 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 ({}) => { - vi.setSystemTime(new Date("2024-09-02")); //Monday - await createBookingScenario( getScenarioData({ eventTypes: [ @@ -106,22 +107,22 @@ describe( eventTypeId: 2, userId: 101, status: BookingStatus.ACCEPTED, - startTime: `2024-09-03T03:30:00.000Z`, - endTime: `2024-09-03T04:00:00.000Z`, + startTime: `2024-08-06T03:30:00.000Z`, + endTime: `2024-08-06T04:00:00.000Z`, }, { eventTypeId: 1, userId: 102, status: BookingStatus.ACCEPTED, - startTime: `2024-09-03T04:00:00.000Z`, - endTime: `2024-09-03T04:30:00.000Z`, + startTime: `2024-08-06T04:00:00.000Z`, + endTime: `2024-08-06T04:30:00.000Z`, }, { eventTypeId: 1, userId: 102, status: BookingStatus.ACCEPTED, - startTime: `2024-09-03T04:30:00.000Z`, - endTime: `2024-09-03T05:00:00.000Z`, + startTime: `2024-08-06T04:30:00.000Z`, + endTime: `2024-08-06T05:00:00.000Z`, }, ], organizer, @@ -131,8 +132,8 @@ describe( const mockBookingData1 = getMockRequestDataForBooking({ data: { - start: `2024-09-05T04:00:00.000Z`, - end: `2024-09-05T04:30:00.000Z`, + start: `2024-08-08T04:00:00.000Z`, + end: `2024-08-08T04:30:00.000Z`, eventTypeId: 1, responses: { email: booker.email, @@ -156,8 +157,8 @@ describe( const mockBookingData2 = getMockRequestDataForBooking({ data: { - start: `2024-09-06T04:00:00.000Z`, - end: `2024-09-06T04:30:00.000Z`, + start: `2024-08-08T04:00:00.000Z`, + end: `2024-08-08T04:30:00.000Z`, eventTypeId: 1, responses: { email: booker.email, @@ -179,15 +180,293 @@ describe( }); test(`Booking limits per day`, async ({}) => { - console.log("todo"); + 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).toContain({ + 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 ({}) => { - console.log("todo"); + 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).toContain({ + 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 ({}) => { - console.log("todo"); + 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).toContain({ + 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" + ); }); }); }, From 5e233ee3d0b4ad6498f82c52bc10a6d83dde66c6 Mon Sep 17 00:00:00 2001 From: CarinaWolli Date: Thu, 12 Sep 2024 13:20:27 -0400 Subject: [PATCH 09/41] move limitManager and booking limit functions outside of getUserAvailability --- .../bookingLimits/getBusyTimesFromLimts.ts | 290 +++++++++++++ packages/core/bookingLimits/limitManager.ts | 63 +++ packages/core/getUserAvailability.ts | 391 +----------------- packages/lib/server/repository/booking.ts | 69 ++++ 4 files changed, 426 insertions(+), 387 deletions(-) create mode 100644 packages/core/bookingLimits/getBusyTimesFromLimts.ts create mode 100644 packages/core/bookingLimits/limitManager.ts diff --git a/packages/core/bookingLimits/getBusyTimesFromLimts.ts b/packages/core/bookingLimits/getBusyTimesFromLimts.ts new file mode 100644 index 00000000000000..6be95aa0826dd6 --- /dev/null +++ b/packages/core/bookingLimits/getBusyTimesFromLimts.ts @@ -0,0 +1,290 @@ +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 prisma from "@calcom/prisma"; +import { BookingStatus } from "@calcom/prisma/enums"; +import type { EventBusyDetails, IntervalLimit } from "@calcom/types/Calendar"; + +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[] +) => { + 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; + } + } + } + } +}; + +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 +) => { + performance.mark("teamLimitsStart"); + + const startDate = dayjs(dateFrom).startOf("week").toDate(); + const endDate = dayjs(dateTo).endOf("week").toDate(); + // maybe I already have them and can filter ? + + const teamBookings = await BookingRepository.getAllAcceptedTeamBookingsOfUser({ + user, + teamId, + startDate, + endDate, + }); + + const limitManager = new 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; + // check if an entry already exists with th + const periodEnd = periodStart.endOf(unit); + + if (unit === "year") { + const bookingsInPeriod = await prisma.booking.count({ + where: { + userId: user.id, + status: BookingStatus.ACCEPTED, + eventType: { + OR: [ + { teamId }, + { + parent: { + teamId, + }, + }, + ], + }, + startTime: { + gte: periodStart.toDate(), + }, + endTime: { + lte: periodEnd.toDate(), + }, + uid: { + not: rescheduleUid, + }, + }, + }); + if (bookingsInPeriod >= limit) { + limitManager.addBusyTime(periodStart, unit); + } + } else { + let totalBookings = 0; + for (const booking of teamBookings) { + // consider booking part of period independent of end date + if (!dayjs(booking.startTime).isBetween(periodStart, periodEnd)) { + continue; + } + totalBookings++; + if (totalBookings >= limit) { + limitManager.addBusyTime(periodStart, unit); + break; + } + } + } + } + } + + performance.mark("teamLimitsEnd"); + performance.measure(`checking all team limits took $1'`, "teamLimitsStart", "teamLimitsEnd"); + 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/getUserAvailability.ts b/packages/core/getUserAvailability.ts index 51105523affb70..20c6f91b194ac9 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/getBusyTimesFromLimts"; import { getBusyTimes } from "./getBusyTimes"; import monitorCallbackAsync, { monitorCallbackSync } from "./sentryWrapper"; @@ -124,7 +117,7 @@ const _getEventType = async (id: number) => { }; }; -type EventType = Awaited>; +export type EventType = Awaited>; const getUser = async (...args: Parameters): Promise> => { return monitorCallbackAsync(_getUser, ...args); @@ -457,7 +450,7 @@ const _getUserAvailability = async function getUsersWorkingHoursLifeTheUniverseA }; }; -const getPeriodStartDatesBetween = ( +export const getPeriodStartDatesBetween = ( ...args: Parameters ): ReturnType => { return monitorCallbackSync(_getPeriodStartDatesBetween, ...args); @@ -474,382 +467,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 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 -) => { - performance.mark("teamLimitsStart"); - - const startDate = dayjs(dateFrom).startOf("week").toDate(); - const endDate = dayjs(dateTo).endOf("week").toDate(); - // maybe I already have them and can filter ? - const collectiveRoundRobinBookings = await prisma.booking.findMany({ - where: { - OR: [ - //use union instead - { - userId: user.id, - }, - { - attendees: { - some: { - email: user.email, - }, - }, - }, - ], - status: BookingStatus.ACCEPTED, - eventType: { - teamId, - }, - startTime: { - gte: startDate, - }, - endTime: { - lte: endDate, - }, - }, - }); - - const managedBookings = await prisma.booking.findMany({ - where: { - userId: user.id, - status: BookingStatus.ACCEPTED, - eventType: { - parent: { - teamId, - }, - }, - startTime: { - gte: startDate, - }, - endTime: { - lte: endDate, - }, - }, - }); - - const teamBookings = [...collectiveRoundRobinBookings, ...managedBookings]; - - const limitManager = new 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; - // check if an entry already exists with th - const periodEnd = periodStart.endOf(unit); - - if (unit === "year") { - const bookingsInPeriod = await prisma.booking.count({ - where: { - userId: user.id, - status: BookingStatus.ACCEPTED, - eventType: { - OR: [ - { teamId }, - { - parent: { - teamId, - }, - }, - ], - }, - startTime: { - gte: periodStart.toDate(), - }, - endTime: { - lte: periodEnd.toDate(), - }, - uid: { - not: rescheduleUid, - }, - }, - }); - if (bookingsInPeriod >= limit) { - limitManager.addBusyTime(periodStart, unit); - } - } else { - let totalBookings = 0; - for (const booking of teamBookings) { - // consider booking part of period independent of end date - if (!dayjs(booking.startTime).isBetween(periodStart, periodEnd)) { - continue; - } - totalBookings++; - if (totalBookings >= limit) { - limitManager.addBusyTime(periodStart, unit); - break; - } - } - } - } - } - - performance.mark("teamLimitsEnd"); - performance.measure(`checking all team limits took $1'`, "teamLimitsStart", "teamLimitsEnd"); - return limitManager.getBusyTimes(); -}; - -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/lib/server/repository/booking.ts b/packages/lib/server/repository/booking.ts index a82c98d47c91b3..f8b6c139e799a8 100644 --- a/packages/lib/server/repository/booking.ts +++ b/packages/lib/server/repository/booking.ts @@ -249,4 +249,73 @@ export class BookingRepository { }, }); } + + static async getAllAcceptedTeamBookingsOfUser(params: { + user: { id: number; email: string }; + teamId: number; + startDate: Date; + endDate: Date; + }) { + const { user, teamId, startDate, endDate } = params; + + const collectiveRoundRobinBookingsOwner = await prisma.booking.findMany({ + where: { + userId: user.id, + status: BookingStatus.ACCEPTED, + eventType: { + teamId, + }, + startTime: { + gte: startDate, + }, + endTime: { + lte: endDate, + }, + }, + }); + + const collectiveRoundRobinBookingsAttendee = await prisma.booking.findMany({ + where: { + attendees: { + some: { + email: user.email, + }, + }, + status: BookingStatus.ACCEPTED, + eventType: { + teamId, + }, + startTime: { + gte: startDate, + }, + endTime: { + lte: endDate, + }, + }, + }); + + const managedBookings = await prisma.booking.findMany({ + where: { + userId: user.id, + status: BookingStatus.ACCEPTED, + eventType: { + parent: { + teamId, + }, + }, + startTime: { + gte: startDate, + }, + endTime: { + lte: endDate, + }, + }, + }); + + return [ + ...collectiveRoundRobinBookingsOwner, + ...collectiveRoundRobinBookingsAttendee, + ...managedBookings, + ]; + } } From 5775af525031ba96a3b3f945cc6ed8c40ef94ac4 Mon Sep 17 00:00:00 2001 From: CarinaWolli Date: Thu, 12 Sep 2024 13:21:24 -0400 Subject: [PATCH 10/41] add migration --- .../20240912150824_add_booking_limits_to_team/migration.sql | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 packages/prisma/migrations/20240912150824_add_booking_limits_to_team/migration.sql 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; From 3315380943b4d5a4cd1f36483a166f262cd14c73 Mon Sep 17 00:00:00 2001 From: CarinaWolli Date: Thu, 12 Sep 2024 13:34:37 -0400 Subject: [PATCH 11/41] clean up code --- .../lib/handleNewBooking/test/booking-limits.test.ts | 8 -------- .../features/ee/teams/pages/team-booking-limits-view.tsx | 1 - 2 files changed, 9 deletions(-) diff --git a/packages/features/bookings/lib/handleNewBooking/test/booking-limits.test.ts b/packages/features/bookings/lib/handleNewBooking/test/booking-limits.test.ts index ecd79c32740e0c..a9f2322ee67a7e 100644 --- a/packages/features/bookings/lib/handleNewBooking/test/booking-limits.test.ts +++ b/packages/features/bookings/lib/handleNewBooking/test/booking-limits.test.ts @@ -511,14 +511,6 @@ describe("handleNewBooking", () => { startTime: `${nextDayDateString}T05:00:00.000Z`, endTime: `${nextDayDateString}T05:15:00.000Z`, }, - { - eventTypeId: 1, - userId: 102, - attendees: [{ email: "organizer@example.com" }], - status: BookingStatus.ACCEPTED, - startTime: `${nextDayDateString}T05:00:00.000Z`, - endTime: `${nextDayDateString}T05:15:00.000Z`, - }, ], organizer, }) 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 4f1552ee415d6c..1237f90137d1a1 100644 --- a/packages/features/ee/teams/pages/team-booking-limits-view.tsx +++ b/packages/features/ee/teams/pages/team-booking-limits-view.tsx @@ -63,7 +63,6 @@ const BookingLimitsView = ({ team }: ProfileViewProps) => { { - console.log("test test"); mutation.mutate({ ...values, id: team.id }); }}> Date: Thu, 12 Sep 2024 14:48:56 -0400 Subject: [PATCH 12/41] move yearly booking count to booking repository --- .../bookingLimits/getBusyTimesFromLimts.ts | 33 +---- packages/lib/server/repository/booking.ts | 138 +++++++++++------- 2 files changed, 93 insertions(+), 78 deletions(-) diff --git a/packages/core/bookingLimits/getBusyTimesFromLimts.ts b/packages/core/bookingLimits/getBusyTimesFromLimts.ts index 6be95aa0826dd6..bcadeb8f17775c 100644 --- a/packages/core/bookingLimits/getBusyTimesFromLimts.ts +++ b/packages/core/bookingLimits/getBusyTimesFromLimts.ts @@ -5,8 +5,6 @@ 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 prisma from "@calcom/prisma"; -import { BookingStatus } from "@calcom/prisma/enums"; import type { EventBusyDetails, IntervalLimit } from "@calcom/types/Calendar"; import type { EventType } from "../getUserAvailability"; @@ -239,31 +237,14 @@ const _getBusyTimesFromTeamLimits = async ( const periodEnd = periodStart.endOf(unit); if (unit === "year") { - const bookingsInPeriod = await prisma.booking.count({ - where: { - userId: user.id, - status: BookingStatus.ACCEPTED, - eventType: { - OR: [ - { teamId }, - { - parent: { - teamId, - }, - }, - ], - }, - startTime: { - gte: periodStart.toDate(), - }, - endTime: { - lte: periodEnd.toDate(), - }, - uid: { - not: rescheduleUid, - }, - }, + const bookingsInPeriod = await BookingRepository.getAllAcceptedTeamBookingsOfUser({ + user: { id: user.id, email: user.email }, + teamId, + startDate: periodStart.toDate(), + endDate: periodEnd.toDate(), + returnCount: true, }); + if (bookingsInPeriod >= limit) { limitManager.addBusyTime(periodStart, unit); } diff --git a/packages/lib/server/repository/booking.ts b/packages/lib/server/repository/booking.ts index f8b6c139e799a8..7663383a994f16 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"; @@ -255,67 +256,100 @@ export class BookingRepository { teamId: number; startDate: Date; endDate: Date; + returnCount: true; + }): Promise; + + static async getAllAcceptedTeamBookingsOfUser(params: { + user: { id: number; email: string }; + teamId: number; + startDate: Date; + endDate: Date; + }): Promise>; + + static async getAllAcceptedTeamBookingsOfUser(params: { + user: { id: number; email: string }; + teamId: number; + startDate: Date; + endDate: Date; + returnCount?: boolean; }) { - const { user, teamId, startDate, endDate } = params; + const { user, teamId, startDate, endDate, returnCount } = params; - const collectiveRoundRobinBookingsOwner = await prisma.booking.findMany({ - where: { - userId: user.id, - status: BookingStatus.ACCEPTED, - eventType: { - teamId, - }, - startTime: { - gte: startDate, - }, - endTime: { - lte: endDate, - }, + const baseWhere: Prisma.BookingWhereInput = { + status: BookingStatus.ACCEPTED, + startTime: { + gte: startDate, }, - }); + endTime: { + lte: endDate, + }, + }; - const collectiveRoundRobinBookingsAttendee = await prisma.booking.findMany({ - where: { - attendees: { - some: { - email: user.email, - }, - }, - status: BookingStatus.ACCEPTED, - eventType: { - teamId, - }, - startTime: { - gte: startDate, - }, - endTime: { - lte: endDate, - }, + const whereCollectiveRoundRobinOwner: Prisma.BookingWhereInput = { + ...baseWhere, + userId: user.id, + eventType: { + teamId, }, - }); + }; - const managedBookings = await prisma.booking.findMany({ - where: { - userId: user.id, - status: BookingStatus.ACCEPTED, - eventType: { - parent: { - teamId, - }, + const whereCollectiveRoundRobinBookingsAttendee: Prisma.BookingWhereInput = { + ...baseWhere, + attendees: { + some: { + email: user.email, }, - startTime: { - gte: startDate, - }, - endTime: { - lte: endDate, + }, + eventType: { + teamId, + }, + }; + + const whereManagedBookings: Prisma.BookingWhereInput = { + ...baseWhere, + userId: user.id, + eventType: { + parent: { + teamId, }, }, - }); + }; + + if (returnCount) { + const collectiveRoundRobinBookingsOwner = await prisma.booking.count({ + where: whereCollectiveRoundRobinOwner, + }); + + const collectiveRoundRobinBookingsAttendee = await prisma.booking.count({ + where: whereCollectiveRoundRobinBookingsAttendee, + }); + + const managedBookings = await prisma.booking.count({ + where: whereManagedBookings, + }); + + const totalNrOfBooking = + collectiveRoundRobinBookingsOwner + collectiveRoundRobinBookingsAttendee + managedBookings; + + return totalNrOfBooking; + } else { + const collectiveRoundRobinBookingsOwner = await prisma.booking.findMany({ + where: whereCollectiveRoundRobinOwner, + }); + + const collectiveRoundRobinBookingsAttendee = await prisma.booking.findMany({ + where: whereCollectiveRoundRobinBookingsAttendee, + }); + + const managedBookings = await prisma.booking.findMany({ + where: whereManagedBookings, + }); - return [ - ...collectiveRoundRobinBookingsOwner, - ...collectiveRoundRobinBookingsAttendee, - ...managedBookings, - ]; + return [ + ...collectiveRoundRobinBookingsOwner, + ...collectiveRoundRobinBookingsAttendee, + ...managedBookings, + ]; + } } } From f7657a078b6e9105c6d00794f7fb2f2e0b6bda54 Mon Sep 17 00:00:00 2001 From: CarinaWolli Date: Thu, 12 Sep 2024 14:51:04 -0400 Subject: [PATCH 13/41] code clean up --- packages/lib/server/repository/booking.ts | 31 +++++++++++------------ 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/packages/lib/server/repository/booking.ts b/packages/lib/server/repository/booking.ts index 7663383a994f16..54f6c6f1efeed0 100644 --- a/packages/lib/server/repository/booking.ts +++ b/packages/lib/server/repository/booking.ts @@ -332,24 +332,23 @@ export class BookingRepository { collectiveRoundRobinBookingsOwner + collectiveRoundRobinBookingsAttendee + managedBookings; return totalNrOfBooking; - } else { - const collectiveRoundRobinBookingsOwner = await prisma.booking.findMany({ - where: whereCollectiveRoundRobinOwner, - }); + } + const collectiveRoundRobinBookingsOwner = await prisma.booking.findMany({ + where: whereCollectiveRoundRobinOwner, + }); - const collectiveRoundRobinBookingsAttendee = await prisma.booking.findMany({ - where: whereCollectiveRoundRobinBookingsAttendee, - }); + const collectiveRoundRobinBookingsAttendee = await prisma.booking.findMany({ + where: whereCollectiveRoundRobinBookingsAttendee, + }); - const managedBookings = await prisma.booking.findMany({ - where: whereManagedBookings, - }); + const managedBookings = await prisma.booking.findMany({ + where: whereManagedBookings, + }); - return [ - ...collectiveRoundRobinBookingsOwner, - ...collectiveRoundRobinBookingsAttendee, - ...managedBookings, - ]; - } + return [ + ...collectiveRoundRobinBookingsOwner, + ...collectiveRoundRobinBookingsAttendee, + ...managedBookings, + ]; } } From b6a91658c8775f747eca87bd74db27516dfe02a2 Mon Sep 17 00:00:00 2001 From: CarinaWolli Date: Thu, 12 Sep 2024 15:50:18 -0400 Subject: [PATCH 14/41] don't count rescheduling booking --- apps/web/public/static/locales/en/common.json | 2 +- packages/core/bookingLimits/getBusyTimesFromLimts.ts | 3 ++- packages/core/getUserAvailability.ts | 9 ++++++++- packages/lib/server/repository/booking.ts | 10 +++++++++- 4 files changed, 20 insertions(+), 4 deletions(-) diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index acdc3df4691e45..814baf0418786f 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -2605,7 +2605,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 time members can be booked across all team event types", + "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", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" diff --git a/packages/core/bookingLimits/getBusyTimesFromLimts.ts b/packages/core/bookingLimits/getBusyTimesFromLimts.ts index bcadeb8f17775c..21310163e54918 100644 --- a/packages/core/bookingLimits/getBusyTimesFromLimts.ts +++ b/packages/core/bookingLimits/getBusyTimesFromLimts.ts @@ -207,7 +207,7 @@ const _getBusyTimesFromTeamLimits = async ( dateFrom: Dayjs, dateTo: Dayjs, teamId: number, - rescheduleUid?: string + rescheduleUid?: string | null ) => { performance.mark("teamLimitsStart"); @@ -220,6 +220,7 @@ const _getBusyTimesFromTeamLimits = async ( teamId, startDate, endDate, + excludedUid: rescheduleUid, }); const limitManager = new LimitManager(); diff --git a/packages/core/getUserAvailability.ts b/packages/core/getUserAvailability.ts index 20c6f91b194ac9..f5ead9ce5e0a83 100644 --- a/packages/core/getUserAvailability.ts +++ b/packages/core/getUserAvailability.ts @@ -295,7 +295,14 @@ const _getUserAvailability = async function getUsersWorkingHoursLifeTheUniverseA const busyTimesFromTeamLimits = teamBookingLimits && teamOfEventType - ? await getBusyTimesFromTeamLimits(user, teamBookingLimits, dateFrom, dateTo, teamOfEventType.id) + ? await getBusyTimesFromTeamLimits( + user, + teamBookingLimits, + dateFrom, + dateTo, + teamOfEventType.id, + initialData?.rescheduleUid + ) : []; // TODO: only query what we need after applying limits (shrink date range) diff --git a/packages/lib/server/repository/booking.ts b/packages/lib/server/repository/booking.ts index 54f6c6f1efeed0..ed6bfba2e7bb10 100644 --- a/packages/lib/server/repository/booking.ts +++ b/packages/lib/server/repository/booking.ts @@ -256,6 +256,7 @@ export class BookingRepository { teamId: number; startDate: Date; endDate: Date; + excludedUid?: string | null; returnCount: true; }): Promise; @@ -264,6 +265,7 @@ export class BookingRepository { teamId: number; startDate: Date; endDate: Date; + excludedUid?: string | null; }): Promise>; static async getAllAcceptedTeamBookingsOfUser(params: { @@ -271,9 +273,10 @@ export class BookingRepository { teamId: number; startDate: Date; endDate: Date; + excludedUid?: string | null; returnCount?: boolean; }) { - const { user, teamId, startDate, endDate, returnCount } = params; + const { user, teamId, startDate, endDate, returnCount, excludedUid } = params; const baseWhere: Prisma.BookingWhereInput = { status: BookingStatus.ACCEPTED, @@ -283,6 +286,11 @@ export class BookingRepository { endTime: { lte: endDate, }, + ...(excludedUid && { + uid: { + not: excludedUid, + }, + }), }; const whereCollectiveRoundRobinOwner: Prisma.BookingWhereInput = { From a83adce1edcb69a306b2a6e32cc3298e94c1aa8b Mon Sep 17 00:00:00 2001 From: CarinaWolli Date: Thu, 12 Sep 2024 16:08:54 -0400 Subject: [PATCH 15/41] add test for getSchedule --- apps/web/test/lib/getSchedule.test.ts | 157 +++++++++++++++++- .../utils/bookingScenario/bookingScenario.ts | 4 +- 2 files changed, 158 insertions(+), 3 deletions(-) diff --git a/apps/web/test/lib/getSchedule.test.ts b/apps/web/test/lib/getSchedule.test.ts index 4946c304f6582a..a1279c1547473b 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"; @@ -1151,6 +1151,161 @@ describe("getSchedule", () => { expect(availableSlotsInTz.filter((slot) => slot.format().startsWith(plus2DateString)).length).toBe(23); // 2 booking per day as limit, only one booking on that }); + 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("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 4fba412f1b78f1..82f9355241f92b 100644 --- a/apps/web/test/utils/bookingScenario/bookingScenario.ts +++ b/apps/web/test/utils/bookingScenario/bookingScenario.ts @@ -289,7 +289,7 @@ export async function addEventTypesToDb( if (eventType.team) { const createdTeam = await prismock.team.create({ - data: { id: eventType.team.id, bookingLimits: eventType.team.bookingLimits }, + data: { id: eventType.team?.id, bookingLimits: eventType.team?.bookingLimits }, }); await prismock.eventType.update({ @@ -1279,7 +1279,7 @@ export function getScenarioData( ...eventType, teamId: eventType.teamId || null, team: { - id: eventType.teamId ?? eventType.team.id, + id: eventType.teamId ?? eventType.team?.id, parentId: org ? org.id : null, bookingLimits: eventType?.team?.bookingLimits, }, From df750c794c8c403b92989aab537036a4df3fb2a5 Mon Sep 17 00:00:00 2001 From: CarinaWolli Date: Thu, 12 Sep 2024 16:48:54 -0400 Subject: [PATCH 16/41] fix type error --- packages/trpc/server/routers/viewer/teams/update.handler.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/trpc/server/routers/viewer/teams/update.handler.ts b/packages/trpc/server/routers/viewer/teams/update.handler.ts index 18a0ef4c4e3f07..34d7095379eef7 100644 --- a/packages/trpc/server/routers/viewer/teams/update.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/update.handler.ts @@ -57,7 +57,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { brandColor: input.brandColor, darkBrandColor: input.darkBrandColor, theme: input.theme, - bookingLimits: input.bookingLimits, + bookingLimits: input.bookingLimits ?? undefined, }; if (input.logo && input.logo.startsWith("data:image/png;base64,")) { From 9ebbdc94c6af6ebdcce78add9b6b133903751dc4 Mon Sep 17 00:00:00 2001 From: CarinaWolli Date: Thu, 12 Sep 2024 18:53:37 -0400 Subject: [PATCH 17/41] fix type error --- apps/web/test/utils/bookingScenario/bookingScenario.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/web/test/utils/bookingScenario/bookingScenario.ts b/apps/web/test/utils/bookingScenario/bookingScenario.ts index 82f9355241f92b..982a408adad25e 100644 --- a/apps/web/test/utils/bookingScenario/bookingScenario.ts +++ b/apps/web/test/utils/bookingScenario/bookingScenario.ts @@ -245,6 +245,7 @@ export async function addEventTypesToDb( // eslint-disable-next-line @typescript-eslint/no-explicit-any schedule?: any; metadata?: any; + team?: { id: number; bookingLimits: IntervalLimit }; })[] ) { log.silly("TestData: Add EventTypes to DB", JSON.stringify(eventTypes)); @@ -289,7 +290,11 @@ export async function addEventTypesToDb( if (eventType.team) { const createdTeam = await prismock.team.create({ - data: { id: eventType.team?.id, bookingLimits: eventType.team?.bookingLimits }, + data: { + id: eventType.team?.id, + bookingLimits: eventType.team?.bookingLimits, + name: "", + }, }); await prismock.eventType.update({ From a82f667cdfa1d805c91a286ed574e0ad86185208 Mon Sep 17 00:00:00 2001 From: CarinaWolli Date: Fri, 13 Sep 2024 15:01:16 -0400 Subject: [PATCH 18/41] fix type error --- apps/web/test/utils/bookingScenario/bookingScenario.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/test/utils/bookingScenario/bookingScenario.ts b/apps/web/test/utils/bookingScenario/bookingScenario.ts index 982a408adad25e..fd40ac37559cde 100644 --- a/apps/web/test/utils/bookingScenario/bookingScenario.ts +++ b/apps/web/test/utils/bookingScenario/bookingScenario.ts @@ -245,7 +245,7 @@ export async function addEventTypesToDb( // eslint-disable-next-line @typescript-eslint/no-explicit-any schedule?: any; metadata?: any; - team?: { id: number; bookingLimits: IntervalLimit }; + team?: { id?: number | null; bookingLimits?: IntervalLimit }; })[] ) { log.silly("TestData: Add EventTypes to DB", JSON.stringify(eventTypes)); From 0195d9a86ebd15c5bafc0f5d240b007b527a88c5 Mon Sep 17 00:00:00 2001 From: CarinaWolli Date: Fri, 13 Sep 2024 15:31:41 -0400 Subject: [PATCH 19/41] fix from and end date for fetching bookings --- apps/web/test/utils/bookingScenario/bookingScenario.ts | 2 +- packages/core/bookingLimits/getBusyTimesFromLimts.ts | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/apps/web/test/utils/bookingScenario/bookingScenario.ts b/apps/web/test/utils/bookingScenario/bookingScenario.ts index fd40ac37559cde..9af946276f25e3 100644 --- a/apps/web/test/utils/bookingScenario/bookingScenario.ts +++ b/apps/web/test/utils/bookingScenario/bookingScenario.ts @@ -288,7 +288,7 @@ export async function addEventTypesToDb( }); } - if (eventType.team) { + if (eventType.team?.id) { const createdTeam = await prismock.team.create({ data: { id: eventType.team?.id, diff --git a/packages/core/bookingLimits/getBusyTimesFromLimts.ts b/packages/core/bookingLimits/getBusyTimesFromLimts.ts index 21310163e54918..14ca008294fd74 100644 --- a/packages/core/bookingLimits/getBusyTimesFromLimts.ts +++ b/packages/core/bookingLimits/getBusyTimesFromLimts.ts @@ -211,9 +211,8 @@ const _getBusyTimesFromTeamLimits = async ( ) => { performance.mark("teamLimitsStart"); - const startDate = dayjs(dateFrom).startOf("week").toDate(); - const endDate = dayjs(dateTo).endOf("week").toDate(); - // maybe I already have them and can filter ? + const startDate = dayjs(dateFrom).startOf("month").startOf("week").toDate(); + const endDate = dayjs(dateTo).endOf("month").startOf("week").toDate(); const teamBookings = await BookingRepository.getAllAcceptedTeamBookingsOfUser({ user, From e1fb61b9980843994e000aade3f9e6b443243c17 Mon Sep 17 00:00:00 2001 From: CarinaWolli Date: Mon, 16 Sep 2024 18:36:25 -0400 Subject: [PATCH 20/41] reuse functions --- .../bookingLimits/getBusyTimesFromLimts.ts | 105 ++++++++---------- packages/core/getBusyTimes.ts | 44 +++++--- packages/lib/server/checkBookingLimits.ts | 47 +++++--- 3 files changed, 106 insertions(+), 90 deletions(-) diff --git a/packages/core/bookingLimits/getBusyTimesFromLimts.ts b/packages/core/bookingLimits/getBusyTimesFromLimts.ts index 14ca008294fd74..99661f931b893c 100644 --- a/packages/core/bookingLimits/getBusyTimesFromLimts.ts +++ b/packages/core/bookingLimits/getBusyTimesFromLimts.ts @@ -7,6 +7,7 @@ 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"; @@ -35,14 +36,14 @@ const _getBusyTimesFromLimits = async ( // run this first, as counting bookings should always run faster.. if (bookingLimits) { performance.mark("bookingLimitsStart"); - await getBusyTimesFromBookingLimits( + await getBusyTimesFromBookingLimits({ bookings, bookingLimits, dateFrom, dateTo, - eventType.id, - limitManager - ); + eventTypeId: eventType.id, + limitManager, + }); performance.mark("bookingLimitsEnd"); performance.measure(`checking booking limits took $1'`, "bookingLimitsStart", "bookingLimitsEnd"); } @@ -75,14 +76,18 @@ const getBusyTimesFromBookingLimits = async ( return monitorCallbackAsync(_getBusyTimesFromBookingLimits, ...args); }; -const _getBusyTimesFromBookingLimits = async ( - bookings: EventBusyDetails[], - bookingLimits: IntervalLimit, - dateFrom: Dayjs, - dateTo: Dayjs, - eventTypeId: number, - limitManager: LimitManager -) => { +const _getBusyTimesFromBookingLimits = async (params: { + bookings: EventBusyDetails[]; + bookingLimits: IntervalLimit; + dateFrom: Dayjs; + dateTo: Dayjs; + limitManager: LimitManager; + eventTypeId?: number; + teamId?: number; + user?: { id: number; email: string }; +}) => { + const { bookings, bookingLimits, dateFrom, dateTo, limitManager, eventTypeId, teamId, user } = params; + for (const key of descendingLimitKeys) { const limit = bookingLimits?.[key]; if (!limit) continue; @@ -101,6 +106,8 @@ const _getBusyTimesFromBookingLimits = async ( limitingNumber: limit, eventId: eventTypeId, key, + teamId, + user, }); } catch (_) { limitManager.addBusyTime(periodStart, unit); @@ -209,63 +216,39 @@ const _getBusyTimesFromTeamLimits = async ( teamId: number, rescheduleUid?: string | null ) => { - performance.mark("teamLimitsStart"); - - const startDate = dayjs(dateFrom).startOf("month").startOf("week").toDate(); - const endDate = dayjs(dateTo).endOf("month").startOf("week").toDate(); + const { limitDateFrom, limitDateTo } = getStartEndDateforLimitCheck( + dateFrom.toISOString(), + dateTo.toISOString(), + bookingLimits + ); - const teamBookings = await BookingRepository.getAllAcceptedTeamBookingsOfUser({ + const bookings = await BookingRepository.getAllAcceptedTeamBookingsOfUser({ user, teamId, - startDate, - endDate, + startDate: limitDateFrom.toDate(), + endDate: limitDateTo.toDate(), excludedUid: rescheduleUid, }); - const limitManager = new LimitManager(); - - for (const key of descendingLimitKeys) { - const limit = bookingLimits?.[key]; - if (!limit) continue; + 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 unit = intervalLimitKeyToUnit(key); - const periodStartDates = getPeriodStartDatesBetween(dateFrom, dateTo, unit); - - for (const periodStart of periodStartDates) { - if (limitManager.isAlreadyBusy(periodStart, unit)) continue; - // check if an entry already exists with th - const periodEnd = periodStart.endOf(unit); - - if (unit === "year") { - const bookingsInPeriod = await BookingRepository.getAllAcceptedTeamBookingsOfUser({ - user: { id: user.id, email: user.email }, - teamId, - startDate: periodStart.toDate(), - endDate: periodEnd.toDate(), - returnCount: true, - }); + const limitManager = new LimitManager(); - if (bookingsInPeriod >= limit) { - limitManager.addBusyTime(periodStart, unit); - } - } else { - let totalBookings = 0; - for (const booking of teamBookings) { - // consider booking part of period independent of end date - if (!dayjs(booking.startTime).isBetween(periodStart, periodEnd)) { - continue; - } - totalBookings++; - if (totalBookings >= limit) { - limitManager.addBusyTime(periodStart, unit); - break; - } - } - } - } - } + getBusyTimesFromBookingLimits({ + bookings: busyTimes, + bookingLimits, + dateFrom, + dateTo, + limitManager, + teamId, + user, + }); - performance.mark("teamLimitsEnd"); - performance.measure(`checking all team limits took $1'`, "teamLimitsStart", "teamLimitsEnd"); return limitManager.getBusyTimes(); }; 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/lib/server/checkBookingLimits.ts b/packages/lib/server/checkBookingLimits.ts index ca67f4f7fd58e7..2506a087b58edf 100644 --- a/packages/lib/server/checkBookingLimits.ts +++ b/packages/lib/server/checkBookingLimits.ts @@ -46,11 +46,13 @@ export async function checkBookingLimit({ timeZone, }: { 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 +64,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.toDate(), + endDate: endDate.toDate(), + 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; From 54d657a8b6e956940feed473901ce47dcd0d2f90 Mon Sep 17 00:00:00 2001 From: CarinaWolli Date: Mon, 16 Sep 2024 18:40:02 -0400 Subject: [PATCH 21/41] allow null for bookingLimits --- packages/trpc/server/routers/viewer/teams/update.schema.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/trpc/server/routers/viewer/teams/update.schema.ts b/packages/trpc/server/routers/viewer/teams/update.schema.ts index dafa3b252627b3..9864621217dad0 100644 --- a/packages/trpc/server/routers/viewer/teams/update.schema.ts +++ b/packages/trpc/server/routers/viewer/teams/update.schema.ts @@ -23,7 +23,7 @@ export const ZUpdateInputSchema = z.object({ brandColor: z.string().optional(), darkBrandColor: z.string().optional(), theme: z.string().optional().nullable(), - bookingLimits: intervalLimitsType.optional(), + bookingLimits: intervalLimitsType.optional().nullable(), }); export type TUpdateInputSchema = z.infer; From b6b8022ad16e50ad8c56ca505f54dc0a90b412b1 Mon Sep 17 00:00:00 2001 From: CarinaWolli Date: Mon, 16 Sep 2024 18:43:58 -0400 Subject: [PATCH 22/41] remove bookings from managed event type --- packages/lib/server/repository/booking.ts | 27 ++--------------------- 1 file changed, 2 insertions(+), 25 deletions(-) diff --git a/packages/lib/server/repository/booking.ts b/packages/lib/server/repository/booking.ts index ed6bfba2e7bb10..72c2af12235307 100644 --- a/packages/lib/server/repository/booking.ts +++ b/packages/lib/server/repository/booking.ts @@ -313,16 +313,6 @@ export class BookingRepository { }, }; - const whereManagedBookings: Prisma.BookingWhereInput = { - ...baseWhere, - userId: user.id, - eventType: { - parent: { - teamId, - }, - }, - }; - if (returnCount) { const collectiveRoundRobinBookingsOwner = await prisma.booking.count({ where: whereCollectiveRoundRobinOwner, @@ -332,12 +322,7 @@ export class BookingRepository { where: whereCollectiveRoundRobinBookingsAttendee, }); - const managedBookings = await prisma.booking.count({ - where: whereManagedBookings, - }); - - const totalNrOfBooking = - collectiveRoundRobinBookingsOwner + collectiveRoundRobinBookingsAttendee + managedBookings; + const totalNrOfBooking = collectiveRoundRobinBookingsOwner + collectiveRoundRobinBookingsAttendee; return totalNrOfBooking; } @@ -349,14 +334,6 @@ export class BookingRepository { where: whereCollectiveRoundRobinBookingsAttendee, }); - const managedBookings = await prisma.booking.findMany({ - where: whereManagedBookings, - }); - - return [ - ...collectiveRoundRobinBookingsOwner, - ...collectiveRoundRobinBookingsAttendee, - ...managedBookings, - ]; + return [...collectiveRoundRobinBookingsOwner, ...collectiveRoundRobinBookingsAttendee]; } } From 8ad7270778ef5d469695021d80226cc744e091b2 Mon Sep 17 00:00:00 2001 From: CarinaWolli Date: Mon, 16 Sep 2024 19:02:06 -0400 Subject: [PATCH 23/41] fix type error --- packages/lib/server/checkBookingLimits.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/lib/server/checkBookingLimits.ts b/packages/lib/server/checkBookingLimits.ts index 2506a087b58edf..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,6 +45,8 @@ export async function checkBookingLimit({ limitingNumber, rescheduleUid, timeZone, + teamId, + user, }: { eventStartDate: Date; eventId?: number; @@ -70,8 +73,8 @@ export async function checkBookingLimit({ bookingsInPeriod = await BookingRepository.getAllAcceptedTeamBookingsOfUser({ user: { id: user.id, email: user.email }, teamId, - startDate: startDate.toDate(), - endDate: endDate.toDate(), + startDate: startDate, + endDate: endDate, returnCount: true, excludedUid: rescheduleUid, }); From ad8c9d5fbf8c9265572e81ac29ce41e9039a7848 Mon Sep 17 00:00:00 2001 From: CarinaWolli Date: Mon, 16 Sep 2024 20:16:17 -0400 Subject: [PATCH 24/41] code clean up --- ...mesFromLimts.ts => getBusyTimesFromLimits.ts} | 0 packages/core/getUserAvailability.ts | 16 +++------------- .../lib/handleNewBooking/getEventTypesFromDB.ts | 6 ------ .../server/routers/viewer/teams/update.schema.ts | 2 +- 4 files changed, 4 insertions(+), 20 deletions(-) rename packages/core/bookingLimits/{getBusyTimesFromLimts.ts => getBusyTimesFromLimits.ts} (100%) diff --git a/packages/core/bookingLimits/getBusyTimesFromLimts.ts b/packages/core/bookingLimits/getBusyTimesFromLimits.ts similarity index 100% rename from packages/core/bookingLimits/getBusyTimesFromLimts.ts rename to packages/core/bookingLimits/getBusyTimesFromLimits.ts diff --git a/packages/core/getUserAvailability.ts b/packages/core/getUserAvailability.ts index f5ead9ce5e0a83..55b4dbb28a13dc 100644 --- a/packages/core/getUserAvailability.ts +++ b/packages/core/getUserAvailability.ts @@ -59,16 +59,6 @@ const _getEventType = async (id: number) => { bookingLimits: true, }, }, - parent: { - select: { - team: { - select: { - id: true, - bookingLimits: true, - }, - }, - }, - }, hosts: { select: { user: { @@ -290,11 +280,11 @@ const _getUserAvailability = async function getUsersWorkingHoursLifeTheUniverseA initialData?.busyTimesFromLimitsBookings ?? [] ) : []; - const teamOfEventType = eventType?.team ?? eventType?.parent?.team; - const teamBookingLimits = parseBookingLimit(teamOfEventType?.bookingLimits); + const teamOfEventType = eventType?.team; + const teamBookingLimits = parseBookingLimit(eventType?.team); const busyTimesFromTeamLimits = - teamBookingLimits && teamOfEventType + eventType?.team && teamBookingLimits ? await getBusyTimesFromTeamLimits( user, teamBookingLimits, diff --git a/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts b/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts index 3e9cffa27a3644..3204e82c7bfc10 100644 --- a/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts +++ b/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts @@ -72,12 +72,6 @@ export const getEventTypesFromDB = async (eventTypeId: number) => { parent: { select: { teamId: true, - team: { - select: { - id: true, - bookingLimits: true, - }, - }, }, }, useEventTypeDestinationCalendarEmail: true, diff --git a/packages/trpc/server/routers/viewer/teams/update.schema.ts b/packages/trpc/server/routers/viewer/teams/update.schema.ts index 9864621217dad0..5b61a0496d497b 100644 --- a/packages/trpc/server/routers/viewer/teams/update.schema.ts +++ b/packages/trpc/server/routers/viewer/teams/update.schema.ts @@ -23,7 +23,7 @@ export const ZUpdateInputSchema = z.object({ brandColor: z.string().optional(), darkBrandColor: z.string().optional(), theme: z.string().optional().nullable(), - bookingLimits: intervalLimitsType.optional().nullable(), + bookingLimits: intervalLimitsType.nullable().optional(), }); export type TUpdateInputSchema = z.infer; From 9f7989bc6915720a7ff59daa3db0531b382cc25f Mon Sep 17 00:00:00 2001 From: CarinaWolli Date: Mon, 16 Sep 2024 20:27:12 -0400 Subject: [PATCH 25/41] small fixes form clean up --- packages/core/getUserAvailability.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/core/getUserAvailability.ts b/packages/core/getUserAvailability.ts index 55b4dbb28a13dc..9bcf56f7b00d37 100644 --- a/packages/core/getUserAvailability.ts +++ b/packages/core/getUserAvailability.ts @@ -20,7 +20,7 @@ import { EventTypeMetaDataSchema, stringToDayjsZod } from "@calcom/prisma/zod-ut import type { EventBusyDetails, IntervalLimitUnit } from "@calcom/types/Calendar"; import type { TimeRange } from "@calcom/types/schedule"; -import { getBusyTimesFromLimits, getBusyTimesFromTeamLimits } from "./bookingLimits/getBusyTimesFromLimts"; +import { getBusyTimesFromLimits, getBusyTimesFromTeamLimits } from "./bookingLimits/getBusyTimesFromLimits"; import { getBusyTimes } from "./getBusyTimes"; import monitorCallbackAsync, { monitorCallbackSync } from "./sentryWrapper"; @@ -280,7 +280,7 @@ const _getUserAvailability = async function getUsersWorkingHoursLifeTheUniverseA initialData?.busyTimesFromLimitsBookings ?? [] ) : []; - const teamOfEventType = eventType?.team; + const teamBookingLimits = parseBookingLimit(eventType?.team); const busyTimesFromTeamLimits = @@ -290,7 +290,7 @@ const _getUserAvailability = async function getUsersWorkingHoursLifeTheUniverseA teamBookingLimits, dateFrom, dateTo, - teamOfEventType.id, + eventType?.team.id, initialData?.rescheduleUid ) : []; From 7341ee1fd40225ffc4d7df2bb9bf8e5d28ff09c6 Mon Sep 17 00:00:00 2001 From: CarinaWolli Date: Tue, 17 Sep 2024 14:23:45 +0000 Subject: [PATCH 26/41] fix type issue --- apps/api/v1/lib/validations/team.ts | 1 + apps/api/v1/pages/api/teams/[teamId]/_patch.ts | 2 ++ packages/trpc/server/routers/viewer/teams/update.schema.ts | 2 +- 3 files changed, 4 insertions(+), 1 deletion(-) 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/packages/trpc/server/routers/viewer/teams/update.schema.ts b/packages/trpc/server/routers/viewer/teams/update.schema.ts index 5b61a0496d497b..dafa3b252627b3 100644 --- a/packages/trpc/server/routers/viewer/teams/update.schema.ts +++ b/packages/trpc/server/routers/viewer/teams/update.schema.ts @@ -23,7 +23,7 @@ export const ZUpdateInputSchema = z.object({ brandColor: z.string().optional(), darkBrandColor: z.string().optional(), theme: z.string().optional().nullable(), - bookingLimits: intervalLimitsType.nullable().optional(), + bookingLimits: intervalLimitsType.optional(), }); export type TUpdateInputSchema = z.infer; From 5d4b3ace2faf0cf6321f89605d4df535e3e4453c Mon Sep 17 00:00:00 2001 From: CarinaWolli Date: Tue, 17 Sep 2024 10:59:35 -0400 Subject: [PATCH 27/41] same fixes in teams/_post --- apps/api/v1/pages/api/teams/_post.ts | 2 ++ 1 file changed, 2 insertions(+) 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, }; From 8c351c78c7228be8fb7f4c7805b10995cad0b6ed Mon Sep 17 00:00:00 2001 From: CarinaWolli Date: Tue, 17 Sep 2024 15:35:31 +0000 Subject: [PATCH 28/41] fix existing tz issue --- packages/core/getUserAvailability.ts | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/core/getUserAvailability.ts b/packages/core/getUserAvailability.ts index 9bcf56f7b00d37..4e221488afa9d4 100644 --- a/packages/core/getUserAvailability.ts +++ b/packages/core/getUserAvailability.ts @@ -265,6 +265,14 @@ const _getUserAvailability = async function getUsersWorkingHoursLifeTheUniverseA currentSeats = await getCurrentSeats(eventType, dateFrom, dateTo); } + const userSchedule = user.schedules.filter( + (schedule) => !user?.defaultScheduleId || schedule.id === user?.defaultScheduleId + )[0]; + + const schedule = eventType?.schedule ? eventType.schedule : userSchedule; + + const timeZone = schedule?.timeZone || eventType?.timeZone || user.timeZone; + const bookingLimits = parseBookingLimit(eventType?.bookingLimits); const durationLimits = parseDurationLimit(eventType?.durationLimits); @@ -273,23 +281,23 @@ const _getUserAvailability = async function getUsersWorkingHoursLifeTheUniverseA ? await getBusyTimesFromLimits( bookingLimits, durationLimits, - dateFrom, - dateTo, + dateFrom.tz(timeZone), + dateTo.tz(timeZone), duration, eventType, initialData?.busyTimesFromLimitsBookings ?? [] ) : []; - const teamBookingLimits = parseBookingLimit(eventType?.team); + const teamBookingLimits = parseBookingLimit(eventType?.team?.bookingLimits); const busyTimesFromTeamLimits = eventType?.team && teamBookingLimits ? await getBusyTimesFromTeamLimits( user, teamBookingLimits, - dateFrom, - dateTo, + dateFrom.tz(timeZone), + dateTo.tz(timeZone), eventType?.team.id, initialData?.rescheduleUid ) @@ -328,12 +336,6 @@ const _getUserAvailability = async function getUsersWorkingHoursLifeTheUniverseA ...busyTimesFromTeamLimits, ]; - const userSchedule = user.schedules.filter( - (schedule) => !user?.defaultScheduleId || schedule.id === user?.defaultScheduleId - )[0]; - - const schedule = eventType?.schedule ? eventType.schedule : userSchedule; - const isDefaultSchedule = userSchedule && userSchedule.id === schedule.id; log.debug( @@ -347,8 +349,6 @@ const _getUserAvailability = async function getUsersWorkingHoursLifeTheUniverseA const startGetWorkingHours = performance.now(); - const timeZone = schedule?.timeZone || eventType?.timeZone || user.timeZone; - if ( !(schedule?.availability || (eventType?.availability.length ? eventType.availability : user.availability)) ) { From e798a4527e5e5f099c40d0fc08f8c658682081a6 Mon Sep 17 00:00:00 2001 From: CarinaWolli Date: Tue, 17 Sep 2024 16:04:38 +0000 Subject: [PATCH 29/41] tests for fix --- apps/web/test/lib/getSchedule.test.ts | 166 ++++++++++++++++++++++++++ 1 file changed, 166 insertions(+) diff --git a/apps/web/test/lib/getSchedule.test.ts b/apps/web/test/lib/getSchedule.test.ts index 3088852fbcc607..14f2387f58a9f4 100644 --- a/apps/web/test/lib/getSchedule.test.ts +++ b/apps/web/test/lib/getSchedule.test.ts @@ -1151,6 +1151,87 @@ describe("getSchedule", () => { expect(availableSlotsInTz.filter((slot) => slot.format().startsWith(plus2DateString)).length).toBe(23); // 2 booking per day as limit, only one booking on that }); + test("test that booking limit is working correctly if user is all day available and attendee is in different timezone than host", 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, + bookingLimits: { + PER_DAY: 1, + }, + 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"], + }, + ], + }, + ], + // One bookings for each(E1 and E2) on plus2Date + 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("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 }); @@ -1306,6 +1387,91 @@ describe("getSchedule", () => { 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 }); From e025f51a98d0534353f031a7ae3cdb09c3004366 Mon Sep 17 00:00:00 2001 From: CarinaWolli Date: Tue, 17 Sep 2024 17:19:05 +0000 Subject: [PATCH 30/41] adds missingn await --- packages/core/bookingLimits/getBusyTimesFromLimits.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/bookingLimits/getBusyTimesFromLimits.ts b/packages/core/bookingLimits/getBusyTimesFromLimits.ts index 99661f931b893c..0e9a78e381997a 100644 --- a/packages/core/bookingLimits/getBusyTimesFromLimits.ts +++ b/packages/core/bookingLimits/getBusyTimesFromLimits.ts @@ -240,7 +240,7 @@ const _getBusyTimesFromTeamLimits = async ( const limitManager = new LimitManager(); - getBusyTimesFromBookingLimits({ + await getBusyTimesFromBookingLimits({ bookings: busyTimes, bookingLimits, dateFrom, From c3a7d5b93dcf8c263e8cd76c021e67eaefdd2ef7 Mon Sep 17 00:00:00 2001 From: CarinaWolli Date: Tue, 17 Sep 2024 18:08:46 +0000 Subject: [PATCH 31/41] imrove description --- apps/web/public/static/locales/en/common.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 11e3d46baf1a00..3a9f48cc9f52b4 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -2609,7 +2609,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", + "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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" From 89d578b48d1efd6163745c7142168407712edcce Mon Sep 17 00:00:00 2001 From: CarinaWolli Date: Tue, 17 Sep 2024 18:59:52 +0000 Subject: [PATCH 32/41] remove spreading --- apps/web/test/utils/bookingScenario/bookingScenario.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/web/test/utils/bookingScenario/bookingScenario.ts b/apps/web/test/utils/bookingScenario/bookingScenario.ts index 8b6ef0dcc479ff..0cf22aa603759c 100644 --- a/apps/web/test/utils/bookingScenario/bookingScenario.ts +++ b/apps/web/test/utils/bookingScenario/bookingScenario.ts @@ -251,9 +251,7 @@ export async function addEventTypesToDb( ) { log.silly("TestData: Add EventTypes to DB", JSON.stringify(eventTypes)); await prismock.eventType.createMany({ - data: eventTypes.map((et) => ({ - ...et, - })), + data: eventTypes, }); const allEventTypes = await prismock.eventType.findMany({ include: { From 3400b03bfdec5da49e0aa4570442ffd942377851 Mon Sep 17 00:00:00 2001 From: CarinaWolli Date: Wed, 18 Sep 2024 15:30:51 +0000 Subject: [PATCH 33/41] fix reschedule issue with booking limits --- .../bookingLimits/getBusyTimesFromLimits.ts | 27 +++++++++++++++---- packages/core/getUserAvailability.ts | 3 ++- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/packages/core/bookingLimits/getBusyTimesFromLimits.ts b/packages/core/bookingLimits/getBusyTimesFromLimits.ts index 0e9a78e381997a..0360506d64dd2b 100644 --- a/packages/core/bookingLimits/getBusyTimesFromLimits.ts +++ b/packages/core/bookingLimits/getBusyTimesFromLimits.ts @@ -26,7 +26,8 @@ const _getBusyTimesFromLimits = async ( dateTo: Dayjs, duration: number | undefined, eventType: NonNullable, - bookings: EventBusyDetails[] + bookings: EventBusyDetails[], + rescheduleUid?: string ) => { performance.mark("limitsStart"); @@ -43,6 +44,7 @@ const _getBusyTimesFromLimits = async ( dateTo, eventTypeId: eventType.id, limitManager, + rescheduleUid, }); performance.mark("bookingLimitsEnd"); performance.measure(`checking booking limits took $1'`, "bookingLimitsStart", "bookingLimitsEnd"); @@ -58,7 +60,8 @@ const _getBusyTimesFromLimits = async ( dateTo, duration, eventType, - limitManager + limitManager, + rescheduleUid ); performance.mark("durationLimitsEnd"); performance.measure(`checking duration limits took $1'`, "durationLimitsStart", "durationLimitsEnd"); @@ -82,11 +85,22 @@ const _getBusyTimesFromBookingLimits = async (params: { 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 } = params; + const { + bookings, + bookingLimits, + dateFrom, + dateTo, + limitManager, + eventTypeId, + teamId, + user, + rescheduleUid, + } = params; for (const key of descendingLimitKeys) { const limit = bookingLimits?.[key]; @@ -108,6 +122,7 @@ const _getBusyTimesFromBookingLimits = async (params: { key, teamId, user, + rescheduleUid, }); } catch (_) { limitManager.addBusyTime(periodStart, unit); @@ -149,7 +164,8 @@ const _getBusyTimesFromDurationLimits = async ( dateTo: Dayjs, duration: number | undefined, eventType: NonNullable, - limitManager: LimitManager + limitManager: LimitManager, + rescheduleUid?: string | null // test if this already works, probably not ) => { for (const key of descendingLimitKeys) { const limit = durationLimits?.[key]; @@ -214,7 +230,7 @@ const _getBusyTimesFromTeamLimits = async ( dateFrom: Dayjs, dateTo: Dayjs, teamId: number, - rescheduleUid?: string | null + rescheduleUid?: string ) => { const { limitDateFrom, limitDateTo } = getStartEndDateforLimitCheck( dateFrom.toISOString(), @@ -246,6 +262,7 @@ const _getBusyTimesFromTeamLimits = async ( dateFrom, dateTo, limitManager, + rescheduleUid, teamId, user, }); diff --git a/packages/core/getUserAvailability.ts b/packages/core/getUserAvailability.ts index 4e221488afa9d4..963e8a9f08238f 100644 --- a/packages/core/getUserAvailability.ts +++ b/packages/core/getUserAvailability.ts @@ -285,7 +285,8 @@ const _getUserAvailability = async function getUsersWorkingHoursLifeTheUniverseA dateTo.tz(timeZone), duration, eventType, - initialData?.busyTimesFromLimitsBookings ?? [] + initialData?.busyTimesFromLimitsBookings ?? [], + initialData?.rescheduleUid ?? undefined ) : []; From fb7d7e3791abe5459ee06ea54e8d90d5ac3f0f47 Mon Sep 17 00:00:00 2001 From: CarinaWolli Date: Wed, 18 Sep 2024 16:12:54 +0000 Subject: [PATCH 34/41] fix reschedule error with booking durations --- .../bookingLimits/getBusyTimesFromLimits.ts | 3 ++- packages/core/getUserAvailability.ts | 2 +- .../features/bookings/lib/handleNewBooking.ts | 7 ++++++- packages/lib/server/checkDurationLimits.ts | 20 ++++++++++++++++--- packages/lib/server/queries/booking/index.ts | 19 ++++++++++++++++-- 5 files changed, 43 insertions(+), 8 deletions(-) diff --git a/packages/core/bookingLimits/getBusyTimesFromLimits.ts b/packages/core/bookingLimits/getBusyTimesFromLimits.ts index 0360506d64dd2b..91b2f8e16a11c2 100644 --- a/packages/core/bookingLimits/getBusyTimesFromLimits.ts +++ b/packages/core/bookingLimits/getBusyTimesFromLimits.ts @@ -165,7 +165,7 @@ const _getBusyTimesFromDurationLimits = async ( duration: number | undefined, eventType: NonNullable, limitManager: LimitManager, - rescheduleUid?: string | null // test if this already works, probably not + rescheduleUid?: string ) => { for (const key of descendingLimitKeys) { const limit = durationLimits?.[key]; @@ -190,6 +190,7 @@ const _getBusyTimesFromDurationLimits = async ( eventId: eventType.id, startDate: periodStart.toDate(), endDate: periodStart.endOf(unit).toDate(), + rescheduleUid, }); if (totalYearlyDuration + selectedDuration > limit) { limitManager.addBusyTime(periodStart, unit); diff --git a/packages/core/getUserAvailability.ts b/packages/core/getUserAvailability.ts index 963e8a9f08238f..a703f732e68cdb 100644 --- a/packages/core/getUserAvailability.ts +++ b/packages/core/getUserAvailability.ts @@ -300,7 +300,7 @@ const _getUserAvailability = async function getUsersWorkingHoursLifeTheUniverseA dateFrom.tz(timeZone), dateTo.tz(timeZone), eventType?.team.id, - initialData?.rescheduleUid + initialData?.rescheduleUid ?? undefined ) : []; diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index 066bb65ef2aa3f..358bf6489b0143 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/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; }; From 413dcba1a1f9404a55e45fa82617d513ad502187 Mon Sep 17 00:00:00 2001 From: CarinaWolli Date: Wed, 18 Sep 2024 19:46:17 +0000 Subject: [PATCH 35/41] remove useeffect --- .../ee/teams/pages/team-booking-limits-view.tsx | 10 ---------- 1 file changed, 10 deletions(-) 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 1237f90137d1a1..006abf561461a7 100644 --- a/packages/features/ee/teams/pages/team-booking-limits-view.tsx +++ b/packages/features/ee/teams/pages/team-booking-limits-view.tsx @@ -1,7 +1,6 @@ "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"; @@ -132,15 +131,6 @@ const BookingLimitsViewWrapper = () => { } ); - useEffect( - function refactorMeWithoutEffect() { - if (error) { - router.replace("/teams"); - } - }, - [error] - ); - if (isPending) return ( Date: Wed, 18 Sep 2024 19:49:50 +0000 Subject: [PATCH 36/41] undo commit --- .../ee/teams/pages/team-booking-limits-view.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) 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 006abf561461a7..1237f90137d1a1 100644 --- a/packages/features/ee/teams/pages/team-booking-limits-view.tsx +++ b/packages/features/ee/teams/pages/team-booking-limits-view.tsx @@ -1,6 +1,7 @@ "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"; @@ -131,6 +132,15 @@ const BookingLimitsViewWrapper = () => { } ); + useEffect( + function refactorMeWithoutEffect() { + if (error) { + router.replace("/teams"); + } + }, + [error] + ); + if (isPending) return ( Date: Wed, 18 Sep 2024 17:33:03 -0400 Subject: [PATCH 37/41] add bookingLimits to UpdateOrgTeamDto --- .../organizations/inputs/update-organization-team.input.ts | 4 ++++ 1 file changed, 4 insertions(+) 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; } From 1e86cad61ffb278aae72b472546ed0a82cfbde94 Mon Sep 17 00:00:00 2001 From: CarinaWolli Date: Mon, 23 Sep 2024 09:51:46 -0400 Subject: [PATCH 38/41] fix unit tests --- .../global-booking-limits.test.ts | 40 +++++++++++-------- 1 file changed, 24 insertions(+), 16 deletions(-) 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 b7d303063f29d9..44cc34066329fa 100644 --- a/packages/features/bookings/lib/handleNewBooking/global-booking-limits.test.ts +++ b/packages/features/bookings/lib/handleNewBooking/global-booking-limits.test.ts @@ -150,10 +150,12 @@ describe( const createdBooking = await handleNewBooking(req1); - expect(createdBooking.responses).toContain({ - email: booker.email, - name: booker.name, - }); + expect(createdBooking.responses).toEqual( + expect.objectContaining({ + email: booker.email, + name: booker.name, + }) + ); const mockBookingData2 = getMockRequestDataForBooking({ data: { @@ -239,10 +241,12 @@ describe( const createdBooking = await handleNewBooking(req1); - expect(createdBooking.responses).toContain({ - email: booker.email, - name: booker.name, - }); + expect(createdBooking.responses).toEqual( + expect.objectContaining({ + email: booker.email, + name: booker.name, + }) + ); const mockBookingData2 = getMockRequestDataForBooking({ data: { @@ -343,10 +347,12 @@ describe( const createdBooking = await handleNewBooking(req1); - expect(createdBooking.responses).toContain({ - email: booker.email, - name: booker.name, - }); + expect(createdBooking.responses).toEqual( + expect.objectContaining({ + email: booker.email, + name: booker.name, + }) + ); const mockBookingData2 = getMockRequestDataForBooking({ data: { @@ -440,10 +446,12 @@ describe( const createdBooking = await handleNewBooking(req1); - expect(createdBooking.responses).toContain({ - email: booker.email, - name: booker.name, - }); + expect(createdBooking.responses).toEqual( + expect.objectContaining({ + email: booker.email, + name: booker.name, + }) + ); const mockBookingData2 = getMockRequestDataForBooking({ data: { From 8c337cd3bbbaf6efb5dfa75f777860bc7e90b651 Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung Date: Mon, 23 Sep 2024 10:12:46 -0400 Subject: [PATCH 39/41] Prepare view for app router migration --- .../settings/teams/[id]/bookingLimits.tsx | 19 +++++++++++++++++-- .../teams/pages/team-booking-limits-view.tsx | 11 +---------- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/apps/web/pages/settings/teams/[id]/bookingLimits.tsx b/apps/web/pages/settings/teams/[id]/bookingLimits.tsx index f9eda053ab8cd8..18b98824936246 100644 --- a/apps/web/pages/settings/teams/[id]/bookingLimits.tsx +++ b/apps/web/pages/settings/teams/[id]/bookingLimits.tsx @@ -1,9 +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 type { CalPageWrapper } from "@components/PageWrapper"; import PageWrapper from "@components/PageWrapper"; -const Page = TeamBookingLimitsView as CalPageWrapper; +const Page = () => { + const { t } = useLocale(); + return ( + <> + + + + ); +}; +Page.getLayout = getLayout; Page.PageWrapper = PageWrapper; export default Page; 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 1237f90137d1a1..84e72cfddba6c5 100644 --- a/packages/features/ee/teams/pages/team-booking-limits-view.tsx +++ b/packages/features/ee/teams/pages/team-booking-limits-view.tsx @@ -14,9 +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, Meta, SettingsToggle, showToast } from "@calcom/ui"; - -import { getLayout } from "../../../settings/layouts/SettingsLayout"; +import { Button, Form, SettingsToggle, showToast } from "@calcom/ui"; type ProfileViewProps = { team: RouterOutputs["viewer"]["teams"]["getMinimal"] }; @@ -53,11 +51,6 @@ const BookingLimitsView = ({ team }: ProfileViewProps) => { return ( <> - {isAdmin ? ( <> { return ; }; -BookingLimitsViewWrapper.getLayout = getLayout; - export default BookingLimitsViewWrapper; From 014ba85df1686e43848f25777ff430733d02f97c Mon Sep 17 00:00:00 2001 From: CarinaWolli Date: Mon, 23 Sep 2024 10:39:29 -0400 Subject: [PATCH 40/41] throw error if not in ascending order --- .../features/ee/teams/pages/team-booking-limits-view.tsx | 6 +++++- .../trpc/server/routers/viewer/teams/update.handler.ts | 7 +++++++ 2 files changed, 12 insertions(+), 1 deletion(-) 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 84e72cfddba6c5..8d367ecd6af396 100644 --- a/packages/features/ee/teams/pages/team-booking-limits-view.tsx +++ b/packages/features/ee/teams/pages/team-booking-limits-view.tsx @@ -7,7 +7,7 @@ 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 } from "@calcom/lib"; +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"; @@ -56,6 +56,10 @@ const BookingLimitsView = ({ team }: ProfileViewProps) => { { + if (values.bookingLimits) { + const isValid = validateIntervalLimitOrder(values.bookingLimits); + if (!isValid) throw new Error(t("event_setup_booking_limits_error")); + } mutation.mutate({ ...values, id: team.id }); }}> { 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, From ab66400ed3a8b458f7cb2a6ed883a53077f973f7 Mon Sep 17 00:00:00 2001 From: CarinaWolli Date: Mon, 23 Sep 2024 12:48:09 -0400 Subject: [PATCH 41/41] fix disabled update button --- .../features/ee/teams/pages/team-booking-limits-view.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 8d367ecd6af396..5a8be9a29f831d 100644 --- a/packages/features/ee/teams/pages/team-booking-limits-view.tsx +++ b/packages/features/ee/teams/pages/team-booking-limits-view.tsx @@ -58,7 +58,10 @@ const BookingLimitsView = ({ team }: ProfileViewProps) => { handleSubmit={(values) => { if (values.bookingLimits) { const isValid = validateIntervalLimitOrder(values.bookingLimits); - if (!isValid) throw new Error(t("event_setup_booking_limits_error")); + if (!isValid) { + reset(); + throw new Error(t("event_setup_booking_limits_error")); + } } mutation.mutate({ ...values, id: team.id }); }}>