Seats events send email to attendees and organizer (#3206)

* Add attendees to email notfications

* New seat title

* Change organizer email

* Clean console.log

* Fix type errors

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

Co-authored-by: Omar López <zomars@me.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
This commit is contained in:
Joe Au-Yeung
2022-07-01 19:19:23 +00:00
committed by GitHub
co-authored by Omar López kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
parent 833791bc9a
commit 2cbf46a323
5 changed files with 141 additions and 67 deletions
+74 -60
View File
@@ -13,6 +13,7 @@ import {
sendOrganizerRequestEmail,
sendRescheduledEmails,
sendScheduledEmails,
sendScheduledSeatsEmails,
} from "@calcom/emails";
import { getLuckyUsers, isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib";
import { getDefaultEvent, getGroupName, getUsernameList } from "@calcom/lib/defaultEvents";
@@ -291,6 +292,67 @@ async function handler(req: NextApiRequest) {
language: { translate: tGuests, locale: "en" },
}));
const seed = `${organizerUser.username}:${dayjs(reqBody.start).utc().format()}:${new Date().getTime()}`;
const uid = translator.fromUUID(uuidv5(seed, uuidv5.URL));
const location = !!eventType.locations ? (eventType.locations as Array<{ type: string }>)[0] : "";
const locationType = !!location && location.type ? location.type : "";
const customInputs = {} as NonNullable<CalendarEvent["customInputs"]>;
const teamMemberPromises =
eventType.schedulingType === SchedulingType.COLLECTIVE
? users.slice(1).map(async function (user) {
return {
email: user.email || "",
name: user.name || "",
timeZone: user.timeZone,
language: {
translate: await getTranslation(user.locale ?? "en", "common"),
locale: user.locale ?? "en",
},
};
})
: [];
const teamMembers = await Promise.all(teamMemberPromises);
const attendeesList = [...invitee, ...guests, ...teamMembers];
const eventNameObject = {
attendeeName: reqBody.name || "Nameless",
eventType: eventType.title,
eventName: eventType.eventName,
host: organizerUser.name || "Nameless",
location: locationType,
t: tOrganizer,
};
const additionalNotes = reqBody.notes;
let evt: CalendarEvent = {
type: eventType.title,
title: getEventName(eventNameObject), //this needs to be either forced in english, or fetched for each attendee and organizer separately
description: eventType.description,
additionalNotes,
customInputs,
startTime: reqBody.start,
endTime: reqBody.end,
organizer: {
name: organizerUser.name || "Nameless",
email: organizerUser.email || "Email-less",
timeZone: organizerUser.timeZone,
language: { translate: tOrganizer, locale: organizer?.locale ?? "en" },
},
attendees: attendeesList,
location: reqBody.location, // Will be processed by the EventManager later.
/** For team events & dynamic collective events, we will need to handle each member destinationCalendar eventually */
destinationCalendar: eventType.destinationCalendar || organizerUser.destinationCalendar,
hideCalendarNotes: eventType.hideCalendarNotes,
requiresConfirmation: eventType.requiresConfirmation ?? false,
eventTypeId: eventType.id,
};
// For seats, if the booking already exists then we want to add the new attendee to the existing booking
if (reqBody.bookingUid) {
if (!eventType.seatsPerTimeSlot)
@@ -306,6 +368,13 @@ async function handler(req: NextApiRequest) {
});
if (!booking) throw new HttpError({ statusCode: 404, message: "Booking not found" });
// Need to add translation for attendees to pass type checks. Since these values are never written to the db we can just use the new attendee language
const bookingAttendees = booking.attendees.map((attendee) => {
return { ...attendee, language: { translate: tAttendees, locale: language ?? "en" } };
});
evt = { ...evt, attendees: [...bookingAttendees, invitee[0]] };
if (eventType.seatsPerTimeSlot <= booking.attendees.length)
throw new HttpError({ statusCode: 409, message: "Booking seats are full" });
@@ -327,76 +396,21 @@ async function handler(req: NextApiRequest) {
},
},
});
const newSeat = booking.attendees.length !== 0;
await sendScheduledSeatsEmails(evt, invitee[0], newSeat);
req.statusCode = 201;
return booking;
}
const teamMemberPromises =
eventType.schedulingType === SchedulingType.COLLECTIVE
? users.slice(1).map(async function (user) {
return {
email: user.email || "",
name: user.name || "",
timeZone: user.timeZone,
language: {
translate: await getTranslation(user.locale ?? "en", "common"),
locale: user.locale ?? "en",
},
};
})
: [];
const teamMembers = await Promise.all(teamMemberPromises);
const attendeesList = [...invitee, ...guests, ...teamMembers];
const seed = `${organizerUser.username}:${dayjs(reqBody.start).utc().format()}:${new Date().getTime()}`;
const uid = translator.fromUUID(uuidv5(seed, uuidv5.URL));
const location = !!eventType.locations ? (eventType.locations as Array<{ type: string }>)[0] : "";
const locationType = !!location && location.type ? location.type : "";
const eventNameObject = {
attendeeName: reqBody.name || "Nameless",
eventType: eventType.title,
eventName: eventType.eventName,
host: organizerUser.name || "Nameless",
location: locationType,
t: tOrganizer,
};
const additionalNotes = reqBody.notes;
const customInputs = {} as NonNullable<CalendarEvent["customInputs"]>;
if (reqBody.customInputs.length > 0) {
reqBody.customInputs.forEach(({ label, value }) => {
customInputs[label] = value;
});
}
const evt: CalendarEvent = {
type: eventType.title,
title: getEventName(eventNameObject), //this needs to be either forced in english, or fetched for each attendee and organizer separately
description: eventType.description,
additionalNotes,
customInputs,
startTime: reqBody.start,
endTime: reqBody.end,
organizer: {
name: organizerUser.name || "Nameless",
email: organizerUser.email || "Email-less",
timeZone: organizerUser.timeZone,
language: { translate: tOrganizer, locale: organizer?.locale ?? "en" },
},
attendees: attendeesList,
location: reqBody.location, // Will be processed by the EventManager later.
/** For team events & dynamic collective events, we will need to handle each member destinationCalendar eventually */
destinationCalendar: eventType.destinationCalendar || organizerUser.destinationCalendar,
hideCalendarNotes: eventType.hideCalendarNotes,
requiresConfirmation: eventType.requiresConfirmation ?? false,
eventTypeId: eventType.id,
};
if (eventType.schedulingType === SchedulingType.COLLECTIVE) {
evt.team = {
members: users.map((user) => user.name || user.username || "Nameless"),
@@ -923,5 +923,7 @@
"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"
"problem_updating_calendar": "There was a problem updating your calendar",
"new_seat_subject": "New Attendee {{name}} on {{eventType}} at {{date}}",
"new_seat_title": "Someone has added themselves to an event"
}
+32
View File
@@ -81,6 +81,38 @@ export const sendRescheduledEmails = async (calEvent: CalendarEvent) => {
await Promise.all(emailsToSend);
};
export const sendScheduledSeatsEmails = async (
calEvent: CalendarEvent,
invitee: Person,
newSeat: boolean
) => {
const emailsToSend: Promise<unknown>[] = [];
emailsToSend.push(
new Promise((resolve, reject) => {
try {
const scheduledEmail = new AttendeeScheduledEmail(calEvent, invitee);
resolve(scheduledEmail.sendEmail());
} catch (e) {
reject(console.error("AttendeeRescheduledEmail.sendEmail failed", e));
}
})
);
emailsToSend.push(
new Promise((resolve, reject) => {
try {
const scheduledEmail = new OrganizerScheduledEmail(calEvent, newSeat);
resolve(scheduledEmail.sendEmail());
} catch (e) {
reject(console.error("OrganizerScheduledEmail.sendEmail failed", e));
}
})
);
await Promise.all(emailsToSend);
};
export const sendOrganizerRequestEmail = async (calEvent: CalendarEvent) => {
await new Promise((resolve, reject) => {
try {
@@ -6,17 +6,33 @@ export const OrganizerScheduledEmail = (
props: {
calEvent: CalendarEvent;
attendee: Person;
newSeat?: boolean;
} & Partial<React.ComponentProps<typeof BaseScheduledEmail>>
) => {
let subject;
let title;
if (props.newSeat) {
subject = "new_seat_subject";
} else {
subject = "confirmed_event_type_subject";
}
if (props.calEvent.recurringEvent?.count) {
title = "new_event_scheduled_recurring";
} else if (props.newSeat) {
title = "new_seat_title";
} else {
title = "new_event_scheduled";
}
const t = props.calEvent.organizer.language.translate;
return (
<BaseScheduledEmail
timeZone={props.calEvent.organizer.timeZone}
t={t}
subject={t("confirmed_event_type_subject")}
title={t(
props.calEvent.recurringEvent?.count ? "new_event_scheduled_recurring" : "new_event_scheduled"
)}
subject={t(subject)}
title={t(title)}
{...props}
/>
);
@@ -12,12 +12,14 @@ import BaseEmail from "./_base-email";
export default class OrganizerScheduledEmail extends BaseEmail {
calEvent: CalendarEvent;
t: TFunction;
newSeat?: boolean;
constructor(calEvent: CalendarEvent) {
constructor(calEvent: CalendarEvent, newSeat?: boolean) {
super();
this.name = "SEND_BOOKING_CONFIRMATION";
this.calEvent = calEvent;
this.t = this.calEvent.organizer.language.translate;
this.newSeat = newSeat;
}
protected getiCalEventAsString(): string | undefined {
@@ -66,6 +68,13 @@ export default class OrganizerScheduledEmail extends BaseEmail {
});
}
let subject;
if (this.newSeat) {
subject = "new_seat_subject";
} else {
subject = "confirmed_event_type_subject";
}
return {
icalEvent: {
filename: "event.ics",
@@ -73,7 +82,7 @@ export default class OrganizerScheduledEmail extends BaseEmail {
},
from: `Cal.com <${this.getMailerOptions().from}>`,
to: toAddresses.join(","),
subject: `${this.t("confirmed_event_type_subject", {
subject: `${this.t(subject, {
eventType: this.calEvent.type,
name: this.calEvent.attendees[0].name,
date: this.getFormattedDate(),
@@ -81,6 +90,7 @@ export default class OrganizerScheduledEmail extends BaseEmail {
html: renderEmail("OrganizerScheduledEmail", {
calEvent: this.calEvent,
attendee: this.calEvent.organizer,
newSeat: this.newSeat,
}),
text: this.getTextBody(),
};