Files
calendar/packages/lib/EventManager.ts
T
devin-ai-integration[bot]GitHubDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>somay@cal.com <somay@cal.com>Somay ChauhanVolnei Munhoz
1914b37ee4 feat: add PATCH endpoint for updating Google Calendar events (#22339)
* feat: add PATCH endpoint for updating Google Calendar events

- Create UpdateUnifiedCalendarEventInput DTO with optional fields for partial updates
- Add PATCH /:calendar/event/:eventUid endpoint to CalUnifiedCalendarsController
- Extend GoogleCalendarService with updateEventDetails method
- Support updating title, description, start/end times for Google Calendar events
- Follow existing patterns for validation, error handling, and response transformation
- Initially support Google Calendar only with extensibility for other providers

Co-Authored-By: somay@cal.com <somay@cal.com>

* feat: expand PATCH endpoint to support all calendar event fields

- Add comprehensive field support to UpdateUnifiedCalendarEventInput DTO
- Include locations, attendees, status, and hosts fields with proper validation
- Expand GoogleCalendarService.updateEventDetails to handle complex field transformations
- Add helper methods for response status and event status mapping
- Preserve existing transformation logic from GoogleCalendarEventOutputPipe
- Support conferenceData updates with conferenceDataVersion parameter

Co-Authored-By: somay@cal.com <somay@cal.com>

* fix: add support for simple location field in Google Calendar events

- Add handling for geographic location field in addition to conferenceData
- Filter non-video locations for the simple location string field
- Preserve existing conferenceData support for video meetings
- Verified against Google Calendar API documentation

Co-Authored-By: somay@cal.com <somay@cal.com>

* refactor: extract transformation logic into GoogleCalendarEventInputPipe

- Create GoogleCalendarEventInputPipe following existing patterns
- Extract transformation logic from updateEventDetails method
- Move mapping methods to the new pipe for better organization
- Improve code reusability and maintainability

Addresses PR comment requesting transformation logic extraction.

Co-Authored-By: somay@cal.com <somay@cal.com>

* refactor: remove locations field and simplify calendar event input handling

* update docs

* refactor: remove hosts field from the update endpoint

* fix: exclude organizer from attendees list in Google Calendar event transformation

* feat: implement organizer preservation logic in PATCH endpoint

- Fetch existing Google Calendar event data before transformation
- Extract organizer attendees from existing event and merge with user-provided attendees
- Prevent accidental removal of organizers when users update attendees
- Add custom interface for GoogleCalendarEventInputPipe to support optional existingEvent parameter
- Preserve organizers unless user explicitly provides hosts field in update request

Co-Authored-By: somay@cal.com <somay@cal.com>

* feat: add hosts array handling to PATCH endpoint

- Add hosts field back to UpdateUnifiedCalendarEventInput DTO
- Implement transformAttendeesWithHostsHandling method in GoogleCalendarEventInputPipe
- Convert hosts to attendees with organizer: true for Google Calendar API
- When hosts are provided, they replace existing organizers (not merge)
- Maintain backward compatibility with existing organizer preservation logic

Co-Authored-By: somay@cal.com <somay@cal.com>

* feat: implement sophisticated attendee update logic with preservation and deletion support

- Add UpdateCalendarEventAttendee class with action: 'delete' support
- Implement attendee preservation logic that starts with existing Google Calendar attendees
- Support explicit attendee deletion using action: 'delete' field
- Update existing attendees when provided without action field
- Add new attendees that don't exist in current event
- Preserve organizer status when updating existing attendees
- Fetch existing event data when attendees OR hosts are being updated
- Maintain hosts array handling that replaces existing organizers

Co-Authored-By: somay@cal.com <somay@cal.com>

* feat: add host management and attendee deletion to calendar event API

* refactor: improve readability of transformAttendeesWithHostsHandling and restrict hosts to responseStatus updates only

- Break down complex transformAttendeesWithHostsHandling method into smaller, more readable helper functions:
  - preserveExistingAttendees: handles existing attendee preservation
  - processAttendeeDeletions: handles attendee deletion logic
  - processAttendeeUpdatesAndAdditions: handles attendee updates and additions
  - replaceHostsWithUpdatedOnes: handles host replacement with restrictions
- Create UpdateCalendarEventHost class that only allows responseStatus updates
- Restrict hosts array to prevent changes to name, email, and other fields
- Preserve existing host displayName from Google Calendar when updating responseStatus

Co-Authored-By: somay@cal.com <somay@cal.com>

* feat: restrict host updates to responseStatus only, preserve email and name from Google Calendar data

- Remove email field from UpdateCalendarEventHost class
- Update replaceHostsWithUpdatedOnes to preserve all host data except responseStatus
- Apply responseStatus updates to all existing organizers from Google Calendar
- Prevent users from updating host email or name fields

Co-Authored-By: somay@cal.com <somay@cal.com>

* Revert "feat: restrict host updates to responseStatus only, preserve email and name from Google Calendar data"

This reverts commit 22d07ce0cb13e71661522b2fd8a237d8b6e0809c.

* refactor: replace optional flag with organizer property in calendar event attendees

* refactor: remove organizer field from calendar event attendee input

* feat: add comprehensive test suites for calendar transformation pipes

- Create google-calendar-event-input.pipe.spec.ts with 67 test cases
- Create get-calendar-event-details-output-pipe.spec.ts with 35 test cases
- Follow event-types transformer test patterns with separate describe blocks
- Cover all transformation functions including edge cases and null handling
- Test attendee preservation, deletion, host management, and status transformations
- Ensure comprehensive coverage for sophisticated attendee update logic

Co-Authored-By: somay@cal.com <somay@cal.com>

* fix: remove invalid optional properties from calendar pipe tests

- Remove optional: false from line 85 in 'should transform attendees without existing event' test case
- Remove optional: true from line 388 in 'should update existing attendee' test case
- Remove optional: false from line 421 in 'should add new attendee' test case
- Update expected outputs to match actual pipe implementation behavior
- All 30 tests now pass locally

Co-Authored-By: somay@cal.com <somay@cal.com>

* fix: add platform library path mappings to Jest configuration

- Resolves module resolution errors for @calcom/platform-libraries/* imports in Jest
- Ensures test suite can run without 'Cannot find module' errors
- Adds comprehensive path mappings matching TypeScript configuration

Co-Authored-By: somay@cal.com <somay@cal.com>

* fix: add @calcom/dayjs path mapping to Jest configuration

- Add missing moduleNameMapper entry for @calcom/dayjs to resolve TypeScript path mappings in Jest
- Fixes TypeError: Cannot read properties of undefined (reading 'extend') in calendar pipe tests
- Calendar transformation pipe tests now pass successfully

Co-Authored-By: somay@cal.com <somay@cal.com>

* fix: configure Jest to use dayjs mock system for consistent test environment

- Remove @calcom/dayjs path mapping from Jest config
- Add setupFilesAfterEnv to automatically import dayjs mock
- Update dayjs mock with comprehensive jest.fn() implementation
- Add @calcom/platform-libraries/repositories and @calcom/prisma/client mappings
- Resolves dayjs TypeError in slots.service.spec.ts and other test files

Co-Authored-By: somay@cal.com <somay@cal.com>

* fix: complete shared Google event data with required properties for test suites

- Add missing required properties to sharedGoogleEvent in both test files
- Fix TypeScript compilation errors in calendar transformation pipe tests
- Correct test assertion for attendees filtering logic in output pipe
- All 11 test suites now pass with 199 tests total

Co-Authored-By: somay@cal.com <somay@cal.com>

* undo extra changes

* better tests

* fix: update calendar API endpoints to use plural events in path

* update documentation

* fix: Restrict updates to only the responseStatus field for hosts

* feat: add PATCH endpoint for updating Google Calendar events

- Create UpdateUnifiedCalendarEventInput DTO with optional fields for partial updates
- Add PATCH /:calendar/event/:eventUid endpoint to CalUnifiedCalendarsController
- Extend GoogleCalendarService with updateEventDetails method
- Support updating title, description, start/end times for Google Calendar events
- Follow existing patterns for validation, error handling, and response transformation
- Initially support Google Calendar only with extensibility for other providers

Co-Authored-By: somay@cal.com <somay@cal.com>

* feat: expand PATCH endpoint to support all calendar event fields

- Add comprehensive field support to UpdateUnifiedCalendarEventInput DTO
- Include locations, attendees, status, and hosts fields with proper validation
- Expand GoogleCalendarService.updateEventDetails to handle complex field transformations
- Add helper methods for response status and event status mapping
- Preserve existing transformation logic from GoogleCalendarEventOutputPipe
- Support conferenceData updates with conferenceDataVersion parameter

Co-Authored-By: somay@cal.com <somay@cal.com>

* fix: add support for simple location field in Google Calendar events

- Add handling for geographic location field in addition to conferenceData
- Filter non-video locations for the simple location string field
- Preserve existing conferenceData support for video meetings
- Verified against Google Calendar API documentation

Co-Authored-By: somay@cal.com <somay@cal.com>

* refactor: extract transformation logic into GoogleCalendarEventInputPipe

- Create GoogleCalendarEventInputPipe following existing patterns
- Extract transformation logic from updateEventDetails method
- Move mapping methods to the new pipe for better organization
- Improve code reusability and maintainability

Addresses PR comment requesting transformation logic extraction.

Co-Authored-By: somay@cal.com <somay@cal.com>

* refactor: remove locations field and simplify calendar event input handling

* update docs

* refactor: remove hosts field from the update endpoint

* fix: exclude organizer from attendees list in Google Calendar event transformation

* feat: implement organizer preservation logic in PATCH endpoint

- Fetch existing Google Calendar event data before transformation
- Extract organizer attendees from existing event and merge with user-provided attendees
- Prevent accidental removal of organizers when users update attendees
- Add custom interface for GoogleCalendarEventInputPipe to support optional existingEvent parameter
- Preserve organizers unless user explicitly provides hosts field in update request

Co-Authored-By: somay@cal.com <somay@cal.com>

* feat: add hosts array handling to PATCH endpoint

- Add hosts field back to UpdateUnifiedCalendarEventInput DTO
- Implement transformAttendeesWithHostsHandling method in GoogleCalendarEventInputPipe
- Convert hosts to attendees with organizer: true for Google Calendar API
- When hosts are provided, they replace existing organizers (not merge)
- Maintain backward compatibility with existing organizer preservation logic

Co-Authored-By: somay@cal.com <somay@cal.com>

* feat: implement sophisticated attendee update logic with preservation and deletion support

- Add UpdateCalendarEventAttendee class with action: 'delete' support
- Implement attendee preservation logic that starts with existing Google Calendar attendees
- Support explicit attendee deletion using action: 'delete' field
- Update existing attendees when provided without action field
- Add new attendees that don't exist in current event
- Preserve organizer status when updating existing attendees
- Fetch existing event data when attendees OR hosts are being updated
- Maintain hosts array handling that replaces existing organizers

Co-Authored-By: somay@cal.com <somay@cal.com>

* feat: add host management and attendee deletion to calendar event API

* refactor: improve readability of transformAttendeesWithHostsHandling and restrict hosts to responseStatus updates only

- Break down complex transformAttendeesWithHostsHandling method into smaller, more readable helper functions:
  - preserveExistingAttendees: handles existing attendee preservation
  - processAttendeeDeletions: handles attendee deletion logic
  - processAttendeeUpdatesAndAdditions: handles attendee updates and additions
  - replaceHostsWithUpdatedOnes: handles host replacement with restrictions
- Create UpdateCalendarEventHost class that only allows responseStatus updates
- Restrict hosts array to prevent changes to name, email, and other fields
- Preserve existing host displayName from Google Calendar when updating responseStatus

Co-Authored-By: somay@cal.com <somay@cal.com>

* feat: restrict host updates to responseStatus only, preserve email and name from Google Calendar data

- Remove email field from UpdateCalendarEventHost class
- Update replaceHostsWithUpdatedOnes to preserve all host data except responseStatus
- Apply responseStatus updates to all existing organizers from Google Calendar
- Prevent users from updating host email or name fields

Co-Authored-By: somay@cal.com <somay@cal.com>

* Revert "feat: restrict host updates to responseStatus only, preserve email and name from Google Calendar data"

This reverts commit 22d07ce0cb13e71661522b2fd8a237d8b6e0809c.

* feat: add comprehensive test suites for calendar transformation pipes

- Create google-calendar-event-input.pipe.spec.ts with 67 test cases
- Create get-calendar-event-details-output-pipe.spec.ts with 35 test cases
- Follow event-types transformer test patterns with separate describe blocks
- Cover all transformation functions including edge cases and null handling
- Test attendee preservation, deletion, host management, and status transformations
- Ensure comprehensive coverage for sophisticated attendee update logic

Co-Authored-By: somay@cal.com <somay@cal.com>

* refactor: replace optional flag with organizer property in calendar event attendees

* refactor: remove organizer field from calendar event attendee input

* fix: remove invalid optional properties from calendar pipe tests

- Remove optional: false from line 85 in 'should transform attendees without existing event' test case
- Remove optional: true from line 388 in 'should update existing attendee' test case
- Remove optional: false from line 421 in 'should add new attendee' test case
- Update expected outputs to match actual pipe implementation behavior
- All 30 tests now pass locally

Co-Authored-By: somay@cal.com <somay@cal.com>

* fix: complete shared Google event data with required properties for test suites

- Add missing required properties to sharedGoogleEvent in both test files
- Fix TypeScript compilation errors in calendar transformation pipe tests
- Correct test assertion for attendees filtering logic in output pipe
- All 11 test suites now pass with 199 tests total

Co-Authored-By: somay@cal.com <somay@cal.com>

* fix: resolve TypeScript compilation errors in calendar transformation pipe tests

- Add comprehensive Jest mocks for @calcom/prisma/client, @calcom/dayjs, CalendarManager, and delegationCredential
- Fix RedisService AbortSignal.timeout() usage
- Update UserAvailabilityService import path to use @calcom/lib/getUserAvailability
- Configure Jest moduleNameMapper to handle ES module imports and package.json mocking
- All 11 test suites now pass with 199 tests total

Co-Authored-By: somay@cal.com <somay@cal.com>

* fix: use proper CacheService dependency injection in AvailableSlotsService

- Replace direct CacheService instantiation with dependency injection
- Update CacheService import path to use correct calendar-cache module
- Fix repository and service dependencies for proper type safety
- Resolves TypeScript compilation errors in slots service tests

Co-Authored-By: somay@cal.com <somay@cal.com>

* fix: remove duplicate test file causing TypeScript compilation error

Co-Authored-By: somay@cal.com <somay@cal.com>

* undo extra changes

* undo extra changes

* fix: remove the action: "delete" key from attendees

* fix: simplify tests

* update docs

* undo: remove hosts

* Update pre-commit

* feat: add alternate route for calendar event retrieval and simplify test fixtures

* remove userId -- unused

* undo

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: somay@cal.com <somay@cal.com>
Co-authored-by: Somay Chauhan <somaychauhan98@gmail.com>
Co-authored-by: Volnei Munhoz <volnei.munhoz@gmail.com>
2025-08-13 17:24:19 +01:00

1284 lines
47 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) => {
return {
type: result.type,
uid: result.createdEvent?.id?.toString() ?? "",
meetingId: result.createdEvent?.id?.toString(),
meetingPassword: result.createdEvent?.password,
meetingUrl: result.createdEvent?.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
): 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 && !changedOrganizer) {
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) {
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: [] };