Broken Integration Alarm System (#3135)

* Create broken integration email

* Send video on broken video link

* Add translations

* Add Trans components

* Remove calendars file

* Type fixes

* Add translations

* Fix type errors

* Fix Spanish translation

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

* Remove iCal event

* Locale fixes

* Clean up trans tag

* Apply suggested translation

Co-authored-by: Bailey Pumfleet <pumfleet@hey.com>

* Change email to en

* Fix type error

* Type fix

* Fix e2e

* Fix e2e

* Fix e2e

* Fix e2e

* Fix e2e

* Update webhookResponse-chromium.txt

Co-authored-by: Omar López <zomars@me.com>
Co-authored-by: Bailey Pumfleet <pumfleet@hey.com>
This commit is contained in:
Joe Au-Yeung
2022-06-24 23:16:20 -06:00
committed by GitHub
co-authored by Omar López Bailey Pumfleet
parent fd863b2ae7
commit 02e5ec37e7
22 changed files with 290 additions and 27 deletions
+2
View File
@@ -104,6 +104,7 @@ async function patchHandler(req: NextApiRequest) {
eventTypeId: true,
eventType: {
select: {
id: true,
recurringEvent: true,
requiresConfirmation: true,
},
@@ -178,6 +179,7 @@ async function patchHandler(req: NextApiRequest) {
uid: booking.uid,
destinationCalendar: booking?.destinationCalendar || currentUser.destinationCalendar,
requiresConfirmation: booking?.eventType?.requiresConfirmation ?? false,
eventTypeId: booking.eventType?.id,
};
const recurringEvent = parseRecurringEvent(booking.eventType?.recurringEvent);
+2
View File
@@ -403,6 +403,7 @@ async function handler(req: NextApiRequest) {
destinationCalendar: eventType.destinationCalendar || organizerUser.destinationCalendar,
hideCalendarNotes: eventType.hideCalendarNotes,
requiresConfirmation: eventType.requiresConfirmation ?? false,
eventTypeId: eventType.id,
};
if (eventType.schedulingType === SchedulingType.COLLECTIVE) {
@@ -803,6 +804,7 @@ async function handler(req: NextApiRequest) {
bookingId,
rescheduleUid,
metadata: reqBody.metadata,
eventTypeId,
}).catch((e) => {
console.error(`Error executing webhook for event: ${eventTrigger}, URL: ${sub.subscriberUrl}`, e);
})
+3 -2
View File
@@ -6,6 +6,7 @@ import { getTranslation } from "@calcom/lib/server/i18n";
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
if (process.env.NODE_ENV !== "development") return res.write("Only for development purposes"), res.end();
const t = await getTranslation("en", "common");
const language = { translate: t, locale: "en" };
const evt = {
type: "30min",
@@ -22,14 +23,14 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
name: "Pro Example",
email: "pro@example.com",
timeZone: "Europe/London",
language: { translate: t, locale: "en" },
language,
},
attendees: [
{
email: "pro@example.com",
name: "pro@example.com",
timeZone: "America/Chihuahua",
language: { translate: t, locale: "en" },
language,
},
],
location: "Zoom video",
+1
View File
@@ -76,6 +76,7 @@ test.describe("Integrations", () => {
body.payload.bookingId = dynamic;
body.payload.additionalInformation = dynamic;
body.payload.requiresConfirmation = dynamic;
body.payload.eventTypeId = dynamic;
// if we change the shape of our webhooks, we can simply update this by clicking `u`
// console.log("BODY", body);
@@ -1 +1 @@
{"triggerEvent":"BOOKING_CREATED","createdAt":"[redacted/dynamic]","payload":{"type":"30 min","title":"30 min between PRO and Test Testson","description":"","additionalNotes":"","customInputs":{},"startTime":"[redacted/dynamic]","endTime":"[redacted/dynamic]","organizer":{"name":"PRO","email":"[redacted/dynamic]","timeZone":"[redacted/dynamic]","language":"[redacted/dynamic]"},"attendees":[{"email":"test@example.com","name":"Test Testson","timeZone":"[redacted/dynamic]","language":"[redacted/dynamic]"}],"location":"[redacted/dynamic]","destinationCalendar":null,"hideCalendarNotes":false,"requiresConfirmation":"[redacted/dynamic]","uid":"[redacted/dynamic]","bookingId":"[redacted/dynamic]","metadata":{},"additionalInformation":"[redacted/dynamic]"}}
{"triggerEvent":"BOOKING_CREATED","createdAt":"[redacted/dynamic]","payload":{"type":"30 min","title":"30 min between PRO and Test Testson","description":"","additionalNotes":"","customInputs":{},"startTime":"[redacted/dynamic]","endTime":"[redacted/dynamic]","organizer":{"name":"PRO","email":"[redacted/dynamic]","timeZone":"[redacted/dynamic]","language":"[redacted/dynamic]"},"attendees":[{"email":"test@example.com","name":"Test Testson","timeZone":"[redacted/dynamic]","language":"[redacted/dynamic]"}],"location":"[redacted/dynamic]","destinationCalendar":null,"hideCalendarNotes":false,"requiresConfirmation":"[redacted/dynamic]","eventTypeId":"[redacted/dynamic]","uid":"[redacted/dynamic]","bookingId":"[redacted/dynamic]","metadata":{},"additionalInformation":"[redacted/dynamic]"}}
@@ -911,5 +911,10 @@
"add_exchange2016": "Connect Exchange 2016 Server",
"specific_issue": "Have a specific issue",
"browse_our_docs": "browse our docs",
"attendee_name": "Attendee's name"
"broken_video_action": "We could not add the <1>{{location}}</1> meeting link to your scheduled event. Contact your invitees or update your calendar event to add the details. You can either <3> change your location on the event type </3> or try <5>removing and adding the app again.</5>",
"broken_calendar_action": "We could not update your <1>{{calendar}}</1>. <2> Please check your calendar settings or remove and add your calendar again </2>",
"attendee_name": "Attendee's name",
"broken_integration": "Broken integration",
"problem_adding_video_link": "There was a problem adding a video link",
"problem_updating_calendar": "There was a problem updating your calendar"
}
@@ -903,5 +903,8 @@
"requires_confirmation": "Requiere confirmación",
"add_exchange2013": "Conectar con Exchange 2013 Server",
"add_exchange2016": "Conectar con Exchange 2016 Server",
"broken_video_action": "No pudimos agregar el enlace de <1>{{location}}</1> a tu evento agendado. Contacta a tus invitados o actualiza tu calendario para agregar los detalles. Puedes <3>cambiar la ubicación de tu tipo de evento</3> o <5>remover la App y agregarla de nuevo.</5>",
"problem_adding_video_link": "Hubo un problema al añadir un enlace de vídeo",
"problem_updating_calendar": "Hubo un problema al actualizar su calendario",
"attendee_name": "Nombre del asistente"
}
+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 { sendBrokenIntegrationEmail } from "@calcom/emails";
import { getUid } from "@calcom/lib/CalEventParser";
import { getErrorFromUnknown } from "@calcom/lib/errors";
import logger from "@calcom/lib/logger";
@@ -141,7 +142,8 @@ export const createEvent = async (
// TODO: Surfice success/error messages coming from apps to improve end user visibility
const creationResult = calendar
? await calendar.createEvent(calEvent).catch((e) => {
? await calendar.createEvent(calEvent).catch(async (e) => {
await sendBrokenIntegrationEmail(calEvent, "calendar");
log.error("createEvent failed", e, calEvent);
success = false;
return undefined;
@@ -177,7 +179,8 @@ export const updateEvent = async (
success = true;
return event;
})
.catch((e) => {
.catch(async (e) => {
await sendBrokenIntegrationEmail(calEvent, "calendar");
log.error("updateEvent failed", e, calEvent);
return undefined;
})
+5 -2
View File
@@ -3,6 +3,7 @@ import short from "short-uuid";
import { v5 as uuidv5 } from "uuid";
import appStore from "@calcom/app-store";
import { sendBrokenIntegrationEmail } from "@calcom/emails";
import { getUid } from "@calcom/lib/CalEventParser";
import logger from "@calcom/lib/logger";
import type { CalendarEvent } from "@calcom/types/Calendar";
@@ -43,7 +44,8 @@ const createMeeting = async (credential: Credential, calEvent: CalendarEvent) =>
const videoAdapters = getVideoAdapters([credential]);
const [firstVideoAdapter] = videoAdapters;
const createdMeeting = await firstVideoAdapter.createMeeting(calEvent).catch((e) => {
const createdMeeting = await firstVideoAdapter.createMeeting(calEvent).catch(async (e) => {
await sendBrokenIntegrationEmail(calEvent, "video");
log.error("createMeeting failed", e, calEvent);
});
@@ -77,7 +79,8 @@ const updateMeeting = async (
const [firstVideoAdapter] = getVideoAdapters([credential]);
const updatedMeeting =
credential && bookingRef
? await firstVideoAdapter.updateMeeting(bookingRef, calEvent).catch((e) => {
? await firstVideoAdapter.updateMeeting(bookingRef, calEvent).catch(async (e) => {
await sendBrokenIntegrationEmail(calEvent, "video");
log.error("updateMeeting failed", e, calEvent);
success = false;
return undefined;
+12
View File
@@ -8,6 +8,7 @@ import AttendeeRequestEmail from "./templates/attendee-request-email";
import AttendeeRequestRescheduledEmail from "./templates/attendee-request-reschedule-email";
import AttendeeRescheduledEmail from "./templates/attendee-rescheduled-email";
import AttendeeScheduledEmail from "./templates/attendee-scheduled-email";
import BrokenIntegrationEmail from "./templates/broken-integration-email";
import FeedbackEmail, { Feedback } from "./templates/feedback-email";
import ForgotPasswordEmail, { PasswordReset } from "./templates/forgot-password-email";
import OrganizerCancelledEmail from "./templates/organizer-cancelled-email";
@@ -284,3 +285,14 @@ export const sendFeedbackEmail = async (feedback: Feedback) => {
}
});
};
export const sendBrokenIntegrationEmail = async (evt: CalendarEvent, type: "video" | "calendar") => {
await new Promise((resolve, reject) => {
try {
const brokenIntegrationEmail = new BrokenIntegrationEmail(evt, type);
resolve(brokenIntegrationEmail.sendEmail());
} catch (e) {
reject(console.error("FeedbackEmail.sendEmail failed", e));
}
});
};
@@ -20,7 +20,7 @@ export const BaseEmailHtml = (props: {
callToAction?: React.ReactNode;
subject: string;
title?: string;
subtitle?: string;
subtitle?: React.ReactNode;
headerType?: BodyHeadType;
}) => {
return (
@@ -1,6 +1,6 @@
import EmailCommonDivider from "./EmailCommonDivider";
const EmailScheduledBodyHeaderContent = (props: { title: string; subtitle?: string }) => (
const EmailScheduledBodyHeaderContent = (props: { title: string; subtitle?: React.ReactNode }) => (
<EmailCommonDivider headStyles={{ padding: 0 }} mutipleRows>
<tr>
<td
@@ -5,12 +5,16 @@ export const AttendeeRequestEmail = (props: React.ComponentProps<typeof Attendee
title={props.calEvent.organizer.language.translate(
props.calEvent.recurringEvent?.count ? "booking_submitted_recurring" : "booking_submitted"
)}
subtitle={props.calEvent.organizer.language.translate(
props.calEvent.recurringEvent?.count
? "user_needs_to_confirm_or_reject_booking_recurring"
: "user_needs_to_confirm_or_reject_booking",
{ user: props.calEvent.organizer.name }
)}
subtitle={
<>
{props.calEvent.organizer.language.translate(
props.calEvent.recurringEvent?.count
? "user_needs_to_confirm_or_reject_booking_recurring"
: "user_needs_to_confirm_or_reject_booking",
{ user: props.calEvent.organizer.name }
)}
</>
}
headerType="calendarCircle"
subject="booking_submitted_subject"
{...props}
@@ -6,9 +6,13 @@ export const AttendeeRequestRescheduledEmail = (
) => (
<OrganizerScheduledEmail
title="request_reschedule_title_attendee"
subtitle={props.calEvent.organizer.language.translate("request_reschedule_subtitle", {
organizer: props.calEvent.organizer.name,
})}
subtitle={
<>
{props.calEvent.organizer.language.translate("request_reschedule_subtitle", {
organizer: props.calEvent.organizer.name,
})}
</>
}
headerType="calendarCircle"
subject="rescheduled_event_type_subject"
callToAction={
@@ -64,7 +64,7 @@ export const BaseScheduledEmail = (
? null
: props.callToAction || <ManageLink attendee={props.attendee} calEvent={props.calEvent} />
}
subtitle={t(props.subtitle || "emailed_you_and_any_other_attendees")}>
subtitle={props.subtitle || <>{t("emailed_you_and_any_other_attendees")}</>}>
<Info label={t("cancellation_reason")} description={props.calEvent.cancellationReason} withSpacer />
<Info label={t("rejection_reason")} description={props.calEvent.rejectionReason} withSpacer />
<Info label={t("what")} description={props.calEvent.type} withSpacer />
@@ -0,0 +1,117 @@
import { TFunction } from "next-i18next";
import { Trans } from "react-i18next";
import { AppStoreLocationType } from "@calcom/app-store/locations";
import { WEBAPP_URL } from "@calcom/lib/constants";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
import { BaseScheduledEmail } from "./BaseScheduledEmail";
// https://stackoverflow.com/questions/56263980/get-key-of-an-enum-from-its-value-in-typescript
export function getEnumKeyByEnumValue(myEnum: any, enumValue: number | string): string {
const keys = Object.keys(myEnum).filter((x) => myEnum[x] == enumValue);
return keys.length > 0 ? keys[0] : "";
}
const BrokenVideoIntegration = (props: { location: string; eventTypeId?: number | null; t: TFunction }) => {
return (
<Trans i18nKey="broken_video_action" t={props.t}>
We could not add the <span>{props.location}</span> meeting link to your scheduled event. Contact your
invitees or update your calendar event to add the details. You can either&nbsp;
<a
href={
props.eventTypeId ? `${WEBAPP_URL}/event-types/${props.eventTypeId}` : `${WEBAPP_URL}/event-types`
}>
change your location on the event type
</a>
&nbsp;or try&nbsp;
<a href={`${WEBAPP_URL}/apps/installed`}>removing and adding the app again.</a>
</Trans>
);
};
const BrokenCalendarIntegration = (props: {
calendar: string;
eventTypeId?: number | null;
t: TFunction;
}) => {
const { t } = props;
return (
<Trans i18nKey="broken_calendar_action" t={props.t}>
We could not update your <span>{props.calendar}</span>.{" "}
<a href={`${WEBAPP_URL}/apps/installed`}>
Please check your calendar settings or remove and add your calendar again
</a>
</Trans>
);
};
export const BrokenIntegrationEmail = (
props: {
calEvent: CalendarEvent;
attendee: Person;
type: "video" | "calendar";
} & Partial<React.ComponentProps<typeof BaseScheduledEmail>>
) => {
const { calEvent, type } = props;
const t = calEvent.organizer.language.translate;
if (type === "video") {
let location = calEvent.location ? getEnumKeyByEnumValue(AppStoreLocationType, calEvent.location) : " ";
if (location === "Daily") {
location = "Cal Video";
}
if (location === "GoogleMeet") {
location = location.slice(0, 5) + " " + location.slice(5);
}
return (
<BaseScheduledEmail
timeZone={calEvent.organizer.timeZone}
t={t}
subject={t("broken_integration")}
title={t("problem_adding_video_link")}
subtitle={<BrokenVideoIntegration location={location} eventTypeId={calEvent.eventTypeId} t={t} />}
headerType="xCircle"
{...props}
/>
);
}
if (type === "calendar") {
// The calendar name is stored as name_calendar
let calendar = calEvent.destinationCalendar
? calEvent.destinationCalendar?.integration.split("_")
: "calendar";
if (Array.isArray(calendar)) {
const calendarCap = calendar.map((name) => name.charAt(0).toUpperCase() + name.slice(1));
calendar = calendarCap[0] + " " + calendarCap[1];
}
return (
<BaseScheduledEmail
timeZone={calEvent.organizer.timeZone}
t={t}
subject={t("broken_integration")}
title={t("problem_updating_calendar")}
subtitle={<BrokenCalendarIntegration calendar={calendar} eventTypeId={calEvent.eventTypeId} t={t} />}
headerType="xCircle"
{...props}
/>
);
}
return (
<BaseScheduledEmail
timeZone={calEvent.organizer.timeZone}
t={t}
subject={t("broken_integration")}
title={t("problem_updating_calendar")}
headerType="xCircle"
{...props}
/>
);
};
@@ -12,9 +12,13 @@ export const OrganizerPaymentRefundFailedEmail = (
subject="refund_failed_subject"
title={t("a_refund_failed")}
callToAction={null}
subtitle={t("check_with_provider_and_user", {
user: props.calEvent.attendees[0].name,
})}>
subtitle={
<>
{t("check_with_provider_and_user", {
user: props.calEvent.attendees[0].name,
})}
</>
}>
<RefundInformation {...props} />
</BaseEmailHtml>
);
@@ -7,9 +7,13 @@ export const OrganizerRequestRescheduledEmail = (
title={props.calEvent.organizer.language.translate("request_reschedule_title_organizer", {
attendee: props.calEvent.attendees[0].name,
})}
subtitle={props.calEvent.organizer.language.translate("request_reschedule_subtitle_organizer", {
attendee: props.calEvent.attendees[0].name,
})}
subtitle={
<>
{props.calEvent.organizer.language.translate("request_reschedule_subtitle_organizer", {
attendee: props.calEvent.attendees[0].name,
})}
</>
}
headerType="calendarCircle"
subject="rescheduled_event_type_subject"
{...props}
+1
View File
@@ -17,3 +17,4 @@ export { OrganizerRequestRescheduledEmail } from "./OrganizerRequestRescheduledE
export { OrganizerRescheduledEmail } from "./OrganizerRescheduledEmail";
export { OrganizerScheduledEmail } from "./OrganizerScheduledEmail";
export { TeamInviteEmail } from "./TeamInviteEmail";
export { BrokenIntegrationEmail } from "./BrokenIntegrationEmail";
@@ -0,0 +1,96 @@
import dayjs from "dayjs";
import localizedFormat from "dayjs/plugin/localizedFormat";
import timezone from "dayjs/plugin/timezone";
import toArray from "dayjs/plugin/toArray";
import utc from "dayjs/plugin/utc";
import { createEvent, DateArray, Person } from "ics";
import { TFunction } from "next-i18next";
import rrule from "rrule";
import { getRichDescription } from "@calcom/lib/CalEventParser";
import type { CalendarEvent } from "@calcom/types/Calendar";
import { renderEmail } from "..";
import BaseEmail from "./_base-email";
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(localizedFormat);
dayjs.extend(toArray);
export default class BrokenIntegrationEmail extends BaseEmail {
type: "calendar" | "video";
calEvent: CalendarEvent;
t: TFunction;
constructor(calEvent: CalendarEvent, type: "calendar" | "video") {
super();
this.name = "SEND_BROKEN_INTEGRATION";
this.calEvent = calEvent;
this.t = this.calEvent.organizer.language.translate;
this.type = type;
}
protected getNodeMailerPayload(): Record<string, unknown> {
const toAddresses = [this.calEvent.organizer.email];
if (this.calEvent.team) {
this.calEvent.team.members.forEach((member) => {
const memberAttendee = this.calEvent.attendees.find((attendee) => attendee.name === member);
if (memberAttendee) {
toAddresses.push(memberAttendee.email);
}
});
}
return {
from: `Cal.com <${this.getMailerOptions().from}>`,
to: toAddresses.join(","),
subject: `[Action Required] ${this.t("confirmed_event_type_subject", {
eventType: this.calEvent.type,
name: this.calEvent.attendees[0].name,
date: this.getFormattedDate(),
})}`,
html: renderEmail("BrokenIntegrationEmail", {
calEvent: this.calEvent,
attendee: this.calEvent.organizer,
type: this.type,
}),
text: this.getTextBody(),
};
}
protected getTextBody(
title = "",
subtitle = "emailed_you_and_any_other_attendees",
extraInfo = "",
callToAction = ""
): string {
return `
${this.t(
title || this.calEvent.recurringEvent?.count ? "new_event_scheduled_recurring" : "new_event_scheduled"
)}
${this.t(subtitle)}
${extraInfo}
${getRichDescription(this.calEvent)}
${callToAction}
`.trim();
}
protected getTimezone(): string {
return this.calEvent.organizer.timeZone;
}
protected getOrganizerStart(format: string) {
return this.getRecipientTime(this.calEvent.startTime, format);
}
protected getOrganizerEnd(format: string) {
return this.getRecipientTime(this.calEvent.endTime, format);
}
protected getFormattedDate() {
return `${this.getOrganizerStart("h:mma")} - ${this.getOrganizerEnd("h:mma")}, ${this.t(
this.getOrganizerStart("dddd").toLowerCase()
)}, ${this.t(this.getOrganizerStart("MMMM").toLowerCase())} ${this.getOrganizerStart("D, YYYY")}`;
}
}
+1
View File
@@ -123,6 +123,7 @@ export interface CalendarEvent {
hideCalendarNotes?: boolean;
recurrence?: string;
recurringEvent?: RecurringEvent | null;
eventTypeId?: number | null;
}
export interface EntryPoint {