import { getCalendar } from "@calcom/app-store/_utils/getCalendar"; import { MeetLocationType } from "@calcom/app-store/locations"; import getApps from "@calcom/app-store/utils"; import dayjs from "@calcom/dayjs"; import getCalendarsEvents, { getCalendarsEventsWithTimezones, } from "@calcom/features/calendars/lib/getCalendarsEvents"; import { getRichDescription, getUid } from "@calcom/lib/CalEventParser"; import { CalendarAppDelegationCredentialError } from "@calcom/lib/CalendarAppError"; import { ORGANIZER_EMAIL_EXEMPT_DOMAINS } from "@calcom/lib/constants"; import { buildNonDelegationCredentials } from "@calcom/lib/delegationCredential"; import { formatCalEvent } from "@calcom/lib/formatCalendarEvent"; import logger from "@calcom/lib/logger"; import { getPiiFreeCalendarEvent, getPiiFreeCredential } from "@calcom/lib/piiFreeData"; import { safeStringify } from "@calcom/lib/safeStringify"; import type { CalendarEvent, CalendarFetchMode, CalendarServiceEvent, EventBusyDate, IntegrationCalendar, NewCalendarEventType, SelectedCalendar, } from "@calcom/types/Calendar"; import type { CredentialForCalendarService, CredentialPayload } from "@calcom/types/Credential"; import type { EventResult } from "@calcom/types/EventManager"; import { sortBy } from "lodash"; const log = logger.getSubLogger({ prefix: ["CalendarManager"] }); /** * Process the calendar event by generating description and removing attendees if needed */ export const processEvent = (calEvent: CalendarEvent): CalendarServiceEvent => { if (calEvent.seatsPerTimeSlot) { calEvent.responses = null; calEvent.userFieldsResponses = null; calEvent.additionalNotes = null; calEvent.customInputs = null; } // Generate the calendar event description const calendarEvent: CalendarServiceEvent = { ...calEvent, calendarDescription: getRichDescription(calEvent), }; const isMeetLocationType = calEvent.location === MeetLocationType; // Determine if the calendar event should include attendees const isOrganizerExempt = ORGANIZER_EMAIL_EXEMPT_DOMAINS?.split(",") .filter((domain) => domain.trim() !== "") .some((domain) => calEvent.organizer.email.toLowerCase().endsWith(domain.toLowerCase())); // Zoho Calendar requires at least one attendee, so don't empty attendees array for Zoho Calendar const hasZohoCalendar = calEvent.destinationCalendar?.some((cal) => cal.integration === "zoho_calendar") ?? false; if (calEvent.hideOrganizerEmail && !isOrganizerExempt && !isMeetLocationType && !hasZohoCalendar) { calendarEvent.attendees = []; } return calendarEvent; }; type IntegrationWithCredentials = Awaited< ReturnType >[number]["integration"] & { credentials?: CredentialPayload[]; credential?: CredentialPayload; }; type CleanIntegration = Omit; /** * Important function to prevent leaking credentials to the client * @param appIntegration * @returns App */ export const cleanIntegrationKeys = (appIntegration: IntegrationWithCredentials): CleanIntegration => { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { credentials, credential, ...rest } = appIntegration; return rest; }; export const getCalendarCredentials = (credentials: Array) => { const calendarCredentials = getApps(credentials, true) .filter((app) => app.type.endsWith("_calendar")) .flatMap((app) => { const credentials = app.credentials.flatMap((credential) => { const calendar = () => getCalendar(credential, "slots"); return app.variant === "calendar" ? [{ integration: app, credential, calendar }] : []; }); return credentials.length ? credentials : []; }); return calendarCredentials; }; export const getCalendarCredentialsWithoutDelegation = (credentials: CredentialPayload[]) => { return getCalendarCredentials(buildNonDelegationCredentials(credentials)); }; export type ConnectedCalendar = Omit & { primary: boolean | null; isSelected: boolean; readOnly: boolean; credentialId: number; delegationCredentialId: string | null; }; export const getConnectedCalendars = async ( calendarCredentials: ReturnType, selectedCalendars: { externalId: string }[], destinationCalendarExternalId?: string ): Promise<{ connectedCalendars: { integration: CleanIntegration; calendars?: ConnectedCalendar[]; credentialId: number; delegationCredentialId?: string | null; error?: { message: string; }; primary?: ConnectedCalendar; }[]; destinationCalendar: IntegrationCalendar | undefined; }> => { const connectedCalendars = await Promise.all( calendarCredentials.map(async (item) => { try { const { integration, credential } = item; const safeToSendIntegration = cleanIntegrationKeys(integration); const calendar = await item.calendar; // Don't leak credentials to the client const credentialId = credential.id; const delegationCredentialId = credential.delegatedToId ?? null; if (!calendar) { return { integration: safeToSendIntegration, credentialId, delegationCredentialId, }; } const calendarInstance = await calendar(); if (!calendarInstance) { return { integration: safeToSendIntegration, credentialId, delegationCredentialId, error: { message: "Could not get calendar instance", }, }; } const cals = await calendarInstance.listCalendars(); const calendars: ConnectedCalendar[] = sortBy( cals.map((cal: IntegrationCalendar) => { return { ...cal, readOnly: cal.readOnly || false, primary: cal.primary || null, isSelected: selectedCalendars.some((selected) => selected.externalId === cal.externalId), credentialId, delegationCredentialId, }; }), ["primary"] ); const primary = calendars.find((item) => item.primary) ?? calendars.find((cal) => cal !== undefined); if (!primary) { return { integration: safeToSendIntegration, credentialId, error: { message: "No primary calendar found", }, }; } return { integration: safeToSendIntegration, credentialId, delegationCredentialId, 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"; } } if (error instanceof CalendarAppDelegationCredentialError) { errorMessage = error.message; } log.error("getConnectedCalendars failed", error, safeStringify({ credentialId: item.credential.id })); return { integration: cleanIntegrationKeys(item.integration), credentialId: item.credential.id, delegationCredentialId: item.credential.delegatedToId, error: { message: errorMessage, }, }; } }) ); let destinationCalendar: IntegrationCalendar | undefined; if (destinationCalendarExternalId) { for (const connectedCalendar of connectedCalendars) { if (!("calendars" in connectedCalendar) || !connectedCalendar.calendars) { continue; } const calendar = connectedCalendar.calendars.find( (cal) => cal.externalId === destinationCalendarExternalId ); if (calendar) { destinationCalendar = { ...calendar, primary: calendar.primary ?? undefined, primaryEmail: connectedCalendar.primary?.email, integrationTitle: connectedCalendar.integration?.title, }; break; } } } return { connectedCalendars, destinationCalendar }; }; /** * This function deduplicates credentials based on selected calendars * It removes regular credentials for which corresponding delegation credentials exist which can be identified only associating them with selected calendars * * This is important to prevent unnecessary/duplicate calls to calendar APIs */ export const deduplicateCredentialsBasedOnSelectedCalendars = ({ credentials, selectedCalendars, }: { credentials: CredentialForCalendarService[]; selectedCalendars: SelectedCalendar[]; }) => { // Only proceed if we have credentials if (credentials.length === 0) { return credentials; } // Get the user email from the first credential const userEmail = credentials[0].user?.email; // If no email, we can't identify which credential is duplicate if (!userEmail) { return credentials; } // Check if there are delegation credentials for the same integration types const delegationCredentials = credentials.filter((credential) => credential.delegatedToId); // Find all selected calendars with externalId matching the user's email and having a regular credential const selectedCalendarsWithUserEmailConnectedWithRegularCredential = selectedCalendars.filter( (calendar) => calendar.externalId === userEmail && calendar.credentialId && calendar.credentialId > 0 ); // If no delegation credentials or no regular credentials connected selected calendars, return original credentials if ( delegationCredentials.length === 0 || selectedCalendarsWithUserEmailConnectedWithRegularCredential.length === 0 ) { return credentials; } const deduplicatedCredentials = [...credentials]; // For each selected calendar with user email, check if a delegation credential exists for the same integration. // If yes, we remove such a regular credential as that is a duplicate const credentialIdsToRemove = selectedCalendarsWithUserEmailConnectedWithRegularCredential .filter((calendar) => delegationCredentials.some((credential) => credential.type === calendar.integration) ) .map((calendar) => calendar.credentialId); // Remove the regular credentials that are now handled by delegation credentials return deduplicatedCredentials.filter((credential) => !credentialIdsToRemove.includes(credential.id)); }; export const getBusyCalendarTimes = async ( /** * withCredentials can possibly have duplicate credential in case DelegationCredential is enabled. * There is no way to deduplicate that at the moment because a `credential` doesn't directly know to which external_id(or email it is connected to). * So, there could be multiple credentials for the same user. * 1. Delegated Credential - that fetches events for john@acme.com * 2. Regular Credential - that fetches events for john@personal.com * */ withCredentials: CredentialForCalendarService[], dateFrom: string, dateTo: string, selectedCalendars: SelectedCalendar[], mode?: CalendarFetchMode, includeTimeZone?: boolean ) => { let results: (EventBusyDate & { timeZone?: string })[][] = []; const deduplicatedCredentials = deduplicateCredentialsBasedOnSelectedCalendars({ credentials: withCredentials, selectedCalendars, }); if (deduplicatedCredentials.length !== withCredentials.length) { log.info( "Deduplicated credentials and removed", withCredentials.length - deduplicatedCredentials.length, "duplicates. Total number of credentials now is", deduplicatedCredentials.length ); } // const months = getMonths(dateFrom, dateTo); // 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).add(14, "hours").format(); try { if (includeTimeZone) { results = await getCalendarsEventsWithTimezones( deduplicatedCredentials, startDate, endDate, selectedCalendars ); } else { results = await getCalendarsEvents( deduplicatedCredentials, startDate, endDate, selectedCalendars, mode ?? "slots" ); } } catch (e) { log.warn(`Error getting calendar availability`, { selectedCalendarIds: selectedCalendars.map((calendar) => calendar.externalId), error: safeStringify(e), }); return { success: false, data: [{ start: startDate, end: endDate, source: "error-placeholder" }] }; } return { success: true, data: results.reduce((acc, availability) => acc.concat(availability), []) }; }; export const createEvent = async ( credential: CredentialForCalendarService, originalEvent: CalendarEvent, externalId?: string ): Promise> => { // Some calendar libraries may edit the original event so let's clone it const formattedEvent = formatCalEvent(originalEvent); const uid: string = getUid(formattedEvent.uid); const calendar = await getCalendar(credential, "booking"); let success = true; let calError: string | undefined; log.debug( "Creating calendar event", safeStringify({ calEvent: getPiiFreeCalendarEvent(formattedEvent), }) ); // Check if the disabledNotes flag is set to true if (formattedEvent.hideCalendarNotes) { formattedEvent.additionalNotes = "Notes have been hidden by the organizer"; // TODO: i18n this string? } const calEvent = processEvent(formattedEvent); const externalCalendarIdWhenDelegationCredentialIsChosen = credential.delegatedToId ? externalId : undefined; // TODO: Surface success/error messages coming from apps to improve end user visibility const creationResult = calendar ? await calendar // Ideally we should pass externalId always, but let's start with DelegationCredential case first as in that case, CalendarService need to handle a special case for DelegationCredential to determine the selectedCalendar. // Such logic shouldn't exist in CalendarService as it would be same for all calendar apps. .createEvent(calEvent, credential.id, externalCalendarIdWhenDelegationCredentialIsChosen) .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), safeStringify({ 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.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, delegatedToId: credential.delegatedToId ?? undefined, }; }; export const updateEvent = async ( credential: CredentialForCalendarService, rawCalEvent: CalendarEvent, bookingRefUid: string | null, externalCalendarId: string | null ): Promise> => { const formattedEvent = formatCalEvent(rawCalEvent); if (formattedEvent.hideCalendarNotes) { formattedEvent.additionalNotes = "Notes have been hidden by the organizer"; // TODO: i18n this string? } const calEvent = processEvent(formattedEvent); const uid = getUid(calEvent.uid); const calendar = await getCalendar(credential, "booking"); let success = false; let calError: string | 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.appName || credential.appId || "", type: credential.type, success, uid, updatedEvent: updatedResult, originalEvent: calEvent, calError, calWarnings, }; }; export const deleteEvent = async ({ credential, bookingRefUid, event, externalCalendarId, }: { credential: CredentialForCalendarService; bookingRefUid: string; event: CalendarEvent; externalCalendarId?: string | null; }): Promise => { if (!bookingRefUid) { log.error( "deleteEvent failed - bookingRefUid is empty, skipping calendar deletion to prevent malformed API request", safeStringify({ credential: getPiiFreeCredential(credential), event: getPiiFreeCalendarEvent(event), }) ); return Promise.resolve({}); } const calendar = await getCalendar(credential, "booking"); 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({}); };