2
0

first commit

This commit is contained in:
2024-08-09 00:39:27 +02:00
commit 79688abe2e
5698 changed files with 497838 additions and 0 deletions

View File

@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import { getCalendarCredentials } from "./CalendarManager";
describe("CalendarManager tests", () => {
describe("fn: getCalendarCredentials", () => {
it("should only return credentials for calendar apps", () => {
const googleCalendarCredentials = {
id: "1",
appId: "google-calendar",
type: "google_calendar",
userId: "3",
key: {
access_token: "google_calendar_key",
},
invalid: false,
};
const credentials = [
googleCalendarCredentials,
{
id: "2",
appId: "office365-video",
type: "office365_video",
userId: "4",
key: {
access_token: "office365_video_key",
},
invalid: false,
},
];
const calendarCredentials = getCalendarCredentials(credentials);
expect(calendarCredentials).toHaveLength(1);
expect(calendarCredentials[0].credential).toBe(googleCalendarCredentials);
});
});
});

View File

@ -0,0 +1,414 @@
import type { SelectedCalendar } from "@prisma/client";
// eslint-disable-next-line no-restricted-imports
import { sortBy } from "lodash";
import { getCalendar } from "@calcom/app-store/_utils/getCalendar";
import getApps from "@calcom/app-store/utils";
import dayjs from "@calcom/dayjs";
import { getUid } from "@calcom/lib/CalEventParser";
import logger from "@calcom/lib/logger";
import { getPiiFreeCalendarEvent, getPiiFreeCredential } from "@calcom/lib/piiFreeData";
import { safeStringify } from "@calcom/lib/safeStringify";
import { performance } from "@calcom/lib/server/perfObserver";
import type {
CalendarEvent,
EventBusyDate,
IntegrationCalendar,
NewCalendarEventType,
} from "@calcom/types/Calendar";
import type { CredentialPayload } from "@calcom/types/Credential";
import type { EventResult } from "@calcom/types/EventManager";
import getCalendarsEvents from "./getCalendarsEvents";
const log = logger.getSubLogger({ prefix: ["CalendarManager"] });
export const getCalendarCredentials = (credentials: Array<CredentialPayload>) => {
const calendarCredentials = getApps(credentials, true)
.filter((app) => app.type.endsWith("_calendar"))
.flatMap((app) => {
const credentials = app.credentials.flatMap((credential) => {
const calendar = getCalendar(credential);
return app.variant === "calendar" ? [{ integration: app, credential, calendar }] : [];
});
return credentials.length ? credentials : [];
});
return calendarCredentials;
};
export const getConnectedCalendars = async (
calendarCredentials: ReturnType<typeof getCalendarCredentials>,
selectedCalendars: { externalId: string }[],
destinationCalendarExternalId?: string
) => {
let destinationCalendar: IntegrationCalendar | undefined;
const connectedCalendars = await Promise.all(
calendarCredentials.map(async (item) => {
try {
const { integration, credential } = item;
const calendar = await item.calendar;
// Don't leak credentials to the client
const credentialId = credential.id;
if (!calendar) {
return {
integration,
credentialId,
};
}
const cals = await calendar.listCalendars();
const calendars = sortBy(
cals.map((cal: IntegrationCalendar) => {
if (cal.externalId === destinationCalendarExternalId) destinationCalendar = cal;
return {
...cal,
readOnly: cal.readOnly || false,
primary: cal.primary || null,
isSelected: selectedCalendars.some((selected) => selected.externalId === cal.externalId),
credentialId,
};
}),
["primary"]
);
const primary = calendars.find((item) => item.primary) ?? calendars.find((cal) => cal !== undefined);
if (!primary) {
return {
integration,
credentialId,
error: {
message: "No primary calendar found",
},
};
}
// HACK https://github.com/calcom/cal.com/pull/7644/files#r1131508414
if (destinationCalendar && !Object.isFrozen(destinationCalendar)) {
destinationCalendar.primaryEmail = primary.email;
destinationCalendar.integrationTitle = integration.title;
destinationCalendar = Object.freeze(destinationCalendar);
}
return {
integration: cleanIntegrationKeys(integration),
credentialId,
primary,
calendars,
};
} catch (error) {
let errorMessage = "Could not get connected calendars";
// Here you can expect for specific errors
if (error instanceof Error) {
if (error.message === "invalid_grant") {
errorMessage = "Access token expired or revoked";
}
}
log.error("getConnectedCalendars failed", safeStringify(error), safeStringify({ item }));
return {
integration: cleanIntegrationKeys(item.integration),
credentialId: item.credential.id,
error: {
message: errorMessage,
},
};
}
})
);
return { connectedCalendars, destinationCalendar };
};
/**
* Important function to prevent leaking credentials to the client
* @param appIntegration
* @returns App
*/
const cleanIntegrationKeys = (
appIntegration: ReturnType<typeof getCalendarCredentials>[number]["integration"] & {
credentials?: Array<CredentialPayload>;
credential: CredentialPayload;
}
) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { credentials, credential, ...rest } = appIntegration;
return rest;
};
// here I will fetch the page json file.
export const getCachedResults = async (
withCredentials: CredentialPayload[],
dateFrom: string,
dateTo: string,
selectedCalendars: SelectedCalendar[]
): Promise<EventBusyDate[][]> => {
const calendarCredentials = withCredentials.filter((credential) => credential.type.endsWith("_calendar"));
const calendars = await Promise.all(calendarCredentials.map((credential) => getCalendar(credential)));
performance.mark("getBusyCalendarTimesStart");
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, appId } = calendarCredentials[i];
/** We just pass the calendars that matched the credential type,
* TODO: Migrate credential type or appId
*/
const passedSelectedCalendars = selectedCalendars.filter((sc) => sc.integration === type);
if (!passedSelectedCalendars.length) return [];
/** 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");
const eventBusyDates = await c.getAvailability(dateFrom, dateTo, passedSelectedCalendars);
performance.mark("eventBusyDatesEnd");
performance.measure(
`[getAvailability for ${selectedCalendarIds.join(", ")}][$1]'`,
"eventBusyDatesStart",
"eventBusyDatesEnd"
);
return eventBusyDates.map((a: object) => ({ ...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"
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return awaitedResults as any;
};
/**
* Get months between given dates
* @returns ["2023-04", "2024-05"]
*/
const getMonths = (dateFrom: string, dateTo: string): string[] => {
const months: string[] = [dayjs(dateFrom).format("YYYY-MM")];
for (
let i = 1;
dayjs(dateFrom).add(i, "month").isBefore(dateTo) ||
dayjs(dateFrom).add(i, "month").isSame(dateTo, "month");
i++
) {
months.push(dayjs(dateFrom).add(i, "month").format("YYYY-MM"));
}
return months;
};
export const getBusyCalendarTimes = async (
username: string,
withCredentials: CredentialPayload[],
dateFrom: string,
dateTo: string,
selectedCalendars: SelectedCalendar[]
) => {
let results: EventBusyDate[][] = [];
// const months = getMonths(dateFrom, dateTo);
try {
// Subtract 11 hours from the start date to avoid problems in UTC- time zones.
const startDate = dayjs(dateFrom).subtract(11, "hours").format();
// Add 14 hours from the start date to avoid problems in UTC+ time zones.
const endDate = dayjs(dateTo).endOf("month").add(14, "hours").format();
results = await getCalendarsEvents(withCredentials, startDate, endDate, selectedCalendars);
} catch (e) {
log.warn(safeStringify(e));
}
return results.reduce((acc, availability) => acc.concat(availability), []);
};
export const createEvent = async (
credential: CredentialPayload,
calEvent: CalendarEvent,
externalId?: string
): Promise<EventResult<NewCalendarEventType>> => {
const uid: string = getUid(calEvent);
const calendar = await getCalendar(credential);
let success = true;
let calError: string | undefined = undefined;
log.debug(
"Creating calendar event",
safeStringify({
calEvent: getPiiFreeCalendarEvent(calEvent),
})
);
// Check if the disabledNotes flag is set to true
if (calEvent.hideCalendarNotes) {
calEvent.additionalNotes = "Notes have been hidden by the organizer"; // TODO: i18n this string?
}
// TODO: Surface success/error messages coming from apps to improve end user visibility
const creationResult = calendar
? await calendar
.createEvent(calEvent, credential.id)
.catch(async (error: { code: number; calError: string }) => {
success = false;
/**
* There is a time when selectedCalendar externalId doesn't match witch certain credential
* so google returns 404.
* */
if (error?.code === 404) {
return undefined;
}
if (error?.calError) {
calError = error.calError;
}
log.error(
"createEvent failed",
safeStringify({ error, calEvent: getPiiFreeCalendarEvent(calEvent) })
);
// @TODO: This code will be off till we can investigate an error with it
//https://github.com/calcom/cal.com/issues/3949
// await sendBrokenIntegrationEmail(calEvent, "calendar");
return undefined;
})
: undefined;
if (!creationResult) {
logger.error(
"createEvent failed",
safeStringify({
success,
uid,
creationResult,
originalEvent: getPiiFreeCalendarEvent(calEvent),
calError,
})
);
}
log.debug(
"Created calendar event",
safeStringify({
calEvent: getPiiFreeCalendarEvent(calEvent),
creationResult,
})
);
return {
appName: credential.appId || "",
type: credential.type,
success,
uid,
iCalUID: creationResult?.iCalUID || undefined,
createdEvent: creationResult,
originalEvent: calEvent,
calError,
calWarnings: creationResult?.additionalInfo?.calWarnings || [],
externalId,
credentialId: credential.id,
};
};
export const updateEvent = async (
credential: CredentialPayload,
calEvent: CalendarEvent,
bookingRefUid: string | null,
externalCalendarId: string | null
): Promise<EventResult<NewCalendarEventType>> => {
const uid = getUid(calEvent);
const calendar = await getCalendar(credential);
let success = false;
let calError: string | undefined = undefined;
let calWarnings: string[] | undefined = [];
log.debug(
"Updating calendar event",
safeStringify({
bookingRefUid,
calEvent: getPiiFreeCalendarEvent(calEvent),
})
);
if (bookingRefUid === "") {
log.error(
"updateEvent failed",
"bookingRefUid is empty",
safeStringify({ calEvent: getPiiFreeCalendarEvent(calEvent) })
);
}
const updatedResult: NewCalendarEventType | NewCalendarEventType[] | undefined =
calendar && bookingRefUid
? await calendar
.updateEvent(bookingRefUid, calEvent, externalCalendarId)
.then((event: NewCalendarEventType | NewCalendarEventType[]) => {
success = true;
return event;
})
.catch(async (e: { calError: string }) => {
// @TODO: This code will be off till we can investigate an error with it
// @see https://github.com/calcom/cal.com/issues/3949
// await sendBrokenIntegrationEmail(calEvent, "calendar");
log.error(
"updateEvent failed",
safeStringify({ e, calEvent: getPiiFreeCalendarEvent(calEvent) })
);
if (e?.calError) {
calError = e.calError;
}
return undefined;
})
: undefined;
if (!updatedResult) {
logger.error(
"updateEvent failed",
safeStringify({
success,
bookingRefUid,
credential: getPiiFreeCredential(credential),
originalEvent: getPiiFreeCalendarEvent(calEvent),
calError,
})
);
}
if (Array.isArray(updatedResult)) {
calWarnings = updatedResult.flatMap((res) => res.additionalInfo?.calWarnings ?? []);
} else {
calWarnings = updatedResult?.additionalInfo?.calWarnings || [];
}
return {
appName: credential.appId || "",
type: credential.type,
success,
uid,
updatedEvent: updatedResult,
originalEvent: calEvent,
calError,
calWarnings,
};
};
export const deleteEvent = async ({
credential,
bookingRefUid,
event,
externalCalendarId,
}: {
credential: CredentialPayload;
bookingRefUid: string;
event: CalendarEvent;
externalCalendarId?: string | null;
}): Promise<unknown> => {
const calendar = await getCalendar(credential);
log.debug(
"Deleting calendar event",
safeStringify({
bookingRefUid,
event: getPiiFreeCalendarEvent(event),
})
);
if (calendar) {
return calendar.deleteEvent(bookingRefUid, event, externalCalendarId);
} else {
log.error(
"Could not do deleteEvent - No calendar adapter found",
safeStringify({
credential: getPiiFreeCredential(credential),
event,
})
);
}
return Promise.resolve({});
};

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,273 @@
import type { Booking } from "@prisma/client";
import { Prisma } from "@prisma/client";
import short from "short-uuid";
import { v5 as uuidv5 } from "uuid";
import dayjs from "@calcom/dayjs";
import { WEBAPP_URL } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { getTranslation } from "@calcom/lib/server/i18n";
import prisma from "@calcom/prisma";
import type { CalendarEvent } from "@calcom/types/Calendar";
import { CalendarEventClass } from "./class";
const log = logger.getSubLogger({ prefix: ["builders", "CalendarEvent", "builder"] });
const translator = short();
const userSelect = Prisma.validator<Prisma.UserArgs>()({
select: {
id: true,
email: true,
name: true,
username: true,
timeZone: true,
credentials: true,
bufferTime: true,
destinationCalendar: true,
locale: true,
},
});
type User = Prisma.UserGetPayload<typeof userSelect>;
type PersonAttendeeCommonFields = Pick<User, "id" | "email" | "name" | "locale" | "timeZone" | "username">;
interface ICalendarEventBuilder {
calendarEvent: CalendarEventClass;
eventType: Awaited<ReturnType<CalendarEventBuilder["getEventFromEventId"]>>;
users: Awaited<ReturnType<CalendarEventBuilder["getUserById"]>>[];
attendeesList: PersonAttendeeCommonFields[];
teamMembers: Awaited<ReturnType<CalendarEventBuilder["getTeamMembers"]>>;
rescheduleLink: string;
}
export class CalendarEventBuilder implements ICalendarEventBuilder {
calendarEvent!: CalendarEventClass;
eventType!: ICalendarEventBuilder["eventType"];
users!: ICalendarEventBuilder["users"];
attendeesList: ICalendarEventBuilder["attendeesList"] = [];
teamMembers: ICalendarEventBuilder["teamMembers"] = [];
rescheduleLink!: string;
constructor() {
this.reset();
}
private reset() {
this.calendarEvent = new CalendarEventClass();
}
public init(initProps: CalendarEventClass) {
this.calendarEvent = new CalendarEventClass(initProps);
}
public setEventType(eventType: ICalendarEventBuilder["eventType"]) {
this.eventType = eventType;
}
public async buildEventObjectFromInnerClass(eventId: number) {
const resultEvent = await this.getEventFromEventId(eventId);
if (resultEvent) {
this.eventType = resultEvent;
}
}
public async buildUsersFromInnerClass() {
if (!this.eventType) {
throw new Error("exec BuildEventObjectFromInnerClass before calling this function");
}
const users = this.eventType.users;
/* If this event was pre-relationship migration */
if (!users.length && this.eventType.userId) {
const eventTypeUser = await this.getUserById(this.eventType.userId);
if (!eventTypeUser) {
throw new Error("buildUsersFromINnerClass.eventTypeUser.notFound");
}
users.push(eventTypeUser);
}
this.setUsers(users);
}
public buildAttendeesList() {
// Language Function was set on builder init
this.attendeesList = [
...(this.calendarEvent.attendees as unknown as PersonAttendeeCommonFields[]),
...this.teamMembers,
];
}
private async getUserById(userId: number) {
let resultUser: User | null;
try {
resultUser = await prisma.user.findUniqueOrThrow({
where: {
id: userId,
},
...userSelect,
});
} catch (error) {
throw new Error("getUsersById.users.notFound");
}
return resultUser;
}
private async getEventFromEventId(eventTypeId: number) {
let resultEventType;
try {
resultEventType = await prisma.eventType.findUniqueOrThrow({
where: {
id: eventTypeId,
},
select: {
id: true,
users: userSelect,
team: {
select: {
id: true,
name: true,
slug: true,
},
},
description: true,
slug: true,
teamId: true,
title: true,
length: true,
eventName: true,
schedulingType: true,
periodType: true,
periodStartDate: true,
periodEndDate: true,
periodDays: true,
periodCountCalendarDays: true,
requiresConfirmation: true,
userId: true,
price: true,
currency: true,
metadata: true,
destinationCalendar: true,
hideCalendarNotes: true,
},
});
} catch (error) {
throw new Error("Error while getting eventType");
}
log.debug("getEventFromEventId.resultEventType", safeStringify(resultEventType));
return resultEventType;
}
public async buildTeamMembers() {
this.teamMembers = await this.getTeamMembers();
}
private async getTeamMembers() {
// Users[0] its organizer so we are omitting with slice(1)
const teamMemberPromises = this.users.slice(1).map(async function (user) {
return {
id: user.id,
username: user.username,
email: user.email || "", // @NOTE: Should we change this "" to teamMemberId?
name: user.name || "",
timeZone: user.timeZone,
language: {
translate: await getTranslation(user.locale ?? "en", "common"),
locale: user.locale ?? "en",
},
locale: user.locale,
} as PersonAttendeeCommonFields;
});
return await Promise.all(teamMemberPromises);
}
public buildUIDCalendarEvent() {
if (this.users && this.users.length > 0) {
throw new Error("call buildUsers before calling this function");
}
const [mainOrganizer] = this.users;
const seed = `${mainOrganizer.username}:${dayjs(this.calendarEvent.startTime)
.utc()
.format()}:${new Date().getTime()}`;
const uid = translator.fromUUID(uuidv5(seed, uuidv5.URL));
this.calendarEvent.uid = uid;
}
public setLocation(location: CalendarEventClass["location"]) {
this.calendarEvent.location = location;
}
public setUId(uid: CalendarEventClass["uid"]) {
this.calendarEvent.uid = uid;
}
public setDestinationCalendar(destinationCalendar: CalendarEventClass["destinationCalendar"]) {
this.calendarEvent.destinationCalendar = destinationCalendar;
}
public setHideCalendarNotes(hideCalendarNotes: CalendarEventClass["hideCalendarNotes"]) {
this.calendarEvent.hideCalendarNotes = hideCalendarNotes;
}
public setDescription(description: CalendarEventClass["description"]) {
this.calendarEvent.description = description;
}
public setNotes(notes: CalendarEvent["additionalNotes"]) {
this.calendarEvent.additionalNotes = notes;
}
public setCancellationReason(cancellationReason: CalendarEventClass["cancellationReason"]) {
this.calendarEvent.cancellationReason = cancellationReason;
}
public setUsers(users: User[]) {
this.users = users;
}
public async setUsersFromId(userId: User["id"]) {
let resultUser: User | null;
try {
resultUser = await prisma.user.findUniqueOrThrow({
where: {
id: userId,
},
...userSelect,
});
this.setUsers([resultUser]);
} catch (error) {
throw new Error("getUsersById.users.notFound");
}
}
public buildRescheduleLink(booking: Partial<Booking>, eventType?: CalendarEventBuilder["eventType"]) {
try {
if (!booking) {
throw new Error("Parameter booking is required to build reschedule link");
}
const isTeam = !!eventType && !!eventType.teamId;
const isDynamic = booking?.dynamicEventSlugRef && booking?.dynamicGroupSlugRef;
let slug = "";
if (isTeam && eventType?.team?.slug) {
slug = `team/${eventType.team?.slug}/${eventType.slug}`;
} else if (isDynamic) {
const dynamicSlug = isDynamic ? `${booking.dynamicGroupSlugRef}/${booking.dynamicEventSlugRef}` : "";
slug = dynamicSlug;
} else if (eventType?.slug) {
slug = `${this.users[0].username}/${eventType.slug}`;
}
const queryParams = new URLSearchParams();
queryParams.set("rescheduleUid", `${booking.uid}`);
slug = `${slug}`;
const rescheduleLink = `${
this.calendarEvent.bookerUrl ?? WEBAPP_URL
}/${slug}?${queryParams.toString()}`;
this.rescheduleLink = rescheduleLink;
} catch (error) {
if (error instanceof Error) {
throw new Error(`buildRescheduleLink.error: ${error.message}`);
}
}
}
}

