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 <pumfleet@hey.com>
This commit is contained in:
Mihai C 2021-10-25 16:05:21 +03:00 committed by GitHub
parent 356d470e16
commit 8d6fec79d3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
35 changed files with 680 additions and 461 deletions

View file

@ -12,13 +12,14 @@ import TableActions, { ActionType } from "@components/ui/TableActions";
type BookingItem = inferQueryOutput<"viewer.bookings">[number]; type BookingItem = inferQueryOutput<"viewer.bookings">[number];
function BookingListItem(booking: BookingItem) { function BookingListItem(booking: BookingItem) {
const { t } = useLocale(); const { t, i18n } = useLocale();
const utils = trpc.useContext(); const utils = trpc.useContext();
const mutation = useMutation( const mutation = useMutation(
async (confirm: boolean) => { async (confirm: boolean) => {
const res = await fetch("/api/book/confirm", { const res = await fetch("/api/book/confirm", {
method: "PATCH", method: "PATCH",
body: JSON.stringify({ id: booking.id, confirmed: confirm }), body: JSON.stringify({ id: booking.id, confirmed: confirm, language: i18n.language }),
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },

View file

@ -36,7 +36,7 @@ import { TeamBookingPageProps } from "../../../pages/team/[slug]/book";
type BookingPageProps = BookPageProps | TeamBookingPageProps; type BookingPageProps = BookPageProps | TeamBookingPageProps;
const BookingPage = (props: BookingPageProps) => { const BookingPage = (props: BookingPageProps) => {
const { t } = useLocale(); const { t, i18n } = useLocale();
const router = useRouter(); const router = useRouter();
const { rescheduleUid } = router.query; const { rescheduleUid } = router.query;
const { isReady } = useTheme(props.profile.theme); const { isReady } = useTheme(props.profile.theme);
@ -109,6 +109,7 @@ const BookingPage = (props: BookingPageProps) => {
guests: guestEmails, guests: guestEmails,
eventTypeId: props.eventType.id, eventTypeId: props.eventType.id,
timeZone: timeZone(), timeZone: timeZone(),
language: i18n.language,
}; };
if (typeof rescheduleUid === "string") payload.rescheduleUid = rescheduleUid; if (typeof rescheduleUid === "string") payload.rescheduleUid = rescheduleUid;
if (typeof router.query.user === "string") payload.user = router.query.user; if (typeof router.query.user === "string") payload.user = router.query.user;

View file

@ -1,5 +1,6 @@
import { UsersIcon } from "@heroicons/react/outline"; import { UsersIcon } from "@heroicons/react/outline";
import { useState } from "react"; import { useState } from "react";
import React, { SyntheticEvent } from "react";
import { useLocale } from "@lib/hooks/useLocale"; import { useLocale } from "@lib/hooks/useLocale";
import { Team } from "@lib/team"; 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 }) { export default function MemberInvitationModal(props: { team: Team | undefined | null; onExit: () => void }) {
const [errorMessage, setErrorMessage] = useState(""); const [errorMessage, setErrorMessage] = useState("");
const { t } = useLocale(); const { t, i18n } = useLocale();
const handleError = async (res: Response) => { const handleError = async (res: Response) => {
const responseData = await res.json(); const responseData = await res.json();
@ -21,13 +22,22 @@ export default function MemberInvitationModal(props: { team: Team | undefined |
return responseData; return responseData;
}; };
const inviteMember = (e) => { const inviteMember = (e: SyntheticEvent) => {
e.preventDefault(); e.preventDefault();
const target = e.target as typeof e.target & {
elements: {
role: { value: string };
inviteUser: { value: string };
sendInviteEmail: { checked: boolean };
};
};
const payload = { const payload = {
role: e.target.elements["role"].value, language: i18n.language,
usernameOrEmail: e.target.elements["inviteUser"].value, role: target.elements["role"].value,
sendEmailInvitation: e.target.elements["sendInviteEmail"].checked, usernameOrEmail: target.elements["inviteUser"].value,
sendEmailInvitation: target.elements["sendInviteEmail"].checked,
}; };
return fetch("/api/teams/" + props?.team?.id + "/invite", { return fetch("/api/teams/" + props?.team?.id + "/invite", {

View file

@ -13,6 +13,7 @@ const config: Config.InitialOptions = {
moduleNameMapper: { moduleNameMapper: {
"^@components(.*)$": "<rootDir>/components$1", "^@components(.*)$": "<rootDir>/components$1",
"^@lib(.*)$": "<rootDir>/lib$1", "^@lib(.*)$": "<rootDir>/lib$1",
"^@server(.*)$": "<rootDir>/server$1",
}, },
}; };

View file

@ -2,7 +2,6 @@ import short from "short-uuid";
import { v5 as uuidv5 } from "uuid"; import { v5 as uuidv5 } from "uuid";
import { getIntegrationName } from "@lib/integrations"; import { getIntegrationName } from "@lib/integrations";
import { VideoCallData } from "@lib/videoClient";
import { CalendarEvent } from "./calendarClient"; import { CalendarEvent } from "./calendarClient";
import { stripHtml } from "./emails/helpers"; import { stripHtml } from "./emails/helpers";
@ -11,13 +10,9 @@ const translator = short();
export default class CalEventParser { export default class CalEventParser {
protected calEvent: CalendarEvent; protected calEvent: CalendarEvent;
protected maybeUid?: string;
protected optionalVideoCallData?: VideoCallData;
constructor(calEvent: CalendarEvent, maybeUid?: string, optionalVideoCallData?: VideoCallData) { constructor(calEvent: CalendarEvent) {
this.calEvent = calEvent; 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. * Returns a unique identifier for the given calendar event.
*/ */
public getUid(): string { 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). * Returns a footer section with links to change the event (as HTML).
*/ */
public getChangeEventFooterHtml(): string { public getChangeEventFooterHtml(): string {
return `<p style="color: #4b5563; margin-top: 20px;">Need to make a change? <a href="${this.getCancelLink()}" style="color: #161e2e;">Cancel</a> or <a href="${this.getRescheduleLink()}" style="color: #161e2e;">reschedule</a></p>`; return `<p style="color: #4b5563; margin-top: 20px;">${this.calEvent.language(
"need_to_make_a_change"
)} <a href="${this.getCancelLink()}" style="color: #161e2e;">${this.calEvent.language(
"cancel"
)}</a> ${this.calEvent
.language("or")
.toLowerCase()} <a href="${this.getRescheduleLink()}" style="color: #161e2e;">${this.calEvent.language(
"reschedule"
)}</a></p>`;
} }
/** /**
@ -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. // This odd indentation is necessary because otherwise the leading tabs will be applied into the event description.
return ( return (
` `
<strong>Event Type:</strong><br />${this.calEvent.type}<br /> <strong>${this.calEvent.language("event_type")}:</strong><br />${this.calEvent.type}<br />
<strong>Invitee Email:</strong><br /><a href="mailto:${this.calEvent.attendees[0].email}">${this.calEvent.attendees[0].email}</a><br /> <strong>${this.calEvent.language("invitee_email")}:</strong><br /><a href="mailto:${
this.calEvent.attendees[0].email
}">${this.calEvent.attendees[0].email}</a><br />
` + ` +
(this.getLocation() (this.getLocation()
? `<strong>Location:</strong><br />${this.getLocation()}<br /> ? `<strong>${this.calEvent.language("location")}:</strong><br />${this.getLocation()}<br />
` `
: "") + : "") +
`<strong>Invitee Time Zone:</strong><br />${this.calEvent.attendees[0].timeZone}<br /> `<strong>${this.calEvent.language("invitee_timezone")}:</strong><br />${
<strong>Additional notes:</strong><br />${this.getDescriptionText()}<br />` + this.calEvent.attendees[0].timeZone
}<br />
<strong>${this.calEvent.language("additional_notes")}:</strong><br />${this.getDescriptionText()}<br />` +
this.getChangeEventFooterHtml() this.getChangeEventFooterHtml()
); );
} }
@ -83,10 +90,10 @@ export default class CalEventParser {
* For Daily video calls returns the direct link * For Daily video calls returns the direct link
* @protected * @protected
*/ */
protected getLocation(): string | undefined { protected getLocation(): string | null | undefined {
const isDaily = this.calEvent.location === "integrations:daily"; const isDaily = this.calEvent.location === "integrations:daily";
if (this.optionalVideoCallData) { if (this.calEvent.videoCallData) {
return this.optionalVideoCallData.url; return this.calEvent.videoCallData.url;
} }
if (isDaily) { if (isDaily) {
return process.env.BASE_URL + "/call/" + this.getUid(); return process.env.BASE_URL + "/call/" + this.getUid();
@ -100,12 +107,14 @@ export default class CalEventParser {
* *
* @protected * @protected
*/ */
protected getDescriptionText(): string | undefined { protected getDescriptionText(): string | null | undefined {
if (this.optionalVideoCallData) { if (this.calEvent.videoCallData) {
return ` return `
${getIntegrationName(this.optionalVideoCallData.type)} meeting ${this.calEvent.language("integration_meeting_id", {
ID: ${this.optionalVideoCallData.id} integrationName: getIntegrationName(this.calEvent.videoCallData.type),
Password: ${this.optionalVideoCallData.password} meetingId: this.calEvent.videoCallData.id,
})}
${this.calEvent.language("password")}: ${this.calEvent.videoCallData.password}
${this.calEvent.description}`; ${this.calEvent.description}`;
} }
return this.calEvent.description; return this.calEvent.description;

View file

@ -1,4 +1,5 @@
import { Credential } from "@prisma/client"; import { Credential } from "@prisma/client";
import { TFunction } from "next-i18next";
import { EventResult } from "@lib/events/EventManager"; import { EventResult } from "@lib/events/EventManager";
import logger from "@lib/logger"; import logger from "@lib/logger";
@ -54,7 +55,7 @@ const googleAuth = (credential) => {
}; };
}; };
function handleErrorsJson(response) { function handleErrorsJson(response: Response) {
if (!response.ok) { if (!response.ok) {
response.json().then((e) => console.error("O365 Error", e)); response.json().then((e) => console.error("O365 Error", e));
throw Error(response.statusText); throw Error(response.statusText);
@ -62,7 +63,7 @@ function handleErrorsJson(response) {
return response.json(); return response.json();
} }
function handleErrorsRaw(response) { function handleErrorsRaw(response: Response) {
if (!response.ok) { if (!response.ok) {
response.text().then((e) => console.error("O365 Error", e)); response.text().then((e) => console.error("O365 Error", e));
throw Error(response.statusText); throw Error(response.statusText);
@ -112,20 +113,41 @@ const o365Auth = (credential) => {
export type Person = { name: string; email: string; timeZone: string }; 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 { export interface CalendarEvent {
type: string; type: string;
title: string; title: string;
startTime: string; startTime: string;
endTime: string; endTime: string;
description?: string; description?: string | null;
team?: { team?: {
name: string; name: string;
members: string[]; members: string[];
}; };
location?: string; location?: string | null;
organizer: Person; organizer: Person;
attendees: Person[]; attendees: Person[];
conferenceData?: ConferenceData; conferenceData?: ConferenceData;
language: TFunction;
additionInformation?: AdditionInformation;
uid?: string | null;
videoCallData?: VideoCallData;
} }
export interface ConferenceData { export interface ConferenceData {
@ -143,9 +165,9 @@ type BufferedBusyTime = { start: string; end: string };
export interface CalendarApiAdapter { export interface CalendarApiAdapter {
createEvent(event: CalendarEvent): Promise<unknown>; createEvent(event: CalendarEvent): Promise<unknown>;
updateEvent(uid: string, event: CalendarEvent); updateEvent(uid: string, event: CalendarEvent): Promise<any>;
deleteEvent(uid: string); deleteEvent(uid: string): Promise<unknown>;
getAvailability( getAvailability(
dateFrom: string, dateFrom: string,
@ -574,11 +596,9 @@ const listCalendars = (withCredentials) =>
const createEvent = async ( const createEvent = async (
credential: Credential, credential: Credential,
calEvent: CalendarEvent, calEvent: CalendarEvent,
noMail = false, noMail: boolean | null = false
maybeUid?: string,
optionalVideoCallData?: VideoCallData
): Promise<EventResult> => { ): Promise<EventResult> => {
const parser: CalEventParser = new CalEventParser(calEvent, maybeUid, optionalVideoCallData); const parser: CalEventParser = new CalEventParser(calEvent);
const uid: string = parser.getUid(); const uid: string = parser.getUid();
/* /*
* Matching the credential type is a workaround because the office calendar simply strips away newlines (\n and \r). * 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; let success = true;
const creationResult = credential const creationResult: any = credential
? await calendars([credential])[0] ? await calendars([credential])[0]
.createEvent(richEvent) .createEvent(richEvent)
.catch((e) => { .catch((e) => {
@ -598,16 +618,18 @@ const createEvent = async (
}) })
: null; : null;
const maybeHangoutLink = creationResult?.hangoutLink; const metadata: AdditionInformation = {};
const maybeEntryPoints = creationResult?.entryPoints; if (creationResult) {
const maybeConferenceData = creationResult?.conferenceData; // 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) { if (!noMail) {
const organizerMail = new EventOrganizerMail(calEvent, uid, { const organizerMail = new EventOrganizerMail(emailEvent);
hangoutLink: maybeHangoutLink,
conferenceData: maybeConferenceData,
entryPoints: maybeEntryPoints,
});
try { try {
await organizerMail.sendEmail(); await organizerMail.sendEmail();
@ -627,28 +649,28 @@ const createEvent = async (
const updateEvent = async ( const updateEvent = async (
credential: Credential, credential: Credential,
uidToUpdate: string,
calEvent: CalendarEvent, calEvent: CalendarEvent,
noMail = false, noMail: boolean | null = false
optionalVideoCallData?: VideoCallData
): Promise<EventResult> => { ): Promise<EventResult> => {
const parser: CalEventParser = new CalEventParser(calEvent, undefined, optionalVideoCallData); const parser: CalEventParser = new CalEventParser(calEvent);
const newUid: string = parser.getUid(); const newUid: string = parser.getUid();
const richEvent: CalendarEvent = parser.asRichEventPlain(); const richEvent: CalendarEvent = parser.asRichEventPlain();
let success = true; let success = true;
const updateResult = credential const updateResult =
? await calendars([credential])[0] credential && calEvent.uid
.updateEvent(uidToUpdate, richEvent) ? await calendars([credential])[0]
.catch((e) => { .updateEvent(calEvent.uid, richEvent)
log.error("updateEvent failed", e, calEvent); .catch((e) => {
success = false; log.error("updateEvent failed", e, calEvent);
}) success = false;
: null; })
: null;
if (!noMail) { if (!noMail) {
const organizerMail = new EventOrganizerRescheduledMail(calEvent, newUid); const emailEvent = { ...calEvent, uid: newUid };
const organizerMail = new EventOrganizerRescheduledMail(emailEvent);
try { try {
await organizerMail.sendEmail(); await organizerMail.sendEmail();
} catch (e) { } catch (e) {

View file

@ -3,12 +3,11 @@ import short from "short-uuid";
import { v5 as uuidv5 } from "uuid"; import { v5 as uuidv5 } from "uuid";
import CalEventParser from "@lib/CalEventParser"; import CalEventParser from "@lib/CalEventParser";
import { AdditionInformation, EntryPoint } from "@lib/emails/EventMail";
import { getIntegrationName } from "@lib/emails/helpers"; import { getIntegrationName } from "@lib/emails/helpers";
import { EventResult } from "@lib/events/EventManager"; import { EventResult } from "@lib/events/EventManager";
import logger from "@lib/logger"; import logger from "@lib/logger";
import { CalendarEvent } from "./calendarClient"; import { CalendarEvent, AdditionInformation, EntryPoint } from "./calendarClient";
import EventAttendeeRescheduledMail from "./emails/EventAttendeeRescheduledMail"; import EventAttendeeRescheduledMail from "./emails/EventAttendeeRescheduledMail";
import EventOrganizerRescheduledMail from "./emails/EventOrganizerRescheduledMail"; import EventOrganizerRescheduledMail from "./emails/EventOrganizerRescheduledMail";
import VideoEventAttendeeMail from "./emails/VideoEventAttendeeMail"; import VideoEventAttendeeMail from "./emails/VideoEventAttendeeMail";
@ -25,7 +24,7 @@ export interface DailyVideoCallData {
url: string; url: string;
} }
function handleErrorsJson(response) { function handleErrorsJson(response: Response) {
if (!response.ok) { if (!response.ok) {
response.json().then(console.log); response.json().then(console.log);
throw Error(response.statusText); throw Error(response.statusText);
@ -38,14 +37,14 @@ const dailyCredential = process.env.DAILY_API_KEY;
interface DailyVideoApiAdapter { interface DailyVideoApiAdapter {
dailyCreateMeeting(event: CalendarEvent): Promise<any>; dailyCreateMeeting(event: CalendarEvent): Promise<any>;
dailyUpdateMeeting(uid: string, event: CalendarEvent); dailyUpdateMeeting(uid: string, event: CalendarEvent): Promise<any>;
dailyDeleteMeeting(uid: string): Promise<unknown>; dailyDeleteMeeting(uid: string): Promise<unknown>;
getAvailability(dateFrom, dateTo): Promise<any>; getAvailability(dateFrom, dateTo): Promise<any>;
} }
const DailyVideo = (credential): DailyVideoApiAdapter => { const DailyVideo = (credential: Credential): DailyVideoApiAdapter => {
const translateEvent = (event: CalendarEvent) => { const translateEvent = (event: CalendarEvent) => {
// Documentation at: https://docs.daily.co/reference#list-rooms // Documentation at: https://docs.daily.co/reference#list-rooms
// added a 1 hour buffer for room expiration and room entry // added a 1 hour buffer for room expiration and room entry
@ -110,12 +109,8 @@ const getBusyVideoTimes: (withCredentials) => Promise<unknown[]> = (withCredenti
results.reduce((acc, availability) => acc.concat(availability), []) results.reduce((acc, availability) => acc.concat(availability), [])
); );
const dailyCreateMeeting = async ( const dailyCreateMeeting = async (credential: Credential, calEvent: CalendarEvent): Promise<EventResult> => {
credential: Credential, const parser: CalEventParser = new CalEventParser(calEvent);
calEvent: CalendarEvent,
maybeUid: string = null
): Promise<EventResult> => {
const parser: CalEventParser = new CalEventParser(calEvent, maybeUid);
const uid: string = parser.getUid(); const uid: string = parser.getUid();
if (!credential) { if (!credential) {
@ -145,18 +140,17 @@ const dailyCreateMeeting = async (
const entryPoint: EntryPoint = { const entryPoint: EntryPoint = {
entryPointType: getIntegrationName(videoCallData), entryPointType: getIntegrationName(videoCallData),
uri: videoCallData.url, uri: videoCallData.url,
label: "Enter Meeting", label: calEvent.language("enter_meeting"),
pin: "", pin: "",
}; };
const additionInformation: AdditionInformation = { const additionInformation: AdditionInformation = {
entryPoints: [entryPoint], entryPoints: [entryPoint],
}; };
const emailEvent = { ...calEvent, uid, additionInformation, videoCallData };
const organizerMail = new VideoEventOrganizerMail(calEvent, uid, videoCallData, additionInformation);
const attendeeMail = new VideoEventAttendeeMail(calEvent, uid, videoCallData, additionInformation);
try { try {
const organizerMail = new VideoEventOrganizerMail(emailEvent);
await organizerMail.sendEmail(); await organizerMail.sendEmail();
} catch (e) { } catch (e) {
console.error("organizerMail.sendEmail failed", e); console.error("organizerMail.sendEmail failed", e);
@ -164,6 +158,7 @@ const dailyCreateMeeting = async (
if (!creationResult || !creationResult.disableConfirmationEmail) { if (!creationResult || !creationResult.disableConfirmationEmail) {
try { try {
const attendeeMail = new VideoEventAttendeeMail(emailEvent);
await attendeeMail.sendEmail(); await attendeeMail.sendEmail();
} catch (e) { } catch (e) {
console.error("attendeeMail.sendEmail failed", e); console.error("attendeeMail.sendEmail failed", e);
@ -179,11 +174,7 @@ const dailyCreateMeeting = async (
}; };
}; };
const dailyUpdateMeeting = async ( const dailyUpdateMeeting = async (credential: Credential, calEvent: CalendarEvent): Promise<EventResult> => {
credential: Credential,
uidToUpdate: string,
calEvent: CalendarEvent
): Promise<EventResult> => {
const newUid: string = translator.fromUUID(uuidv5(JSON.stringify(calEvent), uuidv5.URL)); const newUid: string = translator.fromUUID(uuidv5(JSON.stringify(calEvent), uuidv5.URL));
if (!credential) { if (!credential) {
@ -194,18 +185,20 @@ const dailyUpdateMeeting = async (
let success = true; let success = true;
const updateResult = credential const updateResult =
? await videoIntegrations([credential])[0] credential && calEvent.uid
.dailyUpdateMeeting(uidToUpdate, calEvent) ? await videoIntegrations([credential])[0]
.catch((e) => { .dailyUpdateMeeting(calEvent.uid, calEvent)
log.error("updateMeeting failed", e, calEvent); .catch((e) => {
success = false; log.error("updateMeeting failed", e, calEvent);
}) success = false;
: null; })
: null;
const emailEvent = { ...calEvent, uid: newUid };
const organizerMail = new EventOrganizerRescheduledMail(calEvent, newUid);
const attendeeMail = new EventAttendeeRescheduledMail(calEvent, newUid);
try { try {
const organizerMail = new EventOrganizerRescheduledMail(emailEvent);
await organizerMail.sendEmail(); await organizerMail.sendEmail();
} catch (e) { } catch (e) {
console.error("organizerMail.sendEmail failed", e); console.error("organizerMail.sendEmail failed", e);
@ -213,6 +206,7 @@ const dailyUpdateMeeting = async (
if (!updateResult || !updateResult.disableConfirmationEmail) { if (!updateResult || !updateResult.disableConfirmationEmail) {
try { try {
const attendeeMail = new EventAttendeeRescheduledMail(emailEvent);
await attendeeMail.sendEmail(); await attendeeMail.sendEmail();
} catch (e) { } catch (e) {
console.error("attendeeMail.sendEmail failed", e); console.error("attendeeMail.sendEmail failed", e);

View file

@ -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" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/> />
</svg> </svg>
<h1 style="font-weight: 500; color: #161e2e;">Your meeting has been booked</h1> <h1 style="font-weight: 500; color: #161e2e;">${this.calEvent.language(
<p style="color: #4b5563; margin-bottom: 30px;">You and any other attendees have been emailed with this information.</p> "your_meeting_has_been_booked"
)}</h1>
<p style="color: #4b5563; margin-bottom: 30px;">${this.calEvent.language("emailed_you_and_attendees")}</p>
<hr /> <hr />
<table style="border-spacing: 20px; color: #161e2e; margin-bottom: 10px;"> <table style="border-spacing: 20px; color: #161e2e; margin-bottom: 10px;">
<colgroup> <colgroup>
@ -54,17 +56,17 @@ export default class EventAttendeeMail extends EventMail {
<col span="1" style="width: 60%;"> <col span="1" style="width: 60%;">
</colgroup> </colgroup>
<tr> <tr>
<td>What</td> <td>${this.calEvent.language("what")}</td>
<td>${this.calEvent.type}</td> <td>${this.calEvent.type}</td>
</tr> </tr>
<tr> <tr>
<td>When</td> <td>${this.calEvent.language("when")}</td>
<td>${this.getInviteeStart().format("dddd, LL")}<br>${this.getInviteeStart().format("h:mma")} (${ <td>${this.getInviteeStart().format("dddd, LL")}<br>${this.getInviteeStart().format("h:mma")} (${
this.calEvent.attendees[0].timeZone this.calEvent.attendees[0].timeZone
})</td> })</td>
</tr> </tr>
<tr> <tr>
<td>Who</td> <td>${this.calEvent.language("who")}</td>
<td> <td>
${this.calEvent.team?.name || this.calEvent.organizer.name}<br /> ${this.calEvent.team?.name || this.calEvent.organizer.name}<br />
<small> <small>
@ -74,11 +76,11 @@ export default class EventAttendeeMail extends EventMail {
</td> </td>
</tr> </tr>
<tr> <tr>
<td>Where</td> <td>${this.calEvent.language("where")}</td>
<td>${this.getLocation()}</td> <td>${this.getLocation()}</td>
</tr> </tr>
<tr> <tr>
<td>Notes</td> <td>${this.calEvent.language("notes")}</td>
<td>${this.calEvent.description}</td> <td>${this.calEvent.description}</td>
</tr> </tr>
</table> </table>
@ -104,15 +106,18 @@ export default class EventAttendeeMail extends EventMail {
* @protected * @protected
*/ */
protected getLocation(): string { protected getLocation(): string {
if (this.additionInformation?.hangoutLink) { if (this.calEvent.additionInformation?.hangoutLink) {
return `<a href="${this.additionInformation?.hangoutLink}">${this.additionInformation?.hangoutLink}</a><br />`; return `<a href="${this.calEvent.additionInformation?.hangoutLink}">${this.calEvent.additionInformation?.hangoutLink}</a><br />`;
} }
if (this.additionInformation?.entryPoints && this.additionInformation?.entryPoints.length > 0) { if (
const locations = this.additionInformation?.entryPoints this.calEvent.additionInformation?.entryPoints &&
this.calEvent.additionInformation?.entryPoints.length > 0
) {
const locations = this.calEvent.additionInformation?.entryPoints
.map((entryPoint) => { .map((entryPoint) => {
return ` return `
Join by ${entryPoint.entryPointType}: <br /> ${this.calEvent.language("join_by_entrypoint", { entryPoint: entryPoint.entryPointType })}: <br />
<a href="${entryPoint.uri}">${entryPoint.label}</a> <br /> <a href="${entryPoint.uri}">${entryPoint.label}</a> <br />
`; `;
}) })
@ -142,15 +147,17 @@ export default class EventAttendeeMail extends EventMail {
to: `${this.calEvent.attendees[0].name} <${this.calEvent.attendees[0].email}>`, to: `${this.calEvent.attendees[0].name} <${this.calEvent.attendees[0].email}>`,
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`, from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
replyTo: this.calEvent.organizer.email, replyTo: this.calEvent.organizer.email,
subject: `Confirmed: ${this.calEvent.type} with ${ subject: this.calEvent.language("confirmed_event_type_subject", {
this.calEvent.team?.name || this.calEvent.organizer.name eventType: this.calEvent.type,
} on ${this.getInviteeStart().format("LT dddd, LL")}`, name: this.calEvent.team?.name || this.calEvent.organizer.name,
date: this.getInviteeStart().format("LT dddd, LL"),
}),
html: this.getHtmlRepresentation(), html: this.getHtmlRepresentation(),
text: this.getPlainTextRepresentation(), 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); console.error("SEND_BOOKING_CONFIRMATION_ERROR", this.calEvent.attendees[0].email, error);
} }

View file

@ -10,12 +10,17 @@ export default class EventAttendeeRescheduledMail extends EventAttendeeMail {
return ( return (
` `
<div> <div>
Hi ${this.calEvent.attendees[0].name},<br /> ${this.calEvent.language("hi_user_name", { userName: this.calEvent.attendees[0].name })},<br />
<br /> <br />
Your ${this.calEvent.type} with ${ ${this.calEvent.language("event_type_has_been_rescheduled_on_time_date", {
this.calEvent.team?.name || this.calEvent.organizer.name eventType: this.calEvent.type,
} has been rescheduled to ${this.getInviteeStart().format("h:mma")} name: this.calEvent.team?.name || this.calEvent.organizer.name,
(${this.calEvent.attendees[0].timeZone}) on ${this.getInviteeStart().format("dddd, LL")}.<br /> 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())}`,
})}<br />
` + ` +
this.getAdditionalFooter() + this.getAdditionalFooter() +
` `
@ -34,15 +39,17 @@ export default class EventAttendeeRescheduledMail extends EventAttendeeMail {
to: `${this.calEvent.attendees[0].name} <${this.calEvent.attendees[0].email}>`, to: `${this.calEvent.attendees[0].name} <${this.calEvent.attendees[0].email}>`,
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`, from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
replyTo: this.calEvent.organizer.email, replyTo: this.calEvent.organizer.email,
subject: `Rescheduled: ${this.calEvent.type} with ${ subject: this.calEvent.language("rescheduled_event_type_with_organizer", {
this.calEvent.organizer.name eventType: this.calEvent.type,
} on ${this.getInviteeStart().format("dddd, LL")}`, organizerName: this.calEvent.organizer.name,
date: this.getInviteeStart().format("dddd, LL"),
}),
html: this.getHtmlRepresentation(), html: this.getHtmlRepresentation(),
text: this.getPlainTextRepresentation(), 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); console.error("SEND_RESCHEDULE_CONFIRMATION_ERROR", this.calEvent.attendees[0].email, error);
} }
} }

View file

@ -1,47 +1,25 @@
import nodemailer from "nodemailer"; import nodemailer from "nodemailer";
import { getErrorFromUnknown } from "@lib/errors";
import CalEventParser from "../CalEventParser"; import CalEventParser from "../CalEventParser";
import { CalendarEvent, ConferenceData } from "../calendarClient"; import { CalendarEvent } from "../calendarClient";
import { serverConfig } from "../serverConfig"; import { serverConfig } from "../serverConfig";
import { stripHtml } from "./helpers"; 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 { export default abstract class EventMail {
calEvent: CalendarEvent; calEvent: CalendarEvent;
parser: CalEventParser; parser: CalEventParser;
uid: string;
additionInformation?: AdditionInformation;
/** /**
* An EventMail always consists of a CalendarEvent * An EventMail always consists of a CalendarEvent
* that stores the very basic data of the event (like date, title etc). * that stores the data of the event (like date, title, uid etc).
* It also needs the UID of the stored booking in our database.
* *
* @param calEvent * @param calEvent
* @param uid
* @param additionInformation
*/ */
constructor(calEvent: CalendarEvent, uid: string, additionInformation?: AdditionInformation) { constructor(calEvent: CalendarEvent) {
this.calEvent = calEvent; this.calEvent = calEvent;
this.uid = uid; this.parser = new CalEventParser(calEvent);
this.parser = new CalEventParser(calEvent, uid);
this.additionInformation = additionInformation;
} }
/** /**
@ -74,10 +52,11 @@ export default abstract class EventMail {
new Promise((resolve, reject) => new Promise((resolve, reject) =>
nodemailer nodemailer
.createTransport(this.getMailerOptions().transport) .createTransport(this.getMailerOptions().transport)
.sendMail(this.getNodeMailerPayload(), (error, info) => { .sendMail(this.getNodeMailerPayload(), (_err, info) => {
if (error) { if (_err) {
this.printNodeMailerError(error); const err = getErrorFromUnknown(_err);
reject(new Error(error)); this.printNodeMailerError(err);
reject(err);
} else { } else {
resolve(info); resolve(info);
} }
@ -117,7 +96,7 @@ export default abstract class EventMail {
* @param error * @param error
* @protected * @protected
*/ */
protected abstract printNodeMailerError(error: string): void; protected abstract printNodeMailerError(error: Error): void;
/** /**
* Returns a link to reschedule the given booking. * Returns a link to reschedule the given booking.

View file

@ -5,6 +5,8 @@ import toArray from "dayjs/plugin/toArray";
import utc from "dayjs/plugin/utc"; import utc from "dayjs/plugin/utc";
import { createEvent } from "ics"; import { createEvent } from "ics";
import { Person } from "@lib/calendarClient";
import EventMail from "./EventMail"; import EventMail from "./EventMail";
import { stripHtml } from "./helpers"; 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. * Returns the instance's event as an iCal event in string representation.
* @protected * @protected
*/ */
protected getiCalEventAsString(): string { protected getiCalEventAsString(): string | undefined {
const icsEvent = createEvent({ const icsEvent = createEvent({
start: dayjs(this.calEvent.startTime) start: dayjs(this.calEvent.startTime)
.utc() .utc()
@ -27,14 +29,17 @@ export default class EventOrganizerMail extends EventMail {
.map((v, i) => (i === 1 ? v + 1 : v)), .map((v, i) => (i === 1 ? v + 1 : v)),
startInputType: "utc", startInputType: "utc",
productId: "calendso/ics", 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: description:
this.calEvent.description + this.calEvent.description +
stripHtml(this.getAdditionalBody()) + stripHtml(this.getAdditionalBody()) +
stripHtml(this.getAdditionalFooter()), stripHtml(this.getAdditionalFooter()),
duration: { minutes: dayjs(this.calEvent.endTime).diff(dayjs(this.calEvent.startTime), "minute") }, duration: { minutes: dayjs(this.calEvent.endTime).diff(dayjs(this.calEvent.startTime), "minute") },
organizer: { name: this.calEvent.organizer.name, email: this.calEvent.organizer.email }, 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, name: attendee.name,
email: attendee.email, email: attendee.email,
})), })),
@ -47,13 +52,15 @@ export default class EventOrganizerMail extends EventMail {
} }
protected getBodyHeader(): string { protected getBodyHeader(): string {
return "A new event has been scheduled."; return this.calEvent.language("new_event_scheduled");
} }
protected getAdditionalFooter(): string { protected getAdditionalFooter(): string {
return `<p style="color: #4b5563; margin-top: 20px;">Need to make a change? <a href=${ return `<p style="color: #4b5563; margin-top: 20px;">${this.calEvent.language(
process.env.BASE_URL + "/bookings" "need_to_make_a_change"
} style="color: #161e2e;">Manage my bookings</a></p>`; )} <a href=${process.env.BASE_URL + "/bookings"} style="color: #161e2e;">${this.calEvent.language(
"manage_my_bookings"
)}</a></p>`;
} }
protected getImage(): string { protected getImage(): string {
@ -103,27 +110,27 @@ export default class EventOrganizerMail extends EventMail {
<col span="1" style="width: 60%;"> <col span="1" style="width: 60%;">
</colgroup> </colgroup>
<tr> <tr>
<td>What</td> <td>${this.calEvent.language("what")}</td>
<td>${this.calEvent.type}</td> <td>${this.calEvent.type}</td>
</tr> </tr>
<tr> <tr>
<td>When</td> <td>${this.calEvent.language("when")}</td>
<td>${this.getOrganizerStart().format("dddd, LL")}<br>${this.getOrganizerStart().format("h:mma")} (${ <td>${this.getOrganizerStart().format("dddd, LL")}<br>${this.getOrganizerStart().format("h:mma")} (${
this.calEvent.organizer.timeZone this.calEvent.organizer.timeZone
})</td> })</td>
</tr> </tr>
<tr> <tr>
<td>Who</td> <td>${this.calEvent.language("who")}</td>
<td>${this.calEvent.attendees[0].name}<br /><small><a href="mailto:${ <td>${this.calEvent.attendees[0].name}<br /><small><a href="mailto:${
this.calEvent.attendees[0].email this.calEvent.attendees[0].email
}">${this.calEvent.attendees[0].email}</a></small></td> }">${this.calEvent.attendees[0].email}</a></small></td>
</tr> </tr>
<tr> <tr>
<td>Where</td> <td>${this.calEvent.language("where")}</td>
<td>${this.getLocation()}</td> <td>${this.getLocation()}</td>
</tr> </tr>
<tr> <tr>
<td>Notes</td> <td>${this.calEvent.language("notes")}</td>
<td>${this.calEvent.description}</td> <td>${this.calEvent.description}</td>
</tr> </tr>
</table> </table>
@ -149,15 +156,18 @@ export default class EventOrganizerMail extends EventMail {
* @protected * @protected
*/ */
protected getLocation(): string { protected getLocation(): string {
if (this.additionInformation?.hangoutLink) { if (this.calEvent.additionInformation?.hangoutLink) {
return `<a href="${this.additionInformation?.hangoutLink}">${this.additionInformation?.hangoutLink}</a><br />`; return `<a href="${this.calEvent.additionInformation?.hangoutLink}">${this.calEvent.additionInformation?.hangoutLink}</a><br />`;
} }
if (this.additionInformation?.entryPoints && this.additionInformation?.entryPoints.length > 0) { if (
const locations = this.additionInformation?.entryPoints this.calEvent.additionInformation?.entryPoints &&
this.calEvent.additionInformation?.entryPoints.length > 0
) {
const locations = this.calEvent.additionInformation?.entryPoints
.map((entryPoint) => { .map((entryPoint) => {
return ` return `
Join by ${entryPoint.entryPointType}: <br /> ${this.calEvent.language("join_by_entrypoint", { entryPoint: entryPoint.entryPointType })}: <br />
<a href="${entryPoint.uri}">${entryPoint.label}</a> <br /> <a href="${entryPoint.uri}">${entryPoint.label}</a> <br />
`; `;
}) })
@ -202,12 +212,14 @@ export default class EventOrganizerMail extends EventMail {
} }
protected getSubject(): string { protected getSubject(): string {
return `New event: ${this.calEvent.attendees[0].name} - ${this.getOrganizerStart().format( return this.calEvent.language("new_event_subject", {
"LT dddd, LL" attendeeName: this.calEvent.attendees[0].name,
)} - ${this.calEvent.type}`; 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); console.error("SEND_NEW_EVENT_NOTIFICATION_ERROR", this.calEvent.organizer.email, error);
} }

View file

@ -13,15 +13,17 @@ dayjs.extend(localizedFormat);
export default class EventOrganizerRequestMail extends EventOrganizerMail { export default class EventOrganizerRequestMail extends EventOrganizerMail {
protected getBodyHeader(): string { protected getBodyHeader(): string {
return "A new event is waiting for your approval."; return this.calEvent.language("event_awaiting_approval");
} }
protected getBodyText(): string { 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 { protected getAdditionalBody(): string {
return `<a href="${process.env.BASE_URL}/bookings">Confirm or reject the booking</a>`; return `<a href="${process.env.BASE_URL}/bookings">${this.calEvent.language(
"confirm_or_reject_booking"
)}</a>`;
} }
protected getImage(): string { protected getImage(): string {
@ -43,8 +45,10 @@ export default class EventOrganizerRequestMail extends EventOrganizerMail {
protected getSubject(): string { protected getSubject(): string {
const organizerStart: Dayjs = dayjs(this.calEvent.startTime).tz(this.calEvent.organizer.timeZone); const organizerStart: Dayjs = dayjs(this.calEvent.startTime).tz(this.calEvent.organizer.timeZone);
return `New event request: ${this.calEvent.attendees[0].name} - ${organizerStart.format( return this.calEvent.language("new_event_request", {
"LT dddd, LL" attendeeName: this.calEvent.attendees[0].name,
)} - ${this.calEvent.type}`; date: organizerStart.format("LT dddd, LL"),
eventType: this.calEvent.type,
});
} }
} }

View file

@ -12,32 +12,32 @@ export default class EventOrganizerRescheduledMail extends EventOrganizerMail {
return ( return (
` `
<div> <div>
Hi ${this.calEvent.organizer.name},<br /> ${this.calEvent.language("hi_user_name", { userName: this.calEvent.organizer.name })},<br />
<br /> <br />
Your event has been rescheduled.<br /> ${this.calEvent.language("event_has_been_rescheduled")}<br />
<br /> <br />
<strong>Event Type:</strong><br /> <strong>${this.calEvent.language("event_type")}:</strong><br />
${this.calEvent.type}<br /> ${this.calEvent.type}<br />
<br /> <br />
<strong>Invitee Email:</strong><br /> <strong>${this.calEvent.language("invitee_email")}:</strong><br />
<a href="mailto:${this.calEvent.attendees[0].email}">${this.calEvent.attendees[0].email}</a><br /> <a href="mailto:${this.calEvent.attendees[0].email}">${this.calEvent.attendees[0].email}</a><br />
<br />` + <br />` +
this.getAdditionalBody() + this.getAdditionalBody() +
(this.calEvent.location (this.calEvent.location
? ` ? `
<strong>Location:</strong><br /> <strong>${this.calEvent.language("location")}:</strong><br />
${this.calEvent.location}<br /> ${this.calEvent.location}<br />
<br /> <br />
` `
: "") + : "") +
`<strong>Invitee Time Zone:</strong><br /> `<strong>${this.calEvent.language("invitee_timezone")}:</strong><br />
${this.calEvent.attendees[0].timeZone}<br /> ${this.calEvent.attendees[0].timeZone}<br />
<br /> <br />
<strong>Additional notes:</strong><br /> <strong>${this.calEvent.language("additional_notes")}:</strong><br />
${this.calEvent.description} ${this.calEvent.description}
` + ` +
this.getAdditionalFooter() + this.getAdditionalFooter() +
` `
</div> </div>
` `
); );
@ -58,15 +58,17 @@ export default class EventOrganizerRescheduledMail extends EventOrganizerMail {
}, },
from: `Cal.com <${this.getMailerOptions().from}>`, from: `Cal.com <${this.getMailerOptions().from}>`,
to: this.calEvent.organizer.email, to: this.calEvent.organizer.email,
subject: `Rescheduled event: ${this.calEvent.attendees[0].name} - ${organizerStart.format( subject: this.calEvent.language("rescheduled_event_type_with_attendee", {
"LT dddd, LL" attendeeName: this.calEvent.attendees[0].name,
)} - ${this.calEvent.type}`, date: organizerStart.format("LT dddd, LL"),
eventType: this.calEvent.type,
}),
html: this.getHtmlRepresentation(), html: this.getHtmlRepresentation(),
text: this.getPlainTextRepresentation(), 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); console.error("SEND_RESCHEDULE_EVENT_NOTIFICATION_ERROR", this.calEvent.organizer.email, error);
} }
} }

View file

@ -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" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/> />
</svg> </svg>
<h1 style="font-weight: 500; color: #161e2e;">Your meeting request has been rejected</h1> <h1 style="font-weight: 500; color: #161e2e;">${this.calEvent.language("meeting_request_rejected")}</h1>
<p style="color: #4b5563; margin-bottom: 30px;">You and any other attendees have been emailed with this information.</p> <p style="color: #4b5563; margin-bottom: 30px;">${this.calEvent.language("emailed_you_and_attendees")}</p>
<hr /> <hr />
` + ` +
` `
@ -68,24 +68,35 @@ export default class EventRejectionMail extends EventMail {
to: `${this.calEvent.attendees[0].name} <${this.calEvent.attendees[0].email}>`, to: `${this.calEvent.attendees[0].name} <${this.calEvent.attendees[0].email}>`,
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`, from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
replyTo: this.calEvent.organizer.email, replyTo: this.calEvent.organizer.email,
subject: `Rejected: ${this.calEvent.type} with ${ subject: this.calEvent.language("rejected_event_type_with_organizer", {
this.calEvent.organizer.name eventType: this.calEvent.type,
} on ${this.getInviteeStart().format("dddd, LL")}`, organizer: this.calEvent.organizer.name,
date: this.getInviteeStart().format("dddd, LL"),
}),
html: this.getHtmlRepresentation(), html: this.getHtmlRepresentation(),
text: this.getPlainTextRepresentation(), 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); console.error("SEND_BOOKING_CONFIRMATION_ERROR", this.calEvent.attendees[0].email, error);
} }
/** /**
* Returns the inviteeStart value used at multiple points. * Returns the inviteeStart value used at multiple points.
* *
* @private * @protected
*/ */
protected getInviteeStart(): Dayjs { protected getInviteeStart(): Dayjs {
return dayjs(this.calEvent.startTime).tz(this.calEvent.attendees[0].timeZone); 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 "";
}
} }

View file

@ -1,45 +1,43 @@
import { AdditionInformation } from "@lib/emails/EventMail";
import { CalendarEvent } from "../calendarClient";
import { VideoCallData } from "../videoClient";
import EventAttendeeMail from "./EventAttendeeMail"; import EventAttendeeMail from "./EventAttendeeMail";
import { getFormattedMeetingId, getIntegrationName } from "./helpers"; import { getFormattedMeetingId, getIntegrationName } from "./helpers";
export default class VideoEventAttendeeMail extends EventAttendeeMail { 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. * Adds the video call information to the mail body.
* *
* @protected * @protected
*/ */
protected getAdditionalBody(): string { protected getAdditionalBody(): string {
const meetingPassword = this.videoCallData.password; if (!this.calEvent.videoCallData) {
const meetingId = getFormattedMeetingId(this.videoCallData); return "";
}
const meetingPassword = this.calEvent.videoCallData.password;
const meetingId = getFormattedMeetingId(this.calEvent.videoCallData);
if (meetingId && meetingPassword) { if (meetingId && meetingPassword) {
return ` return `
<strong>Video call provider:</strong> ${getIntegrationName(this.videoCallData)}<br /> <strong>${this.calEvent.language("video_call_provider")}:</strong> ${getIntegrationName(
<strong>Meeting ID:</strong> ${getFormattedMeetingId(this.videoCallData)}<br /> this.calEvent.videoCallData
<strong>Meeting Password:</strong> ${this.videoCallData.password}<br /> )}<br />
<strong>Meeting URL:</strong> <a href="${this.videoCallData.url}">${this.videoCallData.url}</a><br /> <strong>${this.calEvent.language("meeting_id")}:</strong> ${getFormattedMeetingId(
this.calEvent.videoCallData
)}<br />
<strong>${this.calEvent.language("meeting_password")}:</strong> ${
this.calEvent.videoCallData.password
}<br />
<strong>${this.calEvent.language("meeting_url")}:</strong> <a href="${
this.calEvent.videoCallData.url
}">${this.calEvent.videoCallData.url}</a><br />
`; `;
} }
return ` return `
<strong>Video call provider:</strong> ${getIntegrationName(this.videoCallData)}<br /> <strong>${this.calEvent.language("video_call_provider")}:</strong> ${getIntegrationName(
<strong>Meeting URL:</strong> <a href="${this.videoCallData.url}">${this.videoCallData.url}</a><br /> this.calEvent.videoCallData
)}<br />
<strong>${this.calEvent.language("meeting_url")}:</strong> <a href="${
this.calEvent.videoCallData.url
}">${this.calEvent.videoCallData.url}</a><br />
`; `;
} }
} }

View file

@ -1,24 +1,7 @@
import { AdditionInformation } from "@lib/emails/EventMail";
import { CalendarEvent } from "../calendarClient";
import { VideoCallData } from "../videoClient";
import EventOrganizerMail from "./EventOrganizerMail"; import EventOrganizerMail from "./EventOrganizerMail";
import { getFormattedMeetingId, getIntegrationName } from "./helpers"; import { getFormattedMeetingId, getIntegrationName } from "./helpers";
export default class VideoEventOrganizerMail extends EventOrganizerMail { 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 * Adds the video call information to the mail body
* and calendar event description. * and calendar event description.
@ -26,20 +9,33 @@ export default class VideoEventOrganizerMail extends EventOrganizerMail {
* @protected * @protected
*/ */
protected getAdditionalBody(): string { protected getAdditionalBody(): string {
const meetingPassword = this.videoCallData.password; if (!this.calEvent.videoCallData) {
const meetingId = getFormattedMeetingId(this.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. // This odd indentation is necessary because otherwise the leading tabs will be applied into the event description.
if (meetingPassword && meetingId) { if (meetingPassword && meetingId) {
return ` return `
<strong>Video call provider:</strong> ${getIntegrationName(this.videoCallData)}<br /> <strong>${this.calEvent.language("video_call_provider")}:</strong> ${getIntegrationName(
<strong>Meeting ID:</strong> ${getFormattedMeetingId(this.videoCallData)}<br /> this.calEvent.videoCallData
<strong>Meeting Password:</strong> ${this.videoCallData.password}<br /> )}<br />
<strong>Meeting URL:</strong> <a href="${this.videoCallData.url}">${this.videoCallData.url}</a><br /> <strong>${this.calEvent.language("meeting_id")}:</strong> ${getFormattedMeetingId(
this.calEvent.videoCallData
)}<br />
<strong>${this.calEvent.language("meeting_password")}:</strong> ${this.calEvent.videoCallData.password}<br />
<strong>${this.calEvent.language("meeting_url")}:</strong> <a href="${this.calEvent.videoCallData.url}">${
this.calEvent.videoCallData.url
}</a><br />
`; `;
} }
return ` return `
<strong>Video call provider:</strong> ${getIntegrationName(this.videoCallData)}<br /> <strong>${this.calEvent.language("video_call_provider")}:</strong> ${getIntegrationName(
<strong>Meeting URL:</strong> <a href="${this.videoCallData.url}">${this.videoCallData.url}</a><br /> this.calEvent.videoCallData
)}<br />
<strong>${this.calEvent.language("meeting_url")}:</strong> <a href="${this.calEvent.videoCallData.url}">${
this.calEvent.videoCallData.url
}</a><br />
`; `;
} }
} }

View file

@ -1,15 +1,36 @@
import Handlebars from "handlebars"; 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 = ({ export const buildMessageTemplate = ({
messageTemplate, messageTemplate,
subjectTemplate, subjectTemplate,
vars, vars,
}): { subject: string; message: string } => { }: MessageTemplateTypes): BuildTemplateResult => {
const buildMessage = Handlebars.compile(messageTemplate); const buildMessage = Handlebars.compile(messageTemplate);
const message = buildMessage(vars); const message = buildMessage(vars);
const buildSubject = Handlebars.compile(subjectTemplate); const buildSubject = Handlebars.compile(subjectTemplate);
const subject = buildSubject(vars); const subject = buildSubject(vars);
return { return {
subject, subject,
message, message,

View file

@ -1,8 +1,12 @@
import { TFunction } from "next-i18next";
import nodemailer from "nodemailer"; import nodemailer from "nodemailer";
import { getErrorFromUnknown } from "@lib/errors";
import { serverConfig } from "../serverConfig"; import { serverConfig } from "../serverConfig";
export type Invitation = { export type Invitation = {
language: TFunction;
from?: string; from?: string;
toEmail: string; toEmail: string;
teamName: string; teamName: string;
@ -26,21 +30,24 @@ const sendEmail = (invitation: Invitation, provider: EmailProvider): Promise<voi
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
const { transport, from } = provider; const { transport, from } = provider;
const { language: t } = invitation;
const invitationHtml = html(invitation); const invitationHtml = html(invitation);
nodemailer.createTransport(transport).sendMail( nodemailer.createTransport(transport).sendMail(
{ {
from: `Cal.com <${from}>`, from: `Cal.com <${from}>`,
to: invitation.toEmail, to: invitation.toEmail,
subject: subject: invitation.from
(invitation.from ? invitation.from + " invited you" : "You have been invited") + ? t("user_invited_you", { user: invitation.from, teamName: invitation.teamName })
` to join ${invitation.teamName}`, : t("you_have_been_invited", { teamName: invitation.teamName }),
html: invitationHtml, html: invitationHtml,
text: text(invitationHtml), text: text(invitationHtml),
}, },
(error) => { (_err) => {
if (error) { if (_err) {
console.error("SEND_INVITATION_NOTIFICATION_ERROR", invitation.toEmail, error); const err = getErrorFromUnknown(_err);
return reject(new Error(error)); console.error("SEND_INVITATION_NOTIFICATION_ERROR", invitation.toEmail, err);
reject(err);
return;
} }
return resolve(); return resolve();
} }
@ -48,6 +55,7 @@ const sendEmail = (invitation: Invitation, provider: EmailProvider): Promise<voi
}); });
export function html(invitation: Invitation): string { export function html(invitation: Invitation): string {
const { language: t } = invitation;
let url: string = process.env.BASE_URL + "/settings/teams"; let url: string = process.env.BASE_URL + "/settings/teams";
if (invitation.token) { if (invitation.token) {
url = `${process.env.BASE_URL}/auth/signup?token=${invitation.token}&callbackUrl=${url}`; url = `${process.env.BASE_URL}/auth/signup?token=${invitation.token}&callbackUrl=${url}`;
@ -62,10 +70,12 @@ export function html(invitation: Invitation): string {
<table style="width: 640px; border: 1px solid gray; padding: 15px; margin: 0 auto; text-align: left;"> <table style="width: 640px; border: 1px solid gray; padding: 15px; margin: 0 auto; text-align: left;">
<tr> <tr>
<td> <td>
Hi,<br /> ${t("hi")},<br />
<br />` + <br />` +
(invitation.from ? invitation.from + " invited you" : "You have been invited") + (invitation.from
` to join the team "${invitation.teamName}" in Cal.com.<br /> ? t("user_invited_you", { user: invitation.from, teamName: invitation.teamName })
: t("you_have_been_invited", { teamName: invitation.teamName })) +
`<br />
<br /> <br />
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;line-height:100%;"> <table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;line-height:100%;">
<tr> <tr>
@ -77,13 +87,15 @@ export function html(invitation: Invitation): string {
<center style="color:#ffffff;font-family:Helvetica, sans-serif;font-size:18px; font-weight: 600;">Join team</center> <center style="color:#ffffff;font-family:Helvetica, sans-serif;font-size:18px; font-weight: 600;">Join team</center>
</v:roundrect> </v:roundrect>
<![endif]--> <![endif]-->
<a href="${url}" style="display: inline-block; mso-hide:all; background-color: #19cca3; color: #FFFFFF; border:1px solid #19cca3; border-radius: 6px; line-height: 220%; width: 200px; font-family: Helvetica, sans-serif; font-size:18px; font-weight:600; text-align: center; text-decoration: none; -webkit-text-size-adjust:none; " target="_blank">Join team</a> <a href="${url}" style="display: inline-block; mso-hide:all; background-color: #19cca3; color: #FFFFFF; border:1px solid #19cca3; border-radius: 6px; line-height: 220%; width: 200px; font-family: Helvetica, sans-serif; font-size:18px; font-weight:600; text-align: center; text-decoration: none; -webkit-text-size-adjust:none; " target="_blank">${t(
"join_team"
)}</a>
</a> </a>
</div> </div>
</td> </td>
</tr> </tr>
</table><br /> </table><br />
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 })}
</td> </td>
</tr> </tr>
</table> </table>

View file

@ -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) { if (cause instanceof Error) {
return cause; return cause;
} }

View file

@ -3,7 +3,7 @@ import async from "async";
import merge from "lodash/merge"; import merge from "lodash/merge";
import { v5 as uuidv5 } from "uuid"; 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 { dailyCreateMeeting, dailyUpdateMeeting } from "@lib/dailyVideoClient";
import EventAttendeeMail from "@lib/emails/EventAttendeeMail"; import EventAttendeeMail from "@lib/emails/EventAttendeeMail";
import EventAttendeeRescheduledMail from "@lib/emails/EventAttendeeRescheduledMail"; import EventAttendeeRescheduledMail from "@lib/emails/EventAttendeeRescheduledMail";
@ -15,8 +15,8 @@ export interface EventResult {
type: string; type: string;
success: boolean; success: boolean;
uid: string; uid: string;
createdEvent?: unknown; createdEvent?: AdditionInformation & { name: string; id: string; disableConfirmationEmail?: boolean };
updatedEvent?: unknown; updatedEvent?: AdditionInformation & { name: string; id: string; disableConfirmationEmail?: boolean };
originalEvent: CalendarEvent; originalEvent: CalendarEvent;
videoCallData?: VideoCallData; videoCallData?: VideoCallData;
} }
@ -35,9 +35,9 @@ export interface PartialReference {
id?: number; id?: number;
type: string; type: string;
uid: string; uid: string;
meetingId?: string; meetingId?: string | null;
meetingPassword?: string; meetingPassword?: string | null;
meetingUrl?: string; meetingUrl?: string | null;
} }
interface GetLocationRequestFromIntegrationRequest { interface GetLocationRequestFromIntegrationRequest {
@ -78,55 +78,46 @@ export default class EventManager {
* Takes a CalendarEvent and creates all necessary integration entries for it. * 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 * When a video integration is chosen as the event's location, a video integration
* event will be scheduled for it as well. * event will be scheduled for it as well.
* An optional uid can be set to override the auto-generated uid.
* *
* @param event * @param event
* @param maybeUid
*/ */
public async create(event: CalendarEvent, maybeUid?: string): Promise<CreateUpdateResult> { public async create(event: CalendarEvent): Promise<CreateUpdateResult> {
event = EventManager.processLocation(event); let evt = EventManager.processLocation(event);
const isDedicated = EventManager.isDedicatedIntegration(event.location); const isDedicated = evt.location ? EventManager.isDedicatedIntegration(evt.location) : null;
let results: Array<EventResult> = []; let results: Array<EventResult> = [];
let optionalVideoCallData: VideoCallData | undefined = undefined;
// If and only if event type is a dedicated meeting, create a dedicated video meeting. // If and only if event type is a dedicated meeting, create a dedicated video meeting.
if (isDedicated) { if (isDedicated) {
const result = await this.createVideoEvent(event, maybeUid); const result = await this.createVideoEvent(evt);
if (result.videoCallData) { if (result.videoCallData) {
optionalVideoCallData = result.videoCallData; evt = { ...evt, videoCallData: result.videoCallData };
} }
results.push(result); results.push(result);
} else { } 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, // 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. // don't send a mail right here, because it has already been sent.
results = results.concat( results = results.concat(await this.createAllCalendarEvents(evt, isDedicated));
await this.createAllCalendarEvents(event, isDedicated, maybeUid, optionalVideoCallData)
);
const referencesToCreate: Array<PartialReference> = results.map((result: EventResult) => { const referencesToCreate: Array<PartialReference> = results.map((result: EventResult) => {
const isDailyResult = result.type === "daily"; const isDailyResult = result.type === "daily";
if (isDailyResult) { let uid = "";
return { if (isDailyResult && result.createdEvent) {
type: result.type, uid = result.createdEvent.name.toString();
uid: result.createdEvent.name.toString(),
meetingId: result.videoCallData?.id.toString(),
meetingPassword: result.videoCallData?.password,
meetingUrl: result.videoCallData?.url,
};
} }
if (!isDailyResult) { if (!isDailyResult && result.createdEvent) {
return { uid = result.createdEvent.id.toString();
type: result.type,
uid: result.createdEvent.id.toString(),
meetingId: result.videoCallData?.id.toString(),
meetingPassword: result.videoCallData?.password,
meetingUrl: result.videoCallData?.url,
};
} }
return {
type: result.type,
uid,
meetingId: result.videoCallData?.id.toString(),
meetingPassword: result.videoCallData?.password,
meetingUrl: result.videoCallData?.url,
};
}); });
return { return {
@ -140,15 +131,18 @@ export default class EventManager {
* given uid using the data delivered in the given CalendarEvent. * given uid using the data delivered in the given CalendarEvent.
* *
* @param event * @param event
* @param rescheduleUid
*/ */
public async update(event: CalendarEvent, rescheduleUid: string): Promise<CreateUpdateResult> { public async update(event: CalendarEvent): Promise<CreateUpdateResult> {
event = EventManager.processLocation(event); let evt = EventManager.processLocation(event);
if (!evt.uid) {
throw new Error("missing uid");
}
// Get details of existing booking. // Get details of existing booking.
const booking = await prisma.booking.findFirst({ const booking = await prisma.booking.findFirst({
where: { where: {
uid: rescheduleUid, uid: evt.uid,
}, },
select: { select: {
id: true, id: true,
@ -165,28 +159,30 @@ export default class EventManager {
}, },
}); });
const isDedicated = if (!booking) {
EventManager.isDedicatedIntegration(event.location) || event.location === dailyLocation; throw new Error("booking not found");
}
const isDedicated = evt.location
? EventManager.isDedicatedIntegration(evt.location) || evt.location === dailyLocation
: null;
let results: Array<EventResult> = []; let results: Array<EventResult> = [];
let optionalVideoCallData: VideoCallData | undefined = undefined;
// If and only if event type is a dedicated meeting, update the dedicated video meeting. // If and only if event type is a dedicated meeting, update the dedicated video meeting.
if (isDedicated) { if (isDedicated) {
const result = await this.updateVideoEvent(event, booking); const result = await this.updateVideoEvent(evt, booking);
if (result.videoCallData) { if (result.videoCallData) {
optionalVideoCallData = result.videoCallData; evt = { ...evt, videoCallData: result.videoCallData };
} }
results.push(result); results.push(result);
} else { } 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, // 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. // don't send a mail right here, because it has already been sent.
results = results.concat( results = results.concat(await this.updateAllCalendarEvents(evt, booking, isDedicated));
await this.updateAllCalendarEvents(event, booking, isDedicated, optionalVideoCallData)
);
// Now we can delete the old booking and its references. // Now we can delete the old booking and its references.
const bookingReferenceDeletes = prisma.bookingReference.deleteMany({ const bookingReferenceDeletes = prisma.bookingReference.deleteMany({
@ -199,11 +195,16 @@ export default class EventManager {
bookingId: booking.id, bookingId: booking.id,
}, },
}); });
const bookingDeletes = prisma.booking.delete({
where: { let bookingDeletes = null;
uid: rescheduleUid,
}, if (evt.uid) {
}); bookingDeletes = prisma.booking.delete({
where: {
uid: evt.uid,
},
});
}
// Wait for all deletions to be applied. // Wait for all deletions to be applied.
await Promise.all([bookingReferenceDeletes, attendeeDeletes, bookingDeletes]); await Promise.all([bookingReferenceDeletes, attendeeDeletes, bookingDeletes]);
@ -224,19 +225,12 @@ export default class EventManager {
* *
* @param event * @param event
* @param noMail * @param noMail
* @param maybeUid
* @param optionalVideoCallData
* @private * @private
*/ */
private createAllCalendarEvents( private createAllCalendarEvents(event: CalendarEvent, noMail: boolean | null): Promise<Array<EventResult>> {
event: CalendarEvent,
noMail: boolean,
maybeUid?: string,
optionalVideoCallData?: VideoCallData
): Promise<Array<EventResult>> {
return async.mapLimit(this.calendarCredentials, 5, async (credential: Credential) => { 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 { private getVideoCredential(event: CalendarEvent): Credential | undefined {
if (!event.location) {
return undefined;
}
const integrationName = event.location.replace("integrations:", ""); const integrationName = event.location.replace("integrations:", "");
return this.videoCredentials.find((credential: Credential) => credential.type.includes(integrationName)); 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. * When optional uid is set, it will be used instead of the auto generated uid.
* *
* @param event * @param event
* @param maybeUid
* @private * @private
*/ */
private createVideoEvent(event: CalendarEvent, maybeUid?: string): Promise<EventResult> { private createVideoEvent(event: CalendarEvent): Promise<EventResult> {
const credential = this.getVideoCredential(event); const credential = this.getVideoCredential(event);
const isDaily = event.location === dailyLocation; const isDaily = event.location === dailyLocation;
if (credential && !isDaily) { if (credential && !isDaily) {
return createMeeting(credential, event, maybeUid); return createMeeting(credential, event);
} else if (isDaily) { } else if (credential && isDaily) {
return dailyCreateMeeting(credential, event, maybeUid); return dailyCreateMeeting(credential, event);
} else { } else {
return Promise.reject("No suitable credentials given for the requested integration name."); return Promise.reject("No suitable credentials given for the requested integration name.");
} }
@ -289,13 +286,15 @@ export default class EventManager {
*/ */
private updateAllCalendarEvents( private updateAllCalendarEvents(
event: CalendarEvent, event: CalendarEvent,
booking: PartialBooking, booking: PartialBooking | null,
noMail: boolean, noMail: boolean | null
optionalVideoCallData?: VideoCallData
): Promise<Array<EventResult>> { ): Promise<Array<EventResult>> {
return async.mapLimit(this.calendarCredentials, 5, async (credential) => { return async.mapLimit(this.calendarCredentials, 5, async (credential) => {
const bookingRefUid = booking.references.filter((ref) => ref.type === credential.type)[0]?.uid; const bookingRefUid = booking
return updateEvent(credential, bookingRefUid, event, noMail, optionalVideoCallData); ? 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; const isDaily = event.location === dailyLocation;
if (credential && !isDaily) { if (credential && !isDaily) {
const bookingRef = booking.references.filter((ref) => ref.type === credential.type)[0]; const bookingRef = booking ? booking.references.filter((ref) => ref.type === credential.type)[0] : null;
const evt = { ...event, uid: bookingRef?.uid };
return updateMeeting(credential, bookingRef.uid, event).then((returnVal: EventResult) => { return updateMeeting(credential, evt).then((returnVal: EventResult) => {
// Some video integrations, such as Zoom, don't return any data about the booking when updating it. // Some video integrations, such as Zoom, don't return any data about the booking when updating it.
if (returnVal.videoCallData == undefined) { if (returnVal.videoCallData == undefined) {
returnVal.videoCallData = EventManager.bookingReferenceToVideoCallData(bookingRef); returnVal.videoCallData = EventManager.bookingReferenceToVideoCallData(bookingRef);
@ -321,9 +320,12 @@ export default class EventManager {
return returnVal; return returnVal;
}); });
} else { } else {
if (isDaily) { if (credential && isDaily) {
const bookingRefUid = booking.references.filter((ref) => ref.type === "daily")[0].uid; const bookingRefUid = booking
return dailyUpdateMeeting(credential, bookingRefUid, event); ? 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."); return Promise.reject("No suitable credentials given for the requested integration name.");
} }
@ -405,9 +407,15 @@ export default class EventManager {
* @param reference * @param reference
* @private * @private
*/ */
private static bookingReferenceToVideoCallData(reference: PartialReference): VideoCallData | undefined { private static bookingReferenceToVideoCallData(
reference: PartialReference | null
): VideoCallData | undefined {
let isComplete = true; let isComplete = true;
if (!reference) {
throw new Error("missing reference");
}
switch (reference.type) { switch (reference.type) {
case "zoom_video": case "zoom_video":
// Zoom meetings in our system should always have an ID, a password and a join URL. In the // 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 type
* @param results * @param results
* @param event * @param event
* @param maybeUid
* @private * @private
*/ */
private static async sendAttendeeMail( private static async sendAttendeeMail(
type: "new" | "reschedule", type: "new" | "reschedule",
results: Array<EventResult>, results: Array<EventResult>,
event: CalendarEvent, event: CalendarEvent
maybeUid?: string
) { ) {
if ( if (
!results.length || !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) { if (results.length) {
// TODO: Handle created event metadata more elegantly // TODO: Handle created event metadata more elegantly
metadata.hangoutLink = results[0].createdEvent?.hangoutLink; metadata.hangoutLink = results[0].createdEvent?.hangoutLink;
metadata.conferenceData = results[0].createdEvent?.conferenceData; metadata.conferenceData = results[0].createdEvent?.conferenceData;
metadata.entryPoints = results[0].createdEvent?.entryPoints; metadata.entryPoints = results[0].createdEvent?.entryPoints;
} }
const emailEvent = { ...event, additionInformation: metadata };
let attendeeMail; let attendeeMail;
switch (type) { switch (type) {
case "reschedule": case "reschedule":
attendeeMail = new EventAttendeeRescheduledMail(event, maybeUid, metadata); attendeeMail = new EventAttendeeRescheduledMail(emailEvent);
break; break;
case "new": case "new":
attendeeMail = new EventAttendeeMail(event, maybeUid, metadata); attendeeMail = new EventAttendeeMail(emailEvent);
break; break;
} }
try { try {

View file

@ -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. export const forgotPasswordMessageTemplate = (t: TFunction): string => {
{{link}} 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({ return buildMessageTemplate({
subjectTemplate: forgotPasswordSubjectTemplate, subjectTemplate: forgotPasswordSubjectTemplate(vars.language),
messageTemplate: forgotPasswordMessageTemplate, messageTemplate: forgotPasswordMessageTemplate(vars.language),
vars, vars,
}); });
}; };

View file

@ -15,6 +15,7 @@ export type BookingCreateBody = {
timeZone: string; timeZone: string;
users?: string[]; users?: string[];
user?: string; user?: string;
language: string;
}; };
export type BookingResponse = Booking & { export type BookingResponse = Booking & {

View file

@ -3,12 +3,11 @@ import short from "short-uuid";
import { v5 as uuidv5 } from "uuid"; import { v5 as uuidv5 } from "uuid";
import CalEventParser from "@lib/CalEventParser"; import CalEventParser from "@lib/CalEventParser";
import { AdditionInformation, EntryPoint } from "@lib/emails/EventMail";
import { getIntegrationName } from "@lib/emails/helpers"; import { getIntegrationName } from "@lib/emails/helpers";
import { EventResult } from "@lib/events/EventManager"; import { EventResult } from "@lib/events/EventManager";
import logger from "@lib/logger"; import logger from "@lib/logger";
import { CalendarEvent } from "./calendarClient"; import { CalendarEvent, AdditionInformation, EntryPoint } from "./calendarClient";
import EventAttendeeRescheduledMail from "./emails/EventAttendeeRescheduledMail"; import EventAttendeeRescheduledMail from "./emails/EventAttendeeRescheduledMail";
import EventOrganizerRescheduledMail from "./emails/EventOrganizerRescheduledMail"; import EventOrganizerRescheduledMail from "./emails/EventOrganizerRescheduledMail";
import VideoEventAttendeeMail from "./emails/VideoEventAttendeeMail"; import VideoEventAttendeeMail from "./emails/VideoEventAttendeeMail";
@ -34,7 +33,7 @@ export interface VideoCallData {
url: string; url: string;
} }
function handleErrorsJson(response) { function handleErrorsJson(response: Response) {
if (!response.ok) { if (!response.ok) {
response.json().then(console.log); response.json().then(console.log);
throw Error(response.statusText); throw Error(response.statusText);
@ -42,7 +41,7 @@ function handleErrorsJson(response) {
return response.json(); return response.json();
} }
function handleErrorsRaw(response) { function handleErrorsRaw(response: Response) {
if (!response.ok) { if (!response.ok) {
response.text().then(console.log); response.text().then(console.log);
throw Error(response.statusText); throw Error(response.statusText);
@ -216,12 +215,8 @@ const getBusyVideoTimes: (withCredentials: Credential[]) => Promise<unknown[]> =
results.reduce((acc, availability) => acc.concat(availability), []) results.reduce((acc, availability) => acc.concat(availability), [])
); );
const createMeeting = async ( const createMeeting = async (credential: Credential, calEvent: CalendarEvent): Promise<EventResult> => {
credential: Credential, const parser: CalEventParser = new CalEventParser(calEvent);
calEvent: CalendarEvent,
maybeUid?: string
): Promise<EventResult> => {
const parser: CalEventParser = new CalEventParser(calEvent, maybeUid);
const uid: string = parser.getUid(); const uid: string = parser.getUid();
if (!credential) { if (!credential) {
@ -249,7 +244,7 @@ const createMeeting = async (
const entryPoint: EntryPoint = { const entryPoint: EntryPoint = {
entryPointType: getIntegrationName(videoCallData), entryPointType: getIntegrationName(videoCallData),
uri: videoCallData.url, uri: videoCallData.url,
label: "Enter Meeting", label: calEvent.language("enter_meeting"),
pin: videoCallData.password, pin: videoCallData.password,
}; };
@ -257,9 +252,10 @@ const createMeeting = async (
entryPoints: [entryPoint], entryPoints: [entryPoint],
}; };
const organizerMail = new VideoEventOrganizerMail(calEvent, uid, videoCallData, additionInformation); const emailEvent = { ...calEvent, uid, additionInformation, videoCallData };
const attendeeMail = new VideoEventAttendeeMail(calEvent, uid, videoCallData, additionInformation);
try { try {
const organizerMail = new VideoEventOrganizerMail(emailEvent);
await organizerMail.sendEmail(); await organizerMail.sendEmail();
} catch (e) { } catch (e) {
console.error("organizerMail.sendEmail failed", e); console.error("organizerMail.sendEmail failed", e);
@ -267,6 +263,7 @@ const createMeeting = async (
if (!creationResult || !creationResult.disableConfirmationEmail) { if (!creationResult || !creationResult.disableConfirmationEmail) {
try { try {
const attendeeMail = new VideoEventAttendeeMail(emailEvent);
await attendeeMail.sendEmail(); await attendeeMail.sendEmail();
} catch (e) { } catch (e) {
console.error("attendeeMail.sendEmail failed", e); console.error("attendeeMail.sendEmail failed", e);
@ -283,11 +280,7 @@ const createMeeting = async (
}; };
}; };
const updateMeeting = async ( const updateMeeting = async (credential: Credential, calEvent: CalendarEvent): Promise<EventResult> => {
credential: Credential,
uidToUpdate: string,
calEvent: CalendarEvent
): Promise<EventResult> => {
const newUid: string = translator.fromUUID(uuidv5(JSON.stringify(calEvent), uuidv5.URL)); const newUid: string = translator.fromUUID(uuidv5(JSON.stringify(calEvent), uuidv5.URL));
if (!credential) { if (!credential) {
@ -298,18 +291,20 @@ const updateMeeting = async (
let success = true; let success = true;
const updateResult = credential const updateResult =
? await videoIntegrations([credential])[0] credential && calEvent.uid
.updateMeeting(uidToUpdate, calEvent) ? await videoIntegrations([credential])[0]
.catch((e) => { .updateMeeting(calEvent.uid, calEvent)
log.error("updateMeeting failed", e, calEvent); .catch((e) => {
success = false; log.error("updateMeeting failed", e, calEvent);
}) success = false;
: null; })
: null;
const emailEvent = { ...calEvent, uid: newUid };
const organizerMail = new EventOrganizerRescheduledMail(calEvent, newUid);
const attendeeMail = new EventAttendeeRescheduledMail(calEvent, newUid);
try { try {
const organizerMail = new EventOrganizerRescheduledMail(emailEvent);
await organizerMail.sendEmail(); await organizerMail.sendEmail();
} catch (e) { } catch (e) {
console.error("organizerMail.sendEmail failed", e); console.error("organizerMail.sendEmail failed", e);
@ -317,6 +312,7 @@ const updateMeeting = async (
if (!updateResult || !updateResult.disableConfirmationEmail) { if (!updateResult || !updateResult.disableConfirmationEmail) {
try { try {
const attendeeMail = new EventAttendeeRescheduledMail(emailEvent);
await attendeeMail.sendEmail(); await attendeeMail.sendEmail();
} catch (e) { } catch (e) {
console.error("attendeeMail.sendEmail failed", e); console.error("attendeeMail.sendEmail failed", e);

View file

@ -1,17 +1,21 @@
import { User, ResetPasswordRequest } from "@prisma/client"; import { ResetPasswordRequest } from "@prisma/client";
import dayjs from "dayjs"; import dayjs from "dayjs";
import timezone from "dayjs/plugin/timezone"; import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc"; import utc from "dayjs/plugin/utc";
import { NextApiRequest, NextApiResponse } from "next"; import { NextApiRequest, NextApiResponse } from "next";
import sendEmail from "../../../lib/emails/sendMail"; import sendEmail from "@lib/emails/sendMail";
import { buildForgotPasswordMessage } from "../../../lib/forgot-password/messaging/forgot-password"; import { buildForgotPasswordMessage } from "@lib/forgot-password/messaging/forgot-password";
import prisma from "../../../lib/prisma"; import prisma from "@lib/prisma";
import { getTranslation } from "@server/lib/i18n";
dayjs.extend(utc); dayjs.extend(utc);
dayjs.extend(timezone); dayjs.extend(timezone);
export default async function handler(req: NextApiRequest, res: NextApiResponse) { export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const t = await getTranslation(req.body.language ?? "en", "common");
if (req.method !== "POST") { if (req.method !== "POST") {
return res.status(405).json({ message: "" }); return res.status(405).json({ message: "" });
} }
@ -19,7 +23,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
try { try {
const rawEmail = req.body?.email; const rawEmail = req.body?.email;
const maybeUser: User = await prisma.user.findUnique({ const maybeUser = await prisma.user.findUnique({
where: { where: {
email: rawEmail, 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 passwordResetLink = `${process.env.BASE_URL}/auth/forgot-password/${passwordRequest.id}`;
const { subject, message } = buildForgotPasswordMessage({ const { subject, message } = buildForgotPasswordMessage({
language: t,
user: { user: {
name: maybeUser.name, name: maybeUser.name,
}, },

View file

@ -6,12 +6,15 @@ import { getSession } from "@lib/auth";
import { CalendarEvent } from "@lib/calendarClient"; import { CalendarEvent } from "@lib/calendarClient";
import EventRejectionMail from "@lib/emails/EventRejectionMail"; import EventRejectionMail from "@lib/emails/EventRejectionMail";
import EventManager from "@lib/events/EventManager"; 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<void> { export default async function handler(req: NextApiRequest, res: NextApiResponse): Promise<void> {
const t = await getTranslation(req.body.language ?? "en", "common");
const session = await getSession({ req: req }); const session = await getSession({ req: req });
if (!session) { if (!session?.user?.id) {
return res.status(401).json({ message: "Not authenticated" }); 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") { if (req.method == "PATCH") {
const booking = await prisma.booking.findFirst({ const booking = await prisma.booking.findFirst({
where: { where: {
@ -66,14 +73,16 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
description: booking.description, description: booking.description,
startTime: booking.startTime.toISOString(), startTime: booking.startTime.toISOString(),
endTime: booking.endTime.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, attendees: booking.attendees,
location: booking.location, location: booking.location,
uid: booking.uid,
language: t,
}; };
if (req.body.confirmed) { if (req.body.confirmed) {
const eventManager = new EventManager(currentUser.credentials); const eventManager = new EventManager(currentUser.credentials);
const scheduleResult = await eventManager.create(evt, booking.uid); const scheduleResult = await eventManager.create(evt);
await prisma.booking.update({ await prisma.booking.update({
where: { where: {
@ -99,8 +108,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
rejected: true, rejected: true,
}, },
}); });
const attendeeMail = new EventRejectionMail(evt, booking.uid); const attendeeMail = new EventRejectionMail(evt);
await attendeeMail.sendEmail(); await attendeeMail.sendEmail();
res.status(204).json({ message: "ok" }); res.status(204).json({ message: "ok" });
} }
} }

View file

@ -6,7 +6,6 @@ import isBetween from "dayjs/plugin/isBetween";
import timezone from "dayjs/plugin/timezone"; import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc"; import utc from "dayjs/plugin/utc";
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import { getErrorFromUnknown } from "pages/_error";
import short from "short-uuid"; import short from "short-uuid";
import { v5 as uuidv5 } from "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 { CalendarEvent, getBusyCalendarTimes } from "@lib/calendarClient";
import EventOrganizerRequestMail from "@lib/emails/EventOrganizerRequestMail"; import EventOrganizerRequestMail from "@lib/emails/EventOrganizerRequestMail";
import { getErrorFromUnknown } from "@lib/errors";
import { getEventName } from "@lib/event"; import { getEventName } from "@lib/event";
import EventManager, { CreateUpdateResult, EventResult, PartialReference } from "@lib/events/EventManager"; import EventManager, { CreateUpdateResult, EventResult, PartialReference } from "@lib/events/EventManager";
import logger from "@lib/logger"; import logger from "@lib/logger";
@ -23,6 +23,8 @@ import { getBusyVideoTimes } from "@lib/videoClient";
import sendPayload from "@lib/webhooks/sendPayload"; import sendPayload from "@lib/webhooks/sendPayload";
import getSubscriberUrls from "@lib/webhooks/subscriberUrls"; import getSubscriberUrls from "@lib/webhooks/subscriberUrls";
import { getTranslation } from "@server/lib/i18n";
export interface DailyReturnType { export interface DailyReturnType {
name: string; name: string;
url: string; url: string;
@ -126,6 +128,7 @@ function isOutOfBounds(
export default async function handler(req: NextApiRequest, res: NextApiResponse) { export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const reqBody = req.body as BookingCreateBody; const reqBody = req.body as BookingCreateBody;
const eventTypeId = reqBody.eventTypeId; const eventTypeId = reqBody.eventTypeId;
const t = await getTranslation(reqBody.language ?? "en", "common");
log.debug(`Booking eventType ${eventTypeId} started`); log.debug(`Booking eventType ${eventTypeId} started`);
@ -273,6 +276,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}, },
attendees: attendeesList, attendees: attendeesList,
location: reqBody.location, // Will be processed by the EventManager later. location: reqBody.location, // Will be processed by the EventManager later.
language: t,
uid,
}; };
if (eventType.schedulingType === SchedulingType.COLLECTIVE) { if (eventType.schedulingType === SchedulingType.COLLECTIVE) {
@ -425,7 +430,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
if (rescheduleUid) { if (rescheduleUid) {
// Use EventManager to conditionally use all needed integrations. // 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; results = updateResults.results;
referencesToCreate = updateResults.referencesToCreate; referencesToCreate = updateResults.referencesToCreate;
@ -440,7 +446,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
} }
} else if (!eventType.requiresConfirmation && !eventType.price) { } else if (!eventType.requiresConfirmation && !eventType.price) {
// Use EventManager to conditionally use all needed integrations. // 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; results = createResults.results;
referencesToCreate = createResults.referencesToCreate; referencesToCreate = createResults.referencesToCreate;
@ -496,7 +502,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
} }
if (eventType.requiresConfirmation && !rescheduleUid) { if (eventType.requiresConfirmation && !rescheduleUid) {
await new EventOrganizerRequestMail(evt, uid).sendEmail(); await new EventOrganizerRequestMail({ ...evt, uid }).sendEmail();
} }
if (typeof eventType.price === "number" && eventType.price > 0) { if (typeof eventType.price === "number" && eventType.price > 0) {

View file

@ -2,11 +2,14 @@ import { randomBytes } from "crypto";
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import { getSession } from "@lib/auth"; import { getSession } from "@lib/auth";
import { createInvitationEmail } from "@lib/emails/invitation";
import prisma from "@lib/prisma";
import { createInvitationEmail } from "../../../../lib/emails/invitation"; import { getTranslation } from "@server/lib/i18n";
import prisma from "../../../../lib/prisma";
export default async function handler(req: NextApiRequest, res: NextApiResponse) { export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const t = await getTranslation(req.body.language ?? "en", "common");
if (req.method !== "POST") { if (req.method !== "POST") {
return res.status(400).json({ message: "Bad request" }); 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({ const team = await prisma.team.findFirst({
where: { 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({ if (session?.user?.name && team?.name) {
toEmail: req.body.usernameOrEmail, createInvitationEmail({
from: session.user.name, language: t,
teamName: team.name, toEmail: req.body.usernameOrEmail,
token, from: session.user.name,
}); teamName: team.name,
token,
});
}
return res.status(201).json({}); return res.status(201).json({});
} }
@ -87,7 +93,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
role: req.body.role, role: req.body.role,
}, },
}); });
} catch (err) { } catch (err: any) {
if (err.code === "P2002") { if (err.code === "P2002") {
// unique constraint violation // unique constraint violation
return res.status(409).json({ return res.status(409).json({
@ -99,8 +105,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
} }
// inform user of membership by email // inform user of membership by email
if (req.body.sendEmailInvitation) { if (req.body.sendEmailInvitation && session?.user?.name && team?.name) {
createInvitationEmail({ createInvitationEmail({
language: t,
toEmail: invitee.email, toEmail: invitee.email,
from: session.user.name, from: session.user.name,
teamName: team.name, teamName: team.name,

View file

@ -1,29 +1,31 @@
import debounce from "lodash/debounce"; import debounce from "lodash/debounce";
import { GetServerSidePropsContext } from "next";
import { getCsrfToken } from "next-auth/client"; import { getCsrfToken } from "next-auth/client";
import Link from "next/link"; import Link from "next/link";
import React from "react"; import React, { SyntheticEvent } from "react";
import { getSession } from "@lib/auth"; import { getSession } from "@lib/auth";
import { useLocale } from "@lib/hooks/useLocale"; import { useLocale } from "@lib/hooks/useLocale";
import { HeadSeo } from "@components/seo/head-seo"; import { HeadSeo } from "@components/seo/head-seo";
export default function ForgotPassword({ csrfToken }) { export default function ForgotPassword({ csrfToken }: { csrfToken: string }) {
const { t } = useLocale(); const { t, i18n } = useLocale();
const [loading, setLoading] = React.useState(false); 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 [success, setSuccess] = React.useState(false);
const [email, setEmail] = React.useState(""); const [email, setEmail] = React.useState("");
const handleChange = (e) => { const handleChange = (e: SyntheticEvent) => {
setEmail(e.target.value); const target = e.target as typeof e.target & { value: string };
setEmail(target.value);
}; };
const submitForgotPasswordRequest = async ({ email }) => { const submitForgotPasswordRequest = async ({ email }: { email: string }) => {
try { try {
const res = await fetch("/api/auth/forgot-password", { const res = await fetch("/api/auth/forgot-password", {
method: "POST", method: "POST",
body: JSON.stringify({ email: email }), body: JSON.stringify({ email: email, language: i18n.language }),
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
@ -46,7 +48,7 @@ export default function ForgotPassword({ csrfToken }) {
const debouncedHandleSubmitPasswordRequest = debounce(submitForgotPasswordRequest, 250); const debouncedHandleSubmitPasswordRequest = debounce(submitForgotPasswordRequest, 250);
const handleSubmit = async (e) => { const handleSubmit = async (e: SyntheticEvent) => {
e.preventDefault(); e.preventDefault();
if (!email) { 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 { req, res } = context;
const session = await getSession({ req }); const session = await getSession({ req });

View file

@ -63,32 +63,35 @@ import * as RadioArea from "@components/ui/form/radio-area";
dayjs.extend(utc); dayjs.extend(utc);
dayjs.extend(timezone); 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<typeof getServerSideProps>) => { const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
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 } = const { eventType, locationOptions, availability, team, teamMembers, hasPaymentIntegration, currency } =
props; 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 router = useRouter();
const updateMutation = useMutation(updateEventType, { const updateMutation = useMutation(updateEventType, {
onSuccess: async ({ eventType }) => { onSuccess: async ({ eventType }) => {
await router.push("/event-types"); 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) => { onError: (err: HttpError) => {
const message = `${err.statusCode}: ${err.message}`; const message = `${err.statusCode}: ${err.message}`;
@ -99,7 +102,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
const deleteMutation = useMutation(deleteEventType, { const deleteMutation = useMutation(deleteEventType, {
onSuccess: async () => { onSuccess: async () => {
await router.push("/event-types"); await router.push("/event-types");
showToast("Event type deleted successfully", "success"); showToast(t("event_type_deleted_successfully"), "success");
}, },
onError: (err: HttpError) => { onError: (err: HttpError) => {
const message = `${err.statusCode}: ${err.message}`; const message = `${err.statusCode}: ${err.message}`;
@ -274,13 +277,13 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
const schedulingTypeOptions: { value: SchedulingType; label: string; description: string }[] = [ const schedulingTypeOptions: { value: SchedulingType; label: string; description: string }[] = [
{ {
value: SchedulingType.COLLECTIVE, value: SchedulingType.COLLECTIVE,
label: "Collective", label: t("collective"),
description: "Schedule meetings when all selected team members are available.", description: t("collective_description"),
}, },
{ {
value: SchedulingType.ROUND_ROBIN, value: SchedulingType.ROUND_ROBIN,
label: "Round Robin", label: t("round_robin"),
description: "Cycle meetings between multiple team members.", description: t("round_robin_description"),
}, },
]; ];
@ -311,7 +314,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
<div> <div>
<Shell <Shell
centered centered
title={`${eventType.title} | Event Type`} title={t("event_type_title", { eventTypeTitle: eventType.title })}
heading={ heading={
<div className="relative -mb-2 group" onClick={() => setEditIcon(false)}> <div className="relative -mb-2 group" onClick={() => setEditIcon(false)}>
<input <input
@ -321,7 +324,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
id="title" id="title"
required 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" 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} defaultValue={eventType.title}
/> />
{editIcon && ( {editIcon && (
@ -491,7 +494,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
fillRule="evenodd"></path> fillRule="evenodd"></path>
</g> </g>
</svg> </svg>
<span className="ml-2 text-sm"> Daily.co Video</span> <span className="ml-2 text-sm">Daily.co Video</span>
</div> </div>
)} )}
{location.type === LocationType.Zoom && ( {location.type === LocationType.Zoom && (
@ -682,7 +685,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
</div> </div>
<div> <div>
<span className="ml-2 text-sm"> <span className="ml-2 text-sm">
{customInput.required ? "Required" : "Optional"} {customInput.required ? t("required") : t("optional")}
</span> </span>
</div> </div>
</div> </div>
@ -1228,10 +1231,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
const integrations = getIntegrations(credentials); const integrations = getIntegrations(credentials);
const locationOptions: OptionTypeBase[] = [ const locationOptions: OptionTypeBase[] = [];
{ value: LocationType.InPerson, label: "Link or In-person meeting" },
{ value: LocationType.Phone, label: "Phone call" },
];
if (hasIntegration(integrations, "zoom_video")) { if (hasIntegration(integrations, "zoom_video")) {
locationOptions.push({ value: LocationType.Zoom, label: "Zoom Video", disabled: true }); locationOptions.push({ value: LocationType.Zoom, label: "Zoom Video", disabled: true });

View file

@ -344,7 +344,7 @@ const CreateNewEventButton = ({ profiles, canAddEvents }: CreateEventTypeProps)
const createMutation = useMutation(createEventType, { const createMutation = useMutation(createEventType, {
onSuccess: async ({ eventType }) => { onSuccess: async ({ eventType }) => {
await router.push("/event-types/" + eventType.id); 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) => { onError: (err: HttpError) => {
const message = `${err.statusCode}: ${err.message}`; const message = `${err.statusCode}: ${err.message}`;

View file

@ -66,8 +66,10 @@ describe("webhooks", () => {
attendee.timeZone = dynamic; attendee.timeZone = dynamic;
} }
body.payload.organizer.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` // if we change the shape of our webhooks, we can simply update this by clicking `u`
console.log("BODY", body);
expect(body).toMatchInlineSnapshot(` expect(body).toMatchInlineSnapshot(`
Object { Object {
"createdAt": "[redacted/dynamic]", "createdAt": "[redacted/dynamic]",
@ -89,6 +91,7 @@ describe("webhooks", () => {
"startTime": "[redacted/dynamic]", "startTime": "[redacted/dynamic]",
"title": "30min with Test Testson", "title": "30min with Test Testson",
"type": "30min", "type": "30min",
"uid": "[redacted/dynamic]",
}, },
"triggerEvent": "BOOKING_CREATED", "triggerEvent": "BOOKING_CREATED",
} }

View file

@ -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.", "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", "confirm_delete_webhook": "Yes, delete webhook",
"edit_webhook": "Edit Webhook", "edit_webhook": "Edit Webhook",
@ -36,6 +75,7 @@
"number": "Number", "number": "Number",
"checkbox": "Checkbox", "checkbox": "Checkbox",
"is_required": "Is required", "is_required": "Is required",
"required": "Required",
"input_type": "Input type", "input_type": "Input type",
"rejected": "Rejected", "rejected": "Rejected",
"unconfirmed": "Unconfirmed", "unconfirmed": "Unconfirmed",
@ -351,6 +391,8 @@
"new_team_event": "Add a new team event type", "new_team_event": "Add a new team event type",
"new_event_description": "Create a new event type for people to book times with.", "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_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", "hours": "Hours",
"your_email": "Your Email", "your_email": "Your Email",
"change_avatar": "Change Avatar", "change_avatar": "Change Avatar",
@ -418,7 +460,6 @@
"cal_provide_video_meeting_url": "Cal will provide a Daily video meeting URL.", "cal_provide_video_meeting_url": "Cal will provide a Daily video meeting URL.",
"require_payment": "Require Payment", "require_payment": "Require Payment",
"commission_per_transaction": "commission per transaction", "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.", "event_type_updated_successfully_description": "Your event type has been updated successfully.",
"hide_event_type": "Hide event type", "hide_event_type": "Hide event type",
"edit_location": "Edit location", "edit_location": "Edit location",

18
server/lib/i18n.ts Normal file
View file

@ -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);
};

View file

@ -2,22 +2,26 @@ import { expect, it } from "@jest/globals";
import { html, text, Invitation } from "@lib/emails/invitation"; 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", () => { it("email text rendering should strip tags and add new lines", () => {
const result = text("<p>hello world</p><br /><div>welcome to the brave <span>new</span> world"); const result = text("<p>hello world</p><br /><div>welcome to the brave <span>new</span> world");
expect(result).toEqual("hello world\nwelcome 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 = { const invitation = {
language: t,
from: "Huxley", from: "Huxley",
toEmail: "hello@example.com", toEmail: "hello@example.com",
teamName: "Calendar Lovers", teamName: "Calendar Lovers",
token: "invite-token", token: "invite-token",
} as Invitation; } as Invitation;
const result = html(invitation); const result = html(invitation);
expect(result).toContain('<br />Huxley invited you to join the team "Calendar Lovers" in Cal.com.<br />');
expect(result).toContain("/auth/signup?token=invite-token&");
expect(result).toContain( 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.' `<br />${t("user_invited_you", { user: invitation.from, teamName: invitation.teamName })}<br />`
); );
expect(result).toContain("/auth/signup?token=invite-token&");
expect(result).toContain(`${t("request_another_invitation_email", { toEmail: invitation.toEmail })}`);
}); });

View file

@ -1,13 +1,25 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "es5", "target": "es5",
"lib": ["dom", "dom.iterable", "esnext"], "lib": [
"dom",
"dom.iterable",
"esnext"
],
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"@components/*": ["components/*"], "@components/*": [
"@lib/*": ["lib/*"], "components/*"
"@server/*": ["server/*"], ],
"@ee/*": ["ee/*"] "@lib/*": [
"lib/*"
],
"@server/*": [
"server/*"
],
"@ee/*": [
"ee/*"
]
}, },
"skipLibCheck": true, "skipLibCheck": true,
"strict": true, "strict": true,
@ -21,8 +33,19 @@
"isolatedModules": true, "isolatedModules": true,
"useUnknownInCatchVariables": true, "useUnknownInCatchVariables": true,
"jsx": "preserve", "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"], "include": [
"exclude": ["node_modules"] "next-env.d.ts",
"**/*.ts",
"**/*.tsx"
],
"exclude": [
"node_modules"
]
} }