From 0a7f65cd9b48291d6af02bf7b1c2447e9a8426fe Mon Sep 17 00:00:00 2001 From: Haran Rajkumar Date: Tue, 16 Jan 2024 03:19:54 -0500 Subject: [PATCH] fix: Use Google recurring events protocol (#12651) * working create event --wip-- [skip ci] fix bug related to adding event --wip-- [skip ci] --wip-- [skip ci] add delete all remove console.log remove console.log add patching of instances fix yarn.lock remove comments * fixed typing * use rrule * rollback tsconfig changes * fix comments * fix comments * rename db column * fix delete all booking * Update apps/web/pages/api/book/recurring-event.ts Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com> * remove non-null assertion * attempt to fix type errors * fix recurring event logic * remove skipAvailabilityCheck --------- Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com> Co-authored-by: CarinaWolli Co-authored-by: Peer Richelsen Co-authored-by: Hariom --- apps/web/pages/api/book/recurring-event.ts | 31 +++- .../googlecalendar/lib/CalendarService.ts | 101 +++++++++++-- packages/core/EventManager.ts | 3 + packages/core/builders/CalendarEvent/class.ts | 2 + .../bookings/lib/handleCancelBooking.ts | 49 +++--- .../features/bookings/lib/handleNewBooking.ts | 140 +++++++++++------- packages/features/bookings/types.ts | 2 +- .../migration.sql | 2 + packages/prisma/schema.prisma | 23 +-- packages/prisma/zod-utils.ts | 10 +- packages/types/Calendar.d.ts | 6 + packages/types/EventManager.d.ts | 1 + 12 files changed, 269 insertions(+), 101 deletions(-) create mode 100644 packages/prisma/migrations/20231206220118_add_recurring_event_id/migration.sql diff --git a/apps/web/pages/api/book/recurring-event.ts b/apps/web/pages/api/book/recurring-event.ts index 3d1566411a..6462a9c47d 100644 --- a/apps/web/pages/api/book/recurring-event.ts +++ b/apps/web/pages/api/book/recurring-event.ts @@ -20,13 +20,16 @@ async function handler(req: NextApiRequest & { userId?: number }, res: NextApiRe const data: RecurringBookingCreateBody[] = req.body; const session = await getServerSession({ req, res }); const createdBookings: BookingResponse[] = []; - const allRecurringDates: string[] = data.map((booking) => booking.start); + const allRecurringDates: { start: string | undefined; end: string | undefined }[] = data.map((booking) => { + return { start: booking.start, end: booking.end }; + }); const appsStatus: AppsStatus[] | undefined = undefined; /* To mimic API behavior and comply with types */ req.userId = session?.user?.id || -1; const numSlotsToCheckForAvailability = 2; + let thirdPartyRecurringEventId = null; for (let key = 0; key < data.length; key++) { const booking = data[key]; // Disable AppStatus in Recurring Booking Email as it requires us to iterate backwards to be able to compute the AppsStatus for all the bookings except the very first slot and then send that slot's email with statuses @@ -53,16 +56,34 @@ async function handler(req: NextApiRequest & { userId?: number }, res: NextApiRe ...booking, appsStatus, allRecurringDates, + isFirstRecurringSlot: key == 0, + thirdPartyRecurringEventId, + numSlotsToCheckForAvailability, currentRecurringIndex: key, noEmail: key !== 0, }; - const eachRecurringBooking = await handleNewBooking(recurringEventReq, { - isNotAnApiCall: true, - skipAvailabilityCheck: key >= numSlotsToCheckForAvailability, - }); + const promiseEachRecurringBooking: ReturnType = handleNewBooking( + recurringEventReq, + { + isNotAnApiCall: true, + } + ); + + const eachRecurringBooking = await promiseEachRecurringBooking; createdBookings.push(eachRecurringBooking); + + if (!thirdPartyRecurringEventId) { + if (eachRecurringBooking.references && eachRecurringBooking.references.length > 0) { + for (const reference of eachRecurringBooking.references!) { + if (reference.thirdPartyRecurringEventId) { + thirdPartyRecurringEventId = reference.thirdPartyRecurringEventId; + break; + } + } + } + } } return createdBookings; } diff --git a/packages/app-store/googlecalendar/lib/CalendarService.ts b/packages/app-store/googlecalendar/lib/CalendarService.ts index 80f77cb2a8..079a9c420a 100644 --- a/packages/app-store/googlecalendar/lib/CalendarService.ts +++ b/packages/app-store/googlecalendar/lib/CalendarService.ts @@ -2,6 +2,7 @@ import type { Prisma } from "@prisma/client"; import type { calendar_v3 } from "googleapis"; import { google } from "googleapis"; +import { RRule } from "rrule"; import { MeetLocationType } from "@calcom/app-store/locations"; import dayjs from "@calcom/dayjs"; @@ -209,6 +210,16 @@ export default class GoogleCalendarService implements Calendar { payload["location"] = getLocation(calEventRaw); } + if (calEventRaw.recurringEvent) { + const rule = new RRule({ + freq: calEventRaw.recurringEvent.freq, + interval: calEventRaw.recurringEvent.interval, + count: calEventRaw.recurringEvent.count, + }); + + payload["recurrence"] = [rule.toString()]; + } + if (calEventRaw.conferenceData && calEventRaw.location === MeetLocationType) { payload["conferenceData"] = calEventRaw.conferenceData; } @@ -220,22 +231,70 @@ export default class GoogleCalendarService implements Calendar { "primary"; try { - const event = await calendar.events.insert({ - calendarId: selectedCalendar, - requestBody: payload, - conferenceDataVersion: 1, - sendUpdates: "none", - }); + let event; + let recurringEventId = null; + if (calEventRaw.existingRecurringEvent) { + recurringEventId = calEventRaw.existingRecurringEvent.recurringEventId; + const recurringEventInstances = await calendar.events.instances({ + calendarId: selectedCalendar, + eventId: calEventRaw.existingRecurringEvent.recurringEventId, + }); + if (recurringEventInstances.data.items) { + const calComEventStartTime = dayjs(calEventRaw.startTime).format(); + for (let i = 0; i < recurringEventInstances.data.items.length; i++) { + const instance = recurringEventInstances.data.items[i]; + const instanceStartTime = dayjs(instance.start?.dateTime) + .tz(instance.start?.timeZone == null ? undefined : instance.start?.timeZone) + .format(); - if (event && event.data.id && event.data.hangoutLink) { + if (instanceStartTime === calComEventStartTime) { + event = instance; + break; + } + } + + if (!event) { + event = recurringEventInstances.data.items[0]; + this.log.error( + "Unable to find matching event amongst recurring event instances", + safeStringify({ selectedCalendar, credentialId }) + ); + } + await calendar.events.patch({ + calendarId: selectedCalendar, + eventId: event.id || "", + requestBody: { + description: getRichDescription({ + ...calEventRaw, + }), + }, + }); + } + } else { + const eventResponse = await calendar.events.insert({ + calendarId: selectedCalendar, + requestBody: payload, + conferenceDataVersion: 1, + sendUpdates: "none", + }); + event = eventResponse.data; + if (event.recurrence) { + if (event.recurrence.length > 0) { + recurringEventId = event.id; + event = await this.getFirstEventInRecurrence(recurringEventId, selectedCalendar, calendar); + } + } + } + + if (event && event.id && event.hangoutLink) { await calendar.events.patch({ // Update the same event but this time we know the hangout link calendarId: selectedCalendar, - eventId: event.data.id || "", + eventId: event.id || "", requestBody: { description: getRichDescription({ ...calEventRaw, - additionalInformation: { hangoutLink: event.data.hangoutLink }, + additionalInformation: { hangoutLink: event.hangoutLink }, }), }, }); @@ -243,14 +302,16 @@ export default class GoogleCalendarService implements Calendar { return { uid: "", - ...event.data, - id: event.data.id || "", + ...event, + id: event?.id || "", + thirdPartyRecurringEventId: recurringEventId, additionalInfo: { - hangoutLink: event.data.hangoutLink || "", + hangoutLink: event?.hangoutLink || "", }, type: "google_calendar", password: "", url: "", + iCalUID: event?.iCalUID, }; } catch (error) { this.log.error( @@ -260,6 +321,22 @@ export default class GoogleCalendarService implements Calendar { throw error; } } + async getFirstEventInRecurrence( + recurringEventId: string | null | undefined, + selectedCalendar: string, + calendar: calendar_v3.Calendar + ): Promise { + const recurringEventInstances = await calendar.events.instances({ + calendarId: selectedCalendar, + eventId: recurringEventId || "", + }); + + if (recurringEventInstances.data.items) { + return recurringEventInstances.data.items[0]; + } else { + return {} as calendar_v3.Schema$Event; + } + } async updateEvent(uid: string, event: CalendarEvent, externalCalendarId: string): Promise { const payload: calendar_v3.Schema$Event = { diff --git a/packages/core/EventManager.ts b/packages/core/EventManager.ts index 673087d954..719d1b9949 100644 --- a/packages/core/EventManager.ts +++ b/packages/core/EventManager.ts @@ -173,6 +173,7 @@ export default class EventManager { // 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)); @@ -180,11 +181,13 @@ export default class EventManager { 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, diff --git a/packages/core/builders/CalendarEvent/class.ts b/packages/core/builders/CalendarEvent/class.ts index 5f2e16bad3..2c774a2414 100644 --- a/packages/core/builders/CalendarEvent/class.ts +++ b/packages/core/builders/CalendarEvent/class.ts @@ -4,6 +4,7 @@ import type { AdditionalInformation, CalendarEvent, ConferenceData, + ExistingRecurringEvent, Person, VideoCallData, } from "@calcom/types/Calendar"; @@ -22,6 +23,7 @@ class CalendarEventClass implements CalendarEvent { conferenceData?: ConferenceData; additionalInformation?: AdditionalInformation; uid?: string | null; + existingRecurringEvent?: ExistingRecurringEvent | null; videoCallData?: VideoCallData; paymentInfo?: any; destinationCalendar?: DestinationCalendar[] | null; diff --git a/packages/features/bookings/lib/handleCancelBooking.ts b/packages/features/bookings/lib/handleCancelBooking.ts index a9e1d28418..6d39d5ba43 100644 --- a/packages/features/bookings/lib/handleCancelBooking.ts +++ b/packages/features/bookings/lib/handleCancelBooking.ts @@ -59,6 +59,7 @@ async function getBookingToDelete(id: number | undefined, uid: string | undefine type: true, externalCalendarId: true, credentialId: true, + thirdPartyRecurringEventId: true, }, }, payment: true, @@ -458,24 +459,37 @@ async function handler(req: CustomRequest) { bookingToDelete.recurringEventId && allRemainingBookings ) { - const promises = bookingToDelete.user.credentials - .filter((credential) => credential.type.endsWith("_calendar")) - .map(async (credential) => { - const calendar = await getCalendar(credential); - for (const updBooking of updatedBookings) { - const bookingRef = updBooking.references.find((ref) => ref.type.includes("_calendar")); - if (bookingRef) { - const { uid, externalCalendarId } = bookingRef; - const deletedEvent = await calendar?.deleteEvent(uid, evt, externalCalendarId); - apiDeletes.push(deletedEvent); + let thirdPartyRecurringEventId; + for (const reference of bookingToDelete.references) { + if (reference.thirdPartyRecurringEventId) { + thirdPartyRecurringEventId = reference.thirdPartyRecurringEventId; + break; + } + } + if (thirdPartyRecurringEventId) { + apiDeletes.push( + calendar?.deleteEvent(thirdPartyRecurringEventId, evt, externalCalendarId) as Promise + ); + } else { + const promises = bookingToDelete.user.credentials + .filter((credential) => credential.type.endsWith("_calendar")) + .map(async (credential) => { + const calendar = await getCalendar(credential); + for (const updBooking of updatedBookings) { + const bookingRef = updBooking.references.find((ref) => ref.type.includes("_calendar")); + if (bookingRef) { + const { uid, externalCalendarId } = bookingRef; + const deletedEvent = await calendar?.deleteEvent(uid, evt, externalCalendarId); + apiDeletes.push(deletedEvent); + } } + }); + try { + await Promise.all(promises); + } catch (error) { + if (error instanceof Error) { + logger.error(error.message); } - }); - try { - await Promise.all(promises); - } catch (error) { - if (error instanceof Error) { - logger.error(error.message); } } } else { @@ -666,7 +680,8 @@ async function handler(req: CustomRequest) { const prismaPromises: Promise[] = [bookingReferenceDeletes]; try { - const settled = await Promise.allSettled(prismaPromises.concat(apiDeletes)); + const temp = prismaPromises.concat(apiDeletes); + const settled = await Promise.allSettled(temp); const rejected = settled.filter(({ status }) => status === "rejected") as PromiseRejectedResult[]; if (rejected.length) { throw new Error(`Reasons: ${rejected.map(({ reason }) => reason)}`); diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index e495aadd4e..c413d96e0d 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -985,13 +985,10 @@ async function handler( req: NextApiRequest & { userId?: number | undefined }, { isNotAnApiCall = false, - skipAvailabilityCheck = false, }: { isNotAnApiCall?: boolean; - skipAvailabilityCheck?: boolean; } = { isNotAnApiCall: false, - skipAvailabilityCheck: false, } ) { const { userId } = req; @@ -1061,7 +1058,6 @@ async function handler( isTeamEventType, eventType: getPiiFreeEventType(eventType), dynamicUserList, - skipAvailabilityCheck, paymentAppData: { enabled: paymentAppData.enabled, price: paymentAppData.price, @@ -1230,58 +1226,85 @@ async function handler( } //checks what users are available - if (!eventType.seatsPerTimeSlot && !skipAvailabilityCheck) { - const availableUsers = await ensureAvailableUsers( - { - ...eventType, - users: users as IsFixedAwareUser[], - ...(eventType.recurringEvent && { - recurringEvent: { - ...eventType.recurringEvent, - count: recurringCount || eventType.recurringEvent.count, - }, - }), - }, - { - dateFrom: dayjs(reqBody.start).tz(reqBody.timeZone).format(), - dateTo: dayjs(reqBody.end).tz(reqBody.timeZone).format(), - timeZone: reqBody.timeZone, - originalRescheduledBooking, - }, - loggerWithEventDetails - ); - - const luckyUsers: typeof users = []; - const luckyUserPool = availableUsers.filter((user) => !user.isFixed); - loggerWithEventDetails.debug( - "Computed available users", - safeStringify({ - availableUsers: availableUsers.map((user) => user.id), - luckyUserPool: luckyUserPool.map((user) => user.id), - }) - ); - // loop through all non-fixed hosts and get the lucky users - while (luckyUserPool.length > 0 && luckyUsers.length < 1 /* TODO: Add variable */) { - const newLuckyUser = await getLuckyUser("MAXIMIZE_AVAILABILITY", { - // find a lucky user that is not already in the luckyUsers array - availableUsers: luckyUserPool.filter( - (user) => !luckyUsers.find((existing) => existing.id === user.id) - ), - eventTypeId: eventType.id, - }); - if (!newLuckyUser) { - break; // prevent infinite loop + if (!eventType.seatsPerTimeSlot) { + const eventTypeWithUsers: Awaited> & { + users: IsFixedAwareUser[]; + } = { + ...eventType, + users: users as IsFixedAwareUser[], + ...(eventType.recurringEvent && { + recurringEvent: { + ...eventType.recurringEvent, + count: recurringCount || eventType.recurringEvent.count, + }, + }), + }; + if (req.body.allRecurringDates) { + if (req.body.isFirstRecurringSlot) { + for ( + let i = 0; + i < req.body.allRecurringDates.length && i < req.body.numSlotsToCheckForAvailability; + i++ + ) { + const start = req.body.allRecurringDates[i].start; + const end = req.body.allRecurringDates[i].end; + await ensureAvailableUsers( + eventTypeWithUsers, + { + dateFrom: dayjs(start).tz(reqBody.timeZone).format(), + dateTo: dayjs(end).tz(reqBody.timeZone).format(), + timeZone: reqBody.timeZone, + originalRescheduledBooking, + }, + loggerWithEventDetails + ); + } } - luckyUsers.push(newLuckyUser); } - // ALL fixed users must be available - if ( - availableUsers.filter((user) => user.isFixed).length !== users.filter((user) => user.isFixed).length - ) { - throw new Error(ErrorCode.HostsUnavailableForBooking); + if (!req.body.allRecurringDates || req.body.isFirstRecurringSlot) { + const availableUsers = await ensureAvailableUsers( + eventTypeWithUsers, + { + dateFrom: dayjs(reqBody.start).tz(reqBody.timeZone).format(), + dateTo: dayjs(reqBody.end).tz(reqBody.timeZone).format(), + timeZone: reqBody.timeZone, + originalRescheduledBooking, + }, + loggerWithEventDetails + ); + + const luckyUsers: typeof users = []; + const luckyUserPool = availableUsers.filter((user) => !user.isFixed); + loggerWithEventDetails.debug( + "Computed available users", + safeStringify({ + availableUsers: availableUsers.map((user) => user.id), + luckyUserPool: luckyUserPool.map((user) => user.id), + }) + ); + // loop through all non-fixed hosts and get the lucky users + while (luckyUserPool.length > 0 && luckyUsers.length < 1 /* TODO: Add variable */) { + const newLuckyUser = await getLuckyUser("MAXIMIZE_AVAILABILITY", { + // find a lucky user that is not already in the luckyUsers array + availableUsers: luckyUserPool.filter( + (user) => !luckyUsers.find((existing) => existing.id === user.id) + ), + eventTypeId: eventType.id, + }); + if (!newLuckyUser) { + break; // prevent infinite loop + } + luckyUsers.push(newLuckyUser); + } + // ALL fixed users must be available + if ( + availableUsers.filter((user) => user.isFixed).length !== users.filter((user) => user.isFixed).length + ) { + throw new Error(ErrorCode.HostsUnavailableForBooking); + } + // Pushing fixed user before the luckyUser guarantees the (first) fixed user as the organizer. + users = [...availableUsers.filter((user) => user.isFixed), ...luckyUsers]; } - // Pushing fixed user before the luckyUser guarantees the (first) fixed user as the organizer. - users = [...availableUsers.filter((user) => user.isFixed), ...luckyUsers]; } const [organizerUser] = users; @@ -1459,6 +1482,12 @@ async function handler( iCalSequence, }; + if (req.body.thirdPartyRecurringEventId) { + evt.existingRecurringEvent = { + recurringEventId: req.body.thirdPartyRecurringEventId, + }; + } + if (isTeamEventType && eventType.schedulingType === "COLLECTIVE") { evt.destinationCalendar?.push(...teamDestinationCalendars); } @@ -1563,7 +1592,9 @@ async function handler( let results: EventResult[] = []; let referencesToCreate: PartialReference[] = []; - let booking: (Booking & { appsStatus?: AppsStatus[] }) | null = null; + let booking: (Booking & { appsStatus?: AppsStatus[]; paymentUid?: string; paymentId?: number }) | null = + null; + loggerWithEventDetails.debug( "Going to create booking in DB now", safeStringify({ @@ -2253,6 +2284,7 @@ async function handler( req.statusCode = 201; return { ...booking, + references: referencesToCreate, seatReferenceUid: evt.attendeeSeatId, }; } diff --git a/packages/features/bookings/types.ts b/packages/features/bookings/types.ts index ef60006518..1b413c9e6a 100644 --- a/packages/features/bookings/types.ts +++ b/packages/features/bookings/types.ts @@ -25,7 +25,7 @@ export type RecurringBookingCreateBody = BookingCreateBody & { noEmail?: boolean; recurringCount?: number; appsStatus?: AppsStatus[] | undefined; - allRecurringDates?: string[]; + allRecurringDates?: Record[]; currentRecurringIndex?: number; }; diff --git a/packages/prisma/migrations/20231206220118_add_recurring_event_id/migration.sql b/packages/prisma/migrations/20231206220118_add_recurring_event_id/migration.sql new file mode 100644 index 0000000000..47880a1260 --- /dev/null +++ b/packages/prisma/migrations/20231206220118_add_recurring_event_id/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "BookingReference" ADD COLUMN "thirdPartyRecurringEventId" TEXT; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 41f3a836ef..37f0045905 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -373,19 +373,20 @@ model InstantMeetingToken { } model BookingReference { - id Int @id @default(autoincrement()) + id Int @id @default(autoincrement()) /// @zod.min(1) - type String + type String /// @zod.min(1) - uid String - meetingId String? - meetingPassword String? - meetingUrl String? - booking Booking? @relation(fields: [bookingId], references: [id], onDelete: Cascade) - bookingId Int? - externalCalendarId String? - deleted Boolean? - credentialId Int? + uid String + meetingId String? + thirdPartyRecurringEventId String? + meetingPassword String? + meetingUrl String? + booking Booking? @relation(fields: [bookingId], references: [id], onDelete: Cascade) + bookingId Int? + externalCalendarId String? + deleted Boolean? + credentialId Int? @@index([bookingId]) @@index([credentialId]) diff --git a/packages/prisma/zod-utils.ts b/packages/prisma/zod-utils.ts index d81f43ccb2..2cd4232581 100644 --- a/packages/prisma/zod-utils.ts +++ b/packages/prisma/zod-utils.ts @@ -250,7 +250,14 @@ export const extendedBookingCreateBody = bookingCreateBodySchema.merge( z.object({ noEmail: z.boolean().optional(), recurringCount: z.number().optional(), - allRecurringDates: z.string().array().optional(), + allRecurringDates: z + .array( + z.object({ + start: z.string(), + end: z.string(), + }) + ) + .optional(), currentRecurringIndex: z.number().optional(), appsStatus: z .array( @@ -301,6 +308,7 @@ export const vitalSettingsUpdateSchema = z.object({ export const createdEventSchema = z .object({ id: z.string(), + thirdPartyRecurringEventId: z.string(), password: z.union([z.string(), z.undefined()]), onlineMeetingUrl: z.string().nullable(), iCalUID: z.string().optional(), diff --git a/packages/types/Calendar.d.ts b/packages/types/Calendar.d.ts index 811cfc923d..775d8b7c23 100644 --- a/packages/types/Calendar.d.ts +++ b/packages/types/Calendar.d.ts @@ -63,6 +63,7 @@ export type AdditionalInfo = Record & { calWarnings?: string[] export type NewCalendarEventType = { uid: string; id: string; + thirdPartyRecurringEventId?: string | null; type: string; password: string; url: string; @@ -143,6 +144,10 @@ export type CalEventResponses = Record< } >; +export interface ExistingRecurringEvent { + recurringEventId: string; +} + // If modifying this interface, probably should update builders/calendarEvent files export interface CalendarEvent { // Instead of sending this per event. @@ -167,6 +172,7 @@ export interface CalendarEvent { conferenceData?: ConferenceData; additionalInformation?: AdditionalInformation; uid?: string | null; + existingRecurringEvent?: ExistingRecurringEvent | null; bookingId?: number; videoCallData?: VideoCallData; paymentInfo?: PaymentInfo | null; diff --git a/packages/types/EventManager.d.ts b/packages/types/EventManager.d.ts index 14209e0b41..3c133cc93d 100644 --- a/packages/types/EventManager.d.ts +++ b/packages/types/EventManager.d.ts @@ -6,6 +6,7 @@ export interface PartialReference { uid: string; meetingId?: string | null; meetingPassword?: string | null; + thirdPartyRecurringEventId?: string | null; meetingUrl?: string | null; externalCalendarId?: string | null; credentialId?: number | null;