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 <wollencarina@gmail.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Hariom <hariombalhara@gmail.com>
This commit is contained in:
authored and GitHub committed 2024-01-16 13:49:54 +05:30
1 parent f41f4b01a9
commit 0a7f65cd9b
12 files changed
+269 -101

No files matched your search

+26 -5
View File
@@ -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<typeof handleNewBooking> = 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;
}
@@ -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<calendar_v3.Schema$Event> {
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<any> {
const payload: calendar_v3.Schema$Event = {
+3
View File
@@ -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,
@@ -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;
@@ -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<unknown>
);
} 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<unknown>[] = [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)}`);
@@ -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<ReturnType<typeof getEventTypesFromDB>> & {
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<AdditionalInformation & { url?: string; iCalUID?: string }>[] = [];
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,
};
}
+1 -1
View File
@@ -25,7 +25,7 @@ export type RecurringBookingCreateBody = BookingCreateBody & {
noEmail?: boolean;
recurringCount?: number;
appsStatus?: AppsStatus[] | undefined;
allRecurringDates?: string[];
allRecurringDates?: Record<string, string>[];
currentRecurringIndex?: number;
};
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "BookingReference" ADD COLUMN "thirdPartyRecurringEventId" TEXT;
+12 -11
View File
@@ -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])
+9 -1
View File
@@ -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(),
+6
View File
@@ -63,6 +63,7 @@ export type AdditionalInfo = Record<string, unknown> & { 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;
+1
View File
@@ -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;