first commit
This commit is contained in:
38
calcom/packages/core/CalendarManager.test.ts
Normal file
38
calcom/packages/core/CalendarManager.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
414
calcom/packages/core/CalendarManager.ts
Normal file
414
calcom/packages/core/CalendarManager.ts
Normal 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({});
|
||||
};
|
1040
calcom/packages/core/EventManager.ts
Normal file
1040
calcom/packages/core/EventManager.ts
Normal file
File diff suppressed because it is too large
Load Diff
273
calcom/packages/core/builders/CalendarEvent/builder.ts
Normal file
273
calcom/packages/core/builders/CalendarEvent/builder.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
43
calcom/packages/core/builders/CalendarEvent/class.ts
Normal file
43
calcom/packages/core/builders/CalendarEvent/class.ts
Normal 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 };
|
73
calcom/packages/core/builders/CalendarEvent/director.ts
Normal file
73
calcom/packages/core/builders/CalendarEvent/director.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
20
calcom/packages/core/components/NoSSR.tsx
Normal file
20
calcom/packages/core/components/NoSSR.tsx
Normal 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;
|
98
calcom/packages/core/crmManager/crmManager.test.ts
Normal file
98
calcom/packages/core/crmManager/crmManager.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
68
calcom/packages/core/crmManager/crmManager.ts
Normal file
68
calcom/packages/core/crmManager/crmManager.ts
Normal 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;
|
||||
}
|
||||
}
|
394
calcom/packages/core/event.test.ts
Normal file
394
calcom/packages/core/event.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
140
calcom/packages/core/event.ts
Normal file
140
calcom/packages/core/event.ts
Normal 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;
|
||||
};
|
340
calcom/packages/core/getAggregateWorkingHours.test.ts
Normal file
340
calcom/packages/core/getAggregateWorkingHours.test.ts
Normal 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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
60
calcom/packages/core/getAggregateWorkingHours.ts
Normal file
60
calcom/packages/core/getAggregateWorkingHours.ts
Normal 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);
|
||||
};
|
@ -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()
|
||||
);
|
||||
}
|
@ -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),
|
||||
};
|
||||
}
|
37
calcom/packages/core/getAggregatedAvailability/index.ts
Normal file
37
calcom/packages/core/getAggregatedAvailability/index.ts
Normal 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);
|
||||
};
|
368
calcom/packages/core/getBusyTimes.ts
Normal file
368
calcom/packages/core/getBusyTimes.ts
Normal 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;
|
210
calcom/packages/core/getCalendarsEvents.test.ts
Normal file
210
calcom/packages/core/getCalendarsEvents.test.ts
Normal 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",
|
||||
})),
|
||||
]);
|
||||
});
|
||||
});
|
78
calcom/packages/core/getCalendarsEvents.ts
Normal file
78
calcom/packages/core/getCalendarsEvents.ts
Normal 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;
|
885
calcom/packages/core/getUserAvailability.ts
Normal file
885
calcom/packages/core/getUserAvailability.ts
Normal 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);
|
||||
};
|
4
calcom/packages/core/index.ts
Normal file
4
calcom/packages/core/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export * from "./CalendarManager";
|
||||
export * from "./EventManager";
|
||||
export { default as getBusyTimes } from "./getBusyTimes";
|
||||
export * from "./videoClient";
|
1
calcom/packages/core/location.ts
Normal file
1
calcom/packages/core/location.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "@calcom/app-store/locations";
|
24
calcom/packages/core/package.json
Normal file
24
calcom/packages/core/package.json
Normal 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": "*"
|
||||
}
|
||||
}
|
50
calcom/packages/core/sentryWrapper.ts
Normal file
50
calcom/packages/core/sentryWrapper.ts
Normal 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 };
|
10
calcom/packages/core/tsconfig.json
Normal file
10
calcom/packages/core/tsconfig.json
Normal 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"]
|
||||
}
|
398
calcom/packages/core/videoClient.ts
Normal file
398
calcom/packages/core/videoClient.ts
Normal 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,
|
||||
};
|
Reference in New Issue
Block a user