Apps Status + Updates to non-traditional calendars (#5034)

* Apps Status + Updates to non-traditional calendars

* Recurring booking tweaks

* Last tweaks

* Fixing checks

* Fixing eslint

* Reverting unneeded changes, using plain text email

* More unneeded changes revert

* Update packages/features/bookings/lib/handleNewBooking.ts

Co-authored-by: Omar López <zomars@me.com>

* Fixing lint

* Refactored appsStatus in emails

* Update packages/emails/src/templates/OrganizerScheduledEmail.tsx

Co-authored-by: Omar López <zomars@me.com>
This commit is contained in:
Leo Giovanetti
2022-10-19 13:11:50 -03:00
committed by GitHub
co-authored by Omar López
parent f298865d9e
commit b6be94fd14
16 changed files with 220 additions and 38 deletions
@@ -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<ExtendedBookingCreateBody, BookingResponse>("/api/book/event", booking);
} else {
return fetch.post<ExtendedBookingCreateBody, BookingResponse>("/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<ExtendedBookingCreateBody, BookingResponse>("/api/book/event", {
...booking,
noEmail: true,
});
createdBookings.push(response);
}
}
return createdBookings;
};
export default createRecurringBooking;
+3
View File
@@ -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[];
};
@@ -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",
@@ -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");
+5 -2
View File
@@ -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<EventResult<NewCalendarEventType>> => {
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,
+35 -13
View File
@@ -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<typeof createdEventSchema>;
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<NewCalendarEventType>[] = [];
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<Array<EventResult<NewCalendarEventType>>> {
@@ -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: "",
+7 -2
View File
@@ -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<EventResult<VideoCallData>> => {
@@ -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,
@@ -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 (
<Info
label={t("apps_status")}
description={
<ul style={{ lineHeight: "24px" }}>
{props.calEvent.appsStatus.map((status) => (
<li key={status.type} style={{ fontWeight: 400 }}>
{status.appName}{" "}
{status.success >= 1 && `${status.success > 1 ? `(x${status.success})` : ""}`}{" "}
{status.failures >= 1 && `${status.failures > 1 ? `(x${status.failures})` : ""}`}
</li>
))}
</ul>
}
withSpacer
/>
);
};
+1
View File
@@ -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";
@@ -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<React.ComponentProps<typeof BaseEmailHtml>>
) => {
@@ -70,6 +72,7 @@ export const BaseScheduledEmail = (
<LocationInfo calEvent={props.calEvent} t={t} />
<Info label={t("description")} description={props.calEvent.description} withSpacer />
<Info label={t("additional_notes")} description={props.calEvent.additionalNotes} withSpacer />
{props.includeAppsStatus && <AppsStatus calEvent={props.calEvent} t={t} />}
<CustomInputs calEvent={props.calEvent} />
</BaseEmailHtml>
);
@@ -33,6 +33,7 @@ export const OrganizerScheduledEmail = (
t={t}
subject={t(subject)}
title={t(title)}
includeAppsStatus
{...props}
/>
);
@@ -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<typeof createBooking>;
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<AdditionalInformation>[],
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({
+15
View File
@@ -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
+10
View File
@@ -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(),
})
);
+8
View File
@@ -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;
}
+1
View File
@@ -16,6 +16,7 @@ export interface PartialReference {
export interface EventResult<T> {
type: string;
appName: string;
success: boolean;
uid: string;
createdEvent?: T;