diff --git a/apps/web/pages/api/book/event.ts b/apps/web/pages/api/book/event.ts index 699b3f19be..3e4a751cd6 100644 --- a/apps/web/pages/api/book/event.ts +++ b/apps/web/pages/api/book/event.ts @@ -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; + + 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; - 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"), diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 36093b0011..8b4a529f14 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -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" } diff --git a/packages/emails/email-manager.ts b/packages/emails/email-manager.ts index add3d0f9e8..c8a2fa1ac7 100644 --- a/packages/emails/email-manager.ts +++ b/packages/emails/email-manager.ts @@ -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[] = []; + + 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 { diff --git a/packages/emails/src/templates/OrganizerScheduledEmail.tsx b/packages/emails/src/templates/OrganizerScheduledEmail.tsx index d75cf38798..9740476bfe 100644 --- a/packages/emails/src/templates/OrganizerScheduledEmail.tsx +++ b/packages/emails/src/templates/OrganizerScheduledEmail.tsx @@ -6,17 +6,33 @@ export const OrganizerScheduledEmail = ( props: { calEvent: CalendarEvent; attendee: Person; + newSeat?: boolean; } & Partial> ) => { + 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 ( ); diff --git a/packages/emails/templates/organizer-scheduled-email.ts b/packages/emails/templates/organizer-scheduled-email.ts index e76501e209..c4447f8acf 100644 --- a/packages/emails/templates/organizer-scheduled-email.ts +++ b/packages/emails/templates/organizer-scheduled-email.ts @@ -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(), };