From 297a9655080a9b2ae48e882cadaf91a06f99e14c Mon Sep 17 00:00:00 2001 From: Alex van Andel Date: Mon, 15 Aug 2022 20:52:01 +0100 Subject: [PATCH] Bugfix/i3531 round robin assigns to unavailable members (#3813) * wip commit * Finished new algorithm for fetching the least recently booked user * ROUND_ROBIN fix * Removed redundant import * Prisma dependency turned getLuckyUser into a server-only function * DRY avatars * Properly passThrough * name can be undefined. * Remove debug artefact --- .../web/components/booking/AvailableTimes.tsx | 10 - apps/web/components/booking/UserAvatars.tsx | 34 ++ .../booking/pages/AvailabilityPage.tsx | 43 +-- .../components/booking/pages/BookingPage.tsx | 20 +- apps/web/package.json | 1 + apps/web/pages/[user]/book.tsx | 1 + apps/web/pages/api/book/event.ts | 294 ++++++++---------- apps/web/pages/d/[link]/book.tsx | 1 + apps/web/pages/team/[slug]/book.tsx | 1 + apps/web/test/lib/team-event-types.test.ts | 32 +- packages/app-store/_baseApp/config.json | 4 +- packages/app-store/typeform/config.json | 6 +- packages/app-store/typeform/package.json | 2 +- .../core/builders/CalendarEvent/builder.ts | 58 ---- packages/lib/getLuckyUsers.ts | 19 -- packages/lib/index.ts | 1 - packages/lib/isOutOfBounds.tsx | 16 + packages/lib/server/getLuckyUser.ts | 67 ++++ packages/lib/server/index.ts | 1 + packages/prisma/seed-app-store.config.json | 22 +- packages/prisma/seed-app-store.ts | 1 + tests/config/singleton.ts | 15 + turbo.json | 22 ++ yarn.lock | 19 +- 24 files changed, 353 insertions(+), 337 deletions(-) create mode 100644 apps/web/components/booking/UserAvatars.tsx delete mode 100644 packages/lib/getLuckyUsers.ts create mode 100644 packages/lib/server/getLuckyUser.ts create mode 100644 tests/config/singleton.ts diff --git a/apps/web/components/booking/AvailableTimes.tsx b/apps/web/components/booking/AvailableTimes.tsx index 9dc9c800b9..fba3da7b87 100644 --- a/apps/web/components/booking/AvailableTimes.tsx +++ b/apps/web/components/booking/AvailableTimes.tsx @@ -1,4 +1,3 @@ -import { SchedulingType } from "@prisma/client"; import Link from "next/link"; import { useRouter } from "next/router"; import { FC, useEffect, useState } from "react"; @@ -18,10 +17,6 @@ type AvailableTimesProps = { recurringCount: number | undefined; eventTypeSlug: string; date: Dayjs; - users: { - username: string | null; - }[]; - schedulingType: SchedulingType | null; seatsPerTimeSlot?: number | null; slots?: Slot[]; isLoading: boolean; @@ -35,7 +30,6 @@ const AvailableTimes: FC = ({ eventTypeSlug, recurringCount, timeFormat, - schedulingType, seatsPerTimeSlot, }) => { const { t, i18n } = useLocale(); @@ -82,10 +76,6 @@ const AvailableTimes: FC = ({ bookingUrl.query.rescheduleUid = rescheduleUid as string; } - if (schedulingType === SchedulingType.ROUND_ROBIN) { - bookingUrl.query.user = slot.users; - } - // If event already has an attendee add booking id if (slot.bookingUid) { bookingUrl.query.bookingUid = slot.bookingUid; diff --git a/apps/web/components/booking/UserAvatars.tsx b/apps/web/components/booking/UserAvatars.tsx new file mode 100644 index 0000000000..54467431a0 --- /dev/null +++ b/apps/web/components/booking/UserAvatars.tsx @@ -0,0 +1,34 @@ +import { CAL_URL } from "@calcom/lib/constants"; + +import AvatarGroup, { AvatarGroupProps } from "@components/ui/AvatarGroup"; + +export const UserAvatars = ({ + profile, + users, + ...props +}: { + profile: { image: string | null; name?: string | null }; + showMembers: boolean; + users: { username: string | null; name?: string | null }[]; +} & Pick) => { + const showMembers = !users.find((user) => user.name === profile.name) && props.showMembers; + return ( + ({ + title: user.name, + image: `${CAL_URL}/${user.username}/avatar.png`, + alt: user.name || undefined, + })) + : []), + ].filter((item) => !!item.image) as { image: string; alt?: string; title?: string }[] + } + size={props.size} + truncateAfter={props.truncateAfter} + /> + ); +}; diff --git a/apps/web/components/booking/pages/AvailabilityPage.tsx b/apps/web/components/booking/pages/AvailabilityPage.tsx index 7e7b7110b1..1aa119a94e 100644 --- a/apps/web/components/booking/pages/AvailabilityPage.tsx +++ b/apps/web/components/booking/pages/AvailabilityPage.tsx @@ -1,5 +1,6 @@ // Get router variables import { EventType } from "@prisma/client"; +import { SchedulingType } from "@prisma/client"; import * as Collapsible from "@radix-ui/react-collapsible"; import { TFunction } from "next-i18next"; import { useRouter } from "next/router"; @@ -18,7 +19,7 @@ import { import { useContracts } from "@calcom/features/ee/web3/contexts/contractsContext"; import CustomBranding from "@calcom/lib/CustomBranding"; import classNames from "@calcom/lib/classNames"; -import { CAL_URL, WEBSITE_URL } from "@calcom/lib/constants"; +import { WEBSITE_URL } from "@calcom/lib/constants"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import useTheme from "@calcom/lib/hooks/useTheme"; import notEmpty from "@calcom/lib/notEmpty"; @@ -37,9 +38,9 @@ import { isBrandingHidden } from "@lib/isBrandingHidden"; import AvailableTimes from "@components/booking/AvailableTimes"; import TimeOptions from "@components/booking/TimeOptions"; +import { UserAvatars } from "@components/booking/UserAvatars"; import EventTypeDescriptionSafeHTML from "@components/eventtype/EventTypeDescriptionSafeHTML"; import { HeadSeo } from "@components/seo/head-seo"; -import AvatarGroup from "@components/ui/AvatarGroup"; import PoweredByCal from "@components/ui/PoweredByCal"; import type { AvailabilityPageProps } from "../../../pages/[user]/[type]"; @@ -235,8 +236,6 @@ const SlotPicker = ({ eventTypeSlug={eventType.slug} seatsPerTimeSlot={seatsPerTimeSlot} recurringCount={recurringEventCount} - schedulingType={eventType.schedulingType} - users={[]} /> )} @@ -425,20 +424,10 @@ const AvailabilityPage = ({ profile, eventType }: Props) => { {/* mobile: details */}
- user.name !== profile.name) - .map((user) => ({ - title: user.name, - image: `${CAL_URL}/${user.username}/avatar.png`, - alt: user.name || undefined, - })), - ].filter((item) => !!item.image) as { image: string; alt?: string; title?: string }[] - } + @@ -566,20 +555,10 @@ const AvailabilityPage = ({ profile, eventType }: Props) => { "hidden overflow-hidden border-gray-200 p-5 sm:border-r sm:dark:border-gray-700 md:flex md:flex-col " + (isAvailableTimesVisible ? "sm:w-1/3" : recurringEventCount ? "sm:w-2/3" : "sm:w-1/2") }> - user.name !== profile.name) - .map((user) => ({ - title: user.name, - alt: user.name, - image: `${CAL_URL}/${user.username}/avatar.png`, - })), - ].filter((item) => !!item.image) as { image: string; alt?: string; title?: string }[] - } + diff --git a/apps/web/components/booking/pages/BookingPage.tsx b/apps/web/components/booking/pages/BookingPage.tsx index 91b939d2f6..b48d456025 100644 --- a/apps/web/components/booking/pages/BookingPage.tsx +++ b/apps/web/components/booking/pages/BookingPage.tsx @@ -1,5 +1,6 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { EventTypeCustomInputType, WorkflowActions } from "@prisma/client"; +import { SchedulingType } from "@prisma/client"; import { isValidPhoneNumber } from "libphonenumber-js"; import { useSession } from "next-auth/react"; import Head from "next/head"; @@ -42,7 +43,7 @@ import createRecurringBooking from "@lib/mutations/bookings/create-recurring-boo import { parseDate, parseRecurringDates } from "@lib/parseDate"; import slugify from "@lib/slugify"; -import AvatarGroup from "@components/ui/AvatarGroup"; +import { UserAvatars } from "@components/booking/UserAvatars"; import { BookPageProps } from "../../../pages/[user]/book"; import { HashLinkPageProps } from "../../../pages/d/[link]/book"; @@ -499,20 +500,11 @@ const BookingPage = ({ )}>
- user.name !== profile.name) - .map((user) => ({ - title: user.name || "", - image: user.avatar || "", - alt: user.name || "", - })) - )} />

