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
This commit is contained in:
1 parent
c644a10688
commit
297a965508
24 files changed
+353
-337
No files matched your search
@@ -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<AvailableTimesProps> = ({
|
||||
eventTypeSlug,
|
||||
recurringCount,
|
||||
timeFormat,
|
||||
schedulingType,
|
||||
seatsPerTimeSlot,
|
||||
}) => {
|
||||
const { t, i18n } = useLocale();
|
||||
@@ -82,10 +76,6 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
||||
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;
|
||||
|
||||
@@ -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<AvatarGroupProps, "size" | "truncateAfter">) => {
|
||||
const showMembers = !users.find((user) => user.name === profile.name) && props.showMembers;
|
||||
return (
|
||||
<AvatarGroup
|
||||
border="border-2 dark:border-gray-800 border-white"
|
||||
items={
|
||||
[
|
||||
{ image: profile.image, alt: profile.name, title: profile.name },
|
||||
...(showMembers
|
||||
? users.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 }[]
|
||||
}
|
||||
size={props.size}
|
||||
truncateAfter={props.truncateAfter}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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 */}
|
||||
<div className="block px-4 pt-4 sm:p-8 md:hidden">
|
||||
<div>
|
||||
<AvatarGroup
|
||||
border="border-2 dark:border-gray-800 border-white"
|
||||
items={
|
||||
[
|
||||
{ image: profile.image, alt: profile.name, title: profile.name },
|
||||
...eventType.users
|
||||
.filter((user) => 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 }[]
|
||||
}
|
||||
<UserAvatars
|
||||
profile={profile}
|
||||
users={eventType.users}
|
||||
showMembers={eventType.schedulingType !== SchedulingType.ROUND_ROBIN}
|
||||
size={9}
|
||||
truncateAfter={5}
|
||||
/>
|
||||
@@ -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")
|
||||
}>
|
||||
<AvatarGroup
|
||||
border="border-2 dark:border-gray-800 border-white"
|
||||
items={
|
||||
[
|
||||
{ image: profile.image, alt: profile.name, title: profile.name },
|
||||
...eventType.users
|
||||
.filter((user) => 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 }[]
|
||||
}
|
||||
<UserAvatars
|
||||
profile={profile}
|
||||
users={eventType.users}
|
||||
showMembers={eventType.schedulingType !== SchedulingType.ROUND_ROBIN}
|
||||
size={10}
|
||||
truncateAfter={3}
|
||||
/>
|
||||
|
||||
@@ -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 = ({
|
||||
)}>
|
||||
<div className="sm:flex">
|
||||
<div className="px-6 pt-6 pb-0 sm:w-1/2 sm:border-r sm:pb-6 sm:dark:border-gray-700">
|
||||
<AvatarGroup
|
||||
border="border-2 border-white dark:border-gray-800"
|
||||
<UserAvatars
|
||||
profile={profile}
|
||||
users={eventType.users}
|
||||
showMembers={eventType.schedulingType !== SchedulingType.ROUND_ROBIN}
|
||||
size={14}
|
||||
items={[
|
||||
{ image: profile.image || "", alt: profile.name || "", title: profile.name || "" },
|
||||
].concat(
|
||||
eventType.users
|
||||
.filter((user) => user.name !== profile.name)
|
||||
.map((user) => ({
|
||||
title: user.name || "",
|
||||
image: user.avatar || "",
|
||||
alt: user.name || "",
|
||||
}))
|
||||
)}
|
||||
/>
|
||||
<h2 className="font-cal text-bookinglight mt-2 font-medium dark:text-gray-300">
|
||||
{profile.name}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
+122
-172
@@ -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<typeof userSelect>;
|
||||
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<typeof userSelect>;
|
||||
async function ensureAvailableUsers(
|
||||
eventType: Awaited<ReturnType<typeof getEventTypesFromDB>> & {
|
||||
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<AdditionalInformation>[] = [];
|
||||
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<typeof createBooking>;
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -56,6 +56,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
currency: true,
|
||||
metadata: true,
|
||||
seatsPerTimeSlot: true,
|
||||
schedulingType: true,
|
||||
workflows: {
|
||||
include: {
|
||||
workflow: {
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"/*": "This file would be automatically updated by cli according to the inputs"
|
||||
}
|
||||
"/*": "This file would be automatically updated by cli according to the inputs"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -11,4 +11,4 @@
|
||||
"devDependencies": {
|
||||
"@calcom/types": "*"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
import { userSelect } from "@calcom/prisma";
|
||||
|
||||
type User = Prisma.UserGetPayload<typeof userSelect>;
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
export { default as classNames } from "./classNames";
|
||||
export { getLuckyUsers } from "./getLuckyUsers";
|
||||
export { default as isPrismaObj, isPrismaObjOrUndefined } from "./isPrismaObj";
|
||||
export * from "./isRecurringEvent";
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { User } from "@prisma/client";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
async function leastRecentlyBookedUser<T extends Pick<User, "id">>({
|
||||
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<T extends Pick<User, "id">>(
|
||||
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<T>({ availableUsers, eventTypeId });
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
]
|
||||
@@ -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() {
|
||||
|
||||
@@ -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<PrismaClient>(),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
mockReset(prismaMock);
|
||||
});
|
||||
|
||||
export const prismaMock = prisma as unknown as DeepMockProxy<PrismaClient>;
|
||||
+22
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user