View File

@ -0,0 +1,43 @@
import type { DestinationCalendar } from "@prisma/client";
import type {
AdditionalInformation,
CalendarEvent,
ConferenceData,
ExistingRecurringEvent,
Person,
VideoCallData,
} from "@calcom/types/Calendar";
class CalendarEventClass implements CalendarEvent {
bookerUrl?: string | undefined;
type!: string;
title!: string;
startTime!: string;
endTime!: string;
organizer!: Person;
attendees!: Person[];
description?: string | null;
team?: { name: string; members: Person[]; id: number };
location?: string | null;
conferenceData?: ConferenceData;
additionalInformation?: AdditionalInformation;
uid?: string | null;
existingRecurringEvent?: ExistingRecurringEvent | null;
videoCallData?: VideoCallData;
paymentInfo?: any;
destinationCalendar?: DestinationCalendar[] | null;
cancellationReason?: string | null;
rejectionReason?: string | null;
hideCalendarNotes?: boolean;
additionalNotes?: string | null | undefined;
recurrence?: string;
iCalUID?: string | null;
constructor(initProps?: CalendarEvent) {
// If more parameters are given we update this
Object.assign(this, initProps);
}
}
export { CalendarEventClass };

View File

@ -0,0 +1,73 @@
import type { Booking } from "@prisma/client";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import type { CalendarEventBuilder } from "./builder";
const log = logger.getSubLogger({ prefix: ["builders", "CalendarEvent", "director"] });
export class CalendarEventDirector {
private builder!: CalendarEventBuilder;
private existingBooking!: Partial<Booking>;
private cancellationReason!: string;
public setBuilder(builder: CalendarEventBuilder): void {
this.builder = builder;
}
public setExistingBooking(
booking: Pick<
Booking,
| "id"
| "uid"
| "title"
| "startTime"
| "endTime"
| "eventTypeId"
| "userId"
| "dynamicEventSlugRef"
| "dynamicGroupSlugRef"
| "location"
>
) {
this.existingBooking = booking;
}
public setCancellationReason(reason: string) {
this.cancellationReason = reason;
}
public async buildForRescheduleEmail(): Promise<void> {
if (this.existingBooking && this.existingBooking.eventTypeId && this.existingBooking.uid) {
await this.builder.buildEventObjectFromInnerClass(this.existingBooking.eventTypeId);
await this.builder.buildUsersFromInnerClass();
this.builder.buildAttendeesList();
this.builder.setLocation(this.existingBooking.location);
this.builder.setUId(this.existingBooking.uid);
this.builder.setCancellationReason(this.cancellationReason);
this.builder.setDescription(this.builder.eventType.description);
this.builder.setNotes(this.existingBooking.description);
this.builder.buildRescheduleLink(this.existingBooking, this.builder.eventType);
log.debug(
"buildForRescheduleEmail",
safeStringify({ existingBooking: this.existingBooking, builder: this.builder })
);
} else {
throw new Error("buildForRescheduleEmail.missing.params.required");
}
}
public async buildWithoutEventTypeForRescheduleEmail() {
if (this.existingBooking && this.existingBooking.userId && this.existingBooking.uid) {
await this.builder.setUsersFromId(this.existingBooking.userId);
this.builder.buildAttendeesList();
this.builder.setLocation(this.existingBooking.location);
this.builder.setUId(this.existingBooking.uid);
this.builder.setCancellationReason(this.cancellationReason);
this.builder.setDescription(this.existingBooking.description);
await this.builder.buildRescheduleLink(this.existingBooking);
} else {
throw new Error("buildWithoutEventTypeForRescheduleEmail.missing.params.required");
}
}
}

View File

@ -0,0 +1,20 @@
import { useState, useEffect } from "react";
interface Props {
children: React.ReactNode; // React.ReactNode
fallback?: JSX.Element | null; // JSX.Element
}
const NoSSR = ({ children, fallback = null }: Props) => {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) {
return fallback;
}
return <>{children}</>;
};
export default NoSSR;

View File

@ -0,0 +1,98 @@
import { mockCrmApp } from "@calcom/web/test/utils/bookingScenario/bookingScenario";
import type { TFunction } from "next-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);
});
});
});

View File

@ -0,0 +1,68 @@
import getCrm from "@calcom/app-store/_utils/getCrm";
import logger from "@calcom/lib/logger";
import type { CalendarEvent } 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;
constructor(credential: CredentialPayload) {
this.credential = credential;
}
private async getCrmService(credential: CredentialPayload) {
if (this.crmService) return this.crmService;
const crmService = await getCrm(credential);
this.crmService = crmService;
if (this.crmService === null) {
console.log("💀 Error initializing CRM service");
log.error("CRM service initialization failed");
}
return crmService;
}
public async createEvent(event: CalendarEvent, skipContactCreation?: boolean) {
const crmService = await this.getCrmService(this.credential);
// First see if the attendees already exist in the crm
let contacts = (await this.getContacts(event.attendees.map((a) => a.email))) || [];
// Ensure that all attendees are in the crm
if (contacts.length == event.attendees.length) {
return await crmService?.createEvent(event, contacts);
}
if (skipContactCreation) return;
// Figure out which contacts to create
const contactsToCreate = event.attendees.filter(
(attendee) => !contacts.some((contact) => contact.email === attendee.email)
);
const createdContacts = await this.createContacts(contactsToCreate);
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) {
const crmService = await this.getCrmService(this.credential);
return await crmService?.deleteEvent(uid);
}
public async getContacts(emailOrEmails: string | string[], includeOwner?: boolean) {
const crmService = await this.getCrmService(this.credential);
const contacts = await crmService?.getContacts(emailOrEmails, includeOwner);
return contacts;
}
public async createContacts(contactsToCreate: ContactCreateInput[]) {
const crmService = await this.getCrmService(this.credential);
const createdContacts = (await crmService?.createContacts(contactsToCreate)) || [];
return createdContacts;
}
}

