Files
calendar/packages/lib/bookings/getRoutedUsers.ts
T
e29b7e83b8 feat: Round Robin groups (#22296)
* add Add Group button

* add host groups to schema

* UI for host groups

* raname groups to hostGroups

* schema update

* show groups in assignment tab

* add no group hosts to Group 1

* add dummy group for non group hosts

* fix type errors

* use two dimensional array for luckyUserPools

* fix empty array

* group RR hosts in handleNewBooking

* improve logic for grouping lucky users

* find all lucky users of all groups

* allow several RR hosts on booking

* clean up migrations

* create helper function

* group hosts for slots logic

* add group logic to loading available slots

* adding hosts to groups

* add groupId to hostSchema

* disable hosts from other groups

* handle groups in checkedTeamSelect

* fix adding hosts to groups

* remove and add groups

* show hosts if there are no groups

* fixing adding first group with existing hosts

* show groups empty groups correctly

* UI upddate fixes

* fix adding hosts to existing first host group

* small fixes + code clean up

* add availability fix with test

* create new round-robin test file

* disable reassignment

* fix losing fixed hosts

* fix updating weights and priorities

* disable load balancing with Round Robin Groups

* automatically disable load balancing in update handler

* allRRHosts should only include hosts from same group

* fix type errors

* fix type error

* fix tests

* fix type error

* remove undefined from groupId type

* type changes

* add tests for hostGroups

* add tests for host groups

* fixes

* fix type errors with undefined groupId

* remove seperate host groups prop

* fix editing weights

* remove console.log

* code clean up

* improve getAggregatedAvailability tests

* throw error when no available hosts in a group

* add fixme comment

* create constant for DEFAULT_GROUP_ID

* clean up code

* mock default_group_id for unit tests

* don't show fixed hosts in edit weights side bar

* add DEFAULT_GROUP_ID to  mock test-setup

* remove unused index variable

* code clean up

* fix updating host groups

* fix imports

* add default_group_id to mocks

* add uuid() to zod schema

* remove unused code

* fix singular translation key

* remove unnessary !!

* Revert formatting changes

* add additional tests for bookingActions

* use createMany

* import DEFAULT_GROUP_ID for mocks

* fix mocks

* clean up EventTeamAssignmentTab

* fix type errors in tests

* fix mocks

* remove constants.example.test.ts

* fix type error

* add missing groupId

* fix margin

* clean up empty host groups

* fix constants mock

* useCalback

* use reduce

* extract handlers into seperate functions

* fix handler functions

* fix border radius

* fix type error in CheckForEmptyAssignment

* fix type error

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2025-08-08 12:56:13 +01:00

217 lines
6.5 KiB
TypeScript

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 type { RRTimestampBasis } from "@calcom/prisma/enums";
import { SchedulingType } from "@calcom/prisma/enums";
import type { CredentialPayload } from "@calcom/types/Credential";
import { enrichHostsWithDelegationCredentials } from "../delegationCredential/server";
import getOrgIdFromMemberOrTeamId from "../getOrgIdFromMemberOrTeamId";
const log = logger.getSubLogger({ prefix: ["[getRoutedUsers]"] });
export const getRoutedUsersWithContactOwnerAndFixedUsers = <
T extends { id: number; isFixed?: boolean; email: string }
>({
routedTeamMemberIds,
users,
contactOwnerEmail,
}: {
routedTeamMemberIds: number[] | null;
users: T[];
contactOwnerEmail: string | null;
}) => {
// We don't want to enter a scenario where we have no team members to be booked
// So, let's just fallback to regular flow if no routedTeamMemberIds are provided
if (!routedTeamMemberIds || !routedTeamMemberIds.length) {
return users;
}
log.debug(
"filtering users as per routedTeamMemberIds",
safeStringify({ routedTeamMemberIds, contactOwnerEmail })
);
return users.filter(
(user) => routedTeamMemberIds.includes(user.id) || user.isFixed || user.email === contactOwnerEmail
);
};
async function findMatchingTeamMembersIdsForEventRRSegment(eventType: EventType) {
if (!eventType) {
return null;
}
const isSegmentationDisabled = !eventType.assignAllTeamMembers || !eventType.assignRRMembersUsingSegment;
if (isSegmentationDisabled) {
return null;
}
if (!eventType.team || !eventType.team.parentId) {
return null;
}
const { teamMembersMatchingAttributeLogic } = await findTeamMembersMatchingAttributeLogic({
attributesQueryValue: eventType.rrSegmentQueryValue ?? null,
teamId: eventType.team.id,
orgId: eventType.team.parentId,
});
if (!teamMembersMatchingAttributeLogic) {
return teamMembersMatchingAttributeLogic;
}
return teamMembersMatchingAttributeLogic.map((member) => member.userId);
}
type BaseUser = {
id: number;
email: string;
};
type BaseHost<User extends BaseUser> = {
isFixed: boolean;
createdAt: Date;
priority?: number | null;
weight?: number | null;
weightAdjustment?: number | null;
user: User;
groupId: string | null;
};
export type EventType = {
assignAllTeamMembers: boolean;
assignRRMembersUsingSegment: boolean;
rrSegmentQueryValue: AttributesQueryValue | null | undefined;
team: {
id: number;
parentId: number | null;
rrResetInterval: RRResetInterval | null;
rrTimestampBasis: RRTimestampBasis;
} | null;
};
export function getNormalizedHosts<User extends BaseUser, Host extends BaseHost<User>>({
eventType,
}: {
eventType: {
schedulingType: SchedulingType | null;
hosts?: Host[];
users: User[];
};
}) {
if (eventType.hosts?.length && eventType.schedulingType) {
return {
hosts: eventType.hosts.map((host) => ({
isFixed: host.isFixed,
user: host.user,
priority: host.priority,
weight: host.weight,
createdAt: host.createdAt,
groupId: host.groupId,
})),
fallbackHosts: null,
};
} else {
return {
hosts: null,
fallbackHosts: eventType.users.map((user) => {
return {
isFixed: !eventType.schedulingType || eventType.schedulingType === SchedulingType.COLLECTIVE,
email: user.email,
user: user,
createdAt: null,
groupId: null,
};
}),
};
}
}
type BaseUserWithCredentialPayload = BaseUser & { credentials: CredentialPayload[] };
export async function getNormalizedHostsWithDelegationCredentials<
User extends BaseUserWithCredentialPayload,
Host extends BaseHost<User>
>({
eventType,
}: {
eventType: {
schedulingType: SchedulingType | null;
hosts?: Host[];
users: User[];
teamId?: number;
};
}) {
if (eventType.hosts?.length && eventType.schedulingType) {
const hostsWithoutDelegationCredential = eventType.hosts.map((host) => ({
isFixed: host.isFixed,
user: host.user,
priority: host.priority,
weight: host.weight,
createdAt: host.createdAt,
groupId: host.groupId,
}));
const firstHost = hostsWithoutDelegationCredential[0];
const firstUserOrgId = await getOrgIdFromMemberOrTeamId({
memberId: firstHost?.user?.id ?? null,
teamId: eventType.teamId,
});
const hostsEnrichedWithDelegationCredential = await enrichHostsWithDelegationCredentials({
orgId: firstUserOrgId ?? null,
hosts: hostsWithoutDelegationCredential ?? null,
});
return {
hosts: hostsEnrichedWithDelegationCredential,
fallbackHosts: null,
};
} else {
const hostsWithoutDelegationCredential = eventType.users.map((user) => {
return {
isFixed: !eventType.schedulingType || eventType.schedulingType === SchedulingType.COLLECTIVE,
email: user.email,
user: user,
createdAt: null,
};
});
const firstHost = hostsWithoutDelegationCredential[0];
const firstUserOrgId = await getOrgIdFromMemberOrTeamId({
memberId: firstHost?.user?.id ?? null,
teamId: eventType.teamId,
});
const hostsEnrichedWithDelegationCredential = await enrichHostsWithDelegationCredentials({
orgId: firstUserOrgId ?? null,
hosts: hostsWithoutDelegationCredential ?? null,
});
return {
hosts: null,
fallbackHosts: hostsEnrichedWithDelegationCredential,
};
}
}
// We don't allow fixed hosts when segment matching is enabled
// If this ever changes, we need to update this function and return fixed hosts
export async function findMatchingHostsWithEventSegment<User extends BaseUser>({
eventType,
hosts,
}: {
eventType: EventType;
hosts: {
isFixed: boolean;
user: User;
priority?: number | null;
weight?: number | null;
createdAt: Date | null;
groupId: string | null;
}[];
}) {
const matchingRRTeamMembers = await findMatchingTeamMembersIdsForEventRRSegment({
...eventType,
rrSegmentQueryValue: eventType.rrSegmentQueryValue ?? null,
});
const segmentedRoundRobinHosts = hosts.filter((host) => {
if (!matchingRRTeamMembers) return true;
return matchingRRTeamMembers.includes(host.user.id);
});
return segmentedRoundRobinHosts;
}