diff --git a/apps/web/lib/mutations/bookings/create-recurring-booking.ts b/apps/web/lib/mutations/bookings/create-recurring-booking.ts index 1f64d6c97d..b8df6c90f7 100644 --- a/apps/web/lib/mutations/bookings/create-recurring-booking.ts +++ b/apps/web/lib/mutations/bookings/create-recurring-booking.ts @@ -1,22 +1,46 @@ +import { AppsStatus } from "@calcom/types/Calendar"; + import * as fetch from "@lib/core/http/fetch-wrapper"; import { BookingCreateBody, BookingResponse } from "@lib/types/booking"; type ExtendedBookingCreateBody = BookingCreateBody & { noEmail?: boolean; recurringCount?: number }; const createRecurringBooking = async (data: ExtendedBookingCreateBody[]) => { - return Promise.all( - data.map((booking, key) => { - // We only want to send the first occurrence of the meeting at the moment, not all at once - if (key === 0) { - return fetch.post("/api/book/event", booking); - } else { - return fetch.post("/api/book/event", { - ...booking, - noEmail: true, - }); - } - }) - ); + const createdBookings: BookingResponse[] = []; + // Reversing to accumulate results for noEmail instances first, to then lastly, create the + // emailed booking taking into account accumulated results to send app status accurately + for (let key = 0; key < data.length; key++) { + const booking = data[key]; + if (key === data.length - 1) { + const calcAppsStatus: { [key: string]: AppsStatus } = createdBookings + .flatMap((book) => (book.appsStatus !== undefined ? book.appsStatus : [])) + .reduce((prev, curr) => { + if (prev[curr.type]) { + prev[curr.type].failures += curr.failures; + prev[curr.type].success += curr.success; + } else { + prev[curr.type] = curr; + } + return prev; + }, {} as { [key: string]: AppsStatus }); + const appsStatus = Object.values(calcAppsStatus); + const response = await fetch.post< + ExtendedBookingCreateBody & { appsStatus: AppsStatus[] }, + BookingResponse + >("/api/book/event", { + ...booking, + appsStatus, + }); + createdBookings.push(response); + } else { + const response = await fetch.post("/api/book/event", { + ...booking, + noEmail: true, + }); + createdBookings.push(response); + } + } + return createdBookings; }; export default createRecurringBooking; diff --git a/apps/web/lib/types/booking.ts b/apps/web/lib/types/booking.ts index ce3c635302..52efa4ada7 100644 --- a/apps/web/lib/types/booking.ts +++ b/apps/web/lib/types/booking.ts @@ -1,5 +1,7 @@ import { Attendee, Booking } from "@prisma/client"; +import { AppsStatus } from "@calcom/types/Calendar"; + export type BookingCreateBody = { email: string; end: string; @@ -33,4 +35,5 @@ export type BookingCreateBody = { export type BookingResponse = Booking & { paymentUid?: string; attendees: Attendee[]; + appsStatus?: AppsStatus[]; }; diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index e452d055f5..df64c1b86e 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -543,6 +543,7 @@ "link_shared": "Link shared!", "title": "Title", "description": "Description", + "apps_status": "Apps Status", "quick_video_meeting": "A quick video meeting.", "scheduling_type": "Scheduling Type", "preview_team": "Preview team", diff --git a/packages/app-store/hubspotothercalendar/lib/CalendarService.ts b/packages/app-store/hubspotothercalendar/lib/CalendarService.ts index b1faff7d41..f02e1b7a6f 100644 --- a/packages/app-store/hubspotothercalendar/lib/CalendarService.ts +++ b/packages/app-store/hubspotothercalendar/lib/CalendarService.ts @@ -51,7 +51,19 @@ export default class HubspotOtherCalendarService implements Calendar { }; }); return Promise.all( - simplePublicObjectInputs.map((contact) => hubspotClient.crm.contacts.basicApi.create(contact)) + simplePublicObjectInputs.map((contact) => + hubspotClient.crm.contacts.basicApi.create(contact).catch((error) => { + // If multiple events are created, subsequent events may fail due to + // contact was created by previous event creation, so we introduce a + // fallback taking advantage of the error message providing the contact id + if (error.body.message.includes("Contact already exists. Existing ID:")) { + const split = error.body.message.split("Contact already exists. Existing ID: "); + return { id: split[1] }; + } else { + throw error; + } + }) + ) ); }; @@ -233,7 +245,10 @@ export default class HubspotOtherCalendarService implements Calendar { // Continue with meeting creation and association only when all contacts are present in HubSpot if (createContacts.length) { this.log.debug("contact:creation:ok"); - return await this.handleMeetingCreation(event, createContacts.concat(contacts)); + return await this.handleMeetingCreation( + event, + createContacts.concat(contacts) as SimplePublicObjectInput[] + ); } return Promise.reject("Something went wrong when creating non-existing attendees in HubSpot"); } @@ -243,7 +258,7 @@ export default class HubspotOtherCalendarService implements Calendar { this.log.debug("contact:created", { createContacts }); if (createContacts.length) { this.log.debug("contact:creation:ok"); - return await this.handleMeetingCreation(event, createContacts); + return await this.handleMeetingCreation(event, createContacts as SimplePublicObjectInput[]); } } return Promise.reject("Something went wrong when searching/creating the attendees in HubSpot"); diff --git a/packages/core/CalendarManager.ts b/packages/core/CalendarManager.ts index d47a536d81..ab0cc61876 100644 --- a/packages/core/CalendarManager.ts +++ b/packages/core/CalendarManager.ts @@ -5,6 +5,7 @@ import cache from "memory-cache"; import { getCalendar } from "@calcom/app-store/_utils/getCalendar"; import getApps from "@calcom/app-store/utils"; +import type { ExtendedCredential } from "@calcom/core/EventManager"; import { getUid } from "@calcom/lib/CalEventParser"; import logger from "@calcom/lib/logger"; import { performance } from "@calcom/lib/server/perfObserver"; @@ -184,7 +185,7 @@ export const getBusyCalendarTimes = async ( }; export const createEvent = async ( - credential: Credential, + credential: ExtendedCredential, calEvent: CalendarEvent ): Promise> => { const uid: string = getUid(calEvent); @@ -217,6 +218,7 @@ export const createEvent = async ( : undefined; return { + appName: credential.appName, type: credential.type, success, uid, @@ -226,7 +228,7 @@ export const createEvent = async ( }; export const updateEvent = async ( - credential: Credential, + credential: ExtendedCredential, calEvent: CalendarEvent, bookingRefUid: string | null, externalCalendarId: string | null @@ -255,6 +257,7 @@ export const updateEvent = async ( : undefined; return { + appName: credential.appName, type: credential.type, success, uid, diff --git a/packages/core/EventManager.ts b/packages/core/EventManager.ts index 0a3c75d1f1..79952de89f 100644 --- a/packages/core/EventManager.ts +++ b/packages/core/EventManager.ts @@ -1,5 +1,4 @@ import { Credential, DestinationCalendar } from "@prisma/client"; -import async from "async"; import merge from "lodash/merge"; import { v5 as uuidv5 } from "uuid"; import { z } from "zod"; @@ -65,9 +64,11 @@ type EventManagerUser = { type createdEventSchema = z.infer; +export type ExtendedCredential = Credential & { appName: string }; + export default class EventManager { - calendarCredentials: Credential[]; - videoCredentials: Credential[]; + calendarCredentials: ExtendedCredential[]; + videoCredentials: ExtendedCredential[]; /** * Takes an array of credentials and initializes a new instance of the EventManager. @@ -75,7 +76,9 @@ export default class EventManager { * @param user */ constructor(user: EventManagerUser) { - const appCredentials = getApps(user.credentials).flatMap((app) => app.credentials); + const appCredentials = getApps(user.credentials).flatMap((app) => + app.credentials.map((creds) => ({ ...creds, appName: app.name })) + ); this.calendarCredentials = appCredentials.filter((cred) => cred.type.endsWith("_calendar")); this.videoCredentials = appCredentials.filter((cred) => cred.type.endsWith("_video")); } @@ -306,11 +309,9 @@ export default class EventManager { let createdEvents: EventResult[] = []; if (event.destinationCalendar) { if (event.destinationCalendar.credentialId) { - const credential = await prisma.credential.findFirst({ - where: { - id: event.destinationCalendar.credentialId, - }, - }); + const credential = this.calendarCredentials.find( + (c) => c.id === event.destinationCalendar?.credentialId + ); if (credential) { createdEvents.push(await createEvent(credential, event)); @@ -354,7 +355,7 @@ export default class EventManager { * @private */ - private getVideoCredential(event: CalendarEvent): Credential | undefined { + private getVideoCredential(event: CalendarEvent): ExtendedCredential | undefined { if (!event.location) { return undefined; } @@ -374,7 +375,7 @@ export default class EventManager { * 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) videoCredential = FAKE_DAILY_CREDENTIAL; + if (!videoCredential) videoCredential = { ...FAKE_DAILY_CREDENTIAL, appName: "FAKE" }; return videoCredential; } @@ -409,7 +410,7 @@ export default class EventManager { * @param booking * @private */ - private updateAllCalendarEvents( + private async updateAllCalendarEvents( event: CalendarEvent, booking: PartialBooking ): Promise>> { @@ -424,7 +425,7 @@ export default class EventManager { if (!bookingExternalCalendarId) throw new Error("externalCalendarId"); - const result = []; + let result = []; if (calendarReference.credentialId) { credential = this.calendarCredentials.filter( (credential) => credential.id === calendarReference?.credentialId @@ -439,6 +440,26 @@ export default class EventManager { } } + // 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.appName, + type: cred.type, + success: false, + uid: "", + originalEvent: event, + }; + 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}'`; @@ -446,6 +467,7 @@ export default class EventManager { console.error(message); return Promise.resolve([ { + appName: "none", type: calendarReference?.type || "calendar", success: false, uid: "", diff --git a/packages/core/videoClient.ts b/packages/core/videoClient.ts index cec8ea9820..03f4a84421 100644 --- a/packages/core/videoClient.ts +++ b/packages/core/videoClient.ts @@ -4,6 +4,7 @@ import { v5 as uuidv5 } from "uuid"; import appStore from "@calcom/app-store"; import { getDailyAppKeys } from "@calcom/app-store/dailyvideo/lib/getDailyAppKeys"; +import type { ExtendedCredential } from "@calcom/core/EventManager"; import { sendBrokenIntegrationEmail } from "@calcom/emails"; import { getUid } from "@calcom/lib/CalEventParser"; import logger from "@calcom/lib/logger"; @@ -34,7 +35,7 @@ const getBusyVideoTimes = (withCredentials: Credential[]) => results.reduce((acc, availability) => acc.concat(availability), [] as (EventBusyDate | undefined)[]) ); -const createMeeting = async (credential: Credential, calEvent: CalendarEvent) => { +const createMeeting = async (credential: ExtendedCredential, calEvent: CalendarEvent) => { const uid: string = getUid(calEvent); if (!credential) { @@ -51,6 +52,7 @@ const createMeeting = async (credential: Credential, calEvent: CalendarEvent) => if (!createdMeeting) { return { + appName: credential.appName, type: credential.type, success: false, uid, @@ -70,6 +72,7 @@ const createMeeting = async (credential: Credential, calEvent: CalendarEvent) => } return { + appName: credential.appName, type: credential.type, success: true, uid, @@ -79,7 +82,7 @@ const createMeeting = async (credential: Credential, calEvent: CalendarEvent) => }; const updateMeeting = async ( - credential: Credential, + credential: ExtendedCredential, calEvent: CalendarEvent, bookingRef: PartialReference | null ): Promise> => { @@ -100,6 +103,7 @@ const updateMeeting = async ( if (!updatedMeeting) { return { + appName: credential.appName, type: credential.type, success, uid, @@ -108,6 +112,7 @@ const updateMeeting = async ( } return { + appName: credential.appName, type: credential.type, success, uid, diff --git a/packages/emails/src/components/AppsStatus.tsx b/packages/emails/src/components/AppsStatus.tsx new file mode 100644 index 0000000000..fee2760ede --- /dev/null +++ b/packages/emails/src/components/AppsStatus.tsx @@ -0,0 +1,27 @@ +import { TFunction } from "next-i18next"; + +import type { CalendarEvent } from "@calcom/types/Calendar"; + +import { Info } from "./Info"; + +export const AppsStatus = (props: { calEvent: CalendarEvent; t: TFunction }) => { + const { t } = props; + if (!props.calEvent.appsStatus) return null; + return ( + + {props.calEvent.appsStatus.map((status) => ( +
  • + {status.appName}{" "} + {status.success >= 1 && `✅ ${status.success > 1 ? `(x${status.success})` : ""}`}{" "} + {status.failures >= 1 && `❌ ${status.failures > 1 ? `(x${status.failures})` : ""}`} +
  • + ))} + + } + withSpacer + /> + ); +}; diff --git a/packages/emails/src/components/index.ts b/packages/emails/src/components/index.ts index 688b866d42..a01f573d69 100644 --- a/packages/emails/src/components/index.ts +++ b/packages/emails/src/components/index.ts @@ -9,3 +9,4 @@ export { ManageLink } from "./ManageLink"; export { default as RawHtml } from "./RawHtml"; export { WhenInfo } from "./WhenInfo"; export { WhoInfo } from "./WhoInfo"; +export { AppsStatus } from "./AppsStatus"; diff --git a/packages/emails/src/templates/BaseScheduledEmail.tsx b/packages/emails/src/templates/BaseScheduledEmail.tsx index a00f98e1ac..107901277f 100644 --- a/packages/emails/src/templates/BaseScheduledEmail.tsx +++ b/packages/emails/src/templates/BaseScheduledEmail.tsx @@ -1,7 +1,7 @@ import type { TFunction } from "next-i18next"; import dayjs from "@calcom/dayjs"; -import type { CalendarEvent, Person } from "@calcom/types/Calendar"; +import type { AppsStatus as AppsStatusType, CalendarEvent, Person } from "@calcom/types/Calendar"; import { BaseEmailHtml, @@ -11,6 +11,7 @@ import { ManageLink, WhenInfo, WhoInfo, + AppsStatus, } from "../components"; export const BaseScheduledEmail = ( @@ -18,6 +19,7 @@ export const BaseScheduledEmail = ( calEvent: CalendarEvent; attendee: Person; timeZone: string; + includeAppsStatus?: boolean; t: TFunction; } & Partial> ) => { @@ -70,6 +72,7 @@ export const BaseScheduledEmail = ( + {props.includeAppsStatus && } ); diff --git a/packages/emails/src/templates/OrganizerScheduledEmail.tsx b/packages/emails/src/templates/OrganizerScheduledEmail.tsx index 9740476bfe..2056d3d1f3 100644 --- a/packages/emails/src/templates/OrganizerScheduledEmail.tsx +++ b/packages/emails/src/templates/OrganizerScheduledEmail.tsx @@ -33,6 +33,7 @@ export const OrganizerScheduledEmail = ( t={t} subject={t(subject)} title={t(title)} + includeAppsStatus {...props} /> ); diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index 51dee551c5..78f565689b 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -44,7 +44,7 @@ import { updateWebUser as syncServicesUpdateWebUser } from "@calcom/lib/sync/Syn import prisma, { userSelect } from "@calcom/prisma"; import { EventTypeMetaDataSchema, extendedBookingCreateBody } from "@calcom/prisma/zod-utils"; import type { BufferedBusyTime } from "@calcom/types/BufferedBusyTime"; -import type { AdditionalInformation, CalendarEvent } from "@calcom/types/Calendar"; +import type { AdditionalInformation, AppsStatus, CalendarEvent } from "@calcom/types/Calendar"; import type { EventResult, PartialReference } from "@calcom/types/EventManager"; import sendPayload, { EventTypeInfo } from "../../webhooks/lib/sendPayload"; @@ -248,8 +248,16 @@ async function ensureAvailableUsers( async function handler(req: NextApiRequest & { userId?: number }) { const { userId } = req; - const { recurringCount, noEmail, eventTypeSlug, eventTypeId, hasHashedBookingLink, language, ...reqBody } = - extendedBookingCreateBody.parse(req.body); + const { + recurringCount, + noEmail, + eventTypeSlug, + eventTypeId, + hasHashedBookingLink, + language, + appsStatus: reqAppsStatus, + ...reqBody + } = extendedBookingCreateBody.parse(req.body); // handle dynamic user const dynamicUserList = Array.isArray(reqBody.user) @@ -689,7 +697,7 @@ async function handler(req: NextApiRequest & { userId?: number }) { let referencesToCreate: PartialReference[] = []; type Booking = Prisma.PromiseReturnType; - let booking: Booking | null = null; + let booking: (Booking & { appsStatus?: AppsStatus[] }) | null = null; try { booking = await createBooking(); // Sync Services @@ -713,6 +721,39 @@ async function handler(req: NextApiRequest & { userId?: number }) { const credentials = await refreshCredentials(organizerUser.credentials); const eventManager = new EventManager({ ...organizerUser, credentials }); + function handleAppsStatus( + results: EventResult[], + booking: (Booking & { appsStatus?: AppsStatus[] }) | null + ) { + // Taking care of apps status + const resultStatus: AppsStatus[] = results.map((app) => ({ + appName: app.appName, + type: app.type, + success: app.success ? 1 : 0, + failures: !app.success ? 1 : 0, + })); + + if (reqAppsStatus === undefined) { + if (booking !== null) { + booking.appsStatus = resultStatus; + } + evt.appsStatus = resultStatus; + return; + } + // From down here we can assume reqAppsStatus is not undefined anymore + // Other status exist, so this is the last booking of a series, + // proceeding to prepare the info for the event + const calcAppsStatus = reqAppsStatus.concat(resultStatus).reduce((prev, curr) => { + if (prev[curr.type]) { + prev[curr.type].success += curr.success; + } else { + prev[curr.type] = curr; + } + return prev; + }, {} as { [key: string]: AppsStatus }); + evt.appsStatus = Object.values(calcAppsStatus); + } + if (originalRescheduledBooking?.uid) { // Use EventManager to conditionally use all needed integrations. const updateManager = await eventManager.reschedule( @@ -746,6 +787,7 @@ async function handler(req: NextApiRequest & { userId?: number }) { metadata.hangoutLink = updatedEvent.hangoutLink; metadata.conferenceData = updatedEvent.conferenceData; metadata.entryPoints = updatedEvent.entryPoints; + handleAppsStatus(results, booking); } } @@ -785,6 +827,7 @@ async function handler(req: NextApiRequest & { userId?: number }) { metadata.hangoutLink = results[0].createdEvent?.hangoutLink; metadata.conferenceData = results[0].createdEvent?.conferenceData; metadata.entryPoints = results[0].createdEvent?.entryPoints; + handleAppsStatus(results, booking); } if (noEmail !== true) { await sendScheduledEmails({ diff --git a/packages/lib/CalEventParser.ts b/packages/lib/CalEventParser.ts index efdf8ee265..462397c2af 100644 --- a/packages/lib/CalEventParser.ts +++ b/packages/lib/CalEventParser.ts @@ -73,6 +73,20 @@ ${calEvent.customInputs[key]} return customInputsString; }; +export const getAppsStatus = (calEvent: CalendarEvent) => { + if (!calEvent.appsStatus) { + return ""; + } + return `\n${calEvent.attendees[0].language.translate("apps_status")} + \n${calEvent.appsStatus.map( + (app) => + `\t${app.appName} ${app.success >= 1 && `✅ ${app.success > 1 ? `(x${app.success})` : ""}`} ${ + app.failures >= 1 && `❌ ${app.failures > 1 ? `(x${app.failures})` : ""}` + }` + )} + `; +}; + export const getDescription = (calEvent: CalendarEvent) => { if (!calEvent.description) { return ""; @@ -132,6 +146,7 @@ ${getLocation(calEvent)} ${getDescription(calEvent)} ${getAdditionalNotes(calEvent)} ${getCustomInputs(calEvent)} +${getAppsStatus(calEvent)} ${ // TODO: Only the original attendee can make changes to the event // Guests cannot diff --git a/packages/prisma/zod-utils.ts b/packages/prisma/zod-utils.ts index d51cd33fb5..cb9400450e 100644 --- a/packages/prisma/zod-utils.ts +++ b/packages/prisma/zod-utils.ts @@ -147,6 +147,16 @@ export const extendedBookingCreateBody = bookingCreateBodySchema.merge( recurringCount: z.number().optional(), rescheduleReason: z.string().optional(), smsReminderNumber: z.string().optional(), + appsStatus: z + .array( + z.object({ + appName: z.string(), + success: z.number(), + failures: z.number(), + type: z.string(), + }) + ) + .optional(), }) ); diff --git a/packages/types/Calendar.d.ts b/packages/types/Calendar.d.ts index 5e003f1c48..56fa450be3 100644 --- a/packages/types/Calendar.d.ts +++ b/packages/types/Calendar.d.ts @@ -114,6 +114,13 @@ export interface BookingLimit { PER_YEAR?: number | undefined; } +export type AppsStatus = { + appName: string; + type: typeof App["type"]; + success: number; + failures: number; +}; + // If modifying this interface, probably should update builders/calendarEvent files export interface CalendarEvent { type: string; @@ -143,6 +150,7 @@ export interface CalendarEvent { recurrence?: string; recurringEvent?: RecurringEvent | null; eventTypeId?: number | null; + appsStatus?: AppsStatus[]; seatsShowAttendees?: boolean | null; } diff --git a/packages/types/EventManager.d.ts b/packages/types/EventManager.d.ts index 57f3f0f26f..1fc36a7bfd 100644 --- a/packages/types/EventManager.d.ts +++ b/packages/types/EventManager.d.ts @@ -16,6 +16,7 @@ export interface PartialReference { export interface EventResult { type: string; + appName: string; success: boolean; uid: string; createdEvent?: T;