refactor: circular deps between app store and lib [6] (#23971)

* move delegation credential repository to features

* mv credential repository to features

* update imports

* mv

* fix

* fix

* fix

* fix

* fix

* update imports

* update imports

* update eslint rule

* fix

* fix

* mv getConnectedDestinationCalendars

* fix import errors

* mv getCalendarsEvents

* remove getUsersCredentials

* wip

* revert eslint rule change for now

* fix type checks

* fix

* format

* cleanup

* fix

* fix

* fix

* fix

* fix tests

* migrate getUserAvailability

* migrate

* fix tests

* fix type checks

* fix

* fix

* migrate crmManager

* update imports

* migrate raqbUtils to appstore

* migrate getLuckyUser to features

* migrate findTeamMembersMatchingAttributeLogic to appstore

* update imports

* fix

* fix

* fix test

* fix unit tests

* fix

* fix

* add eslint config
This commit is contained in:
Benny Joo
2025-10-09 14:02:12 +00:00
committed by GitHub
parent 445b307972
commit bb68cd73ef
114 changed files with 263 additions and 245 deletions
+1 -1
View File
@@ -12,7 +12,7 @@ import {
import { getQueryBuilderConfigForAttributes } from "@calcom/app-store/routing-forms/lib/getQueryBuilderConfig";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { isEqual } from "@calcom/lib/isEqual";
import { buildStateFromQueryValue } from "@calcom/lib/raqb/raqbUtils";
import { buildStateFromQueryValue } from "@calcom/app-store/_utils/raqb/raqbUtils";
import type { AttributesQueryValue } from "@calcom/lib/raqb/types";
import { trpc, type RouterOutputs } from "@calcom/trpc";
import cn from "@calcom/ui/classNames";
@@ -12,7 +12,7 @@ import type {
EventTypes,
} from "@calcom/features/eventtypes/components/BulkEditDefaultForEventsModal";
import { BulkEditDefaultForEventsModal } from "@calcom/features/eventtypes/components/BulkEditDefaultForEventsModal";
import { isDelegationCredential } from "@calcom/lib/delegationCredential/clientAndServer";
import { isDelegationCredential } from "@calcom/lib/delegationCredential";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { AppCategories } from "@calcom/prisma/enums";
import { type RouterOutputs } from "@calcom/trpc";
@@ -2,7 +2,7 @@
import { useState } from "react";
import { isDelegationCredential } from "@calcom/lib/delegationCredential/clientAndServer";
import { isDelegationCredential } from "@calcom/lib/delegationCredential";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import type { ButtonProps } from "@calcom/ui/components/button";
@@ -0,0 +1,27 @@
import { enrichUserWithDelegationCredentialsIncludeServiceAccountKey } from "@calcom/app-store/delegationCredential";
import { withSelectedCalendars } from "@calcom/lib/server/withSelectedCalendars";
import { availabilityUserSelect } from "@calcom/prisma";
import { prisma } from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
export async function findUsersForAvailabilityCheck({ where }: { where: Prisma.UserWhereInput }) {
const user = await prisma.user.findFirst({
where,
select: {
...availabilityUserSelect,
selectedCalendars: true,
credentials: {
select: credentialForCalendarServiceSelect,
},
},
});
if (!user) {
return null;
}
return await enrichUserWithDelegationCredentialsIncludeServiceAccountKey({
user: withSelectedCalendars(user),
});
}
@@ -0,0 +1,683 @@
import * as Sentry from "@sentry/nextjs";
import { z } from "zod";
import { getCalendar } from "@calcom/app-store/_utils/getCalendar";
import type { Dayjs } from "@calcom/dayjs";
import dayjs from "@calcom/dayjs";
import { getBusyTimesService } from "@calcom/features/di/containers/BusyTimes";
import type { IRedisService } from "@calcom/features/redis/IRedisService";
import { getWorkingHours } from "@calcom/lib/availability";
import type { DateOverride, WorkingHours } from "@calcom/lib/date-ranges";
import { buildDateRanges, subtract } from "@calcom/lib/date-ranges";
import { stringToDayjsZod } from "@calcom/lib/dayjs";
import { ErrorCode } from "@calcom/lib/errorCodes";
import { HttpError } from "@calcom/lib/http-error";
import { parseBookingLimit } from "@calcom/lib/intervalLimits/isBookingLimits";
import { parseDurationLimit } from "@calcom/lib/intervalLimits/isDurationLimits";
import {
getBusyTimesFromLimits,
getBusyTimesFromTeamLimits,
} from "@calcom/lib/intervalLimits/server/getBusyTimesFromLimits";
import { getPeriodStartDatesBetween as getPeriodStartDatesBetweenUtil } from "@calcom/lib/intervalLimits/utils/getPeriodStartDatesBetween";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { withReporting } from "@calcom/lib/sentryWrapper";
import type { BookingRepository } from "@calcom/lib/server/repository/booking";
import { EventTypeRepository } from "@calcom/lib/server/repository/eventTypeRepository";
import type { PrismaOOORepository } from "@calcom/lib/server/repository/ooo";
import type {
Booking,
Prisma,
OutOfOfficeEntry,
OutOfOfficeReason,
User,
EventType as PrismaEventType,
} from "@calcom/prisma/client";
import { SchedulingType } from "@calcom/prisma/enums";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import type { EventBusyDetails, IntervalLimitUnit } from "@calcom/types/Calendar";
import type { TimeRange } from "@calcom/types/schedule";
import { findUsersForAvailabilityCheck } from "./findUsersForAvailabilityCheck";
const log = logger.getSubLogger({ prefix: ["getUserAvailability"] });
const availabilitySchema = z
.object({
dateFrom: stringToDayjsZod,
dateTo: stringToDayjsZod,
eventTypeId: z.number().optional(),
username: z.string().optional(),
userId: z.number().optional(),
afterEventBuffer: z.number().optional(),
beforeEventBuffer: z.number().optional(),
duration: z.number().optional(),
withSource: z.boolean().optional(),
returnDateOverrides: z.boolean(),
bypassBusyCalendarTimes: z.boolean().optional(),
silentlyHandleCalendarFailures: z.boolean().optional(),
shouldServeCache: z.boolean().optional(),
})
.refine((data) => !!data.username || !!data.userId, "Either username or userId should be filled in.");
export type EventType = Awaited<ReturnType<(typeof UserAvailabilityService)["prototype"]["_getEventType"]>>;
type GetUser = Awaited<ReturnType<(typeof UserAvailabilityService)["prototype"]["_getUser"]>>;
export type GetUserAvailabilityInitialData = {
user?: GetUser;
eventType?: EventType;
currentSeats?: CurrentSeats;
rescheduleUid?: string | null;
currentBookings?: (Pick<Booking, "id" | "uid" | "userId" | "startTime" | "endTime" | "title"> & {
eventType: Pick<
PrismaEventType,
"id" | "beforeEventBuffer" | "afterEventBuffer" | "seatsPerTimeSlot"
> | null;
_count?: {
seatsReferences: number;
};
})[];
outOfOfficeDays?: (Pick<OutOfOfficeEntry, "id" | "start" | "end"> & {
user: Pick<User, "id" | "name">;
toUser: Pick<User, "id" | "username" | "name"> | null;
reason: Pick<OutOfOfficeReason, "id" | "emoji" | "reason"> | null;
})[];
busyTimesFromLimitsBookings: EventBusyDetails[];
busyTimesFromLimits?: Map<number, EventBusyDetails[]>;
eventTypeForLimits?: {
id: number;
bookingLimits?: unknown;
durationLimits?: unknown;
} | null;
teamBookingLimits?: Map<number, EventBusyDetails[]>;
teamForBookingLimits?: {
id: number;
bookingLimits?: unknown;
includeManagedEventsInLimits: boolean;
} | null;
};
export type GetAvailabilityUser = NonNullable<GetUserAvailabilityInitialData["user"]>;
type GetUserAvailabilityQuery = {
withSource?: boolean;
username?: string;
userId?: number;
dateFrom: string;
dateTo: string;
eventTypeId?: number;
afterEventBuffer?: number;
beforeEventBuffer?: number;
duration?: number;
returnDateOverrides: boolean;
bypassBusyCalendarTimes: boolean;
silentlyHandleCalendarFailures?: boolean;
shouldServeCache?: boolean;
};
export type CurrentSeats = Awaited<
ReturnType<(typeof UserAvailabilityService)["prototype"]["_getCurrentSeats"]>
>;
export type GetUserAvailabilityResult = Awaited<
ReturnType<(typeof UserAvailabilityService)["prototype"]["_getUserAvailability"]>
>;
interface GetUserAvailabilityParamsDTO {
availability: (DateOverride | WorkingHours)[];
}
export interface IFromUser {
id: number;
displayName: string | null;
}
export interface IToUser {
id: number;
username: string | null;
displayName: string | null;
}
export interface IOutOfOfficeData {
[key: string]: {
fromUser: IFromUser | null;
toUser?: IToUser | null;
reason?: string | null;
emoji?: string | null;
};
}
type GetUsersAvailabilityProps = {
users: (GetAvailabilityUser & {
currentBookings?: GetUserAvailabilityInitialData["currentBookings"];
outOfOfficeDays?: GetUserAvailabilityInitialData["outOfOfficeDays"];
})[];
query: Omit<GetUserAvailabilityQuery, "userId" | "username">;
initialData?: Omit<GetUserAvailabilityInitialData, "user">;
};
export interface IUserAvailabilityService {
eventTypeRepo: EventTypeRepository;
oooRepo: PrismaOOORepository;
bookingRepo: BookingRepository;
redisClient: IRedisService;
}
export class UserAvailabilityService {
constructor(public readonly dependencies: IUserAvailabilityService) {}
// Fetch timezones from outlook or google using delegated credentials (formely known as domain wide delegatiion)
async getTimezoneFromDelegatedCalendars(user: GetAvailabilityUser): Promise<string | null> {
if (!user.credentials || user.credentials.length === 0) {
return null;
}
const delegatedCredentials = user.credentials.filter(
(credential) => credential.type.endsWith("_calendar") && Boolean(credential.delegatedToId)
);
if (!delegatedCredentials || delegatedCredentials.length === 0) {
return null;
}
const cacheKey = `user-timezone:${user.id}`;
try {
const cachedTimezone = await this.dependencies.redisClient.get<string>(cacheKey);
if (cachedTimezone) {
log.debug(`Got timezone ${cachedTimezone} from Redis cache for user ${user.id}`);
return cachedTimezone;
}
} catch (error) {
log.warn(`Failed to get timezone from Redis cache for user ${user.id}:`, error);
}
if (delegatedCredentials.length === 0) {
return null;
}
for (const credential of delegatedCredentials) {
try {
const calendar = await getCalendar(credential);
if (calendar && "getMainTimeZone" in calendar && typeof calendar.getMainTimeZone === "function") {
const timezone = await calendar.getMainTimeZone();
if (timezone && timezone !== "UTC") {
log.debug(`Got timezone ${timezone} from calendar service ${credential.type}`);
try {
await this.dependencies.redisClient.set<string>(cacheKey, timezone, { ttl: 3600 * 6 * 1000 }); // 6 hours ttl in ms;
log.debug(`Cached timezone ${timezone} in Redis for user ${user.id}`);
} catch (error) {
log.warn(`Failed to set timezone in Redis cache for user ${user.id}:`, error);
}
return timezone;
}
}
} catch (error) {
log.warn(`Failed to get timezone from calendar service ${credential.type}:`, error);
}
}
return null;
}
async _getEventType(id: number) {
const eventType = await this.dependencies.eventTypeRepo.findByIdForUserAvailability({ id });
if (!eventType) {
return eventType;
}
return {
...eventType,
metadata: EventTypeMetaDataSchema.parse(eventType.metadata),
};
}
getEventType = withReporting(this._getEventType.bind(this), "getEventType");
async _getUser(where: Prisma.UserWhereInput) {
return findUsersForAvailabilityCheck({ where });
}
getUser = withReporting(this._getUser.bind(this), "getUser");
async _getCurrentSeats(
eventType: {
id?: number;
schedulingType?: SchedulingType | null;
hosts?: {
user: {
email: string;
};
}[];
},
dateFrom: Dayjs,
dateTo: Dayjs
) {
const { schedulingType, hosts, id } = eventType;
const hostEmails = hosts?.map((host) => host.user.email);
const isTeamEvent =
schedulingType === SchedulingType.MANAGED ||
schedulingType === SchedulingType.ROUND_ROBIN ||
schedulingType === SchedulingType.COLLECTIVE;
const bookings = await this.dependencies.bookingRepo.findAcceptedBookingByEventTypeId({
eventTypeId: id,
dateFrom: dateFrom.format(),
dateTo: dateTo.format(),
});
return bookings.map((booking) => {
const attendees = isTeamEvent
? booking.attendees.filter((attendee) => !hostEmails?.includes(attendee.email))
: booking.attendees;
return {
uid: booking.uid,
startTime: booking.startTime,
_count: {
attendees: attendees.length,
},
};
});
}
getCurrentSeats = withReporting(this._getCurrentSeats.bind(this), "getCurrentSeats");
/** This should be called getUsersWorkingHoursAndBusySlots (...and remaining seats, and final timezone) */
async _getUserAvailability(query: GetUserAvailabilityQuery, initialData?: GetUserAvailabilityInitialData) {
const {
username,
userId,
dateFrom,
dateTo,
eventTypeId,
afterEventBuffer,
beforeEventBuffer,
duration,
returnDateOverrides,
bypassBusyCalendarTimes = false,
silentlyHandleCalendarFailures = false,
shouldServeCache,
} = availabilitySchema.parse(query);
log.debug(
`EventType: ${eventTypeId} | User: ${username} (ID: ${userId}) - Called with: ${safeStringify({
query,
})}`
);
if (!dateFrom.isValid() || !dateTo.isValid()) {
throw new HttpError({ statusCode: 400, message: "Invalid time range given." });
}
const where: Prisma.UserWhereInput = {};
if (username) where.username = username;
if (userId) where.id = userId;
const user = initialData?.user || (await this.getUser(where));
if (!user) {
throw new HttpError({ statusCode: 404, message: "No user found in getUserAvailability" });
}
let eventType: EventType | null = initialData?.eventType || null;
if (!eventType && eventTypeId) eventType = await this.getEventType(eventTypeId);
/* Current logic is if a booking is in a time slot mark it as busy, but seats can have more than one attendee so grab
current bookings with a seats event type and display them on the calendar, even if they are full */
let currentSeats: CurrentSeats | null = initialData?.currentSeats || null;
if (!currentSeats && eventType?.seatsPerTimeSlot) {
currentSeats = await this.getCurrentSeats(eventType, dateFrom, dateTo);
}
const userSchedule = user.schedules.filter(
(schedule) => !user?.defaultScheduleId || schedule.id === user?.defaultScheduleId
)[0];
const hostSchedule = eventType?.hosts?.find((host) => host.user.id === user.id)?.schedule;
// TODO: It uses default timezone of user. Should we use timezone of team ?
const fallbackTimezoneIfScheduleIsMissing = eventType?.timeZone || user.timeZone;
const fallbackSchedule = {
availability: [
{
startTime: new Date("1970-01-01T09:00:00Z"),
endTime: new Date("1970-01-01T17:00:00Z"),
days: [1, 2, 3, 4, 5], // Monday to Friday
date: null,
},
],
id: 0,
timeZone: fallbackTimezoneIfScheduleIsMissing,
};
// possible timezones that have been set by or for a user
const potentialSchedule = eventType?.schedule
? eventType.schedule
: hostSchedule
? hostSchedule
: userSchedule;
// if no schedules set by or for a user, use fallbackSchedule
const schedule = potentialSchedule ?? fallbackSchedule;
const bookingLimits =
eventType?.bookingLimits &&
typeof eventType.bookingLimits === "object" &&
Object.keys(eventType.bookingLimits).length > 0
? parseBookingLimit(eventType.bookingLimits)
: null;
const durationLimits =
eventType?.durationLimits &&
typeof eventType.durationLimits === "object" &&
Object.keys(eventType.durationLimits).length > 0
? parseDurationLimit(eventType.durationLimits)
: null;
// TODO: only query what we need after applying limits (shrink date range)
const getBusyTimesStart = dateFrom.toISOString();
const getBusyTimesEnd = dateTo.toISOString();
const selectedCalendars = eventType?.useEventLevelSelectedCalendars
? EventTypeRepository.getSelectedCalendarsFromUser({ user, eventTypeId: eventType.id })
: user.userLevelSelectedCalendars;
const isTimezoneSet = Boolean(potentialSchedule && potentialSchedule.timeZone !== null);
// this timezone is synced with google/outlook calendars timezone usingg delegated credentials
// it's a fallback for delegated credentials users who want to sync their timezone with third party calendars
const calendarTimezone = !isTimezoneSet ? await this.getTimezoneFromDelegatedCalendars(user) : null;
const finalTimezone =
!isTimezoneSet && calendarTimezone
? calendarTimezone
: schedule?.timeZone || fallbackTimezoneIfScheduleIsMissing;
let busyTimesFromLimits: EventBusyDetails[] = [];
if (initialData?.busyTimesFromLimits && initialData?.eventTypeForLimits) {
busyTimesFromLimits = initialData.busyTimesFromLimits.get(user.id) || [];
} else if (eventType && (bookingLimits || durationLimits)) {
// Fall back to individual query if not available in initialData
busyTimesFromLimits = await getBusyTimesFromLimits(
bookingLimits,
durationLimits,
dateFrom.tz(finalTimezone),
dateTo.tz(finalTimezone),
duration,
eventType,
initialData?.busyTimesFromLimitsBookings ?? [],
finalTimezone,
initialData?.rescheduleUid ?? undefined
);
}
const teamForBookingLimits =
initialData?.teamForBookingLimits ??
eventType?.team ??
(eventType?.parent?.team?.includeManagedEventsInLimits ? eventType?.parent?.team : null);
const teamBookingLimits = parseBookingLimit(teamForBookingLimits?.bookingLimits);
let busyTimesFromTeamLimits: EventBusyDetails[] = [];
if (initialData?.teamBookingLimits && teamForBookingLimits) {
busyTimesFromTeamLimits = initialData.teamBookingLimits.get(user.id) || [];
} else if (teamForBookingLimits && teamBookingLimits) {
// Fall back to individual query if not available in initialData
busyTimesFromTeamLimits = await getBusyTimesFromTeamLimits(
user,
teamBookingLimits,
dateFrom.tz(finalTimezone),
dateTo.tz(finalTimezone),
teamForBookingLimits.id,
teamForBookingLimits.includeManagedEventsInLimits,
finalTimezone,
initialData?.rescheduleUid ?? undefined
);
}
let busyTimes = [];
try {
const busyTimesService = getBusyTimesService();
busyTimes = await busyTimesService.getBusyTimes({
credentials: user.credentials,
startTime: getBusyTimesStart,
endTime: getBusyTimesEnd,
eventTypeId,
userId: user.id,
userEmail: user.email,
username: `${user.username}`,
beforeEventBuffer,
afterEventBuffer,
selectedCalendars,
seatedEvent: !!eventType?.seatsPerTimeSlot,
rescheduleUid: initialData?.rescheduleUid || null,
duration,
currentBookings: initialData?.currentBookings,
bypassBusyCalendarTimes,
silentlyHandleCalendarFailures,
shouldServeCache,
});
} catch (error) {
log.error(`Error fetching busy times for user ${username}:`, error);
return {
busy: [],
timeZone: finalTimezone,
dateRanges: [],
oooExcludedDateRanges: [],
workingHours: [],
dateOverrides: [],
currentSeats: [],
datesOutOfOffice: undefined,
};
}
const detailedBusyTimes: EventBusyDetails[] = [
...busyTimes.map((a) => ({
...a,
start: dayjs(a.start).toISOString(),
end: dayjs(a.end).toISOString(),
title: a.title,
source: query.withSource ? a.source : undefined,
})),
...busyTimesFromLimits,
...busyTimesFromTeamLimits,
];
const isDefaultSchedule = userSchedule && userSchedule.id === schedule?.id;
log.debug(
`EventType: ${eventTypeId} | User: ${username} (ID: ${userId}) - usingSchedule: ${safeStringify({
chosenSchedule: schedule,
eventTypeSchedule: eventType?.schedule,
userSchedule: userSchedule,
hostSchedule: hostSchedule,
})}`
);
if (
!(
schedule?.availability ||
(eventType?.availability.length ? eventType.availability : user.availability)
)
) {
throw new HttpError({ statusCode: 400, message: ErrorCode.AvailabilityNotFoundInSchedule });
}
const availability = (
schedule?.availability || (eventType?.availability.length ? eventType.availability : user.availability)
).map((a) => ({
...a,
userId: user.id,
}));
const workingHours = getWorkingHours({ timeZone: finalTimezone }, availability);
const dateOverrides: TimeRange[] = [];
// NOTE: getSchedule is currently calling this function for every user in a team event
// but not using these values at all, wasting CPU. Adding this check here temporarily to avoid a larger refactor
// since other callers do using this data.
if (returnDateOverrides) {
const calculateDateOverridesSpan = Sentry.startInactiveSpan({ name: "calculateDateOverrides" });
const availabilityWithDates = availability.filter((availability) => !!availability.date);
for (let i = 0; i < availabilityWithDates.length; i++) {
const override = availabilityWithDates[i];
const startTime = dayjs.utc(override.startTime);
const endTime = dayjs.utc(override.endTime);
const overrideStartDate = dayjs.utc(override.date).hour(startTime.hour()).minute(startTime.minute());
const overrideEndDate = dayjs.utc(override.date).hour(endTime.hour()).minute(endTime.minute());
if (
overrideStartDate.isBetween(dateFrom, dateTo, null, "[]") ||
overrideEndDate.isBetween(dateFrom, dateTo, null, "[]")
) {
dateOverrides.push({
start: overrideStartDate.toDate(),
end: overrideEndDate.toDate(),
});
}
}
calculateDateOverridesSpan.end();
}
const outOfOfficeDays =
initialData?.outOfOfficeDays ??
(await this.dependencies.oooRepo.findUserOOODays({
userId: user.id,
dateFrom: dateFrom.toISOString(),
dateTo: dateTo.toISOString(),
}));
const datesOutOfOffice: IOutOfOfficeData = this.calculateOutOfOfficeRanges(outOfOfficeDays, availability);
const { dateRanges, oooExcludedDateRanges } = buildDateRanges({
dateFrom,
dateTo,
availability,
timeZone: finalTimezone,
travelSchedules: isDefaultSchedule
? user.travelSchedules.map((schedule) => {
return {
startDate: dayjs(schedule.startDate),
endDate: schedule.endDate ? dayjs(schedule.endDate) : undefined,
timeZone: schedule.timeZone,
};
})
: [],
outOfOffice: datesOutOfOffice,
});
const formattedBusyTimes = detailedBusyTimes.map((busy) => ({
start: dayjs(busy.start),
end: dayjs(busy.end),
}));
const dateRangesInWhichUserIsAvailable = subtract(dateRanges, formattedBusyTimes);
const dateRangesInWhichUserIsAvailableWithoutOOO = subtract(oooExcludedDateRanges, formattedBusyTimes);
const result = {
busy: detailedBusyTimes,
timeZone: finalTimezone,
dateRanges: dateRangesInWhichUserIsAvailable,
oooExcludedDateRanges: dateRangesInWhichUserIsAvailableWithoutOOO,
workingHours,
dateOverrides,
currentSeats,
datesOutOfOffice,
};
log.debug(
`EventType: ${eventTypeId} | User: ${username} (ID: ${userId}) - Result: ${safeStringify(result)}`
);
return result;
}
getUserAvailability = withReporting(this._getUserAvailability.bind(this), "getUserAvailability");
getPeriodStartDatesBetween = withReporting(
(dateFrom: Dayjs, dateTo: Dayjs, period: IntervalLimitUnit, timeZone?: string) =>
getPeriodStartDatesBetweenUtil(dateFrom, dateTo, period, timeZone),
"getPeriodStartDatesBetween"
);
calculateOutOfOfficeRanges(
outOfOfficeDays: GetUserAvailabilityInitialData["outOfOfficeDays"],
availability: GetUserAvailabilityParamsDTO["availability"]
): IOutOfOfficeData {
if (!outOfOfficeDays || outOfOfficeDays.length === 0) {
return {};
}
return outOfOfficeDays.reduce((acc: IOutOfOfficeData, { start, end, toUser, user, reason }) => {
// here we should use startDate or today if start is before today
// consider timezone in start and end date range
const startDateRange = dayjs(start).utc().isBefore(dayjs().startOf("day").utc())
? dayjs().utc().startOf("day")
: dayjs(start).utc().startOf("day");
// get number of day in the week and see if it's on the availability
const flattenDays = Array.from(new Set(availability.flatMap((a) => ("days" in a ? a.days : [])))).sort(
(a, b) => a - b
);
const endDateRange = dayjs(end).utc().endOf("day");
for (let date = startDateRange; date.isBefore(endDateRange); date = date.add(1, "day")) {
const dayNumberOnWeek = date.day();
if (!flattenDays?.includes(dayNumberOnWeek)) {
continue; // Skip to the next iteration if day not found in flattenDays
}
acc[date.format("YYYY-MM-DD")] = {
// @TODO: would be good having start and end availability time here, but for now should be good
// you can obtain that from user availability defined outside of here
fromUser: { id: user.id, displayName: user.name },
// optional chaining destructuring toUser
toUser: !!toUser ? { id: toUser.id, displayName: toUser.name, username: toUser.username } : null,
reason: !!reason ? reason.reason : null,
emoji: !!reason ? reason.emoji : null,
};
}
return acc;
}, {});
}
async _getUsersAvailability({ users, query, initialData }: GetUsersAvailabilityProps) {
if (users.length >= 50) {
const userIds = users.map(({ id }) => id).join(", ");
log.warn(
`High-load warning: Attempting to fetch availability for ${users.length} users. User IDs: [${userIds}], EventTypeId: [${query.eventTypeId}]`
);
}
return await Promise.all(
users.map((user) =>
this._getUserAvailability(
{
...query,
userId: user.id,
username: user.username || "",
},
initialData
? {
...initialData,
user,
currentBookings: user.currentBookings,
outOfOfficeDays: user.outOfOfficeDays,
}
: undefined
)
)
);
}
getUsersAvailability = withReporting(this._getUsersAvailability.bind(this), "getUsersAvailability");
}
@@ -1,7 +1,7 @@
import { useRouter } from "next/navigation";
import ServerTrans from "@calcom/lib/components/ServerTrans";
import type { IOutOfOfficeData } from "@calcom/lib/getUserAvailability";
import type { IOutOfOfficeData } from "@calcom/features/availability/lib/getUserAvailability";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import classNames from "@calcom/ui/classNames";
@@ -11,7 +11,7 @@ import { OutOfOfficeInSlots } from "@calcom/features/bookings/Booker/components/
import type { IUseBookingLoadingStates } from "@calcom/features/bookings/Booker/components/hooks/useBookings";
import type { BookerEvent } from "@calcom/features/bookings/types";
import type { Slot } from "@calcom/features/schedules/lib/use-schedule/types";
import type { IOutOfOfficeData } from "@calcom/lib/getUserAvailability";
import type { IOutOfOfficeData } from "@calcom/features/availability/lib/getUserAvailability";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { localStorage } from "@calcom/lib/webstorage";
import classNames from "@calcom/ui/classNames";
@@ -8,10 +8,14 @@ import { FAKE_DAILY_CREDENTIAL } from "@calcom/app-store/dailyvideo/lib/VideoApi
import { appKeysSchema as calVideoKeysSchema } from "@calcom/app-store/dailyvideo/zod";
import { getLocationFromApp, MeetLocationType, MSTeamsLocationType } from "@calcom/app-store/locations";
import getApps from "@calcom/app-store/utils";
import { createMeeting, updateMeeting, deleteMeeting } from "@calcom/app-store/videoClient";
import { createEvent, updateEvent, deleteEvent } from "@calcom/features/calendars/lib/CalendarManager";
import CrmManager from "@calcom/features/crmManager/crmManager";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { getUid } from "@calcom/lib/CalEventParser";
import CRMScheduler from "@calcom/lib/crmManager/tasker/crmScheduler";
import { symmetricDecrypt } from "@calcom/lib/crypto";
import { isDelegationCredential } from "@calcom/lib/delegationCredential";
import logger from "@calcom/lib/logger";
import {
getPiiFreeDestinationCalendar,
@@ -34,11 +38,6 @@ import type {
PartialReference,
} from "@calcom/types/EventManager";
import { createEvent, updateEvent, deleteEvent } from "@calcom/features/calendars/lib/CalendarManager";
import CrmManager from "@calcom/lib/crmManager/crmManager";
import { isDelegationCredential } from "@calcom/lib/delegationCredential/clientAndServer";
import { createMeeting, updateMeeting, deleteMeeting } from "@calcom/app-store/videoClient";
const log = logger.getSubLogger({ prefix: ["EventManager"] });
const CALENDSO_ENCRYPTION_KEY = process.env.CALENDSO_ENCRYPTION_KEY || "";
const CALDAV_CALENDAR_TYPE = "caldav_calendar";
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import dayjs from "@calcom/dayjs";
import type { CurrentSeats } from "@calcom/lib/getUserAvailability";
import type { CurrentSeats } from "@calcom/features/availability/lib/getUserAvailability";
import type { EventBusyDate } from "@calcom/types/Calendar";
import { checkForConflicts } from "./checkForConflicts";
@@ -1,7 +1,7 @@
import type { Dayjs } from "dayjs";
import dayjs from "@calcom/dayjs";
import type { CurrentSeats } from "@calcom/lib/getUserAvailability";
import type { CurrentSeats } from "@calcom/features/availability/lib/getUserAvailability";
import type { BufferedBusyTime } from "@calcom/types/BufferedBusyTime";
type BufferedBusyTimes = BufferedBusyTime[];
@@ -1,7 +1,7 @@
import type { z } from "zod";
import { eventTypeAppMetadataOptionalSchema } from "@calcom/app-store/zod-utils";
import { enrichUserWithDelegationCredentialsIncludeServiceAccountKey } from "@calcom/lib/delegationCredential/server";
import { enrichUserWithDelegationCredentialsIncludeServiceAccountKey } from "@calcom/app-store/delegationCredential";
import { UserRepository } from "@calcom/lib/server/repository/user";
import prisma from "@calcom/prisma";
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
@@ -1,7 +1,7 @@
import async from "async";
import { isDelegationCredential } from "@calcom/lib/delegationCredential/clientAndServer";
import { buildAllCredentials } from "@calcom/lib/delegationCredential/server";
import { isDelegationCredential } from "@calcom/lib/delegationCredential";
import { buildAllCredentials } from "@calcom/app-store/delegationCredential";
import { withReporting } from "@calcom/lib/sentryWrapper";
import type { CredentialForCalendarService } from "@calcom/types/Credential";
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,9 +1,10 @@
import short, { uuid } from "short-uuid";
import { v5 as uuidv5 } from "uuid";
import processExternalId from "@calcom/app-store/_utils/calendars/processExternalId";
import { getPaymentAppData } from "@calcom/app-store/_utils/payments/getPaymentAppData";
import { getFirstDelegationConferencingCredentialAppLocation } from "@calcom/app-store/delegationCredential";
import { enrichHostsWithDelegationCredentials } from "@calcom/app-store/delegationCredential";
import { metadata as GoogleMeetMetadata } from "@calcom/app-store/googlevideo/_metadata";
import {
getLocationValueForDB,
@@ -25,6 +26,9 @@ import { handlePayment } from "@calcom/features/bookings/lib/handlePayment";
import { handleWebhookTrigger } from "@calcom/features/bookings/lib/handleWebhookTrigger";
import { isEventTypeLoggingEnabled } from "@calcom/features/bookings/lib/isEventTypeLoggingEnabled";
import type { CacheService } from "@calcom/features/calendar-cache/lib/getShouldServeCache";
import { getCheckBookingAndDurationLimitsService } from "@calcom/features/di/containers/BookingLimits";
import { getCacheService } from "@calcom/features/di/containers/Cache";
import { getLuckyUserService } from "@calcom/features/di/containers/LuckyUser";
import AssignmentReasonRecorder from "@calcom/features/ee/round-robin/assignmentReason/AssignmentReasonRecorder";
import { getUsernameList } from "@calcom/features/eventtypes/lib/defaultEvents";
import { getEventName, updateHostInEventName } from "@calcom/features/eventtypes/lib/eventNaming";
@@ -43,13 +47,6 @@ import { getVideoCallUrlFromCalEvent } from "@calcom/lib/CalEventParser";
import { groupHostsByGroupId } from "@calcom/lib/bookings/hostGroupUtils";
import { shouldIgnoreContactOwner } from "@calcom/lib/bookings/routing/utils";
import { DEFAULT_GROUP_ID } from "@calcom/lib/constants";
import {
enrichHostsWithDelegationCredentials,
getFirstDelegationConferencingCredentialAppLocation,
} from "@calcom/lib/delegationCredential/server";
import { getCheckBookingAndDurationLimitsService } from "@calcom/features/di/containers/BookingLimits";
import { getCacheService } from "@calcom/features/di/containers/Cache";
import { getLuckyUserService } from "@calcom/features/di/containers/LuckyUser";
import { ErrorCode } from "@calcom/lib/errorCodes";
import { getErrorFromUnknown } from "@calcom/lib/errors";
import { extractBaseEmail } from "@calcom/lib/extract-base-email";
@@ -1480,7 +1477,8 @@ async function handler(
const changedOrganizer =
!!originalRescheduledBooking &&
(eventType.schedulingType === SchedulingType.ROUND_ROBIN || eventType.schedulingType === SchedulingType.COLLECTIVE) &&
(eventType.schedulingType === SchedulingType.ROUND_ROBIN ||
eventType.schedulingType === SchedulingType.COLLECTIVE) &&
originalRescheduledBooking.userId !== evt.organizer.id;
const skipDeleteEventsAndMeetings = changedOrganizer;
@@ -1,4 +1,4 @@
import { getFirstDelegationConferencingCredentialAppLocation } from "@calcom/lib/delegationCredential/server";
import { getFirstDelegationConferencingCredentialAppLocation } from "@calcom/app-store/delegationCredential";
import { withReporting } from "@calcom/lib/sentryWrapper";
import type { Prisma } from "@calcom/prisma/client";
import { userMetadata as userMetadataSchema } from "@calcom/prisma/zod-utils";
@@ -1,14 +1,14 @@
import type { Logger } from "tslog";
import { checkIfUsersAreBlocked } from "@calcom/features/watchlist/operations/check-if-users-are-blocked.controller";
import { enrichUsersWithDelegationCredentials } from "@calcom/lib/delegationCredential/server";
import { enrichUsersWithDelegationCredentials } from "@calcom/app-store/delegationCredential";
import type { RoutingFormResponse } from "@calcom/features/bookings/lib/getLuckyUser";
import { getQualifiedHostsService } from "@calcom/features/di/containers/QualifiedHosts";
import { checkIfUsersAreBlocked } from "@calcom/features/watchlist/operations/check-if-users-are-blocked.controller";
import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId";
import { HttpError } from "@calcom/lib/http-error";
import { getPiiFreeUser } from "@calcom/lib/piiFreeData";
import { safeStringify } from "@calcom/lib/safeStringify";
import { withReporting } from "@calcom/lib/sentryWrapper";
import type { RoutingFormResponse } from "@calcom/lib/server/getLuckyUser";
import { withSelectedCalendars } from "@calcom/lib/server/repository/user";
import { userSelect } from "@calcom/prisma";
import prisma from "@calcom/prisma";
@@ -3,7 +3,7 @@ import {
getRoutedUsersWithContactOwnerAndFixedUsers,
findMatchingHostsWithEventSegment,
getNormalizedHosts,
} from "@calcom/lib/bookings/getRoutedUsers";
} from "@calcom/features/users/lib/getRoutedUsers";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
@@ -9,7 +9,7 @@ vi.mock("@calcom/prisma/zod-utils", () => ({
userMetadata: { parse: (metadata: any) => metadata },
}));
vi.mock("@calcom/lib/delegationCredential/server", () => ({
vi.mock("@calcom/app-store/delegationCredential", () => ({
getFirstDelegationConferencingCredentialAppLocation: ({
credentials,
}: {
@@ -2,7 +2,7 @@ import type { TFunction } from "i18next";
import type { PaymentAppData } from "@calcom/app-store/_utils/payments/getPaymentAppData";
import type { EventTypeAppsList } from "@calcom/app-store/utils";
import type { GetUserAvailabilityResult } from "@calcom/lib/getUserAvailability";
import type { GetUserAvailabilityResult } from "@calcom/features/availability/lib/getUserAvailability";
import type { userSelect } from "@calcom/prisma";
import type { App } from "@calcom/prisma/client";
import type { Prisma } from "@calcom/prisma/client";
@@ -3,8 +3,8 @@ import { sendCancelledSeatEmailsAndSMS } from "@calcom/emails";
import sendPayload from "@calcom/features/webhooks/lib/sendOrSchedulePayload";
import type { EventPayloadType, EventTypeInfo } from "@calcom/features/webhooks/lib/sendPayload";
import { getRichDescription } from "@calcom/lib/CalEventParser";
import { getAllDelegationCredentialsForUserIncludeServiceAccountKey } from "@calcom/lib/delegationCredential/server";
import { getDelegationCredentialOrFindRegularCredential } from "@calcom/lib/delegationCredential/server";
import { getAllDelegationCredentialsForUserIncludeServiceAccountKey } from "@calcom/app-store/delegationCredential";
import { getDelegationCredentialOrFindRegularCredential } from "@calcom/app-store/delegationCredential";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
@@ -1,7 +1,7 @@
// eslint-disable-next-line no-restricted-imports
import { getCalendar } from "@calcom/app-store/_utils/getCalendar";
import { getAllDelegationCredentialsForUserIncludeServiceAccountKey } from "@calcom/lib/delegationCredential/server";
import { getDelegationCredentialOrFindRegularCredential } from "@calcom/lib/delegationCredential/server";
import { getAllDelegationCredentialsForUserIncludeServiceAccountKey } from "@calcom/app-store/delegationCredential";
import { getDelegationCredentialOrFindRegularCredential } from "@calcom/app-store/delegationCredential";
import { deleteMeeting } from "@calcom/app-store/videoClient";
import prisma from "@calcom/prisma";
import type { Attendee } from "@calcom/prisma/client";
@@ -0,0 +1,205 @@
import { prisma } from "@calcom/prisma/__mocks__/prisma";
import type { Mock } from "vitest";
import { describe, expect, it, vi, afterEach, beforeEach } from "vitest";
import { getLuckyUserService } from "@calcom/features/di/containers/LuckyUser";
import { RRResetInterval, RRTimestampBasis } from "@calcom/prisma/enums";
import { filterHostsByLeadThreshold, errorCodes } from "./filterHostsByLeadThreshold";
vi.mock("@calcom/prisma", () => ({
prisma,
}));
const luckyUserService = getLuckyUserService();
// Mocking setup
const prismaMock = {
booking: {
groupBy: vi.fn(), // Mock the groupBy method
},
};
// Use `vi.spyOn` to make `prisma.booking.groupBy` call the mock instead
vi.spyOn(prismaMock.booking, "groupBy").mockImplementation(prismaMock.booking.groupBy);
// This variable will hold our mock function
let getOrderedListOfLuckyUsersMock: Mock;
beforeEach(() => {
// Clear all mocks and spies before each test
vi.clearAllMocks();
// Spy on the real method and explicitly cast it as a Mock
getOrderedListOfLuckyUsersMock = vi.spyOn(luckyUserService, "getOrderedListOfLuckyUsers") as Mock;
});
afterEach(() => {
// Restore all spies to their original implementation
vi.restoreAllMocks();
});
describe("filterHostByLeadThreshold", () => {
it("skips filter if lead threshold is null", async () => {
const hosts = [
{
isFixed: false as const,
createdAt: new Date(),
user: {
id: 1,
email: "member1-acme@example.com",
credentials: [],
userLevelSelectedCalendars: [],
},
},
];
expect(
filterHostsByLeadThreshold({
hosts,
maxLeadThreshold: null,
eventType: {
id: 1,
isRRWeightsEnabled: true,
team: {
parentId: null,
rrResetInterval: RRResetInterval.MONTH,
rrTimestampBasis: RRTimestampBasis.CREATED_AT,
},
includeNoShowInRRCalculation: false,
},
routingFormResponse: null,
})
).resolves.toStrictEqual(hosts);
});
it("throws error when maxLeadThreshold = 0, 0 ahead makes no sense.", async () => {
expect(
filterHostsByLeadThreshold({
hosts: [],
maxLeadThreshold: 0,
eventType: {
id: 1,
isRRWeightsEnabled: true,
team: {
parentId: null,
rrResetInterval: RRResetInterval.MONTH,
rrTimestampBasis: RRTimestampBasis.CREATED_AT,
},
includeNoShowInRRCalculation: false,
},
routingFormResponse: null,
})
).rejects.toThrow(errorCodes.MAX_LEAD_THRESHOLD_FALSY);
});
it("correctly disqualifies a host when the lead offset is exceeding the threshold without weights", async () => {
const hosts = [
{
isFixed: false as const,
createdAt: new Date(),
user: {
id: 1,
email: "member1-acme@example.com",
credentials: [],
userLevelSelectedCalendars: [],
},
},
{
isFixed: false as const,
createdAt: new Date(),
user: {
id: 2,
email: "member2-acme@example.com",
credentials: [],
userLevelSelectedCalendars: [],
},
},
];
getOrderedListOfLuckyUsersMock.mockResolvedValue({
perUserData: {
bookingsCount: { 1: 10, 2: 6 },
},
});
expect(
filterHostsByLeadThreshold({
hosts,
maxLeadThreshold: 3,
eventType: {
id: 1,
isRRWeightsEnabled: false,
team: {
parentId: null,
rrResetInterval: RRResetInterval.MONTH,
rrTimestampBasis: RRTimestampBasis.CREATED_AT,
},
includeNoShowInRRCalculation: false,
},
routingFormResponse: null,
})
).resolves.toStrictEqual([hosts[1]]); // host 1 (host[0]) disqualified
});
it("correctly disqualifies a host when the lead offset is exceeding the threshold with weights", async () => {
const hosts = [
{
isFixed: false as const,
createdAt: new Date(),
user: {
id: 1,
email: "member1-acme@example.com",
credentials: [],
userLevelSelectedCalendars: [],
},
},
{
isFixed: false as const,
createdAt: new Date(),
user: {
id: 2,
email: "member2-acme@example.com",
credentials: [],
userLevelSelectedCalendars: [],
},
},
{
isFixed: false as const,
createdAt: new Date(),
user: {
id: 3,
email: "member3-acme@example.com",
credentials: [],
userLevelSelectedCalendars: [],
},
},
];
getOrderedListOfLuckyUsersMock.mockResolvedValue({
perUserData: {
bookingsCount: { 1: 7, 2: 5, 3: 0 },
weights: { 1: 100, 2: 50, 3: 20 },
bookingShortfalls: { 1: 1, 2: -3, 3: 0 },
calibrations: { 1: 1, 2: 2, 3: 1 },
},
});
expect(
filterHostsByLeadThreshold({
hosts,
maxLeadThreshold: 3,
eventType: {
id: 1,
isRRWeightsEnabled: true,
team: {
parentId: null,
rrResetInterval: RRResetInterval.MONTH,
rrTimestampBasis: RRTimestampBasis.CREATED_AT,
},
includeNoShowInRRCalculation: false,
},
routingFormResponse: null,
})
).resolves.toStrictEqual([hosts[0], hosts[2]]); // host 2 (host[1]) disqualified
});
});
@@ -0,0 +1,166 @@
import { getLuckyUserService } from "@calcom/features/di/containers/LuckyUser";
import logger from "@calcom/lib/logger";
import type { LuckyUserService, RoutingFormResponse } from "@calcom/features/bookings/lib/getLuckyUser";
import type { RRResetInterval, SelectedCalendar } from "@calcom/prisma/client";
import { RRTimestampBasis } from "@calcom/prisma/enums";
import type { CredentialForCalendarService } from "@calcom/types/Credential";
export const errorCodes = {
MAX_LEAD_THRESHOLD_FALSY: "Max lead threshold should be null or > 1, not 0.",
} as const;
type BaseUser = {
id: number;
email: string;
credentials: CredentialForCalendarService[];
userLevelSelectedCalendars: SelectedCalendar[];
} & Record<string, unknown>;
type BaseHost<User extends BaseUser> = {
isFixed: boolean;
createdAt: Date;
priority?: number | null;
weight?: number | null;
weightAdjustment?: number | null;
user: User;
};
type PerUserData = Awaited<
ReturnType<(typeof LuckyUserService.prototype)["getOrderedListOfLuckyUsers"]>
>["perUserData"];
type WeightedPerUserData = Omit<PerUserData, "weights" | "calibrations" | "bookingShortfalls"> & {
weights: NonNullable<PerUserData["weights"]>;
calibrations: NonNullable<PerUserData["calibrations"]>;
bookingShortfalls: NonNullable<PerUserData["bookingShortfalls"]>;
};
const log = logger.getSubLogger({ name: "filterHostsByLeadThreshold" });
function filterHostsByLeadThresholdWithWeights(perUserData: WeightedPerUserData, maxLeadThreshold: number) {
const filteredUserIds: number[] = [];
// negative shortfall means the host should receive negative bookings, so they are overbooked
const maxShortfall = Math.max(...Object.values(perUserData.bookingShortfalls)); // least amount of bookings
for (const userIdStr in perUserData.bookingShortfalls) {
const shortfall = perUserData.bookingShortfalls[userIdStr];
// if user's shortfall is more than
if (maxShortfall - shortfall > maxLeadThreshold) {
log.debug(
`Host ${userIdStr} has been filtered out because the amount of bookings made him exceed the thresholds. Shortfall: ${shortfall}, Max Shortfall: ${maxShortfall}`
);
} else {
filteredUserIds.push(parseInt(userIdStr, 10));
}
}
return filteredUserIds;
}
function filterHostsByLeadThresholdWithoutWeights(perUserData: PerUserData, maxLeadThreshold: number) {
const filteredUserIds: number[] = [];
const bookingsArray = Object.values(perUserData.bookingsCount);
const minBookings = Math.min(...bookingsArray);
for (const userIdStr in perUserData.bookingsCount) {
const bookingsCount = perUserData.bookingsCount[userIdStr];
if (bookingsCount - minBookings > maxLeadThreshold) {
log.debug(
`Host ${userIdStr} has been filtered out because the given data made them exceed the thresholds. BookingsCount: ${bookingsCount}, MinBookings: ${minBookings}`
);
} else {
filteredUserIds.push(parseInt(userIdStr, 10));
log.debug(
`Host Allowed ${userIdStr} has been filtered out because the given data made them exceed the thresholds. BookingsCount: ${bookingsCount}, MinBookings: ${minBookings}, MaxLeadThreshold: ${maxLeadThreshold}`
);
}
}
return filteredUserIds;
}
/*
* Filter the hosts by lead threshold, disqualifying hosts that have exceeded the maximum
*
* NOTE: This function cleans up the leadOffset value so can't be used afterwards.
*
* @throws errorCodes.MAX_LEAD_THRESHOLD_FALSY
*/
export const filterHostsByLeadThreshold = async <T extends BaseHost<BaseUser>>({
hosts,
maxLeadThreshold,
eventType,
routingFormResponse,
}: {
hosts: T[];
maxLeadThreshold: number | null;
eventType: {
id: number;
isRRWeightsEnabled: boolean;
team: {
parentId?: number | null;
rrResetInterval: RRResetInterval | null;
rrTimestampBasis: RRTimestampBasis;
} | null;
includeNoShowInRRCalculation: boolean;
};
routingFormResponse: RoutingFormResponse | null;
}) => {
if (maxLeadThreshold === 0) {
throw new Error(errorCodes.MAX_LEAD_THRESHOLD_FALSY);
}
if (
maxLeadThreshold === null ||
hosts.length < 1 ||
(eventType.team?.rrTimestampBasis && eventType.team.rrTimestampBasis !== RRTimestampBasis.CREATED_AT)
) {
return hosts; // don't apply filter.
}
// this needs the routing forms response too, because it needs to know what queue we are in
const luckyUserService = getLuckyUserService();
const orderedLuckyUsers = await luckyUserService.getOrderedListOfLuckyUsers({
availableUsers: [
{
...hosts[0].user,
weight: hosts[0].weight ?? null,
priority: hosts[0].priority ?? null,
},
...hosts.slice(1).map((host) => ({
...host.user,
weight: host.weight ?? null,
priority: host.priority ?? null,
})),
],
eventType,
allRRHosts: hosts,
routingFormResponse,
});
const perUserData = orderedLuckyUsers["perUserData"];
let filteredUserIds: number[];
if (eventType.isRRWeightsEnabled) {
// Check if any of the required data is null
if (
perUserData.calibrations === null ||
perUserData.weights === null ||
perUserData.bookingShortfalls === null
) {
throw new Error("Calibrations, weights, or booking shortfalls are null");
}
filteredUserIds = filterHostsByLeadThresholdWithWeights(
{
bookingsCount: perUserData.bookingsCount,
bookingShortfalls: perUserData.bookingShortfalls,
calibrations: perUserData.calibrations,
weights: perUserData.weights,
},
maxLeadThreshold
);
} else {
filteredUserIds = filterHostsByLeadThresholdWithoutWeights(perUserData, maxLeadThreshold);
}
const filteredHosts = hosts.filter((host) => filteredUserIds.includes(host.user.id));
return filteredHosts;
};
@@ -0,0 +1,126 @@
import type { Mock } from "vitest";
import { describe, expect, it, vi, afterEach } from "vitest";
import type { BookingRepository } from "@calcom/lib/server/repository/booking";
import { FilterHostsService } from "./filterHostsBySameRoundRobinHost";
const mockBookingRepo = {
findOriginalRescheduledBookingUserId: vi.fn(),
} as unknown as BookingRepository;
const filterHostsService = new FilterHostsService({
bookingRepo: mockBookingRepo,
});
afterEach(() => {
(mockBookingRepo.findOriginalRescheduledBookingUserId as Mock).mockClear();
});
describe("FilterHostsService", () => {
it("skips filter if rescheduleWithSameRoundRobinHost set to false", async () => {
const hosts = [
{ isFixed: false as const, createdAt: new Date(), user: { id: 1, email: "example1@acme.com" } },
];
expect(
filterHostsService.filterHostsBySameRoundRobinHost({
hosts,
rescheduleUid: "some-uid",
rescheduleWithSameRoundRobinHost: false,
routedTeamMemberIds: null,
})
).resolves.toStrictEqual(hosts);
});
it("skips filter if rerouting", async () => {
const hosts = [
{ isFixed: false as const, createdAt: new Date(), user: { id: 1, email: "example1@acme.com" } },
];
expect(
filterHostsService.filterHostsBySameRoundRobinHost({
hosts,
rescheduleUid: "some-uid",
rescheduleWithSameRoundRobinHost: true,
routedTeamMemberIds: [23],
})
).resolves.toStrictEqual(hosts);
});
it("correctly selects the same host if the filter applies and the host is in the RR users", async () => {
(mockBookingRepo.findOriginalRescheduledBookingUserId as Mock).mockResolvedValue({ userId: 1 });
const hosts = [
{ isFixed: false as const, createdAt: new Date(), user: { id: 1, email: "example1@acme.com" } },
{ isFixed: false as const, createdAt: new Date(), user: { id: 2, email: "example2@acme.com" } },
];
expect(
filterHostsService.filterHostsBySameRoundRobinHost({
hosts,
rescheduleUid: "some-uid",
rescheduleWithSameRoundRobinHost: true,
routedTeamMemberIds: null,
})
).resolves.toStrictEqual([hosts[0]]);
});
// Tests for bookings that have more than one host
describe("Fixed hosts and round robin groups support", () => {
it("should return organizer and attendee hosts", async () => {
(mockBookingRepo.findOriginalRescheduledBookingUserId as Mock).mockResolvedValue({
userId: 1,
attendees: [
{ email: "host2@acme.com" },
{ email: "host3@acme.com" },
{ email: "attendee@example.com" }, // Non-host attendee
],
});
const hosts = [
{ isFixed: false as const, createdAt: new Date(), user: { id: 1, email: "host1@acme.com" } },
{ isFixed: false as const, createdAt: new Date(), user: { id: 2, email: "host2@acme.com" } },
{ isFixed: false as const, createdAt: new Date(), user: { id: 3, email: "host3@acme.com" } },
{ isFixed: false as const, createdAt: new Date(), user: { id: 4, email: "host4@acme.com" } },
];
const result = await filterHostsService.filterHostsBySameRoundRobinHost({
hosts,
rescheduleUid: "some-uid",
rescheduleWithSameRoundRobinHost: true,
routedTeamMemberIds: null,
});
// Should return organizer host (id: 1) and attendee hosts (ids: 2, 3)
expect(result).toHaveLength(3);
expect(result).toEqual([
expect.objectContaining({ user: { id: 1, email: "host1@acme.com" } }), // organizer
expect.objectContaining({ user: { id: 2, email: "host2@acme.com" } }), // attendee
expect.objectContaining({ user: { id: 3, email: "host3@acme.com" } }), // attendee
]);
});
it("should return only organizer host when no attendees match current hosts", async () => {
(mockBookingRepo.findOriginalRescheduledBookingUserId as Mock).mockResolvedValue({
userId: 1,
attendees: [
{ email: "attendee1@example.com" }, // Non-host attendee
{ email: "attendee2@example.com" }, // Non-host attendee
],
});
const hosts = [
{ isFixed: false as const, createdAt: new Date(), user: { id: 1, email: "host1@acme.com" } },
{ isFixed: false as const, createdAt: new Date(), user: { id: 2, email: "host2@acme.com" } },
];
const result = await filterHostsService.filterHostsBySameRoundRobinHost({
hosts,
rescheduleUid: "some-uid",
rescheduleWithSameRoundRobinHost: true,
routedTeamMemberIds: null,
});
// Should return only organizer host
expect(result).toHaveLength(1);
expect(result[0]).toEqual(expect.objectContaining({ user: { id: 1, email: "host1@acme.com" } }));
});
});
});
@@ -0,0 +1,52 @@
import { isRerouting } from "@calcom/lib/bookings/routing/utils";
import type { BookingRepository } from "@calcom/lib/server/repository/booking";
export interface IFilterHostsService {
bookingRepo: BookingRepository;
}
export class FilterHostsService {
constructor(public readonly dependencies: IFilterHostsService) {}
async filterHostsBySameRoundRobinHost<
T extends {
isFixed: false; // ensure no fixed hosts are passed.
user: { id: number; email: string };
}
>({
hosts,
rescheduleUid,
rescheduleWithSameRoundRobinHost,
routedTeamMemberIds,
}: {
hosts: T[];
rescheduleUid: string | null;
rescheduleWithSameRoundRobinHost: boolean;
routedTeamMemberIds: number[] | null;
}) {
if (
!rescheduleUid ||
!rescheduleWithSameRoundRobinHost ||
isRerouting({ rescheduleUid, routedTeamMemberIds })
) {
return hosts;
}
const originalRescheduledBooking =
await this.dependencies.bookingRepo.findOriginalRescheduledBookingUserId({
rescheduleUid,
});
if (!originalRescheduledBooking) {
return hosts;
}
const attendeeEmails = originalRescheduledBooking.attendees?.map((attendee) => attendee.email) || [];
return hosts.filter((host) => {
const isOrganizer = host.user.id === originalRescheduledBooking.userId;
const isAttendee = attendeeEmails.includes(host.user.email);
return isOrganizer || isAttendee;
});
}
}
@@ -0,0 +1,691 @@
import prismaMock from "../../../../../tests/libs/__mocks__/prismaMock";
import { vi, it, describe, expect, afterEach } from "vitest";
import type { Mock } from "vitest";
import { getQualifiedHostsService } from "@calcom/features/di/containers/QualifiedHosts";
import * as getRoutedUsers from "@calcom/features/users/lib/getRoutedUsers";
import { RRResetInterval, SchedulingType } from "@calcom/prisma/enums";
import { filterHostsByLeadThreshold } from "./filterHostsByLeadThreshold";
// Mock the filterHostsByLeadThreshold function
vi.mock("./filterHostsByLeadThreshold", () => {
return {
filterHostsByLeadThreshold: vi.fn(),
};
});
// Clear call history after each test
afterEach(() => {
(filterHostsByLeadThreshold as Mock).mockClear();
});
const qualifiedHostsService = getQualifiedHostsService();
describe("findQualifiedHostsWithDelegationCredentials", async () => {
it("should return qualified hosts based on mock of filterHostsByLeadThreshold", async () => {
const hosts = [
{
isFixed: true,
createdAt: new Date(),
user: {
id: 2,
email: "hellouser2@email.com",
credentials: [],
userLevelSelectedCalendars: [],
},
priority: undefined,
weight: undefined,
groupId: null,
},
{
isFixed: false,
createdAt: new Date(),
user: {
id: 1,
email: "hellouser@email.com",
credentials: [],
userLevelSelectedCalendars: [],
},
priority: undefined,
weight: undefined,
groupId: null,
},
{
isFixed: false,
createdAt: new Date(),
user: {
id: 3,
email: "hellouser3@email.com",
credentials: [],
userLevelSelectedCalendars: [],
},
priority: undefined,
weight: undefined,
groupId: null,
},
];
const rrHosts = hosts.filter((host) => !host.isFixed);
const fixedHosts = hosts.filter((host) => host.isFixed);
const rrHostsAfterFairness = [rrHosts[2]];
// Configure the mock return value
(filterHostsByLeadThreshold as Mock).mockResolvedValue(rrHostsAfterFairness);
// Define the input for the test
const eventType = {
id: 1,
hosts,
users: [],
schedulingType: SchedulingType.ROUND_ROBIN,
maxLeadThreshold: null,
rescheduleWithSameRoundRobinHost: true,
assignAllTeamMembers: true,
assignRRMembersUsingSegment: false,
rrSegmentQueryValue: null,
isRRWeightsEnabled: false,
team: {
id: 1,
parentId: null,
rrResetInterval: RRResetInterval.MONTH,
},
};
// Call the function under test
const result = await qualifiedHostsService.findQualifiedHostsWithDelegationCredentials({
eventType,
routedTeamMemberIds: [],
rescheduleUid: null,
contactOwnerEmail: null,
routingFormResponse: null,
});
// Verify the result
expect(result).toStrictEqual({
qualifiedRRHosts: rrHostsAfterFairness,
fixedHosts,
allFallbackRRHosts: rrHosts,
});
});
it("should return hosts after valid input with users", async () => {
const users = [
{
email: "hello@gmail.com",
id: 1,
credentials: [],
userLevelSelectedCalendars: [],
},
{
email: "hello2@gmail.com",
id: 2,
credentials: [],
userLevelSelectedCalendars: [],
},
];
// Define the input for the test
const eventType = {
id: 1,
hosts: [],
users,
schedulingType: null,
maxLeadThreshold: null,
rescheduleWithSameRoundRobinHost: true,
assignAllTeamMembers: true,
assignRRMembersUsingSegment: false,
rrSegmentQueryValue: null,
isRRWeightsEnabled: false,
team: {
id: 1,
parentId: null,
rrResetInterval: RRResetInterval.MONTH,
},
};
// Call the function under test
const result = await qualifiedHostsService.findQualifiedHostsWithDelegationCredentials({
eventType,
routedTeamMemberIds: [],
rescheduleUid: null,
contactOwnerEmail: null,
routingFormResponse: null,
});
// Verify the result
expect(result).toEqual({
qualifiedRRHosts: [],
fixedHosts: users.map((user) => ({
user: user,
isFixed: true,
email: user.email,
createdAt: null,
})),
});
expect(filterHostsByLeadThreshold).not.toHaveBeenCalled();
});
it("should return only the crm contact owner match & other users + contact owner as fallback ", async () => {
const oneYearAgo = new Date();
oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1);
const hosts = [
{
weight: 100,
priority: 2,
createdAt: oneYearAgo,
isFixed: false,
user: {
email: "hello1@gmail.com",
id: 1,
credentials: [],
userLevelSelectedCalendars: [],
},
},
{
weight: 100,
priority: 2,
createdAt: oneYearAgo,
isFixed: false,
user: {
email: "hello2@gmail.com",
id: 2,
credentials: [],
userLevelSelectedCalendars: [],
},
},
{
weight: 100,
priority: 2,
createdAt: oneYearAgo,
isFixed: false,
user: {
email: "hello3@gmail.com",
id: 3,
credentials: [],
userLevelSelectedCalendars: [],
},
},
];
(filterHostsByLeadThreshold as Mock).mockResolvedValue(hosts);
// Define the input for the test
const eventType = {
id: 1,
hosts,
users: [],
schedulingType: SchedulingType.ROUND_ROBIN,
maxLeadThreshold: null,
rescheduleWithSameRoundRobinHost: true,
assignAllTeamMembers: true,
assignRRMembersUsingSegment: false,
rrSegmentQueryValue: null,
isRRWeightsEnabled: false,
team: {
id: 1,
parentId: null,
rrResetInterval: RRResetInterval.MONTH,
},
};
// Call the function under test
const result = await qualifiedHostsService.findQualifiedHostsWithDelegationCredentials({
eventType,
routedTeamMemberIds: [],
rescheduleUid: null,
contactOwnerEmail: "hello1@gmail.com",
routingFormResponse: null,
});
// Verify the result
expect(result).toEqual({
qualifiedRRHosts: [
{
...hosts[0],
},
],
allFallbackRRHosts: hosts,
fixedHosts: [],
});
});
// it("should return only the crm contact owner match & other users + contact owner as fallback (with routing and segment filtering)", async () => {
// });
it("should return only routed members + contact owner as fallback for crm contact owner match", async () => {
const oneYearAgo = new Date();
oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1);
const hosts = [
{
isFixed: false,
createdAt: oneYearAgo,
weight: undefined,
priority: undefined,
user: {
email: "hello1@gmail.com",
id: 1,
credentials: [],
userLevelSelectedCalendars: [],
},
},
{
isFixed: false,
createdAt: oneYearAgo,
weight: undefined,
priority: undefined,
user: {
email: "hello2@gmail.com",
id: 2,
credentials: [],
userLevelSelectedCalendars: [],
},
},
{
isFixed: false,
createdAt: oneYearAgo,
weight: undefined,
priority: undefined,
user: {
email: "hello3@gmail.com",
id: 3,
credentials: [],
userLevelSelectedCalendars: [],
},
},
];
(filterHostsByLeadThreshold as Mock).mockResolvedValue([hosts[0], hosts[1]]);
// Define the input for the test
const eventType = {
id: 1,
hosts,
users: [],
schedulingType: SchedulingType.ROUND_ROBIN,
maxLeadThreshold: null,
rescheduleWithSameRoundRobinHost: true,
assignAllTeamMembers: true,
assignRRMembersUsingSegment: false,
rrSegmentQueryValue: null,
isRRWeightsEnabled: false,
team: {
id: 1,
parentId: null,
rrResetInterval: RRResetInterval.MONTH,
},
};
// Call the function under test
const result = await qualifiedHostsService.findQualifiedHostsWithDelegationCredentials({
eventType,
routedTeamMemberIds: [1],
rescheduleUid: null,
contactOwnerEmail: "hello3@gmail.com",
routingFormResponse: null,
});
// Verify the result
expect(result).toEqual({
qualifiedRRHosts: [hosts[2]],
allFallbackRRHosts: [hosts[0], hosts[2]],
fixedHosts: [],
});
});
it("if it's a reschedule with same host, it should only return this host and the fixed hosts", async () => {
const oneYearAgo = new Date();
oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1);
const hosts = [
{
isFixed: false,
createdAt: oneYearAgo,
weight: undefined,
priority: undefined,
user: {
email: "hello1@gmail.com",
id: 1,
credentials: [],
userLevelSelectedCalendars: [],
},
},
{
isFixed: false,
createdAt: oneYearAgo,
weight: undefined,
priority: undefined,
user: {
email: "hello2@gmail.com",
id: 2,
credentials: [],
userLevelSelectedCalendars: [],
},
},
{
isFixed: true,
createdAt: oneYearAgo,
weight: undefined,
priority: undefined,
user: {
email: "hello3@gmail.com",
id: 3,
credentials: [],
userLevelSelectedCalendars: [],
},
},
];
const eventType = {
id: 1,
hosts,
users: [],
schedulingType: SchedulingType.ROUND_ROBIN,
maxLeadThreshold: null,
rescheduleWithSameRoundRobinHost: true,
assignAllTeamMembers: true,
assignRRMembersUsingSegment: false,
rrSegmentQueryValue: null,
isRRWeightsEnabled: false,
team: {
id: 1,
parentId: null,
rrResetInterval: RRResetInterval.MONTH,
},
};
prismaMock.booking.findFirst.mockResolvedValue({ userId: 2 });
const result = await qualifiedHostsService.findQualifiedHostsWithDelegationCredentials({
eventType,
routedTeamMemberIds: [],
rescheduleUid: "recheduleUid",
contactOwnerEmail: null,
routingFormResponse: null,
});
expect(result).toEqual({
qualifiedRRHosts: [hosts[1]],
fixedHosts: [hosts[2]],
});
});
it("should return early if segment matching results in only one host", async () => {
const oneYearAgo = new Date();
oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1);
const hosts = [
{
isFixed: false,
createdAt: oneYearAgo,
weight: undefined,
priority: undefined,
user: {
email: "hello1@gmail.com",
id: 1,
credentials: [],
userLevelSelectedCalendars: [],
},
},
{
isFixed: false,
createdAt: oneYearAgo,
weight: undefined,
priority: undefined,
user: {
email: "hello2@gmail.com",
id: 2,
credentials: [],
userLevelSelectedCalendars: [],
},
},
{
isFixed: false,
createdAt: oneYearAgo,
weight: undefined,
priority: undefined,
user: {
email: "hello3@gmail.com",
id: 3,
credentials: [],
userLevelSelectedCalendars: [],
},
},
];
const eventType = {
id: 1,
hosts,
users: [],
schedulingType: SchedulingType.ROUND_ROBIN,
maxLeadThreshold: null,
rescheduleWithSameRoundRobinHost: true,
assignAllTeamMembers: true,
assignRRMembersUsingSegment: false,
rrSegmentQueryValue: null,
isRRWeightsEnabled: false,
team: {
id: 1,
parentId: null,
rrResetInterval: RRResetInterval.MONTH,
},
};
const findMatchingHostsSpy = vi
.spyOn(getRoutedUsers, "findMatchingHostsWithEventSegment")
.mockImplementation(async () => [hosts[0]]);
// Call the function under test
const result = await qualifiedHostsService.findQualifiedHostsWithDelegationCredentials({
eventType,
routedTeamMemberIds: [0, 1, 2],
rescheduleUid: null,
contactOwnerEmail: null,
routingFormResponse: null,
});
// Verify the result
expect(result).toEqual({
qualifiedRRHosts: [hosts[0]],
fixedHosts: [],
});
// Verify that findMatchingHostsWithEventSegment was called with correct parameters
expect(findMatchingHostsSpy).toHaveBeenCalledWith({
eventType,
hosts: hosts.filter((host) => !host.isFixed), // Only round-robin hosts should be passed
});
// Verify that filterHostsByLeadThreshold was not called since we returned early
expect(filterHostsByLeadThreshold).not.toHaveBeenCalled();
// Verify that allFallbackRRHosts is not present in the result
expect(result).not.toHaveProperty("allFallbackRRHosts");
});
it("should filter for segment matching and routed team member ids", async () => {
const oneYearAgo = new Date();
oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1);
const hosts = [
{
isFixed: false,
createdAt: oneYearAgo,
weight: undefined,
priority: undefined,
user: {
email: "hello1@gmail.com",
id: 1,
credentials: [],
userLevelSelectedCalendars: [],
},
},
{
isFixed: false,
createdAt: oneYearAgo,
weight: undefined,
priority: undefined,
user: {
email: "hello2@gmail.com",
id: 2,
credentials: [],
userLevelSelectedCalendars: [],
},
},
{
isFixed: false,
createdAt: oneYearAgo,
weight: undefined,
priority: undefined,
user: {
email: "hello3@gmail.com",
id: 3,
credentials: [],
userLevelSelectedCalendars: [],
},
},
];
const eventType = {
id: 1,
hosts,
users: [],
schedulingType: SchedulingType.ROUND_ROBIN,
maxLeadThreshold: null,
rescheduleWithSameRoundRobinHost: true,
assignAllTeamMembers: true,
assignRRMembersUsingSegment: false,
rrSegmentQueryValue: null,
isRRWeightsEnabled: false,
team: {
id: 1,
parentId: null,
rrResetInterval: RRResetInterval.MONTH,
},
};
vi.spyOn(getRoutedUsers, "findMatchingHostsWithEventSegment").mockImplementation(async () => [
hosts[0],
hosts[1],
]);
// Call the function under test
const result = await qualifiedHostsService.findQualifiedHostsWithDelegationCredentials({
eventType,
routedTeamMemberIds: [2, 3],
rescheduleUid: null,
contactOwnerEmail: null,
routingFormResponse: null,
});
// Verify the result
expect(result).toEqual({
qualifiedRRHosts: [hosts[1]],
fixedHosts: [],
});
});
it("should filter for fairness and return fallback with segment filtering and routed team member ids", async () => {
const oneYearAgo = new Date();
oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1);
const hosts = [
{
isFixed: false,
createdAt: oneYearAgo,
weight: undefined,
priority: undefined,
user: {
email: "hello1@gmail.com",
id: 1,
credentials: [],
userLevelSelectedCalendars: [],
},
},
{
isFixed: false,
createdAt: oneYearAgo,
weight: undefined,
priority: undefined,
user: {
email: "hello2@gmail.com",
id: 2,
credentials: [],
userLevelSelectedCalendars: [],
},
},
{
isFixed: false,
createdAt: oneYearAgo,
weight: undefined,
priority: undefined,
user: {
email: "hello3@gmail.com",
id: 3,
credentials: [],
userLevelSelectedCalendars: [],
},
},
{
isFixed: false,
createdAt: oneYearAgo,
weight: undefined,
priority: undefined,
user: {
email: "hello3@gmail.com",
id: 3,
credentials: [],
userLevelSelectedCalendars: [],
},
},
];
const eventType = {
id: 1,
hosts,
users: [],
schedulingType: SchedulingType.ROUND_ROBIN,
maxLeadThreshold: null,
rescheduleWithSameRoundRobinHost: true,
assignAllTeamMembers: true,
assignRRMembersUsingSegment: false,
rrSegmentQueryValue: null,
isRRWeightsEnabled: false,
team: {
id: 1,
parentId: null,
rrResetInterval: RRResetInterval.MONTH,
},
};
vi.spyOn(getRoutedUsers, "findMatchingHostsWithEventSegment").mockImplementation(async () => [
hosts[0],
hosts[1],
hosts[2],
]);
const rrHostsAfterFairness = [hosts[2]];
// Configure the mock return value
(filterHostsByLeadThreshold as Mock).mockResolvedValue(rrHostsAfterFairness);
// Call the function under test
const result = await qualifiedHostsService.findQualifiedHostsWithDelegationCredentials({
eventType,
routedTeamMemberIds: [2, 3],
rescheduleUid: null,
contactOwnerEmail: null,
routingFormResponse: null,
});
// Verify the result
expect(result).toEqual({
qualifiedRRHosts: [hosts[2]],
allFallbackRRHosts: [hosts[1], hosts[2]],
fixedHosts: [],
});
});
});
@@ -0,0 +1,227 @@
import {
findMatchingHostsWithEventSegment,
getNormalizedHostsWithDelegationCredentials,
} from "@calcom/features/users/lib/getRoutedUsers";
import type { EventType } from "@calcom/features/users/lib/getRoutedUsers";
import { withReporting } from "@calcom/lib/sentryWrapper";
import type { RoutingFormResponse } from "@calcom/features/bookings/lib/getLuckyUser";
import type { BookingRepository } from "@calcom/lib/server/repository/booking";
import type { SelectedCalendar } from "@calcom/prisma/client";
import type { SchedulingType } from "@calcom/prisma/enums";
import type { CredentialForCalendarService, CredentialPayload } from "@calcom/types/Credential";
import { filterHostsByLeadThreshold } from "./filterHostsByLeadThreshold";
import type { FilterHostsService } from "./filterHostsBySameRoundRobinHost";
export interface IQualifiedHostsService {
bookingRepo: BookingRepository;
filterHostsService: FilterHostsService;
}
type Host<T> = {
isFixed: boolean;
createdAt: Date;
priority?: number | null;
weight?: number | null;
groupId: string | null;
} & {
user: T;
};
// In case we don't have any matching team members, we return all the RR hosts, as we always want the team event to be bookable.
// Each filter is filtered down, but we never return 0-length.
// TODO: We should notify about it to the organizer somehow.
function applyFilterWithFallback<T>(currentValue: T[], newValue: T[]): T[] {
return newValue.length > 0 ? newValue : currentValue;
}
function getFallBackWithContactOwner<T extends { user: { id: number } }>(
fallbackHosts: T[],
contactOwner: T
) {
if (fallbackHosts.find((host) => host.user.id === contactOwner.user.id)) {
return fallbackHosts;
}
return [...fallbackHosts, contactOwner];
}
const isRoundRobinHost = <T extends { isFixed: boolean }>(host: T): host is T & { isFixed: false } => {
return host.isFixed === false;
};
const isFixedHost = <T extends { isFixed: boolean }>(host: T): host is T & { isFixed: false } => {
return host.isFixed;
};
export class QualifiedHostsService {
constructor(public readonly dependencies: IQualifiedHostsService) {}
async _findQualifiedHostsWithDelegationCredentials<
T extends {
email: string;
id: number;
credentials: CredentialPayload[];
userLevelSelectedCalendars: SelectedCalendar[];
} & Record<string, unknown>
>({
eventType,
rescheduleUid,
routedTeamMemberIds,
contactOwnerEmail,
routingFormResponse,
}: {
eventType: {
id: number;
maxLeadThreshold?: number | null;
hosts?: Host<T>[];
users: T[];
schedulingType: SchedulingType | null;
isRRWeightsEnabled: boolean;
rescheduleWithSameRoundRobinHost: boolean;
includeNoShowInRRCalculation: boolean;
} & EventType;
rescheduleUid: string | null;
routedTeamMemberIds: number[];
contactOwnerEmail: string | null;
routingFormResponse: RoutingFormResponse | null;
}): Promise<{
qualifiedRRHosts: {
isFixed: boolean;
createdAt: Date | null;
priority?: number | null;
weight?: number | null;
user: Omit<T, "credentials"> & { credentials: CredentialForCalendarService[] };
}[];
fixedHosts: {
isFixed: boolean;
createdAt: Date | null;
priority?: number | null;
weight?: number | null;
user: Omit<T, "credentials"> & { credentials: CredentialForCalendarService[] };
}[];
// all hosts we want to fallback to including the qualifiedRRHosts (fairness + crm contact owner)
allFallbackRRHosts?: {
isFixed: boolean;
createdAt: Date | null;
priority?: number | null;
weight?: number | null;
user: Omit<T, "credentials"> & { credentials: CredentialForCalendarService[] };
}[];
}> {
const { hosts: normalizedHosts, fallbackHosts: fallbackUsers } =
await getNormalizedHostsWithDelegationCredentials({
eventType,
});
// not a team event type, or some other reason - segment matching isn't necessary.
if (!normalizedHosts) {
const fixedHosts = fallbackUsers.filter(isFixedHost);
const roundRobinHosts = fallbackUsers.filter(isRoundRobinHost);
return { qualifiedRRHosts: roundRobinHosts, fixedHosts };
}
const fixedHosts = normalizedHosts.filter(isFixedHost);
const roundRobinHosts = normalizedHosts.filter(isRoundRobinHost);
// If it is rerouting, we should not force reschedule with same host.
const hostsAfterRescheduleWithSameRoundRobinHost = applyFilterWithFallback(
roundRobinHosts,
await this.dependencies.filterHostsService.filterHostsBySameRoundRobinHost({
hosts: roundRobinHosts,
rescheduleUid,
rescheduleWithSameRoundRobinHost: eventType.rescheduleWithSameRoundRobinHost,
routedTeamMemberIds,
})
);
if (hostsAfterRescheduleWithSameRoundRobinHost.length === 1) {
return {
qualifiedRRHosts: hostsAfterRescheduleWithSameRoundRobinHost,
fixedHosts,
};
}
const hostsAfterSegmentMatching = applyFilterWithFallback(
hostsAfterRescheduleWithSameRoundRobinHost,
(await findMatchingHostsWithEventSegment({
eventType,
hosts: hostsAfterRescheduleWithSameRoundRobinHost,
})) as typeof hostsAfterRescheduleWithSameRoundRobinHost
);
if (hostsAfterSegmentMatching.length === 1) {
return {
qualifiedRRHosts: hostsAfterSegmentMatching,
fixedHosts,
};
}
//if segment matching doesn't return any hosts we fall back to all round robin hosts
const officalRRHosts = hostsAfterSegmentMatching.length
? hostsAfterSegmentMatching
: hostsAfterRescheduleWithSameRoundRobinHost;
const hostsAfterContactOwnerMatching = applyFilterWithFallback(
officalRRHosts,
officalRRHosts.filter((host) => host.user.email === contactOwnerEmail)
);
const hostsAfterRoutedTeamMemberIdsMatching = applyFilterWithFallback(
officalRRHosts,
officalRRHosts.filter((host) => routedTeamMemberIds.includes(host.user.id))
);
if (hostsAfterRoutedTeamMemberIdsMatching.length === 1) {
if (hostsAfterContactOwnerMatching.length === 1) {
return {
qualifiedRRHosts: hostsAfterContactOwnerMatching,
allFallbackRRHosts: getFallBackWithContactOwner(
hostsAfterRoutedTeamMemberIdsMatching,
hostsAfterContactOwnerMatching[0]
),
fixedHosts,
};
}
return {
qualifiedRRHosts: hostsAfterRoutedTeamMemberIdsMatching,
fixedHosts,
};
}
const hostsAfterFairnessMatching = applyFilterWithFallback(
hostsAfterRoutedTeamMemberIdsMatching,
await filterHostsByLeadThreshold({
eventType,
hosts: hostsAfterRoutedTeamMemberIdsMatching,
maxLeadThreshold: eventType.maxLeadThreshold ?? null,
routingFormResponse,
})
);
if (hostsAfterContactOwnerMatching.length === 1) {
return {
qualifiedRRHosts: hostsAfterContactOwnerMatching,
allFallbackRRHosts: getFallBackWithContactOwner(
hostsAfterFairnessMatching,
hostsAfterContactOwnerMatching[0]
),
fixedHosts,
};
}
return {
qualifiedRRHosts: hostsAfterFairnessMatching,
// only if fairness filtering is active
allFallbackRRHosts:
hostsAfterFairnessMatching.length !== hostsAfterRoutedTeamMemberIdsMatching.length
? hostsAfterRoutedTeamMemberIdsMatching
: undefined,
fixedHosts,
};
}
findQualifiedHostsWithDelegationCredentials = withReporting(
this._findQualifiedHostsWithDelegationCredentials.bind(this),
"findQualifiedHostsWithDelegationCredentials"
);
}
@@ -1,6 +1,6 @@
import { workflowSelect } from "@calcom/ee/workflows/lib/getAllWorkflows";
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
import { enrichUserWithDelegationCredentials } from "@calcom/lib/delegationCredential/server";
import { enrichUserWithDelegationCredentials } from "@calcom/app-store/delegationCredential";
import { getBookerBaseUrl } from "@calcom/lib/getBookerUrl/server";
import { HttpError as HttpCode } from "@calcom/lib/http-error";
import { isPrismaObjOrUndefined } from "@calcom/lib/isPrismaObj";
@@ -1,5 +1,5 @@
import { uniqueBy } from "@calcom/lib/array";
import { isInMemoryDelegationCredential } from "@calcom/lib/delegationCredential/clientAndServer";
import { isInMemoryDelegationCredential } from "@calcom/lib/delegationCredential";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import prisma from "@calcom/prisma";
@@ -1,6 +1,6 @@
import { getCalendar } from "@calcom/app-store/_utils/getCalendar";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { getCredentialForCalendarCache } from "@calcom/lib/delegationCredential/server";
import { getCredentialForCalendarCache } from "@calcom/app-store/delegationCredential";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import prisma from "@calcom/prisma";
@@ -1,3 +1,4 @@
import { getCredentialForSelectedCalendar } from "@calcom/app-store/delegationCredential";
import type {
AdapterFactory,
CalendarSubscriptionProvider,
@@ -9,7 +10,6 @@ import type {
import type { CalendarCacheEventService } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventService";
import type { CalendarSyncService } from "@calcom/features/calendar-subscription/lib/sync/CalendarSyncService";
import type { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { getCredentialForSelectedCalendar } from "@calcom/lib/delegationCredential/server";
import logger from "@calcom/lib/logger";
import type { ISelectedCalendarRepository } from "@calcom/lib/server/repository/SelectedCalendarRepository.interface";
import { SelectedCalendar } from "@calcom/prisma/client";
@@ -7,6 +7,6 @@ export const getCredentialForSelectedCalendar = vi.fn().mockResolvedValue({
delegatedTo: null,
});
vi.doMock("@calcom/lib/delegationCredential/server", () => ({
vi.doMock("@calcom/app-store/delegationCredential", () => ({
getCredentialForSelectedCalendar,
}));
+1 -1
View File
@@ -7,7 +7,7 @@ import { useEmbedStyles } from "@calcom/embed-core/embed-iframe";
import { useBookerStoreContext } from "@calcom/features/bookings/Booker/BookerStoreProvider";
import { getAvailableDatesInMonth } from "@calcom/features/calendars/lib/getAvailableDatesInMonth";
import { daysInMonth, yyyymmdd } from "@calcom/lib/dayjs";
import type { IFromUser, IToUser } from "@calcom/lib/getUserAvailability";
import type { IFromUser, IToUser } from "@calcom/features/availability/lib/getUserAvailability";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { weekdayNames } from "@calcom/lib/weekday";
import type { PeriodData } from "@calcom/types/Event";
@@ -5,14 +5,15 @@ import { getCalendar } from "@calcom/app-store/_utils/getCalendar";
import { MeetLocationType } from "@calcom/app-store/locations";
import getApps from "@calcom/app-store/utils";
import dayjs from "@calcom/dayjs";
import getCalendarsEvents, {
getCalendarsEventsWithTimezones,
} from "@calcom/features/calendars/lib/getCalendarsEvents";
import { getUid } from "@calcom/lib/CalEventParser";
import { getRichDescription } from "@calcom/lib/CalEventParser";
import { CalendarAppDelegationCredentialError } from "@calcom/lib/CalendarAppError";
import { ORGANIZER_EMAIL_EXEMPT_DOMAINS } from "@calcom/lib/constants";
import { buildNonDelegationCredentials } from "@calcom/lib/delegationCredential/clientAndServer";
import { buildNonDelegationCredentials } from "@calcom/lib/delegationCredential";
import { formatCalEvent } from "@calcom/lib/formatCalendarEvent";
import getCalendarsEvents from "@calcom/lib/getCalendarsEvents";
import { getCalendarsEventsWithTimezones } from "@calcom/lib/getCalendarsEvents";
import logger from "@calcom/lib/logger";
import { getPiiFreeCalendarEvent, getPiiFreeCredential } from "@calcom/lib/piiFreeData";
import { safeStringify } from "@calcom/lib/safeStringify";
@@ -0,0 +1,786 @@
import "../../../../tests/libs/__mocks__/prisma";
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import GoogleCalendarService from "@calcom/app-store/googlecalendar/lib/CalendarService";
import OfficeCalendarService from "@calcom/app-store/office365calendar/lib/CalendarService";
import { symmetricDecrypt } from "@calcom/lib/crypto";
import logger from "@calcom/lib/logger";
import type { SelectedCalendar } from "@calcom/prisma/client";
import type { EventBusyDate } from "@calcom/types/Calendar";
import type { CredentialForCalendarService, CredentialPayload } from "@calcom/types/Credential";
import getCalendarsEvents, {
getCalendarsEventsWithTimezones,
filterSelectedCalendarsForCredential,
} from "./getCalendarsEvents";
vi.mock("@calcom/lib/crypto", () => ({
symmetricDecrypt: vi.fn(),
}));
const mockedSymmetricDecrypt = vi.mocked(symmetricDecrypt);
vi.mock("@calcom/app-store/calendar.services.generated", () => {
class MockGoogleCalendarService {
constructor(credential: any) {
this.credential = credential;
}
getCredentialId() {
return this.credential.id;
}
async createEvent() {
return {};
}
async updateEvent() {
return {};
}
async deleteEvent() {
return {};
}
async getAvailability() {
return [];
}
async getAvailabilityWithTimeZones() {
return [];
}
async listCalendars() {
return [];
}
}
class MockOfficeCalendarService {
constructor(credential: any) {
this.credential = credential;
}
getCredentialId() {
return this.credential.id;
}
async createEvent() {
return {};
}
async updateEvent() {
return {};
}
async deleteEvent() {
return {};
}
async getAvailability() {
return [];
}
async getAvailabilityWithTimeZones() {
return [];
}
async listCalendars() {
return [];
}
}
return {
CalendarServiceMap: {
googlecalendar: vi.importActual("@calcom/app-store/googlecalendar/lib/CalendarService"),
office365calendar: vi.importActual("@calcom/app-store/office365calendar/lib/CalendarService"),
},
};
});
function buildDelegationCredential(credential: CredentialPayload): CredentialForCalendarService {
return {
...credential,
id: -1,
delegatedTo: {
serviceAccountKey: {
client_email: "client_email",
tenant_id: "tenant_id",
client_id: "client_id",
private_key: "private_key",
},
},
};
}
function buildRegularCredential(credential: CredentialPayload): CredentialForCalendarService {
return {
...credential,
delegatedTo: null,
delegatedToId: null,
};
}
function buildSelectedCalendar(credential: {
credentialId: number;
externalId: string;
integration: string;
userId: number;
id: string;
}): SelectedCalendar {
return {
googleChannelId: null,
googleChannelKind: null,
googleChannelResourceId: null,
eventTypeId: null,
googleChannelResourceUri: null,
googleChannelExpiration: null,
delegationCredentialId: null,
domainWideDelegationCredentialId: null,
error: null,
createdAt: new Date(),
updatedAt: new Date(),
lastErrorAt: null,
watchAttempts: 0,
unwatchAttempts: 0,
maxAttempts: 3,
...credential,
};
}
describe("getCalendarsEvents", () => {
let credential: CredentialPayload;
beforeEach(() => {
vi.spyOn(logger.constructor.prototype, "debug");
credential = {
id: 303,
type: "google_calendar",
key: {
scope: "example scope",
token_type: "Bearer",
expiry_date: Date.now() + 84000,
access_token: "access token",
refresh_token: "refresh token",
},
userId: 808,
teamId: null,
user: {
email: "test@example.com",
},
appId: "exampleApp",
invalid: false,
delegationCredentialId: null,
};
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("Regular Credentials", () => {
it("should return empty array if no calendar credentials", async () => {
const result = await getCalendarsEvents(
[
buildRegularCredential({
...credential,
type: "totally_unrelated",
}),
],
"2010-12-01",
"2010-12-02",
[]
);
expect(result).toEqual([]);
});
it("should return unknown calendars as empty", async () => {
const result = await getCalendarsEvents(
[
buildRegularCredential({
...credential,
type: "unknown_calendar",
}),
],
"2010-12-01",
"2010-12-02",
[]
);
expect(result).toEqual([[]]);
});
it("should return unmatched calendars as empty", async () => {
const selectedCalendar: SelectedCalendar = buildSelectedCalendar({
credentialId: 100,
externalId: "externalId",
integration: "office365_calendar",
userId: 200,
id: "id",
});
const result = await getCalendarsEvents(
[
buildRegularCredential({
...credential,
type: "google_calendar",
}),
],
"2010-12-01",
"2010-12-02",
[selectedCalendar]
);
expect(result).toEqual([[]]);
});
it("should return availability from selected calendar", async () => {
const availability: EventBusyDate[] = [
{
start: new Date(2010, 11, 2),
end: new Date(2010, 11, 3),
},
{
start: new Date(2010, 11, 2, 4),
end: new Date(2010, 11, 2, 16),
},
];
const getAvailabilitySpy = vi
.spyOn(GoogleCalendarService.prototype, "getAvailability")
.mockReturnValue(Promise.resolve(availability));
const selectedCalendar: SelectedCalendar = buildSelectedCalendar({
credentialId: 100,
externalId: "externalId",
integration: "google_calendar",
userId: 200,
id: "id",
});
const result = await getCalendarsEvents(
[
buildRegularCredential({
...credential,
type: "google_calendar",
}),
],
"2010-12-01",
"2010-12-04",
[selectedCalendar]
);
expect(getAvailabilitySpy).toHaveBeenCalledWith(
"2010-12-01",
"2010-12-04",
[selectedCalendar],
undefined,
false
);
expect(result).toEqual([
availability.map((av) => ({
...av,
source: "exampleApp",
})),
]);
});
it("should return availability from multiple calendars", async () => {
const googleAvailability: EventBusyDate[] = [
{
start: new Date(2010, 11, 2),
end: new Date(2010, 11, 3),
},
];
const officeAvailability: EventBusyDate[] = [
{
start: new Date(2010, 11, 2, 4),
end: new Date(2010, 11, 2, 16),
},
];
const getGoogleAvailabilitySpy = vi
.spyOn(GoogleCalendarService.prototype, "getAvailability")
.mockReturnValue(Promise.resolve(googleAvailability));
const getOfficeAvailabilitySpy = vi
.spyOn(OfficeCalendarService.prototype, "getAvailability")
.mockReturnValue(Promise.resolve(officeAvailability));
const selectedGoogleCalendar: SelectedCalendar = buildSelectedCalendar({
credentialId: 100,
externalId: "externalId",
integration: "google_calendar",
userId: 200,
id: "id",
});
const selectedOfficeCalendar: SelectedCalendar = buildSelectedCalendar({
credentialId: 100,
externalId: "externalId",
integration: "office365_calendar",
userId: 200,
id: "id",
});
const result = await getCalendarsEvents(
[
buildRegularCredential({
...credential,
type: "google_calendar",
}),
buildRegularCredential({
...credential,
type: "office365_calendar",
key: {
access_token: "access",
refresh_token: "refresh",
expires_in: Date.now() + 86400,
},
}),
],
"2010-12-01",
"2010-12-04",
[selectedGoogleCalendar, selectedOfficeCalendar]
);
expect(getGoogleAvailabilitySpy).toHaveBeenCalledWith(
"2010-12-01",
"2010-12-04",
[selectedGoogleCalendar],
undefined,
false
);
expect(getOfficeAvailabilitySpy).toHaveBeenCalledWith(
"2010-12-01",
"2010-12-04",
[selectedOfficeCalendar],
undefined,
false
);
expect(result).toEqual([
googleAvailability.map((av) => ({
...av,
source: "exampleApp",
})),
officeAvailability.map((av) => ({
...av,
source: "exampleApp",
})),
]);
});
it("should not call getAvailability if selectedCalendars is empty", async () => {
const getAvailabilitySpy = vi
.spyOn(GoogleCalendarService.prototype, "getAvailability")
.mockReturnValue(Promise.resolve([]));
const result = await getCalendarsEvents(
[buildRegularCredential(credential)],
"2010-12-01",
"2010-12-02",
[]
);
expect(getAvailabilitySpy).not.toHaveBeenCalled();
expect(result).toEqual([[]]);
});
});
describe("Delegation Credentials", () => {
it("should allow getAvailability call even without any selected calendars with allowFallbackToPrimary=true", async () => {
const startDate = "2010-12-01";
const endDate = "2010-12-02";
const delegationCredential: CredentialForCalendarService = buildDelegationCredential(credential);
const credentials = [delegationCredential];
const getAvailabilitySpy = vi
.spyOn(GoogleCalendarService.prototype, "getAvailability")
.mockReturnValue(Promise.resolve([]));
const result = await getCalendarsEvents(credentials, startDate, endDate, []);
expect(getAvailabilitySpy).toHaveBeenCalledWith(startDate, endDate, [], undefined, true);
expect(result).toEqual([[]]);
});
});
});
describe("getCalendarsEventsWithTimezones", () => {
let credential: CredentialPayload;
beforeEach(() => {
vi.spyOn(logger.constructor.prototype, "debug");
credential = {
id: 303,
type: "google_calendar",
key: {
scope: "example scope",
token_type: "Bearer",
expiry_date: Date.now() + 84000,
access_token: "access token",
refresh_token: "refresh token",
},
userId: 808,
teamId: null,
user: {
email: "test@example.com",
},
appId: "exampleApp",
invalid: false,
delegationCredentialId: null,
};
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("Regular Credentials", () => {
it("should return empty array if no calendar credentials", async () => {
const result = await getCalendarsEventsWithTimezones(
[
buildRegularCredential({
...credential,
type: "totally_unrelated",
}),
],
"2010-12-01",
"2010-12-02",
[]
);
expect(result).toEqual([]);
});
it("should return unknown calendars as empty", async () => {
const result = await getCalendarsEventsWithTimezones(
[
buildRegularCredential({
...credential,
type: "unknown_calendar",
}),
],
"2010-12-01",
"2010-12-02",
[]
);
expect(result).toEqual([]);
});
it("should return unmatched calendars as empty", async () => {
const selectedCalendar: SelectedCalendar = buildSelectedCalendar({
credentialId: 100,
externalId: "externalId",
integration: "office365_calendar",
userId: 200,
id: "id",
});
const result = await getCalendarsEventsWithTimezones(
[
buildRegularCredential({
...credential,
type: "google_calendar",
}),
],
"2010-12-01",
"2010-12-02",
[selectedCalendar]
);
expect(result).toEqual([[]]);
});
it("should return availability from selected calendar", async () => {
const availability = [
{
start: new Date(2010, 11, 2),
end: new Date(2010, 11, 3),
timeZone: "America/New_York",
},
{
start: new Date(2010, 11, 2, 4),
end: new Date(2010, 11, 2, 16),
timeZone: "America/New_York",
},
];
const getAvailabilityWithTimezonesSpy = vi
.spyOn(GoogleCalendarService.prototype, "getAvailabilityWithTimeZones")
.mockReturnValue(Promise.resolve(availability));
const selectedCalendar: SelectedCalendar = buildSelectedCalendar({
credentialId: 100,
externalId: "externalId",
integration: "google_calendar",
userId: 200,
id: "id",
});
const result = await getCalendarsEventsWithTimezones(
[
buildRegularCredential({
...credential,
type: "google_calendar",
}),
],
"2010-12-01",
"2010-12-04",
[selectedCalendar]
);
expect(getAvailabilityWithTimezonesSpy).toHaveBeenCalledWith(
"2010-12-01",
"2010-12-04",
[selectedCalendar],
false
);
expect(result).toEqual([
availability.map((av) => ({
...av,
})),
]);
});
it("should not call getAvailabilityWithTimezones if selectedCalendars is empty", async () => {
const getAvailabilityWithTimezonesSpy = vi
.spyOn(GoogleCalendarService.prototype, "getAvailabilityWithTimeZones")
.mockReturnValue(Promise.resolve([]));
const result = await getCalendarsEventsWithTimezones(
[buildRegularCredential(credential)],
"2010-12-01",
"2010-12-02",
[]
);
expect(getAvailabilityWithTimezonesSpy).not.toHaveBeenCalled();
expect(result).toEqual([[]]);
});
});
describe("Delegation Credentials", () => {
it("should allow getAvailabilityWithTimezones call even without any selected calendars with allowFallbackToPrimary=true", async () => {
const startDate = "2010-12-01";
const endDate = "2010-12-02";
const delegationCredential: CredentialForCalendarService = buildDelegationCredential(credential);
const credentials = [delegationCredential];
const getAvailabilityWithTimezonesSpy = vi
.spyOn(GoogleCalendarService.prototype, "getAvailabilityWithTimeZones")
.mockReturnValue(Promise.resolve([]));
const result = await getCalendarsEventsWithTimezones(credentials, startDate, endDate, []);
expect(getAvailabilityWithTimezonesSpy).toHaveBeenCalledWith(startDate, endDate, [], true);
expect(result).toEqual([[]]);
});
});
});
// CalDAV Credential Leak Prevention Tests
describe("CalDAV credential leak prevention", () => {
function buildCalDAVCredential(data: {
id: number;
key: string;
userId?: number;
}): CredentialForCalendarService {
return {
id: data.id,
type: "caldav_calendar",
key: data.key,
userId: data.userId || 1,
user: { email: "test@example.com" },
teamId: null,
appId: "caldav-calendar",
invalid: false,
delegatedTo: null,
delegationCredentialId: null,
};
}
function buildCalDAVSelectedCalendar(data: {
id: string;
externalId: string;
credentialId?: number;
}): SelectedCalendar {
return {
id: data.id,
userId: 1,
integration: "caldav_calendar",
externalId: data.externalId,
credentialId: data.credentialId || null,
createdAt: new Date(),
updatedAt: new Date(),
googleChannelId: null,
googleChannelKind: null,
googleChannelResourceId: null,
googleChannelResourceUri: null,
googleChannelExpiration: null,
delegationCredentialId: null,
domainWideDelegationCredentialId: null,
error: null,
lastErrorAt: null,
watchAttempts: 0,
unwatchAttempts: 0,
maxAttempts: 3,
eventTypeId: null,
};
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("filterSelectedCalendarsForCredential", () => {
it("prevents CalDAV credential leak by matching server URLs", () => {
// Setup: Two CalDAV servers with different URLs
const serverACredential = buildCalDAVCredential({
id: 1,
key: "encrypted_server_a_key",
});
const serverBCredential = buildCalDAVCredential({
id: 2,
key: "encrypted_server_b_key",
});
// Mock encrypted credential data for different servers
mockedSymmetricDecrypt
.mockReturnValueOnce(
JSON.stringify({
username: "user_a",
password: "pass_a",
url: "https://server-a.example.com/dav/calendars/user/",
})
)
.mockReturnValueOnce(
JSON.stringify({
username: "user_b",
password: "pass_b",
url: "https://server-b.example.com/dav/calendars/user/",
})
);
// Selected calendars from both servers
const selectedCalendars = [
buildCalDAVSelectedCalendar({
id: "cal_1",
externalId: "https://server-a.example.com/dav/calendars/user/calendar1/",
credentialId: 1,
}),
buildCalDAVSelectedCalendar({
id: "cal_2",
externalId: "https://server-b.example.com/dav/calendars/user/calendar2/",
credentialId: 2,
}),
];
// Test Server A credential - should only return Server A calendars
const serverACalendars = filterSelectedCalendarsForCredential(selectedCalendars, serverACredential);
expect(serverACalendars).toHaveLength(1);
expect(serverACalendars[0].externalId).toBe(
"https://server-a.example.com/dav/calendars/user/calendar1/"
);
// Test Server B credential - should only return Server B calendars
const serverBCalendars = filterSelectedCalendarsForCredential(selectedCalendars, serverBCredential);
expect(serverBCalendars).toHaveLength(1);
expect(serverBCalendars[0].externalId).toBe(
"https://server-b.example.com/dav/calendars/user/calendar2/"
);
});
it("demonstrates the credential leak that existed before the fix", () => {
// This test shows what WOULD happen with naive filtering (integration type only)
const serverACredential = buildCalDAVCredential({
id: 1,
key: "encrypted_server_a_key",
});
const selectedCalendars = [
buildCalDAVSelectedCalendar({
id: "cal_1",
externalId: "https://server-a.example.com/dav/calendars/user/calendar1/",
credentialId: 1,
}),
buildCalDAVSelectedCalendar({
id: "cal_2",
externalId: "https://server-b.example.com/dav/calendars/user/calendar2/",
credentialId: 2,
}),
];
// Legacy filtering (type-only) would return ALL CalDAV calendars
const legacyFiltering = selectedCalendars.filter((sc) => sc.integration === "caldav_calendar");
expect(legacyFiltering).toHaveLength(2); // This demonstrates the leak - both calendars returned
// Our new filtering prevents this
mockedSymmetricDecrypt.mockReturnValue(
JSON.stringify({
username: "user_a",
password: "pass_a",
url: "https://server-a.example.com/dav/calendars/user/",
})
);
const secureFiltering = filterSelectedCalendarsForCredential(selectedCalendars, serverACredential);
expect(secureFiltering).toHaveLength(1); // Only calendars from matching server
expect(secureFiltering[0].externalId).toContain("server-a.example.com");
});
it("handles non-CalDAV calendars normally", () => {
const googleCredential: CredentialForCalendarService = {
id: 1,
type: "google_calendar",
key: "google_key",
userId: 1,
user: { email: "test@example.com" },
teamId: null,
appId: "google-calendar",
invalid: false,
delegatedTo: null,
delegationCredentialId: null,
};
const selectedCalendars = [
buildCalDAVSelectedCalendar({
id: "cal_1",
externalId: "https://server-a.example.com/dav/calendars/user/calendar1/",
}),
{
...buildCalDAVSelectedCalendar({
id: "cal_2",
externalId: "primary",
}),
integration: "google_calendar",
},
];
const googleCalendars = filterSelectedCalendarsForCredential(selectedCalendars, googleCredential);
expect(googleCalendars).toHaveLength(1);
expect(googleCalendars[0].integration).toBe("google_calendar");
});
it("handles invalid CalDAV credential URLs gracefully", () => {
const invalidCredential = buildCalDAVCredential({
id: 1,
key: "encrypted_invalid_key",
});
mockedSymmetricDecrypt.mockReturnValue(
JSON.stringify({
username: "user",
password: "pass",
url: "invalid-url-format",
})
);
const selectedCalendars = [
buildCalDAVSelectedCalendar({
id: "cal_1",
externalId: "https://server-a.example.com/dav/calendars/user/calendar1/",
}),
];
const result = filterSelectedCalendarsForCredential(selectedCalendars, invalidCredential);
expect(result).toHaveLength(0); // Should return empty array for safety
});
});
});
@@ -0,0 +1,274 @@
import { getCalendar } from "@calcom/app-store/_utils/getCalendar";
import { symmetricDecrypt } from "@calcom/lib/crypto";
import { isDelegationCredential } from "@calcom/lib/delegationCredential";
import logger from "@calcom/lib/logger";
import { getPiiFreeSelectedCalendar, getPiiFreeCredential } from "@calcom/lib/piiFreeData";
import { safeStringify } from "@calcom/lib/safeStringify";
import { performance } from "@calcom/lib/server/perfObserver";
import type { EventBusyDate, SelectedCalendar } from "@calcom/types/Calendar";
import type { CredentialForCalendarService } from "@calcom/types/Credential";
const log = logger.getSubLogger({ prefix: ["getCalendarsEvents"] });
const CALENDSO_ENCRYPTION_KEY = process.env.CALENDSO_ENCRYPTION_KEY || "";
// only for Google Calendar for now
export const getCalendarsEventsWithTimezones = async (
withCredentials: CredentialForCalendarService[],
dateFrom: string,
dateTo: string,
selectedCalendars: SelectedCalendar[]
): Promise<(EventBusyDate & { timeZone: string })[][]> => {
const calendarCredentials = withCredentials
.filter((credential) => credential.type === "google_calendar")
// filter out invalid credentials - these won't work.
.filter((credential) => !credential.invalid);
const calendarAndCredentialPairs = await Promise.all(
calendarCredentials.map(async (credential) => {
const calendar = await getCalendar(credential);
return [calendar, credential] as const;
})
);
const calendars = calendarAndCredentialPairs.map(([calendar]) => calendar);
const calendarToCredentialMap = new Map(calendarAndCredentialPairs);
const results = calendars.map(async (c, i) => {
/** Filter out nulls */
if (!c) return [];
/** We rely on the index so we can match credentials with calendars */
const { type } = calendarCredentials[i];
const credential = calendarToCredentialMap.get(c);
/** We just pass the calendars that matched the credential type,
* TODO: Migrate credential type or appId
*/
const passedSelectedCalendars = credential
? filterSelectedCalendarsForCredential(selectedCalendars, credential)
: selectedCalendars
.filter((sc) => sc.integration === type)
// Needed to ensure cache keys are consistent
.sort((a, b) => (a.externalId < b.externalId ? -1 : a.externalId > b.externalId ? 1 : 0));
const isADelegationCredential = credential && isDelegationCredential({ credentialId: credential.id });
// We want to fallback to primary calendar when no selectedCalendars are passed
// Default behaviour for Google Calendar is to use all available calendars, which isn't good default.
const allowFallbackToPrimary = isADelegationCredential;
if (!passedSelectedCalendars.length) {
if (!isADelegationCredential) {
// It was done to fix the secondary calendar connections from always checking the conflicts even if intentional no calendars are selected.
// https://github.com/calcom/cal.com/issues/8929
log.error(
`No selected calendars for non DWD credential: Skipping getAvailability call for credential ${credential?.id}`
);
return [];
}
// For delegation credential, we should allow getAvailability even without any selected calendars. It ensures that enabling Delegation Credential at Organization level always ensure one selected calendar for conflicts checking, without requiring any manual action from organization members
// This is also, similar to how Google Calendar connect flow(through /googlecalendar/api/callback) sets the primary calendar as the selected calendar automatically.
log.info("Allowing getAvailability even without any selected calendars for Delegation Credential");
}
/** We extract external Ids so we don't cache too much */
const eventBusyDates =
(await c.getAvailabilityWithTimeZones?.(
dateFrom,
dateTo,
passedSelectedCalendars,
allowFallbackToPrimary
)) || [];
return eventBusyDates;
});
const awaitedResults = await Promise.all(results);
return awaitedResults;
};
const getCalendarsEvents = async (
withCredentials: CredentialForCalendarService[],
dateFrom: string,
dateTo: string,
selectedCalendars: SelectedCalendar[],
shouldServeCache?: boolean
): Promise<EventBusyDate[][]> => {
const calendarCredentials = withCredentials
.filter((credential) => credential.type.endsWith("_calendar"))
// filter out invalid credentials - these won't work.
.filter((credential) => !credential.invalid);
const calendarAndCredentialPairs = await Promise.all(
calendarCredentials.map(async (credential) => {
const calendar = await getCalendar(credential);
return [calendar, credential] as const;
})
);
const calendars = calendarAndCredentialPairs.map(([calendar]) => calendar);
const calendarToCredentialMap = new Map(calendarAndCredentialPairs);
performance.mark("getBusyCalendarTimesStart");
const results = calendars.map(async (calendarService, i) => {
/** Filter out nulls */
if (!calendarService) return [];
/** We rely on the index so we can match credentials with calendars */
const { type, appId } = calendarCredentials[i];
const credential = calendarToCredentialMap.get(calendarService);
/** We just pass the calendars that matched the credential type,
* TODO: Migrate credential type or appId
*/
// Important to have them unique so that
const passedSelectedCalendars = credential
? filterSelectedCalendarsForCredential(selectedCalendars, credential)
: selectedCalendars
.filter((sc) => sc.integration === type)
// Needed to ensure cache keys are consistent
.sort((a, b) => (a.externalId < b.externalId ? -1 : a.externalId > b.externalId ? 1 : 0));
const isADelegationCredential = credential && isDelegationCredential({ credentialId: credential.id });
// We want to fallback to primary calendar when no selectedCalendars are passed
// Default behaviour for Google Calendar is to use all available calendars, which isn't good default.
const allowFallbackToPrimary = isADelegationCredential;
if (!passedSelectedCalendars.length) {
if (!isADelegationCredential) {
// It was done to fix the secondary calendar connections from always checking the conflicts even if intentional no calendars are selected.
// https://github.com/calcom/cal.com/issues/8929
log.error(
`No selected calendars for non DWD credential: Skipping getAvailability call for credential ${credential?.id}`
);
return [];
}
// For delegation credential, we should allow getAvailability even without any selected calendars. It ensures that enabling Delegation Credential at Organization level always ensure one selected calendar for conflicts checking, without requiring any manual action from organization members
// This is also, similar to how Google Calendar connect flow(through /googlecalendar/api/callback) sets the primary calendar as the selected calendar automatically.
log.info("Allowing getAvailability even without any selected calendars for Delegation Credential");
}
/** We extract external Ids so we don't cache too much */
const selectedCalendarIds = passedSelectedCalendars.map((sc) => sc.externalId);
/** If we don't then we actually fetch external calendars (which can be very slow) */
performance.mark("eventBusyDatesStart");
log.debug(
`Getting availability for`,
safeStringify({
calendarService: calendarService.constructor.name,
selectedCalendars: passedSelectedCalendars.map(getPiiFreeSelectedCalendar),
})
);
const eventBusyDates = await calendarService.getAvailability(
dateFrom,
dateTo,
passedSelectedCalendars,
shouldServeCache,
allowFallbackToPrimary
);
performance.mark("eventBusyDatesEnd");
performance.measure(
`[getAvailability for ${selectedCalendarIds.join(", ")}][$1]'`,
"eventBusyDatesStart",
"eventBusyDatesEnd"
);
return eventBusyDates.map((a) => ({
...a,
source: `${appId}`,
}));
});
const awaitedResults = await Promise.all(results);
performance.mark("getBusyCalendarTimesEnd");
performance.measure(
`getBusyCalendarTimes took $1 for creds ${calendarCredentials.map((cred) => cred.id)}`,
"getBusyCalendarTimesStart",
"getBusyCalendarTimesEnd"
);
log.debug(
"Result",
safeStringify({
calendarCredentials: calendarCredentials.map(getPiiFreeCredential),
selectedCalendars: selectedCalendars.map(getPiiFreeSelectedCalendar),
calendarEvents: awaitedResults,
})
);
return awaitedResults;
};
export default getCalendarsEvents;
/**
* Extract server URL from CalDAV calendar externalId
*/
function getServerUrlFromCalendarExternalId(externalId: string): string | null {
try {
const url = new URL(externalId);
return `${url.protocol}//${url.host}`;
} catch (error) {
return null;
}
}
/**
* Extract server URL from CalDAV credential
*/
function getServerUrlFromCredential(credential: CredentialForCalendarService): string | null {
try {
if (credential.type !== "caldav_calendar") {
return null;
}
const decryptedData = JSON.parse(symmetricDecrypt(credential.key as string, CALENDSO_ENCRYPTION_KEY));
if (!decryptedData.url) {
return null;
}
const url = new URL(decryptedData.url);
return `${url.protocol}//${url.host}`;
} catch (error) {
return null;
}
}
/**
* Filter selected calendars for the specific credential, handling CalDAV server URL matching
*/
export function filterSelectedCalendarsForCredential(
selectedCalendars: SelectedCalendar[],
credential: CredentialForCalendarService
): SelectedCalendar[] {
const { type } = credential;
// For all other calendar types, use the existing logic
if (type !== "caldav_calendar") {
return selectedCalendars.filter((sc) => sc.integration === type);
}
const credentialServerUrl = getServerUrlFromCredential(credential);
if (!credentialServerUrl) {
log.warn("Could not extract server URL from CalDAV credential", {
credentialId: credential.id,
});
return [];
}
return selectedCalendars.filter((sc) => {
if (sc.integration !== type) {
return false;
}
const calendarServerUrl = getServerUrlFromCalendarExternalId(sc.externalId);
if (!calendarServerUrl) {
log.warn("Could not extract server URL from calendar externalId", {
externalId: sc.externalId,
integration: sc.integration,
});
return false;
}
const matches = credentialServerUrl === calendarServerUrl;
if (!matches) {
log.debug("CalDAV calendar server URL does not match credential server URL", {
credentialId: credential.id,
credentialServerUrl,
calendarServerUrl,
calendarExternalId: sc.externalId,
});
}
return matches;
});
}
@@ -0,0 +1,387 @@
import { enrichUserWithDelegationCredentialsIncludeServiceAccountKey } from "@calcom/app-store/delegationCredential";
import {
getCalendarCredentials,
getConnectedCalendars,
} from "@calcom/features/calendars/lib/CalendarManager";
import { isDelegationCredential } from "@calcom/lib/delegationCredential";
import logger from "@calcom/lib/logger";
import { DestinationCalendarRepository } from "@calcom/lib/server/repository/destinationCalendar";
import { EventTypeRepository } from "@calcom/lib/server/repository/eventTypeRepository";
import { SelectedCalendarRepository } from "@calcom/lib/server/repository/selectedCalendar";
import type { PrismaClient } from "@calcom/prisma";
import prisma from "@calcom/prisma";
import type { DestinationCalendar, SelectedCalendar, User } from "@calcom/prisma/client";
import { AppCategories } from "@calcom/prisma/enums";
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
const log = logger.getSubLogger({ prefix: ["getConnectedDestinationCalendarsAndEnsureDefaultsInDb"] });
type ReturnTypeGetConnectedCalendars = Awaited<ReturnType<typeof getConnectedCalendars>>;
type ConnectedCalendarsFromGetConnectedCalendars = ReturnTypeGetConnectedCalendars["connectedCalendars"];
export type UserWithCalendars = Pick<User, "id" | "email"> & {
allSelectedCalendars: Pick<
SelectedCalendar,
"externalId" | "integration" | "eventTypeId" | "updatedAt" | "googleChannelId"
>[];
userLevelSelectedCalendars: Pick<
SelectedCalendar,
"externalId" | "integration" | "eventTypeId" | "updatedAt" | "googleChannelId"
>[];
destinationCalendar: DestinationCalendar | null;
};
export type ConnectedDestinationCalendars = Awaited<
ReturnType<typeof getConnectedDestinationCalendarsAndEnsureDefaultsInDb>
>;
/**
* Ensures that when DelegationCredential is enabled and there is already a calendar connected for the corresponding domain, we only allow the DelegationCredential calendar to be returned
* This is to ensure that duplicate calendar connections aren't shown in UI(apps/installed/calendars). We choose DelegationCredential connection to be shown because we don't want users to be able to work with individual calendars
*/
const _ensureNoConflictingNonDelegatedConnectedCalendar = <
T extends {
integration: { slug: string };
primary?: { email?: string | null | undefined } | undefined;
delegationCredentialId?: string | null | undefined;
}
>({
connectedCalendars,
loggedInUser,
}: {
connectedCalendars: T[];
loggedInUser: { email: string };
}) => {
return connectedCalendars.filter((connectedCalendar, index, array) => {
const allCalendarsWithSameAppSlug = array.filter(
(cal) => cal.integration.slug === connectedCalendar.integration.slug
);
// If no other calendar with this slug, keep it
if (allCalendarsWithSameAppSlug.length === 1) return true;
const delegatedCalendarsWithSameAppSlug = allCalendarsWithSameAppSlug.filter(
(cal) => cal.delegationCredentialId
);
if (!delegatedCalendarsWithSameAppSlug.length) {
return true;
}
if (connectedCalendar.delegationCredentialId) {
return true;
}
// DelegationCredential Credential is always of the loggedInUser
if (!connectedCalendar.primary?.email || connectedCalendar.primary.email !== loggedInUser.email) {
return true;
}
return false;
});
};
async function handleNoConnectedCalendars(user: UserWithCalendars) {
log.debug(`No connected calendars, deleting destination calendar if it exists for user ${user.id}`);
if (!user.destinationCalendar) return user;
await prisma.destinationCalendar.delete({
where: { userId: user.id },
});
return {
...user,
destinationCalendar: null,
};
}
type ToggledCalendarDetails = {
externalId: string;
integration: string;
};
async function handleNoDestinationCalendar({
user,
connectedCalendars,
onboarding,
}: {
user: UserWithCalendars;
connectedCalendars: ConnectedCalendarsFromGetConnectedCalendars;
onboarding: boolean;
}) {
if (!connectedCalendars.length) {
throw new Error("No connected calendars");
}
// This is the calendar that we will ensure is enabled for conflict check
let calendarToEnsureIsEnabledForConflictCheck: ToggledCalendarDetails | null = null;
log.debug(
`There are connected calendars, but no destination calendar, so create a default destination calendar in DB for user ${user.id}`
);
/*
So create a default destination calendar with the first primary connected calendar
*/
const {
integration = "",
externalId = "",
credentialId,
delegationCredentialId,
email: primaryEmail,
} = connectedCalendars[0].primary ?? {};
// Select the first calendar matching the primary by default since that will also be the destination calendar
if (onboarding && externalId) {
const calendarIndex = (connectedCalendars[0].calendars || []).findIndex(
(item) => item.externalId === externalId && item.integration === integration
);
if (calendarIndex >= 0 && connectedCalendars[0].calendars) {
connectedCalendars[0].calendars[calendarIndex].isSelected = true;
calendarToEnsureIsEnabledForConflictCheck = {
externalId,
integration,
};
}
}
user.destinationCalendar = await DestinationCalendarRepository.createIfNotExistsForUser({
userId: user.id,
integration,
externalId,
primaryEmail,
...(!isDelegationCredential({ credentialId })
? {
credentialId,
}
: {
delegationCredentialId,
}),
});
return {
user,
connectedCalendars,
calendarToEnsureIsEnabledForConflictCheck,
};
}
async function handleDestinationCalendarNotInConnectedCalendars({
user,
connectedCalendars,
onboarding,
}: {
user: UserWithCalendars;
connectedCalendars: ConnectedCalendarsFromGetConnectedCalendars;
onboarding: boolean;
}) {
let calendarToEnsureIsEnabledForConflictCheck: ToggledCalendarDetails | null = null;
log.debug(
`Destination calendar isn't in connectedCalendars, update it to the first primary connected calendar for user ${user.id}`
);
const { integration = "", externalId = "", email: primaryEmail } = connectedCalendars[0].primary ?? {};
// Select the first calendar matching the primary by default since that will also be the destination calendar
if (onboarding && externalId) {
const calendarIndex = (connectedCalendars[0].calendars || []).findIndex(
(item) => item.externalId === externalId && item.integration === integration
);
if (calendarIndex >= 0 && connectedCalendars[0].calendars) {
connectedCalendars[0].calendars[calendarIndex].isSelected = true;
calendarToEnsureIsEnabledForConflictCheck = {
externalId,
integration,
};
}
}
user.destinationCalendar = await prisma.destinationCalendar.update({
where: { userId: user.id },
data: {
integration,
externalId,
primaryEmail,
},
});
return {
user,
connectedCalendars,
calendarToEnsureIsEnabledForConflictCheck,
};
}
function findMatchingCalendar({
connectedCalendars,
calendar,
}: {
connectedCalendars: ConnectedCalendarsFromGetConnectedCalendars;
calendar: DestinationCalendar;
}) {
// Check if destinationCalendar exists in connectedCalendars
const allCals = connectedCalendars.map((cal) => cal.calendars ?? []).flat();
const matchingCalendar = allCals.find(
(cal) => cal.externalId === calendar.externalId && cal.integration === calendar.integration
);
return matchingCalendar;
}
async function ensureSelectedCalendarIsInDb({
user,
selectedCalendar,
eventTypeId,
}: {
user: UserWithCalendars;
selectedCalendar: {
integration: string;
externalId: string;
};
eventTypeId: number | null;
}) {
console.log(
`Upsert the selectedCalendar record to the DB for user ${user.id} with details ${JSON.stringify(
selectedCalendar
)}`
);
await SelectedCalendarRepository.createIfNotExists({
userId: user.id,
integration: selectedCalendar.integration,
externalId: selectedCalendar.externalId,
eventTypeId,
});
}
function getSelectedCalendars({
user,
eventTypeId,
}: {
user: UserWithCalendars;
eventTypeId: number | null;
}) {
if (eventTypeId) {
return EventTypeRepository.getSelectedCalendarsFromUser({
user,
eventTypeId: eventTypeId ?? null,
});
}
return user.userLevelSelectedCalendars;
}
/**
* Fetches the calendars for the authenticated user or the event-type if provided
* It also takes care of updating the destination calendar in some edge cases
*/
export async function getConnectedDestinationCalendarsAndEnsureDefaultsInDb({
user,
onboarding,
eventTypeId,
prisma,
}: {
user: UserWithCalendars;
onboarding: boolean;
eventTypeId?: number | null;
prisma: PrismaClient;
}) {
const userCredentials = await prisma.credential.findMany({
where: {
userId: user.id,
app: {
categories: { has: AppCategories.calendar },
enabled: true,
},
},
select: credentialForCalendarServiceSelect,
});
const { credentials: allCredentials } = await enrichUserWithDelegationCredentialsIncludeServiceAccountKey({
user: { id: user.id, email: user.email, credentials: userCredentials },
});
const selectedCalendars = getSelectedCalendars({ user, eventTypeId: eventTypeId ?? null });
// get user's credentials + their connected integrations
const calendarCredentials = getCalendarCredentials(allCredentials);
// get all the connected integrations' calendars (from third party)
const getConnectedCalendarsResult = await getConnectedCalendars(
calendarCredentials,
selectedCalendars,
user.destinationCalendar?.externalId
);
let connectedCalendars = getConnectedCalendarsResult.connectedCalendars;
const destinationCalendar = getConnectedCalendarsResult.destinationCalendar;
let calendarToEnsureIsEnabledForConflictCheck: ToggledCalendarDetails | null = null;
if (connectedCalendars.length === 0) {
user = await handleNoConnectedCalendars(user);
} else if (!user.destinationCalendar) {
({ user, calendarToEnsureIsEnabledForConflictCheck, connectedCalendars } =
await handleNoDestinationCalendar({
user,
connectedCalendars,
onboarding,
}));
} else {
/* There are connected calendars and a destination calendar */
log.debug(
`There are connected calendars and a destination calendar, so check if destinationCalendar exists in connectedCalendars for user ${user.id}`
);
const destinationCal = findMatchingCalendar({ connectedCalendars, calendar: user.destinationCalendar });
if (!destinationCal) {
({ user, calendarToEnsureIsEnabledForConflictCheck, connectedCalendars } =
await handleDestinationCalendarNotInConnectedCalendars({
user,
connectedCalendars,
onboarding,
}));
} else if (onboarding && !destinationCal.isSelected) {
log.debug(
`Onboarding:Destination calendar is not selected, but in connectedCalendars, so mark it as selected in the calendar list for user ${user.id}`
);
// Mark the destination calendar as selected in the calendar list
// We use every so that we can exit early once we find the matching calendar
connectedCalendars.every((cal) => {
const index = (cal.calendars || []).findIndex(
(calendar) =>
calendar.externalId === destinationCal.externalId &&
calendar.integration === destinationCal.integration
);
if (index >= 0 && cal.calendars) {
cal.calendars[index].isSelected = true;
calendarToEnsureIsEnabledForConflictCheck = {
externalId: destinationCal.externalId,
integration: destinationCal.integration || "",
};
return false;
}
return true;
});
}
}
// Insert the newly toggled record to the DB
if (calendarToEnsureIsEnabledForConflictCheck) {
await ensureSelectedCalendarIsInDb({
user,
selectedCalendar: calendarToEnsureIsEnabledForConflictCheck,
eventTypeId: eventTypeId ?? null,
});
}
const noConflictingNonDelegatedConnectedCalendars = _ensureNoConflictingNonDelegatedConnectedCalendar({
connectedCalendars,
loggedInUser: { email: user.email },
});
return {
connectedCalendars: noConflictingNonDelegatedConnectedCalendars,
destinationCalendar: {
...(user.destinationCalendar as DestinationCalendar),
...destinationCalendar,
},
};
}
// Legacy export for @calcom/platform-libraries
export const getConnectedDestinationCalendars = getConnectedDestinationCalendarsAndEnsureDefaultsInDb;
@@ -1,4 +1,4 @@
import type { IFromUser, IToUser } from "@calcom/lib/getUserAvailability";
import type { IFromUser, IToUser } from "@calcom/features/availability/lib/getUserAvailability";
import type { TimeRange } from "@calcom/types/schedule";
import type { CalendarEvent } from "./events";
@@ -12,7 +12,7 @@ import { sendCancelledEmailsAndSMS } from "@calcom/emails";
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
import { deletePayment } from "@calcom/features/bookings/lib/payment/deletePayment";
import { deleteWebhookScheduledTriggers } from "@calcom/features/webhooks/lib/scheduleTrigger";
import { buildNonDelegationCredential } from "@calcom/lib/delegationCredential/server";
import { buildNonDelegationCredential } from "@calcom/lib/delegationCredential";
import { isPrismaObjOrUndefined } from "@calcom/lib/isPrismaObj";
import { parseRecurringEvent } from "@calcom/lib/isRecurringEvent";
import { getTranslation } from "@calcom/lib/server/i18n";
@@ -0,0 +1,98 @@
import { mockCrmApp } from "@calcom/web/test/utils/bookingScenario/bookingScenario";
import type { TFunction } from "i18next";
import { describe, expect, test, vi } from "vitest";
import { getCrm } from "@calcom/app-store/_utils/getCrm";
import CrmManager from "./crmManager";
// vi.mock("@calcom/app-store/_utils/getCrm");
describe.skip("crmManager tests", () => {
test("Set crmService if not set", async () => {
const spy = vi.spyOn(CrmManager.prototype as any, "getCrmService");
const crmManager = new CrmManager({
id: 1,
type: "credential_crm",
key: {},
userId: 1,
teamId: null,
appId: "crm-app",
invalid: false,
user: { email: "test@test.com" },
});
expect(crmManager.crmService).toBe(null);
crmManager.getContacts(["test@test.com"]);
expect(spy).toBeCalledTimes(1);
});
describe("creating events", () => {
test("If the contact exists, create the event", async () => {
const tFunc = vi.fn(() => "foo");
vi.spyOn(getCrm).mockReturnValue({
getContacts: () => [
{
id: "contact-id",
email: "test@test.com",
},
],
createContacts: [{ id: "contact-id", email: "test@test.com" }],
});
// This mock is defaulting to non implemented mock return
const mockedCrmApp = mockCrmApp("salesforce", {
getContacts: [
{
id: "contact-id",
email: "test@test.com",
},
],
createContacts: [{ id: "contact-id", email: "test@test.com" }],
});
const crmManager = new CrmManager({
id: 1,
type: "salesforce_crm",
key: {
clientId: "test-client-id",
},
userId: 1,
teamId: null,
appId: "salesforce",
invalid: false,
user: { email: "test@test.com" },
});
crmManager.createEvent({
title: "Test Meeting",
type: "test-meeting",
description: "Test Description",
startTime: Date(),
endTime: Date(),
organizer: {
email: "organizer@test.com",
name: "Organizer",
timeZone: "America/New_York",
language: {
locale: "en",
translate: tFunc as TFunction,
},
},
attendees: [
{
email: "test@test.com",
name: "Test",
timeZone: "America/New_York",
language: {
locale: "en",
translate: tFunc as TFunction,
},
},
],
});
console.log(mockedCrmApp);
});
});
});
@@ -0,0 +1,92 @@
import getCrm from "@calcom/app-store/_utils/getCrm";
import logger from "@calcom/lib/logger";
import type { CalendarEvent, CalEventResponses } from "@calcom/types/Calendar";
import type { CredentialPayload } from "@calcom/types/Credential";
import type { CRM, ContactCreateInput } from "@calcom/types/CrmService";
const log = logger.getSubLogger({ prefix: ["CrmManager"] });
export default class CrmManager {
crmService: CRM | null | undefined = null;
credential: CredentialPayload;
appOptions: any;
constructor(credential: CredentialPayload, appOptions?: any) {
this.credential = credential;
this.appOptions = appOptions;
}
private async getCrmService(credential: CredentialPayload) {
if (this.crmService) return this.crmService;
const crmService = await getCrm(credential, this.appOptions);
this.crmService = crmService;
if (!this.crmService) {
console.log("💀 Error initializing CRM service");
log.error("CRM service initialization failed");
}
return crmService;
}
public async createEvent(event: CalendarEvent) {
const crmService = await this.getCrmService(this.credential);
if (!crmService) return;
const { skipContactCreation = false, ignoreGuests = false } = crmService.getAppOptions() || {};
const eventAttendees = ignoreGuests ? [event.attendees[0]] : event.attendees;
// First see if the attendees already exist in the crm
let contacts = (await this.getContacts({ emails: eventAttendees.map((a) => a.email) })) || [];
// Ensure that all attendees are in the crm
if (contacts.length == eventAttendees.length) {
return await crmService.createEvent(event, contacts);
}
if (skipContactCreation) return;
const contactSet = new Set(contacts.map((c: { email: string }) => c.email));
// Figure out which contacts to create
const contactsToCreate = eventAttendees.filter((attendee) => !contactSet.has(attendee.email));
const createdContacts = await this.createContacts(
contactsToCreate,
event.organizer?.email,
event.responses
);
contacts = contacts.concat(createdContacts);
return await crmService.createEvent(event, contacts);
}
public async updateEvent(uid: string, event: CalendarEvent) {
const crmService = await this.getCrmService(this.credential);
return await crmService?.updateEvent(uid, event);
}
public async deleteEvent(uid: string, event: CalendarEvent) {
const crmService = await this.getCrmService(this.credential);
return await crmService?.deleteEvent(uid, event);
}
public async getContacts(params: {
emails: string | string[];
includeOwner?: boolean;
forRoundRobinSkip?: boolean;
}) {
const crmService = await this.getCrmService(this.credential);
const contacts = await crmService?.getContacts(params);
return contacts;
}
public async createContacts(
contactsToCreate: ContactCreateInput[],
organizerEmail?: string,
calEventResponses?: CalEventResponses | null
) {
const crmService = await this.getCrmService(this.credential);
const createdContacts =
(await crmService?.createContacts(contactsToCreate, organizerEmail, calEventResponses)) || [];
return createdContacts;
}
public async handleAttendeeNoShow(bookingUid: string, attendees: { email: string; noShow: boolean }[]) {
const crmService = await this.getCrmService(this.credential);
if (crmService?.handleAttendeeNoShow) {
await crmService.handleAttendeeNoShow(bookingUid, attendees);
}
}
}
@@ -1,7 +1,7 @@
import type { FilterHostsService } from "@calcom/features/bookings/lib/host-filtering/filterHostsBySameRoundRobinHost";
import { DI_TOKENS } from "@calcom/features/di/tokens";
import { prismaModule } from "@calcom/prisma/prisma.module";
import type { FilterHostsService } from "../../bookings/filterHostsBySameRoundRobinHost";
import { createContainer } from "../di";
import { bookingRepositoryModule } from "../modules/Booking";
import { filterHostsModule } from "../modules/FilterHosts";
@@ -1,6 +1,6 @@
import { DI_TOKENS } from "@calcom/features/di/tokens";
import { redisModule } from "@calcom/features/redis/di/redisModule";
import type { UserAvailabilityService } from "@calcom/lib/getUserAvailability";
import type { UserAvailabilityService } from "@calcom/features/availability/lib/getUserAvailability";
import { prismaModule } from "@calcom/prisma/prisma.module";
import { createContainer } from "../di";
+1 -1
View File
@@ -1,4 +1,4 @@
import type { LuckyUserService } from "@calcom/lib/server/getLuckyUser";
import type { LuckyUserService } from "@calcom/features/bookings/lib/getLuckyUser";
import { createContainer } from "../di";
import { moduleLoader as luckyUserServiceModuleLoader } from "../modules/LuckyUser";
@@ -1,5 +1,5 @@
import type { QualifiedHostsService } from "@calcom/features/bookings/lib/host-filtering/findQualifiedHostsWithDelegationCredentials";
import { DI_TOKENS } from "@calcom/features/di/tokens";
import type { QualifiedHostsService } from "@calcom/lib/bookings/findQualifiedHostsWithDelegationCredentials";
import { prismaModule } from "@calcom/prisma/prisma.module";
import { createContainer } from "../di";
+2 -2
View File
@@ -1,5 +1,5 @@
import type { IFilterHostsService } from "@calcom/lib/bookings/filterHostsBySameRoundRobinHost";
import { FilterHostsService } from "@calcom/lib/bookings/filterHostsBySameRoundRobinHost";
import type { IFilterHostsService } from "@calcom/features/bookings/lib/host-filtering/filterHostsBySameRoundRobinHost";
import { FilterHostsService } from "@calcom/features/bookings/lib/host-filtering/filterHostsBySameRoundRobinHost";
import { createModule } from "../di";
import { DI_TOKENS } from "../tokens";
@@ -1,5 +1,5 @@
import type { IUserAvailabilityService } from "@calcom/lib/getUserAvailability";
import { UserAvailabilityService } from "@calcom/lib/getUserAvailability";
import type { IUserAvailabilityService } from "@calcom/features/availability/lib/getUserAvailability";
import { UserAvailabilityService } from "@calcom/features/availability/lib/getUserAvailability";
import { createModule } from "../di";
import { DI_TOKENS } from "../tokens";
+1 -1
View File
@@ -1,5 +1,5 @@
import { DI_TOKENS } from "@calcom/features/di/tokens";
import { LuckyUserService } from "@calcom/lib/server/getLuckyUser";
import { LuckyUserService } from "@calcom/features/bookings/lib/getLuckyUser";
import { bindModuleToClassOnToken, createModule, type ModuleLoader } from "../di";
import { moduleLoader as attributeRepositoryModuleLoader } from "./Attribute";
@@ -1,5 +1,5 @@
import type { IQualifiedHostsService } from "@calcom/lib/bookings/findQualifiedHostsWithDelegationCredentials";
import { QualifiedHostsService } from "@calcom/lib/bookings/findQualifiedHostsWithDelegationCredentials";
import type { IQualifiedHostsService } from "@calcom/features/bookings/lib/host-filtering/findQualifiedHostsWithDelegationCredentials";
import { QualifiedHostsService } from "@calcom/features/bookings/lib/host-filtering/findQualifiedHostsWithDelegationCredentials";
import { createModule } from "../di";
import { DI_TOKENS } from "../tokens";
@@ -1,6 +1,6 @@
import type { FormResponse, Fields } from "@calcom/app-store/routing-forms/types/types";
import { zodRoutes } from "@calcom/app-store/routing-forms/zod";
import { acrossQueryValueCompatiblity } from "@calcom/lib/raqb/raqbUtils";
import { acrossQueryValueCompatiblity } from "@calcom/app-store/_utils/raqb/raqbUtils";
import { withReporting } from "@calcom/lib/sentryWrapper";
import { getUsersAttributes } from "@calcom/lib/service/attribute/server/getAttributes";
import prisma from "@calcom/prisma";
@@ -1,6 +1,7 @@
// eslint-disable-next-line no-restricted-imports
import { cloneDeep } from "lodash";
import { enrichUserWithDelegationCredentialsIncludeServiceAccountKey } from "@calcom/app-store/delegationCredential";
import { eventTypeAppMetadataOptionalSchema } from "@calcom/app-store/zod-utils";
import dayjs from "@calcom/dayjs";
import {
@@ -8,6 +9,7 @@ import {
sendRoundRobinScheduledEmailsAndSMS,
sendRoundRobinUpdatedEmailsAndSMS,
} from "@calcom/emails";
import EventManager from "@calcom/features/bookings/lib/EventManager";
import { getAllCredentialsIncludeServiceAccountKey } from "@calcom/features/bookings/lib/getAllCredentialsForUsersOnEvent/getAllCredentials";
import getBookingResponsesSchema from "@calcom/features/bookings/lib/getBookingResponsesSchema";
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
@@ -23,9 +25,7 @@ import {
import { scheduleWorkflowReminders } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler";
import { getEventName } from "@calcom/features/eventtypes/lib/eventNaming";
import { getVideoCallUrlFromCalEvent } from "@calcom/lib/CalEventParser";
import EventManager from "@calcom/features/bookings/lib/EventManager";
import { SENDER_NAME } from "@calcom/lib/constants";
import { enrichUserWithDelegationCredentialsIncludeServiceAccountKey } from "@calcom/lib/delegationCredential/server";
import { getBookerBaseUrl } from "@calcom/lib/getBookerUrl/server";
import { IdempotencyKeyService } from "@calcom/lib/idempotencyKey/idempotencyKeyService";
import { isPrismaObjOrUndefined } from "@calcom/lib/isPrismaObj";
@@ -1,6 +1,10 @@
// eslint-disable-next-line no-restricted-imports
import { cloneDeep } from "lodash";
import {
enrichHostsWithDelegationCredentials,
enrichUserWithDelegationCredentialsIncludeServiceAccountKey,
} from "@calcom/app-store/delegationCredential";
import { OrganizerDefaultConferencingAppType, getLocationValueForDB } from "@calcom/app-store/locations";
import { eventTypeAppMetadataOptionalSchema } from "@calcom/app-store/zod-utils";
import dayjs from "@calcom/dayjs";
@@ -9,22 +13,18 @@ import {
sendRoundRobinScheduledEmailsAndSMS,
sendRoundRobinUpdatedEmailsAndSMS,
} from "@calcom/emails";
import EventManager from "@calcom/features/bookings/lib/EventManager";
import { getAllCredentialsIncludeServiceAccountKey } from "@calcom/features/bookings/lib/getAllCredentialsForUsersOnEvent/getAllCredentials";
import getBookingResponsesSchema from "@calcom/features/bookings/lib/getBookingResponsesSchema";
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
import { ensureAvailableUsers } from "@calcom/features/bookings/lib/handleNewBooking/ensureAvailableUsers";
import { getEventTypesFromDB } from "@calcom/features/bookings/lib/handleNewBooking/getEventTypesFromDB";
import type { IsFixedAwareUser } from "@calcom/features/bookings/lib/handleNewBooking/types";
import { getLuckyUserService } from "@calcom/features/di/containers/LuckyUser";
import AssignmentReasonRecorder, {
RRReassignmentType,
} from "@calcom/features/ee/round-robin/assignmentReason/AssignmentReasonRecorder";
import { getEventName } from "@calcom/features/eventtypes/lib/eventNaming";
import EventManager from "@calcom/features/bookings/lib/EventManager";
import {
enrichHostsWithDelegationCredentials,
enrichUserWithDelegationCredentialsIncludeServiceAccountKey,
} from "@calcom/lib/delegationCredential/server";
import { getLuckyUserService } from "@calcom/features/di/containers/LuckyUser";
import { ErrorCode } from "@calcom/lib/errorCodes";
import { IdempotencyKeyService } from "@calcom/lib/idempotencyKey/idempotencyKeyService";
import { isPrismaObjOrUndefined } from "@calcom/lib/isPrismaObj";
@@ -2,7 +2,7 @@ import type { z } from "zod";
import type { eventTypeAppCardZod } from "@calcom/app-store/eventTypeAppCardZod";
import { eventTypeAppMetadataOptionalSchema } from "@calcom/app-store/zod-utils";
import CrmManager from "@calcom/lib/crmManager/crmManager";
import CrmManager from "@calcom/features/crmManager/crmManager";
import logger from "@calcom/lib/logger";
import prisma from "@calcom/prisma";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
@@ -62,7 +62,7 @@ vi.mock("../lib/buildCalendarEvent", () => ({
}));
const mockCreateEvent = vi.fn().mockResolvedValue({ id: "sf-event-123" });
vi.mock("@calcom/lib/crmManager/crmManager", () => ({
vi.mock("@calcom/features/crmManager/crmManager", () => ({
default: class MockCrmManager {
private credential: CRMCredential;
@@ -170,7 +170,7 @@ export async function createCRMEvent(payload: string): Promise<void> {
continue;
}
const CrmManager = (await import("@calcom/lib/crmManager/crmManager")).default;
const CrmManager = (await import("@calcom/features/crmManager/crmManager")).default;
const crm = new CrmManager(crmCredential, app);
@@ -0,0 +1,70 @@
import { describe, it, expect, vi } from "vitest";
import { getRoutedUsersWithContactOwnerAndFixedUsers } from "./getRoutedUsers";
vi.mock("@calcom/prisma", () => {
return {
default: vi.fn(),
};
});
describe("getRoutedUsersWithContactOwnerAndFixedUsers", () => {
const users = [
{ id: 1, email: "user1@example.com", isFixed: false },
{ id: 2, email: "user2@example.com", isFixed: true },
{ id: 3, email: "user3@example.com", isFixed: false },
{ id: 4, email: "owner@example.com", isFixed: false },
];
const usersWithoutFixedHosts = users.map((user) => ({ ...user, isFixed: false }));
it("should return all users when routedTeamMemberIds is null", () => {
const result = getRoutedUsersWithContactOwnerAndFixedUsers({
routedTeamMemberIds: null,
users,
contactOwnerEmail: "owner@example.com",
});
expect(result).toEqual(users);
});
it("should return all users when routedTeamMemberIds is empty - We don't want to enter a scenario where we have no team members to be booked", () => {
const result = getRoutedUsersWithContactOwnerAndFixedUsers({
routedTeamMemberIds: [],
users,
contactOwnerEmail: "owner@example.com",
});
expect(result).toEqual(users);
});
it("should filter users based on routedTeamMemberIds, isFixed, and contactOwnerEmail", () => {
const result = getRoutedUsersWithContactOwnerAndFixedUsers({
routedTeamMemberIds: [1, 3],
users,
contactOwnerEmail: "owner@example.com",
});
expect(result).toEqual([
{ id: 1, email: "user1@example.com", isFixed: false },
{ id: 2, email: "user2@example.com", isFixed: true },
{ id: 3, email: "user3@example.com", isFixed: false },
{ id: 4, email: "owner@example.com", isFixed: false },
]);
});
it("should return an empty array when neither fixed, nor routedTeamMemberIds, nor contactOwnerEmail match", () => {
const result = getRoutedUsersWithContactOwnerAndFixedUsers({
routedTeamMemberIds: [5],
users: usersWithoutFixedHosts,
contactOwnerEmail: "nonexistent@example.com",
});
expect(result).toEqual([]);
});
it("should return fixed hosts even if routedTeamMemberIds and contactOwnerEmail are invalid ", () => {
const result = getRoutedUsersWithContactOwnerAndFixedUsers({
routedTeamMemberIds: [5],
users,
contactOwnerEmail: "nonexistent@example.com",
});
expect(result).toEqual([{ id: 2, email: "user2@example.com", isFixed: true }]);
});
});
@@ -0,0 +1,215 @@
import { enrichHostsWithDelegationCredentials } from "@calcom/app-store/delegationCredential";
import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId";
import logger from "@calcom/lib/logger";
import { findTeamMembersMatchingAttributeLogic } from "@calcom/app-store/_utils/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";
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;
}