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];
function BookingListItem(booking: BookingItem) {
const { t } = useLocale();
const { t, i18n } = useLocale();
const utils = trpc.useContext();
const mutation = useMutation(
async (confirm: boolean) => {
const res = await fetch("/api/book/confirm", {
method: "PATCH",
body: JSON.stringify({ id: booking.id, confirmed: confirm }),
body: JSON.stringify({ id: booking.id, confirmed: confirm, language: i18n.language }),
headers: {
"Content-Type": "application/json",
},

View file

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

View file

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

View file

@ -13,6 +13,7 @@ const config: Config.InitialOptions = {
moduleNameMapper: {
"^@components(.*)$": "<rootDir>/components$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 { getIntegrationName } from "@lib/integrations";
import { VideoCallData } from "@lib/videoClient";
import { CalendarEvent } from "./calendarClient";
import { stripHtml } from "./emails/helpers";
@ -11,13 +10,9 @@ const translator = short();
export default class CalEventParser {
protected calEvent: CalendarEvent;
protected maybeUid?: string;
protected optionalVideoCallData?: VideoCallData;
constructor(calEvent: CalendarEvent, maybeUid?: string, optionalVideoCallData?: VideoCallData) {
constructor(calEvent: CalendarEvent) {
this.calEvent = calEvent;
this.maybeUid = maybeUid;
this.optionalVideoCallData = optionalVideoCallData;
}
/**
@ -38,14 +33,22 @@ export default class CalEventParser {
* Returns a unique identifier for the given calendar event.
*/
public getUid(): string {
return this.maybeUid ?? translator.fromUUID(uuidv5(JSON.stringify(this.calEvent), uuidv5.URL));
return this.calEvent.uid ?? translator.fromUUID(uuidv5(JSON.stringify(this.calEvent), uuidv5.URL));
}
/**
* Returns a footer section with links to change the event (as HTML).
*/
public getChangeEventFooterHtml(): string {
return `<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.
return (
`
<strong>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("event_type")}:</strong><br />${this.calEvent.type}<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()
? `<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>Additional notes:</strong><br />${this.getDescriptionText()}<br />` +
`<strong>${this.calEvent.language("invitee_timezone")}:</strong><br />${
this.calEvent.attendees[0].timeZone
}<br />
<strong>${this.calEvent.language("additional_notes")}:</strong><br />${this.getDescriptionText()}<br />` +
this.getChangeEventFooterHtml()
);
}
@ -83,10 +90,10 @@ export default class CalEventParser {
* For Daily video calls returns the direct link
* @protected
*/
protected getLocation(): string | undefined {
protected getLocation(): string | null | undefined {
const isDaily = this.calEvent.location === "integrations:daily";
if (this.optionalVideoCallData) {
return this.optionalVideoCallData.url;
if (this.calEvent.videoCallData) {
return this.calEvent.videoCallData.url;
}
if (isDaily) {
return process.env.BASE_URL + "/call/" + this.getUid();
@ -100,12 +107,14 @@ export default class CalEventParser {
*
* @protected
*/
protected getDescriptionText(): string | undefined {
if (this.optionalVideoCallData) {
protected getDescriptionText(): string | null | undefined {
if (this.calEvent.videoCallData) {
return `
${getIntegrationName(this.optionalVideoCallData.type)} meeting
ID: ${this.optionalVideoCallData.id}
Password: ${this.optionalVideoCallData.password}
${this.calEvent.language("integration_meeting_id", {
integrationName: getIntegrationName(this.calEvent.videoCallData.type),
meetingId: this.calEvent.videoCallData.id,
})}
${this.calEvent.language("password")}: ${this.calEvent.videoCallData.password}
${this.calEvent.description}`;
}
return this.calEvent.description;

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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 { getFormattedMeetingId, getIntegrationName } from "./helpers";
export default class VideoEventAttendeeMail extends EventAttendeeMail {
videoCallData: VideoCallData;
constructor(
calEvent: CalendarEvent,
uid: string,
videoCallData: VideoCallData,
additionInformation: AdditionInformation = null
) {
super(calEvent, uid);
this.videoCallData = videoCallData;
this.additionInformation = additionInformation;
}
/**
* Adds the video call information to the mail body.
*
* @protected
*/
protected getAdditionalBody(): string {
const meetingPassword = this.videoCallData.password;
const meetingId = getFormattedMeetingId(this.videoCallData);
if (!this.calEvent.videoCallData) {
return "";
}
const meetingPassword = this.calEvent.videoCallData.password;
const meetingId = getFormattedMeetingId(this.calEvent.videoCallData);
if (meetingId && meetingPassword) {
return `
<strong>Video call provider:</strong> ${getIntegrationName(this.videoCallData)}<br />
<strong>Meeting ID:</strong> ${getFormattedMeetingId(this.videoCallData)}<br />
<strong>Meeting Password:</strong> ${this.videoCallData.password}<br />
<strong>Meeting URL:</strong> <a href="${this.videoCallData.url}">${this.videoCallData.url}</a><br />
<strong>${this.calEvent.language("video_call_provider")}:</strong> ${getIntegrationName(
this.calEvent.videoCallData
)}<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 `
<strong>Video call provider:</strong> ${getIntegrationName(this.videoCallData)}<br />
<strong>Meeting URL:</strong> <a href="${this.videoCallData.url}">${this.videoCallData.url}</a><br />
<strong>${this.calEvent.language("video_call_provider")}:</strong> ${getIntegrationName(
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 { getFormattedMeetingId, getIntegrationName } from "./helpers";
export default class VideoEventOrganizerMail extends EventOrganizerMail {
videoCallData: VideoCallData;
constructor(
calEvent: CalendarEvent,
uid: string,
videoCallData: VideoCallData,
additionInformation: AdditionInformation = null
) {
super(calEvent, uid);
this.videoCallData = videoCallData;
this.additionInformation = additionInformation;
}
/**
* Adds the video call information to the mail body
* and calendar event description.
@ -26,20 +9,33 @@ export default class VideoEventOrganizerMail extends EventOrganizerMail {
* @protected
*/
protected getAdditionalBody(): string {
const meetingPassword = this.videoCallData.password;
const meetingId = getFormattedMeetingId(this.videoCallData);
if (!this.calEvent.videoCallData) {
return "";
}
const meetingPassword = this.calEvent.videoCallData.password;
const meetingId = getFormattedMeetingId(this.calEvent.videoCallData);
// This odd indentation is necessary because otherwise the leading tabs will be applied into the event description.
if (meetingPassword && meetingId) {
return `
<strong>Video call provider:</strong> ${getIntegrationName(this.videoCallData)}<br />
<strong>Meeting ID:</strong> ${getFormattedMeetingId(this.videoCallData)}<br />
<strong>Meeting Password:</strong> ${this.videoCallData.password}<br />
<strong>Meeting URL:</strong> <a href="${this.videoCallData.url}">${this.videoCallData.url}</a><br />
<strong>${this.calEvent.language("video_call_provider")}:</strong> ${getIntegrationName(
this.calEvent.videoCallData
)}<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 `
<strong>Video call provider:</strong> ${getIntegrationName(this.videoCallData)}<br />
<strong>Meeting URL:</strong> <a href="${this.videoCallData.url}">${this.videoCallData.url}</a><br />
<strong>${this.calEvent.language("video_call_provider")}:</strong> ${getIntegrationName(
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 { TFunction } from "next-i18next";
export type VarType = {
language: TFunction;
user: {
name: string | null;
};
link: string;
};
export type MessageTemplateTypes = {
messageTemplate: string;
subjectTemplate: string;
vars: VarType;
};
export type BuildTemplateResult = {
subject: string;
message: string;
};
export const buildMessageTemplate = ({
messageTemplate,
subjectTemplate,
vars,
}): { subject: string; message: string } => {
}: MessageTemplateTypes): BuildTemplateResult => {
const buildMessage = Handlebars.compile(messageTemplate);
const message = buildMessage(vars);
const buildSubject = Handlebars.compile(subjectTemplate);
const subject = buildSubject(vars);
return {
subject,
message,

View file

@ -1,8 +1,12 @@
import { TFunction } from "next-i18next";
import nodemailer from "nodemailer";
import { getErrorFromUnknown } from "@lib/errors";
import { serverConfig } from "../serverConfig";
export type Invitation = {
language: TFunction;
from?: string;
toEmail: string;
teamName: string;
@ -26,21 +30,24 @@ const sendEmail = (invitation: Invitation, provider: EmailProvider): Promise<voi
new Promise((resolve, reject) => {
const { transport, from } = provider;
const { language: t } = invitation;
const invitationHtml = html(invitation);
nodemailer.createTransport(transport).sendMail(
{
from: `Cal.com <${from}>`,
to: invitation.toEmail,
subject:
(invitation.from ? invitation.from + " invited you" : "You have been invited") +
` to join ${invitation.teamName}`,
subject: invitation.from
? t("user_invited_you", { user: invitation.from, teamName: invitation.teamName })
: t("you_have_been_invited", { teamName: invitation.teamName }),
html: invitationHtml,
text: text(invitationHtml),
},
(error) => {
if (error) {
console.error("SEND_INVITATION_NOTIFICATION_ERROR", invitation.toEmail, error);
return reject(new Error(error));
(_err) => {
if (_err) {
const err = getErrorFromUnknown(_err);
console.error("SEND_INVITATION_NOTIFICATION_ERROR", invitation.toEmail, err);
reject(err);
return;
}
return resolve();
}
@ -48,6 +55,7 @@ const sendEmail = (invitation: Invitation, provider: EmailProvider): Promise<voi
});
export function html(invitation: Invitation): string {
const { language: t } = invitation;
let url: string = process.env.BASE_URL + "/settings/teams";
if (invitation.token) {
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;">
<tr>
<td>
Hi,<br />
${t("hi")},<br />
<br />` +
(invitation.from ? invitation.from + " invited you" : "You have been invited") +
` to join the team "${invitation.teamName}" in Cal.com.<br />
(invitation.from
? t("user_invited_you", { user: invitation.from, teamName: invitation.teamName })
: t("you_have_been_invited", { teamName: invitation.teamName })) +
`<br />
<br />
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;line-height:100%;">
<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>
</v:roundrect>
<![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>
</div>
</td>
</tr>
</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>
</tr>
</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) {
return cause;
}

View file

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

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.
{{link}}
export const forgotPasswordMessageTemplate = (t: TFunction): string => {
const text = `${t("hey_there")}
p.s. It expires in 6 hours.
${t("use_link_to_reset_password")}
{{link}}
- Cal.com`;
${t("link_expires", { expiresIn: 6 })}
export const buildForgotPasswordMessage = (vars) => {
- Cal.com`;
return text;
};
export const buildForgotPasswordMessage = (vars: VarType) => {
return buildMessageTemplate({
subjectTemplate: forgotPasswordSubjectTemplate,
messageTemplate: forgotPasswordMessageTemplate,
subjectTemplate: forgotPasswordSubjectTemplate(vars.language),
messageTemplate: forgotPasswordMessageTemplate(vars.language),
vars,
});
};

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -63,32 +63,35 @@ import * as RadioArea from "@components/ui/form/radio-area";
dayjs.extend(utc);
dayjs.extend(timezone);
const PERIOD_TYPES = [
{
type: "rolling",
suffix: "into the future",
},
{
type: "range",
prefix: "Within a date range",
},
{
type: "unlimited",
prefix: "Indefinitely into the future",
},
];
const EventTypePage = (props: inferSSRProps<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 } =
props;
locationOptions.push(
{ value: LocationType.InPerson, label: t("in_person_meeting") },
{ value: LocationType.Phone, label: t("phone_call") }
);
const { t } = useLocale();
const router = useRouter();
const updateMutation = useMutation(updateEventType, {
onSuccess: async ({ eventType }) => {
await router.push("/event-types");
showToast(`${eventType.title} event type updated successfully`, "success");
showToast(t("event_type_updated_successfully", { eventTypeTitle: eventType.title }), "success");
},
onError: (err: HttpError) => {
const message = `${err.statusCode}: ${err.message}`;
@ -99,7 +102,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
const deleteMutation = useMutation(deleteEventType, {
onSuccess: async () => {
await router.push("/event-types");
showToast("Event type deleted successfully", "success");
showToast(t("event_type_deleted_successfully"), "success");
},
onError: (err: HttpError) => {
const message = `${err.statusCode}: ${err.message}`;
@ -274,13 +277,13 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
const schedulingTypeOptions: { value: SchedulingType; label: string; description: string }[] = [
{
value: SchedulingType.COLLECTIVE,
label: "Collective",
description: "Schedule meetings when all selected team members are available.",
label: t("collective"),
description: t("collective_description"),
},
{
value: SchedulingType.ROUND_ROBIN,
label: "Round Robin",
description: "Cycle meetings between multiple team members.",
label: t("round_robin"),
description: t("round_robin_description"),
},
];
@ -311,7 +314,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
<div>
<Shell
centered
title={`${eventType.title} | Event Type`}
title={t("event_type_title", { eventTypeTitle: eventType.title })}
heading={
<div className="relative -mb-2 group" onClick={() => setEditIcon(false)}>
<input
@ -321,7 +324,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
id="title"
required
className="w-full pl-0 text-xl font-bold text-gray-900 bg-transparent border-none cursor-pointer focus:text-black hover:text-gray-700 focus:ring-0 focus:outline-none"
placeholder="Quick Chat"
placeholder={t("quick_chat")}
defaultValue={eventType.title}
/>
{editIcon && (
@ -491,7 +494,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
fillRule="evenodd"></path>
</g>
</svg>
<span className="ml-2 text-sm"> Daily.co Video</span>
<span className="ml-2 text-sm">Daily.co Video</span>
</div>
)}
{location.type === LocationType.Zoom && (
@ -682,7 +685,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
</div>
<div>
<span className="ml-2 text-sm">
{customInput.required ? "Required" : "Optional"}
{customInput.required ? t("required") : t("optional")}
</span>
</div>
</div>
@ -1228,10 +1231,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
const integrations = getIntegrations(credentials);
const locationOptions: OptionTypeBase[] = [
{ value: LocationType.InPerson, label: "Link or In-person meeting" },
{ value: LocationType.Phone, label: "Phone call" },
];
const locationOptions: OptionTypeBase[] = [];
if (hasIntegration(integrations, "zoom_video")) {
locationOptions.push({ value: LocationType.Zoom, label: "Zoom Video", disabled: true });

View file

@ -344,7 +344,7 @@ const CreateNewEventButton = ({ profiles, canAddEvents }: CreateEventTypeProps)
const createMutation = useMutation(createEventType, {
onSuccess: async ({ eventType }) => {
await router.push("/event-types/" + eventType.id);
showToast(`${eventType.title} event type created successfully`, "success");
showToast(t("event_type_created_successfully", { eventTypeTitle: eventType.title }), "success");
},
onError: (err: HttpError) => {
const message = `${err.statusCode}: ${err.message}`;

View file

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

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

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 { getTranslation } from "@server/lib/i18n";
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");
expect(result).toEqual("hello world\nwelcome to the brave new world");
});
it("email html should render invite email", () => {
it("email html should render invite email", async () => {
const t = await getTranslation("en", "common");
const invitation = {
language: t,
from: "Huxley",
toEmail: "hello@example.com",
teamName: "Calendar Lovers",
token: "invite-token",
} as Invitation;
const result = html(invitation);
expect(result).toContain('<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(
'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": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"baseUrl": ".",
"paths": {
"@components/*": ["components/*"],
"@lib/*": ["lib/*"],
"@server/*": ["server/*"],
"@ee/*": ["ee/*"]
"@components/*": [
"components/*"
],
"@lib/*": [
"lib/*"
],
"@server/*": [
"server/*"
],
"@ee/*": [
"ee/*"
]
},
"skipLibCheck": true,
"strict": true,
@ -21,8 +33,19 @@
"isolatedModules": true,
"useUnknownInCatchVariables": true,
"jsx": "preserve",
"types": ["@types/jest", "jest-playwright-preset", "expect-playwright"]
"types": [
"@types/jest",
"jest-playwright-preset",
"expect-playwright"
],
"allowJs": true
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx"
],
"exclude": [
"node_modules"
]
}