feat: round robin reset interval for teams (#19441)

* add component to set interval

* fix variable namings

* fetch data for given interval

* migration

* fix type errors in test

* test correct filtering for interval

* fix start end end times to interval

* fix type error

* pass rrResetInterval to getOrderedListOfLuckyUsers

* fix imports

* add translations

* add missing translation

* code clean up

* fix type errors

* fix type error

* fix import

* add translation

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
This commit is contained in:
authored and GitHub committed 2025-03-10 17:34:47 +00:00
1 parent 1e86f1046b
commit 6758654796
17 files changed
+357 -98

No files matched your search

@@ -2988,6 +2988,10 @@
"desc": "Desc",
"verify_email": "Verify email",
"verify_email_change": "Verify email change",
"rr_reset_interval": "Round Robin Reset Interval",
"interval": "Interval",
"rr_reset_interval_description": "Determines how often the round robin booking count resets to ensure balanced distribution.",
"daily": "Daily",
"end_date_not_selected": "You didn't set an end date for your OOO",
"my_ooo": "My OOO",
"team_ooo": "Team OOO",
@@ -3001,6 +3005,7 @@
"ooo_team_redirect_infinite_not_allowed": "There is already a reverse forwarding between these users.",
"previous_ooo_empty_title": "No previous OOO found",
"previous": "Previous",
"rr_interval_successfully_updated":"Round Robin interval successfully updated",
"trial_mode": "Trial mode",
"inactive_team_plan": "Inactive team plan",
"inactive_team_plan_description": "Your team plan is inactive. Check your subcription or reach out to customer support",
@@ -42,6 +42,7 @@ export const getEventTypesFromDB = async (eventTypeId: number) => {
parentId: true,
bookingLimits: true,
includeManagedEventsInLimits: true,
rrResetInterval: true,
},
},
bookingFields: true,
@@ -0,0 +1,101 @@
"use client";
import { useForm, Controller } from "react-hook-form";
import SectionBottomActions from "@calcom/features/settings/SectionBottomActions";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { MembershipRole, RRResetInterval } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc/react";
import type { RouterOutputs } from "@calcom/trpc/react";
import { Form, showToast, Label, Select, Button } from "@calcom/ui";
interface ProfileViewProps {
team: RouterOutputs["viewer"]["teams"]["get"];
}
const RoundRobinResetInterval = ({ team }: ProfileViewProps) => {
const { t } = useLocale();
const utils = trpc.useUtils();
const intervalOptions: {
value: RRResetInterval;
label: string;
}[] = [
{
value: RRResetInterval.MONTH,
label: t("monthly"),
},
{
value: RRResetInterval.DAY,
label: t("daily"),
},
];
const form = useForm<{ rrResetInterval: RRResetInterval }>({
defaultValues: {
rrResetInterval: team?.rrResetInterval ?? RRResetInterval.MONTH,
},
});
const mutation = trpc.viewer.teams.update.useMutation({
onError: (err) => {
showToast(err.message, "error");
},
async onSuccess(res) {
await utils.viewer.teams.get.invalidate();
showToast(t("rr_interval_successfully_updated"), "success");
},
});
const isAdmin =
team && (team.membership.role === MembershipRole.OWNER || team.membership.role === MembershipRole.ADMIN);
if (!isAdmin) {
return (
<div className="border-subtle rounded-md border p-5">
<span className="text-default text-sm">{t("only_owner_change")}</span>
</div>
);
}
return (
<Form
form={form}
handleSubmit={(values) => {
mutation.mutate({ ...values, id: team.id });
}}>
<Controller
name="rrResetInterval"
render={({ field: { value, onChange } }) => {
return (
<div className="border-subtle mt-6 space-x-3 rounded-t-lg border border-b-0 px-4 py-6 sm:px-6">
<div>
<h4 className="text-emphasis text-sm font-semibold leading-5">{t("rr_reset_interval")}</h4>
<p className="text-default text-sm leading-tight">{t("rr_reset_interval_description")}</p>
<div className="-mx-4 mt-4 border-t sm:-mx-6">
<div className="px-4 pt-6 sm:px-6">
<Label>{t("interval")}</Label>
<Select
options={intervalOptions}
value={intervalOptions.find((opt) => opt.value === value)}
onChange={(val) => {
onChange(val?.value);
}}
/>
</div>
</div>
</div>
</div>
);
}}
/>
<SectionBottomActions align="end">
<Button type="submit" color="primary" loading={mutation.isPending}>
{t("update")}
</Button>
</SectionBottomActions>
</Form>
);
};
export default RoundRobinResetInterval;
@@ -18,6 +18,7 @@ import { Button, CheckboxField, Form, SettingsToggle, showToast } from "@calcom/
import classNames from "@calcom/ui/classNames";
import { default as InternalNotePresetsView } from "../components/InternalNotePresetsView";
import RoundRobinResetInterval from "../components/RoundRobinResetInterval";
type ProfileViewProps = { team: RouterOutputs["viewer"]["teams"]["get"] };
@@ -171,6 +172,7 @@ const TeamSettingsViewWrapper = () => {
<>
<BookingLimitsView team={team} />
<InternalNotePresetsView team={team} />
<RoundRobinResetInterval team={team} />
</>
);
};
@@ -1,6 +1,7 @@
import { describe, expect, it, vi, afterEach } from "vitest";
import prisma from "@calcom/prisma";
import { RRResetInterval } from "@calcom/prisma/client";
import { getOrderedListOfLuckyUsers } from "../server/getLuckyUser";
import { filterHostsByLeadThreshold, errorCodes } from "./filterHostsByLeadThreshold";
@@ -49,6 +50,7 @@ describe("filterHostByLeadThreshold", () => {
isRRWeightsEnabled: true,
team: {
parentId: null,
rrResetInterval: RRResetInterval.MONTH,
},
},
routingFormResponse: null,
@@ -66,6 +68,7 @@ describe("filterHostByLeadThreshold", () => {
isRRWeightsEnabled: true,
team: {
parentId: null,
rrResetInterval: RRResetInterval.MONTH,
},
},
routingFormResponse: null,
@@ -112,6 +115,7 @@ describe("filterHostByLeadThreshold", () => {
isRRWeightsEnabled: false,
team: {
parentId: null,
rrResetInterval: RRResetInterval.MONTH,
},
},
routingFormResponse: null,
@@ -170,6 +174,7 @@ describe("filterHostByLeadThreshold", () => {
isRRWeightsEnabled: true,
team: {
parentId: null,
rrResetInterval: RRResetInterval.MONTH,
},
},
routingFormResponse: null,
@@ -184,6 +189,7 @@ describe("filterHostByLeadThreshold", () => {
isRRWeightsEnabled: true,
team: {
parentId: null,
rrResetInterval: RRResetInterval.MONTH,
},
},
routingFormResponse: null,
@@ -1,5 +1,5 @@
import logger from "@calcom/lib/logger";
import type { SelectedCalendar } from "@calcom/prisma/client";
import type { RRResetInterval, SelectedCalendar } from "@calcom/prisma/client";
import type { CredentialForCalendarService } from "@calcom/types/Credential";
import type { RoutingFormResponse } from "../server/getLuckyUser";
@@ -96,6 +96,7 @@ export const filterHostsByLeadThreshold = async <T extends BaseHost<BaseUser>>({
isRRWeightsEnabled: boolean;
team: {
parentId?: number | null;
rrResetInterval: RRResetInterval | null;
} | null;
};
routingFormResponse: RoutingFormResponse | null;
@@ -3,7 +3,7 @@ import prismaMock from "../../../tests/libs/__mocks__/prismaMock";
import { vi, it, describe, expect, afterEach } from "vitest";
import type { Mock } from "vitest";
import { SchedulingType } from "@calcom/prisma/enums";
import { RRResetInterval, SchedulingType } from "@calcom/prisma/enums";
import { filterHostsByLeadThreshold } from "./filterHostsByLeadThreshold";
import { findQualifiedHostsWithDelegationCredentials } from "./findQualifiedHostsWithDelegationCredentials";
@@ -84,6 +84,7 @@ describe("findQualifiedHostsWithDelegationCredentials", async () => {
team: {
id: 1,
parentId: null,
rrResetInterval: RRResetInterval.MONTH,
},
};
@@ -135,6 +136,7 @@ describe("findQualifiedHostsWithDelegationCredentials", async () => {
team: {
id: 1,
parentId: null,
rrResetInterval: RRResetInterval.MONTH,
},
};
@@ -221,6 +223,7 @@ describe("findQualifiedHostsWithDelegationCredentials", async () => {
team: {
id: 1,
parentId: null,
rrResetInterval: RRResetInterval.MONTH,
},
};
@@ -309,6 +312,7 @@ describe("findQualifiedHostsWithDelegationCredentials", async () => {
team: {
id: 1,
parentId: null,
rrResetInterval: RRResetInterval.MONTH,
},
};
@@ -386,6 +390,7 @@ describe("findQualifiedHostsWithDelegationCredentials", async () => {
team: {
id: 1,
parentId: null,
rrResetInterval: RRResetInterval.MONTH,
},
};
prismaMock.booking.findFirst.mockResolvedValue({ userId: 2 });
@@ -461,6 +466,7 @@ describe("findQualifiedHostsWithDelegationCredentials", async () => {
team: {
id: 1,
parentId: null,
rrResetInterval: RRResetInterval.MONTH,
},
};
@@ -553,6 +559,7 @@ describe("findQualifiedHostsWithDelegationCredentials", async () => {
team: {
id: 1,
parentId: null,
rrResetInterval: RRResetInterval.MONTH,
},
};
@@ -646,6 +653,7 @@ describe("findQualifiedHostsWithDelegationCredentials", async () => {
team: {
id: 1,
parentId: null,
rrResetInterval: RRResetInterval.MONTH,
},
};
+2 -1
View File
@@ -2,6 +2,7 @@ import logger from "@calcom/lib/logger";
import { findTeamMembersMatchingAttributeLogic } from "@calcom/lib/raqb/findTeamMembersMatchingAttributeLogic";
import type { AttributesQueryValue } from "@calcom/lib/raqb/types";
import { safeStringify } from "@calcom/lib/safeStringify";
import type { RRResetInterval } from "@calcom/prisma/client";
import { SchedulingType } from "@calcom/prisma/enums";
import type { CredentialPayload } from "@calcom/types/Credential";
@@ -80,7 +81,7 @@ export type EventType = {
assignAllTeamMembers: boolean;
assignRRMembersUsingSegment: boolean;
rrSegmentQueryValue: AttributesQueryValue | null | undefined;
team: { id: number; parentId: number | null } | null;
team: { id: number; parentId: number | null; rrResetInterval: RRResetInterval | null } | null;
};
export function getNormalizedHosts<User extends BaseUser, Host extends BaseHost<User>>({
+1
View File
@@ -124,6 +124,7 @@ const commons = {
fieldTranslations: [],
maxLeadThreshold: null,
useEventLevelSelectedCalendars: false,
rrResetInterval: null,
};
export const dynamicEvent = {
+88 -14
View File
@@ -6,7 +6,7 @@ import { expect, it, describe, vi, beforeAll } from "vitest";
import dayjs from "@calcom/dayjs";
import { buildUser, buildBooking } from "@calcom/lib/test/builder";
import { AttributeType } from "@calcom/prisma/enums";
import { AttributeType, RRResetInterval } from "@calcom/prisma/enums";
import { getLuckyUser, prepareQueuesAndAttributesData } from "./getLuckyUser";
@@ -65,7 +65,7 @@ it("can find lucky user with maximize availability", async () => {
eventType: {
id: 1,
isRRWeightsEnabled: false,
team: {},
team: { rrResetInterval: RRResetInterval.MONTH },
},
allRRHosts: [],
routingFormResponse: null,
@@ -118,7 +118,7 @@ it("can find lucky user with maximize availability and priority ranking", async
eventType: {
id: 1,
isRRWeightsEnabled: false,
team: {},
team: { rrResetInterval: RRResetInterval.MONTH },
},
allRRHosts: [],
routingFormResponse: null,
@@ -175,7 +175,7 @@ it("can find lucky user with maximize availability and priority ranking", async
eventType: {
id: 1,
isRRWeightsEnabled: false,
team: {},
team: { rrResetInterval: RRResetInterval.MONTH },
},
allRRHosts: [],
routingFormResponse: null,
@@ -237,7 +237,7 @@ it("can find lucky user with maximize availability and priority ranking", async
eventType: {
id: 1,
isRRWeightsEnabled: false,
team: {},
team: { rrResetInterval: RRResetInterval.MONTH },
},
allRRHosts: [],
routingFormResponse: null,
@@ -328,12 +328,22 @@ describe("maximize availability and weights", () => {
eventType: {
id: 1,
isRRWeightsEnabled: true,
team: {},
team: { rrResetInterval: RRResetInterval.MONTH },
},
allRRHosts,
routingFormResponse: null,
})
).resolves.toStrictEqual(users[1]);
const queryArgs = prismaMock.booking.findMany.mock.calls[0][0];
// Today: 2021-06-20T11:59:59Z, monthly interval
expect(queryArgs.where?.createdAt).toEqual(
expect.objectContaining({
gte: new Date("2021-06-01T00:00:00Z"),
lte: new Date("2021-06-20T11:59:59.000Z"),
})
);
});
it("can find lucky user if hosts have different weights", async () => {
@@ -426,12 +436,22 @@ describe("maximize availability and weights", () => {
eventType: {
id: 1,
isRRWeightsEnabled: true,
team: {},
team: { rrResetInterval: RRResetInterval.DAY },
},
allRRHosts,
routingFormResponse: null,
})
).resolves.toStrictEqual(users[0]);
const queryArgs = prismaMock.booking.findMany.mock.calls[0][0];
// Today: 2021-06-20T11:59:59Z, daily interval
expect(queryArgs.where?.createdAt).toEqual(
expect.objectContaining({
gte: new Date("2021-06-20T00:00:00Z"),
lte: new Date("2021-06-20T11:59:59.000Z"),
})
);
});
it("can find lucky user with weights and adjusted weights", async () => {
@@ -524,12 +544,22 @@ describe("maximize availability and weights", () => {
eventType: {
id: 1,
isRRWeightsEnabled: true,
team: {},
team: { rrResetInterval: RRResetInterval.DAY },
},
allRRHosts,
routingFormResponse: null,
})
).resolves.toStrictEqual(users[0]);
const queryArgs = prismaMock.booking.findMany.mock.calls[0][0];
// Today: 2021-06-20T11:59:59Z, daily interval
expect(queryArgs.where?.createdAt).toEqual(
expect.objectContaining({
gte: new Date("2021-06-20T00:00:00Z"),
lte: new Date("2021-06-20T11:59:59.000Z"),
})
);
});
it("applies calibration when user had OOO entries this month", async () => {
@@ -609,12 +639,22 @@ describe("maximize availability and weights", () => {
eventType: {
id: 1,
isRRWeightsEnabled: true,
team: {},
team: { rrResetInterval: RRResetInterval.MONTH },
},
allRRHosts,
routingFormResponse: null,
})
).resolves.toStrictEqual(users[1]); // user[1] has one more bookings, but user[0] has calibration 2
const queryArgs = prismaMock.booking.findMany.mock.calls[0][0];
// Today: 2021-06-20T11:59:59Z, monthly interval
expect(queryArgs.where?.createdAt).toEqual(
expect.objectContaining({
gte: new Date("2021-06-01T00:00:00Z"),
lte: new Date("2021-06-20T11:59:59.000Z"),
})
);
});
it("applies calibration when user had full day calendar events this month", async () => {
@@ -706,12 +746,22 @@ describe("maximize availability and weights", () => {
eventType: {
id: 1,
isRRWeightsEnabled: true,
team: {},
team: { rrResetInterval: RRResetInterval.MONTH },
},
allRRHosts,
routingFormResponse: null,
})
).resolves.toStrictEqual(users[1]); // user[1] has one more booking, but user[0] has calibration 2
const queryArgs = prismaMock.booking.findMany.mock.calls[0][0];
// Today: 2021-06-20T11:59:59Z, monthly interval
expect(queryArgs.where?.createdAt).toEqual(
expect.objectContaining({
gte: new Date("2021-06-01T00:00:00Z"),
lte: new Date("2021-06-20T11:59:59.000Z"),
})
);
});
it("applies calibration to newly added hosts so they are not penalized unfairly compared to their peers", async () => {
@@ -791,7 +841,7 @@ describe("maximize availability and weights", () => {
eventType: {
id: 1,
isRRWeightsEnabled: true,
team: {},
team: { rrResetInterval: RRResetInterval.MONTH },
},
allRRHosts,
routingFormResponse: null,
@@ -816,12 +866,22 @@ describe("maximize availability and weights", () => {
eventType: {
id: 1,
isRRWeightsEnabled: true,
team: {},
team: { rrResetInterval: RRResetInterval.MONTH },
},
allRRHosts,
routingFormResponse: null,
})
).resolves.toStrictEqual(users[0]);
const queryArgs = prismaMock.booking.findMany.mock.calls[0][0];
// Today: 2021-06-20T11:59:59Z, monthly interval
expect(queryArgs.where?.createdAt).toEqual(
expect.objectContaining({
gte: new Date("2021-06-01T00:00:00Z"),
lte: new Date("2021-06-20T11:59:59.000Z"),
})
);
});
});
@@ -950,7 +1010,11 @@ describe("attribute weights and virtual queues", () => {
});
const queuesAndAttributesData = await prepareQueuesAndAttributesData({
eventType: { id: 1, isRRWeightsEnabled: true, team: { parentId: 1 } },
eventType: {
id: 1,
isRRWeightsEnabled: true,
team: { parentId: 1, rrResetInterval: RRResetInterval.DAY },
},
routingFormResponse,
allRRHosts: [
{
@@ -1190,11 +1254,21 @@ describe("attribute weights and virtual queues", () => {
eventType: {
id: 1,
isRRWeightsEnabled: true,
team: { parentId: 1 },
team: { parentId: 1, rrResetInterval: RRResetInterval.DAY },
},
allRRHosts,
routingFormResponse,
})
).resolves.toStrictEqual(users[1]);
const queryArgs = prismaMock.booking.findMany.mock.calls[0][0];
// Today: 2021-06-20T11:59:59Z, daily interval
expect(queryArgs.where?.createdAt).toEqual(
expect.objectContaining({
gte: new Date("2021-06-20T00:00:00Z"),
lte: new Date("2021-06-20T11:59:59.000Z"),
})
);
});
});
+104 -78
View File
@@ -13,7 +13,7 @@ import prisma from "@calcom/prisma";
import type { Booking } from "@calcom/prisma/client";
import type { SelectedCalendar } from "@calcom/prisma/client";
import type { AttributeType } from "@calcom/prisma/enums";
import { BookingStatus } from "@calcom/prisma/enums";
import { BookingStatus, RRResetInterval } from "@calcom/prisma/enums";
import type { EventBusyDate } from "@calcom/types/Calendar";
import type { CredentialForCalendarService } from "@calcom/types/Credential";
@@ -63,7 +63,11 @@ type VirtualQueuesDataType = {
interface GetLuckyUserParams<T extends PartialUser> {
availableUsers: [T, ...T[]]; // ensure contains at least 1
eventType: { id: number; isRRWeightsEnabled: boolean; team: { parentId?: number | null } | null };
eventType: {
id: number;
isRRWeightsEnabled: boolean;
team: { parentId?: number | null; rrResetInterval: RRResetInterval | null } | null;
};
// all routedTeamMemberIds or all hosts of event types
allRRHosts: {
user: {
@@ -77,9 +81,20 @@ interface GetLuckyUserParams<T extends PartialUser> {
}[];
routingFormResponse: RoutingFormResponse | null;
}
// === dayjs.utc().startOf("month").toDate();
const startOfMonth = () => new Date(Date.UTC(new Date().getUTCFullYear(), new Date().getUTCMonth(), 1));
const startOfToday = () =>
new Date(Date.UTC(new Date().getUTCFullYear(), new Date().getUTCMonth(), new Date().getUTCDate()));
const getIntervalStartDate = (interval: RRResetInterval) => {
if (interval === RRResetInterval.DAY) {
return startOfToday();
}
return startOfMonth();
};
// TS helper function.
const isNonEmptyArray = <T>(arr: T[]): arr is [T, ...T[]] => arr.length > 0;
@@ -143,13 +158,13 @@ function leastRecentlyBookedUser<T extends PartialUser>({
function getHostsWithCalibration({
hosts,
allRRHostsBookingsOfThisMonth,
allRRHostsCreatedThisMonth,
allRRHostsBookingsOfInterval,
allRRHostsCreatedInInterval,
oooData,
}: {
hosts: { userId: number; email: string; createdAt: Date }[];
allRRHostsBookingsOfThisMonth: PartialBooking[];
allRRHostsCreatedThisMonth: { userId: number; createdAt: Date }[];
allRRHostsBookingsOfInterval: PartialBooking[];
allRRHostsCreatedInInterval: { userId: number; createdAt: Date }[];
oooData: OOODataType;
}) {
// Helper function to calculate calibration for a new host
@@ -177,7 +192,7 @@ function getHostsWithCalibration({
return calibration;
}
const existingBookings = allRRHostsBookingsOfThisMonth;
const existingBookings = allRRHostsBookingsOfInterval;
const oooCalibration = new Map<number, number>();
@@ -208,10 +223,10 @@ function getHostsWithCalibration({
}
> = new Map();
if (allRRHostsCreatedThisMonth.length && existingBookings.length) {
if (allRRHostsCreatedInInterval.length && existingBookings.length) {
// Calculate calibration for each new host and store in a Map
newHostsWithCalibration = new Map(
allRRHostsCreatedThisMonth.map((newHost) => [
allRRHostsCreatedInInterval.map((newHost) => [
newHost.userId,
{ ...newHost, calibration: calculateNewHostCalibration(newHost) },
])
@@ -256,24 +271,24 @@ function filterUsersBasedOnWeights<
}
>({
availableUsers,
currentMonthBookingsOfAvailableUsers,
bookingsOfNotAvailableUsersOfThisMonth,
bookingsOfAvailableUsersOfInterval,
bookingsOfNotAvailableUsersOfInterval,
allRRHosts,
allRRHostsBookingsOfThisMonth,
allRRHostsCreatedThisMonth,
allRRHostsBookingsOfInterval,
allRRHostsCreatedInInterval,
attributeWeights,
oooData,
}: GetLuckyUserParams<T> & FetchedData) {
//get all bookings of all other RR hosts that are not available
const allBookings = currentMonthBookingsOfAvailableUsers.concat(bookingsOfNotAvailableUsersOfThisMonth);
const allBookings = bookingsOfAvailableUsersOfInterval.concat(bookingsOfNotAvailableUsersOfInterval);
const allHostsWithCalibration = getHostsWithCalibration({
hosts: allRRHosts.map((host) => {
return { email: host.user.email, userId: host.user.id, createdAt: host.createdAt };
}),
allRRHostsBookingsOfThisMonth,
allRRHostsCreatedThisMonth,
allRRHostsBookingsOfInterval,
allRRHostsCreatedInInterval,
oooData,
});
@@ -304,7 +319,7 @@ function filterUsersBasedOnWeights<
userWeight = attributeWeights.find((userWeight) => userWeight.userId === user.id)?.weight ?? 100;
}
const targetPercentage = userWeight / totalWeight;
const userBookings = currentMonthBookingsOfAvailableUsers.filter(
const userBookings = bookingsOfAvailableUsersOfInterval.filter(
(booking) =>
booking.userId === user.id || booking.attendees.some((attendee) => attendee.email === user.email)
);
@@ -377,19 +392,20 @@ function filterUsersBasedOnWeights<
};
}
async function getCurrentMonthCalendarBusyTimes(
async function getCalendarBusyTimesOfInterval(
usersWithCredentials: {
id: number;
email: string;
credentials: CredentialForCalendarService[];
userLevelSelectedCalendars: SelectedCalendar[];
}[]
}[],
interval: RRResetInterval
): Promise<{ userId: number; busyTimes: (EventBusyDate & { timeZone?: string })[] }[]> {
return Promise.all(
usersWithCredentials.map((user) =>
getBusyCalendarTimes(
user.credentials,
startOfMonth().toISOString(),
getIntervalStartDate(interval).toISOString(),
new Date().toISOString(),
user.userLevelSelectedCalendars,
true
@@ -401,19 +417,21 @@ async function getCurrentMonthCalendarBusyTimes(
);
}
async function getCurrentMonthsBookings({
async function getBookingsOfInterval({
eventTypeId,
users,
virtualQueuesData,
interval,
}: {
eventTypeId: number;
users: { id: number; email: string }[];
virtualQueuesData: VirtualQueuesDataType | null;
interval: RRResetInterval;
}) {
return await BookingRepository.getAllBookingsForRoundRobin({
eventTypeId: eventTypeId,
users,
startDate: startOfMonth(),
startDate: getIntervalStartDate(interval),
endDate: new Date(),
virtualQueuesData,
});
@@ -426,10 +444,10 @@ export async function getLuckyUser<
}
>(getLuckyUserParams: GetLuckyUserParams<T>) {
const {
currentMonthBookingsOfAvailableUsers,
bookingsOfNotAvailableUsersOfThisMonth,
allRRHostsBookingsOfThisMonth,
allRRHostsCreatedThisMonth,
bookingsOfAvailableUsersOfInterval,
bookingsOfNotAvailableUsersOfInterval,
allRRHostsBookingsOfInterval,
allRRHostsCreatedInInterval,
organizersWithLastCreated,
attributeWeights,
virtualQueuesData,
@@ -438,10 +456,10 @@ export async function getLuckyUser<
const { luckyUser } = getLuckyUser_requiresDataToBePreFetched({
...getLuckyUserParams,
currentMonthBookingsOfAvailableUsers,
bookingsOfNotAvailableUsersOfThisMonth,
allRRHostsBookingsOfThisMonth,
allRRHostsCreatedThisMonth,
bookingsOfAvailableUsersOfInterval,
bookingsOfNotAvailableUsersOfInterval,
allRRHostsBookingsOfInterval,
allRRHostsCreatedInInterval,
organizersWithLastCreated,
attributeWeights,
virtualQueuesData,
@@ -452,10 +470,10 @@ export async function getLuckyUser<
}
type FetchedData = {
bookingsOfNotAvailableUsersOfThisMonth: PartialBooking[];
currentMonthBookingsOfAvailableUsers: PartialBooking[];
allRRHostsBookingsOfThisMonth: PartialBooking[];
allRRHostsCreatedThisMonth: { userId: number; createdAt: Date }[];
bookingsOfNotAvailableUsersOfInterval: PartialBooking[];
bookingsOfAvailableUsersOfInterval: PartialBooking[];
allRRHostsBookingsOfInterval: PartialBooking[];
allRRHostsCreatedInInterval: { userId: number; createdAt: Date }[];
organizersWithLastCreated: { id: number; bookings: { createdAt: Date }[] }[];
attributeWeights?:
| {
@@ -475,10 +493,10 @@ export function getLuckyUser_requiresDataToBePreFetched<
>({ availableUsers, ...getLuckyUserParams }: GetLuckyUserParams<T> & FetchedData) {
const {
eventType,
currentMonthBookingsOfAvailableUsers,
bookingsOfNotAvailableUsersOfThisMonth,
allRRHostsBookingsOfThisMonth,
allRRHostsCreatedThisMonth,
bookingsOfAvailableUsersOfInterval,
bookingsOfNotAvailableUsersOfInterval,
allRRHostsBookingsOfInterval,
allRRHostsCreatedInInterval,
organizersWithLastCreated,
oooData,
} = getLuckyUserParams;
@@ -501,10 +519,10 @@ export function getLuckyUser_requiresDataToBePreFetched<
} = filterUsersBasedOnWeights({
...getLuckyUserParams,
availableUsers,
currentMonthBookingsOfAvailableUsers,
bookingsOfNotAvailableUsersOfThisMonth,
allRRHostsBookingsOfThisMonth,
allRRHostsCreatedThisMonth,
bookingsOfAvailableUsersOfInterval,
bookingsOfNotAvailableUsersOfInterval,
allRRHostsBookingsOfInterval,
allRRHostsCreatedInInterval,
oooData,
});
availableUsers = remainingUsersAfterWeightFilter;
@@ -524,7 +542,7 @@ export function getLuckyUser_requiresDataToBePreFetched<
luckyUser: leastRecentlyBookedUser({
...getLuckyUserParams,
availableUsers: highestPriorityUsers,
bookingsOfAvailableUsers: currentMonthBookingsOfAvailableUsers,
bookingsOfAvailableUsers: bookingsOfAvailableUsersOfInterval,
organizersWithLastCreated,
}),
usersAndTheirBookingShortfalls,
@@ -571,35 +589,43 @@ async function fetchAllDataNeededForCalculations<
const { attributeWeights, virtualQueuesData } = await prepareQueuesAndAttributesData(getLuckyUserParams);
const interval = getLuckyUserParams.eventType.team?.rrResetInterval ?? RRResetInterval.MONTH;
const [
currentMonthUserBusyTimes,
currentMonthBookingsOfAvailableUsers,
bookingsOfNotAvailableUsersOfThisMonth,
allRRHostsBookingsOfThisMonth,
allRRHostsCreatedThisMonth,
userBusyTimesOfInterval,
bookingsOfAvailableUsersOfInterval,
bookingsOfNotAvailableUsersOfInterval,
allRRHostsBookingsOfInterval,
allRRHostsCreatedInInterval,
organizersWithLastCreated,
] = await Promise.all([
getCurrentMonthCalendarBusyTimes(allRRHosts.map((host) => host.user)),
getCurrentMonthsBookings({
getCalendarBusyTimesOfInterval(
allRRHosts.map((host) => host.user),
interval
),
getBookingsOfInterval({
eventTypeId: eventType.id,
users: availableUsers.map((user) => {
return { id: user.id, email: user.email };
}),
virtualQueuesData: virtualQueuesData ?? null,
interval,
}),
getCurrentMonthsBookings({
getBookingsOfInterval({
eventTypeId: eventType.id,
users: notAvailableHosts,
virtualQueuesData: virtualQueuesData ?? null,
interval,
}),
getCurrentMonthsBookings({
getBookingsOfInterval({
eventTypeId: eventType.id,
users: allRRHosts.map((host) => {
return { id: host.user.id, email: host.user.email };
}),
virtualQueuesData: virtualQueuesData ?? null,
interval,
}),
prisma.host.findMany({
@@ -610,7 +636,7 @@ async function fetchAllDataNeededForCalculations<
eventTypeId: eventType.id,
isFixed: false,
createdAt: {
gte: startOfMonth(),
gte: getIntervalStartDate(interval),
},
},
}),
@@ -657,7 +683,7 @@ async function fetchAllDataNeededForCalculations<
const userFullDayBusyTimes = new Map<number, { start: Date; end: Date }[]>();
currentMonthUserBusyTimes.forEach((userBusyTime) => {
userBusyTimesOfInterval.forEach((userBusyTime) => {
const fullDayBusyTimes = userBusyTime.busyTimes
.filter((busyTime) => {
//timeZone is always defined when calling getBusyCalendarTimes with includeTimeZone true
@@ -687,7 +713,7 @@ async function fetchAllDataNeededForCalculations<
},
end: {
lte: new Date(),
gte: startOfMonth(),
gte: getIntervalStartDate(interval),
},
},
select: {
@@ -725,10 +751,10 @@ async function fetchAllDataNeededForCalculations<
log.debug(
"fetchAllDataNeededForCalculations",
safeStringify({
currentMonthBookingsOfAvailableUsers: currentMonthBookingsOfAvailableUsers.length,
bookingsOfNotAvailableUsersOfThisMonth: bookingsOfNotAvailableUsersOfThisMonth.length,
allRRHostsBookingsOfThisMonth: allRRHostsBookingsOfThisMonth.length,
allRRHostsCreatedThisMonth: allRRHostsCreatedThisMonth.length,
bookingsOfAvailableUsersOfInterval: bookingsOfAvailableUsersOfInterval.length,
bookingsOfNotAvailableUsersOfInterval: bookingsOfNotAvailableUsersOfInterval.length,
allRRHostsBookingsOfInterval: allRRHostsBookingsOfInterval.length,
allRRHostsCreatedInInterval: allRRHostsCreatedInInterval.length,
virtualQueuesData,
attributeWeights,
oooData,
@@ -736,10 +762,10 @@ async function fetchAllDataNeededForCalculations<
);
return {
currentMonthBookingsOfAvailableUsers,
bookingsOfNotAvailableUsersOfThisMonth,
allRRHostsBookingsOfThisMonth,
allRRHostsCreatedThisMonth,
bookingsOfAvailableUsersOfInterval,
bookingsOfNotAvailableUsersOfInterval,
allRRHostsBookingsOfInterval,
allRRHostsCreatedInInterval,
organizersWithLastCreated,
attributeWeights,
virtualQueuesData,
@@ -758,10 +784,10 @@ export async function getOrderedListOfLuckyUsers<AvailableUser extends Available
const { availableUsers, eventType } = getLuckyUserParams;
const {
currentMonthBookingsOfAvailableUsers,
bookingsOfNotAvailableUsersOfThisMonth,
allRRHostsBookingsOfThisMonth,
allRRHostsCreatedThisMonth,
bookingsOfAvailableUsersOfInterval,
bookingsOfNotAvailableUsersOfInterval,
allRRHostsBookingsOfInterval,
allRRHostsCreatedInInterval,
organizersWithLastCreated,
attributeWeights,
virtualQueuesData,
@@ -774,16 +800,16 @@ export async function getOrderedListOfLuckyUsers<AvailableUser extends Available
availableUsers: availableUsers.map((user) => {
return { id: user.id, email: user.email, priority: user.priority, weight: user.weight };
}),
currentMonthBookingsOfAvailableUsers,
bookingsOfNotAvailableUsersOfThisMonth,
allRRHostsBookingsOfThisMonth,
allRRHostsCreatedThisMonth,
bookingsOfAvailableUsersOfInterval,
bookingsOfNotAvailableUsersOfInterval,
allRRHostsBookingsOfInterval,
allRRHostsCreatedInInterval,
organizersWithLastCreated,
})
);
let remainingAvailableUsers = [...availableUsers];
let currentMonthBookingsOfRemainingAvailableUsers = [...currentMonthBookingsOfAvailableUsers];
let bookingsOfRemainingAvailableUsersOfInterval = [...bookingsOfAvailableUsersOfInterval];
const orderedUsersSet = new Set<AvailableUser>();
const perUserBookingsCount: Record<number, number> = {};
@@ -801,10 +827,10 @@ export async function getOrderedListOfLuckyUsers<AvailableUser extends Available
...getLuckyUserParams,
eventType,
availableUsers: remainingAvailableUsers as [AvailableUser, ...AvailableUser[]],
currentMonthBookingsOfAvailableUsers: currentMonthBookingsOfRemainingAvailableUsers,
bookingsOfNotAvailableUsersOfThisMonth,
allRRHostsBookingsOfThisMonth,
allRRHostsCreatedThisMonth,
bookingsOfAvailableUsersOfInterval: bookingsOfRemainingAvailableUsersOfInterval,
bookingsOfNotAvailableUsersOfInterval,
allRRHostsBookingsOfInterval,
allRRHostsCreatedInInterval,
organizersWithLastCreated,
attributeWeights,
virtualQueuesData,
@@ -824,11 +850,11 @@ export async function getOrderedListOfLuckyUsers<AvailableUser extends Available
}
orderedUsersSet.add(luckyUser);
perUserBookingsCount[luckyUser.id] = currentMonthBookingsOfAvailableUsers.filter(
perUserBookingsCount[luckyUser.id] = bookingsOfAvailableUsersOfInterval.filter(
(booking) => booking.userId === luckyUser.id
).length;
remainingAvailableUsers = remainingAvailableUsers.filter((user) => user.id !== luckyUser.id);
currentMonthBookingsOfRemainingAvailableUsers = currentMonthBookingsOfRemainingAvailableUsers.filter(
bookingsOfRemainingAvailableUsersOfInterval = bookingsOfRemainingAvailableUsersOfInterval.filter(
(booking) => remainingAvailableUsers.map((user) => user.id).includes(booking.userId ?? 0)
);
}
@@ -313,6 +313,7 @@ export async function getTeamWithoutMembers(args: {
isPrivate: true,
metadata: true,
bookingLimits: true,
rrResetInterval: true,
includeManagedEventsInLimits: true,
parent: {
select: {
@@ -741,6 +741,7 @@ export class EventTypeRepository {
team: {
select: {
parentId: true,
rrResetInterval: true,
},
},
},
@@ -813,6 +814,7 @@ export class EventTypeRepository {
bookingLimits: true,
includeManagedEventsInLimits: true,
parentId: true,
rrResetInterval: true,
},
},
parent: {
@@ -0,0 +1,5 @@
-- CreateEnum
CREATE TYPE "RRResetInterval" AS ENUM ('MONTH', 'DAY');
-- AlterTable
ALTER TABLE "Team" ADD COLUMN "rrResetInterval" "RRResetInterval" DEFAULT 'MONTH';
+6
View File
@@ -446,6 +446,7 @@ model Team {
/// @zod.custom(imports.teamMetadataSchema)
metadata Json?
theme String?
rrResetInterval RRResetInterval? @default(MONTH)
brandColor String?
darkBrandColor String?
verifiedNumbers VerifiedNumber[]
@@ -1355,6 +1356,11 @@ enum FeatureType {
PERMISSION
}
enum RRResetInterval {
MONTH
DAY
}
model SelectedSlots {
id Int @id @default(autoincrement())
eventTypeId Int
@@ -65,6 +65,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
theme: input.theme,
bookingLimits: input.bookingLimits ?? undefined,
includeManagedEventsInLimits: input.includeManagedEventsInLimits ?? undefined,
rrResetInterval: input.rrResetInterval,
};
if (input.logo && input.logo.startsWith("data:image/png;base64,")) {
@@ -145,6 +146,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
darkBrandColor: updatedTeam.darkBrandColor,
bookingLimits: updatedTeam.bookingLimits as IntervalLimit,
includeManagedEventsInLimits: updatedTeam.includeManagedEventsInLimits,
rrResetInterval: updatedTeam.rrResetInterval,
};
};
@@ -1,10 +1,28 @@
import { z } from "zod";
import { intervalLimitsType } from "@calcom/lib/intervalLimits/intervalLimitSchema";
import type { IntervalLimit } from "@calcom/lib/intervalLimits/intervalLimitSchema";
import { resizeBase64Image } from "@calcom/lib/server/resizeBase64Image";
import slugify from "@calcom/lib/slugify";
export const ZUpdateInputSchema = z.object({
export type TUpdateInputSchema = {
id: number;
bio?: string;
name?: string;
logo?: string | null;
slug?: string;
hideBranding?: boolean;
hideBookATeamMember?: boolean;
isPrivate?: boolean;
brandColor?: string;
darkBrandColor?: string;
theme?: string | null;
bookingLimits?: IntervalLimit | null;
includeManagedEventsInLimits?: boolean;
rrResetInterval?: "DAY" | "MONTH";
};
export const ZUpdateInputSchema: z.Schema<TUpdateInputSchema> = z.object({
id: z.number(),
bio: z.string().optional(),
name: z.string().optional(),
@@ -25,6 +43,5 @@ export const ZUpdateInputSchema = z.object({
theme: z.string().optional().nullable(),
bookingLimits: intervalLimitsType.optional(),
includeManagedEventsInLimits: z.boolean().optional(),
rrResetInterval: z.enum(["DAY", "MONTH"]).optional(),
});
export type TUpdateInputSchema = z.infer<typeof ZUpdateInputSchema>;