View File

@ -0,0 +1,394 @@
import type { TFunction } from "next-i18next";
import { describe, expect, it, vi } from "vitest";
import * as event from "./event";
describe("event tests", () => {
describe("fn: getEventName", () => {
it("should return event_between_users message if no name", () => {
const tFunc = vi.fn(() => "foo");
const result = event.getEventName({
attendeeName: "example attendee",
eventType: "example event type",
host: "example host",
t: tFunc as TFunction,
});
expect(result).toBe("foo");
const lastCall = tFunc.mock.lastCall;
expect(lastCall).toEqual([
"event_between_users",
{
eventName: "example event type",
host: "example host",
attendeeName: "example attendee",
interpolation: {
escapeValue: false,
},
},
]);
});
it("should return event_between_users message if no name with team set", () => {
const tFunc = vi.fn(() => "foo");
const result = event.getEventName({
attendeeName: "example attendee",
eventType: "example event type",
host: "example host",
teamName: "example team name",
t: tFunc as TFunction,
});
expect(result).toBe("foo");
const lastCall = tFunc.mock.lastCall;
expect(lastCall).toEqual([
"event_between_users",
{
eventName: "example event type",
host: "example team name",
attendeeName: "example attendee",
interpolation: {
escapeValue: false,
},
},
]);
});
it("should return event name if no vars used", () => {
const tFunc = vi.fn(() => "foo");
const result = event.getEventName({
attendeeName: "example attendee",
eventType: "example event type",
host: "example host",
eventName: "example event name",
t: tFunc as TFunction,
});
expect(tFunc).not.toHaveBeenCalled();
expect(result).toBe("example event name");
});
it("should support templating of event type", () => {
const tFunc = vi.fn(() => "foo");
const result = event.getEventName({
attendeeName: "example attendee",
eventType: "example event type",
host: "example host",
eventName: "event type: {Event type title}",
t: tFunc as TFunction,
});
expect(tFunc).not.toHaveBeenCalled();
expect(result).toBe("event type: example event type");
});
it("should support templating of scheduler", () => {
const tFunc = vi.fn(() => "foo");
const result = event.getEventName({
attendeeName: "example attendee",
eventType: "example event type",
host: "example host",
eventName: "scheduler: {Scheduler}",
t: tFunc as TFunction,
});
expect(tFunc).not.toHaveBeenCalled();
expect(result).toBe("scheduler: example attendee");
});
it("should support templating of organiser", () => {
const tFunc = vi.fn(() => "foo");
const result = event.getEventName({
attendeeName: "example attendee",
eventType: "example event type",
host: "example host",
eventName: "organiser: {Organiser}",
t: tFunc as TFunction,
});
expect(tFunc).not.toHaveBeenCalled();
expect(result).toBe("organiser: example host");
});
it("should support templating of user", () => {
const tFunc = vi.fn(() => "foo");
const result = event.getEventName({
attendeeName: "example attendee",
eventType: "example event type",
host: "example host",
eventName: "user: {USER}",
t: tFunc as TFunction,
});
expect(tFunc).not.toHaveBeenCalled();
expect(result).toBe("user: example attendee");
});
it("should support templating of attendee", () => {
const tFunc = vi.fn(() => "foo");
const result = event.getEventName({
attendeeName: "example attendee",
eventType: "example event type",
host: "example host",
eventName: "attendee: {ATTENDEE}",
t: tFunc as TFunction,
});
expect(tFunc).not.toHaveBeenCalled();
expect(result).toBe("attendee: example attendee");
});
it("should support templating of host", () => {
const tFunc = vi.fn(() => "foo");
const result = event.getEventName({
attendeeName: "example attendee",
eventType: "example event type",
host: "example host",
eventName: "host: {HOST}",
t: tFunc as TFunction,
});
expect(tFunc).not.toHaveBeenCalled();
expect(result).toBe("host: example host");
});
it("should support templating of attendee with host/attendee", () => {
const tFunc = vi.fn(() => "foo");
const result = event.getEventName({
attendeeName: "example attendee",
eventType: "example event type",
host: "example host",
eventName: "host or attendee: {HOST/ATTENDEE}",
t: tFunc as TFunction,
});
expect(tFunc).not.toHaveBeenCalled();
expect(result).toBe("host or attendee: example attendee");
});
it("should support templating of host with host/attendee", () => {
const tFunc = vi.fn(() => "foo");
const result = event.getEventName(
{
attendeeName: "example attendee",
eventType: "example event type",
host: "example host",
eventName: "host or attendee: {HOST/ATTENDEE}",
t: tFunc as TFunction,
},
true
);
expect(tFunc).not.toHaveBeenCalled();
expect(result).toBe("host or attendee: example host");
});
it("should support templating of custom booking fields", () => {
const tFunc = vi.fn(() => "foo");
const result = event.getEventName({
attendeeName: "example attendee",
eventType: "example event type",
host: "example host",
eventName: "custom field: {customField}",
bookingFields: {
customField: "example custom field",
},
t: tFunc as TFunction,
});
expect(tFunc).not.toHaveBeenCalled();
expect(result).toBe("custom field: example custom field");
});
it("should support templating of custom booking fields with values", () => {
const tFunc = vi.fn(() => "foo");
const result = event.getEventName({
attendeeName: "example attendee",
eventType: "example event type",
host: "example host",
eventName: "custom field: {customField}",
bookingFields: {
customField: {
value: "example custom field",
},
},
t: tFunc as TFunction,
});
expect(tFunc).not.toHaveBeenCalled();
expect(result).toBe("custom field: example custom field");
});
it("should support templating of custom booking fields with non-string values", () => {
const tFunc = vi.fn(() => "foo");
const result = event.getEventName({
attendeeName: "example attendee",
eventType: "example event type",
host: "example host",
eventName: "custom field: {customField}",
bookingFields: {
customField: {
value: 808,
},
},
t: tFunc as TFunction,
});
expect(tFunc).not.toHaveBeenCalled();
expect(result).toBe("custom field: 808");
});
it("should support templating of custom booking fields with no value", () => {
const tFunc = vi.fn(() => "foo");
const result = event.getEventName({
attendeeName: "example attendee",
eventType: "example event type",
host: "example host",
eventName: "custom field: {customField}",
bookingFields: {
customField: {
value: undefined,
},
},
t: tFunc as TFunction,
});
expect(tFunc).not.toHaveBeenCalled();
expect(result).toBe("custom field: ");
});
it("should support templating of location via {Location}", () => {
const tFunc = vi.fn(() => "foo");
const result = event.getEventName({
attendeeName: "example attendee",
eventType: "example event type",
host: "example host",
location: "attendeeInPerson",
eventName: "location: {Location}",
t: tFunc as TFunction,
});
expect(tFunc).not.toHaveBeenCalled();
expect(result).toBe("location: in_person_attendee_address");
});
it("should support templating of location via {LOCATION}", () => {
const tFunc = vi.fn(() => "foo");
const result = event.getEventName({
attendeeName: "example attendee",
eventType: "example event type",
host: "example host",
location: "attendeeInPerson",
eventName: "location: {LOCATION}",
t: tFunc as TFunction,
});
expect(tFunc).not.toHaveBeenCalled();
expect(result).toBe("location: in_person_attendee_address");
});
it("should strip location template if none set", () => {
const tFunc = vi.fn(() => "foo");
const result = event.getEventName({
attendeeName: "example attendee",
eventType: "example event type",
host: "example host",
eventName: "location: {Location}",
t: tFunc as TFunction,
});
expect(tFunc).not.toHaveBeenCalled();
expect(result).toBe("location: ");
});
it("should strip location template if empty", () => {
const tFunc = vi.fn(() => "foo");
const result = event.getEventName({
attendeeName: "example attendee",
eventType: "example event type",
host: "example host",
location: "",
eventName: "location: {Location}",
t: tFunc as TFunction,
});
expect(tFunc).not.toHaveBeenCalled();
expect(result).toBe("location: ");
});
it("should template {Location} as passed location if unknown type", () => {
const tFunc = vi.fn(() => "foo");
const result = event.getEventName({
attendeeName: "example attendee",
eventType: "example event type",
host: "example host",
location: "unknownNonsense",
eventName: "location: {Location}",
t: tFunc as TFunction,
});
expect(tFunc).not.toHaveBeenCalled();
expect(result).toBe("location: unknownNonsense");
});
});
describe("fn: validateCustomEventName", () => {
it("should be valid when no variables used", () => {
expect(event.validateCustomEventName("foo", "error message")).toBe(true);
});
[
"Event type title",
"Organiser",
"Scheduler",
"Location",
"LOCATION",
"HOST/ATTENDEE",
"HOST",
"ATTENDEE",
"USER",
].forEach((value) => {
it(`should support {${value}} variable`, () => {
expect(event.validateCustomEventName(`foo {${value}} bar`, "error message")).toBe(true);
expect(event.validateCustomEventName(`{${value}} bar`, "error message")).toBe(true);
expect(event.validateCustomEventName(`foo {${value}}`, "error message")).toBe(true);
});
});
it("should support booking field variables", () => {
expect(
event.validateCustomEventName("foo{customField}bar", "error message", {
customField: true,
})
).toBe(true);
});
it("should return error when invalid variable used", () => {
expect(event.validateCustomEventName("foo{nonsenseField}bar", "error message")).toBe("error message");
});
});
});

View File

@ -0,0 +1,140 @@
import type { TFunction } from "next-i18next";
import z from "zod";
import { guessEventLocationType } from "@calcom/app-store/locations";
import type { Prisma } from "@calcom/prisma/client";
export const nameObjectSchema = z.object({
firstName: z.string(),
lastName: z.string().optional(),
});
function parseName(name: z.infer<typeof nameObjectSchema> | string | undefined) {
if (typeof name === "string") return name;
else if (typeof name === "object" && nameObjectSchema.parse(name))
return `${name.firstName} ${name.lastName}`.trim();
else return "Nameless";
}
export type EventNameObjectType = {
attendeeName: z.infer<typeof nameObjectSchema> | string;
eventType: string;
eventName?: string | null;
teamName?: string | null;
host: string;
location?: string;
bookingFields?: Prisma.JsonObject;
t: TFunction;
};
export function getEventName(eventNameObj: EventNameObjectType, forAttendeeView = false) {
const attendeeName = parseName(eventNameObj.attendeeName);
if (!eventNameObj.eventName)
return eventNameObj.t("event_between_users", {
eventName: eventNameObj.eventType,
host: eventNameObj.teamName || eventNameObj.host,
attendeeName,
interpolation: {
escapeValue: false,
},
});
let eventName = eventNameObj.eventName;
let locationString = eventNameObj.location || "";
if (eventNameObj.eventName.includes("{Location}") || eventNameObj.eventName.includes("{LOCATION}")) {
const eventLocationType = guessEventLocationType(eventNameObj.location);
if (eventLocationType) {
locationString = eventLocationType.label;
}
eventName = eventName.replace("{Location}", locationString);
eventName = eventName.replace("{LOCATION}", locationString);
}
let dynamicEventName = eventName
// Need this for compatibility with older event names
.replaceAll("{Event type title}", eventNameObj.eventType)
.replaceAll("{Scheduler}", attendeeName)
.replaceAll("{Organiser}", eventNameObj.host)
.replaceAll("{Organiser first name}", eventNameObj.host.split(" ")[0])
.replaceAll("{USER}", attendeeName)
.replaceAll("{ATTENDEE}", attendeeName)
.replaceAll("{HOST}", eventNameObj.host)
.replaceAll("{HOST/ATTENDEE}", forAttendeeView ? eventNameObj.host : attendeeName);
const { bookingFields } = eventNameObj || {};
const { name } = bookingFields || {};
if (name && typeof name === "object" && !Array.isArray(name) && typeof name.firstName === "string") {
dynamicEventName = dynamicEventName.replaceAll("{Scheduler first name}", name.firstName.toString());
}
if (name && typeof name === "object" && !Array.isArray(name) && typeof name.lastName === "string") {
dynamicEventName = dynamicEventName.replaceAll("{Scheduler last name}", name.lastName.toString());
}
const customInputvariables = dynamicEventName.match(/\{(.+?)}/g)?.map((variable) => {
return variable.replace("{", "").replace("}", "");
});
customInputvariables?.forEach((variable) => {
if (eventNameObj.bookingFields) {
Object.keys(eventNameObj.bookingFields).forEach((bookingField) => {
if (variable === bookingField) {
let fieldValue;
if (eventNameObj.bookingFields) {
const field = eventNameObj.bookingFields[bookingField as keyof typeof eventNameObj.bookingFields];
if (field && typeof field === "object" && "value" in field) {
fieldValue = field?.value?.toString();
} else {
fieldValue = field?.toString();
}
}
dynamicEventName = dynamicEventName.replace(`{${variable}}`, fieldValue || "");
}
});
}
});
return dynamicEventName;
}
export const validateCustomEventName = (
value: string,
message: string,
bookingFields?: Prisma.JsonObject
) => {
let customInputVariables: string[] = [];
if (bookingFields) {
customInputVariables = Object.keys(bookingFields).map((customInput) => {
return `{${customInput}}`;
});
}
const validVariables = customInputVariables.concat([
"{Event type title}",
"{Organiser}",
"{Scheduler}",
"{Location}",
"{Organiser first name}",
"{Scheduler first name}",
"{Scheduler last name}",
//allowed for fallback reasons
"{LOCATION}",
"{HOST/ATTENDEE}",
"{HOST}",
"{ATTENDEE}",
"{USER}",
]);
const matches = value.match(/\{([^}]+)\}/g);
if (matches?.length) {
for (const item of matches) {
if (!validVariables.includes(item)) {
return message;
}
}
}
return true;
};

