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:
parent
356d470e16
commit
8d6fec79d3
35 changed files with 680 additions and 461 deletions
|
@ -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",
|
||||
},
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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", {
|
||||
|
|
|
@ -13,6 +13,7 @@ const config: Config.InitialOptions = {
|
|||
moduleNameMapper: {
|
||||
"^@components(.*)$": "<rootDir>/components$1",
|
||||
"^@lib(.*)$": "<rootDir>/lib$1",
|
||||
"^@server(.*)$": "<rootDir>/server$1",
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 "";
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 />
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 />
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
};
|
||||
|
|
|
@ -15,6 +15,7 @@ export type BookingCreateBody = {
|
|||
timeZone: string;
|
||||
users?: string[];
|
||||
user?: string;
|
||||
language: string;
|
||||
};
|
||||
|
||||
export type BookingResponse = Booking & {
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
|
|
|
@ -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" });
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 });
|
||||
|
||||
|
|
|
@ -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 });
|
||||
|
|
|
@ -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}`;
|
||||
|
|
|
@ -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",
|
||||
}
|
||||
|
|
|
@ -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
18
server/lib/i18n.ts
Normal 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);
|
||||
};
|
|
@ -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 })}`);
|
||||
});
|
||||
|
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue