Files
calendar/packages/lib/CalendarManager.ts
T
Hariom BalharaandGitHub e3bd90c4f2 fix: Handle calendar-cache with Delegation Credentials
Fixes CAL-5372

# Delegation Credentials with CalendarCache.

Following content is a snapshot of the [internal document](https://calendso.slack.com/docs/T08B8KA2BNF/F08L5JYU3V3)



**Problem-1 :** 

CalendarCache needs SelectedCalendar records to work but SelectedCalendar record is only created when a user connects their calendar and then enables some calendar for conflict checking. Because with Delegation, no manual connection is done by any of the members, we need a way to create SelectedCalendar records automatically.

**Problem-2**

CalendarCache connects to credential(regular credential) which doesn’t exist for Delegation Credential scenario. Also, DelegationCredential is common for all the members(different from Credential which is different for different members) of the organization and we need to identify to which user the CalendarCache belongs.  

**Solution for both problems**
- Create credential records for Delegation Credentials as well - Through Cron(new - we could schedule it every 5mins)
- Now create SelectedCalendar  records for those Credential records -  Through another Cron(new - we could schedule it every 5mins)
- Now CalendarCache records will automatically be created for those SelectedCalendar records -existing cron

## Fixed some Delegation Credentials bugs unrelated to calendar-cache
- If DestinationCalendar wasn't set(which is possible only with Delegation Credentials), then Google Meet wasn't used as a conferencing app - [Added a test]
- If no SelectedCalendar is there but Google Calendar connection exists(possible only with Delegation Credential) then we were not doing conflict checking. It is expected to not do it for Regular Credentials, but for Delegation Credential we must check for conflict in that case too [Added a test]
- Earlier if a user has Regular Credential as well as Delegation Credential for the same external id which is the member email(say member1@acme.com) then availability were retrieved twice because we weren't deduplicating credentials as it wasn't a trivial thing to do. Now that is being done.


**Env Variables:**
Note this PR doesn't introduce any new env variable. The existing env variable has been added to .env.example. But if this env variable isn't already set, it must be set.

`CALCOM_SERVICE_ACCOUNT_ENCRYPTION_KEY={SAME_AS_SET_FOR_V2_API}`

**Deployment Plan:**
1. Add Observability for SelectedCalendar when _error_ field is set
2. Follow https://github.com/calcom/cal.com/blob/calendar-cache-dwd-support/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/delegation-credential/delegation-credential.md#setting-up-delegation-credential-for-google-calendar-api to enable Delegation Credential for i.cal.com
3. Note that to be able to see the option to enable Delegation Credential for an organization, you need to enable `teamFeature` and `feature` for `delegation-credential`

## Automation Tests
- Introduced tests for calendar-cache.repository.ts
   - Tests all methods of the repository
- Added more tests for handleNewBooking/delegation-credential flow.
   - Added test to verify the bug fix when no DestinationCalendar exists and Google Meet should be used still 
- Added more tests for Google Calendar/CalendarService targeting DelegationCredential
- Added more tests for getCalendarsEvents. 
    - To test the new logic of calling getAvailability still if there are no selectedCalendars in case of Delegation Credential
    - Also introduced tests for `getAvailabitlityWithTimezones` which was an existing function but now has some new changes.
- Added tests for deduplication logic in CalendarManager.ts

## How to Test
Enable Calendar Cache and Delegation Credential feature for acme org through `features` and `teamFeatures` tables.
- Enable Delegation Credential for acme org
- Enable atleast 1 calendar for conflict checking for one of the users(say owner1)
- Ensure GOOGLE_WEBHOOK_TOKEN is set in .env file
- Ensure GOOGLE_WEBHOOK_URL is set to ngrok url of webapp in .env file
- Hit cron endpoint `curl http://localhost:3000/api/calendar-cache/cron\?apiKey\={API_KEY}` that would cache the freebusy result for the selected calendars



Followup 
- https://github.com/calcom/cal.com/pull/20698
- https://github.com/calcom/cal.com/pull/18619/files#r2046795643
2025-04-28 18:11:29 -03:00

482 lines
16 KiB
TypeScript

// 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 { CalendarAppDelegationCredentialError } from "@calcom/lib/CalendarAppError";
import { buildNonDelegationCredentials } from "@calcom/lib/delegationCredential/clientAndServer";
import logger from "@calcom/lib/logger";
import { getPiiFreeCalendarEvent, getPiiFreeCredential } from "@calcom/lib/piiFreeData";
import { safeStringify } from "@calcom/lib/safeStringify";
import type {
CalendarEvent,
EventBusyDate,
IntegrationCalendar,
NewCalendarEventType,
SelectedCalendar,
} from "@calcom/types/Calendar";
import type { CredentialForCalendarService, CredentialPayload } from "@calcom/types/Credential";
import type { EventResult } from "@calcom/types/EventManager";
import getCalendarsEvents from "./getCalendarsEvents";
import { getCalendarsEventsWithTimezones } from "./getCalendarsEvents";
const log = logger.getSubLogger({ prefix: ["CalendarManager"] });
export const getCalendarCredentials = (credentials: Array<CredentialForCalendarService>) => {
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 getCalendarCredentialsWithoutDelegation = (credentials: CredentialPayload[]) => {
return getCalendarCredentials(buildNonDelegationCredentials(credentials));
};
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 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 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,
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",
},
};
}
// 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: 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", safeStringify(error), safeStringify({ item }));
return {
integration: cleanIntegrationKeys(item.integration),
credentialId: item.credential.id,
delegationCredentialId: item.credential.delegatedToId,
error: {
message: errorMessage,
},
};
}
})
);
return { connectedCalendars, destinationCalendar };
};
/**
* Important function to prevent leaking credentials to the client
* @param appIntegration
* @returns App
*/
const cleanIntegrationKeys = (
appIntegration: Awaited<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;
};
/**
* 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[],
shouldServeCache?: boolean,
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);
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).add(14, "hours").format();
log.debug(
"getBusyCalendarTimes manipulated dates",
safeStringify({
newStartDate: startDate,
newEndDate: endDate,
oldStartDate: dateFrom,
oldEndDate: dateTo,
})
);
if (includeTimeZone) {
results = await getCalendarsEventsWithTimezones(
deduplicatedCredentials,
startDate,
endDate,
selectedCalendars
);
} else {
results = await getCalendarsEvents(
deduplicatedCredentials,
startDate,
endDate,
selectedCalendars,
shouldServeCache
);
}
} catch (e) {
log.warn(safeStringify(e));
}
return results.reduce((acc, availability) => acc.concat(availability), []);
};
export const createEvent = async (
credential: CredentialForCalendarService,
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?
}
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.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,
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: CredentialForCalendarService;
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({});
};