View File

@ -0,0 +1,340 @@
import { describe, it, expect } from "vitest";
import type { WorkingHours } from "@calcom/types/schedule";
import { getAggregateWorkingHours } from "./getAggregateWorkingHours";
describe("getAggregateWorkingHours", () => {
it("should return all schedules if no scheduling type", () => {
const workingHours: WorkingHours[] = [
{
days: [1, 2, 3],
startTime: 0,
endTime: 720,
},
];
const result = getAggregateWorkingHours(
[
{
busy: [],
timeZone: "Europe/London",
workingHours,
dateOverrides: [],
datesOutOfOffice: {},
},
{
busy: [],
timeZone: "Europe/London",
workingHours,
dateOverrides: [],
datesOutOfOffice: {},
},
],
null
);
expect(result).toEqual([...workingHours, ...workingHours]);
});
it("should return all schedules if no fixed users exist", () => {
const workingHours: WorkingHours[] = [
{
days: [1, 2, 3],
startTime: 0,
endTime: 720,
},
];
const result = getAggregateWorkingHours(
[
{
busy: [],
timeZone: "Europe/London",
workingHours,
dateOverrides: [],
datesOutOfOffice: {},
},
{
busy: [],
timeZone: "Europe/London",
workingHours,
dateOverrides: [],
datesOutOfOffice: {},
},
],
"MANAGED"
);
expect(result).toEqual([...workingHours, ...workingHours]);
});
it("should consider all schedules fixed if collective", () => {
const workingHoursA: WorkingHours[] = [
{
days: [1, 2],
startTime: 0,
endTime: 200,
},
];
const workingHoursB: WorkingHours[] = [
{
days: [2, 3],
startTime: 100,
endTime: 300,
},
];
const result = getAggregateWorkingHours(
[
{
busy: [],
timeZone: "Europe/London",
workingHours: workingHoursA,
dateOverrides: [],
datesOutOfOffice: {},
user: {
isFixed: false,
},
},
{
busy: [],
timeZone: "Europe/London",
workingHours: workingHoursB,
dateOverrides: [],
datesOutOfOffice: {},
user: {
isFixed: false,
},
},
],
"COLLECTIVE"
);
expect(result).toEqual([
{
days: [2],
startTime: 100,
endTime: 200,
},
]);
});
it("should include loose host hours", () => {
const workingHoursA: WorkingHours[] = [
{
days: [1, 2],
startTime: 0,
endTime: 200,
},
];
const workingHoursB: WorkingHours[] = [
{
days: [2, 3],
startTime: 100,
endTime: 300,
},
];
const result = getAggregateWorkingHours(
[
{
busy: [],
timeZone: "Europe/London",
workingHours: workingHoursA,
dateOverrides: [],
datesOutOfOffice: {},
user: {
isFixed: false,
},
},
{
busy: [],
timeZone: "Europe/London",
workingHours: workingHoursB,
dateOverrides: [],
datesOutOfOffice: {},
user: {
isFixed: true,
},
},
],
"COLLECTIVE"
);
expect(result).toEqual([
{
days: [2],
startTime: 100,
endTime: 200,
userId: undefined,
},
]);
});
it("should return last user's hours if no intersection", () => {
const workingHoursA: WorkingHours[] = [
{
days: [1],
startTime: 0,
endTime: 200,
},
];
const workingHoursB: WorkingHours[] = [
{
days: [2],
startTime: 100,
endTime: 300,
},
];
const result = getAggregateWorkingHours(
[
{
busy: [],
timeZone: "Europe/London",
workingHours: workingHoursA,
dateOverrides: [],
datesOutOfOffice: {},
user: {
isFixed: true,
},
},
{
busy: [],
timeZone: "Europe/London",
workingHours: workingHoursB,
dateOverrides: [],
datesOutOfOffice: {},
user: {
isFixed: true,
},
},
],
"COLLECTIVE"
);
expect(result).toEqual([...workingHoursB]);
});
it("should include user IDs when not collective", () => {
const workingHoursA: WorkingHours[] = [
{
days: [1, 2],
startTime: 0,
endTime: 200,
userId: 1,
},
];
const workingHoursB: WorkingHours[] = [
{
days: [2, 3],
startTime: 100,
endTime: 300,
userId: 2,
},
];
const result = getAggregateWorkingHours(
[
{
busy: [],
timeZone: "Europe/London",
workingHours: workingHoursA,
dateOverrides: [],
datesOutOfOffice: {},
user: {
isFixed: true,
},
},
{
busy: [],
timeZone: "Europe/London",
workingHours: workingHoursB,
dateOverrides: [],
datesOutOfOffice: {},
user: {
isFixed: true,
},
},
],
"MANAGED"
);
expect(result).toEqual([
{
userId: 1,
days: [2],
startTime: 100,
endTime: 200,
},
]);
});
it("should handle multiple intersections", () => {
const workingHoursA: WorkingHours[] = [
{
days: [1, 2],
startTime: 0,
endTime: 200,
},
{
days: [3, 4],
startTime: 100,
endTime: 300,
},
];
const workingHoursB: WorkingHours[] = [
{
days: [2, 3],
startTime: 100,
endTime: 300,
},
{
days: [4, 5],
startTime: 0,
endTime: 200,
},
];
const result = getAggregateWorkingHours(
[
{
busy: [],
timeZone: "Europe/London",
workingHours: workingHoursA,
dateOverrides: [],
datesOutOfOffice: {},
user: {
isFixed: true,
},
},
{
busy: [],
timeZone: "Europe/London",
workingHours: workingHoursB,
dateOverrides: [],
datesOutOfOffice: {},
user: {
isFixed: true,
},
},
],
"COLLECTIVE"
);
expect(result).toEqual([
{
days: [2],
startTime: 100,
endTime: 200,
userId: undefined,
},
{
days: [3],
startTime: 100,
endTime: 300,
userId: undefined,
},
{
days: [4],
startTime: 100,
endTime: 200,
userId: undefined,
},
]);
});
});

View File

@ -0,0 +1,60 @@
import { SchedulingType } from "@calcom/prisma/enums";
import type { WorkingHours } from "@calcom/types/schedule";
/**
* This function gets team members working hours and busy slots,
* offsets them to UTC and intersects them for collective events.
**/
export const getAggregateWorkingHours = (
usersWorkingHoursAndBusySlots: (Omit<
Awaited<ReturnType<Awaited<typeof import("./getUserAvailability")>["getUserAvailability"]>>,
"currentSeats" | "dateRanges" | "oooExcludedDateRanges"
> & { user?: { isFixed?: boolean } })[],
// eslint-disable-next-line @typescript-eslint/no-unused-vars
schedulingType: SchedulingType | null
): WorkingHours[] => {
// during personal events, just flatMap.
if (!schedulingType) {
return usersWorkingHoursAndBusySlots.flatMap((s) => s.workingHours);
}
const looseHostWorkingHours = usersWorkingHoursAndBusySlots
.filter(({ user }) => schedulingType !== SchedulingType.COLLECTIVE && user?.isFixed !== true)
.flatMap((s) => s.workingHours);
const fixedHostSchedules = usersWorkingHoursAndBusySlots.filter(
({ user }) => schedulingType === SchedulingType.COLLECTIVE || user?.isFixed
);
// return early when there are no fixed hosts.
if (!fixedHostSchedules.length) {
return looseHostWorkingHours;
}
return fixedHostSchedules.reduce((currentWorkingHours: WorkingHours[], s) => {
const updatedWorkingHours: typeof currentWorkingHours = [];
s.workingHours.forEach((workingHour) => {
const sameDayWorkingHours = currentWorkingHours.filter((compare) =>
compare.days.find((day) => workingHour.days.includes(day))
);
if (!sameDayWorkingHours.length) {
updatedWorkingHours.push(workingHour); // the first day is always added.
return;
}
// days are overlapping when different users are involved, instead of adding we now need to subtract
updatedWorkingHours.push(
...sameDayWorkingHours.map((compare) => {
const intersect = workingHour.days.filter((day) => compare.days.includes(day));
const retVal: WorkingHours & { userId?: number | null } = {
days: intersect,
startTime: Math.max(workingHour.startTime, compare.startTime),
endTime: Math.min(workingHour.endTime, compare.endTime),
};
if (schedulingType !== SchedulingType.COLLECTIVE) {
retVal.userId = compare.userId;
}
return retVal;
})
);
});
return updatedWorkingHours;
}, looseHostWorkingHours);
};

View File

@ -0,0 +1,36 @@
import type { DateRange } from "@calcom/lib/date-ranges";
export function mergeOverlappingDateRanges(dateRanges: DateRange[]) {
dateRanges.sort((a, b) => a.start.valueOf() - b.start.valueOf());
const mergedDateRanges: DateRange[] = [];
let currentRange = dateRanges[0];
if (!currentRange) {
return [];
}
for (let i = 1; i < dateRanges.length; i++) {
const nextRange = dateRanges[i];
if (isCurrentRangeOverlappingNext(currentRange, nextRange)) {
currentRange = {
start: currentRange.start,
end: currentRange.end.valueOf() > nextRange.end.valueOf() ? currentRange.end : nextRange.end,
};
} else {
mergedDateRanges.push(currentRange);
currentRange = nextRange;
}
}
mergedDateRanges.push(currentRange);
return mergedDateRanges;
}
function isCurrentRangeOverlappingNext(currentRange: DateRange, nextRange: DateRange): boolean {
return (
currentRange.start.valueOf() <= nextRange.start.valueOf() &&
currentRange.end.valueOf() > nextRange.start.valueOf()
);
}

View File

@ -0,0 +1,62 @@
import { describe, it, expect } from "vitest";
import dayjs from "@calcom/dayjs";
import type { DateRange } from "@calcom/lib/date-ranges";
import { mergeOverlappingDateRanges } from ".";
const november2 = "2023-11-02";
const november3 = "2023-11-03";
describe("mergeOverlappingDateRanges", () => {
it("should merge all ranges into one when one range includes all others", () => {
const dateRanges = [
createDateRange(`${november2}T23:00:00.000Z`, `${november3}T07:00:00.000Z`), // Includes all others
createDateRange(`${november2}T23:15:00.000Z`, `${november3}T00:00:00.000Z`),
createDateRange(`${november3}T00:15:00.000Z`, `${november3}T01:00:00.000Z`),
createDateRange(`${november3}T01:15:00.000Z`, `${november3}T02:00:00.000Z`),
];
const mergedRanges = mergeOverlappingDateRanges(dateRanges);
expect(mergedRanges).toHaveLength(1);
expect(mergedRanges[0].start.isSame(dayjs(dateRanges[0].start))).toBe(true);
expect(mergedRanges[0].end.isSame(dayjs(dateRanges[0].end))).toBe(true);
});
it("should merge only overlapping ranges over 2 days and leave non-overlapping ranges as is", () => {
const dateRanges = [
createDateRange(`${november2}T23:00:00.000Z`, `${november3}T07:00:00.000Z`),
createDateRange(`${november3}T05:00:00.000Z`, `${november3}T06:00:00.000Z`),
createDateRange(`${november3}T08:00:00.000Z`, `${november3}T10:00:00.000Z`), // This range should not be merged
];
const mergedRanges = mergeOverlappingDateRanges(dateRanges);
expect(mergedRanges).toHaveLength(2);
expect(mergedRanges[0].start.isSame(dayjs(dateRanges[0].start))).toBe(true);
expect(mergedRanges[0].end.isSame(dayjs(dateRanges[0].end))).toBe(true);
expect(mergedRanges[1].start.isSame(dayjs(dateRanges[2].start))).toBe(true);
expect(mergedRanges[1].end.isSame(dayjs(dateRanges[2].end))).toBe(true);
});
it("should merge ranges that overlap on the same day", () => {
const dateRanges = [
createDateRange(`${november2}T01:00:00.000Z`, `${november2}T04:00:00.000Z`),
createDateRange(`${november2}T02:00:00.000Z`, `${november2}T03:00:00.000Z`), // This overlaps with the first range
createDateRange(`${november2}T05:00:00.000Z`, `${november2}T06:00:00.000Z`), // This doesn't overlap with above
];
const mergedRanges = mergeOverlappingDateRanges(dateRanges);
expect(mergedRanges).toHaveLength(2);
expect(mergedRanges[0].start.isSame(dayjs(dateRanges[0].start))).toBe(true);
expect(mergedRanges[0].end.isSame(dayjs(dateRanges[0].end))).toBe(true);
expect(mergedRanges[1].start.isSame(dayjs(dateRanges[2].start))).toBe(true);
expect(mergedRanges[1].end.isSame(dayjs(dateRanges[2].end))).toBe(true);
});
});
function createDateRange(start: string, end: string): DateRange {
return {
start: dayjs(start),
end: dayjs(end),
};
}

