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:
Alex van Andel authored and GitHub committed 2022-08-15 13:52:01 -06:00
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}
+1
View File
@@ -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",
+1
View File
@@ -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
View File
@@ -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,
};
+1
View File
@@ -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,
+1
View File
@@ -56,6 +56,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
currency: true,
metadata: true,
seatsPerTimeSlot: true,
schedulingType: true,
workflows: {
include: {
workflow: {
+25 -7
View File
@@ -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]);
});
+2 -2
View File
@@ -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"
}
+2 -4
View File
@@ -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
}
}
+1 -1
View File
@@ -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();
}
-19
View File
@@ -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
View File
@@ -1,4 +1,3 @@
export { default as classNames } from "./classNames";
export { getLuckyUsers } from "./getLuckyUsers";
export { default as isPrismaObj, isPrismaObjOrUndefined } from "./isPrismaObj";
export * from "./isRecurringEvent";
+16
View File
@@ -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) {
+67
View File
@@ -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
View File
@@ -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";
+6 -16
View File
@@ -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"
}
]
]
+1
View File
@@ -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() {
+15
View File
@@ -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
View File
@@ -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"
]
}
+18 -1
View File
@@ -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"