fix: recurring round robin events (#13471)

* use same lucky user + check availability

* add tests for recurring round robin events

* only use luckyUsers for recurring bookings

* don't check availability for all recurring dates

* code clean up

* pass schedulingType

* fix type error

* add availability check for fixed hosts + test

* fix type error

* implement feedback

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
This commit is contained in:
Carina Wollendorfer
2024-02-06 18:18:42 +00:00
committed by GitHub
co-authored by CarinaWolli Udit Takkar
parent 684f4a795c
commit 271f31730d
9 changed files with 1271 additions and 547 deletions
+30 -1
View File
@@ -6,6 +6,7 @@ import type { BookingResponse, RecurringBookingCreateBody } from "@calcom/featur
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import getIP from "@calcom/lib/getIP";
import { defaultResponder } from "@calcom/lib/server";
import { SchedulingType } from "@calcom/prisma/client";
import type { AppsStatus } from "@calcom/types/Calendar";
// @TODO: Didn't look at the contents of this function in order to not break old booking page.
@@ -30,7 +31,34 @@ async function handler(req: NextApiRequest & { userId?: number }, res: NextApiRe
const numSlotsToCheckForAvailability = 2;
let thirdPartyRecurringEventId = null;
for (let key = 0; key < data.length; key++) {
// for round robin, the first slot needs to be handled first to define the lucky user
const firstBooking = data[0];
const isRoundRobin = firstBooking.schedulingType === SchedulingType.ROUND_ROBIN;
let luckyUsers = undefined;
if (isRoundRobin) {
const recurringEventReq: NextApiRequest & { userId?: number } = req;
recurringEventReq.body = {
...firstBooking,
appsStatus,
allRecurringDates,
isFirstRecurringSlot: true,
thirdPartyRecurringEventId,
numSlotsToCheckForAvailability,
currentRecurringIndex: 0,
noEmail: false,
};
const firstBookingResult = await handleNewBooking(recurringEventReq, {
isNotAnApiCall: true,
});
luckyUsers = firstBookingResult.luckyUsers?.map((user) => user.id);
}
for (let key = isRoundRobin ? 1 : 0; key < data.length; key++) {
const booking = data[key];
// Disable AppStatus in Recurring Booking Email as it requires us to iterate backwards to be able to compute the AppsStatus for all the bookings except the very first slot and then send that slot's email with statuses
// It is also doubtful that how useful is to have the AppsStatus of all the bookings in the email.
@@ -61,6 +89,7 @@ async function handler(req: NextApiRequest & { userId?: number }, res: NextApiRe
numSlotsToCheckForAvailability,
currentRecurringIndex: key,
noEmail: key !== 0,
luckyUsers,
};
const promiseEachRecurringBooking: ReturnType<typeof handleNewBooking> = handleNewBooking(
+2 -2
View File
@@ -798,10 +798,10 @@ describe("getSchedule", () => {
schedulingType: "COLLECTIVE",
hosts: [
{
id: 101,
userId: 101,
},
{
id: 102,
userId: 102,
},
],
},
@@ -49,11 +49,15 @@ type InputWorkflow = {
action: WorkflowActions;
template: WorkflowTemplates;
};
type InputHost = {
userId: number;
isFixed?: boolean;
};
/**
* Data to be mocked
*/
export type ScenarioData = {
// hosts: { id: number; eventTypeId?: number; userId?: number; isFixed?: boolean }[];
/**
* Prisma would return these eventTypes
*/
@@ -119,7 +123,7 @@ export type InputEventType = {
* These user ids are `ScenarioData["users"]["id"]`
*/
users?: { id: number }[];
hosts?: { id: number }[];
hosts?: InputHost[];
schedulingType?: SchedulingType;
beforeEventBuffer?: number;
afterEventBuffer?: number;
@@ -161,6 +165,20 @@ export const Timezones = {
"+6:00": "Asia/Dhaka",
};
async function addHostsToDb(eventTypes: InputEventType[]) {
for (const eventType of eventTypes) {
if (eventType.hosts && eventType.hosts.length > 0) {
await prismock.host.createMany({
data: eventType.hosts.map((host) => ({
userId: host.userId,
eventTypeId: eventType.id,
isFixed: host.isFixed ?? false,
})),
});
}
}
}
async function addEventTypesToDb(
eventTypes: (Omit<
Prisma.EventTypeCreateInput,
@@ -283,6 +301,7 @@ async function addEventTypes(eventTypes: InputEventType[], usersStore: InputUser
}
: eventType.schedule,
owner: eventType.owner ? { connect: { id: eventType.owner } } : undefined,
schedulingType: eventType.schedulingType,
};
});
log.silly("TestData: Creating EventType", JSON.stringify(eventTypesWithUsers));
@@ -586,6 +605,7 @@ export async function createBookingScenario(data: ScenarioData) {
);
}
const eventTypes = await addEventTypes(data.eventTypes, data.users);
await addHostsToDb(data.eventTypes);
data.bookings = data.bookings || [];
// allowSuccessfulBookingCreation();
@@ -963,8 +983,7 @@ export function getScenarioData(
webhooks,
workflows,
bookings,
}: // hosts = [],
{
}: {
organizer: ReturnType<typeof getOrganizer>;
eventTypes: ScenarioData["eventTypes"];
apps?: ScenarioData["apps"];
@@ -972,7 +991,6 @@ export function getScenarioData(
webhooks?: ScenarioData["webhooks"];
workflows?: ScenarioData["workflows"];
bookings?: ScenarioData["bookings"];
// hosts?: ScenarioData["hosts"];
},
org?: { id: number | null } | undefined | null
) {
@@ -1003,7 +1021,6 @@ export function getScenarioData(
}
});
return {
// hosts: [...hosts],
eventTypes: eventTypes.map((eventType, index) => {
return {
...eventType,
@@ -1,3 +1,4 @@
import type { SchedulingType } from "@calcom/prisma/client";
import { getDate } from "@calcom/web/test/utils/bookingScenario/bookingScenario";
export const DEFAULT_TIMEZONE_BOOKER = "Asia/Kolkata";
@@ -24,6 +25,7 @@ export function getMockRequestDataForBooking({
bookingUid?: string;
recurringEventId?: string;
recurringCount?: number;
schedulingType?: SchedulingType;
responses: {
email: string;
name: string;
@@ -85,6 +85,7 @@ export const mapRecurringBookingToMutationInput = (
.add(booking.duration || booking.event.length, "minute")
.format(),
recurringEventId,
schedulingType: booking.event.schedulingType || undefined,
recurringCount: recurringDates.length,
}));
};
@@ -1011,6 +1011,7 @@ async function handler(
notes: additionalNotes,
smsReminderNumber,
rescheduleReason,
luckyUsers,
...reqBody
} = await getBookingData({
req,
@@ -1227,6 +1228,8 @@ async function handler(
}
}
let luckyUserResponse;
//checks what users are available
if (!eventType.seatsPerTimeSlot) {
const eventTypeWithUsers: Awaited<ReturnType<typeof getEventTypesFromDB>> & {
@@ -1241,15 +1244,37 @@ async function handler(
},
}),
};
if (req.body.allRecurringDates) {
if (req.body.isFirstRecurringSlot) {
for (
let i = 0;
i < req.body.allRecurringDates.length && i < req.body.numSlotsToCheckForAvailability;
i++
) {
const start = req.body.allRecurringDates[i].start;
const end = req.body.allRecurringDates[i].end;
if (req.body.allRecurringDates && req.body.isFirstRecurringSlot) {
const isTeamEvent =
eventType.schedulingType === SchedulingType.COLLECTIVE ||
eventType.schedulingType === SchedulingType.ROUND_ROBIN;
const fixedUsers = isTeamEvent
? eventTypeWithUsers.users.filter((user: IsFixedAwareUser) => user.isFixed)
: [];
for (
let i = 0;
i < req.body.allRecurringDates.length && i < req.body.numSlotsToCheckForAvailability;
i++
) {
const start = req.body.allRecurringDates[i].start;
const end = req.body.allRecurringDates[i].end;
if (isTeamEvent) {
// each fixed user must be available
for (const key in fixedUsers) {
await ensureAvailableUsers(
{ ...eventTypeWithUsers, users: [fixedUsers[key]] },
{
dateFrom: dayjs(start).tz(reqBody.timeZone).format(),
dateTo: dayjs(end).tz(reqBody.timeZone).format(),
timeZone: reqBody.timeZone,
originalRescheduledBooking,
},
loggerWithEventDetails
);
}
} else {
await ensureAvailableUsers(
eventTypeWithUsers,
{
@@ -1263,6 +1288,7 @@ async function handler(
}
}
}
if (!req.body.allRecurringDates || req.body.isFirstRecurringSlot) {
const availableUsers = await ensureAvailableUsers(
eventTypeWithUsers,
@@ -1277,6 +1303,8 @@ async function handler(
const luckyUsers: typeof users = [];
const luckyUserPool = availableUsers.filter((user) => !user.isFixed);
const notAvailableLuckyUsers: typeof users = [];
loggerWithEventDetails.debug(
"Computed available users",
safeStringify({
@@ -1289,14 +1317,46 @@ async function handler(
const newLuckyUser = await getLuckyUser("MAXIMIZE_AVAILABILITY", {
// find a lucky user that is not already in the luckyUsers array
availableUsers: luckyUserPool.filter(
(user) => !luckyUsers.find((existing) => existing.id === user.id)
(user) => !luckyUsers.concat(notAvailableLuckyUsers).find((existing) => existing.id === user.id)
),
eventTypeId: eventType.id,
});
if (!newLuckyUser) {
break; // prevent infinite loop
}
luckyUsers.push(newLuckyUser);
if (req.body.isFirstRecurringSlot && eventType.schedulingType === SchedulingType.ROUND_ROBIN) {
// for recurring round robin events check if lucky user is available for next slots
try {
for (
let i = 0;
i < req.body.allRecurringDates.length && i < req.body.numSlotsToCheckForAvailability;
i++
) {
const start = req.body.allRecurringDates[i].start;
const end = req.body.allRecurringDates[i].end;
await ensureAvailableUsers(
{ ...eventTypeWithUsers, users: [newLuckyUser] },
{
dateFrom: dayjs(start).tz(reqBody.timeZone).format(),
dateTo: dayjs(end).tz(reqBody.timeZone).format(),
timeZone: reqBody.timeZone,
originalRescheduledBooking,
},
loggerWithEventDetails
);
}
// if no error, then lucky user is available for the next slots
luckyUsers.push(newLuckyUser);
} catch {
notAvailableLuckyUsers.push(newLuckyUser);
loggerWithEventDetails.info(
`Round robin host ${newLuckyUser.name} not available for first two slots. Trying to find another host.`
);
}
} else {
luckyUsers.push(newLuckyUser);
}
}
// ALL fixed users must be available
if (
@@ -1306,9 +1366,22 @@ async function handler(
}
// Pushing fixed user before the luckyUser guarantees the (first) fixed user as the organizer.
users = [...availableUsers.filter((user) => user.isFixed), ...luckyUsers];
luckyUserResponse = { luckyUsers };
} else if (req.body.allRecurringDates && eventType.schedulingType === SchedulingType.ROUND_ROBIN) {
// all recurring slots except the first one
const luckyUsersFromFirstBooking = luckyUsers
? eventTypeWithUsers.users.filter((user) => luckyUsers.find((luckyUserId) => luckyUserId === user.id))
: [];
const fixedHosts = eventTypeWithUsers.users.filter((user: IsFixedAwareUser) => user.isFixed);
users = [...fixedHosts, ...luckyUsersFromFirstBooking];
}
}
if (users.length === 0 && eventType.schedulingType === SchedulingType.ROUND_ROBIN) {
loggerWithEventDetails.error(`No available users found for round robin event.`);
throw new Error(ErrorCode.NoAvailableUsersFound);
}
const [organizerUser] = users;
const tOrganizer = await getTranslation(organizerUser?.locale ?? "en", "common");
@@ -1577,7 +1650,7 @@ async function handler(
});
if (newBooking) {
req.statusCode = 201;
return newBooking;
return { ...newBooking, ...luckyUserResponse };
}
}
if (isTeamEventType) {
@@ -2171,7 +2244,13 @@ async function handler(
});
req.statusCode = 201;
return { ...booking, message: "Payment required", paymentUid: payment?.uid, paymentId: payment?.id };
return {
...booking,
...luckyUserResponse,
message: "Payment required",
paymentUid: payment?.uid,
paymentId: payment?.id,
};
}
loggerWithEventDetails.debug(`Booking ${organizerUser.username} completed`);
@@ -2296,6 +2375,7 @@ async function handler(
req.statusCode = 201;
return {
...booking,
...luckyUserResponse,
references: referencesToCreate,
seatReferenceUid: evt.attendeeSeatId,
};
File diff suppressed because it is too large Load Diff
+3
View File
@@ -4,6 +4,8 @@ import type { BookingCreateBody } from "@calcom/prisma/zod-utils";
import type { RouterOutputs } from "@calcom/trpc/react";
import type { AppsStatus } from "@calcom/types/Calendar";
import type { SchedulingType } from ".prisma/client";
export type PublicEvent = NonNullable<RouterOutputs["viewer"]["public"]["event"]>;
export type ValidationErrors<T extends object> = { key: FieldPath<T>; error: ErrorOption }[];
@@ -27,6 +29,7 @@ export type RecurringBookingCreateBody = BookingCreateBody & {
appsStatus?: AppsStatus[] | undefined;
allRecurringDates?: Record<string, string>[];
currentRecurringIndex?: number;
schedulingType?: SchedulingType;
};
export type BookingResponse = Awaited<
+1
View File
@@ -271,6 +271,7 @@ export const extendedBookingCreateBody = bookingCreateBodySchema.merge(
})
)
.optional(),
luckyUsers: z.array(z.number()).optional(),
})
);