View File

@ -0,0 +1,37 @@
import type { DateRange } from "@calcom/lib/date-ranges";
import { intersect } from "@calcom/lib/date-ranges";
import { SchedulingType } from "@calcom/prisma/enums";
import { mergeOverlappingDateRanges } from "./date-range-utils/mergeOverlappingDateRanges";
export const getAggregatedAvailability = (
userAvailability: {
dateRanges: DateRange[];
oooExcludedDateRanges: DateRange[];
user?: { isFixed?: boolean };
}[],
schedulingType: SchedulingType | null
): DateRange[] => {
const isTeamEvent =
schedulingType === SchedulingType.COLLECTIVE ||
schedulingType === SchedulingType.ROUND_ROBIN ||
userAvailability.length > 1;
const fixedHosts = userAvailability.filter(
({ user }) => !schedulingType || schedulingType === SchedulingType.COLLECTIVE || user?.isFixed
);
const dateRangesToIntersect = fixedHosts.map((s) =>
!isTeamEvent ? s.dateRanges : s.oooExcludedDateRanges
);
const unfixedHosts = userAvailability.filter(({ user }) => user?.isFixed !== true);
if (unfixedHosts.length) {
dateRangesToIntersect.push(
unfixedHosts.flatMap((s) => (!isTeamEvent ? s.dateRanges : s.oooExcludedDateRanges))
);
}
const availability = intersect(dateRangesToIntersect);
return mergeOverlappingDateRanges(availability);
};

View File

@ -0,0 +1,368 @@
import type { Booking, EventType } from "@prisma/client";
import { getBusyCalendarTimes } from "@calcom/core/CalendarManager";
import dayjs from "@calcom/dayjs";
import { subtract } from "@calcom/lib/date-ranges";
import { intervalLimitKeyToUnit } from "@calcom/lib/intervalLimit";
import logger from "@calcom/lib/logger";
import { getPiiFreeBooking } from "@calcom/lib/piiFreeData";
import { performance } from "@calcom/lib/server/perfObserver";
import prisma from "@calcom/prisma";
import type { Prisma, SelectedCalendar } from "@calcom/prisma/client";
import { BookingStatus } from "@calcom/prisma/enums";
import { stringToDayjs } from "@calcom/prisma/zod-utils";
import type { EventBusyDetails, IntervalLimit } from "@calcom/types/Calendar";
import type { CredentialPayload } from "@calcom/types/Credential";
import { getDefinedBufferTimes } from "../features/eventtypes/lib/getDefinedBufferTimes";
export async function getBusyTimes(params: {
credentials: CredentialPayload[];
userId: number;
userEmail: string;
username: string;
eventTypeId?: number;
startTime: string;
beforeEventBuffer?: number;
afterEventBuffer?: number;
endTime: string;
selectedCalendars: SelectedCalendar[];
seatedEvent?: boolean;
rescheduleUid?: string | null;
duration?: number | null;
currentBookings?:
| (Pick<Booking, "id" | "uid" | "userId" | "startTime" | "endTime" | "title"> & {
eventType: Pick<
EventType,
"id" | "beforeEventBuffer" | "afterEventBuffer" | "seatsPerTimeSlot"
> | null;
_count?: {
seatsReferences: number;
};
})[]
| null;
}) {
const {
credentials,
userId,
userEmail,
username,
eventTypeId,
startTime,
endTime,
beforeEventBuffer,
afterEventBuffer,
selectedCalendars,
seatedEvent,
rescheduleUid,
duration,
} = params;
logger.silly(
`Checking Busy time from Cal Bookings in range ${startTime} to ${endTime} for input ${JSON.stringify({
userId,
eventTypeId,
status: BookingStatus.ACCEPTED,
})}`
);
/**
* A user is considered busy within a given time period if there
* is a booking they own OR attend.
*
* Performs a query for all bookings where:
* - The given booking is owned by this user, or..
* - The current user has a different booking at this time he/she attends
*
* See further discussion within this GH issue:
* https://github.com/calcom/cal.com/issues/6374
*
* NOTE: Changes here will likely require changes to some mocking
* logic within getSchedule.test.ts:addBookings
*/
performance.mark("prismaBookingGetStart");
const startTimeDate =
rescheduleUid && duration ? dayjs(startTime).subtract(duration, "minute").toDate() : new Date(startTime);
const endTimeDate =
rescheduleUid && duration ? dayjs(endTime).add(duration, "minute").toDate() : new Date(endTime);
// to also get bookings that are outside of start and end time, but the buffer falls within the start and end time
const definedBufferTimes = getDefinedBufferTimes();
const maxBuffer = definedBufferTimes[definedBufferTimes.length - 1];
const startTimeAdjustedWithMaxBuffer = dayjs(startTimeDate).subtract(maxBuffer, "minute").toDate();
const endTimeAdjustedWithMaxBuffer = dayjs(endTimeDate).add(maxBuffer, "minute").toDate();
// startTime is less than endTimeDate and endTime grater than startTimeDate
const sharedQuery = {
startTime: { lte: endTimeAdjustedWithMaxBuffer },
endTime: { gte: startTimeAdjustedWithMaxBuffer },
status: {
in: [BookingStatus.ACCEPTED],
},
};
// INFO: Refactored to allow this method to take in a list of current bookings for the user.
// Will keep support for retrieving a user's bookings if the caller does not already supply them.
// This function is called from multiple places but we aren't refactoring all of them at this moment
// to avoid potential side effects.
const bookings = params.currentBookings
? params.currentBookings
: await prisma.booking.findMany({
where: {
OR: [
// User is primary host (individual events, or primary organizer)
{
...sharedQuery,
userId,
},
// The current user has a different booking at this time he/she attends
{
...sharedQuery,
attendees: {
some: {
email: userEmail,
},
},
},
],
},
select: {
id: true,
uid: true,
userId: true,
startTime: true,
endTime: true,
title: true,
eventType: {
select: {
id: true,
afterEventBuffer: true,
beforeEventBuffer: true,
seatsPerTimeSlot: true,
},
},
...(seatedEvent && {
_count: {
select: {
seatsReferences: true,
},
},
}),
},
});
const bookingSeatCountMap: { [x: string]: number } = {};
const busyTimes = bookings.reduce(
(aggregate: EventBusyDetails[], { id, startTime, endTime, eventType, title, ...rest }) => {
if (rest._count?.seatsReferences) {
const bookedAt = `${dayjs(startTime).utc().format()}<>${dayjs(endTime).utc().format()}`;
bookingSeatCountMap[bookedAt] = bookingSeatCountMap[bookedAt] || 0;
bookingSeatCountMap[bookedAt]++;
// Seat references on the current event are non-blocking until the event is fully booked.
if (
// there are still seats available.
bookingSeatCountMap[bookedAt] < (eventType?.seatsPerTimeSlot || 1) &&
// and this is the seated event, other event types should be blocked.
eventTypeId === eventType?.id
) {
// then we do not add the booking to the busyTimes.
return aggregate;
}
// if it does get blocked at this point; we remove the bookingSeatCountMap entry
// doing this allows using the map later to remove the ranges from calendar busy times.
delete bookingSeatCountMap[bookedAt];
}
if (rest.uid === rescheduleUid) {
return aggregate;
}
aggregate.push({
start: dayjs(startTime)
.subtract((eventType?.beforeEventBuffer || 0) + (afterEventBuffer || 0), "minute")
.toDate(),
end: dayjs(endTime)
.add((eventType?.afterEventBuffer || 0) + (beforeEventBuffer || 0), "minute")
.toDate(),
title,
source: `eventType-${eventType?.id}-booking-${id}`,
});
return aggregate;
},
[]
);
logger.debug(
`Busy Time from Cal Bookings ${JSON.stringify({
busyTimes,
bookings: bookings?.map((booking) => getPiiFreeBooking(booking)),
numCredentials: credentials?.length,
})}`
);
performance.mark("prismaBookingGetEnd");
performance.measure(`prisma booking get took $1'`, "prismaBookingGetStart", "prismaBookingGetEnd");
if (credentials?.length > 0) {
const startConnectedCalendarsGet = performance.now();
const calendarBusyTimes = await getBusyCalendarTimes(
username,
credentials,
startTime,
endTime,
selectedCalendars
);
const endConnectedCalendarsGet = performance.now();
logger.debug(
`Connected Calendars get took ${
endConnectedCalendarsGet - startConnectedCalendarsGet
} ms for user ${username}`,
JSON.stringify({
calendarBusyTimes,
})
);
const openSeatsDateRanges = Object.keys(bookingSeatCountMap).map((key) => {
const [start, end] = key.split("<>");
return {
start: dayjs(start),
end: dayjs(end),
};
});
if (rescheduleUid) {
const originalRescheduleBooking = bookings.find((booking) => booking.uid === rescheduleUid);
// calendar busy time from original rescheduled booking should not be blocked
if (originalRescheduleBooking) {
openSeatsDateRanges.push({
start: dayjs(originalRescheduleBooking.startTime),
end: dayjs(originalRescheduleBooking.endTime),
});
}
}
const result = subtract(
calendarBusyTimes.map((value) => ({
...value,
end: dayjs(value.end),
start: dayjs(value.start),
})),
openSeatsDateRanges
);
busyTimes.push(
...result.map((busyTime) => ({
...busyTime,
start: busyTime.start.subtract(afterEventBuffer || 0, "minute").toDate(),
end: busyTime.end.add(beforeEventBuffer || 0, "minute").toDate(),
}))
);
/*
// TODO: Disabled until we can filter Zoom events by date. Also this is adding too much latency.
const videoBusyTimes = (await getBusyVideoTimes(credentials)).filter(notEmpty);
console.log("videoBusyTimes", videoBusyTimes);
busyTimes.push(...videoBusyTimes);
*/
}
logger.debug(
"getBusyTimes:",
JSON.stringify({
allBusyTimes: busyTimes,
})
);
return busyTimes;
}
export async function getBusyTimesForLimitChecks(params: {
userIds: number[];
eventTypeId: number;
startDate: string;
endDate: string;
rescheduleUid?: string | null;
bookingLimits?: IntervalLimit | null;
durationLimits?: IntervalLimit | null;
}) {
const { userIds, eventTypeId, startDate, endDate, rescheduleUid, bookingLimits, durationLimits } = params;
const startTimeAsDayJs = stringToDayjs(startDate);
const endTimeAsDayJs = stringToDayjs(endDate);
performance.mark("getBusyTimesForLimitChecksStart");
let busyTimes: EventBusyDetails[] = [];
if (!bookingLimits && !durationLimits) {
return busyTimes;
}
let limitDateFrom = stringToDayjs(startDate);
let limitDateTo = stringToDayjs(endDate);
// expand date ranges by absolute minimum required to apply limits
// (yearly limits are handled separately for performance)
for (const key of ["PER_MONTH", "PER_WEEK", "PER_DAY"] as Exclude<keyof IntervalLimit, "PER_YEAR">[]) {
if (bookingLimits?.[key] || durationLimits?.[key]) {
const unit = intervalLimitKeyToUnit(key);
limitDateFrom = dayjs.min(limitDateFrom, startTimeAsDayJs.startOf(unit));
limitDateTo = dayjs.max(limitDateTo, endTimeAsDayJs.endOf(unit));
}
}
logger.silly(
`Fetch limit checks bookings in range ${limitDateFrom} to ${limitDateTo} for input ${JSON.stringify({
eventTypeId,
status: BookingStatus.ACCEPTED,
})}`
);
const where: Prisma.BookingWhereInput = {
userId: {
in: userIds,
},
eventTypeId,
status: BookingStatus.ACCEPTED,
// FIXME: bookings that overlap on one side will never be counted
startTime: {
gte: limitDateFrom.toDate(),
},
endTime: {
lte: limitDateTo.toDate(),
},
};
if (rescheduleUid) {
where.NOT = {
uid: rescheduleUid,
};
}
const bookings = await prisma.booking.findMany({
where,
select: {
id: true,
startTime: true,
endTime: true,
eventType: {
select: {
id: true,
},
},
title: true,
userId: true,
},
});
busyTimes = bookings.map(({ id, startTime, endTime, eventType, title, userId }) => ({
start: dayjs(startTime).toDate(),
end: dayjs(endTime).toDate(),
title,
source: `eventType-${eventType?.id}-booking-${id}`,
userId,
}));
logger.silly(`Fetch limit checks bookings for eventId: ${eventTypeId} ${JSON.stringify(busyTimes)}`);
performance.mark("getBusyTimesForLimitChecksEnd");
performance.measure(
`prisma booking get for limits took $1'`,
"getBusyTimesForLimitChecksStart",
"getBusyTimesForLimitChecksEnd"
);
return busyTimes;
}
export default getBusyTimes;