{profile.name} diff --git a/apps/web/package.json b/apps/web/package.json index a41897329c..39907eef72 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -151,6 +151,7 @@ "env-cmd": "10.1.0", "eslint": "^8.16.0", "jest": "^26.0.0", + "jest-mock-extended": "^2.0.7", "mockdate": "^3.0.5", "module-alias": "^2.2.2", "postcss": "^8.4.13", diff --git a/apps/web/pages/[user]/book.tsx b/apps/web/pages/[user]/book.tsx index 44b34546d1..c734368005 100644 --- a/apps/web/pages/[user]/book.tsx +++ b/apps/web/pages/[user]/book.tsx @@ -142,6 +142,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { ...e, periodStartDate: e.periodStartDate?.toString() ?? null, periodEndDate: e.periodEndDate?.toString() ?? null, + schedulingType: null, users: users.map((u) => ({ id: u.id, name: u.name, diff --git a/apps/web/pages/api/book/event.ts b/apps/web/pages/api/book/event.ts index 4e95d07d27..0e6ce8d29b 100644 --- a/apps/web/pages/api/book/event.ts +++ b/apps/web/pages/api/book/event.ts @@ -18,11 +18,12 @@ import { } from "@calcom/emails"; import verifyAccount from "@calcom/features/ee/web3/utils/verifyAccount"; import { scheduleWorkflowReminders } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler"; -import { getLuckyUsers, isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib"; +import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib"; import { getDefaultEvent, getGroupName, getUsernameList } from "@calcom/lib/defaultEvents"; import { getErrorFromUnknown } from "@calcom/lib/errors"; -import isOutOfBounds from "@calcom/lib/isOutOfBounds"; +import isOutOfBounds, { BookingDateInPastError } from "@calcom/lib/isOutOfBounds"; import logger from "@calcom/lib/logger"; +import { getLuckyUser } from "@calcom/lib/server"; import { defaultResponder } from "@calcom/lib/server"; import prisma, { userSelect } from "@calcom/prisma"; import { extendedBookingCreateBody } from "@calcom/prisma/zod-utils"; @@ -32,7 +33,6 @@ import type { EventResult, PartialReference } from "@calcom/types/EventManager"; import { getSession } from "@lib/auth"; import { HttpError } from "@lib/core/http/error"; -import { ensureArray } from "@lib/ensureArray"; import { getEventName } from "@lib/event"; import sendPayload from "@lib/webhooks/sendPayload"; import getSubscribers from "@lib/webhooks/subscriptions"; @@ -42,6 +42,7 @@ import { getTranslation } from "@server/lib/i18n"; const translator = short(); const log = logger.getChildLogger({ prefix: ["[api] book:user"] }); +type User = Prisma.UserGetPayload; type BufferedBusyTimes = BufferedBusyTime[]; /** @@ -108,43 +109,6 @@ function isAvailable(busyTimes: BufferedBusyTimes, time: dayjs.ConfigType, lengt return t; } -const getUserNameWithBookingCounts = async (eventTypeId: number, selectedUserNames: string[]) => { - const users = await prisma.user.findMany({ - where: { - username: { in: selectedUserNames }, - eventTypes: { - some: { - id: eventTypeId, - }, - }, - }, - select: { - id: true, - username: true, - locale: true, - }, - }); - - const userNamesWithBookingCounts = await Promise.all( - users.map(async (user) => ({ - username: user.username, - bookingCount: await prisma.booking.count({ - where: { - user: { - id: user.id, - }, - startTime: { - gt: new Date(), - }, - eventTypeId, - }, - }), - })) - ); - - return userNamesWithBookingCounts; -}; - const getEventTypesFromDB = async (eventTypeId: number) => { const eventType = await prisma.eventType.findUnique({ rejectOnNotFound: true, @@ -212,7 +176,60 @@ const getEventTypesFromDB = async (eventTypeId: number) => { }; }; -type User = Prisma.UserGetPayload; +async function ensureAvailableUsers( + eventType: Awaited> & { + users: User[]; + }, + input: { dateFrom: string; dateTo: string } +) { + const availableUsers: typeof eventType.users = []; + /** Let's start checking for availability */ + for (const user of eventType.users) { + const { busy: bufferedBusyTimes } = await getUserAvailability( + { + userId: user.id, + eventTypeId: eventType.id, + ...input, + }, + { user, eventType } + ); + + console.log("calendarBusyTimes==>>>", bufferedBusyTimes); + + let isAvailableToBeBooked = true; + try { + if (eventType.recurringEvent) { + const recurringEvent = parseRecurringEvent(eventType.recurringEvent); + const allBookingDates = new rrule({ dtstart: new Date(input.dateFrom), ...recurringEvent }).all(); + // Go through each date for the recurring event and check if each one's availability + // DONE: Decreased computational complexity from O(2^n) to O(n) by refactoring this loop to stop + // running at the first unavailable time. + let i = 0; + while (isAvailableToBeBooked === true && i < allBookingDates.length) { + const aDate = allBookingDates[i]; + i++; + isAvailableToBeBooked = isAvailable(bufferedBusyTimes, aDate, eventType.length); + // We bail at the first false, we don't need to keep checking + if (!isAvailableToBeBooked) break; + } + } else { + isAvailableToBeBooked = isAvailable(bufferedBusyTimes, input.dateFrom, eventType.length); + } + } catch { + log.debug({ + message: "Unable set isAvailableToBeBooked. Using true. ", + }); + } + + if (isAvailableToBeBooked) { + availableUsers.push(user); + } + } + if (!availableUsers.length) { + throw new Error("No available users found."); + } + return availableUsers; +} async function handler(req: NextApiRequest) { const session = await getSession({ req }); @@ -229,25 +246,39 @@ async function handler(req: NextApiRequest) { const tGuests = await getTranslation("en", "common"); log.debug(`Booking eventType ${eventTypeId} started`); - const isTimeInPast = (time: string): boolean => { - return dayjs(time).isBefore(new Date(), "day"); - }; - - if (isTimeInPast(reqBody.start)) { - const error = { - errorCode: "BookingDateInPast", - message: "Attempting to create a meeting in the past.", - }; - - log.error(`Booking ${eventTypeId} failed`, error); - throw new HttpError({ statusCode: 400, message: error.message }); - } - const eventType = !eventTypeId && !!eventTypeSlug ? getDefaultEvent(eventTypeSlug) : await getEventTypesFromDB(eventTypeId); if (!eventType) throw new HttpError({ statusCode: 404, message: "eventType.notFound" }); - let user: User | null = null; + let timeOutOfBounds = false; + try { + timeOutOfBounds = isOutOfBounds(reqBody.start, { + periodType: eventType.periodType, + periodDays: eventType.periodDays, + periodEndDate: eventType.periodEndDate, + periodStartDate: eventType.periodStartDate, + periodCountCalendarDays: eventType.periodCountCalendarDays, + }); + } catch (error) { + if (error instanceof BookingDateInPastError) { + // TODO: HttpError should not bleed through to the console. + log.info(`Booking eventType ${eventTypeId} failed`, error); + throw new HttpError({ statusCode: 400, message: error.message }); + } + log.debug({ + message: "Unable set timeOutOfBounds. Using false. ", + }); + } + + if (timeOutOfBounds) { + const error = { + errorCode: "BookingTimeOutOfBounds", + message: `EventType '${eventType.eventName}' cannot be booked at this time.`, + }; + + throw new HttpError({ statusCode: 400, message: error.message }); + } + let users = !eventTypeId ? await prisma.user.findMany({ where: { @@ -266,7 +297,8 @@ async function handler(req: NextApiRequest) { }); } - /* If this event was pre-relationship migration */ + // If this event was pre-relationship migration + // TODO: Establish whether this is dead code. if (!users.length && eventType.userId) { const eventTypeUser = await prisma.user.findUnique({ where: { @@ -280,30 +312,33 @@ async function handler(req: NextApiRequest) { if (!users) throw new HttpError({ statusCode: 404, message: "eventTypeUser.notFound" }); - const [organizerUser] = users; - /** - * @TODO: add a validation to check if organizerUser is found, otherwise it will throw error on user not found - * Probably an alert email to team owner should be sent - */ - const organizer = await prisma.user.findUnique({ - where: { - id: organizerUser?.id, + const availableUsers = await ensureAvailableUsers( + { + ...eventType, + users, }, - select: { - locale: true, - }, - }); + { + dateFrom: reqBody.start, + dateTo: reqBody.end, + } + ); - const tOrganizer = await getTranslation(organizer?.locale ?? "en", "common"); + // Assign to only one user when ROUND_ROBIN if (eventType.schedulingType === SchedulingType.ROUND_ROBIN) { - const bookingCounts = await getUserNameWithBookingCounts( - eventTypeId, - ensureArray(reqBody.user) || users.map((user) => user.username) - ); - - users = getLuckyUsers(users, bookingCounts); + users = [await getLuckyUser("MAXIMIZE_AVAILABILITY", { availableUsers, eventTypeId: eventType.id })]; + } else { + // excluding ROUND_ROBIN, all users have availability required. + if (availableUsers.length !== users.length) { + throw new Error("Some users are unavailable for booking."); + } + users = availableUsers; } + console.log("available users", users); + + const [organizerUser] = users; + const tOrganizer = await getTranslation(organizerUser.locale ?? "en", "common"); + const invitee = [ { email: reqBody.email, @@ -369,7 +404,7 @@ async function handler(req: NextApiRequest) { name: organizerUser.name || "Nameless", email: organizerUser.email || "Email-less", timeZone: organizerUser.timeZone, - language: { translate: tOrganizer, locale: organizer?.locale ?? "en" }, + language: { translate: tOrganizer, locale: organizerUser.locale ?? "en" }, }, attendees: attendeesList, location: reqBody.location, // Will be processed by the EventManager later. @@ -440,9 +475,8 @@ async function handler(req: NextApiRequest) { await sendScheduledSeatsEmails(evt, invitee[0], newSeat); - user = users[0]; - const credentials = await refreshCredentials(user.credentials); - const eventManager = new EventManager({ ...user, credentials }); + const credentials = await refreshCredentials(organizerUser.credentials); + const eventManager = new EventManager({ ...organizerUser, credentials }); await eventManager.updateCalendarAttendees(evt, booking); req.statusCode = 201; @@ -630,88 +664,6 @@ async function handler(req: NextApiRequest) { let results: EventResult[] = []; let referencesToCreate: PartialReference[] = []; - /** Let's start checking for availability */ - for (const currentUser of users) { - if (!currentUser) { - console.error(`currentUser not found`); - continue; - } - if (!user) user = currentUser; - - const { busy: bufferedBusyTimes } = await getUserAvailability( - { - userId: currentUser.id, - dateFrom: reqBody.start, - dateTo: reqBody.end, - eventTypeId, - }, - { user, eventType } - ); - - console.log("calendarBusyTimes==>>>", bufferedBusyTimes); - - let isAvailableToBeBooked = true; - try { - if (eventType.recurringEvent) { - const recurringEvent = parseRecurringEvent(eventType.recurringEvent); - const allBookingDates = new rrule({ dtstart: new Date(reqBody.start), ...recurringEvent }).all(); - // Go through each date for the recurring event and check if each one's availability - // DONE: Decreased computational complexity from O(2^n) to O(n) by refactoring this loop to stop - // running at the first unavailable time. - let i = 0; - while (isAvailableToBeBooked === true && i < allBookingDates.length) { - const aDate = allBookingDates[i]; - i++; - isAvailableToBeBooked = isAvailable(bufferedBusyTimes, aDate, eventType.length); - /* We bail at the first false, we don't need to keep checking */ - if (!isAvailableToBeBooked) break; - } - } else { - isAvailableToBeBooked = isAvailable(bufferedBusyTimes, reqBody.start, eventType.length); - } - } catch { - log.debug({ - message: "Unable set isAvailableToBeBooked. Using true. ", - }); - } - - if (!isAvailableToBeBooked) { - const error = { - errorCode: "BookingUserUnAvailable", - message: `${currentUser.name} is unavailable at this time.`, - }; - - log.debug(`Booking ${currentUser.name} failed`, error); - throw new HttpError({ statusCode: 409, message: error.message }); - } - - let timeOutOfBounds = false; - - try { - timeOutOfBounds = isOutOfBounds(reqBody.start, { - periodType: eventType.periodType, - periodDays: eventType.periodDays, - periodEndDate: eventType.periodEndDate, - periodStartDate: eventType.periodStartDate, - periodCountCalendarDays: eventType.periodCountCalendarDays, - }); - } catch { - log.debug({ - message: "Unable set timeOutOfBounds. Using false. ", - }); - } - - if (timeOutOfBounds) { - const error = { - errorCode: "BookingUserUnAvailable", - message: `${currentUser.name} is unavailable at this time.`, - }; - - log.debug(`Booking ${currentUser.name} failed`, error); - throw new HttpError({ statusCode: 409, message: error.message }); - } - } - type Booking = Prisma.PromiseReturnType; let booking: Booking | null = null; try { @@ -726,11 +678,9 @@ async function handler(req: NextApiRequest) { throw err; } - if (!user) throw new HttpError({ statusCode: 404, message: "Can't continue, user not found." }); - // After polling videoBusyTimes, credentials might have been changed due to refreshment, so query them again. - const credentials = await refreshCredentials(user.credentials); - const eventManager = new EventManager({ ...user, credentials }); + const credentials = await refreshCredentials(organizerUser.credentials); + const eventManager = new EventManager({ ...organizerUser, credentials }); if (originalRescheduledBooking?.uid) { // Use EventManager to conditionally use all needed integrations. @@ -752,7 +702,7 @@ async function handler(req: NextApiRequest) { message: "Booking Rescheduling failed", }; - log.error(`Booking ${user.name} failed`, error, results); + log.error(`Booking ${organizerUser.name} failed`, error, results); } else { const metadata: AdditionalInformation = {}; @@ -795,7 +745,7 @@ async function handler(req: NextApiRequest) { message: "Booking failed", }; - log.error(`Booking ${user.username} failed`, error, results); + log.error(`Booking ${organizerUser.username} failed`, error, results); } else { const metadata: AdditionalInformation = {}; @@ -827,23 +777,23 @@ async function handler(req: NextApiRequest) { !originalRescheduledBooking?.paid && !!booking ) { - const [firstStripeCredential] = user.credentials.filter((cred) => cred.type == "stripe_payment"); + const [firstStripeCredential] = organizerUser.credentials.filter((cred) => cred.type == "stripe_payment"); if (!firstStripeCredential) throw new HttpError({ statusCode: 400, message: "Missing payment credentials" }); - if (!booking.user) booking.user = user; + if (!booking.user) booking.user = organizerUser; const payment = await handlePayment(evt, eventType, firstStripeCredential, booking); req.statusCode = 201; return { ...booking, message: "Payment required", paymentUid: payment.uid }; } - log.debug(`Booking ${user.username} completed`); + log.debug(`Booking ${organizerUser.username} completed`); const eventTrigger: WebhookTriggerEvents = rescheduleUid ? "BOOKING_RESCHEDULED" : "BOOKING_CREATED"; const subscriberOptions = { - userId: user.id, + userId: organizerUser.id, eventTypeId, triggerEvent: eventTrigger, }; diff --git a/apps/web/pages/d/[link]/book.tsx b/apps/web/pages/d/[link]/book.tsx index b21bbf08f2..1affc3e62d 100644 --- a/apps/web/pages/d/[link]/book.tsx +++ b/apps/web/pages/d/[link]/book.tsx @@ -103,6 +103,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { ...e, periodStartDate: e.periodStartDate?.toString() ?? null, periodEndDate: e.periodEndDate?.toString() ?? null, + schedulingType: null, users: users.map((u) => ({ id: u.id, name: u.name, diff --git a/apps/web/pages/team/[slug]/book.tsx b/apps/web/pages/team/[slug]/book.tsx index 6f6ac5703c..13e00762cf 100644 --- a/apps/web/pages/team/[slug]/book.tsx +++ b/apps/web/pages/team/[slug]/book.tsx @@ -56,6 +56,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { currency: true, metadata: true, seatsPerTimeSlot: true, + schedulingType: true, workflows: { include: { workflow: { diff --git a/apps/web/test/lib/team-event-types.test.ts b/apps/web/test/lib/team-event-types.test.ts index 74367e50c4..fa5d6d52d8 100644 --- a/apps/web/test/lib/team-event-types.test.ts +++ b/apps/web/test/lib/team-event-types.test.ts @@ -1,6 +1,8 @@ import { UserPlan } from "@prisma/client"; -import { getLuckyUsers } from "@calcom/lib"; +import { getLuckyUser } from "@calcom/lib/server"; + +import { prismaMock } from "../../../../tests/config/singleton"; const baseUser = { id: 0, @@ -27,7 +29,7 @@ const baseUser = { allowDynamicBooking: true, }; -it("can find lucky users", async () => { +it("can find lucky user with maximize availability", async () => { const users = [ { ...baseUser, @@ -35,6 +37,11 @@ it("can find lucky users", async () => { username: "test", name: "Test User", email: "test@example.com", + bookings: [ + { + createdAt: new Date("2022-01-25"), + }, + ], }, { ...baseUser, @@ -42,12 +49,23 @@ it("can find lucky users", async () => { username: "test2", name: "Test 2 User", email: "test2@example.com", + bookings: [ + { + createdAt: new Date(), + }, + ], }, ]; + + // TODO: we may be able to use native prisma generics somehow? + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + prismaMock.user.findMany.mockResolvedValue(users); + expect( - getLuckyUsers(users, [ - { username: "test", bookingCount: 2 }, - { username: "test2", bookingCount: 0 }, - ]) - ).toStrictEqual([users[1]]); + getLuckyUser("MAXIMIZE_AVAILABILITY", { + availableUsers: users, + eventTypeId: 1, + }) + ).resolves.toStrictEqual(users[1]); }); diff --git a/packages/app-store/_baseApp/config.json b/packages/app-store/_baseApp/config.json index b2a5255bf3..74be1674b4 100644 --- a/packages/app-store/_baseApp/config.json +++ b/packages/app-store/_baseApp/config.json @@ -1,3 +1,3 @@ { - "/*": "This file would be automatically updated by cli according to the inputs" -} \ No newline at end of file + "/*": "This file would be automatically updated by cli according to the inputs" +} diff --git a/packages/app-store/typeform/config.json b/packages/app-store/typeform/config.json index 5c8a956a3e..017590a7a4 100644 --- a/packages/app-store/typeform/config.json +++ b/packages/app-store/typeform/config.json @@ -6,11 +6,9 @@ "logo": "/api/app-store/typeform/icon.svg", "url": "https://cal.com/apps/typeform", "variant": "other", - "categories": [ - "other" - ], + "categories": ["other"], "publisher": "Cal.com", "email": "help@cal.com", "description": "Adds a link to copy Typeform Redirect URL", "__createdUsingCli": true -} \ No newline at end of file +} diff --git a/packages/app-store/typeform/package.json b/packages/app-store/typeform/package.json index 02760844ec..d76de14f63 100644 --- a/packages/app-store/typeform/package.json +++ b/packages/app-store/typeform/package.json @@ -11,4 +11,4 @@ "devDependencies": { "@calcom/types": "*" } -} \ No newline at end of file +} diff --git a/packages/core/builders/CalendarEvent/builder.ts b/packages/core/builders/CalendarEvent/builder.ts index 8f1d196d5c..6ddf1b7124 100644 --- a/packages/core/builders/CalendarEvent/builder.ts +++ b/packages/core/builders/CalendarEvent/builder.ts @@ -153,64 +153,6 @@ export class CalendarEventBuilder implements ICalendarEventBuilder { return resultEventType; } - public async buildLuckyUsers() { - if (!this.eventType && this.users && this.users.length) { - throw new Error("exec buildUsersFromInnerClass before calling this function"); - } - - // @TODO: user?.username gets flagged as null somehow, maybe a filter before map? - const filterUsernames = this.users.filter((user) => user && typeof user.username === "string"); - const userUsernames = filterUsernames.map((user) => user.username) as string[]; // @TODO: hack - const users = await prisma.user.findMany({ - where: { - username: { in: userUsernames }, - eventTypes: { - some: { - id: this.eventType.id, - }, - }, - }, - select: { - id: true, - username: true, - locale: true, - }, - }); - - const userNamesWithBookingCounts = await Promise.all( - users.map(async (user) => ({ - username: user.username, - bookingCount: await prisma.booking.count({ - where: { - user: { - id: user.id, - }, - startTime: { - gt: new Date(), - }, - eventTypeId: this.eventType.id, - }, - }), - })) - ); - const luckyUsers = this.getLuckyUsers(this.users, userNamesWithBookingCounts); - this.users = luckyUsers; - } - - private getLuckyUsers( - users: User[], - bookingCounts: { - username: string | null; - bookingCount: number; - }[] - ) { - if (!bookingCounts.length) users.slice(0, 1); - - const [firstMostAvailableUser] = bookingCounts.sort((a, b) => (a.bookingCount > b.bookingCount ? 1 : -1)); - const luckyUser = users.find((user) => user.username === firstMostAvailableUser?.username); - return luckyUser ? [luckyUser] : users; - } - public async buildTeamMembers() { this.teamMembers = await this.getTeamMembers(); } diff --git a/packages/lib/getLuckyUsers.ts b/packages/lib/getLuckyUsers.ts deleted file mode 100644 index 0f31359063..0000000000 --- a/packages/lib/getLuckyUsers.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Prisma } from "@prisma/client"; - -import { userSelect } from "@calcom/prisma"; - -type User = Prisma.UserGetPayload; - -export function getLuckyUsers( - users: User[], - bookingCounts: { - username: string | null; - bookingCount: number; - }[] -) { - if (!bookingCounts.length) users.slice(0, 1); - - const [firstMostAvailableUser] = bookingCounts.sort((a, b) => (a.bookingCount > b.bookingCount ? 1 : -1)); - const luckyUser = users.find((user) => user.username === firstMostAvailableUser?.username); - return luckyUser ? [luckyUser] : users; -} diff --git a/packages/lib/index.ts b/packages/lib/index.ts index dab1a5763e..a56dca088e 100644 --- a/packages/lib/index.ts +++ b/packages/lib/index.ts @@ -1,4 +1,3 @@ export { default as classNames } from "./classNames"; -export { getLuckyUsers } from "./getLuckyUsers"; export { default as isPrismaObj, isPrismaObjOrUndefined } from "./isPrismaObj"; export * from "./isRecurringEvent"; diff --git a/packages/lib/isOutOfBounds.tsx b/packages/lib/isOutOfBounds.tsx index 1a115f737d..11216b1ea7 100644 --- a/packages/lib/isOutOfBounds.tsx +++ b/packages/lib/isOutOfBounds.tsx @@ -2,6 +2,20 @@ import { EventType, PeriodType } from "@prisma/client"; import dayjs from "@calcom/dayjs"; +export class BookingDateInPastError extends Error { + constructor(message = "Attempting to book a meeting in the past.") { + super(message); + } +} + +function guardAgainstBookingInThePast(date: Date) { + if (date >= new Date()) { + // Date is in the future. + return; + } + throw new BookingDateInPastError(); +} + function isOutOfBounds( time: dayjs.ConfigType, { @@ -16,6 +30,8 @@ function isOutOfBounds( > ) { const date = dayjs(time); + guardAgainstBookingInThePast(date.toDate()); + periodDays = periodDays || 0; switch (periodType) { diff --git a/packages/lib/server/getLuckyUser.ts b/packages/lib/server/getLuckyUser.ts new file mode 100644 index 0000000000..0a5a36a075 --- /dev/null +++ b/packages/lib/server/getLuckyUser.ts @@ -0,0 +1,67 @@ +import { User } from "@prisma/client"; + +import prisma from "@calcom/prisma"; + +async function leastRecentlyBookedUser>({ + availableUsers, + eventTypeId, +}: { + availableUsers: T[]; + eventTypeId: number; +}) { + const usersWithLastCreated = await prisma?.user.findMany({ + where: { + id: { + in: availableUsers.map((user) => user.id), + }, + }, + select: { + id: true, + bookings: { + select: { + createdAt: true, + }, + where: { + eventTypeId, + }, + orderBy: { + createdAt: "desc", + }, + take: 1, + }, + }, + }); + + if (!usersWithLastCreated) { + throw new Error("Unable to find users by availableUser ids."); // should never happen. + } + + const userIdAndAtCreatedPair = usersWithLastCreated.reduce( + (keyValuePair: { [key: number]: Date }, user) => { + keyValuePair[user.id] = user.bookings[0]?.createdAt; + return keyValuePair; + }, + {} + ); + + const leastRecentlyBookedUser = availableUsers.sort((a, b) => { + return userIdAndAtCreatedPair[a.id] > userIdAndAtCreatedPair[b.id] ? 1 : -1; + })[0]; + + return leastRecentlyBookedUser; +} + +// TODO: Configure distributionAlgorithm from the event type configuration +// TODO: Add 'MAXIMIZE_FAIRNESS' algorithm. +export async function getLuckyUser>( + distributionAlgorithm: "MAXIMIZE_AVAILABILITY" = "MAXIMIZE_AVAILABILITY", + { availableUsers, eventTypeId }: { availableUsers: T[]; eventTypeId: number } +) { + if (availableUsers.length === 1) { + return availableUsers[0]; + } + switch (distributionAlgorithm) { + case "MAXIMIZE_AVAILABILITY": + return leastRecentlyBookedUser({ availableUsers, eventTypeId }); + } +} diff --git a/packages/lib/server/index.ts b/packages/lib/server/index.ts index 0664a9ab55..972203cdeb 100644 --- a/packages/lib/server/index.ts +++ b/packages/lib/server/index.ts @@ -1,5 +1,6 @@ export { default as defaultHandler } from "./defaultHandler"; export { default as defaultResponder } from "./defaultResponder"; +export { getLuckyUser } from "./getLuckyUser"; export { getServerErrorFromUnknown } from "./getServerErrorFromUnknown"; export { getTranslation } from "./i18n"; export { default as perfObserver } from "./perfObserver"; diff --git a/packages/prisma/seed-app-store.config.json b/packages/prisma/seed-app-store.config.json index 0a06ae1f23..c939802bbc 100644 --- a/packages/prisma/seed-app-store.config.json +++ b/packages/prisma/seed-app-store.config.json @@ -2,41 +2,31 @@ { "/*": "This file is auto-generated and updated by `yarn app-store create/edit`. Don't edit it manually", "dirName": "routing_forms", - "categories": [ - "other" - ], + "categories": ["other"], "slug": "routing_forms", "type": "routing_forms_other" }, { "dirName": "whereby", - "categories": [ - "video" - ], + "categories": ["video"], "slug": "whereby", "type": "whereby_video" }, { "dirName": "around", - "categories": [ - "video" - ], + "categories": ["video"], "slug": "around", "type": "around_video" }, { "dirName": "riverside", - "categories": [ - "video" - ], + "categories": ["video"], "slug": "riverside", "type": "riverside_video" }, { "dirName": "typeform", - "categories": [ - "other" - ], + "categories": ["other"], "slug": "typeform", "type": "typeform_other" }, @@ -46,4 +36,4 @@ "slug": "ping", "type": "ping_video" } -] \ No newline at end of file +] diff --git a/packages/prisma/seed-app-store.ts b/packages/prisma/seed-app-store.ts index 851c644001..b6089c753f 100644 --- a/packages/prisma/seed-app-store.ts +++ b/packages/prisma/seed-app-store.ts @@ -9,6 +9,7 @@ export const seededForm = { name: "Seeded Form - Pro", }; +// eslint-disable-next-line @typescript-eslint/no-var-requires require("dotenv").config({ path: "../../.env.appStore" }); async function seedAppData() { diff --git a/tests/config/singleton.ts b/tests/config/singleton.ts new file mode 100644 index 0000000000..e6e38deaf1 --- /dev/null +++ b/tests/config/singleton.ts @@ -0,0 +1,15 @@ +import { PrismaClient } from "@prisma/client"; +import { mockDeep, mockReset, DeepMockProxy } from "jest-mock-extended"; + +import prisma from "@calcom/prisma"; + +jest.mock("@calcom/prisma", () => ({ + __esModule: true, + default: mockDeep(), +})); + +beforeEach(() => { + mockReset(prismaMock); +}); + +export const prismaMock = prisma as unknown as DeepMockProxy; diff --git a/turbo.json b/turbo.json index f6a77f73d7..2df8fc5ad5 100644 --- a/turbo.json +++ b/turbo.json @@ -193,6 +193,7 @@ "$CRON_API_KEY", "$DAILY_API_KEY", "$DAILY_SCALE_PLAN", + "$DEBUG", "$EMAIL_FROM", "$EMAIL_SERVER_HOST", "$EMAIL_SERVER_PASSWORD", @@ -200,11 +201,23 @@ "$EMAIL_SERVER_USER", "$EMAIL_SERVER", "$EXCHANGE_DEFAULT_EWS_URL", + "$GIPHY_API_KEY", "$GOOGLE_API_CREDENTIALS", "$GOOGLE_LOGIN_ENABLED", + "$MS_GRAPH_CLIENT_ID", + "$MS_GRAPH_CLIENT_SECRET", + "$LARK_OPEN_APP_ID", + "$LARK_OPEN_APP_SECRET", + "$LARK_OPEN_VERIFICATION_TOKEN", + "$TANDEM_CLIENT_ID", + "$TANDEM_CLIENT_SECRET", + "$TANDEM_BASE_URL", + "$ZOOM_CLIENT_ID", + "$ZOOM_CLIENT_SECRET", "$HEROKU_APP_NAME", "$HOSTED_CAL_FEATURES", "$HUBSPOT_CLIENT_ID", + "$HUBSPOT_CLIENT_SECRET", "$INTEGRATION_TEST_MODE", "$NEXT_PUBLIC_CONSOLE_URL", "$NEXT_PUBLIC_DEBUG", @@ -221,12 +234,21 @@ "$RAILWAY_STATIC_URL", "$SAML_ADMINS", "$SAML_DATABASE_URL", + "$SEND_FEEDBACK_EMAIL", + "$SLACK_CLIENT_ID", + "$SLACK_CLIENT_SECRET", + "$SLACK_SIGNING_SECRET", "$STRIPE_CLIENT_ID", "$STRIPE_PRIVATE_KEY", "$STRIPE_WEBHOOK_SECRET", "$TELEMETRY_DEBUG", "$VERCEL_ENV", "$VERCEL_URL", + "$VITAL_API_KEY", + "$VITAL_WEBHOOK_SECRET", + "$VITAL_DEVELOPMENT_MODE", + "$VITAL_REGION", + "$ZAPIER_INVITE_LINK", "yarn.lock" ] } diff --git a/yarn.lock b/yarn.lock index b8c6ba5376..b9629c679d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6227,11 +6227,16 @@ "@types/node" "*" form-data "^3.0.0" -"@types/node@*", "@types/node@16.9.1", "@types/node@>=12.0.0", "@types/node@>=8.1.0", "@types/node@^12.12.6", "@types/node@^14.0.10 || ^16.0.0", "@types/node@^14.14.20 || ^16.0.0": +"@types/node@*", "@types/node@16.9.1", "@types/node@>=12.0.0", "@types/node@>=8.1.0", "@types/node@^14.0.10 || ^16.0.0", "@types/node@^14.14.20 || ^16.0.0": version "16.9.1" resolved "https://registry.yarnpkg.com/@types/node/-/node-16.9.1.tgz#0611b37db4246c937feef529ddcc018cf8e35708" integrity sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g== +"@types/node@^12.12.6": + version "12.20.55" + resolved "https://registry.yarnpkg.com/@types/node/-/node-12.20.55.tgz#c329cbd434c42164f846b909bd6f85b5537f6240" + integrity sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ== + "@types/nodemailer@^6.4.4": version "6.4.4" resolved "https://registry.yarnpkg.com/@types/nodemailer/-/nodemailer-6.4.4.tgz#c265f7e7a51df587597b3a49a023acaf0c741f4b" @@ -14770,6 +14775,13 @@ jest-message-util@^28.1.3: slash "^3.0.0" stack-utils "^2.0.3" +jest-mock-extended@^2.0.7: + version "2.0.7" + resolved "https://registry.yarnpkg.com/jest-mock-extended/-/jest-mock-extended-2.0.7.tgz#73ad87d8a744949bc3415d840f03468229f73f2a" + integrity sha512-h8brJJN5BZb03hTwplvt+raT6Nj0U2U71Z26Py12Qc3kvYnAjDW/zSuQJLnXCNyyufy592VC9k3X7AOz+2H52g== + dependencies: + ts-essentials "^7.0.3" + jest-mock@^26.6.2: version "26.6.2" resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-26.6.2.tgz#d6cb712b041ed47fe0d9b6fc3474bc6543feb302" @@ -22481,6 +22493,11 @@ ts-dedent@^2.0.0, ts-dedent@^2.2.0: resolved "https://registry.yarnpkg.com/ts-dedent/-/ts-dedent-2.2.0.tgz#39e4bd297cd036292ae2394eb3412be63f563bb5" integrity sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ== +ts-essentials@^7.0.3: + version "7.0.3" + resolved "https://registry.yarnpkg.com/ts-essentials/-/ts-essentials-7.0.3.tgz#686fd155a02133eedcc5362dc8b5056cde3e5a38" + integrity sha512-8+gr5+lqO3G84KdiTSMRLtuyJ+nTBVRKuCrK4lidMPdVeEp0uqC875uE5NMcaA7YYMN7XsNiFQuMvasF8HT/xQ== + ts-jest@^26.0.0: version "26.5.6" resolved "https://registry.yarnpkg.com/ts-jest/-/ts-jest-26.5.6.tgz#c32e0746425274e1dfe333f43cd3c800e014ec35"