Skip to content

Commit 60a8841

Browse files
refactor: handle emails and sms side effects (calcom#23578)
* refactor: handle emails and sms side effects * fix: typo and import part * fix: types import path * fix: rename * refactor: class with logger dependency * fix: replace structured clone with cloneDeep * fix: await send reschedule round robin notifs * fixup! fix: await send reschedule round robin notifs * fix: avoid oversharing data * refactor and fix tests * chore: bump platform lib * chore: bump platform lib * chore: use logger with event details * chore: add try catches * refactor: remove private methods to send sms and emails * chore: address code review * chore: bump platform lib * chore: address code review
1 parent 184d1eb commit 60a8841

4 files changed

Lines changed: 367 additions & 184 deletions

File tree

apps/api/v2/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
"@axiomhq/winston": "^1.2.0",
3939
"@calcom/platform-constants": "*",
4040
"@calcom/platform-enums": "*",
41-
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.340",
41+
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.344",
4242
"@calcom/platform-types": "*",
4343
"@calcom/platform-utils": "*",
4444
"@calcom/prisma": "*",
Lines changed: 325 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,325 @@
1+
import { default as cloneDeep } from "lodash/cloneDeep";
2+
import type { Logger } from "tslog";
3+
4+
import dayjs from "@calcom/dayjs";
5+
import {
6+
allowDisablingHostConfirmationEmails,
7+
allowDisablingAttendeeConfirmationEmails,
8+
} from "@calcom/ee/workflows/lib/allowDisablingStandardEmails";
9+
import type { Workflow as WorkflowType } from "@calcom/ee/workflows/lib/types";
10+
import {
11+
sendRoundRobinRescheduledEmailsAndSMS,
12+
sendRoundRobinScheduledEmailsAndSMS,
13+
sendRoundRobinCancelledEmailsAndSMS,
14+
sendRescheduledEmailsAndSMS,
15+
sendScheduledEmailsAndSMS,
16+
sendOrganizerRequestEmail,
17+
sendAttendeeRequestEmailAndSMS,
18+
} from "@calcom/emails";
19+
import type { BookingType } from "@calcom/features/bookings/lib/handleNewBooking/originalRescheduledBookingUtils";
20+
import type { EventNameObjectType } from "@calcom/lib/event";
21+
import { getPiiFreeCalendarEvent } from "@calcom/lib/piiFreeData";
22+
import { safeStringify } from "@calcom/lib/safeStringify";
23+
import { getTranslation } from "@calcom/lib/server/i18n";
24+
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
25+
import type { DestinationCalendar, Prisma, User } from "@calcom/prisma/client";
26+
import type { SchedulingType } from "@calcom/prisma/enums";
27+
import type { EventTypeMetadata } from "@calcom/prisma/zod-utils";
28+
import type { AdditionalInformation, CalendarEvent, Person } from "@calcom/types/Calendar";
29+
30+
export const BookingActionMap = {
31+
confirmed: "BOOKING_CONFIRMED",
32+
rescheduled: "BOOKING_RESCHEDULED",
33+
requested: "BOOKING_REQUESTED",
34+
} as const;
35+
36+
type EmailAndSmsPayload = {
37+
evt: CalendarEvent;
38+
eventType: {
39+
metadata?: EventTypeMetadata;
40+
schedulingType: SchedulingType | null;
41+
};
42+
};
43+
44+
type RescheduleEmailAndSmsPayload = EmailAndSmsPayload & {
45+
rescheduleReason?: string;
46+
additionalInformation: AdditionalInformation;
47+
additionalNotes: string | null | undefined;
48+
iCalUID: string;
49+
users: (Pick<User, "id" | "name" | "timeZone" | "locale" | "email"> & {
50+
destinationCalendar: DestinationCalendar | null;
51+
isFixed?: boolean;
52+
})[];
53+
changedOrganizer?: boolean;
54+
isRescheduledByBooker: boolean;
55+
originalRescheduledBooking: NonNullable<BookingType>;
56+
};
57+
58+
type ConfirmedEmailAndSmsPayload = EmailAndSmsPayload & {
59+
workflows: WorkflowType[];
60+
eventNameObject: EventNameObjectType;
61+
additionalInformation: AdditionalInformation;
62+
additionalNotes: string | null | undefined;
63+
customInputs: Prisma.JsonObject | null | undefined;
64+
};
65+
66+
type RequestedEmailAndSmsPayload = EmailAndSmsPayload & {
67+
attendees?: Person[];
68+
additionalNotes?: string | null;
69+
};
70+
71+
type RescheduledSideEffectsPayload = {
72+
action: typeof BookingActionMap.rescheduled;
73+
data: RescheduleEmailAndSmsPayload;
74+
};
75+
76+
type ConfirmedSideEffectsPayload = {
77+
action: typeof BookingActionMap.confirmed;
78+
data: ConfirmedEmailAndSmsPayload;
79+
};
80+
81+
type RequestedSideEffectsPayload = {
82+
action: typeof BookingActionMap.requested;
83+
data: RequestedEmailAndSmsPayload;
84+
};
85+
86+
export type EmailsAndSmsSideEffectsPayload =
87+
| RescheduledSideEffectsPayload
88+
| RequestedSideEffectsPayload
89+
| ConfirmedSideEffectsPayload;
90+
91+
export interface IBookingEmailSmsHandler {
92+
logger: Logger<unknown>;
93+
}
94+
95+
export class BookingEmailSmsHandler {
96+
private readonly log: Logger<unknown>;
97+
98+
constructor(dependencies: IBookingEmailSmsHandler) {
99+
this.log = dependencies.logger.getSubLogger({ prefix: ["BookingEmailSmsHandler"] });
100+
}
101+
102+
public async send(payload: EmailsAndSmsSideEffectsPayload) {
103+
const { action, data } = payload;
104+
105+
if (action === BookingActionMap.rescheduled) {
106+
if (data.eventType.schedulingType === "ROUND_ROBIN") return this._handleRoundRobinRescheduled(data);
107+
return this._handleRescheduled(data);
108+
}
109+
110+
if (action === BookingActionMap.confirmed) return this._handleConfirmed(data);
111+
if (action === BookingActionMap.requested) return this._handleRequested(data);
112+
113+
this.log.warn("Unknown email/SMS action requested.", { action });
114+
}
115+
116+
/**
117+
* Handles notifications for a RESCHEDULED booking.
118+
*/
119+
private async _handleRescheduled(data: RescheduleEmailAndSmsPayload) {
120+
const {
121+
evt,
122+
eventType: { metadata },
123+
rescheduleReason,
124+
additionalNotes,
125+
additionalInformation,
126+
} = data;
127+
128+
await sendRescheduledEmailsAndSMS(
129+
{
130+
...evt,
131+
additionalInformation,
132+
additionalNotes,
133+
cancellationReason: `$RCH$${rescheduleReason || ""}`,
134+
},
135+
metadata
136+
);
137+
}
138+
139+
/**
140+
* Handles notifications for a RESCHEDULED RR booking.
141+
*/
142+
private async _handleRoundRobinRescheduled(data: RescheduleEmailAndSmsPayload) {
143+
const {
144+
evt,
145+
eventType: { metadata },
146+
originalRescheduledBooking,
147+
rescheduleReason,
148+
additionalNotes,
149+
changedOrganizer,
150+
additionalInformation,
151+
users,
152+
isRescheduledByBooker,
153+
iCalUID,
154+
} = data;
155+
const copyEvent = cloneDeep(evt);
156+
const copyEventAdditionalInfo = {
157+
...copyEvent,
158+
additionalInformation,
159+
additionalNotes,
160+
cancellationReason: `$RCH$${rescheduleReason || ""}`,
161+
};
162+
const cancelledRRHostEvt = cloneDeep(copyEventAdditionalInfo);
163+
this.log.debug("Emails: Sending rescheduled emails for booking confirmation");
164+
165+
const originalBookingMemberEmails: Person[] = [];
166+
167+
for (const user of originalRescheduledBooking.attendees) {
168+
const translate = await getTranslation(user.locale ?? "en", "common");
169+
originalBookingMemberEmails.push({
170+
name: user.name,
171+
email: user.email,
172+
timeZone: user.timeZone,
173+
phoneNumber: user.phoneNumber,
174+
language: { translate, locale: user.locale ?? "en" },
175+
});
176+
}
177+
if (originalRescheduledBooking.user) {
178+
const translate = await getTranslation(originalRescheduledBooking.user.locale ?? "en", "common");
179+
const originalOrganizer = originalRescheduledBooking.user;
180+
181+
originalBookingMemberEmails.push({
182+
...originalRescheduledBooking.user,
183+
username: originalRescheduledBooking.user.username ?? undefined,
184+
timeFormat: getTimeFormatStringFromUserTimeFormat(originalRescheduledBooking.user.timeFormat),
185+
name: originalRescheduledBooking.user.name || "",
186+
language: { translate, locale: originalRescheduledBooking.user.locale ?? "en" },
187+
});
188+
189+
if (changedOrganizer) {
190+
cancelledRRHostEvt.title = originalRescheduledBooking.title;
191+
cancelledRRHostEvt.startTime =
192+
dayjs(originalRescheduledBooking?.startTime).utc().format() || copyEventAdditionalInfo.startTime;
193+
cancelledRRHostEvt.endTime =
194+
dayjs(originalRescheduledBooking?.endTime).utc().format() || copyEventAdditionalInfo.endTime;
195+
cancelledRRHostEvt.organizer = {
196+
email: originalOrganizer.email,
197+
name: originalOrganizer.name || "",
198+
timeZone: originalOrganizer.timeZone,
199+
language: { translate, locale: originalOrganizer.locale || "en" },
200+
};
201+
}
202+
}
203+
204+
const newBookingMemberEmails: Person[] = [
205+
...(copyEvent.team?.members || []),
206+
copyEvent.organizer,
207+
...copyEvent.attendees,
208+
];
209+
210+
const matchOriginalMemberWithNewMember = (originalMember: Person, newMember: Person) =>
211+
originalMember.email === newMember.email;
212+
213+
const newBookedMembers = newBookingMemberEmails.filter(
214+
(member) => !originalBookingMemberEmails.some((om) => matchOriginalMemberWithNewMember(om, member))
215+
);
216+
const cancelledMembers = originalBookingMemberEmails.filter(
217+
(member) => !newBookingMemberEmails.some((nm) => matchOriginalMemberWithNewMember(member, nm))
218+
);
219+
const rescheduledMembers = newBookingMemberEmails.filter((member) =>
220+
originalBookingMemberEmails.some((om) => matchOriginalMemberWithNewMember(om, member))
221+
);
222+
223+
const reassignedTo = users.find(
224+
(user) => !user.isFixed && newBookedMembers.some((member) => member.email === user.email)
225+
);
226+
227+
try {
228+
await Promise.all([
229+
sendRoundRobinRescheduledEmailsAndSMS(
230+
{ ...copyEventAdditionalInfo, iCalUID },
231+
rescheduledMembers,
232+
metadata
233+
),
234+
sendRoundRobinScheduledEmailsAndSMS({
235+
calEvent: copyEventAdditionalInfo,
236+
members: newBookedMembers,
237+
eventTypeMetadata: metadata,
238+
}),
239+
sendRoundRobinCancelledEmailsAndSMS(
240+
cancelledRRHostEvt,
241+
cancelledMembers,
242+
metadata,
243+
reassignedTo
244+
? {
245+
name: reassignedTo.name,
246+
email: reassignedTo.email,
247+
...(isRescheduledByBooker && { reason: "Booker Rescheduled" }),
248+
}
249+
: undefined
250+
),
251+
]);
252+
} catch (err) {
253+
this.log.error("Failed to send rescheduled round robin event related emails", err);
254+
}
255+
}
256+
257+
/**
258+
* Handles notifications for a newly CONFIRMED booking.
259+
*/
260+
private async _handleConfirmed(data: ConfirmedEmailAndSmsPayload) {
261+
const {
262+
evt,
263+
eventType: { metadata },
264+
workflows,
265+
eventNameObject,
266+
additionalInformation,
267+
additionalNotes,
268+
customInputs,
269+
} = data;
270+
271+
let isHostConfirmationEmailsDisabled = metadata?.disableStandardEmails?.confirmation?.host || false;
272+
if (isHostConfirmationEmailsDisabled) {
273+
isHostConfirmationEmailsDisabled = allowDisablingHostConfirmationEmails(workflows);
274+
}
275+
276+
let isAttendeeConfirmationEmailDisabled =
277+
metadata?.disableStandardEmails?.confirmation?.attendee || false;
278+
if (isAttendeeConfirmationEmailDisabled) {
279+
isAttendeeConfirmationEmailDisabled = allowDisablingAttendeeConfirmationEmails(workflows);
280+
}
281+
282+
try {
283+
await sendScheduledEmailsAndSMS(
284+
{ ...evt, additionalInformation, additionalNotes, customInputs },
285+
eventNameObject,
286+
isHostConfirmationEmailsDisabled,
287+
isAttendeeConfirmationEmailDisabled,
288+
metadata
289+
);
290+
} catch (err) {
291+
this.log.error("Failed to send scheduled event related emails", err);
292+
}
293+
}
294+
295+
/**
296+
* Handles notifications when a booking REQUEST is made (requires confirmation).
297+
*/
298+
private async _handleRequested(data: RequestedEmailAndSmsPayload) {
299+
const {
300+
evt,
301+
eventType: { metadata },
302+
attendees,
303+
additionalNotes,
304+
} = data;
305+
if (!attendees?.length) {
306+
this.log.error("Requested action called without attendee details.");
307+
return;
308+
}
309+
this.log.debug(
310+
"Action: BOOKING_REQUESTED. Sending request emails.",
311+
safeStringify({ calEvent: getPiiFreeCalendarEvent(evt) })
312+
);
313+
314+
const eventWithNotes = { ...evt, additionalNotes };
315+
316+
try {
317+
await Promise.all([
318+
sendOrganizerRequestEmail(eventWithNotes, metadata),
319+
sendAttendeeRequestEmailAndSMS(eventWithNotes, attendees[0], metadata),
320+
]);
321+
} catch (err) {
322+
this.log.error("Failed to send requested event related emails", err);
323+
}
324+
}
325+
}

0 commit comments

Comments
 (0)