From 8d6fec79d3df0d9b89a1234349eaf35363996dd5 Mon Sep 17 00:00:00 2001 From: Mihai C <34626017+mihaic195@users.noreply.github.com> Date: Mon, 25 Oct 2021 16:05:21 +0300 Subject: [PATCH] feat: add translations for emails and type error fixes overall (#994) * feat: add translations for forgot password email and misc * fix: type fixes * feat: translate invitation email * fix: e2e tests * fix: lint * feat: type fixes and i18n for emails * Merge main * fix: jest import on server path * Merge * fix: playwright tests * fix: lint Co-authored-by: Bailey Pumfleet --- components/booking/BookingListItem.tsx | 5 +- components/booking/pages/BookingPage.tsx | 3 +- components/team/MemberInvitationModal.tsx | 20 +- jest.config.ts | 1 + lib/CalEventParser.ts | 51 ++--- lib/calendarClient.ts | 86 +++++---- lib/dailyVideoClient.ts | 54 +++--- lib/emails/EventAttendeeMail.ts | 39 ++-- lib/emails/EventAttendeeRescheduledMail.ts | 25 ++- lib/emails/EventMail.ts | 45 ++--- lib/emails/EventOrganizerMail.ts | 54 +++--- lib/emails/EventOrganizerRequestMail.ts | 16 +- lib/emails/EventOrganizerRescheduledMail.ts | 26 +-- lib/emails/EventRejectionMail.ts | 25 ++- lib/emails/VideoEventAttendeeMail.ts | 48 +++-- lib/emails/VideoEventOrganizerMail.ts | 46 +++-- lib/emails/buildMessageTemplate.ts | 23 ++- lib/emails/invitation.ts | 36 ++-- lib/errors.ts | 2 +- lib/events/EventManager.ts | 178 +++++++++--------- .../messaging/forgot-password.ts | 28 ++- lib/types/booking.ts | 1 + lib/videoClient.ts | 52 +++-- pages/api/auth/forgot-password.ts | 15 +- pages/api/book/confirm.ts | 20 +- pages/api/book/event.ts | 14 +- pages/api/teams/[team]/invite.ts | 29 +-- pages/auth/forgot-password/index.tsx | 22 ++- pages/event-types/[type].tsx | 60 +++--- pages/event-types/index.tsx | 2 +- playwright/integrations.test.ts | 3 + public/static/locales/en/common.json | 43 ++++- server/lib/i18n.ts | 18 ++ test/lib/emails/invitation.test.ts | 12 +- tsconfig.json | 39 +++- 35 files changed, 680 insertions(+), 461 deletions(-) create mode 100644 server/lib/i18n.ts diff --git a/components/booking/BookingListItem.tsx b/components/booking/BookingListItem.tsx index 2a7f1679..094370d9 100644 --- a/components/booking/BookingListItem.tsx +++ b/components/booking/BookingListItem.tsx @@ -12,13 +12,14 @@ import TableActions, { ActionType } from "@components/ui/TableActions"; type BookingItem = inferQueryOutput<"viewer.bookings">[number]; function BookingListItem(booking: BookingItem) { - const { t } = useLocale(); + const { t, i18n } = useLocale(); const utils = trpc.useContext(); + const mutation = useMutation( async (confirm: boolean) => { const res = await fetch("/api/book/confirm", { method: "PATCH", - body: JSON.stringify({ id: booking.id, confirmed: confirm }), + body: JSON.stringify({ id: booking.id, confirmed: confirm, language: i18n.language }), headers: { "Content-Type": "application/json", }, diff --git a/components/booking/pages/BookingPage.tsx b/components/booking/pages/BookingPage.tsx index 4ba006ae..bab46967 100644 --- a/components/booking/pages/BookingPage.tsx +++ b/components/booking/pages/BookingPage.tsx @@ -36,7 +36,7 @@ import { TeamBookingPageProps } from "../../../pages/team/[slug]/book"; type BookingPageProps = BookPageProps | TeamBookingPageProps; const BookingPage = (props: BookingPageProps) => { - const { t } = useLocale(); + const { t, i18n } = useLocale(); const router = useRouter(); const { rescheduleUid } = router.query; const { isReady } = useTheme(props.profile.theme); @@ -109,6 +109,7 @@ const BookingPage = (props: BookingPageProps) => { guests: guestEmails, eventTypeId: props.eventType.id, timeZone: timeZone(), + language: i18n.language, }; if (typeof rescheduleUid === "string") payload.rescheduleUid = rescheduleUid; if (typeof router.query.user === "string") payload.user = router.query.user; diff --git a/components/team/MemberInvitationModal.tsx b/components/team/MemberInvitationModal.tsx index 7fd6cd02..f9a602f6 100644 --- a/components/team/MemberInvitationModal.tsx +++ b/components/team/MemberInvitationModal.tsx @@ -1,5 +1,6 @@ import { UsersIcon } from "@heroicons/react/outline"; import { useState } from "react"; +import React, { SyntheticEvent } from "react"; import { useLocale } from "@lib/hooks/useLocale"; import { Team } from "@lib/team"; @@ -8,7 +9,7 @@ import Button from "@components/ui/Button"; export default function MemberInvitationModal(props: { team: Team | undefined | null; onExit: () => void }) { const [errorMessage, setErrorMessage] = useState(""); - const { t } = useLocale(); + const { t, i18n } = useLocale(); const handleError = async (res: Response) => { const responseData = await res.json(); @@ -21,13 +22,22 @@ export default function MemberInvitationModal(props: { team: Team | undefined | return responseData; }; - const inviteMember = (e) => { + const inviteMember = (e: SyntheticEvent) => { e.preventDefault(); + const target = e.target as typeof e.target & { + elements: { + role: { value: string }; + inviteUser: { value: string }; + sendInviteEmail: { checked: boolean }; + }; + }; + const payload = { - role: e.target.elements["role"].value, - usernameOrEmail: e.target.elements["inviteUser"].value, - sendEmailInvitation: e.target.elements["sendInviteEmail"].checked, + language: i18n.language, + role: target.elements["role"].value, + usernameOrEmail: target.elements["inviteUser"].value, + sendEmailInvitation: target.elements["sendInviteEmail"].checked, }; return fetch("/api/teams/" + props?.team?.id + "/invite", { diff --git a/jest.config.ts b/jest.config.ts index 601428e4..13aeb6d6 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -13,6 +13,7 @@ const config: Config.InitialOptions = { moduleNameMapper: { "^@components(.*)$": "/components$1", "^@lib(.*)$": "/lib$1", + "^@server(.*)$": "/server$1", }, }; diff --git a/lib/CalEventParser.ts b/lib/CalEventParser.ts index 752dd158..1b8ca779 100644 --- a/lib/CalEventParser.ts +++ b/lib/CalEventParser.ts @@ -2,7 +2,6 @@ import short from "short-uuid"; import { v5 as uuidv5 } from "uuid"; import { getIntegrationName } from "@lib/integrations"; -import { VideoCallData } from "@lib/videoClient"; import { CalendarEvent } from "./calendarClient"; import { stripHtml } from "./emails/helpers"; @@ -11,13 +10,9 @@ const translator = short(); export default class CalEventParser { protected calEvent: CalendarEvent; - protected maybeUid?: string; - protected optionalVideoCallData?: VideoCallData; - constructor(calEvent: CalendarEvent, maybeUid?: string, optionalVideoCallData?: VideoCallData) { + constructor(calEvent: CalendarEvent) { this.calEvent = calEvent; - this.maybeUid = maybeUid; - this.optionalVideoCallData = optionalVideoCallData; } /** @@ -38,14 +33,22 @@ export default class CalEventParser { * Returns a unique identifier for the given calendar event. */ public getUid(): string { - return this.maybeUid ?? translator.fromUUID(uuidv5(JSON.stringify(this.calEvent), uuidv5.URL)); + return this.calEvent.uid ?? translator.fromUUID(uuidv5(JSON.stringify(this.calEvent), uuidv5.URL)); } /** * Returns a footer section with links to change the event (as HTML). */ public getChangeEventFooterHtml(): string { - return `

Need to make a change? Cancel or reschedule

`; + return `

${this.calEvent.language( + "need_to_make_a_change" + )} ${this.calEvent.language( + "cancel" + )} ${this.calEvent + .language("or") + .toLowerCase()} ${this.calEvent.language( + "reschedule" + )}

`; } /** @@ -64,15 +67,19 @@ export default class CalEventParser { // This odd indentation is necessary because otherwise the leading tabs will be applied into the event description. return ( ` -Event Type:
${this.calEvent.type}
-Invitee Email:
${this.calEvent.attendees[0].email}
+${this.calEvent.language("event_type")}:
${this.calEvent.type}
+${this.calEvent.language("invitee_email")}:
${this.calEvent.attendees[0].email}
` + (this.getLocation() - ? `Location:
${this.getLocation()}
+ ? `${this.calEvent.language("location")}:
${this.getLocation()}
` : "") + - `Invitee Time Zone:
${this.calEvent.attendees[0].timeZone}
-Additional notes:
${this.getDescriptionText()}
` + + `${this.calEvent.language("invitee_timezone")}:
${ + this.calEvent.attendees[0].timeZone + }
+${this.calEvent.language("additional_notes")}:
${this.getDescriptionText()}
` + this.getChangeEventFooterHtml() ); } @@ -83,10 +90,10 @@ export default class CalEventParser { * For Daily video calls returns the direct link * @protected */ - protected getLocation(): string | undefined { + protected getLocation(): string | null | undefined { const isDaily = this.calEvent.location === "integrations:daily"; - if (this.optionalVideoCallData) { - return this.optionalVideoCallData.url; + if (this.calEvent.videoCallData) { + return this.calEvent.videoCallData.url; } if (isDaily) { return process.env.BASE_URL + "/call/" + this.getUid(); @@ -100,12 +107,14 @@ export default class CalEventParser { * * @protected */ - protected getDescriptionText(): string | undefined { - if (this.optionalVideoCallData) { + protected getDescriptionText(): string | null | undefined { + if (this.calEvent.videoCallData) { return ` -${getIntegrationName(this.optionalVideoCallData.type)} meeting -ID: ${this.optionalVideoCallData.id} -Password: ${this.optionalVideoCallData.password} +${this.calEvent.language("integration_meeting_id", { + integrationName: getIntegrationName(this.calEvent.videoCallData.type), + meetingId: this.calEvent.videoCallData.id, +})} +${this.calEvent.language("password")}: ${this.calEvent.videoCallData.password} ${this.calEvent.description}`; } return this.calEvent.description; diff --git a/lib/calendarClient.ts b/lib/calendarClient.ts index 79fd30ee..b0dfcfe6 100644 --- a/lib/calendarClient.ts +++ b/lib/calendarClient.ts @@ -1,4 +1,5 @@ import { Credential } from "@prisma/client"; +import { TFunction } from "next-i18next"; import { EventResult } from "@lib/events/EventManager"; import logger from "@lib/logger"; @@ -54,7 +55,7 @@ const googleAuth = (credential) => { }; }; -function handleErrorsJson(response) { +function handleErrorsJson(response: Response) { if (!response.ok) { response.json().then((e) => console.error("O365 Error", e)); throw Error(response.statusText); @@ -62,7 +63,7 @@ function handleErrorsJson(response) { return response.json(); } -function handleErrorsRaw(response) { +function handleErrorsRaw(response: Response) { if (!response.ok) { response.text().then((e) => console.error("O365 Error", e)); throw Error(response.statusText); @@ -112,20 +113,41 @@ const o365Auth = (credential) => { export type Person = { name: string; email: string; timeZone: string }; +export interface EntryPoint { + entryPointType?: string; + uri?: string; + label?: string; + pin?: string; + accessCode?: string; + meetingCode?: string; + passcode?: string; + password?: string; +} + +export interface AdditionInformation { + conferenceData?: ConferenceData; + entryPoints?: EntryPoint[]; + hangoutLink?: string; +} + export interface CalendarEvent { type: string; title: string; startTime: string; endTime: string; - description?: string; + description?: string | null; team?: { name: string; members: string[]; }; - location?: string; + location?: string | null; organizer: Person; attendees: Person[]; conferenceData?: ConferenceData; + language: TFunction; + additionInformation?: AdditionInformation; + uid?: string | null; + videoCallData?: VideoCallData; } export interface ConferenceData { @@ -143,9 +165,9 @@ type BufferedBusyTime = { start: string; end: string }; export interface CalendarApiAdapter { createEvent(event: CalendarEvent): Promise; - updateEvent(uid: string, event: CalendarEvent); + updateEvent(uid: string, event: CalendarEvent): Promise; - deleteEvent(uid: string); + deleteEvent(uid: string): Promise; getAvailability( dateFrom: string, @@ -574,11 +596,9 @@ const listCalendars = (withCredentials) => const createEvent = async ( credential: Credential, calEvent: CalendarEvent, - noMail = false, - maybeUid?: string, - optionalVideoCallData?: VideoCallData + noMail: boolean | null = false ): Promise => { - const parser: CalEventParser = new CalEventParser(calEvent, maybeUid, optionalVideoCallData); + const parser: CalEventParser = new CalEventParser(calEvent); const uid: string = parser.getUid(); /* * Matching the credential type is a workaround because the office calendar simply strips away newlines (\n and \r). @@ -589,7 +609,7 @@ const createEvent = async ( let success = true; - const creationResult = credential + const creationResult: any = credential ? await calendars([credential])[0] .createEvent(richEvent) .catch((e) => { @@ -598,16 +618,18 @@ const createEvent = async ( }) : null; - const maybeHangoutLink = creationResult?.hangoutLink; - const maybeEntryPoints = creationResult?.entryPoints; - const maybeConferenceData = creationResult?.conferenceData; + const metadata: AdditionInformation = {}; + if (creationResult) { + // TODO: Handle created event metadata more elegantly + metadata.hangoutLink = creationResult.hangoutLink; + metadata.conferenceData = creationResult.conferenceData; + metadata.entryPoints = creationResult.entryPoints; + } + + const emailEvent = { ...calEvent, additionInformation: metadata }; if (!noMail) { - const organizerMail = new EventOrganizerMail(calEvent, uid, { - hangoutLink: maybeHangoutLink, - conferenceData: maybeConferenceData, - entryPoints: maybeEntryPoints, - }); + const organizerMail = new EventOrganizerMail(emailEvent); try { await organizerMail.sendEmail(); @@ -627,28 +649,28 @@ const createEvent = async ( const updateEvent = async ( credential: Credential, - uidToUpdate: string, calEvent: CalendarEvent, - noMail = false, - optionalVideoCallData?: VideoCallData + noMail: boolean | null = false ): Promise => { - const parser: CalEventParser = new CalEventParser(calEvent, undefined, optionalVideoCallData); + const parser: CalEventParser = new CalEventParser(calEvent); const newUid: string = parser.getUid(); const richEvent: CalendarEvent = parser.asRichEventPlain(); let success = true; - const updateResult = credential - ? await calendars([credential])[0] - .updateEvent(uidToUpdate, richEvent) - .catch((e) => { - log.error("updateEvent failed", e, calEvent); - success = false; - }) - : null; + const updateResult = + credential && calEvent.uid + ? await calendars([credential])[0] + .updateEvent(calEvent.uid, richEvent) + .catch((e) => { + log.error("updateEvent failed", e, calEvent); + success = false; + }) + : null; if (!noMail) { - const organizerMail = new EventOrganizerRescheduledMail(calEvent, newUid); + const emailEvent = { ...calEvent, uid: newUid }; + const organizerMail = new EventOrganizerRescheduledMail(emailEvent); try { await organizerMail.sendEmail(); } catch (e) { diff --git a/lib/dailyVideoClient.ts b/lib/dailyVideoClient.ts index 4e37130c..736eaee9 100644 --- a/lib/dailyVideoClient.ts +++ b/lib/dailyVideoClient.ts @@ -3,12 +3,11 @@ import short from "short-uuid"; import { v5 as uuidv5 } from "uuid"; import CalEventParser from "@lib/CalEventParser"; -import { AdditionInformation, EntryPoint } from "@lib/emails/EventMail"; import { getIntegrationName } from "@lib/emails/helpers"; import { EventResult } from "@lib/events/EventManager"; import logger from "@lib/logger"; -import { CalendarEvent } from "./calendarClient"; +import { CalendarEvent, AdditionInformation, EntryPoint } from "./calendarClient"; import EventAttendeeRescheduledMail from "./emails/EventAttendeeRescheduledMail"; import EventOrganizerRescheduledMail from "./emails/EventOrganizerRescheduledMail"; import VideoEventAttendeeMail from "./emails/VideoEventAttendeeMail"; @@ -25,7 +24,7 @@ export interface DailyVideoCallData { url: string; } -function handleErrorsJson(response) { +function handleErrorsJson(response: Response) { if (!response.ok) { response.json().then(console.log); throw Error(response.statusText); @@ -38,14 +37,14 @@ const dailyCredential = process.env.DAILY_API_KEY; interface DailyVideoApiAdapter { dailyCreateMeeting(event: CalendarEvent): Promise; - dailyUpdateMeeting(uid: string, event: CalendarEvent); + dailyUpdateMeeting(uid: string, event: CalendarEvent): Promise; dailyDeleteMeeting(uid: string): Promise; getAvailability(dateFrom, dateTo): Promise; } -const DailyVideo = (credential): DailyVideoApiAdapter => { +const DailyVideo = (credential: Credential): DailyVideoApiAdapter => { const translateEvent = (event: CalendarEvent) => { // Documentation at: https://docs.daily.co/reference#list-rooms // added a 1 hour buffer for room expiration and room entry @@ -110,12 +109,8 @@ const getBusyVideoTimes: (withCredentials) => Promise = (withCredenti results.reduce((acc, availability) => acc.concat(availability), []) ); -const dailyCreateMeeting = async ( - credential: Credential, - calEvent: CalendarEvent, - maybeUid: string = null -): Promise => { - const parser: CalEventParser = new CalEventParser(calEvent, maybeUid); +const dailyCreateMeeting = async (credential: Credential, calEvent: CalendarEvent): Promise => { + const parser: CalEventParser = new CalEventParser(calEvent); const uid: string = parser.getUid(); if (!credential) { @@ -145,18 +140,17 @@ const dailyCreateMeeting = async ( const entryPoint: EntryPoint = { entryPointType: getIntegrationName(videoCallData), uri: videoCallData.url, - label: "Enter Meeting", + label: calEvent.language("enter_meeting"), pin: "", }; const additionInformation: AdditionInformation = { entryPoints: [entryPoint], }; - - const organizerMail = new VideoEventOrganizerMail(calEvent, uid, videoCallData, additionInformation); - const attendeeMail = new VideoEventAttendeeMail(calEvent, uid, videoCallData, additionInformation); + const emailEvent = { ...calEvent, uid, additionInformation, videoCallData }; try { + const organizerMail = new VideoEventOrganizerMail(emailEvent); await organizerMail.sendEmail(); } catch (e) { console.error("organizerMail.sendEmail failed", e); @@ -164,6 +158,7 @@ const dailyCreateMeeting = async ( if (!creationResult || !creationResult.disableConfirmationEmail) { try { + const attendeeMail = new VideoEventAttendeeMail(emailEvent); await attendeeMail.sendEmail(); } catch (e) { console.error("attendeeMail.sendEmail failed", e); @@ -179,11 +174,7 @@ const dailyCreateMeeting = async ( }; }; -const dailyUpdateMeeting = async ( - credential: Credential, - uidToUpdate: string, - calEvent: CalendarEvent -): Promise => { +const dailyUpdateMeeting = async (credential: Credential, calEvent: CalendarEvent): Promise => { const newUid: string = translator.fromUUID(uuidv5(JSON.stringify(calEvent), uuidv5.URL)); if (!credential) { @@ -194,18 +185,20 @@ const dailyUpdateMeeting = async ( let success = true; - const updateResult = credential - ? await videoIntegrations([credential])[0] - .dailyUpdateMeeting(uidToUpdate, calEvent) - .catch((e) => { - log.error("updateMeeting failed", e, calEvent); - success = false; - }) - : null; + const updateResult = + credential && calEvent.uid + ? await videoIntegrations([credential])[0] + .dailyUpdateMeeting(calEvent.uid, calEvent) + .catch((e) => { + log.error("updateMeeting failed", e, calEvent); + success = false; + }) + : null; + + const emailEvent = { ...calEvent, uid: newUid }; - const organizerMail = new EventOrganizerRescheduledMail(calEvent, newUid); - const attendeeMail = new EventAttendeeRescheduledMail(calEvent, newUid); try { + const organizerMail = new EventOrganizerRescheduledMail(emailEvent); await organizerMail.sendEmail(); } catch (e) { console.error("organizerMail.sendEmail failed", e); @@ -213,6 +206,7 @@ const dailyUpdateMeeting = async ( if (!updateResult || !updateResult.disableConfirmationEmail) { try { + const attendeeMail = new EventAttendeeRescheduledMail(emailEvent); await attendeeMail.sendEmail(); } catch (e) { console.error("attendeeMail.sendEmail failed", e); diff --git a/lib/emails/EventAttendeeMail.ts b/lib/emails/EventAttendeeMail.ts index 1d18978e..2058e6ea 100644 --- a/lib/emails/EventAttendeeMail.ts +++ b/lib/emails/EventAttendeeMail.ts @@ -45,8 +45,10 @@ export default class EventAttendeeMail extends EventMail { d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /> -

Your meeting has been booked

-

You and any other attendees have been emailed with this information.

+

${this.calEvent.language( + "your_meeting_has_been_booked" + )}

+

${this.calEvent.language("emailed_you_and_attendees")}


@@ -54,17 +56,17 @@ export default class EventAttendeeMail extends EventMail { - + - + - + - + - +
What${this.calEvent.language("what")} ${this.calEvent.type}
When${this.calEvent.language("when")} ${this.getInviteeStart().format("dddd, LL")}
${this.getInviteeStart().format("h:mma")} (${ this.calEvent.attendees[0].timeZone })
Who${this.calEvent.language("who")} ${this.calEvent.team?.name || this.calEvent.organizer.name}
@@ -74,11 +76,11 @@ export default class EventAttendeeMail extends EventMail {
Where${this.calEvent.language("where")} ${this.getLocation()}
Notes${this.calEvent.language("notes")} ${this.calEvent.description}
@@ -104,15 +106,18 @@ export default class EventAttendeeMail extends EventMail { * @protected */ protected getLocation(): string { - if (this.additionInformation?.hangoutLink) { - return `${this.additionInformation?.hangoutLink}
`; + if (this.calEvent.additionInformation?.hangoutLink) { + return `${this.calEvent.additionInformation?.hangoutLink}
`; } - if (this.additionInformation?.entryPoints && this.additionInformation?.entryPoints.length > 0) { - const locations = this.additionInformation?.entryPoints + if ( + this.calEvent.additionInformation?.entryPoints && + this.calEvent.additionInformation?.entryPoints.length > 0 + ) { + const locations = this.calEvent.additionInformation?.entryPoints .map((entryPoint) => { return ` - Join by ${entryPoint.entryPointType}:
+ ${this.calEvent.language("join_by_entrypoint", { entryPoint: entryPoint.entryPointType })}:
${entryPoint.label}
`; }) @@ -142,15 +147,17 @@ export default class EventAttendeeMail extends EventMail { to: `${this.calEvent.attendees[0].name} <${this.calEvent.attendees[0].email}>`, from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`, replyTo: this.calEvent.organizer.email, - subject: `Confirmed: ${this.calEvent.type} with ${ - this.calEvent.team?.name || this.calEvent.organizer.name - } on ${this.getInviteeStart().format("LT dddd, LL")}`, + subject: this.calEvent.language("confirmed_event_type_subject", { + eventType: this.calEvent.type, + name: this.calEvent.team?.name || this.calEvent.organizer.name, + date: this.getInviteeStart().format("LT dddd, LL"), + }), html: this.getHtmlRepresentation(), text: this.getPlainTextRepresentation(), }; } - protected printNodeMailerError(error: string): void { + protected printNodeMailerError(error: Error): void { console.error("SEND_BOOKING_CONFIRMATION_ERROR", this.calEvent.attendees[0].email, error); } diff --git a/lib/emails/EventAttendeeRescheduledMail.ts b/lib/emails/EventAttendeeRescheduledMail.ts index 92592df3..a8eb6647 100644 --- a/lib/emails/EventAttendeeRescheduledMail.ts +++ b/lib/emails/EventAttendeeRescheduledMail.ts @@ -10,12 +10,17 @@ export default class EventAttendeeRescheduledMail extends EventAttendeeMail { return ( `
- Hi ${this.calEvent.attendees[0].name},
+ ${this.calEvent.language("hi_user_name", { userName: this.calEvent.attendees[0].name })},

- Your ${this.calEvent.type} with ${ - this.calEvent.team?.name || this.calEvent.organizer.name - } has been rescheduled to ${this.getInviteeStart().format("h:mma")} - (${this.calEvent.attendees[0].timeZone}) on ${this.getInviteeStart().format("dddd, LL")}.
+ ${this.calEvent.language("event_type_has_been_rescheduled_on_time_date", { + eventType: this.calEvent.type, + name: this.calEvent.team?.name || this.calEvent.organizer.name, + time: this.getInviteeStart().format("h:mma"), + timeZone: this.calEvent.attendees[0].timeZone, + date: + `${this.calEvent.language(this.getInviteeStart().format("dddd, ").toLowerCase())}` + + `${this.calEvent.language(this.getInviteeStart().format("LL").toLowerCase())}`, + })}
` + this.getAdditionalFooter() + ` @@ -34,15 +39,17 @@ export default class EventAttendeeRescheduledMail extends EventAttendeeMail { to: `${this.calEvent.attendees[0].name} <${this.calEvent.attendees[0].email}>`, from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`, replyTo: this.calEvent.organizer.email, - subject: `Rescheduled: ${this.calEvent.type} with ${ - this.calEvent.organizer.name - } on ${this.getInviteeStart().format("dddd, LL")}`, + subject: this.calEvent.language("rescheduled_event_type_with_organizer", { + eventType: this.calEvent.type, + organizerName: this.calEvent.organizer.name, + date: this.getInviteeStart().format("dddd, LL"), + }), html: this.getHtmlRepresentation(), text: this.getPlainTextRepresentation(), }; } - protected printNodeMailerError(error: string): void { + protected printNodeMailerError(error: Error): void { console.error("SEND_RESCHEDULE_CONFIRMATION_ERROR", this.calEvent.attendees[0].email, error); } } diff --git a/lib/emails/EventMail.ts b/lib/emails/EventMail.ts index 998aaee5..833ace99 100644 --- a/lib/emails/EventMail.ts +++ b/lib/emails/EventMail.ts @@ -1,47 +1,25 @@ import nodemailer from "nodemailer"; +import { getErrorFromUnknown } from "@lib/errors"; + import CalEventParser from "../CalEventParser"; -import { CalendarEvent, ConferenceData } from "../calendarClient"; +import { CalendarEvent } from "../calendarClient"; import { serverConfig } from "../serverConfig"; import { stripHtml } from "./helpers"; -export interface EntryPoint { - entryPointType?: string; - uri?: string; - label?: string; - pin?: string; - accessCode?: string; - meetingCode?: string; - passcode?: string; - password?: string; -} - -export interface AdditionInformation { - conferenceData?: ConferenceData; - entryPoints?: EntryPoint[]; - hangoutLink?: string; -} - export default abstract class EventMail { calEvent: CalendarEvent; parser: CalEventParser; - uid: string; - additionInformation?: AdditionInformation; /** * An EventMail always consists of a CalendarEvent - * that stores the very basic data of the event (like date, title etc). - * It also needs the UID of the stored booking in our database. + * that stores the data of the event (like date, title, uid etc). * * @param calEvent - * @param uid - * @param additionInformation */ - constructor(calEvent: CalendarEvent, uid: string, additionInformation?: AdditionInformation) { + constructor(calEvent: CalendarEvent) { this.calEvent = calEvent; - this.uid = uid; - this.parser = new CalEventParser(calEvent, uid); - this.additionInformation = additionInformation; + this.parser = new CalEventParser(calEvent); } /** @@ -74,10 +52,11 @@ export default abstract class EventMail { new Promise((resolve, reject) => nodemailer .createTransport(this.getMailerOptions().transport) - .sendMail(this.getNodeMailerPayload(), (error, info) => { - if (error) { - this.printNodeMailerError(error); - reject(new Error(error)); + .sendMail(this.getNodeMailerPayload(), (_err, info) => { + if (_err) { + const err = getErrorFromUnknown(_err); + this.printNodeMailerError(err); + reject(err); } else { resolve(info); } @@ -117,7 +96,7 @@ export default abstract class EventMail { * @param error * @protected */ - protected abstract printNodeMailerError(error: string): void; + protected abstract printNodeMailerError(error: Error): void; /** * Returns a link to reschedule the given booking. diff --git a/lib/emails/EventOrganizerMail.ts b/lib/emails/EventOrganizerMail.ts index ecf5c59a..a08e40f6 100644 --- a/lib/emails/EventOrganizerMail.ts +++ b/lib/emails/EventOrganizerMail.ts @@ -5,6 +5,8 @@ import toArray from "dayjs/plugin/toArray"; import utc from "dayjs/plugin/utc"; import { createEvent } from "ics"; +import { Person } from "@lib/calendarClient"; + import EventMail from "./EventMail"; import { stripHtml } from "./helpers"; @@ -18,7 +20,7 @@ export default class EventOrganizerMail extends EventMail { * Returns the instance's event as an iCal event in string representation. * @protected */ - protected getiCalEventAsString(): string { + protected getiCalEventAsString(): string | undefined { const icsEvent = createEvent({ start: dayjs(this.calEvent.startTime) .utc() @@ -27,14 +29,17 @@ export default class EventOrganizerMail extends EventMail { .map((v, i) => (i === 1 ? v + 1 : v)), startInputType: "utc", productId: "calendso/ics", - title: `${this.calEvent.type} with ${this.calEvent.attendees[0].name}`, + title: this.calEvent.language("organizer_ics_event_title", { + eventType: this.calEvent.type, + attendeeName: this.calEvent.attendees[0].name, + }), description: this.calEvent.description + stripHtml(this.getAdditionalBody()) + stripHtml(this.getAdditionalFooter()), duration: { minutes: dayjs(this.calEvent.endTime).diff(dayjs(this.calEvent.startTime), "minute") }, organizer: { name: this.calEvent.organizer.name, email: this.calEvent.organizer.email }, - attendees: this.calEvent.attendees.map((attendee: unknown) => ({ + attendees: this.calEvent.attendees.map((attendee: Person) => ({ name: attendee.name, email: attendee.email, })), @@ -47,13 +52,15 @@ export default class EventOrganizerMail extends EventMail { } protected getBodyHeader(): string { - return "A new event has been scheduled."; + return this.calEvent.language("new_event_scheduled"); } protected getAdditionalFooter(): string { - return `

Need to make a change? Manage my bookings

`; + return `

${this.calEvent.language( + "need_to_make_a_change" + )} ${this.calEvent.language( + "manage_my_bookings" + )}

`; } protected getImage(): string { @@ -103,27 +110,27 @@ export default class EventOrganizerMail extends EventMail { - What + ${this.calEvent.language("what")} ${this.calEvent.type} - When + ${this.calEvent.language("when")} ${this.getOrganizerStart().format("dddd, LL")}
${this.getOrganizerStart().format("h:mma")} (${ this.calEvent.organizer.timeZone }) - Who + ${this.calEvent.language("who")} ${this.calEvent.attendees[0].name}
${this.calEvent.attendees[0].email} - Where + ${this.calEvent.language("where")} ${this.getLocation()} - Notes + ${this.calEvent.language("notes")} ${this.calEvent.description} @@ -149,15 +156,18 @@ export default class EventOrganizerMail extends EventMail { * @protected */ protected getLocation(): string { - if (this.additionInformation?.hangoutLink) { - return `${this.additionInformation?.hangoutLink}
`; + if (this.calEvent.additionInformation?.hangoutLink) { + return `${this.calEvent.additionInformation?.hangoutLink}
`; } - if (this.additionInformation?.entryPoints && this.additionInformation?.entryPoints.length > 0) { - const locations = this.additionInformation?.entryPoints + if ( + this.calEvent.additionInformation?.entryPoints && + this.calEvent.additionInformation?.entryPoints.length > 0 + ) { + const locations = this.calEvent.additionInformation?.entryPoints .map((entryPoint) => { return ` - Join by ${entryPoint.entryPointType}:
+ ${this.calEvent.language("join_by_entrypoint", { entryPoint: entryPoint.entryPointType })}:
${entryPoint.label}
`; }) @@ -202,12 +212,14 @@ export default class EventOrganizerMail extends EventMail { } protected getSubject(): string { - return `New event: ${this.calEvent.attendees[0].name} - ${this.getOrganizerStart().format( - "LT dddd, LL" - )} - ${this.calEvent.type}`; + return this.calEvent.language("new_event_subject", { + attendeeName: this.calEvent.attendees[0].name, + date: this.getOrganizerStart().format("LT dddd, LL"), + eventType: this.calEvent.type, + }); } - protected printNodeMailerError(error: string): void { + protected printNodeMailerError(error: Error): void { console.error("SEND_NEW_EVENT_NOTIFICATION_ERROR", this.calEvent.organizer.email, error); } diff --git a/lib/emails/EventOrganizerRequestMail.ts b/lib/emails/EventOrganizerRequestMail.ts index dc2f345a..f7cd2621 100644 --- a/lib/emails/EventOrganizerRequestMail.ts +++ b/lib/emails/EventOrganizerRequestMail.ts @@ -13,15 +13,17 @@ dayjs.extend(localizedFormat); export default class EventOrganizerRequestMail extends EventOrganizerMail { protected getBodyHeader(): string { - return "A new event is waiting for your approval."; + return this.calEvent.language("event_awaiting_approval"); } protected getBodyText(): string { - return "Check your bookings page to confirm or reject the booking."; + return this.calEvent.language("check_bookings_page_to_confirm_or_reject"); } protected getAdditionalBody(): string { - return `Confirm or reject the booking`; + return `${this.calEvent.language( + "confirm_or_reject_booking" + )}`; } protected getImage(): string { @@ -43,8 +45,10 @@ export default class EventOrganizerRequestMail extends EventOrganizerMail { protected getSubject(): string { const organizerStart: Dayjs = dayjs(this.calEvent.startTime).tz(this.calEvent.organizer.timeZone); - return `New event request: ${this.calEvent.attendees[0].name} - ${organizerStart.format( - "LT dddd, LL" - )} - ${this.calEvent.type}`; + return this.calEvent.language("new_event_request", { + attendeeName: this.calEvent.attendees[0].name, + date: organizerStart.format("LT dddd, LL"), + eventType: this.calEvent.type, + }); } } diff --git a/lib/emails/EventOrganizerRescheduledMail.ts b/lib/emails/EventOrganizerRescheduledMail.ts index ef218559..5a77e766 100644 --- a/lib/emails/EventOrganizerRescheduledMail.ts +++ b/lib/emails/EventOrganizerRescheduledMail.ts @@ -12,32 +12,32 @@ export default class EventOrganizerRescheduledMail extends EventOrganizerMail { return ( `
- Hi ${this.calEvent.organizer.name},
+ ${this.calEvent.language("hi_user_name", { userName: this.calEvent.organizer.name })},

- Your event has been rescheduled.
+ ${this.calEvent.language("event_has_been_rescheduled")}

- Event Type:
+ ${this.calEvent.language("event_type")}:
${this.calEvent.type}

- Invitee Email:
+ ${this.calEvent.language("invitee_email")}:
${this.calEvent.attendees[0].email}

` + this.getAdditionalBody() + (this.calEvent.location ? ` - Location:
+ ${this.calEvent.language("location")}:
${this.calEvent.location}

` : "") + - `Invitee Time Zone:
+ `${this.calEvent.language("invitee_timezone")}:
${this.calEvent.attendees[0].timeZone}

- Additional notes:
+ ${this.calEvent.language("additional_notes")}:
${this.calEvent.description} ` + this.getAdditionalFooter() + - ` + `
` ); @@ -58,15 +58,17 @@ export default class EventOrganizerRescheduledMail extends EventOrganizerMail { }, from: `Cal.com <${this.getMailerOptions().from}>`, to: this.calEvent.organizer.email, - subject: `Rescheduled event: ${this.calEvent.attendees[0].name} - ${organizerStart.format( - "LT dddd, LL" - )} - ${this.calEvent.type}`, + subject: this.calEvent.language("rescheduled_event_type_with_attendee", { + attendeeName: this.calEvent.attendees[0].name, + date: organizerStart.format("LT dddd, LL"), + eventType: this.calEvent.type, + }), html: this.getHtmlRepresentation(), text: this.getPlainTextRepresentation(), }; } - protected printNodeMailerError(error: string): void { + protected printNodeMailerError(error: Error): void { console.error("SEND_RESCHEDULE_EVENT_NOTIFICATION_ERROR", this.calEvent.organizer.email, error); } } diff --git a/lib/emails/EventRejectionMail.ts b/lib/emails/EventRejectionMail.ts index b3316520..53067bbc 100644 --- a/lib/emails/EventRejectionMail.ts +++ b/lib/emails/EventRejectionMail.ts @@ -45,8 +45,8 @@ export default class EventRejectionMail extends EventMail { d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /> -

Your meeting request has been rejected

-

You and any other attendees have been emailed with this information.

+

${this.calEvent.language("meeting_request_rejected")}

+

${this.calEvent.language("emailed_you_and_attendees")}


` + ` @@ -68,24 +68,35 @@ export default class EventRejectionMail extends EventMail { to: `${this.calEvent.attendees[0].name} <${this.calEvent.attendees[0].email}>`, from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`, replyTo: this.calEvent.organizer.email, - subject: `Rejected: ${this.calEvent.type} with ${ - this.calEvent.organizer.name - } on ${this.getInviteeStart().format("dddd, LL")}`, + subject: this.calEvent.language("rejected_event_type_with_organizer", { + eventType: this.calEvent.type, + organizer: this.calEvent.organizer.name, + date: this.getInviteeStart().format("dddd, LL"), + }), html: this.getHtmlRepresentation(), text: this.getPlainTextRepresentation(), }; } - protected printNodeMailerError(error: string): void { + protected printNodeMailerError(error: Error): void { console.error("SEND_BOOKING_CONFIRMATION_ERROR", this.calEvent.attendees[0].email, error); } /** * Returns the inviteeStart value used at multiple points. * - * @private + * @protected */ protected getInviteeStart(): Dayjs { return dayjs(this.calEvent.startTime).tz(this.calEvent.attendees[0].timeZone); } + + /** + * Adds the video call information to the mail body. + * + * @protected + */ + protected getLocation(): string { + return ""; + } } diff --git a/lib/emails/VideoEventAttendeeMail.ts b/lib/emails/VideoEventAttendeeMail.ts index 26ea18d0..6c723ea0 100644 --- a/lib/emails/VideoEventAttendeeMail.ts +++ b/lib/emails/VideoEventAttendeeMail.ts @@ -1,45 +1,43 @@ -import { AdditionInformation } from "@lib/emails/EventMail"; - -import { CalendarEvent } from "../calendarClient"; -import { VideoCallData } from "../videoClient"; import EventAttendeeMail from "./EventAttendeeMail"; import { getFormattedMeetingId, getIntegrationName } from "./helpers"; export default class VideoEventAttendeeMail extends EventAttendeeMail { - videoCallData: VideoCallData; - - constructor( - calEvent: CalendarEvent, - uid: string, - videoCallData: VideoCallData, - additionInformation: AdditionInformation = null - ) { - super(calEvent, uid); - this.videoCallData = videoCallData; - this.additionInformation = additionInformation; - } - /** * Adds the video call information to the mail body. * * @protected */ protected getAdditionalBody(): string { - const meetingPassword = this.videoCallData.password; - const meetingId = getFormattedMeetingId(this.videoCallData); + if (!this.calEvent.videoCallData) { + return ""; + } + const meetingPassword = this.calEvent.videoCallData.password; + const meetingId = getFormattedMeetingId(this.calEvent.videoCallData); if (meetingId && meetingPassword) { return ` - Video call provider: ${getIntegrationName(this.videoCallData)}
- Meeting ID: ${getFormattedMeetingId(this.videoCallData)}
- Meeting Password: ${this.videoCallData.password}
- Meeting URL: ${this.videoCallData.url}
+ ${this.calEvent.language("video_call_provider")}: ${getIntegrationName( + this.calEvent.videoCallData + )}
+ ${this.calEvent.language("meeting_id")}: ${getFormattedMeetingId( + this.calEvent.videoCallData + )}
+ ${this.calEvent.language("meeting_password")}: ${ + this.calEvent.videoCallData.password + }
+ ${this.calEvent.language("meeting_url")}: ${this.calEvent.videoCallData.url}
`; } return ` - Video call provider: ${getIntegrationName(this.videoCallData)}
- Meeting URL: ${this.videoCallData.url}
+ ${this.calEvent.language("video_call_provider")}: ${getIntegrationName( + this.calEvent.videoCallData + )}
+ ${this.calEvent.language("meeting_url")}: ${this.calEvent.videoCallData.url}
`; } } diff --git a/lib/emails/VideoEventOrganizerMail.ts b/lib/emails/VideoEventOrganizerMail.ts index 9a63a645..c4ab3a3d 100644 --- a/lib/emails/VideoEventOrganizerMail.ts +++ b/lib/emails/VideoEventOrganizerMail.ts @@ -1,24 +1,7 @@ -import { AdditionInformation } from "@lib/emails/EventMail"; - -import { CalendarEvent } from "../calendarClient"; -import { VideoCallData } from "../videoClient"; import EventOrganizerMail from "./EventOrganizerMail"; import { getFormattedMeetingId, getIntegrationName } from "./helpers"; export default class VideoEventOrganizerMail extends EventOrganizerMail { - videoCallData: VideoCallData; - - constructor( - calEvent: CalendarEvent, - uid: string, - videoCallData: VideoCallData, - additionInformation: AdditionInformation = null - ) { - super(calEvent, uid); - this.videoCallData = videoCallData; - this.additionInformation = additionInformation; - } - /** * Adds the video call information to the mail body * and calendar event description. @@ -26,20 +9,33 @@ export default class VideoEventOrganizerMail extends EventOrganizerMail { * @protected */ protected getAdditionalBody(): string { - const meetingPassword = this.videoCallData.password; - const meetingId = getFormattedMeetingId(this.videoCallData); + if (!this.calEvent.videoCallData) { + return ""; + } + const meetingPassword = this.calEvent.videoCallData.password; + const meetingId = getFormattedMeetingId(this.calEvent.videoCallData); // This odd indentation is necessary because otherwise the leading tabs will be applied into the event description. if (meetingPassword && meetingId) { return ` -Video call provider: ${getIntegrationName(this.videoCallData)}
-Meeting ID: ${getFormattedMeetingId(this.videoCallData)}
-Meeting Password: ${this.videoCallData.password}
-Meeting URL: ${this.videoCallData.url}
+${this.calEvent.language("video_call_provider")}: ${getIntegrationName( + this.calEvent.videoCallData + )}
+${this.calEvent.language("meeting_id")}: ${getFormattedMeetingId( + this.calEvent.videoCallData + )}
+${this.calEvent.language("meeting_password")}: ${this.calEvent.videoCallData.password}
+${this.calEvent.language("meeting_url")}: ${ + this.calEvent.videoCallData.url + }
`; } return ` -Video call provider: ${getIntegrationName(this.videoCallData)}
-Meeting URL: ${this.videoCallData.url}
+${this.calEvent.language("video_call_provider")}: ${getIntegrationName( + this.calEvent.videoCallData + )}
+${this.calEvent.language("meeting_url")}: ${ + this.calEvent.videoCallData.url + }
`; } } diff --git a/lib/emails/buildMessageTemplate.ts b/lib/emails/buildMessageTemplate.ts index 2d3f0696..68e49e07 100644 --- a/lib/emails/buildMessageTemplate.ts +++ b/lib/emails/buildMessageTemplate.ts @@ -1,15 +1,36 @@ import Handlebars from "handlebars"; +import { TFunction } from "next-i18next"; + +export type VarType = { + language: TFunction; + user: { + name: string | null; + }; + link: string; +}; + +export type MessageTemplateTypes = { + messageTemplate: string; + subjectTemplate: string; + vars: VarType; +}; + +export type BuildTemplateResult = { + subject: string; + message: string; +}; export const buildMessageTemplate = ({ messageTemplate, subjectTemplate, vars, -}): { subject: string; message: string } => { +}: MessageTemplateTypes): BuildTemplateResult => { const buildMessage = Handlebars.compile(messageTemplate); const message = buildMessage(vars); const buildSubject = Handlebars.compile(subjectTemplate); const subject = buildSubject(vars); + return { subject, message, diff --git a/lib/emails/invitation.ts b/lib/emails/invitation.ts index b95d7790..47c8bdda 100644 --- a/lib/emails/invitation.ts +++ b/lib/emails/invitation.ts @@ -1,8 +1,12 @@ +import { TFunction } from "next-i18next"; import nodemailer from "nodemailer"; +import { getErrorFromUnknown } from "@lib/errors"; + import { serverConfig } from "../serverConfig"; export type Invitation = { + language: TFunction; from?: string; toEmail: string; teamName: string; @@ -26,21 +30,24 @@ const sendEmail = (invitation: Invitation, provider: EmailProvider): Promise { const { transport, from } = provider; + const { language: t } = invitation; const invitationHtml = html(invitation); nodemailer.createTransport(transport).sendMail( { from: `Cal.com <${from}>`, to: invitation.toEmail, - subject: - (invitation.from ? invitation.from + " invited you" : "You have been invited") + - ` to join ${invitation.teamName}`, + subject: invitation.from + ? t("user_invited_you", { user: invitation.from, teamName: invitation.teamName }) + : t("you_have_been_invited", { teamName: invitation.teamName }), html: invitationHtml, text: text(invitationHtml), }, - (error) => { - if (error) { - console.error("SEND_INVITATION_NOTIFICATION_ERROR", invitation.toEmail, error); - return reject(new Error(error)); + (_err) => { + if (_err) { + const err = getErrorFromUnknown(_err); + console.error("SEND_INVITATION_NOTIFICATION_ERROR", invitation.toEmail, err); + reject(err); + return; } return resolve(); } @@ -48,6 +55,7 @@ const sendEmail = (invitation: Invitation, provider: EmailProvider): Promise - Hi,
+ ${t("hi")},

` + - (invitation.from ? invitation.from + " invited you" : "You have been invited") + - ` to join the team "${invitation.teamName}" in Cal.com.
+ (invitation.from + ? t("user_invited_you", { user: invitation.from, teamName: invitation.teamName }) + : t("you_have_been_invited", { teamName: invitation.teamName })) + + `

@@ -77,13 +87,15 @@ export function html(invitation: Invitation): string {
Join team
- Join team + ${t( + "join_team" + )}

- If you prefer not to use "${invitation.toEmail}" as your Cal.com email or already have a Cal.com account, please request another invitation to that email. + ${t("request_another_invitation_email", { toEmail: invitation.toEmail })} diff --git a/lib/errors.ts b/lib/errors.ts index 9d1df3ae..a274c699 100644 --- a/lib/errors.ts +++ b/lib/errors.ts @@ -1,4 +1,4 @@ -export function getErrorFromUnknown(cause: unknown): Error & { statusCode?: number } { +export function getErrorFromUnknown(cause: unknown): Error & { statusCode?: number; code?: unknown } { if (cause instanceof Error) { return cause; } diff --git a/lib/events/EventManager.ts b/lib/events/EventManager.ts index ed76dc14..ee624a42 100644 --- a/lib/events/EventManager.ts +++ b/lib/events/EventManager.ts @@ -3,7 +3,7 @@ import async from "async"; import merge from "lodash/merge"; import { v5 as uuidv5 } from "uuid"; -import { CalendarEvent, createEvent, updateEvent } from "@lib/calendarClient"; +import { CalendarEvent, AdditionInformation, createEvent, updateEvent } from "@lib/calendarClient"; import { dailyCreateMeeting, dailyUpdateMeeting } from "@lib/dailyVideoClient"; import EventAttendeeMail from "@lib/emails/EventAttendeeMail"; import EventAttendeeRescheduledMail from "@lib/emails/EventAttendeeRescheduledMail"; @@ -15,8 +15,8 @@ export interface EventResult { type: string; success: boolean; uid: string; - createdEvent?: unknown; - updatedEvent?: unknown; + createdEvent?: AdditionInformation & { name: string; id: string; disableConfirmationEmail?: boolean }; + updatedEvent?: AdditionInformation & { name: string; id: string; disableConfirmationEmail?: boolean }; originalEvent: CalendarEvent; videoCallData?: VideoCallData; } @@ -35,9 +35,9 @@ export interface PartialReference { id?: number; type: string; uid: string; - meetingId?: string; - meetingPassword?: string; - meetingUrl?: string; + meetingId?: string | null; + meetingPassword?: string | null; + meetingUrl?: string | null; } interface GetLocationRequestFromIntegrationRequest { @@ -78,55 +78,46 @@ export default class EventManager { * Takes a CalendarEvent and creates all necessary integration entries for it. * When a video integration is chosen as the event's location, a video integration * event will be scheduled for it as well. - * An optional uid can be set to override the auto-generated uid. * * @param event - * @param maybeUid */ - public async create(event: CalendarEvent, maybeUid?: string): Promise { - event = EventManager.processLocation(event); - const isDedicated = EventManager.isDedicatedIntegration(event.location); + public async create(event: CalendarEvent): Promise { + let evt = EventManager.processLocation(event); + const isDedicated = evt.location ? EventManager.isDedicatedIntegration(evt.location) : null; let results: Array = []; - let optionalVideoCallData: VideoCallData | undefined = undefined; // If and only if event type is a dedicated meeting, create a dedicated video meeting. if (isDedicated) { - const result = await this.createVideoEvent(event, maybeUid); + const result = await this.createVideoEvent(evt); if (result.videoCallData) { - optionalVideoCallData = result.videoCallData; + evt = { ...evt, videoCallData: result.videoCallData }; } results.push(result); } else { - await EventManager.sendAttendeeMail("new", results, event, maybeUid); + await EventManager.sendAttendeeMail("new", results, evt); } // Now create all calendar events. If this is a dedicated integration event, // don't send a mail right here, because it has already been sent. - results = results.concat( - await this.createAllCalendarEvents(event, isDedicated, maybeUid, optionalVideoCallData) - ); + results = results.concat(await this.createAllCalendarEvents(evt, isDedicated)); const referencesToCreate: Array = results.map((result: EventResult) => { const isDailyResult = result.type === "daily"; - if (isDailyResult) { - return { - type: result.type, - uid: result.createdEvent.name.toString(), - meetingId: result.videoCallData?.id.toString(), - meetingPassword: result.videoCallData?.password, - meetingUrl: result.videoCallData?.url, - }; + let uid = ""; + if (isDailyResult && result.createdEvent) { + uid = result.createdEvent.name.toString(); } - if (!isDailyResult) { - return { - type: result.type, - uid: result.createdEvent.id.toString(), - meetingId: result.videoCallData?.id.toString(), - meetingPassword: result.videoCallData?.password, - meetingUrl: result.videoCallData?.url, - }; + if (!isDailyResult && result.createdEvent) { + uid = result.createdEvent.id.toString(); } + return { + type: result.type, + uid, + meetingId: result.videoCallData?.id.toString(), + meetingPassword: result.videoCallData?.password, + meetingUrl: result.videoCallData?.url, + }; }); return { @@ -140,15 +131,18 @@ export default class EventManager { * given uid using the data delivered in the given CalendarEvent. * * @param event - * @param rescheduleUid */ - public async update(event: CalendarEvent, rescheduleUid: string): Promise { - event = EventManager.processLocation(event); + public async update(event: CalendarEvent): Promise { + let evt = EventManager.processLocation(event); + + if (!evt.uid) { + throw new Error("missing uid"); + } // Get details of existing booking. const booking = await prisma.booking.findFirst({ where: { - uid: rescheduleUid, + uid: evt.uid, }, select: { id: true, @@ -165,28 +159,30 @@ export default class EventManager { }, }); - const isDedicated = - EventManager.isDedicatedIntegration(event.location) || event.location === dailyLocation; + if (!booking) { + throw new Error("booking not found"); + } + + const isDedicated = evt.location + ? EventManager.isDedicatedIntegration(evt.location) || evt.location === dailyLocation + : null; let results: Array = []; - let optionalVideoCallData: VideoCallData | undefined = undefined; // If and only if event type is a dedicated meeting, update the dedicated video meeting. if (isDedicated) { - const result = await this.updateVideoEvent(event, booking); + const result = await this.updateVideoEvent(evt, booking); if (result.videoCallData) { - optionalVideoCallData = result.videoCallData; + evt = { ...evt, videoCallData: result.videoCallData }; } results.push(result); } else { - await EventManager.sendAttendeeMail("reschedule", results, event, rescheduleUid); + await EventManager.sendAttendeeMail("reschedule", results, evt); } // Now update all calendar events. If this is a dedicated integration event, // don't send a mail right here, because it has already been sent. - results = results.concat( - await this.updateAllCalendarEvents(event, booking, isDedicated, optionalVideoCallData) - ); + results = results.concat(await this.updateAllCalendarEvents(evt, booking, isDedicated)); // Now we can delete the old booking and its references. const bookingReferenceDeletes = prisma.bookingReference.deleteMany({ @@ -199,11 +195,16 @@ export default class EventManager { bookingId: booking.id, }, }); - const bookingDeletes = prisma.booking.delete({ - where: { - uid: rescheduleUid, - }, - }); + + let bookingDeletes = null; + + if (evt.uid) { + bookingDeletes = prisma.booking.delete({ + where: { + uid: evt.uid, + }, + }); + } // Wait for all deletions to be applied. await Promise.all([bookingReferenceDeletes, attendeeDeletes, bookingDeletes]); @@ -224,19 +225,12 @@ export default class EventManager { * * @param event * @param noMail - * @param maybeUid - * @param optionalVideoCallData * @private */ - private createAllCalendarEvents( - event: CalendarEvent, - noMail: boolean, - maybeUid?: string, - optionalVideoCallData?: VideoCallData - ): Promise> { + private createAllCalendarEvents(event: CalendarEvent, noMail: boolean | null): Promise> { return async.mapLimit(this.calendarCredentials, 5, async (credential: Credential) => { - return createEvent(credential, event, noMail, maybeUid, optionalVideoCallData); + return createEvent(credential, event, noMail); }); } @@ -248,6 +242,10 @@ export default class EventManager { */ private getVideoCredential(event: CalendarEvent): Credential | undefined { + if (!event.location) { + return undefined; + } + const integrationName = event.location.replace("integrations:", ""); return this.videoCredentials.find((credential: Credential) => credential.type.includes(integrationName)); @@ -259,18 +257,17 @@ export default class EventManager { * When optional uid is set, it will be used instead of the auto generated uid. * * @param event - * @param maybeUid * @private */ - private createVideoEvent(event: CalendarEvent, maybeUid?: string): Promise { + private createVideoEvent(event: CalendarEvent): Promise { const credential = this.getVideoCredential(event); const isDaily = event.location === dailyLocation; if (credential && !isDaily) { - return createMeeting(credential, event, maybeUid); - } else if (isDaily) { - return dailyCreateMeeting(credential, event, maybeUid); + return createMeeting(credential, event); + } else if (credential && isDaily) { + return dailyCreateMeeting(credential, event); } else { return Promise.reject("No suitable credentials given for the requested integration name."); } @@ -289,13 +286,15 @@ export default class EventManager { */ private updateAllCalendarEvents( event: CalendarEvent, - booking: PartialBooking, - noMail: boolean, - optionalVideoCallData?: VideoCallData + booking: PartialBooking | null, + noMail: boolean | null ): Promise> { return async.mapLimit(this.calendarCredentials, 5, async (credential) => { - const bookingRefUid = booking.references.filter((ref) => ref.type === credential.type)[0]?.uid; - return updateEvent(credential, bookingRefUid, event, noMail, optionalVideoCallData); + const bookingRefUid = booking + ? booking.references.filter((ref) => ref.type === credential.type)[0]?.uid + : null; + const evt = { ...event, uid: bookingRefUid }; + return updateEvent(credential, evt, noMail); }); } @@ -311,9 +310,9 @@ export default class EventManager { const isDaily = event.location === dailyLocation; if (credential && !isDaily) { - const bookingRef = booking.references.filter((ref) => ref.type === credential.type)[0]; - - return updateMeeting(credential, bookingRef.uid, event).then((returnVal: EventResult) => { + const bookingRef = booking ? booking.references.filter((ref) => ref.type === credential.type)[0] : null; + const evt = { ...event, uid: bookingRef?.uid }; + return updateMeeting(credential, evt).then((returnVal: EventResult) => { // Some video integrations, such as Zoom, don't return any data about the booking when updating it. if (returnVal.videoCallData == undefined) { returnVal.videoCallData = EventManager.bookingReferenceToVideoCallData(bookingRef); @@ -321,9 +320,12 @@ export default class EventManager { return returnVal; }); } else { - if (isDaily) { - const bookingRefUid = booking.references.filter((ref) => ref.type === "daily")[0].uid; - return dailyUpdateMeeting(credential, bookingRefUid, event); + if (credential && isDaily) { + const bookingRefUid = booking + ? booking.references.filter((ref) => ref.type === "daily")[0].uid + : null; + const evt = { ...event, uid: bookingRefUid }; + return dailyUpdateMeeting(credential, evt); } return Promise.reject("No suitable credentials given for the requested integration name."); } @@ -405,9 +407,15 @@ export default class EventManager { * @param reference * @private */ - private static bookingReferenceToVideoCallData(reference: PartialReference): VideoCallData | undefined { + private static bookingReferenceToVideoCallData( + reference: PartialReference | null + ): VideoCallData | undefined { let isComplete = true; + if (!reference) { + throw new Error("missing reference"); + } + switch (reference.type) { case "zoom_video": // Zoom meetings in our system should always have an ID, a password and a join URL. In the @@ -441,33 +449,33 @@ export default class EventManager { * @param type * @param results * @param event - * @param maybeUid * @private */ private static async sendAttendeeMail( type: "new" | "reschedule", results: Array, - event: CalendarEvent, - maybeUid?: string + event: CalendarEvent ) { if ( !results.length || - !results.some((eRes) => (eRes.createdEvent || eRes.updatedEvent).disableConfirmationEmail) + !results.some((eRes) => (eRes.createdEvent || eRes.updatedEvent)?.disableConfirmationEmail) ) { - const metadata: { hangoutLink?: string; conferenceData?: unknown; entryPoints?: unknown } = {}; + const metadata: AdditionInformation = {}; if (results.length) { // TODO: Handle created event metadata more elegantly metadata.hangoutLink = results[0].createdEvent?.hangoutLink; metadata.conferenceData = results[0].createdEvent?.conferenceData; metadata.entryPoints = results[0].createdEvent?.entryPoints; } + const emailEvent = { ...event, additionInformation: metadata }; + let attendeeMail; switch (type) { case "reschedule": - attendeeMail = new EventAttendeeRescheduledMail(event, maybeUid, metadata); + attendeeMail = new EventAttendeeRescheduledMail(emailEvent); break; case "new": - attendeeMail = new EventAttendeeMail(event, maybeUid, metadata); + attendeeMail = new EventAttendeeMail(emailEvent); break; } try { diff --git a/lib/forgot-password/messaging/forgot-password.ts b/lib/forgot-password/messaging/forgot-password.ts index 8bbf5157..46348096 100644 --- a/lib/forgot-password/messaging/forgot-password.ts +++ b/lib/forgot-password/messaging/forgot-password.ts @@ -1,20 +1,28 @@ -import buildMessageTemplate from "../../emails/buildMessageTemplate"; +import { TFunction } from "next-i18next"; -export const forgotPasswordSubjectTemplate = "Forgot your password? - Cal.com"; +import { buildMessageTemplate, VarType } from "../../emails/buildMessageTemplate"; -export const forgotPasswordMessageTemplate = `Hey there, +export const forgotPasswordSubjectTemplate = (t: TFunction): string => { + const text = t("forgot_your_password_calcom"); + return text; +}; -Use the link below to reset your password. -{{link}} +export const forgotPasswordMessageTemplate = (t: TFunction): string => { + const text = `${t("hey_there")} -p.s. It expires in 6 hours. + ${t("use_link_to_reset_password")} + {{link}} -- Cal.com`; + ${t("link_expires", { expiresIn: 6 })} -export const buildForgotPasswordMessage = (vars) => { + - Cal.com`; + return text; +}; + +export const buildForgotPasswordMessage = (vars: VarType) => { return buildMessageTemplate({ - subjectTemplate: forgotPasswordSubjectTemplate, - messageTemplate: forgotPasswordMessageTemplate, + subjectTemplate: forgotPasswordSubjectTemplate(vars.language), + messageTemplate: forgotPasswordMessageTemplate(vars.language), vars, }); }; diff --git a/lib/types/booking.ts b/lib/types/booking.ts index a22b20f9..1b0b8d81 100644 --- a/lib/types/booking.ts +++ b/lib/types/booking.ts @@ -15,6 +15,7 @@ export type BookingCreateBody = { timeZone: string; users?: string[]; user?: string; + language: string; }; export type BookingResponse = Booking & { diff --git a/lib/videoClient.ts b/lib/videoClient.ts index 1e9f2cf5..15719fa7 100644 --- a/lib/videoClient.ts +++ b/lib/videoClient.ts @@ -3,12 +3,11 @@ import short from "short-uuid"; import { v5 as uuidv5 } from "uuid"; import CalEventParser from "@lib/CalEventParser"; -import { AdditionInformation, EntryPoint } from "@lib/emails/EventMail"; import { getIntegrationName } from "@lib/emails/helpers"; import { EventResult } from "@lib/events/EventManager"; import logger from "@lib/logger"; -import { CalendarEvent } from "./calendarClient"; +import { CalendarEvent, AdditionInformation, EntryPoint } from "./calendarClient"; import EventAttendeeRescheduledMail from "./emails/EventAttendeeRescheduledMail"; import EventOrganizerRescheduledMail from "./emails/EventOrganizerRescheduledMail"; import VideoEventAttendeeMail from "./emails/VideoEventAttendeeMail"; @@ -34,7 +33,7 @@ export interface VideoCallData { url: string; } -function handleErrorsJson(response) { +function handleErrorsJson(response: Response) { if (!response.ok) { response.json().then(console.log); throw Error(response.statusText); @@ -42,7 +41,7 @@ function handleErrorsJson(response) { return response.json(); } -function handleErrorsRaw(response) { +function handleErrorsRaw(response: Response) { if (!response.ok) { response.text().then(console.log); throw Error(response.statusText); @@ -216,12 +215,8 @@ const getBusyVideoTimes: (withCredentials: Credential[]) => Promise = results.reduce((acc, availability) => acc.concat(availability), []) ); -const createMeeting = async ( - credential: Credential, - calEvent: CalendarEvent, - maybeUid?: string -): Promise => { - const parser: CalEventParser = new CalEventParser(calEvent, maybeUid); +const createMeeting = async (credential: Credential, calEvent: CalendarEvent): Promise => { + const parser: CalEventParser = new CalEventParser(calEvent); const uid: string = parser.getUid(); if (!credential) { @@ -249,7 +244,7 @@ const createMeeting = async ( const entryPoint: EntryPoint = { entryPointType: getIntegrationName(videoCallData), uri: videoCallData.url, - label: "Enter Meeting", + label: calEvent.language("enter_meeting"), pin: videoCallData.password, }; @@ -257,9 +252,10 @@ const createMeeting = async ( entryPoints: [entryPoint], }; - const organizerMail = new VideoEventOrganizerMail(calEvent, uid, videoCallData, additionInformation); - const attendeeMail = new VideoEventAttendeeMail(calEvent, uid, videoCallData, additionInformation); + const emailEvent = { ...calEvent, uid, additionInformation, videoCallData }; + try { + const organizerMail = new VideoEventOrganizerMail(emailEvent); await organizerMail.sendEmail(); } catch (e) { console.error("organizerMail.sendEmail failed", e); @@ -267,6 +263,7 @@ const createMeeting = async ( if (!creationResult || !creationResult.disableConfirmationEmail) { try { + const attendeeMail = new VideoEventAttendeeMail(emailEvent); await attendeeMail.sendEmail(); } catch (e) { console.error("attendeeMail.sendEmail failed", e); @@ -283,11 +280,7 @@ const createMeeting = async ( }; }; -const updateMeeting = async ( - credential: Credential, - uidToUpdate: string, - calEvent: CalendarEvent -): Promise => { +const updateMeeting = async (credential: Credential, calEvent: CalendarEvent): Promise => { const newUid: string = translator.fromUUID(uuidv5(JSON.stringify(calEvent), uuidv5.URL)); if (!credential) { @@ -298,18 +291,20 @@ const updateMeeting = async ( let success = true; - const updateResult = credential - ? await videoIntegrations([credential])[0] - .updateMeeting(uidToUpdate, calEvent) - .catch((e) => { - log.error("updateMeeting failed", e, calEvent); - success = false; - }) - : null; + const updateResult = + credential && calEvent.uid + ? await videoIntegrations([credential])[0] + .updateMeeting(calEvent.uid, calEvent) + .catch((e) => { + log.error("updateMeeting failed", e, calEvent); + success = false; + }) + : null; + + const emailEvent = { ...calEvent, uid: newUid }; - const organizerMail = new EventOrganizerRescheduledMail(calEvent, newUid); - const attendeeMail = new EventAttendeeRescheduledMail(calEvent, newUid); try { + const organizerMail = new EventOrganizerRescheduledMail(emailEvent); await organizerMail.sendEmail(); } catch (e) { console.error("organizerMail.sendEmail failed", e); @@ -317,6 +312,7 @@ const updateMeeting = async ( if (!updateResult || !updateResult.disableConfirmationEmail) { try { + const attendeeMail = new EventAttendeeRescheduledMail(emailEvent); await attendeeMail.sendEmail(); } catch (e) { console.error("attendeeMail.sendEmail failed", e); diff --git a/pages/api/auth/forgot-password.ts b/pages/api/auth/forgot-password.ts index f2fbbfb0..d8787749 100644 --- a/pages/api/auth/forgot-password.ts +++ b/pages/api/auth/forgot-password.ts @@ -1,17 +1,21 @@ -import { User, ResetPasswordRequest } from "@prisma/client"; +import { ResetPasswordRequest } from "@prisma/client"; import dayjs from "dayjs"; import timezone from "dayjs/plugin/timezone"; import utc from "dayjs/plugin/utc"; import { NextApiRequest, NextApiResponse } from "next"; -import sendEmail from "../../../lib/emails/sendMail"; -import { buildForgotPasswordMessage } from "../../../lib/forgot-password/messaging/forgot-password"; -import prisma from "../../../lib/prisma"; +import sendEmail from "@lib/emails/sendMail"; +import { buildForgotPasswordMessage } from "@lib/forgot-password/messaging/forgot-password"; +import prisma from "@lib/prisma"; + +import { getTranslation } from "@server/lib/i18n"; dayjs.extend(utc); dayjs.extend(timezone); export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const t = await getTranslation(req.body.language ?? "en", "common"); + if (req.method !== "POST") { return res.status(405).json({ message: "" }); } @@ -19,7 +23,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) try { const rawEmail = req.body?.email; - const maybeUser: User = await prisma.user.findUnique({ + const maybeUser = await prisma.user.findUnique({ where: { email: rawEmail, }, @@ -59,6 +63,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) const passwordResetLink = `${process.env.BASE_URL}/auth/forgot-password/${passwordRequest.id}`; const { subject, message } = buildForgotPasswordMessage({ + language: t, user: { name: maybeUser.name, }, diff --git a/pages/api/book/confirm.ts b/pages/api/book/confirm.ts index 3a6f63e3..0eda3b24 100644 --- a/pages/api/book/confirm.ts +++ b/pages/api/book/confirm.ts @@ -6,12 +6,15 @@ import { getSession } from "@lib/auth"; import { CalendarEvent } from "@lib/calendarClient"; import EventRejectionMail from "@lib/emails/EventRejectionMail"; import EventManager from "@lib/events/EventManager"; +import prisma from "@lib/prisma"; -import prisma from "../../../lib/prisma"; +import { getTranslation } from "@server/lib/i18n"; export default async function handler(req: NextApiRequest, res: NextApiResponse): Promise { + const t = await getTranslation(req.body.language ?? "en", "common"); + const session = await getSession({ req: req }); - if (!session) { + if (!session?.user?.id) { return res.status(401).json({ message: "Not authenticated" }); } @@ -33,6 +36,10 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) }, }); + if (!currentUser) { + return res.status(404).json({ message: "User not found" }); + } + if (req.method == "PATCH") { const booking = await prisma.booking.findFirst({ where: { @@ -66,14 +73,16 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) description: booking.description, startTime: booking.startTime.toISOString(), endTime: booking.endTime.toISOString(), - organizer: { email: currentUser.email, name: currentUser.name, timeZone: currentUser.timeZone }, + organizer: { email: currentUser.email, name: currentUser.name!, timeZone: currentUser.timeZone }, attendees: booking.attendees, location: booking.location, + uid: booking.uid, + language: t, }; if (req.body.confirmed) { const eventManager = new EventManager(currentUser.credentials); - const scheduleResult = await eventManager.create(evt, booking.uid); + const scheduleResult = await eventManager.create(evt); await prisma.booking.update({ where: { @@ -99,8 +108,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) rejected: true, }, }); - const attendeeMail = new EventRejectionMail(evt, booking.uid); + const attendeeMail = new EventRejectionMail(evt); await attendeeMail.sendEmail(); + res.status(204).json({ message: "ok" }); } } diff --git a/pages/api/book/event.ts b/pages/api/book/event.ts index 58d9f8fe..2e8a9a53 100644 --- a/pages/api/book/event.ts +++ b/pages/api/book/event.ts @@ -6,7 +6,6 @@ import isBetween from "dayjs/plugin/isBetween"; import timezone from "dayjs/plugin/timezone"; import utc from "dayjs/plugin/utc"; import type { NextApiRequest, NextApiResponse } from "next"; -import { getErrorFromUnknown } from "pages/_error"; import short from "short-uuid"; import { v5 as uuidv5 } from "uuid"; @@ -14,6 +13,7 @@ import { handlePayment } from "@ee/lib/stripe/server"; import { CalendarEvent, getBusyCalendarTimes } from "@lib/calendarClient"; import EventOrganizerRequestMail from "@lib/emails/EventOrganizerRequestMail"; +import { getErrorFromUnknown } from "@lib/errors"; import { getEventName } from "@lib/event"; import EventManager, { CreateUpdateResult, EventResult, PartialReference } from "@lib/events/EventManager"; import logger from "@lib/logger"; @@ -23,6 +23,8 @@ import { getBusyVideoTimes } from "@lib/videoClient"; import sendPayload from "@lib/webhooks/sendPayload"; import getSubscriberUrls from "@lib/webhooks/subscriberUrls"; +import { getTranslation } from "@server/lib/i18n"; + export interface DailyReturnType { name: string; url: string; @@ -126,6 +128,7 @@ function isOutOfBounds( export default async function handler(req: NextApiRequest, res: NextApiResponse) { const reqBody = req.body as BookingCreateBody; const eventTypeId = reqBody.eventTypeId; + const t = await getTranslation(reqBody.language ?? "en", "common"); log.debug(`Booking eventType ${eventTypeId} started`); @@ -273,6 +276,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) }, attendees: attendeesList, location: reqBody.location, // Will be processed by the EventManager later. + language: t, + uid, }; if (eventType.schedulingType === SchedulingType.COLLECTIVE) { @@ -425,7 +430,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) if (rescheduleUid) { // Use EventManager to conditionally use all needed integrations. - const updateResults: CreateUpdateResult = await eventManager.update(evt, rescheduleUid); + const eventManagerCalendarEvent = { ...evt, uid: rescheduleUid }; + const updateResults: CreateUpdateResult = await eventManager.update(eventManagerCalendarEvent); results = updateResults.results; referencesToCreate = updateResults.referencesToCreate; @@ -440,7 +446,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) } } else if (!eventType.requiresConfirmation && !eventType.price) { // Use EventManager to conditionally use all needed integrations. - const createResults: CreateUpdateResult = await eventManager.create(evt, uid); + const createResults: CreateUpdateResult = await eventManager.create(evt); results = createResults.results; referencesToCreate = createResults.referencesToCreate; @@ -496,7 +502,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) } if (eventType.requiresConfirmation && !rescheduleUid) { - await new EventOrganizerRequestMail(evt, uid).sendEmail(); + await new EventOrganizerRequestMail({ ...evt, uid }).sendEmail(); } if (typeof eventType.price === "number" && eventType.price > 0) { diff --git a/pages/api/teams/[team]/invite.ts b/pages/api/teams/[team]/invite.ts index fa18b9da..f1f529de 100644 --- a/pages/api/teams/[team]/invite.ts +++ b/pages/api/teams/[team]/invite.ts @@ -2,11 +2,14 @@ import { randomBytes } from "crypto"; import type { NextApiRequest, NextApiResponse } from "next"; import { getSession } from "@lib/auth"; +import { createInvitationEmail } from "@lib/emails/invitation"; +import prisma from "@lib/prisma"; -import { createInvitationEmail } from "../../../../lib/emails/invitation"; -import prisma from "../../../../lib/prisma"; +import { getTranslation } from "@server/lib/i18n"; export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const t = await getTranslation(req.body.language ?? "en", "common"); + if (req.method !== "POST") { return res.status(400).json({ message: "Bad request" }); } @@ -18,7 +21,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) const team = await prisma.team.findFirst({ where: { - id: parseInt(req.query.team), + id: parseInt(req.query.team as string), }, }); @@ -68,12 +71,15 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) }, }); - createInvitationEmail({ - toEmail: req.body.usernameOrEmail, - from: session.user.name, - teamName: team.name, - token, - }); + if (session?.user?.name && team?.name) { + createInvitationEmail({ + language: t, + toEmail: req.body.usernameOrEmail, + from: session.user.name, + teamName: team.name, + token, + }); + } return res.status(201).json({}); } @@ -87,7 +93,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) role: req.body.role, }, }); - } catch (err) { + } catch (err: any) { if (err.code === "P2002") { // unique constraint violation return res.status(409).json({ @@ -99,8 +105,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) } // inform user of membership by email - if (req.body.sendEmailInvitation) { + if (req.body.sendEmailInvitation && session?.user?.name && team?.name) { createInvitationEmail({ + language: t, toEmail: invitee.email, from: session.user.name, teamName: team.name, diff --git a/pages/auth/forgot-password/index.tsx b/pages/auth/forgot-password/index.tsx index be94247b..a17d7bc8 100644 --- a/pages/auth/forgot-password/index.tsx +++ b/pages/auth/forgot-password/index.tsx @@ -1,29 +1,31 @@ import debounce from "lodash/debounce"; +import { GetServerSidePropsContext } from "next"; import { getCsrfToken } from "next-auth/client"; import Link from "next/link"; -import React from "react"; +import React, { SyntheticEvent } from "react"; import { getSession } from "@lib/auth"; import { useLocale } from "@lib/hooks/useLocale"; import { HeadSeo } from "@components/seo/head-seo"; -export default function ForgotPassword({ csrfToken }) { - const { t } = useLocale(); +export default function ForgotPassword({ csrfToken }: { csrfToken: string }) { + const { t, i18n } = useLocale(); const [loading, setLoading] = React.useState(false); - const [error, setError] = React.useState(null); + const [error, setError] = React.useState<{ message: string } | null>(null); const [success, setSuccess] = React.useState(false); const [email, setEmail] = React.useState(""); - const handleChange = (e) => { - setEmail(e.target.value); + const handleChange = (e: SyntheticEvent) => { + const target = e.target as typeof e.target & { value: string }; + setEmail(target.value); }; - const submitForgotPasswordRequest = async ({ email }) => { + const submitForgotPasswordRequest = async ({ email }: { email: string }) => { try { const res = await fetch("/api/auth/forgot-password", { method: "POST", - body: JSON.stringify({ email: email }), + body: JSON.stringify({ email: email, language: i18n.language }), headers: { "Content-Type": "application/json", }, @@ -46,7 +48,7 @@ export default function ForgotPassword({ csrfToken }) { const debouncedHandleSubmitPasswordRequest = debounce(submitForgotPasswordRequest, 250); - const handleSubmit = async (e) => { + const handleSubmit = async (e: SyntheticEvent) => { e.preventDefault(); if (!email) { @@ -157,7 +159,7 @@ export default function ForgotPassword({ csrfToken }) { ); } -ForgotPassword.getInitialProps = async (context) => { +ForgotPassword.getInitialProps = async (context: GetServerSidePropsContext) => { const { req, res } = context; const session = await getSession({ req }); diff --git a/pages/event-types/[type].tsx b/pages/event-types/[type].tsx index ae890c86..22cd7e93 100644 --- a/pages/event-types/[type].tsx +++ b/pages/event-types/[type].tsx @@ -63,32 +63,35 @@ import * as RadioArea from "@components/ui/form/radio-area"; dayjs.extend(utc); dayjs.extend(timezone); -const PERIOD_TYPES = [ - { - type: "rolling", - suffix: "into the future", - }, - { - type: "range", - prefix: "Within a date range", - }, - { - type: "unlimited", - prefix: "Indefinitely into the future", - }, -]; - const EventTypePage = (props: inferSSRProps) => { + const { t } = useLocale(); + const PERIOD_TYPES = [ + { + type: "rolling", + suffix: t("into_the_future"), + }, + { + type: "range", + prefix: t("within_date_range"), + }, + { + type: "unlimited", + prefix: t("indefinitely_into_future"), + }, + ]; const { eventType, locationOptions, availability, team, teamMembers, hasPaymentIntegration, currency } = props; + locationOptions.push( + { value: LocationType.InPerson, label: t("in_person_meeting") }, + { value: LocationType.Phone, label: t("phone_call") } + ); - const { t } = useLocale(); const router = useRouter(); const updateMutation = useMutation(updateEventType, { onSuccess: async ({ eventType }) => { await router.push("/event-types"); - showToast(`${eventType.title} event type updated successfully`, "success"); + showToast(t("event_type_updated_successfully", { eventTypeTitle: eventType.title }), "success"); }, onError: (err: HttpError) => { const message = `${err.statusCode}: ${err.message}`; @@ -99,7 +102,7 @@ const EventTypePage = (props: inferSSRProps) => { const deleteMutation = useMutation(deleteEventType, { onSuccess: async () => { await router.push("/event-types"); - showToast("Event type deleted successfully", "success"); + showToast(t("event_type_deleted_successfully"), "success"); }, onError: (err: HttpError) => { const message = `${err.statusCode}: ${err.message}`; @@ -274,13 +277,13 @@ const EventTypePage = (props: inferSSRProps) => { const schedulingTypeOptions: { value: SchedulingType; label: string; description: string }[] = [ { value: SchedulingType.COLLECTIVE, - label: "Collective", - description: "Schedule meetings when all selected team members are available.", + label: t("collective"), + description: t("collective_description"), }, { value: SchedulingType.ROUND_ROBIN, - label: "Round Robin", - description: "Cycle meetings between multiple team members.", + label: t("round_robin"), + description: t("round_robin_description"), }, ]; @@ -311,7 +314,7 @@ const EventTypePage = (props: inferSSRProps) => {
setEditIcon(false)}> ) => { id="title" required className="w-full pl-0 text-xl font-bold text-gray-900 bg-transparent border-none cursor-pointer focus:text-black hover:text-gray-700 focus:ring-0 focus:outline-none" - placeholder="Quick Chat" + placeholder={t("quick_chat")} defaultValue={eventType.title} /> {editIcon && ( @@ -491,7 +494,7 @@ const EventTypePage = (props: inferSSRProps) => { fillRule="evenodd"> - Daily.co Video + Daily.co Video
)} {location.type === LocationType.Zoom && ( @@ -682,7 +685,7 @@ const EventTypePage = (props: inferSSRProps) => {
- {customInput.required ? "Required" : "Optional"} + {customInput.required ? t("required") : t("optional")}
@@ -1228,10 +1231,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => const integrations = getIntegrations(credentials); - const locationOptions: OptionTypeBase[] = [ - { value: LocationType.InPerson, label: "Link or In-person meeting" }, - { value: LocationType.Phone, label: "Phone call" }, - ]; + const locationOptions: OptionTypeBase[] = []; if (hasIntegration(integrations, "zoom_video")) { locationOptions.push({ value: LocationType.Zoom, label: "Zoom Video", disabled: true }); diff --git a/pages/event-types/index.tsx b/pages/event-types/index.tsx index 653270c7..6026ad71 100644 --- a/pages/event-types/index.tsx +++ b/pages/event-types/index.tsx @@ -344,7 +344,7 @@ const CreateNewEventButton = ({ profiles, canAddEvents }: CreateEventTypeProps) const createMutation = useMutation(createEventType, { onSuccess: async ({ eventType }) => { await router.push("/event-types/" + eventType.id); - showToast(`${eventType.title} event type created successfully`, "success"); + showToast(t("event_type_created_successfully", { eventTypeTitle: eventType.title }), "success"); }, onError: (err: HttpError) => { const message = `${err.statusCode}: ${err.message}`; diff --git a/playwright/integrations.test.ts b/playwright/integrations.test.ts index 78fd7682..dede3ffc 100644 --- a/playwright/integrations.test.ts +++ b/playwright/integrations.test.ts @@ -66,8 +66,10 @@ describe("webhooks", () => { attendee.timeZone = dynamic; } body.payload.organizer.timeZone = dynamic; + body.payload.uid = dynamic; // if we change the shape of our webhooks, we can simply update this by clicking `u` + console.log("BODY", body); expect(body).toMatchInlineSnapshot(` Object { "createdAt": "[redacted/dynamic]", @@ -89,6 +91,7 @@ describe("webhooks", () => { "startTime": "[redacted/dynamic]", "title": "30min with Test Testson", "type": "30min", + "uid": "[redacted/dynamic]", }, "triggerEvent": "BOOKING_CREATED", } diff --git a/public/static/locales/en/common.json b/public/static/locales/en/common.json index ff2de8ab..11e854a7 100644 --- a/public/static/locales/en/common.json +++ b/public/static/locales/en/common.json @@ -1,4 +1,43 @@ { + "integration_meeting_id": "{{integrationName}} meeting ID: {{meetingId}}", + "confirmed_event_type_subject": "Confirmed: {{eventType}} with {{name}} on {{date}}", + "new_event_request": "New event request: {{attendeeName}} - {{date}} - {{eventType}}", + "confirm_or_reject_booking": "Confirm or reject the booking", + "check_bookings_page_to_confirm_or_reject": "Check your bookings page to confirm or reject the booking.", + "event_awaiting_approval": "A new event is waiting for your approval", + "your_meeting_has_been_booked": "Your meeting has been booked", + "event_type_has_been_rescheduled_on_time_date": "Your {{eventType}} with {{name}} has been rescheduled to {{time}} ({{timeZone}}) on {{date}}.", + "event_has_been_rescheduled": "Your event has been rescheduled.", + "hi_user_name": "Hi {{userName}}", + "organizer_ics_event_title": "{{eventType}} with {{attendeeName}}", + "new_event_subject": "New event: {{attendeeName}} - {{date}} - {{eventType}}", + "join_by_entrypoint": "Join by {{entryPoint}}", + "notes": "Notes", + "manage_my_bookings": "Manage my bookings", + "need_to_make_a_change": "Need to make a change?", + "new_event_scheduled": "A new event has been scheduled.", + "invitee_email": "Invitee Email", + "invitee_timezone": "Invitee Time Zone", + "event_type": "Event Type", + "enter_meeting": "Enter Meeting", + "video_call_provider": "Video call provider", + "meeting_id": "Meeting ID", + "meeting_password": "Meeting Password", + "meeting_url": "Meeting URL", + "meeting_request_rejected": "Your meeting request has been rejected", + "rescheduled_event_type_with_organizer": "Rescheduled: {{eventType}} with {{organizerName}} on {{date}}", + "rescheduled_event_type_with_attendee": "Rescheduled event: {{attendeeName}} - {{date}} - {{eventType}}", + "rejected_event_type_with_organizer": "Rejected: {{eventType}} with {{organizer}} on {{date}}", + "hi": "Hi", + "join_team": "Join team", + "request_another_invitation_email": "If you prefer not to use {{toEmail}} as your Cal.com email or already have a Cal.com account, please request another invitation to that email.", + "you_have_been_invited": "You have been invited to join the team {{teamName}}", + "user_invited_you": "{{user}} invited you to join the team {{teamName}}", + "link_expires": "p.s. It expires in {{expiresIn}} hours.", + "use_link_to_reset_password": "Use the link below to reset your password", + "hey_there": "Hey there,", + "forgot_your_password_calcom": "Forgot your password? - Cal.com", + "event_type_title": "{{eventTypeTitle}} | Event Type", "delete_webhook_confirmation_message": "Are you sure you want to delete this webhook? You will no longer receive Cal.com meeting data at a specified URL, in real-time, when an event is scheduled or canceled.", "confirm_delete_webhook": "Yes, delete webhook", "edit_webhook": "Edit Webhook", @@ -36,6 +75,7 @@ "number": "Number", "checkbox": "Checkbox", "is_required": "Is required", + "required": "Required", "input_type": "Input type", "rejected": "Rejected", "unconfirmed": "Unconfirmed", @@ -351,6 +391,8 @@ "new_team_event": "Add a new team event type", "new_event_description": "Create a new event type for people to book times with.", "event_type_created_successfully": "{{eventTypeTitle}} event type created successfully", + "event_type_updated_successfully": "{{eventTypeTitle}} event type updated successfully", + "event_type_deleted_successfully": "Event type deleted successfully", "hours": "Hours", "your_email": "Your Email", "change_avatar": "Change Avatar", @@ -418,7 +460,6 @@ "cal_provide_video_meeting_url": "Cal will provide a Daily video meeting URL.", "require_payment": "Require Payment", "commission_per_transaction": "commission per transaction", - "event_type_updated_successfully": "Event Type updated successfully", "event_type_updated_successfully_description": "Your event type has been updated successfully.", "hide_event_type": "Hide event type", "edit_location": "Edit location", diff --git a/server/lib/i18n.ts b/server/lib/i18n.ts new file mode 100644 index 00000000..19528ab7 --- /dev/null +++ b/server/lib/i18n.ts @@ -0,0 +1,18 @@ +import i18next from "i18next"; +import { i18n as nexti18next } from "next-i18next"; +import { serverSideTranslations } from "next-i18next/serverSideTranslations"; + +export const getTranslation = async (locale: string, ns: string) => { + const create = async () => { + const { _nextI18Next } = await serverSideTranslations(locale, [ns]); + const _i18n = i18next.createInstance(); + _i18n.init({ + lng: locale, + resources: _nextI18Next.initialI18nStore, + fallbackLng: _nextI18Next.userConfig?.i18n.defaultLocale, + }); + return _i18n; + }; + const _i18n = nexti18next != null ? nexti18next : await create(); + return _i18n.getFixedT(locale, ns); +}; diff --git a/test/lib/emails/invitation.test.ts b/test/lib/emails/invitation.test.ts index e291e850..3ba0fea7 100644 --- a/test/lib/emails/invitation.test.ts +++ b/test/lib/emails/invitation.test.ts @@ -2,22 +2,26 @@ import { expect, it } from "@jest/globals"; import { html, text, Invitation } from "@lib/emails/invitation"; +import { getTranslation } from "@server/lib/i18n"; + it("email text rendering should strip tags and add new lines", () => { const result = text("

hello world


welcome to the brave new world"); expect(result).toEqual("hello world\nwelcome to the brave new world"); }); -it("email html should render invite email", () => { +it("email html should render invite email", async () => { + const t = await getTranslation("en", "common"); const invitation = { + language: t, from: "Huxley", toEmail: "hello@example.com", teamName: "Calendar Lovers", token: "invite-token", } as Invitation; const result = html(invitation); - expect(result).toContain('
Huxley invited you to join the team "Calendar Lovers" in Cal.com.
'); - expect(result).toContain("/auth/signup?token=invite-token&"); expect(result).toContain( - 'If you prefer not to use "hello@example.com" as your Cal.com email or already have a Cal.com account, please request another invitation to that email.' + `
${t("user_invited_you", { user: invitation.from, teamName: invitation.teamName })}
` ); + expect(result).toContain("/auth/signup?token=invite-token&"); + expect(result).toContain(`${t("request_another_invitation_email", { toEmail: invitation.toEmail })}`); }); diff --git a/tsconfig.json b/tsconfig.json index 4c11ea54..1570ab69 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,13 +1,25 @@ { "compilerOptions": { "target": "es5", - "lib": ["dom", "dom.iterable", "esnext"], + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], "baseUrl": ".", "paths": { - "@components/*": ["components/*"], - "@lib/*": ["lib/*"], - "@server/*": ["server/*"], - "@ee/*": ["ee/*"] + "@components/*": [ + "components/*" + ], + "@lib/*": [ + "lib/*" + ], + "@server/*": [ + "server/*" + ], + "@ee/*": [ + "ee/*" + ] }, "skipLibCheck": true, "strict": true, @@ -21,8 +33,19 @@ "isolatedModules": true, "useUnknownInCatchVariables": true, "jsx": "preserve", - "types": ["@types/jest", "jest-playwright-preset", "expect-playwright"] + "types": [ + "@types/jest", + "jest-playwright-preset", + "expect-playwright" + ], + "allowJs": true }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], - "exclude": ["node_modules"] + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx" + ], + "exclude": [ + "node_modules" + ] }