2022-07-12 21:35:12 +00:00
/* eslint-disable @typescript-eslint/triple-slash-reference */
2022-03-23 22:00:30 +00:00
/// <reference path="../types/ical.d.ts"/>
2022-02-10 17:42:06 +00:00
import { Credential , Prisma } from "@prisma/client" ;
2021-12-09 15:51:37 +00:00
import ICAL from "ical.js" ;
2022-08-17 17:38:21 +00:00
import type { Attendee , DateArray , DurationObject , Person } from "ics" ;
import { createEvent } from "ics" ;
2021-12-09 15:51:37 +00:00
import {
createAccount ,
createCalendarObject ,
2022-01-06 17:28:31 +00:00
DAVAccount ,
2021-12-09 15:51:37 +00:00
deleteCalendarObject ,
fetchCalendarObjects ,
fetchCalendars ,
getBasicAuthHeaders ,
updateCalendarObject ,
} from "tsdav" ;
import { v4 as uuidv4 } from "uuid" ;
2022-06-28 20:40:58 +00:00
import dayjs from "@calcom/dayjs" ;
2022-08-25 09:18:30 +00:00
import sanitizeCalendarObject from "@calcom/lib/sanitizeCalendarObject" ;
2022-03-23 22:00:30 +00:00
import type {
Calendar ,
CalendarEvent ,
CalendarEventType ,
EventBusyDate ,
IntegrationCalendar ,
NewCalendarEventType ,
} from "@calcom/types/Calendar" ;
2021-12-09 15:51:37 +00:00
2022-03-23 22:00:30 +00:00
import { getLocation , getRichDescription } from "./CalEventParser" ;
import { symmetricDecrypt } from "./crypto" ;
import logger from "./logger" ;
const TIMEZONE_FORMAT = "YYYY-MM-DDTHH:mm:ss[Z]" ;
const DEFAULT_CALENDAR_TYPE = "caldav" ;
2021-12-09 15:51:37 +00:00
2022-01-06 17:28:31 +00:00
const CALENDSO_ENCRYPTION_KEY = process . env . CALENDSO_ENCRYPTION_KEY || "" ;
2021-12-09 15:51:37 +00:00
2022-03-23 22:00:30 +00:00
const convertDate = ( date : string ) : DateArray = >
dayjs ( date )
. utc ( )
. toArray ( )
. slice ( 0 , 6 )
. map ( ( v , i ) = > ( i === 1 ? v + 1 : v ) ) as DateArray ;
const getDuration = ( start : string , end : string ) : DurationObject = > ( {
minutes : dayjs ( end ) . diff ( dayjs ( start ) , "minute" ) ,
} ) ;
const getAttendees = ( attendees : Person [ ] ) : Attendee [ ] = >
attendees . map ( ( { email , name } ) = > ( { name , email , partstat : "NEEDS-ACTION" } ) ) ;
2022-01-06 17:28:31 +00:00
export default abstract class BaseCalendarService implements Calendar {
private url = "" ;
private credentials : Record < string , string > = { } ;
private headers : Record < string , string > = { } ;
protected integrationName = "" ;
2022-01-06 22:06:31 +00:00
private log : typeof logger ;
2021-12-09 15:51:37 +00:00
constructor ( credential : Credential , integrationName : string , url? : string ) {
this . integrationName = integrationName ;
2022-01-06 17:28:31 +00:00
const {
username ,
password ,
url : credentialURL ,
} = JSON . parse ( symmetricDecrypt ( credential . key as string , CALENDSO_ENCRYPTION_KEY ) ) ;
2021-12-09 15:51:37 +00:00
2022-01-06 17:28:31 +00:00
this . url = url || credentialURL ;
2021-12-09 15:51:37 +00:00
2022-01-06 17:28:31 +00:00
this . credentials = { username , password } ;
this . headers = getBasicAuthHeaders ( { username , password } ) ;
2022-01-06 22:06:31 +00:00
this . log = logger . getChildLogger ( { prefix : [ ` [[lib] ${ this . integrationName } ` ] } ) ;
2021-12-09 15:51:37 +00:00
}
2022-01-06 17:28:31 +00:00
async createEvent ( event : CalendarEvent ) : Promise < NewCalendarEventType > {
2021-12-09 15:51:37 +00:00
try {
const calendars = await this . listCalendars ( event ) ;
2022-01-06 17:28:31 +00:00
2021-12-09 15:51:37 +00:00
const uid = uuidv4 ( ) ;
2022-01-06 17:28:31 +00:00
// We create local ICS files
2021-12-09 15:51:37 +00:00
const { error , value : iCalString } = createEvent ( {
uid ,
startInputType : "utc" ,
2022-01-06 17:28:31 +00:00
start : convertDate ( event . startTime ) ,
duration : getDuration ( event . startTime , event . endTime ) ,
2021-12-09 15:51:37 +00:00
title : event.title ,
description : getRichDescription ( event ) ,
location : getLocation ( event ) ,
organizer : { email : event.organizer.email , name : event.organizer.name } ,
2022-08-11 17:16:44 +00:00
attendees : getAttendees ( event . attendees ) ,
2022-01-06 17:28:31 +00:00
/** according to https:/ / datatracker . ietf . org / doc / html / rfc2446 # section - 3.2 . 1 , in a published iCalendar component .
* "Attendees" MUST NOT be present
* ` attendees: this.getAttendees(event.attendees), `
2022-08-11 17:16:44 +00:00
* [ UPDATE ] : Since we ' re not using the PUBLISH method to publish the iCalendar event and creating the event directly on iCal ,
* this shouldn ' t be an issue and we should be able to add attendees to the event right here .
2022-01-06 17:28:31 +00:00
* /
2021-12-09 15:51:37 +00:00
} ) ;
2022-08-11 17:16:44 +00:00
if ( error || ! iCalString )
throw new Error ( ` Error creating iCalString:=> ${ error ? . message } : ${ error ? . name } ` ) ;
2021-12-09 15:51:37 +00:00
2022-01-06 17:28:31 +00:00
// We create the event directly on iCal
2021-12-13 13:58:09 +00:00
const responses = await Promise . all (
2021-12-09 15:51:37 +00:00
calendars
. filter ( ( c ) = >
event . destinationCalendar ? . externalId
? c . externalId === event . destinationCalendar . externalId
: true
)
. map ( ( calendar ) = >
createCalendarObject ( {
calendar : {
url : calendar.externalId ,
} ,
filename : ` ${ uid } .ics ` ,
2021-12-13 13:58:09 +00:00
// according to https://datatracker.ietf.org/doc/html/rfc4791#section-4.1, Calendar object resources contained in calendar collections MUST NOT specify the iCalendar METHOD property.
iCalString : iCalString.replace ( /METHOD:[^\r\n]+\r\n/g , "" ) ,
2021-12-09 15:51:37 +00:00
headers : this.headers ,
} )
)
) ;
2021-12-13 13:58:09 +00:00
if ( responses . some ( ( r ) = > ! r . ok ) ) {
throw new Error (
` Error creating event: ${ ( await Promise . all ( responses . map ( ( r ) = > r . text ( ) ) ) ) . join ( ", " ) } `
) ;
}
2021-12-09 15:51:37 +00:00
return {
uid ,
id : uid ,
type : this . integrationName ,
password : "" ,
url : "" ,
2022-01-06 17:28:31 +00:00
additionalInfo : { } ,
2021-12-09 15:51:37 +00:00
} ;
} catch ( reason ) {
2022-01-06 17:28:31 +00:00
logger . error ( reason ) ;
2021-12-09 15:51:37 +00:00
throw reason ;
}
}
2022-06-17 18:34:41 +00:00
async updateEvent (
uid : string ,
event : CalendarEvent
) : Promise < NewCalendarEventType | NewCalendarEventType [ ] > {
2021-12-09 15:51:37 +00:00
try {
2022-01-06 17:28:31 +00:00
const events = await this . getEventsByUID ( uid ) ;
2021-12-09 15:51:37 +00:00
2022-01-21 21:35:31 +00:00
/** We generate the ICS files */
2021-12-09 15:51:37 +00:00
const { error , value : iCalString } = createEvent ( {
uid ,
startInputType : "utc" ,
2022-01-06 17:28:31 +00:00
start : convertDate ( event . startTime ) ,
duration : getDuration ( event . startTime , event . endTime ) ,
2021-12-09 15:51:37 +00:00
title : event.title ,
description : getRichDescription ( event ) ,
location : getLocation ( event ) ,
organizer : { email : event.organizer.email , name : event.organizer.name } ,
2022-01-06 17:28:31 +00:00
attendees : getAttendees ( event . attendees ) ,
2021-12-09 15:51:37 +00:00
} ) ;
if ( error ) {
this . log . debug ( "Error creating iCalString" ) ;
2022-01-06 17:28:31 +00:00
2022-02-10 10:44:46 +00:00
return {
2022-06-17 18:34:41 +00:00
uid ,
2022-02-10 10:44:46 +00:00
type : event . type ,
id : typeof event . uid === "string" ? event . uid : "-1" ,
password : "" ,
url : typeof event . location === "string" ? event . location : "-1" ,
2022-06-17 18:34:41 +00:00
additionalInfo : { } ,
2022-02-10 10:44:46 +00:00
} ;
2021-12-09 15:51:37 +00:00
}
2022-01-21 21:35:31 +00:00
const eventsToUpdate = events . filter ( ( e ) = > e . uid === uid ) ;
2022-01-06 17:28:31 +00:00
return Promise . all (
2022-01-21 21:35:31 +00:00
eventsToUpdate . map ( ( e ) = > {
2021-12-09 15:51:37 +00:00
return updateCalendarObject ( {
calendarObject : {
2022-01-21 21:35:31 +00:00
url : e.url ,
2021-12-09 15:51:37 +00:00
data : iCalString ,
2022-01-21 21:35:31 +00:00
etag : e?.etag ,
2021-12-09 15:51:37 +00:00
} ,
headers : this.headers ,
} ) ;
} )
2022-06-17 18:34:41 +00:00
) . then ( ( p ) = > p . map ( ( r ) = > r . json ( ) as unknown as NewCalendarEventType ) ) ;
2021-12-09 15:51:37 +00:00
} catch ( reason ) {
2022-01-06 17:28:31 +00:00
this . log . error ( reason ) ;
2021-12-09 15:51:37 +00:00
throw reason ;
}
}
async deleteEvent ( uid : string ) : Promise < void > {
try {
2022-01-06 17:28:31 +00:00
const events = await this . getEventsByUID ( uid ) ;
2021-12-09 15:51:37 +00:00
2022-01-06 17:28:31 +00:00
const eventsToDelete = events . filter ( ( event ) = > event . uid === uid ) ;
2021-12-09 15:51:37 +00:00
await Promise . all (
2022-01-06 17:28:31 +00:00
eventsToDelete . map ( ( event ) = > {
2021-12-09 15:51:37 +00:00
return deleteCalendarObject ( {
calendarObject : {
url : event.url ,
etag : event?.etag ,
} ,
headers : this.headers ,
} ) ;
} )
) ;
} catch ( reason ) {
2022-01-06 17:28:31 +00:00
this . log . error ( reason ) ;
2021-12-09 15:51:37 +00:00
throw reason ;
}
}
2022-10-04 09:02:28 +00:00
isValidFormat = ( url : string ) : boolean = > {
const acceptedFormats = [ "eml" , "ics" ] ;
const urlFormat = url . split ( "." ) . pop ( ) ;
if ( urlFormat === undefined ) {
console . error ( "Invalid request, calendar object extension missing" ) ;
return false ;
}
if ( ! acceptedFormats . includes ( urlFormat ) ) {
console . error ( ` Unsupported calendar object format: ${ urlFormat } ` ) ;
return false ;
}
return true ;
} ;
2022-01-29 14:38:53 +00:00
async getAvailability (
2022-01-06 17:28:31 +00:00
dateFrom : string ,
dateTo : string ,
selectedCalendars : IntegrationCalendar [ ]
) : Promise < EventBusyDate [ ] > {
2022-01-29 14:38:53 +00:00
const objects = (
await Promise . all (
2022-02-08 14:56:49 +00:00
selectedCalendars
2022-02-09 17:16:44 +00:00
. filter ( ( sc ) = > [ "caldav_calendar" , "apple_calendar" ] . includes ( sc . integration ? ? "" ) )
2022-02-08 14:56:49 +00:00
. map ( ( sc ) = >
fetchCalendarObjects ( {
2022-10-04 09:02:28 +00:00
urlFilter : ( url : string ) = > this . isValidFormat ( url ) ,
2022-02-08 14:56:49 +00:00
calendar : {
url : sc.externalId ,
} ,
headers : this.headers ,
expand : true ,
timeRange : {
start : new Date ( dateFrom ) . toISOString ( ) ,
end : new Date ( dateTo ) . toISOString ( ) ,
} ,
} )
)
2022-01-29 14:38:53 +00:00
)
) . flat ( ) ;
2022-04-14 22:29:16 +00:00
const events : { start : string ; end : string } [ ] = [ ] ;
objects . forEach ( ( object ) = > {
if ( object . data == null ) return ;
2022-08-25 09:18:30 +00:00
const jcalData = ICAL . parse ( sanitizeCalendarObject ( object ) ) ;
2022-04-14 22:29:16 +00:00
const vcalendar = new ICAL . Component ( jcalData ) ;
const vevent = vcalendar . getFirstSubcomponent ( "vevent" ) ;
2022-08-25 09:18:30 +00:00
// if event status is free or transparent, return
if ( vevent ? . getFirstPropertyValue ( "transp" ) === "TRANSPARENT" ) return ;
2022-04-14 22:29:16 +00:00
const event = new ICAL . Event ( vevent ) ;
const vtimezone = vcalendar . getFirstSubcomponent ( "vtimezone" ) ;
if ( event . isRecurring ( ) ) {
let maxIterations = 365 ;
if ( [ "HOURLY" , "SECONDLY" , "MINUTELY" ] . includes ( event . getRecurrenceTypes ( ) ) ) {
console . error ( ` Won't handle [ ${ event . getRecurrenceTypes ( ) } ] recurrence ` ) ;
return ;
2022-04-05 19:01:47 +00:00
}
2022-01-29 14:38:53 +00:00
2022-04-14 22:29:16 +00:00
const start = dayjs ( dateFrom ) ;
const end = dayjs ( dateTo ) ;
const iterator = event . iterator ( ) ;
let current ;
let currentEvent ;
let currentStart ;
2022-06-12 21:23:14 +00:00
let currentError ;
2022-04-14 22:29:16 +00:00
do {
maxIterations -= 1 ;
current = iterator . next ( ) ;
2022-06-12 21:23:14 +00:00
try {
// @see https://github.com/mozilla-comm/ical.js/issues/514
currentEvent = event . getOccurrenceDetails ( current ) ;
} catch ( error ) {
if ( error instanceof Error && error . message !== currentError ) {
currentError = error . message ;
console . log ( "error" , error ) ;
}
}
if ( ! currentEvent ) return ;
2022-06-15 12:52:24 +00:00
// do not mix up caldav and icalendar! For the recurring events here, the timezone
// provided is relevant, not as pointed out in https://datatracker.ietf.org/doc/html/rfc4791#section-9.6.5
// where recurring events are always in utc (in caldav!). Thus, apply the time zone here.
if ( vtimezone ) {
const zone = new ICAL . Timezone ( vtimezone ) ;
currentEvent . startDate = currentEvent . startDate . convertToZone ( zone ) ;
2022-06-17 18:34:41 +00:00
currentEvent . endDate = currentEvent . endDate . convertToZone ( zone ) ;
2022-06-15 12:52:24 +00:00
}
2022-04-14 22:29:16 +00:00
currentStart = dayjs ( currentEvent . startDate . toJSDate ( ) ) ;
if ( currentStart . isBetween ( start , end ) === true ) {
2022-08-12 21:28:45 +00:00
events . push ( {
2022-04-14 22:29:16 +00:00
start : currentStart.toISOString ( ) ,
end : dayjs ( currentEvent . endDate . toJSDate ( ) ) . toISOString ( ) ,
} ) ;
}
} while ( maxIterations > 0 && currentStart . isAfter ( end ) === false ) ;
if ( maxIterations <= 0 ) {
console . warn ( "could not find any occurrence for recurring event in 365 iterations" ) ;
}
return ;
}
if ( vtimezone ) {
const zone = new ICAL . Timezone ( vtimezone ) ;
event . startDate = event . startDate . convertToZone ( zone ) ;
event . endDate = event . endDate . convertToZone ( zone ) ;
}
return events . push ( {
start : dayjs ( event . startDate . toJSDate ( ) ) . toISOString ( ) ,
end : dayjs ( event . endDate . toJSDate ( ) ) . toISOString ( ) ,
2022-01-29 14:38:53 +00:00
} ) ;
2022-04-14 22:29:16 +00:00
} ) ;
2022-01-06 17:28:31 +00:00
2022-01-29 14:38:53 +00:00
return Promise . resolve ( events ) ;
2021-12-09 15:51:37 +00:00
}
async listCalendars ( event? : CalendarEvent ) : Promise < IntegrationCalendar [ ] > {
try {
const account = await this . getAccount ( ) ;
2022-01-06 17:28:31 +00:00
2021-12-09 15:51:37 +00:00
const calendars = await fetchCalendars ( {
account ,
headers : this.headers ,
} ) ;
return calendars . reduce < IntegrationCalendar [ ] > ( ( newCalendars , calendar ) = > {
if ( ! calendar . components ? . includes ( "VEVENT" ) ) return newCalendars ;
2022-01-06 17:28:31 +00:00
2021-12-09 15:51:37 +00:00
newCalendars . push ( {
externalId : calendar.url ,
name : calendar.displayName ? ? "" ,
primary : event?.destinationCalendar?.externalId
? event . destinationCalendar . externalId === calendar . url
: false ,
integration : this.integrationName ,
} ) ;
return newCalendars ;
} , [ ] ) ;
} catch ( reason ) {
2022-01-06 17:28:31 +00:00
logger . error ( reason ) ;
2021-12-09 15:51:37 +00:00
throw reason ;
}
}
2022-01-06 17:28:31 +00:00
private async getEvents (
2021-12-09 15:51:37 +00:00
calId : string ,
dateFrom : string | null ,
dateTo : string | null ,
objectUrls? : string [ ] | null
) {
try {
const objects = await fetchCalendarObjects ( {
calendar : {
url : calId ,
} ,
objectUrls : objectUrls ? objectUrls : undefined ,
timeRange :
dateFrom && dateTo
? {
2022-01-06 17:28:31 +00:00
start : dayjs ( dateFrom ) . utc ( ) . format ( TIMEZONE_FORMAT ) ,
end : dayjs ( dateTo ) . utc ( ) . format ( TIMEZONE_FORMAT ) ,
2021-12-09 15:51:37 +00:00
}
: undefined ,
headers : this.headers ,
} ) ;
const events = objects
. filter ( ( e ) = > ! ! e . data )
. map ( ( object ) = > {
2022-08-25 09:18:30 +00:00
const jcalData = ICAL . parse ( sanitizeCalendarObject ( object ) ) ;
2022-01-06 17:28:31 +00:00
2021-12-09 15:51:37 +00:00
const vcalendar = new ICAL . Component ( jcalData ) ;
2022-01-06 17:28:31 +00:00
2021-12-09 15:51:37 +00:00
const vevent = vcalendar . getFirstSubcomponent ( "vevent" ) ;
const event = new ICAL . Event ( vevent ) ;
const calendarTimezone =
2022-03-23 22:00:30 +00:00
vcalendar . getFirstSubcomponent ( "vtimezone" ) ? . getFirstPropertyValue < string > ( "tzid" ) || "" ;
2021-12-09 15:51:37 +00:00
const startDate = calendarTimezone
2022-02-09 18:34:47 +00:00
? dayjs . tz ( event . startDate . toString ( ) , calendarTimezone )
2021-12-09 15:51:37 +00:00
: new Date ( event . startDate . toUnixTime ( ) * 1000 ) ;
2022-01-06 17:28:31 +00:00
2021-12-09 15:51:37 +00:00
const endDate = calendarTimezone
2022-02-09 18:34:47 +00:00
? dayjs . tz ( event . endDate . toString ( ) , calendarTimezone )
2021-12-09 15:51:37 +00:00
: new Date ( event . endDate . toUnixTime ( ) * 1000 ) ;
return {
uid : event.uid ,
etag : object.etag ,
url : object.url ,
summary : event.summary ,
description : event.description ,
location : event.location ,
sequence : event.sequence ,
startDate ,
endDate ,
duration : {
weeks : event.duration.weeks ,
days : event.duration.days ,
hours : event.duration.hours ,
minutes : event.duration.minutes ,
seconds : event.duration.seconds ,
isNegative : event.duration.isNegative ,
} ,
organizer : event.organizer ,
attendees : event.attendees.map ( ( a ) = > a . getValues ( ) ) ,
recurrenceId : event.recurrenceId ,
timezone : calendarTimezone ,
} ;
} ) ;
return events ;
} catch ( reason ) {
console . error ( reason ) ;
throw reason ;
}
}
2022-01-06 17:28:31 +00:00
private async getEventsByUID ( uid : string ) : Promise < CalendarEventType [ ] > {
2022-02-10 17:42:06 +00:00
const events : Prisma.PromiseReturnType < typeof this.getEvents > = [ ] ;
2022-01-06 17:28:31 +00:00
const calendars = await this . listCalendars ( ) ;
for ( const cal of calendars ) {
const calEvents = await this . getEvents ( cal . externalId , null , null , [ ` ${ cal . externalId } ${ uid } .ics ` ] ) ;
for ( const ev of calEvents ) {
events . push ( ev ) ;
}
}
return events ;
}
private async getAccount ( ) : Promise < DAVAccount > {
return createAccount ( {
2021-12-09 15:51:37 +00:00
account : {
serverUrl : this.url ,
2022-03-23 22:00:30 +00:00
accountType : DEFAULT_CALENDAR_TYPE ,
2021-12-09 15:51:37 +00:00
credentials : this.credentials ,
} ,
headers : this.headers ,
} ) ;
}
}