View File

@ -0,0 +1,210 @@
import type { SelectedCalendar } from "@prisma/client";
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 logger from "@calcom/lib/logger";
import type { EventBusyDate } from "@calcom/types/Calendar";
import type { CredentialPayload } from "@calcom/types/Credential";
import getCalendarsEvents from "./getCalendarsEvents";
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,
appId: "exampleApp",
subscriptionId: null,
paymentStatus: null,
billingCycleStart: null,
invalid: false,
};
});
afterEach(() => {
vi.restoreAllMocks();
});
it("should return empty array if no calendar credentials", async () => {
const result = await getCalendarsEvents(
[
{
...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(
[
{
...credential,
type: "unknown_calendar",
},
],
"2010-12-01",
"2010-12-02",
[]
);
expect(result).toEqual([[]]);
});
it("should return unmatched calendars as empty", async () => {
const selectedCalendar: SelectedCalendar = {
credentialId: 100,
externalId: "externalId",
integration: "office365_calendar",
userId: 200,
};
const result = await getCalendarsEvents(
[
{
...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 = {
credentialId: 100,
externalId: "externalId",
integration: "google_calendar",
userId: 200,
};
const result = await getCalendarsEvents(
[
{
...credential,
type: "google_calendar",
},
],
"2010-12-01",
"2010-12-04",
[selectedCalendar]
);
expect(getAvailabilitySpy).toHaveBeenCalledWith("2010-12-01", "2010-12-04", [selectedCalendar]);
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 = {
credentialId: 100,
externalId: "externalId",
integration: "google_calendar",
userId: 200,
};
const selectedOfficeCalendar: SelectedCalendar = {
credentialId: 100,
externalId: "externalId",
integration: "office365_calendar",
userId: 200,
};
const result = await getCalendarsEvents(
[
{
...credential,
type: "google_calendar",
},
{
...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,
]);
expect(getOfficeAvailabilitySpy).toHaveBeenCalledWith("2010-12-01", "2010-12-04", [
selectedOfficeCalendar,
]);
expect(result).toEqual([
googleAvailability.map((av) => ({
...av,
source: "exampleApp",
})),
officeAvailability.map((av) => ({
...av,
source: "exampleApp",
})),
]);
});
});

View File

@ -0,0 +1,78 @@
import type { SelectedCalendar } from "@prisma/client";
import { getCalendar } from "@calcom/app-store/_utils/getCalendar";
import logger from "@calcom/lib/logger";
import { getPiiFreeCredential, getPiiFreeSelectedCalendar } from "@calcom/lib/piiFreeData";
import { safeStringify } from "@calcom/lib/safeStringify";
import { performance } from "@calcom/lib/server/perfObserver";
import type { EventBusyDate } from "@calcom/types/Calendar";
import type { CredentialPayload } from "@calcom/types/Credential";
const log = logger.getSubLogger({ prefix: ["getCalendarsEvents"] });
const getCalendarsEvents = async (
withCredentials: CredentialPayload[],
dateFrom: string,
dateTo: string,
selectedCalendars: SelectedCalendar[]
): Promise<EventBusyDate[][]> => {
const calendarCredentials = withCredentials
.filter((credential) => credential.type.endsWith("_calendar"))
// filter out invalid credentials - these won't work.
.filter((credential) => !credential.invalid);
const calendars = await Promise.all(calendarCredentials.map((credential) => getCalendar(credential)));
performance.mark("getBusyCalendarTimesStart");
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, appId } = calendarCredentials[i];
/** We just pass the calendars that matched the credential type,
* TODO: Migrate credential type or appId
*/
const passedSelectedCalendars = selectedCalendars.filter((sc) => sc.integration === type);
if (!passedSelectedCalendars.length) return [];
/** 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: c.constructor.name,
selectedCalendars: passedSelectedCalendars.map(getPiiFreeSelectedCalendar),
})
);
const eventBusyDates = await c.getAvailability(dateFrom, dateTo, passedSelectedCalendars);
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;

View File

@ -0,0 +1,885 @@
import type { Booking, Prisma, EventType as PrismaEventType } from "@prisma/client";
import { z } from "zod";
import type { Dayjs } from "@calcom/dayjs";
import dayjs from "@calcom/dayjs";
import { parseBookingLimit, parseDurationLimit } from "@calcom/lib";
import { getWorkingHours } from "@calcom/lib/availability";
import type { DateOverride, WorkingHours } from "@calcom/lib/date-ranges";
import { buildDateRanges, subtract } from "@calcom/lib/date-ranges";
import { ErrorCode } from "@calcom/lib/errorCodes";
import { HttpError } from "@calcom/lib/http-error";
import { descendingLimitKeys, intervalLimitKeyToUnit } from "@calcom/lib/intervalLimit";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { checkBookingLimit } from "@calcom/lib/server";
import { performance } from "@calcom/lib/server/perfObserver";
import { getTotalBookingDuration } from "@calcom/lib/server/queries";
import prisma, { availabilityUserSelect } from "@calcom/prisma";
import { SchedulingType } from "@calcom/prisma/enums";
import { BookingStatus } from "@calcom/prisma/enums";
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
import { EventTypeMetaDataSchema, stringToDayjsZod } from "@calcom/prisma/zod-utils";
import type {
EventBusyDate,
EventBusyDetails,
IntervalLimit,
IntervalLimitUnit,
} from "@calcom/types/Calendar";
import type { TimeRange } from "@calcom/types/schedule";
import { getBusyTimes } from "./getBusyTimes";
import monitorCallbackAsync, { monitorCallbackSync } from "./sentryWrapper";
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(),
})
.refine((data) => !!data.username || !!data.userId, "Either username or userId should be filled in.");
const getEventType = async (
...args: Parameters<typeof _getEventType>
): Promise<ReturnType<typeof _getEventType>> => {
return monitorCallbackAsync(_getEventType, ...args);
};
const _getEventType = async (id: number) => {
const eventType = await prisma.eventType.findUnique({
where: { id },
select: {
id: true,
seatsPerTimeSlot: true,
bookingLimits: true,
hosts: {
select: {
user: {
select: {
email: true,
},
},
},
},
durationLimits: true,
assignAllTeamMembers: true,
schedulingType: true,
timeZone: true,
length: true,
metadata: true,
schedule: {
select: {
id: true,
availability: {
select: {
days: true,
date: true,
startTime: true,
endTime: true,
},
},
timeZone: true,
},
},
availability: {
select: {
startTime: true,
endTime: true,
days: true,
date: true,
},
},
},
});
if (!eventType) {
return eventType;
}
return {
...eventType,
metadata: EventTypeMetaDataSchema.parse(eventType.metadata),
};
};
type EventType = Awaited<ReturnType<typeof getEventType>>;
const getUser = async (...args: Parameters<typeof _getUser>): Promise<ReturnType<typeof _getUser>> => {
return monitorCallbackAsync(_getUser, ...args);
};
const _getUser = async (where: Prisma.UserWhereInput) => {
return await prisma.user.findFirst({
where,
select: {
...availabilityUserSelect,
credentials: {
select: credentialForCalendarServiceSelect,
},
},
});
};
type User = Awaited<ReturnType<typeof getUser>>;
export const getCurrentSeats = async (
...args: Parameters<typeof _getCurrentSeats>
): Promise<ReturnType<typeof _getCurrentSeats>> => {
return monitorCallbackAsync(_getCurrentSeats, ...args);
};
const _getCurrentSeats = async (
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 prisma.booking.findMany({
where: {
eventTypeId: id,
startTime: {
gte: dateFrom.format(),
lte: dateTo.format(),
},
status: BookingStatus.ACCEPTED,
},
select: {
uid: true,
startTime: true,
attendees: {
select: {
email: true,
},
},
_count: {
select: {
attendees: true,
},
},
},
});
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,
},
};
});
};
export type CurrentSeats = Awaited<ReturnType<typeof getCurrentSeats>>;
export const getUserAvailability = async (
...args: Parameters<typeof _getUserAvailability>
): Promise<ReturnType<typeof _getUserAvailability>> => {
return monitorCallbackAsync(_getUserAvailability, ...args);
};
/** This should be called getUsersWorkingHoursAndBusySlots (...and remaining seats, and final timezone) */
const _getUserAvailability = async function getUsersWorkingHoursLifeTheUniverseAndEverythingElse(
query: {
withSource?: boolean;
username?: string;
userId?: number;
dateFrom: string;
dateTo: string;
eventTypeId?: number;
afterEventBuffer?: number;
beforeEventBuffer?: number;
duration?: number;
returnDateOverrides: boolean;
},
initialData?: {
user?: User;
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;
};
})[];
busyTimesFromLimitsBookings: EventBusyDetails[];
}
) {
const {
username,
userId,
dateFrom,
dateTo,
eventTypeId,
afterEventBuffer,
beforeEventBuffer,
duration,
returnDateOverrides,
} = availabilitySchema.parse(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 getUser(where));
if (!user) throw new HttpError({ statusCode: 404, message: "No user found in getUserAvailability" });
log.debug(
"getUserAvailability for user",
safeStringify({ user: { id: user.id }, slot: { dateFrom, dateTo } })
);
let eventType: EventType | null = initialData?.eventType || null;
if (!eventType && eventTypeId) eventType = await 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 getCurrentSeats(eventType, dateFrom, dateTo);
}
const bookingLimits = parseBookingLimit(eventType?.bookingLimits);
const durationLimits = parseDurationLimit(eventType?.durationLimits);
const busyTimesFromLimits =
eventType && (bookingLimits || durationLimits)
? await getBusyTimesFromLimits(
bookingLimits,
durationLimits,
dateFrom,
dateTo,
duration,
eventType,
initialData?.busyTimesFromLimitsBookings ?? []
)
: [];
// TODO: only query what we need after applying limits (shrink date range)
const getBusyTimesStart = dateFrom.toISOString();
const getBusyTimesEnd = dateTo.toISOString();
const busyTimes = await getBusyTimes({
credentials: user.credentials,
startTime: getBusyTimesStart,
endTime: getBusyTimesEnd,
eventTypeId,
userId: user.id,
userEmail: user.email,
username: `${user.username}`,
beforeEventBuffer,
afterEventBuffer,
selectedCalendars: user.selectedCalendars,
seatedEvent: !!eventType?.seatsPerTimeSlot,
rescheduleUid: initialData?.rescheduleUid || null,
duration,
currentBookings: initialData?.currentBookings,
});
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,
];
const userSchedule = user.schedules.filter(
(schedule) => !user?.defaultScheduleId || schedule.id === user?.defaultScheduleId
)[0];
const useHostSchedulesForTeamEvent = eventType?.metadata?.config?.useHostSchedulesForTeamEvent;
const schedule = !useHostSchedulesForTeamEvent && eventType?.schedule ? eventType.schedule : userSchedule;
const isDefaultSchedule = userSchedule && userSchedule.id === schedule.id;
log.debug(
"Using schedule:",
safeStringify({
chosenSchedule: schedule,
eventTypeSchedule: eventType?.schedule,
userSchedule: userSchedule,
useHostSchedulesForTeamEvent: eventType?.metadata?.config?.useHostSchedulesForTeamEvent,
})
);
const startGetWorkingHours = performance.now();
const timeZone = schedule?.timeZone || eventType?.timeZone || user.timeZone;
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 }, availability);
const endGetWorkingHours = performance.now();
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 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(),
});
}
}
}
const datesOutOfOffice = await getOutOfOfficeDays({
userId: user.id,
dateFrom,
dateTo,
availability,
});
const { dateRanges, oooExcludedDateRanges } = buildDateRanges({
dateFrom,
dateTo,
availability,
timeZone,
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);
log.debug(
`getWorkingHours took ${endGetWorkingHours - startGetWorkingHours}ms for userId ${userId}`,
JSON.stringify({
workingHoursInUtc: workingHours,
dateOverrides,
dateRangesAsPerAvailability: dateRanges,
dateRangesInWhichUserIsAvailable,
detailedBusyTimes,
})
);
return {
busy: detailedBusyTimes,
timeZone,
dateRanges: dateRangesInWhichUserIsAvailable,
oooExcludedDateRanges: dateRangesInWhichUserIsAvailableWithoutOOO,
workingHours,
dateOverrides,
currentSeats,
datesOutOfOffice,
};
};
const getPeriodStartDatesBetween = (
...args: Parameters<typeof _getPeriodStartDatesBetween>
): ReturnType<typeof _getPeriodStartDatesBetween> => {
return monitorCallbackSync(_getPeriodStartDatesBetween, ...args);
};
const _getPeriodStartDatesBetween = (dateFrom: Dayjs, dateTo: Dayjs, period: IntervalLimitUnit) => {
const dates = [];
let startDate = dayjs(dateFrom).startOf(period);
const endDate = dayjs(dateTo).endOf(period);
while (startDate.isBefore(endDate)) {
dates.push(startDate);
startDate = startDate.add(1, period);
}
return dates;
};
type BusyMapKey = `${IntervalLimitUnit}-${ReturnType<Dayjs["toISOString"]>}`;
/**
* Helps create, check, and return busy times from limits (with parallel support)
*/
class LimitManager {
private busyMap: Map<BusyMapKey, EventBusyDate> = new Map();
/**
* Creates a busy map key
*/
private static createKey(start: Dayjs, unit: IntervalLimitUnit): BusyMapKey {
return `${unit}-${start.startOf(unit).toISOString()}`;
}
/**
* Checks if already marked busy by ancestors or siblings
*/
isAlreadyBusy(start: Dayjs, unit: IntervalLimitUnit) {
if (this.busyMap.has(LimitManager.createKey(start, "year"))) return true;
if (unit === "month" && this.busyMap.has(LimitManager.createKey(start, "month"))) {
return true;
} else if (
unit === "week" &&
// weeks can be part of two months
((this.busyMap.has(LimitManager.createKey(start, "month")) &&
this.busyMap.has(LimitManager.createKey(start.endOf("week"), "month"))) ||
this.busyMap.has(LimitManager.createKey(start, "week")))
) {
return true;
} else if (
unit === "day" &&
(this.busyMap.has(LimitManager.createKey(start, "month")) ||
this.busyMap.has(LimitManager.createKey(start, "week")) ||
this.busyMap.has(LimitManager.createKey(start, "day")))
) {
return true;
} else {
return false;
}
}
/**
* Adds a new busy time
*/
addBusyTime(start: Dayjs, unit: IntervalLimitUnit) {
this.busyMap.set(`${unit}-${start.toISOString()}`, {
start: start.toISOString(),
end: start.endOf(unit).toISOString(),
});
}
/**
* Returns all busy times
*/
getBusyTimes() {
return Array.from(this.busyMap.values());
}
}
const getBusyTimesFromLimits = async (
...args: Parameters<typeof _getBusyTimesFromLimits>
): Promise<ReturnType<typeof _getBusyTimesFromLimits>> => {
return monitorCallbackAsync(_getBusyTimesFromLimits, ...args);
};
const _getBusyTimesFromLimits = async (
bookingLimits: IntervalLimit | null,
durationLimits: IntervalLimit | null,
dateFrom: Dayjs,
dateTo: Dayjs,
duration: number | undefined,
eventType: NonNullable<EventType>,
bookings: EventBusyDetails[]
) => {
performance.mark("limitsStart");
// shared amongst limiters to prevent processing known busy periods
const limitManager = new LimitManager();
// run this first, as counting bookings should always run faster..
if (bookingLimits) {
performance.mark("bookingLimitsStart");
await getBusyTimesFromBookingLimits(
bookings,
bookingLimits,
dateFrom,
dateTo,
eventType.id,
limitManager
);
performance.mark("bookingLimitsEnd");
performance.measure(`checking booking limits took $1'`, "bookingLimitsStart", "bookingLimitsEnd");
}
// ..than adding up durations (especially for the whole year)
if (durationLimits) {
performance.mark("durationLimitsStart");
await getBusyTimesFromDurationLimits(
bookings,
durationLimits,
dateFrom,
dateTo,
duration,
eventType,
limitManager
);
performance.mark("durationLimitsEnd");
performance.measure(`checking duration limits took $1'`, "durationLimitsStart", "durationLimitsEnd");
}
performance.mark("limitsEnd");
performance.measure(`checking all limits took $1'`, "limitsStart", "limitsEnd");
return limitManager.getBusyTimes();
};
const getBusyTimesFromBookingLimits = async (
...args: Parameters<typeof _getBusyTimesFromBookingLimits>
): Promise<ReturnType<typeof _getBusyTimesFromBookingLimits>> => {
return monitorCallbackAsync(_getBusyTimesFromBookingLimits, ...args);
};
const _getBusyTimesFromBookingLimits = async (
bookings: EventBusyDetails[],
bookingLimits: IntervalLimit,
dateFrom: Dayjs,
dateTo: Dayjs,
eventTypeId: number,
limitManager: LimitManager
) => {
for (const key of descendingLimitKeys) {
const limit = bookingLimits?.[key];
if (!limit) continue;
const unit = intervalLimitKeyToUnit(key);
const periodStartDates = getPeriodStartDatesBetween(dateFrom, dateTo, unit);
for (const periodStart of periodStartDates) {
if (limitManager.isAlreadyBusy(periodStart, unit)) continue;
// special handling of yearly limits to improve performance
if (unit === "year") {
try {
await checkBookingLimit({
eventStartDate: periodStart.toDate(),
limitingNumber: limit,
eventId: eventTypeId,
key,
});
} catch (_) {
limitManager.addBusyTime(periodStart, unit);
if (periodStartDates.every((start) => limitManager.isAlreadyBusy(start, unit))) {
return;
}
}
continue;
}
const periodEnd = periodStart.endOf(unit);
let totalBookings = 0;
for (const booking of bookings) {
// consider booking part of period independent of end date
if (!dayjs(booking.start).isBetween(periodStart, periodEnd)) {
continue;
}
totalBookings++;
if (totalBookings >= limit) {
limitManager.addBusyTime(periodStart, unit);
break;
}
}
}
}
};
const getBusyTimesFromDurationLimits = async (
...args: Parameters<typeof _getBusyTimesFromDurationLimits>
): Promise<ReturnType<typeof _getBusyTimesFromDurationLimits>> => {
return monitorCallbackAsync(_getBusyTimesFromDurationLimits, ...args);
};
const _getBusyTimesFromDurationLimits = async (
bookings: EventBusyDetails[],
durationLimits: IntervalLimit,
dateFrom: Dayjs,
dateTo: Dayjs,
duration: number | undefined,
eventType: NonNullable<EventType>,
limitManager: LimitManager
) => {
for (const key of descendingLimitKeys) {
const limit = durationLimits?.[key];
if (!limit) continue;
const unit = intervalLimitKeyToUnit(key);
const periodStartDates = getPeriodStartDatesBetween(dateFrom, dateTo, unit);
for (const periodStart of periodStartDates) {
if (limitManager.isAlreadyBusy(periodStart, unit)) continue;
const selectedDuration = (duration || eventType.length) ?? 0;
if (selectedDuration > limit) {
limitManager.addBusyTime(periodStart, unit);
continue;
}
// special handling of yearly limits to improve performance
if (unit === "year") {
const totalYearlyDuration = await getTotalBookingDuration({
eventId: eventType.id,
startDate: periodStart.toDate(),
endDate: periodStart.endOf(unit).toDate(),
});
if (totalYearlyDuration + selectedDuration > limit) {
limitManager.addBusyTime(periodStart, unit);
if (periodStartDates.every((start) => limitManager.isAlreadyBusy(start, unit))) {
return;
}
}
continue;
}
const periodEnd = periodStart.endOf(unit);
let totalDuration = selectedDuration;
for (const booking of bookings) {
// consider booking part of period independent of end date
if (!dayjs(booking.start).isBetween(periodStart, periodEnd)) {
continue;
}
totalDuration += dayjs(booking.end).diff(dayjs(booking.start), "minute");
if (totalDuration > limit) {
limitManager.addBusyTime(periodStart, unit);
break;
}
}
}
}
};
interface GetUserAvailabilityParamsDTO {
userId: number;
dateFrom: Dayjs;
dateTo: Dayjs;
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;
};
}
const getOutOfOfficeDays = async (
...args: Parameters<typeof _getOutOfOfficeDays>
): Promise<ReturnType<typeof _getOutOfOfficeDays>> => {
return monitorCallbackAsync(_getOutOfOfficeDays, ...args);
};
const _getOutOfOfficeDays = async ({
userId,
dateFrom,
dateTo,
availability,
}: GetUserAvailabilityParamsDTO): Promise<IOutOfOfficeData> => {
const outOfOfficeDays = await prisma.outOfOfficeEntry.findMany({
where: {
userId,
OR: [
// outside of range
// (start <= 'dateTo' AND end >= 'dateFrom')
{
start: {
lte: dateTo.toISOString(),
},
end: {
gte: dateFrom.toISOString(),
},
},
// start is between dateFrom and dateTo but end is outside of range
// (start <= 'dateTo' AND end >= 'dateTo')
{
start: {
lte: dateTo.toISOString(),
},
end: {
gte: dateTo.toISOString(),
},
},
// end is between dateFrom and dateTo but start is outside of range
// (start <= 'dateFrom' OR end <= 'dateTo')
{
start: {
lte: dateFrom.toISOString(),
},
end: {
lte: dateTo.toISOString(),
},
},
],
},
select: {
id: true,
start: true,
end: true,
user: {
select: {
id: true,
name: true,
},
},
toUser: {
select: {
id: true,
username: true,
name: true,
},
},
reason: {
select: {
id: true,
emoji: true,
reason: true,
},
},
},
});
if (!outOfOfficeDays.length) {
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;
}, {});
};
type GetUserAvailabilityQuery = Parameters<typeof getUserAvailability>[0];
type GetUserAvailabilityInitialData = NonNullable<Parameters<typeof getUserAvailability>[1]>;
const _getUsersAvailability = async ({
users,
query,
initialData,
}: {
users: (NonNullable<GetUserAvailabilityInitialData["user"]> & {
currentBookings?: GetUserAvailabilityInitialData["currentBookings"];
})[];
query: Omit<GetUserAvailabilityQuery, "userId" | "username">;
initialData?: Omit<GetUserAvailabilityInitialData, "user">;
}) => {
return await Promise.all(
users.map((user) =>
_getUserAvailability(
{
...query,
userId: user.id,
username: user.username || "",
},
initialData
? {
...initialData,
user,
currentBookings: user.currentBookings,
}
: undefined
)
)
);
};
export const getUsersAvailability = async (
...args: Parameters<typeof _getUsersAvailability>
): Promise<ReturnType<typeof _getUsersAvailability>> => {
return monitorCallbackAsync(_getUsersAvailability, ...args);
};

