Files
calendar/packages/lib/EventManager.ts
T

1315 lines
48 KiB
TypeScript

import type { DestinationCalendar, BookingReference } from "@prisma/client";
// eslint-disable-next-line no-restricted-imports
import { cloneDeep, merge } from "lodash";
import { v5 as uuidv5 } from "uuid";
import type { z } from "zod";
import { getCalendar } from "@calcom/app-store/_utils/getCalendar";
import { FAKE_DAILY_CREDENTIAL } from "@calcom/app-store/dailyvideo/lib/VideoApiAdapter";
import { appKeysSchema as calVideoKeysSchema } from "@calcom/app-store/dailyvideo/zod";
import { getLocationFromApp, MeetLocationType, MSTeamsLocationType } from "@calcom/app-store/locations";
import getApps from "@calcom/app-store/utils";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { getUid } from "@calcom/lib/CalEventParser";
import CRMScheduler from "@calcom/lib/crmManager/tasker/crmScheduler";
import { symmetricDecrypt } from "@calcom/lib/crypto";
import logger from "@calcom/lib/logger";
import {
getPiiFreeDestinationCalendar,
getPiiFreeUser,
getPiiFreeCredential,
getPiiFreeCalendarEvent,
} from "@calcom/lib/piiFreeData";
import { safeStringify } from "@calcom/lib/safeStringify";
import { CredentialRepository } from "@calcom/lib/server/repository/credential";
import prisma from "@calcom/prisma";
import { createdEventSchema } from "@calcom/prisma/zod-utils";
import type { EventTypeAppMetadataSchema } from "@calcom/prisma/zod-utils";
import type { AdditionalInformation, CalendarEvent, NewCalendarEventType } from "@calcom/types/Calendar";
import type { CredentialForCalendarService } from "@calcom/types/Credential";
import type { Event } from "@calcom/types/Event";
import type {
CreateUpdateResult,
EventResult,
PartialBooking,
PartialReference,
} from "@calcom/types/EventManager";
import { createEvent, updateEvent, deleteEvent } from "./CalendarManager";
import CrmManager from "./crmManager/crmManager";
import { isDelegationCredential } from "./delegationCredential/clientAndServer";
import { createMeeting, updateMeeting, deleteMeeting } from "./videoClient";
const log = logger.getSubLogger({ prefix: ["EventManager"] });
const CALENDSO_ENCRYPTION_KEY = process.env.CALENDSO_ENCRYPTION_KEY || "";
const CALDAV_CALENDAR_TYPE = "caldav_calendar";
export const isDedicatedIntegration = (location: string): boolean => {
return location !== MeetLocationType && location.includes("integrations:");
};
interface HasId {
id: number;
}
const latestCredentialFirst = <T extends HasId>(a: T, b: T) => {
return b.id - a.id;
};
const delegatedCredentialFirst = <T extends { delegatedToId?: string | null }>(a: T, b: T) => {
return (b.delegatedToId ? 1 : 0) - (a.delegatedToId ? 1 : 0);
};
const delegatedCredentialLast = <T extends { delegatedToId?: string | null }>(a: T, b: T) => {
return (a.delegatedToId ? 1 : 0) - (b.delegatedToId ? 1 : 0);
};
export const getLocationRequestFromIntegration = (location: string) => {
const eventLocationType = getLocationFromApp(location);
if (eventLocationType) {
const requestId = uuidv5(location, uuidv5.URL);
return {
conferenceData: {
createRequest: {
requestId: requestId,
},
},
location,
};
}
return null;
};
const getCredential = ({
id,
allCredentials,
}: {
id: {
delegationCredentialId: string | null;
credentialId: number | null;
};
allCredentials: CredentialForCalendarService[];
}) => {
return id.delegationCredentialId
? allCredentials.find((c) => c.delegatedToId === id.delegationCredentialId)
: allCredentials.find((c) => c.id === id.credentialId);
};
export const processLocation = (event: CalendarEvent): CalendarEvent => {
// If location is set to an integration location
// Build proper transforms for evt object
// Extend evt object with those transformations
// TODO: Rely on linkType:"dynamic" here. static links don't send their type. They send their URL directly.
if (event.location?.includes("integration")) {
const maybeLocationRequestObject = getLocationRequestFromIntegration(event.location);
event = merge(event, maybeLocationRequestObject);
}
return event;
};
/**
* Ensures invalid non-delegationCredentialId isn't returned
*/
function getCredentialPayload(result: EventResult<Exclude<Event, AdditionalInformation>>) {
return {
credentialId: result?.credentialId && result.credentialId > 0 ? result.credentialId : undefined,
delegationCredentialId: result?.delegatedToId || undefined,
};
}
export type EventManagerUser = {
credentials: CredentialForCalendarService[];
destinationCalendar: DestinationCalendar | null;
};
type createdEventSchema = z.infer<typeof createdEventSchema>;
export type EventManagerInitParams = {
user: EventManagerUser;
eventTypeAppMetadata?: z.infer<typeof EventTypeAppMetadataSchema>;
};
export default class EventManager {
calendarCredentials: CredentialForCalendarService[];
videoCredentials: CredentialForCalendarService[];
crmCredentials: CredentialForCalendarService[];
appOptions?: z.infer<typeof EventTypeAppMetadataSchema>;
/**
* Takes an array of credentials and initializes a new instance of the EventManager.
*
* @param user
*/
constructor(user: EventManagerUser, eventTypeAppMetadata?: z.infer<typeof EventTypeAppMetadataSchema>) {
log.silly("Initializing EventManager", safeStringify({ user: getPiiFreeUser(user) }));
const appCredentials = getApps(user.credentials, true).flatMap((app) =>
app.credentials.map((creds) => ({ ...creds, appName: app.name }))
);
// This includes all calendar-related apps, traditional calendars such as Google Calendar
this.calendarCredentials = appCredentials
.filter(
// Backwards compatibility until CRM manager is implemented
(cred) => cred.type.endsWith("_calendar") && !cred.type.includes("other_calendar")
)
// see https://github.com/calcom/cal.com/issues/11671#issue-1923600672
// This sorting is mostly applicable for fallback which happens when there is no explicit destinationCalendar set.
// That could be true for really old accounts but not for new
.sort(latestCredentialFirst)
// Keep Delegation Credentials first so because those credentials never expire and are preferred.
// Also, those credentials have consistent permission for all the members avoiding the scenario where user doesn't give all permissions
.sort(delegatedCredentialFirst);
this.videoCredentials = appCredentials
.filter((cred) => cred.type.endsWith("_video") || cred.type.endsWith("_conferencing"))
// Whenever a new video connection is added, latest credentials are added with the highest ID.
// Because you can't rely on having them in the highest first order here, ensure this by sorting in DESC order
// We also don't have updatedAt or createdAt dates on credentials so this is the best we can do
.sort(latestCredentialFirst);
this.crmCredentials = appCredentials.filter(
(cred) => cred.type.endsWith("_crm") || cred.type.endsWith("_other_calendar")
);
this.appOptions = eventTypeAppMetadata;
}
private extractServerUrlFromCredential(credential: CredentialForCalendarService): string | null {
try {
if (credential.type !== CALDAV_CALENDAR_TYPE) {
return null;
}
const decryptedData = JSON.parse(symmetricDecrypt(credential.key as string, CALENDSO_ENCRYPTION_KEY));
if (!decryptedData.url) {
return null;
}
const url = new URL(decryptedData.url);
return `${url.protocol}//${url.host}`;
} catch (error) {
log.warn("Failed to extract server URL from CalDAV credential", {
credentialId: credential.id,
error: error instanceof Error ? error.message : "Unknown error",
});
return null;
}
}
private extractServerUrlFromDestination(destination: DestinationCalendar): string | null {
try {
if (destination.integration !== CALDAV_CALENDAR_TYPE || !destination.externalId) {
return null;
}
const url = new URL(destination.externalId);
return `${url.protocol}//${url.host}`;
} catch (error) {
log.warn("Failed to extract server URL from destination calendar", {
destinationId: destination.id,
externalId: destination.externalId,
error: error instanceof Error ? error.message : "Unknown error",
});
return null;
}
}
private credentialMatchesDestination(
credential: CredentialForCalendarService,
destination: DestinationCalendar
): boolean {
if (credential.type !== CALDAV_CALENDAR_TYPE || destination.integration !== CALDAV_CALENDAR_TYPE) {
return true;
}
const credentialServerUrl = this.extractServerUrlFromCredential(credential);
const destinationServerUrl = this.extractServerUrlFromDestination(destination);
if (!credentialServerUrl || !destinationServerUrl) {
log.warn("Could not extract server URLs for CalDAV credential validation", {
credentialId: credential.id,
destinationId: destination.id,
credentialServerUrl,
destinationServerUrl,
});
return false;
}
const matches = credentialServerUrl === destinationServerUrl;
if (!matches) {
log.warn("CalDAV credential server URL does not match destination calendar server URL", {
credentialId: credential.id,
destinationId: destination.id,
credentialServerUrl,
destinationServerUrl,
});
}
return matches;
}
private updateMSTeamsVideoCallData(
evt: CalendarEvent,
results: Array<EventResult<Exclude<Event, AdditionalInformation>>>
) {
const office365CalendarWithTeams = results.find(
(result) => result.type === "office365_calendar" && result.success && result.createdEvent?.url
);
if (office365CalendarWithTeams) {
evt.videoCallData = {
type: "office365_video",
id: office365CalendarWithTeams.createdEvent?.id,
password: "",
url: office365CalendarWithTeams.createdEvent?.url,
};
if (evt.location && evt.responses) {
evt.responses["location"] = {
...(evt.responses["location"] ?? {}),
value: {
optionValue: "",
value: evt.location,
},
};
}
}
}
/**
* Takes a CalendarEvent and creates all necessary integration entries for it.
* When a video integration is chosen as the event's location, a video integration
* event will be scheduled for it as well.
*
* @param event
*/
public async create(event: CalendarEvent): Promise<CreateUpdateResult> {
// TODO this method shouldn't be modifying the event object that's passed in
const evt = processLocation(event);
// Fallback to cal video if no location is set
if (!evt.location) {
// See if cal video is enabled & has keys
const calVideo = await prisma.app.findUnique({
where: {
slug: "daily-video",
},
select: {
keys: true,
enabled: true,
},
});
const calVideoKeys = calVideoKeysSchema.safeParse(calVideo?.keys);
if (calVideo?.enabled && calVideoKeys.success) evt["location"] = "integrations:daily";
log.warn("Falling back to cal video as no location is set");
}
const [mainHostDestinationCalendar] =
(evt.destinationCalendar as [undefined | NonNullable<typeof evt.destinationCalendar>[number]]) ?? [];
// Fallback to Cal Video if Google Meet is selected w/o a Google Calendar connection
if (evt.location === MeetLocationType && mainHostDestinationCalendar?.integration !== "google_calendar") {
const [googleCalendarCredential] = this.calendarCredentials.filter(
(cred) => cred.type === "google_calendar"
);
// Delegation Credential case won't normally have DestinationCalendar set and thus fallback of using Google Calendar credential would be used. Identify that case.
// TODO: We could extend this logic to Regular Credentials also. Having a Google Calendar credential would cause fallback to use that credential to create calendar and thus we could have Google Meet link
if (!isDelegationCredential({ credentialId: googleCalendarCredential?.id })) {
log.warn(
"Falling back to Cal Video integration for Regular Credential as Google Calendar is not set as destination calendar"
);
evt["location"] = "integrations:daily";
evt["conferenceCredentialId"] = undefined;
}
}
const isDedicated = evt.location ? isDedicatedIntegration(evt.location) : null;
const isMSTeamsWithOutlookCalendar =
evt.location === MSTeamsLocationType &&
mainHostDestinationCalendar?.integration === "office365_calendar";
const results: Array<EventResult<Exclude<Event, AdditionalInformation>>> = [];
// If and only if event type is a dedicated meeting, create a dedicated video meeting.
// If the event is a Microsoft Teams meeting with Outlook Calendar, do not create a MSTeams video event, create calendar event will take care.
if (isDedicated && !isMSTeamsWithOutlookCalendar) {
const result = await this.createVideoEvent(evt);
if (result?.createdEvent) {
evt.videoCallData = result.createdEvent;
evt.location = result.originalEvent.location;
result.type = result.createdEvent.type;
//responses data is later sent to webhook
if (evt.location && evt.responses) {
evt.responses["location"] = {
...(evt.responses["location"] ?? {}),
value: {
optionValue: "",
value: evt.location,
},
};
}
}
results.push(result);
}
// Some calendar libraries may edit the original event so let's clone it
const clonedCalEvent = cloneDeep(event);
// Create the calendar event with the proper video call data
results.push(...(await this.createAllCalendarEvents(clonedCalEvent)));
if (evt.location === MSTeamsLocationType) {
this.updateMSTeamsVideoCallData(evt, results);
}
// Since the result can be a new calendar event or video event, we have to create a type guard
// https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates
const isCalendarResult = (
result: (typeof results)[number]
): result is EventResult<NewCalendarEventType> => {
return result.type.includes("_calendar");
};
const createdCRMEvents = await this.createAllCRMEvents(evt);
results.push(...createdCRMEvents);
// References can be any type: calendar/video
const referencesToCreate = results.map((result) => {
let thirdPartyRecurringEventId;
let createdEventObj: createdEventSchema | null = null;
if (typeof result?.createdEvent === "string") {
createdEventObj = createdEventSchema.parse(JSON.parse(result.createdEvent));
}
const isCalendarType = isCalendarResult(result);
if (isCalendarType) {
evt.iCalUID = result.iCalUID || event.iCalUID || undefined;
thirdPartyRecurringEventId = result.createdEvent?.thirdPartyRecurringEventId;
}
return {
type: result.type,
uid: createdEventObj ? createdEventObj.id : result.createdEvent?.id?.toString() ?? "",
thirdPartyRecurringEventId: isCalendarType ? thirdPartyRecurringEventId : undefined,
meetingId: createdEventObj ? createdEventObj.id : result.createdEvent?.id?.toString(),
meetingPassword: createdEventObj ? createdEventObj.password : result.createdEvent?.password,
meetingUrl: createdEventObj ? createdEventObj.onlineMeetingUrl : result.createdEvent?.url,
externalCalendarId: isCalendarType ? result.externalId : undefined,
...getCredentialPayload(result),
};
});
return {
results,
referencesToCreate,
};
}
public async updateLocation(event: CalendarEvent, booking: PartialBooking): Promise<CreateUpdateResult> {
const evt = processLocation(event);
const isDedicated = evt.location ? isDedicatedIntegration(evt.location) : null;
const results: Array<EventResult<Exclude<Event, AdditionalInformation>>> = [];
// If and only if event type is a dedicated meeting, create a dedicated video meeting.
if (isDedicated) {
const result = await this.createVideoEvent(evt);
if (result.createdEvent) {
evt.videoCallData = result.createdEvent;
evt.location = result.originalEvent.location;
result.type = result.createdEvent.type;
//responses data is later sent to webhook
if (evt.location && evt.responses) {
evt.responses["location"] = {
...(evt.responses["location"] ?? {}),
value: {
optionValue: "",
value: evt.location,
},
};
}
}
results.push(result);
}
// Update the calendar event with the proper video call data
const calendarReference = booking.references.find((reference) => reference.type.includes("_calendar"));
if (calendarReference) {
results.push(...(await this.updateAllCalendarEvents(evt, booking)));
if (evt.location === MSTeamsLocationType) {
this.updateMSTeamsVideoCallData(evt, results);
}
}
const referencesToCreate = results.map((result) => {
// For update operations, check updatedEvent first, then fall back to createdEvent
const updatedEvent = Array.isArray(result.updatedEvent) ? result.updatedEvent[0] : result.updatedEvent;
const createdEvent = result.createdEvent;
let event = updatedEvent;
if (!event) {
log.warn(
"updateLocation: No updatedEvent when doing updateLocation. Falling back to createdEvent but this is probably not what we want",
safeStringify({ bookingId: booking.id })
);
event = createdEvent;
}
const uid = event?.id?.toString() ?? "";
const meetingId = event?.id?.toString();
if (!uid) {
log.error(
"updateLocation: No uid for booking reference. The corresponding record in third party if created is orphan now",
safeStringify({ result })
);
}
return {
type: result.type,
uid,
meetingId,
meetingPassword: event?.password,
meetingUrl: event?.url,
externalCalendarId: result.externalId,
...(result.credentialId && result.credentialId > 0 ? { credentialId: result.credentialId } : {}),
};
});
return {
results,
referencesToCreate,
};
}
private async deleteCalendarEventForBookingReference({
reference,
event,
isBookingInRecurringSeries,
}: {
reference: PartialReference;
event: CalendarEvent;
isBookingInRecurringSeries?: boolean;
}) {
log.debug(
"deleteCalendarEventForBookingReference",
safeStringify({ bookingCalendarReference: reference, event: getPiiFreeCalendarEvent(event) })
);
const {
// uid: bookingRefUid,
externalCalendarId: bookingExternalCalendarId,
credentialId,
type: credentialType,
} = reference;
const bookingRefUid =
isBookingInRecurringSeries && reference?.thirdPartyRecurringEventId
? reference.thirdPartyRecurringEventId
: reference.uid;
const calendarCredential = await this.getCredentialAndWarnIfNotFound(
credentialId,
this.calendarCredentials,
credentialType,
reference.delegationCredentialId
);
if (calendarCredential) {
await deleteEvent({
credential: calendarCredential,
bookingRefUid,
event,
externalCalendarId: bookingExternalCalendarId,
});
}
}
private async deleteVideoEventForBookingReference({ reference }: { reference: PartialReference }) {
log.debug("deleteVideoEventForBookingReference", safeStringify({ bookingVideoReference: reference }));
const { uid: bookingRefUid, credentialId } = reference;
const videoCredential = await this.getCredentialAndWarnIfNotFound(
credentialId,
this.videoCredentials,
reference.type
);
if (videoCredential) {
await deleteMeeting(videoCredential, bookingRefUid);
}
}
private async getCredentialAndWarnIfNotFound(
credentialId: number | null | undefined,
credentials: CredentialForCalendarService[],
type: string,
delegationCredentialId?: string | null
) {
if (delegationCredentialId) {
return this.calendarCredentials.find((cred) => cred.delegatedToId === delegationCredentialId);
}
const credential = credentials.find((cred) => cred.id === credentialId);
if (credential) {
return credential;
} else {
const credential =
typeof credentialId === "number" && credentialId > 0
? await CredentialRepository.findCredentialForCalendarServiceById({ id: credentialId })
: // Fallback for zero or nullish credentialId which could be the case of Global App e.g. dailyVideo
this.videoCredentials.find((cred) => cred.type === type) ||
this.calendarCredentials.find((cred) => cred.type === type) ||
null;
if (!credential) {
log.error(
"getCredentialAndWarnIfNotFound: Could not find credential",
safeStringify({
credentialId,
type,
videoCredentials: this.videoCredentials,
})
);
}
return credential;
}
}
/**
* Takes a calendarEvent and a rescheduleUid and updates the event that has the
* given uid using the data delivered in the given CalendarEvent.
*
* @param event
*/
public async reschedule(
event: CalendarEvent,
rescheduleUid: string,
newBookingId?: number,
changedOrganizer?: boolean,
previousHostDestinationCalendar?: DestinationCalendar[] | null,
isBookingRequestedReschedule?: boolean,
skipDeleteEventsAndMeetings?: boolean
): Promise<CreateUpdateResult> {
const originalEvt = processLocation(event);
const evt = cloneDeep(originalEvt);
if (!rescheduleUid) {
throw new Error("You called eventManager.update without an `rescheduleUid`. This should never happen.");
}
// Get details of existing booking.
const booking = await prisma.booking.findUnique({
where: {
uid: rescheduleUid,
},
select: {
id: true,
userId: true,
attendees: true,
location: true,
endTime: true,
references: {
where: {
deleted: null,
},
// NOTE: id field removed from select as we don't require for deletingMany
// but was giving error on recreate for reschedule, probably because promise.all() didn't finished
select: {
type: true,
uid: true,
meetingId: true,
meetingPassword: true,
meetingUrl: true,
externalCalendarId: true,
credentialId: true,
},
},
destinationCalendar: true,
payment: true,
eventType: {
select: {
seatsPerTimeSlot: true,
seatsShowAttendees: true,
seatsShowAvailabilityCount: true,
},
},
},
});
if (!booking) {
throw new Error("booking not found");
}
const results: Array<EventResult<Event>> = [];
const updatedBookingReferences: Array<PartialReference> = [];
const isLocationChanged = !!evt.location && !!booking.location && evt.location !== booking.location;
let isDailyVideoRoomExpired = false;
if (evt.location === "integrations:daily") {
const originalBookingEndTime = new Date(booking.endTime);
const roomExpiryTime = new Date(originalBookingEndTime.getTime() + 14 * 24 * 60 * 60 * 1000);
const now = new Date();
isDailyVideoRoomExpired = now > roomExpiryTime;
}
const shouldUpdateBookingReferences =
!!changedOrganizer || isLocationChanged || !!isBookingRequestedReschedule || isDailyVideoRoomExpired;
if (evt.requiresConfirmation && !skipDeleteEventsAndMeetings) {
log.debug("RescheduleRequiresConfirmation: Deleting Event and Meeting for previous booking");
// As the reschedule requires confirmation, we can't update the events and meetings to new time yet. So, just delete them and let it be handled when organizer confirms the booking.
await this.deleteEventsAndMeetings({
event: { ...event, destinationCalendar: previousHostDestinationCalendar },
bookingReferences: booking.references,
});
} else {
if (changedOrganizer) {
if (!skipDeleteEventsAndMeetings) {
log.debug("RescheduleOrganizerChanged: Deleting Event and Meeting for previous booking");
await this.deleteEventsAndMeetings({
event: { ...event, destinationCalendar: previousHostDestinationCalendar },
bookingReferences: booking.references,
});
}
log.debug("RescheduleOrganizerChanged: Creating Event and Meeting for for new booking");
const createdEvent = await this.create(originalEvt);
results.push(...createdEvent.results);
updatedBookingReferences.push(...createdEvent.referencesToCreate);
} else {
// If the reschedule doesn't require confirmation, we can "update" the events and meetings to new time.
if (isLocationChanged || isBookingRequestedReschedule || isDailyVideoRoomExpired) {
const updatedLocation = await this.updateLocation(evt, booking);
results.push(...updatedLocation.results);
updatedBookingReferences.push(...updatedLocation.referencesToCreate);
} else {
const isDedicated = evt.location ? isDedicatedIntegration(evt.location) : null;
// If and only if event type is a dedicated meeting, update the dedicated video meeting.
if (isDedicated) {
const result = await this.updateVideoEvent(evt, booking);
const [updatedEvent] = Array.isArray(result.updatedEvent)
? result.updatedEvent
: [result.updatedEvent];
if (updatedEvent) {
evt.videoCallData = updatedEvent;
evt.location = updatedEvent.url;
}
results.push(result);
}
const bookingCalendarReference = booking.references.find((reference) =>
reference.type.includes("_calendar")
);
// There was a case that booking didn't had any reference and we don't want to throw error on function
if (bookingCalendarReference) {
// Update all calendar events.
results.push(...(await this.updateAllCalendarEvents(evt, booking, newBookingId)));
}
}
results.push(...(await this.updateAllCRMEvents(evt, booking)));
}
}
const bookingPayment = booking?.payment;
// Updating all payment to new
if (bookingPayment && newBookingId) {
const paymentIds = bookingPayment.map((payment) => payment.id);
await prisma.payment.updateMany({
where: {
id: {
in: paymentIds,
},
},
data: {
bookingId: newBookingId,
},
});
}
return {
results,
referencesToCreate: shouldUpdateBookingReferences ? updatedBookingReferences : [...booking.references],
};
}
public async cancelEvent(
event: CalendarEvent,
bookingReferences: Pick<
BookingReference,
"uid" | "type" | "externalCalendarId" | "credentialId" | "thirdPartyRecurringEventId"
>[],
isBookingInRecurringSeries?: boolean
) {
await this.deleteEventsAndMeetings({
event,
bookingReferences,
isBookingInRecurringSeries,
});
}
public async deleteEventsAndMeetings({
event,
bookingReferences,
isBookingInRecurringSeries,
}: {
event: CalendarEvent;
bookingReferences: PartialReference[];
isBookingInRecurringSeries?: boolean;
}) {
const log = logger.getSubLogger({ prefix: [`[deleteEventsAndMeetings]: ${event?.uid}`] });
const calendarReferences = [],
videoReferences = [],
crmReferences = [],
allPromises = [];
for (const reference of bookingReferences) {
if (reference.type.includes("_calendar") && !reference.type.includes("other_calendar")) {
calendarReferences.push(reference);
allPromises.push(
this.deleteCalendarEventForBookingReference({
reference,
event,
isBookingInRecurringSeries,
})
);
}
if (reference.type.includes("_video")) {
videoReferences.push(reference);
allPromises.push(
this.deleteVideoEventForBookingReference({
reference,
})
);
}
if (reference.type.includes("_crm") || reference.type.includes("other_calendar")) {
crmReferences.push(reference);
allPromises.push(this.deleteCRMEvent({ reference, event }));
}
}
log.debug("deleteEventsAndMeetings", safeStringify({ calendarReferences, videoReferences }));
// Using allSettled to ensure that if one of the promises rejects, the others will still be executed.
// Because we are just cleaning up the events and meetings, we don't want to throw an error if one of them fails.
(await Promise.allSettled(allPromises)).some((result) => {
if (result.status === "rejected") {
// Make it a soft error because in case a PENDING booking is rescheduled there would be no calendar events or video meetings.
log.warn(
"Error deleting calendar event or video meeting for booking",
safeStringify({ error: result.reason })
);
}
});
if (!allPromises.length) {
log.warn("No calendar or video references found for booking - Couldn't delete events or meetings");
}
}
public async updateCalendarAttendees(event: CalendarEvent, booking: PartialBooking) {
if (booking.references.length === 0) {
console.error("Tried to update references but there wasn't any.");
return;
}
await this.updateAllCalendarEvents(event, booking);
}
/**
* Creates event entries for all calendar integrations given in the credentials.
* When noMail is true, no mails will be sent. This is used when the event is
* a video meeting because then the mail containing the video credentials will be
* more important than the mails created for these bare calendar events.
*
* When the optional uid is set, it will be used instead of the auto generated uid.
*
* @param event
* @param noMail
* @private
*/
private async createAllCalendarEvents(event: CalendarEvent) {
let createdEvents: EventResult<NewCalendarEventType>[] = [];
const fallbackToFirstCalendarInTheList = async () => {
const [credential] = this.calendarCredentials.filter((cred) => !cred.type.endsWith("other_calendar"));
if (credential) {
if (!isDelegationCredential({ credentialId: credential.id })) {
log.warn("Check the User setup, it isn't normal to fallback for regular credential.");
} else {
// It is completely normal to fallback for delegation credential as in that case by default no destination calendar would be set.
}
const createdEvent = await createEvent(credential, event);
log.silly("Created Calendar event using credential", safeStringify({ credential, createdEvent }));
if (createdEvent) {
createdEvents.push(createdEvent);
}
}
};
if (event.destinationCalendar && event.destinationCalendar.length > 0) {
let eventCreated = false;
// Since GCal pushes events to multiple calendars we only want to create one event per booking
let gCalAdded = false;
const destinationCalendars: DestinationCalendar[] = event.destinationCalendar.reduce(
(destinationCals, cal) => {
if (cal.integration === "google_calendar") {
if (gCalAdded) {
return destinationCals;
} else {
gCalAdded = true;
destinationCals.push(cal);
}
} else {
destinationCals.push(cal);
}
return destinationCals;
},
[] as DestinationCalendar[]
);
for (const destination of destinationCalendars) {
if (eventCreated) break;
log.silly("Creating Calendar event", JSON.stringify({ destination }));
if (destination.credentialId || destination.delegationCredentialId) {
let credential = getCredential({
id: {
credentialId: destination.credentialId,
delegationCredentialId: destination.delegationCredentialId,
},
allCredentials: this.calendarCredentials,
});
if (!credential) {
if (destination.credentialId) {
// Fetch credential from DB
const credentialFromDB = await CredentialRepository.findCredentialForCalendarServiceById({
id: destination.credentialId,
});
if (credentialFromDB && credentialFromDB.appId) {
credential = {
id: credentialFromDB.id,
type: credentialFromDB.type,
key: credentialFromDB.key,
userId: credentialFromDB.userId,
teamId: credentialFromDB.teamId,
invalid: credentialFromDB.invalid,
appId: credentialFromDB.appId,
user: credentialFromDB.user,
delegatedToId: credentialFromDB.delegatedToId,
delegatedTo: credentialFromDB.delegatedTo,
delegationCredentialId: credentialFromDB.delegationCredentialId,
};
}
} else if (destination.delegationCredentialId) {
log.warn(
"DelegationCredential: DelegationCredential seems to be disabled, falling back to first non-delegationCredential"
);
// In case DelegationCredential is disabled, we land here where the destination calendar is connected to a Delegation credential, but the credential isn't available(because DelegationCredential is disabled)
// In this case, we fallback to the first non-delegationCredential. That would be there for all existing users before DelegationCredential was enabled
const firstNonDelegatedCalendarCredential = this.calendarCredentials.find(
(cred) => !cred.type.endsWith("other_calendar") && !cred.delegatedToId
);
credential = firstNonDelegatedCalendarCredential;
}
}
if (credential) {
const createdEvent = await createEvent(credential, event, destination.externalId);
if (createdEvent) {
createdEvents.push(createdEvent);
eventCreated = true;
}
}
} else {
const destinationCalendarCredentials = this.calendarCredentials.filter((c) => {
if (c.type !== destination.integration) return false;
if (c.type === CALDAV_CALENDAR_TYPE) {
return this.credentialMatchesDestination(c, destination);
}
return true;
});
// It might not be the first connected calendar as it seems that the order is not guaranteed to be ascending of credentialId.
const firstCalendarCredential = destinationCalendarCredentials[0] as
| (typeof destinationCalendarCredentials)[number]
| undefined;
if (!firstCalendarCredential) {
if (destination.integration === CALDAV_CALENDAR_TYPE) {
log.warn(
"No CalDAV credentials found with matching server URL for destination calendar. This prevents credential leakage.",
safeStringify({
destination: getPiiFreeDestinationCalendar(destination),
})
);
} else {
log.warn(
"No other credentials found of the same type as the destination calendar. Falling back to first connected calendar"
);
}
await fallbackToFirstCalendarInTheList();
} else {
log.warn(
"No credentialId found for destination calendar, falling back to first found calendar of same type as destination calendar",
safeStringify({
destination: getPiiFreeDestinationCalendar(destination),
firstConnectedCalendar: getPiiFreeCredential(firstCalendarCredential),
})
);
createdEvents.push(await createEvent(firstCalendarCredential, event));
eventCreated = true;
}
}
}
} else {
log.warn(
"No destination Calendar found, falling back to first connected calendar",
safeStringify({
calendarCredentials: this.calendarCredentials,
})
);
await fallbackToFirstCalendarInTheList();
}
// Taking care of non-traditional calendar integrations
createdEvents = createdEvents.concat(
await Promise.all(
this.calendarCredentials
.filter((cred) => cred.type.includes("other_calendar"))
.map(async (cred) => await createEvent(cred, event))
)
);
return createdEvents;
}
/**
* Checks which video integration is needed for the event's location and returns
* credentials for that - if existing.
* @param event
* @private
*/
private getVideoCredential(event: CalendarEvent): CredentialForCalendarService | undefined {
if (!event.location) {
return undefined;
}
/** @fixme potential bug since Google Meet are saved as `integrations:google:meet` and there are no `google:meet` type in our DB */
const integrationName = event.location.replace("integrations:", "");
let videoCredential;
if (event.conferenceCredentialId) {
videoCredential = this.videoCredentials.find(
(credential) => credential.id === event.conferenceCredentialId
);
} else {
videoCredential = this.videoCredentials.find((credential: CredentialForCalendarService) =>
credential.type.includes(integrationName)
);
log.warn(
`Could not find conferenceCredentialId for event with location: ${event.location}, trying to use last added video credential`
);
}
/**
* This might happen if someone tries to use a location with a missing credential, so we fallback to Cal Video.
* @todo remove location from event types that has missing credentials
* */
if (!videoCredential) {
log.warn(
`Falling back to "daily" video integration for event with location: ${event.location} because credential is missing for the app`
);
videoCredential = { ...FAKE_DAILY_CREDENTIAL };
}
return videoCredential;
}
/**
* Creates a video event entry for the selected integration location.
*
* When optional uid is set, it will be used instead of the auto generated uid.
*
* @param event
* @private
*/
private async createVideoEvent(event: CalendarEvent) {
const credential = this.getVideoCredential(event);
if (credential) {
return createMeeting(credential, event);
} else {
return Promise.reject(
`No suitable credentials given for the requested integration name:${event.location}`
);
}
}
/**
* Updates the event entries for all calendar integrations given in the credentials.
* When noMail is true, no mails will be sent. This is used when the event is
* a video meeting because then the mail containing the video credentials will be
* more important than the mails created for these bare calendar events.
*
* @param event
* @param booking
* @private
*/
private async updateAllCalendarEvents(
event: CalendarEvent,
booking: PartialBooking,
newBookingId?: number
): Promise<Array<EventResult<NewCalendarEventType>>> {
let calendarReference: PartialReference[] | undefined = undefined,
credential;
log.silly("updateAllCalendarEvents", JSON.stringify({ event, booking, newBookingId }));
try {
// If a newBookingId is given, update that calendar event
let newBooking;
if (newBookingId) {
newBooking = await prisma.booking.findUnique({
where: {
id: newBookingId,
},
select: {
references: true,
},
});
}
calendarReference = newBooking?.references.length
? newBooking.references.filter((reference) => reference.type.includes("_calendar"))
: booking.references.filter((reference) => reference.type.includes("_calendar"));
if (calendarReference.length === 0) {
return [];
}
// process all calendar references
let result = [];
for (const reference of calendarReference) {
const { uid: bookingRefUid, externalCalendarId: bookingExternalCalendarId } = reference;
let calenderExternalId: string | null = null;
if (bookingExternalCalendarId) {
calenderExternalId = bookingExternalCalendarId;
}
if (reference.credentialId) {
credential = this.calendarCredentials.filter(
(credential) => credential.id === reference?.credentialId
)[0];
if (!credential) {
// Fetch credential from DB
const credentialFromDB = await CredentialRepository.findCredentialForCalendarServiceById({
id: reference.credentialId,
});
if (credentialFromDB && credentialFromDB.appId) {
credential = {
id: credentialFromDB.id,
type: credentialFromDB.type,
key: credentialFromDB.key,
userId: credentialFromDB.userId,
teamId: credentialFromDB.teamId,
invalid: credentialFromDB.invalid,
appId: credentialFromDB.appId,
user: credentialFromDB.user,
delegatedToId: credentialFromDB.delegatedToId,
delegatedTo: credentialFromDB.delegatedTo,
delegationCredentialId: credentialFromDB.delegationCredentialId,
};
}
}
result.push(updateEvent(credential, event, bookingRefUid, calenderExternalId));
} else {
const credentials = this.calendarCredentials.filter(
(credential) => credential.type === reference?.type
);
for (const credential of credentials) {
log.silly("updateAllCalendarEvents-credential", JSON.stringify({ credentials }));
result.push(updateEvent(credential, event, bookingRefUid, calenderExternalId));
}
}
}
// If we are merging two calendar events we should delete the old calendar event
if (newBookingId) {
const oldCalendarEvent = booking.references.find((reference) => reference.type.includes("_calendar"));
if (oldCalendarEvent?.credentialId) {
const calendarCredential = await CredentialRepository.findCredentialForCalendarServiceById({
id: oldCalendarEvent.credentialId,
});
const calendar = await getCalendar(calendarCredential);
await calendar?.deleteEvent(oldCalendarEvent.uid, event, oldCalendarEvent.externalCalendarId);
}
}
// Taking care of non-traditional calendar integrations
result = result.concat(
this.calendarCredentials
.filter((cred) => cred.type.includes("other_calendar"))
.map(async (cred) => {
const calendarReference = booking.references.find((ref) => ref.type === cred.type);
if (!calendarReference) {
return {
appName: cred.appId || "",
type: cred.type,
success: false,
uid: "",
originalEvent: event,
credentialId: cred.id,
};
}
const { externalCalendarId: bookingExternalCalendarId, meetingId: bookingRefUid } =
calendarReference;
return await updateEvent(cred, event, bookingRefUid ?? null, bookingExternalCalendarId ?? null);
})
);
return Promise.all(result);
} catch (error) {
let message = `Tried to 'updateAllCalendarEvents' but there was no '{thing}' for '${credential?.type}', userId: '${credential?.userId}', bookingId: '${booking?.id}'`;
if (error instanceof Error) {
message = message.replace("{thing}", error.message);
}
return Promise.resolve(
calendarReference?.map((reference) => {
return {
appName: "none",
type: reference?.type || "calendar",
success: false,
uid: "",
originalEvent: event,
credentialId: 0,
};
}) ?? ([] as Array<EventResult<NewCalendarEventType>>)
);
}
}
/**
* Updates a single video event.
*
* @param event
* @param booking
* @private
*/
private async updateVideoEvent(event: CalendarEvent, booking: PartialBooking) {
const credential = this.getVideoCredential(event);
if (credential) {
const bookingRef = booking ? booking.references.filter((ref) => ref.type === credential.type)[0] : null;
return updateMeeting(credential, event, bookingRef);
} else {
return Promise.reject(
`No suitable credentials given for the requested integration name:${event.location}`
);
}
}
private async createAllCRMEvents(event: CalendarEvent) {
const createdEvents = [];
const featureRepo = new FeaturesRepository(prisma);
const isTaskerEnabledForSalesforceCrm = event.team?.id
? await featureRepo.checkIfTeamHasFeature(event.team.id, "salesforce-crm-tasker")
: false;
const uid = getUid(event);
for (const credential of this.crmCredentials) {
if (isTaskerEnabledForSalesforceCrm) {
if (!event.uid) {
console.error(
`Missing bookingId when scheduling CRM event creation on event type ${event?.eventTypeId}`
);
continue;
}
await CRMScheduler.createEvent({ bookingUid: event.uid });
continue;
}
const currentAppOption = this.getAppOptionsFromEventMetadata(credential);
const crm = new CrmManager(credential, currentAppOption);
let success = true;
const createdEvent = await crm.createEvent(event).catch((error) => {
success = false;
// We don't know the type of the error here, so for an Error instance we can read message but otherwise we stringify the error
const errorMsg = error instanceof Error ? error.message : JSON.stringify(error);
log.warn(`Error creating crm event for ${credential.type} for booking ${event?.uid}`, errorMsg);
});
if (createdEvent) {
createdEvents.push({
type: credential.type,
appName: credential.appId || "",
uid,
success,
createdEvent: {
id: createdEvent?.id || "",
type: credential.type,
credentialId: credential.id,
},
id: createdEvent?.id || "",
originalEvent: event,
credentialId: credential.id,
});
}
}
return createdEvents;
}
private async updateAllCRMEvents(event: CalendarEvent, booking: PartialBooking) {
const updatedEvents = [];
// Loop through all booking references and update the corresponding CRM event
for (const reference of booking.references) {
const credential = this.crmCredentials.find((cred) => cred.id === reference.credentialId);
let success = true;
if (credential) {
const crm = new CrmManager(credential);
const updatedEvent = await crm.updateEvent(reference.uid, event).catch((error) => {
success = false;
log.warn(`Error updating crm event for ${credential.type} for booking ${event?.uid}`, error);
});
updatedEvents.push({
type: credential.type,
appName: credential.appId || "",
success,
uid: updatedEvent?.id || "",
originalEvent: event,
});
}
}
return updatedEvents;
}
private async deleteCRMEvent({ reference, event }: { reference: PartialReference; event: CalendarEvent }) {
const credential = this.crmCredentials.find((cred) => cred.id === reference.credentialId);
if (credential) {
const currentAppOption = this.getAppOptionsFromEventMetadata(credential);
const crm = new CrmManager(credential, currentAppOption);
await crm.deleteEvent(reference.uid, event);
}
}
private getAppOptionsFromEventMetadata(credential: CredentialForCalendarService) {
if (!this.appOptions || !credential.appId) return {};
if (credential.appId in this.appOptions)
return this.appOptions[credential.appId as keyof typeof this.appOptions];
}
}
export const placeholderCreatedEvent = { results: [], referencesToCreate: [] };