View File

@ -0,0 +1,4 @@
export * from "./CalendarManager";
export * from "./EventManager";
export { default as getBusyTimes } from "./getBusyTimes";
export * from "./videoClient";

View File

@ -0,0 +1 @@
export * from "@calcom/app-store/locations";

View File

@ -0,0 +1,24 @@
{
"name": "@calcom/core",
"sideEffects": false,
"description": "Cal.com core functionality",
"version": "0.0.0",
"private": true,
"license": "MIT",
"main": "./index.ts",
"scripts": {
"clean": "rm -rf .turbo && rm -rf node_modules"
},
"dependencies": {
"@calcom/app-store": "*",
"@calcom/dayjs": "*",
"@calcom/lib": "*",
"ical.js": "^1.4.0",
"ics": "^2.37.0",
"uuid": "^8.3.2"
},
"devDependencies": {
"@calcom/tsconfig": "*",
"@calcom/types": "*"
}
}

View File

@ -0,0 +1,50 @@
import { startSpan, captureException } from "@sentry/nextjs";
/*
WHEN TO USE
We ran a script that performs a simple mathematical calculation within a loop of 1000000 iterations.
Our results were: Plain execution time: 441, Monitored execution time: 8094.
This suggests that using these wrappers within large loops can incur significant overhead and is thus not recommended.
For smaller loops, the cost incurred may not be very significant on an absolute scale
considering that a million monitored iterations only took roughly 8 seconds when monitored.
*/
const monitorCallbackAsync = async <T extends (...args: any[]) => any>(
cb: T,
...args: Parameters<T>
): Promise<ReturnType<T>> => {
// Check if Sentry set
if (!process.env.NEXT_PUBLIC_SENTRY_DSN) return (await cb(...args)) as ReturnType<T>;
return await startSpan({ name: cb.name }, async () => {
try {
const result = await cb(...args);
return result as ReturnType<T>;
} catch (error) {
captureException(error);
throw error;
}
});
};
const monitorCallbackSync = <T extends (...args: any[]) => any>(
cb: T,
...args: Parameters<T>
): ReturnType<T> => {
// Check if Sentry set
if (!process.env.NEXT_PUBLIC_SENTRY_DSN) return cb(...args) as ReturnType<T>;
return startSpan({ name: cb.name }, () => {
try {
const result = cb(...args);
return result as ReturnType<T>;
} catch (error) {
captureException(error);
throw error;
}
});
};
export default monitorCallbackAsync;
export { monitorCallbackSync };

View File

@ -0,0 +1,10 @@
{
"extends": "@calcom/tsconfig/base.json",
"compilerOptions": {
"baseUrl": ".",
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"]
},
"include": [".", "../types/*.d.ts"],
"exclude": ["dist", "build", "node_modules"]
}

View File

@ -0,0 +1,398 @@
import short from "short-uuid";
import { v5 as uuidv5 } from "uuid";
import appStore from "@calcom/app-store";
import { getDailyAppKeys } from "@calcom/app-store/dailyvideo/lib/getDailyAppKeys";
import { DailyLocationType } from "@calcom/app-store/locations";
import { sendBrokenIntegrationEmail } from "@calcom/emails";
import { getUid } from "@calcom/lib/CalEventParser";
import logger from "@calcom/lib/logger";
import { getPiiFreeCalendarEvent, getPiiFreeCredential } from "@calcom/lib/piiFreeData";
import { safeStringify } from "@calcom/lib/safeStringify";
import { prisma } from "@calcom/prisma";
import type { GetRecordingsResponseSchema } from "@calcom/prisma/zod-utils";
import type { CalendarEvent, EventBusyDate } from "@calcom/types/Calendar";
import type { CredentialPayload } from "@calcom/types/Credential";
import type { EventResult, PartialReference } from "@calcom/types/EventManager";
import type { VideoApiAdapter, VideoApiAdapterFactory, VideoCallData } from "@calcom/types/VideoApiAdapter";
const log = logger.getSubLogger({ prefix: ["[lib] videoClient"] });
const translator = short();
// factory
const getVideoAdapters = async (withCredentials: CredentialPayload[]): Promise<VideoApiAdapter[]> => {
const videoAdapters: VideoApiAdapter[] = [];
for (const cred of withCredentials) {
const appName = cred.type.split("_").join(""); // Transform `zoom_video` to `zoomvideo`;
log.silly("Getting video adapter for", safeStringify({ appName, cred: getPiiFreeCredential(cred) }));
const appImportFn = appStore[appName as keyof typeof appStore];
// Static Link Video Apps don't exist in packages/app-store/index.ts(it's manually maintained at the moment) and they aren't needed there anyway.
const app = appImportFn ? await appImportFn() : null;
if (!app) {
log.error(`Couldn't get adapter for ${appName}`);
continue;
}
if ("lib" in app && "VideoApiAdapter" in app.lib) {
const makeVideoApiAdapter = app.lib.VideoApiAdapter as VideoApiAdapterFactory;
const videoAdapter = makeVideoApiAdapter(cred);
videoAdapters.push(videoAdapter);
} else {
log.error(`App ${appName} doesn't have 'lib.VideoApiAdapter' defined`);
}
}
return videoAdapters;
};
const getBusyVideoTimes = async (withCredentials: CredentialPayload[]) =>
Promise.all((await getVideoAdapters(withCredentials)).map((c) => c?.getAvailability())).then((results) =>
results.reduce((acc, availability) => acc.concat(availability), [] as (EventBusyDate | undefined)[])
);
const createMeeting = async (credential: CredentialPayload, calEvent: CalendarEvent) => {
const uid: string = getUid(calEvent);
log.debug(
"createMeeting",
safeStringify({
credential: getPiiFreeCredential(credential),
uid,
calEvent: getPiiFreeCalendarEvent(calEvent),
})
);
if (!credential || !credential.appId) {
throw new Error(
"Credentials must be set! Video platforms are optional, so this method shouldn't even be called when no video credentials are set."
);
}
const videoAdapters = await getVideoAdapters([credential]);
const [firstVideoAdapter] = videoAdapters;
let createdMeeting;
let returnObject: {
appName: string;
type: string;
uid: string;
originalEvent: CalendarEvent;
success: boolean;
createdEvent: VideoCallData | undefined;
credentialId: number;
} = {
appName: credential.appId || "",
type: credential.type,
uid,
originalEvent: calEvent,
success: false,
createdEvent: undefined,
credentialId: credential.id,
};
try {
// Check to see if video app is enabled
const enabledApp = await prisma.app.findFirst({
where: {
slug: credential.appId,
},
select: {
enabled: true,
},
});
if (!enabledApp?.enabled)
throw `Location app ${credential.appId} is either disabled or not seeded at all`;
createdMeeting = await firstVideoAdapter?.createMeeting(calEvent);
returnObject = { ...returnObject, createdEvent: createdMeeting, success: true };
log.debug("created Meeting", safeStringify(returnObject));
} catch (err) {
await sendBrokenIntegrationEmail(calEvent, "video");
log.error(
"createMeeting failed",
safeStringify(err),
safeStringify({ calEvent: getPiiFreeCalendarEvent(calEvent) })
);
// Default to calVideo
const defaultMeeting = await createMeetingWithCalVideo(calEvent);
if (defaultMeeting) {
calEvent.location = DailyLocationType;
}
returnObject = { ...returnObject, originalEvent: calEvent, createdEvent: defaultMeeting };
}
return returnObject;
};
const updateMeeting = async (
credential: CredentialPayload,
calEvent: CalendarEvent,
bookingRef: PartialReference | null
): Promise<EventResult<VideoCallData>> => {
const uid = translator.fromUUID(uuidv5(JSON.stringify(calEvent), uuidv5.URL));
let success = true;
const [firstVideoAdapter] = await getVideoAdapters([credential]);
const canCallUpdateMeeting = !!(credential && bookingRef);
const updatedMeeting = canCallUpdateMeeting
? await firstVideoAdapter?.updateMeeting(bookingRef, calEvent).catch(async (e) => {
await sendBrokenIntegrationEmail(calEvent, "video");
log.error("updateMeeting failed", e, calEvent);
success = false;
return undefined;
})
: undefined;
if (!updatedMeeting) {
log.error(
"updateMeeting failed",
safeStringify({ bookingRef, canCallUpdateMeeting, calEvent, credential })
);
return {
appName: credential.appId || "",
type: credential.type,
success,
uid,
originalEvent: calEvent,
};
}
return {
appName: credential.appId || "",
type: credential.type,
success,
uid,
updatedEvent: updatedMeeting,
originalEvent: calEvent,
};
};
const deleteMeeting = async (credential: CredentialPayload | null, uid: string): Promise<unknown> => {
if (credential) {
const videoAdapter = (await getVideoAdapters([credential]))[0];
log.debug(
"Calling deleteMeeting for",
safeStringify({ credential: getPiiFreeCredential(credential), uid })
);
// There are certain video apps with no video adapter defined. e.g. riverby,whereby
if (videoAdapter) {
return videoAdapter.deleteMeeting(uid);
}
}
return Promise.resolve({});
};
// @TODO: This is a temporary solution to create a meeting with cal.com video as fallback url
const createMeetingWithCalVideo = async (calEvent: CalendarEvent) => {
let dailyAppKeys: Awaited<ReturnType<typeof getDailyAppKeys>>;
try {
dailyAppKeys = await getDailyAppKeys();
} catch (e) {
return;
}
const [videoAdapter] = await getVideoAdapters([
{
id: 0,
appId: "daily-video",
type: "daily_video",
userId: null,
user: { email: "" },
teamId: null,
key: dailyAppKeys,
invalid: false,
},
]);
return videoAdapter?.createMeeting(calEvent);
};
export const createInstantMeetingWithCalVideo = async (endTime: string) => {
let dailyAppKeys: Awaited<ReturnType<typeof getDailyAppKeys>>;
try {
dailyAppKeys = await getDailyAppKeys();
} catch (e) {
return;
}
const [videoAdapter] = await getVideoAdapters([
{
id: 0,
appId: "daily-video",
type: "daily_video",
userId: null,
user: { email: "" },
teamId: null,
key: dailyAppKeys,
invalid: false,
},
]);
return videoAdapter?.createInstantCalVideoRoom?.(endTime);
};
const getRecordingsOfCalVideoByRoomName = async (
roomName: string
): Promise<GetRecordingsResponseSchema | undefined> => {
let dailyAppKeys: Awaited<ReturnType<typeof getDailyAppKeys>>;
try {
dailyAppKeys = await getDailyAppKeys();
} catch (e) {
console.error("Error: Cal video provider is not installed.");
return;
}
const [videoAdapter] = await getVideoAdapters([
{
id: 0,
appId: "daily-video",
type: "daily_video",
userId: null,
user: { email: "" },
teamId: null,
key: dailyAppKeys,
invalid: false,
},
]);
return videoAdapter?.getRecordings?.(roomName);
};
const getDownloadLinkOfCalVideoByRecordingId = async (recordingId: string) => {
let dailyAppKeys: Awaited<ReturnType<typeof getDailyAppKeys>>;
try {
dailyAppKeys = await getDailyAppKeys();
} catch (e) {
console.error("Error: Cal video provider is not installed.");
return;
}
const [videoAdapter] = await getVideoAdapters([
{
id: 0,
appId: "daily-video",
type: "daily_video",
userId: null,
user: { email: "" },
teamId: null,
key: dailyAppKeys,
invalid: false,
},
]);
return videoAdapter?.getRecordingDownloadLink?.(recordingId);
};
const getAllTranscriptsAccessLinkFromRoomName = async (roomName: string) => {
let dailyAppKeys: Awaited<ReturnType<typeof getDailyAppKeys>>;
try {
dailyAppKeys = await getDailyAppKeys();
} catch (e) {
console.error("Error: Cal video provider is not installed.");
return;
}
const [videoAdapter] = await getVideoAdapters([
{
id: 0,
appId: "daily-video",
type: "daily_video",
userId: null,
user: { email: "" },
teamId: null,
key: dailyAppKeys,
invalid: false,
},
]);
return videoAdapter?.getAllTranscriptsAccessLinkFromRoomName?.(roomName);
};
const submitBatchProcessorTranscriptionJob = async (recordingId: string) => {
let dailyAppKeys: Awaited<ReturnType<typeof getDailyAppKeys>>;
try {
dailyAppKeys = await getDailyAppKeys();
} catch (e) {
console.error("Error: Cal video provider is not installed.");
return;
}
const [videoAdapter] = await getVideoAdapters([
{
id: 0,
appId: "daily-video",
type: "daily_video",
userId: null,
user: { email: "" },
teamId: null,
key: dailyAppKeys,
invalid: false,
},
]);
return videoAdapter?.submitBatchProcessorJob?.({
preset: "transcript",
inParams: {
sourceType: "recordingId",
recordingId: recordingId,
},
outParams: {
s3Config: {
s3KeyTemplate: "transcript",
},
},
});
};
const getTranscriptsAccessLinkFromRecordingId = async (recordingId: string) => {
let dailyAppKeys: Awaited<ReturnType<typeof getDailyAppKeys>>;
try {
dailyAppKeys = await getDailyAppKeys();
} catch (e) {
console.error("Error: Cal video provider is not installed.");
return;
}
const [videoAdapter] = await getVideoAdapters([
{
id: 0,
appId: "daily-video",
type: "daily_video",
userId: null,
user: { email: "" },
teamId: null,
key: dailyAppKeys,
invalid: false,
},
]);
return videoAdapter?.getTranscriptsAccessLinkFromRecordingId?.(recordingId);
};
const checkIfRoomNameMatchesInRecording = async (roomName: string, recordingId: string) => {
let dailyAppKeys: Awaited<ReturnType<typeof getDailyAppKeys>>;
try {
dailyAppKeys = await getDailyAppKeys();
} catch (e) {
console.error("Error: Cal video provider is not installed.");
return;
}
const [videoAdapter] = await getVideoAdapters([
{
id: 0,
appId: "daily-video",
type: "daily_video",
userId: null,
user: { email: "" },
teamId: null,
key: dailyAppKeys,
invalid: false,
},
]);
return videoAdapter?.checkIfRoomNameMatchesInRecording?.(roomName, recordingId);
};
export {
getBusyVideoTimes,
createMeeting,
updateMeeting,
deleteMeeting,
getRecordingsOfCalVideoByRoomName,
getDownloadLinkOfCalVideoByRecordingId,
getAllTranscriptsAccessLinkFromRoomName,
submitBatchProcessorTranscriptionJob,
getTranscriptsAccessLinkFromRecordingId,
checkIfRoomNameMatchesInRecording,
};