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];
|
type BookingItem = inferQueryOutput<"viewer.bookings">[number];
|
||||||
|
|
||||||
function BookingListItem(booking: BookingItem) {
|
function BookingListItem(booking: BookingItem) {
|
||||||
const { t } = useLocale();
|
const { t, i18n } = useLocale();
|
||||||
const utils = trpc.useContext();
|
const utils = trpc.useContext();
|
||||||
|
|
||||||
const mutation = useMutation(
|
const mutation = useMutation(
|
||||||
async (confirm: boolean) => {
|
async (confirm: boolean) => {
|
||||||
const res = await fetch("/api/book/confirm", {
|
const res = await fetch("/api/book/confirm", {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: JSON.stringify({ id: booking.id, confirmed: confirm }),
|
body: JSON.stringify({ id: booking.id, confirmed: confirm, language: i18n.language }),
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
|
|
|
@ -36,7 +36,7 @@ import { TeamBookingPageProps } from "../../../pages/team/[slug]/book";
|
||||||
type BookingPageProps = BookPageProps | TeamBookingPageProps;
|
type BookingPageProps = BookPageProps | TeamBookingPageProps;
|
||||||
|
|
||||||
const BookingPage = (props: BookingPageProps) => {
|
const BookingPage = (props: BookingPageProps) => {
|
||||||
const { t } = useLocale();
|
const { t, i18n } = useLocale();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { rescheduleUid } = router.query;
|
const { rescheduleUid } = router.query;
|
||||||
const { isReady } = useTheme(props.profile.theme);
|
const { isReady } = useTheme(props.profile.theme);
|
||||||
|
@ -109,6 +109,7 @@ const BookingPage = (props: BookingPageProps) => {
|
||||||
guests: guestEmails,
|
guests: guestEmails,
|
||||||
eventTypeId: props.eventType.id,
|
eventTypeId: props.eventType.id,
|
||||||
timeZone: timeZone(),
|
timeZone: timeZone(),
|
||||||
|
language: i18n.language,
|
||||||
};
|
};
|
||||||
if (typeof rescheduleUid === "string") payload.rescheduleUid = rescheduleUid;
|
if (typeof rescheduleUid === "string") payload.rescheduleUid = rescheduleUid;
|
||||||
if (typeof router.query.user === "string") payload.user = router.query.user;
|
if (typeof router.query.user === "string") payload.user = router.query.user;
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { UsersIcon } from "@heroicons/react/outline";
|
import { UsersIcon } from "@heroicons/react/outline";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import React, { SyntheticEvent } from "react";
|
||||||
|
|
||||||
import { useLocale } from "@lib/hooks/useLocale";
|
import { useLocale } from "@lib/hooks/useLocale";
|
||||||
import { Team } from "@lib/team";
|
import { Team } from "@lib/team";
|
||||||
|
@ -8,7 +9,7 @@ import Button from "@components/ui/Button";
|
||||||
|
|
||||||
export default function MemberInvitationModal(props: { team: Team | undefined | null; onExit: () => void }) {
|
export default function MemberInvitationModal(props: { team: Team | undefined | null; onExit: () => void }) {
|
||||||
const [errorMessage, setErrorMessage] = useState("");
|
const [errorMessage, setErrorMessage] = useState("");
|
||||||
const { t } = useLocale();
|
const { t, i18n } = useLocale();
|
||||||
|
|
||||||
const handleError = async (res: Response) => {
|
const handleError = async (res: Response) => {
|
||||||
const responseData = await res.json();
|
const responseData = await res.json();
|
||||||
|
@ -21,13 +22,22 @@ export default function MemberInvitationModal(props: { team: Team | undefined |
|
||||||
return responseData;
|
return responseData;
|
||||||
};
|
};
|
||||||
|
|
||||||
const inviteMember = (e) => {
|
const inviteMember = (e: SyntheticEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
const target = e.target as typeof e.target & {
|
||||||
|
elements: {
|
||||||
|
role: { value: string };
|
||||||
|
inviteUser: { value: string };
|
||||||
|
sendInviteEmail: { checked: boolean };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
role: e.target.elements["role"].value,
|
language: i18n.language,
|
||||||
usernameOrEmail: e.target.elements["inviteUser"].value,
|
role: target.elements["role"].value,
|
||||||
sendEmailInvitation: e.target.elements["sendInviteEmail"].checked,
|
usernameOrEmail: target.elements["inviteUser"].value,
|
||||||
|
sendEmailInvitation: target.elements["sendInviteEmail"].checked,
|
||||||
};
|
};
|
||||||
|
|
||||||
return fetch("/api/teams/" + props?.team?.id + "/invite", {
|
return fetch("/api/teams/" + props?.team?.id + "/invite", {
|
||||||
|
|
|
@ -13,6 +13,7 @@ const config: Config.InitialOptions = {
|
||||||
moduleNameMapper: {
|
moduleNameMapper: {
|
||||||
"^@components(.*)$": "<rootDir>/components$1",
|
"^@components(.*)$": "<rootDir>/components$1",
|
||||||
"^@lib(.*)$": "<rootDir>/lib$1",
|
"^@lib(.*)$": "<rootDir>/lib$1",
|
||||||
|
"^@server(.*)$": "<rootDir>/server$1",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -2,7 +2,6 @@ import short from "short-uuid";
|
||||||
import { v5 as uuidv5 } from "uuid";
|
import { v5 as uuidv5 } from "uuid";
|
||||||
|
|
||||||
import { getIntegrationName } from "@lib/integrations";
|
import { getIntegrationName } from "@lib/integrations";
|
||||||
import { VideoCallData } from "@lib/videoClient";
|
|
||||||
|
|
||||||
import { CalendarEvent } from "./calendarClient";
|
import { CalendarEvent } from "./calendarClient";
|
||||||
import { stripHtml } from "./emails/helpers";
|
import { stripHtml } from "./emails/helpers";
|
||||||
|
@ -11,13 +10,9 @@ const translator = short();
|
||||||
|
|
||||||
export default class CalEventParser {
|
export default class CalEventParser {
|
||||||
protected calEvent: CalendarEvent;
|
protected calEvent: CalendarEvent;
|
||||||
protected maybeUid?: string;
|
|
||||||
protected optionalVideoCallData?: VideoCallData;
|
|
||||||
|
|
||||||
constructor(calEvent: CalendarEvent, maybeUid?: string, optionalVideoCallData?: VideoCallData) {
|
constructor(calEvent: CalendarEvent) {
|
||||||
this.calEvent = calEvent;
|
this.calEvent = calEvent;
|
||||||
this.maybeUid = maybeUid;
|
|
||||||
this.optionalVideoCallData = optionalVideoCallData;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -38,14 +33,22 @@ export default class CalEventParser {
|
||||||
* Returns a unique identifier for the given calendar event.
|
* Returns a unique identifier for the given calendar event.
|
||||||
*/
|
*/
|
||||||
public getUid(): string {
|
public getUid(): string {
|
||||||
return this.maybeUid ?? translator.fromUUID(uuidv5(JSON.stringify(this.calEvent), uuidv5.URL));
|
return this.calEvent.uid ?? translator.fromUUID(uuidv5(JSON.stringify(this.calEvent), uuidv5.URL));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a footer section with links to change the event (as HTML).
|
* Returns a footer section with links to change the event (as HTML).
|
||||||
*/
|
*/
|
||||||
public getChangeEventFooterHtml(): string {
|
public getChangeEventFooterHtml(): string {
|
||||||
return `<p style="color: #4b5563; margin-top: 20px;">Need to make a change? <a href="${this.getCancelLink()}" style="color: #161e2e;">Cancel</a> or <a href="${this.getRescheduleLink()}" style="color: #161e2e;">reschedule</a></p>`;
|
return `<p style="color: #4b5563; margin-top: 20px;">${this.calEvent.language(
|
||||||
|
"need_to_make_a_change"
|
||||||
|
)} <a href="${this.getCancelLink()}" style="color: #161e2e;">${this.calEvent.language(
|
||||||
|
"cancel"
|
||||||
|
)}</a> ${this.calEvent
|
||||||
|
.language("or")
|
||||||
|
.toLowerCase()} <a href="${this.getRescheduleLink()}" style="color: #161e2e;">${this.calEvent.language(
|
||||||
|
"reschedule"
|
||||||
|
)}</a></p>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -64,15 +67,19 @@ export default class CalEventParser {
|
||||||
// This odd indentation is necessary because otherwise the leading tabs will be applied into the event description.
|
// This odd indentation is necessary because otherwise the leading tabs will be applied into the event description.
|
||||||
return (
|
return (
|
||||||
`
|
`
|
||||||
<strong>Event Type:</strong><br />${this.calEvent.type}<br />
|
<strong>${this.calEvent.language("event_type")}:</strong><br />${this.calEvent.type}<br />
|
||||||
<strong>Invitee Email:</strong><br /><a href="mailto:${this.calEvent.attendees[0].email}">${this.calEvent.attendees[0].email}</a><br />
|
<strong>${this.calEvent.language("invitee_email")}:</strong><br /><a href="mailto:${
|
||||||
|
this.calEvent.attendees[0].email
|
||||||
|
}">${this.calEvent.attendees[0].email}</a><br />
|
||||||
` +
|
` +
|
||||||
(this.getLocation()
|
(this.getLocation()
|
||||||
? `<strong>Location:</strong><br />${this.getLocation()}<br />
|
? `<strong>${this.calEvent.language("location")}:</strong><br />${this.getLocation()}<br />
|
||||||
`
|
`
|
||||||
: "") +
|
: "") +
|
||||||
`<strong>Invitee Time Zone:</strong><br />${this.calEvent.attendees[0].timeZone}<br />
|
`<strong>${this.calEvent.language("invitee_timezone")}:</strong><br />${
|
||||||
<strong>Additional notes:</strong><br />${this.getDescriptionText()}<br />` +
|
this.calEvent.attendees[0].timeZone
|
||||||
|
}<br />
|
||||||
|
<strong>${this.calEvent.language("additional_notes")}:</strong><br />${this.getDescriptionText()}<br />` +
|
||||||
this.getChangeEventFooterHtml()
|
this.getChangeEventFooterHtml()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -83,10 +90,10 @@ export default class CalEventParser {
|
||||||
* For Daily video calls returns the direct link
|
* For Daily video calls returns the direct link
|
||||||
* @protected
|
* @protected
|
||||||
*/
|
*/
|
||||||
protected getLocation(): string | undefined {
|
protected getLocation(): string | null | undefined {
|
||||||
const isDaily = this.calEvent.location === "integrations:daily";
|
const isDaily = this.calEvent.location === "integrations:daily";
|
||||||
if (this.optionalVideoCallData) {
|
if (this.calEvent.videoCallData) {
|
||||||
return this.optionalVideoCallData.url;
|
return this.calEvent.videoCallData.url;
|
||||||
}
|
}
|
||||||
if (isDaily) {
|
if (isDaily) {
|
||||||
return process.env.BASE_URL + "/call/" + this.getUid();
|
return process.env.BASE_URL + "/call/" + this.getUid();
|
||||||
|
@ -100,12 +107,14 @@ export default class CalEventParser {
|
||||||
*
|
*
|
||||||
* @protected
|
* @protected
|
||||||
*/
|
*/
|
||||||
protected getDescriptionText(): string | undefined {
|
protected getDescriptionText(): string | null | undefined {
|
||||||
if (this.optionalVideoCallData) {
|
if (this.calEvent.videoCallData) {
|
||||||
return `
|
return `
|
||||||
${getIntegrationName(this.optionalVideoCallData.type)} meeting
|
${this.calEvent.language("integration_meeting_id", {
|
||||||
ID: ${this.optionalVideoCallData.id}
|
integrationName: getIntegrationName(this.calEvent.videoCallData.type),
|
||||||
Password: ${this.optionalVideoCallData.password}
|
meetingId: this.calEvent.videoCallData.id,
|
||||||
|
})}
|
||||||
|
${this.calEvent.language("password")}: ${this.calEvent.videoCallData.password}
|
||||||
${this.calEvent.description}`;
|
${this.calEvent.description}`;
|
||||||
}
|
}
|
||||||
return this.calEvent.description;
|
return this.calEvent.description;
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import { Credential } from "@prisma/client";
|
import { Credential } from "@prisma/client";
|
||||||
|
import { TFunction } from "next-i18next";
|
||||||
|
|
||||||
import { EventResult } from "@lib/events/EventManager";
|
import { EventResult } from "@lib/events/EventManager";
|
||||||
import logger from "@lib/logger";
|
import logger from "@lib/logger";
|
||||||
|
@ -54,7 +55,7 @@ const googleAuth = (credential) => {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
function handleErrorsJson(response) {
|
function handleErrorsJson(response: Response) {
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
response.json().then((e) => console.error("O365 Error", e));
|
response.json().then((e) => console.error("O365 Error", e));
|
||||||
throw Error(response.statusText);
|
throw Error(response.statusText);
|
||||||
|
@ -62,7 +63,7 @@ function handleErrorsJson(response) {
|
||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleErrorsRaw(response) {
|
function handleErrorsRaw(response: Response) {
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
response.text().then((e) => console.error("O365 Error", e));
|
response.text().then((e) => console.error("O365 Error", e));
|
||||||
throw Error(response.statusText);
|
throw Error(response.statusText);
|
||||||
|
@ -112,20 +113,41 @@ const o365Auth = (credential) => {
|
||||||
|
|
||||||
export type Person = { name: string; email: string; timeZone: string };
|
export type Person = { name: string; email: string; timeZone: string };
|
||||||
|
|
||||||
|
export interface EntryPoint {
|
||||||
|
entryPointType?: string;
|
||||||
|
uri?: string;
|
||||||
|
label?: string;
|
||||||
|
pin?: string;
|
||||||
|
accessCode?: string;
|
||||||
|
meetingCode?: string;
|
||||||
|
passcode?: string;
|
||||||
|
password?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdditionInformation {
|
||||||
|
conferenceData?: ConferenceData;
|
||||||
|
entryPoints?: EntryPoint[];
|
||||||
|
hangoutLink?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CalendarEvent {
|
export interface CalendarEvent {
|
||||||
type: string;
|
type: string;
|
||||||
title: string;
|
title: string;
|
||||||
startTime: string;
|
startTime: string;
|
||||||
endTime: string;
|
endTime: string;
|
||||||
description?: string;
|
description?: string | null;
|
||||||
team?: {
|
team?: {
|
||||||
name: string;
|
name: string;
|
||||||
members: string[];
|
members: string[];
|
||||||
};
|
};
|
||||||
location?: string;
|
location?: string | null;
|
||||||
organizer: Person;
|
organizer: Person;
|
||||||
attendees: Person[];
|
attendees: Person[];
|
||||||
conferenceData?: ConferenceData;
|
conferenceData?: ConferenceData;
|
||||||
|
language: TFunction;
|
||||||
|
additionInformation?: AdditionInformation;
|
||||||
|
uid?: string | null;
|
||||||
|
videoCallData?: VideoCallData;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConferenceData {
|
export interface ConferenceData {
|
||||||
|
@ -143,9 +165,9 @@ type BufferedBusyTime = { start: string; end: string };
|
||||||
export interface CalendarApiAdapter {
|
export interface CalendarApiAdapter {
|
||||||
createEvent(event: CalendarEvent): Promise<unknown>;
|
createEvent(event: CalendarEvent): Promise<unknown>;
|
||||||
|
|
||||||
updateEvent(uid: string, event: CalendarEvent);
|
updateEvent(uid: string, event: CalendarEvent): Promise<any>;
|
||||||
|
|
||||||
deleteEvent(uid: string);
|
deleteEvent(uid: string): Promise<unknown>;
|
||||||
|
|
||||||
getAvailability(
|
getAvailability(
|
||||||
dateFrom: string,
|
dateFrom: string,
|
||||||
|
@ -574,11 +596,9 @@ const listCalendars = (withCredentials) =>
|
||||||
const createEvent = async (
|
const createEvent = async (
|
||||||
credential: Credential,
|
credential: Credential,
|
||||||
calEvent: CalendarEvent,
|
calEvent: CalendarEvent,
|
||||||
noMail = false,
|
noMail: boolean | null = false
|
||||||
maybeUid?: string,
|
|
||||||
optionalVideoCallData?: VideoCallData
|
|
||||||
): Promise<EventResult> => {
|
): Promise<EventResult> => {
|
||||||
const parser: CalEventParser = new CalEventParser(calEvent, maybeUid, optionalVideoCallData);
|
const parser: CalEventParser = new CalEventParser(calEvent);
|
||||||
const uid: string = parser.getUid();
|
const uid: string = parser.getUid();
|
||||||
/*
|
/*
|
||||||
* Matching the credential type is a workaround because the office calendar simply strips away newlines (\n and \r).
|
* Matching the credential type is a workaround because the office calendar simply strips away newlines (\n and \r).
|
||||||
|
@ -589,7 +609,7 @@ const createEvent = async (
|
||||||
|
|
||||||
let success = true;
|
let success = true;
|
||||||
|
|
||||||
const creationResult = credential
|
const creationResult: any = credential
|
||||||
? await calendars([credential])[0]
|
? await calendars([credential])[0]
|
||||||
.createEvent(richEvent)
|
.createEvent(richEvent)
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
|
@ -598,16 +618,18 @@ const createEvent = async (
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const maybeHangoutLink = creationResult?.hangoutLink;
|
const metadata: AdditionInformation = {};
|
||||||
const maybeEntryPoints = creationResult?.entryPoints;
|
if (creationResult) {
|
||||||
const maybeConferenceData = creationResult?.conferenceData;
|
// TODO: Handle created event metadata more elegantly
|
||||||
|
metadata.hangoutLink = creationResult.hangoutLink;
|
||||||
|
metadata.conferenceData = creationResult.conferenceData;
|
||||||
|
metadata.entryPoints = creationResult.entryPoints;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emailEvent = { ...calEvent, additionInformation: metadata };
|
||||||
|
|
||||||
if (!noMail) {
|
if (!noMail) {
|
||||||
const organizerMail = new EventOrganizerMail(calEvent, uid, {
|
const organizerMail = new EventOrganizerMail(emailEvent);
|
||||||
hangoutLink: maybeHangoutLink,
|
|
||||||
conferenceData: maybeConferenceData,
|
|
||||||
entryPoints: maybeEntryPoints,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await organizerMail.sendEmail();
|
await organizerMail.sendEmail();
|
||||||
|
@ -627,28 +649,28 @@ const createEvent = async (
|
||||||
|
|
||||||
const updateEvent = async (
|
const updateEvent = async (
|
||||||
credential: Credential,
|
credential: Credential,
|
||||||
uidToUpdate: string,
|
|
||||||
calEvent: CalendarEvent,
|
calEvent: CalendarEvent,
|
||||||
noMail = false,
|
noMail: boolean | null = false
|
||||||
optionalVideoCallData?: VideoCallData
|
|
||||||
): Promise<EventResult> => {
|
): Promise<EventResult> => {
|
||||||
const parser: CalEventParser = new CalEventParser(calEvent, undefined, optionalVideoCallData);
|
const parser: CalEventParser = new CalEventParser(calEvent);
|
||||||
const newUid: string = parser.getUid();
|
const newUid: string = parser.getUid();
|
||||||
const richEvent: CalendarEvent = parser.asRichEventPlain();
|
const richEvent: CalendarEvent = parser.asRichEventPlain();
|
||||||
|
|
||||||
let success = true;
|
let success = true;
|
||||||
|
|
||||||
const updateResult = credential
|
const updateResult =
|
||||||
? await calendars([credential])[0]
|
credential && calEvent.uid
|
||||||
.updateEvent(uidToUpdate, richEvent)
|
? await calendars([credential])[0]
|
||||||
.catch((e) => {
|
.updateEvent(calEvent.uid, richEvent)
|
||||||
log.error("updateEvent failed", e, calEvent);
|
.catch((e) => {
|
||||||
success = false;
|
log.error("updateEvent failed", e, calEvent);
|
||||||
})
|
success = false;
|
||||||
: null;
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
if (!noMail) {
|
if (!noMail) {
|
||||||
const organizerMail = new EventOrganizerRescheduledMail(calEvent, newUid);
|
const emailEvent = { ...calEvent, uid: newUid };
|
||||||
|
const organizerMail = new EventOrganizerRescheduledMail(emailEvent);
|
||||||
try {
|
try {
|
||||||
await organizerMail.sendEmail();
|
await organizerMail.sendEmail();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
|
@ -3,12 +3,11 @@ import short from "short-uuid";
|
||||||
import { v5 as uuidv5 } from "uuid";
|
import { v5 as uuidv5 } from "uuid";
|
||||||
|
|
||||||
import CalEventParser from "@lib/CalEventParser";
|
import CalEventParser from "@lib/CalEventParser";
|
||||||
import { AdditionInformation, EntryPoint } from "@lib/emails/EventMail";
|
|
||||||
import { getIntegrationName } from "@lib/emails/helpers";
|
import { getIntegrationName } from "@lib/emails/helpers";
|
||||||
import { EventResult } from "@lib/events/EventManager";
|
import { EventResult } from "@lib/events/EventManager";
|
||||||
import logger from "@lib/logger";
|
import logger from "@lib/logger";
|
||||||
|
|
||||||
import { CalendarEvent } from "./calendarClient";
|
import { CalendarEvent, AdditionInformation, EntryPoint } from "./calendarClient";
|
||||||
import EventAttendeeRescheduledMail from "./emails/EventAttendeeRescheduledMail";
|
import EventAttendeeRescheduledMail from "./emails/EventAttendeeRescheduledMail";
|
||||||
import EventOrganizerRescheduledMail from "./emails/EventOrganizerRescheduledMail";
|
import EventOrganizerRescheduledMail from "./emails/EventOrganizerRescheduledMail";
|
||||||
import VideoEventAttendeeMail from "./emails/VideoEventAttendeeMail";
|
import VideoEventAttendeeMail from "./emails/VideoEventAttendeeMail";
|
||||||
|
@ -25,7 +24,7 @@ export interface DailyVideoCallData {
|
||||||
url: string;
|
url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleErrorsJson(response) {
|
function handleErrorsJson(response: Response) {
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
response.json().then(console.log);
|
response.json().then(console.log);
|
||||||
throw Error(response.statusText);
|
throw Error(response.statusText);
|
||||||
|
@ -38,14 +37,14 @@ const dailyCredential = process.env.DAILY_API_KEY;
|
||||||
interface DailyVideoApiAdapter {
|
interface DailyVideoApiAdapter {
|
||||||
dailyCreateMeeting(event: CalendarEvent): Promise<any>;
|
dailyCreateMeeting(event: CalendarEvent): Promise<any>;
|
||||||
|
|
||||||
dailyUpdateMeeting(uid: string, event: CalendarEvent);
|
dailyUpdateMeeting(uid: string, event: CalendarEvent): Promise<any>;
|
||||||
|
|
||||||
dailyDeleteMeeting(uid: string): Promise<unknown>;
|
dailyDeleteMeeting(uid: string): Promise<unknown>;
|
||||||
|
|
||||||
getAvailability(dateFrom, dateTo): Promise<any>;
|
getAvailability(dateFrom, dateTo): Promise<any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DailyVideo = (credential): DailyVideoApiAdapter => {
|
const DailyVideo = (credential: Credential): DailyVideoApiAdapter => {
|
||||||
const translateEvent = (event: CalendarEvent) => {
|
const translateEvent = (event: CalendarEvent) => {
|
||||||
// Documentation at: https://docs.daily.co/reference#list-rooms
|
// Documentation at: https://docs.daily.co/reference#list-rooms
|
||||||
// added a 1 hour buffer for room expiration and room entry
|
// added a 1 hour buffer for room expiration and room entry
|
||||||
|
@ -110,12 +109,8 @@ const getBusyVideoTimes: (withCredentials) => Promise<unknown[]> = (withCredenti
|
||||||
results.reduce((acc, availability) => acc.concat(availability), [])
|
results.reduce((acc, availability) => acc.concat(availability), [])
|
||||||
);
|
);
|
||||||
|
|
||||||
const dailyCreateMeeting = async (
|
const dailyCreateMeeting = async (credential: Credential, calEvent: CalendarEvent): Promise<EventResult> => {
|
||||||
credential: Credential,
|
const parser: CalEventParser = new CalEventParser(calEvent);
|
||||||
calEvent: CalendarEvent,
|
|
||||||
maybeUid: string = null
|
|
||||||
): Promise<EventResult> => {
|
|
||||||
const parser: CalEventParser = new CalEventParser(calEvent, maybeUid);
|
|
||||||
const uid: string = parser.getUid();
|
const uid: string = parser.getUid();
|
||||||
|
|
||||||
if (!credential) {
|
if (!credential) {
|
||||||
|
@ -145,18 +140,17 @@ const dailyCreateMeeting = async (
|
||||||
const entryPoint: EntryPoint = {
|
const entryPoint: EntryPoint = {
|
||||||
entryPointType: getIntegrationName(videoCallData),
|
entryPointType: getIntegrationName(videoCallData),
|
||||||
uri: videoCallData.url,
|
uri: videoCallData.url,
|
||||||
label: "Enter Meeting",
|
label: calEvent.language("enter_meeting"),
|
||||||
pin: "",
|
pin: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
const additionInformation: AdditionInformation = {
|
const additionInformation: AdditionInformation = {
|
||||||
entryPoints: [entryPoint],
|
entryPoints: [entryPoint],
|
||||||
};
|
};
|
||||||
|
const emailEvent = { ...calEvent, uid, additionInformation, videoCallData };
|
||||||
const organizerMail = new VideoEventOrganizerMail(calEvent, uid, videoCallData, additionInformation);
|
|
||||||
const attendeeMail = new VideoEventAttendeeMail(calEvent, uid, videoCallData, additionInformation);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const organizerMail = new VideoEventOrganizerMail(emailEvent);
|
||||||
await organizerMail.sendEmail();
|
await organizerMail.sendEmail();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("organizerMail.sendEmail failed", e);
|
console.error("organizerMail.sendEmail failed", e);
|
||||||
|
@ -164,6 +158,7 @@ const dailyCreateMeeting = async (
|
||||||
|
|
||||||
if (!creationResult || !creationResult.disableConfirmationEmail) {
|
if (!creationResult || !creationResult.disableConfirmationEmail) {
|
||||||
try {
|
try {
|
||||||
|
const attendeeMail = new VideoEventAttendeeMail(emailEvent);
|
||||||
await attendeeMail.sendEmail();
|
await attendeeMail.sendEmail();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("attendeeMail.sendEmail failed", e);
|
console.error("attendeeMail.sendEmail failed", e);
|
||||||
|
@ -179,11 +174,7 @@ const dailyCreateMeeting = async (
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const dailyUpdateMeeting = async (
|
const dailyUpdateMeeting = async (credential: Credential, calEvent: CalendarEvent): Promise<EventResult> => {
|
||||||
credential: Credential,
|
|
||||||
uidToUpdate: string,
|
|
||||||
calEvent: CalendarEvent
|
|
||||||
): Promise<EventResult> => {
|
|
||||||
const newUid: string = translator.fromUUID(uuidv5(JSON.stringify(calEvent), uuidv5.URL));
|
const newUid: string = translator.fromUUID(uuidv5(JSON.stringify(calEvent), uuidv5.URL));
|
||||||
|
|
||||||
if (!credential) {
|
if (!credential) {
|
||||||
|
@ -194,18 +185,20 @@ const dailyUpdateMeeting = async (
|
||||||
|
|
||||||
let success = true;
|
let success = true;
|
||||||
|
|
||||||
const updateResult = credential
|
const updateResult =
|
||||||
? await videoIntegrations([credential])[0]
|
credential && calEvent.uid
|
||||||
.dailyUpdateMeeting(uidToUpdate, calEvent)
|
? await videoIntegrations([credential])[0]
|
||||||
.catch((e) => {
|
.dailyUpdateMeeting(calEvent.uid, calEvent)
|
||||||
log.error("updateMeeting failed", e, calEvent);
|
.catch((e) => {
|
||||||
success = false;
|
log.error("updateMeeting failed", e, calEvent);
|
||||||
})
|
success = false;
|
||||||
: null;
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const emailEvent = { ...calEvent, uid: newUid };
|
||||||
|
|
||||||
const organizerMail = new EventOrganizerRescheduledMail(calEvent, newUid);
|
|
||||||
const attendeeMail = new EventAttendeeRescheduledMail(calEvent, newUid);
|
|
||||||
try {
|
try {
|
||||||
|
const organizerMail = new EventOrganizerRescheduledMail(emailEvent);
|
||||||
await organizerMail.sendEmail();
|
await organizerMail.sendEmail();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("organizerMail.sendEmail failed", e);
|
console.error("organizerMail.sendEmail failed", e);
|
||||||
|
@ -213,6 +206,7 @@ const dailyUpdateMeeting = async (
|
||||||
|
|
||||||
if (!updateResult || !updateResult.disableConfirmationEmail) {
|
if (!updateResult || !updateResult.disableConfirmationEmail) {
|
||||||
try {
|
try {
|
||||||
|
const attendeeMail = new EventAttendeeRescheduledMail(emailEvent);
|
||||||
await attendeeMail.sendEmail();
|
await attendeeMail.sendEmail();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("attendeeMail.sendEmail failed", e);
|
console.error("attendeeMail.sendEmail failed", e);
|
||||||
|
|
|
@ -45,8 +45,10 @@ export default class EventAttendeeMail extends EventMail {
|
||||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<h1 style="font-weight: 500; color: #161e2e;">Your meeting has been booked</h1>
|
<h1 style="font-weight: 500; color: #161e2e;">${this.calEvent.language(
|
||||||
<p style="color: #4b5563; margin-bottom: 30px;">You and any other attendees have been emailed with this information.</p>
|
"your_meeting_has_been_booked"
|
||||||
|
)}</h1>
|
||||||
|
<p style="color: #4b5563; margin-bottom: 30px;">${this.calEvent.language("emailed_you_and_attendees")}</p>
|
||||||
<hr />
|
<hr />
|
||||||
<table style="border-spacing: 20px; color: #161e2e; margin-bottom: 10px;">
|
<table style="border-spacing: 20px; color: #161e2e; margin-bottom: 10px;">
|
||||||
<colgroup>
|
<colgroup>
|
||||||
|
@ -54,17 +56,17 @@ export default class EventAttendeeMail extends EventMail {
|
||||||
<col span="1" style="width: 60%;">
|
<col span="1" style="width: 60%;">
|
||||||
</colgroup>
|
</colgroup>
|
||||||
<tr>
|
<tr>
|
||||||
<td>What</td>
|
<td>${this.calEvent.language("what")}</td>
|
||||||
<td>${this.calEvent.type}</td>
|
<td>${this.calEvent.type}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>When</td>
|
<td>${this.calEvent.language("when")}</td>
|
||||||
<td>${this.getInviteeStart().format("dddd, LL")}<br>${this.getInviteeStart().format("h:mma")} (${
|
<td>${this.getInviteeStart().format("dddd, LL")}<br>${this.getInviteeStart().format("h:mma")} (${
|
||||||
this.calEvent.attendees[0].timeZone
|
this.calEvent.attendees[0].timeZone
|
||||||
})</td>
|
})</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Who</td>
|
<td>${this.calEvent.language("who")}</td>
|
||||||
<td>
|
<td>
|
||||||
${this.calEvent.team?.name || this.calEvent.organizer.name}<br />
|
${this.calEvent.team?.name || this.calEvent.organizer.name}<br />
|
||||||
<small>
|
<small>
|
||||||
|
@ -74,11 +76,11 @@ export default class EventAttendeeMail extends EventMail {
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Where</td>
|
<td>${this.calEvent.language("where")}</td>
|
||||||
<td>${this.getLocation()}</td>
|
<td>${this.getLocation()}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Notes</td>
|
<td>${this.calEvent.language("notes")}</td>
|
||||||
<td>${this.calEvent.description}</td>
|
<td>${this.calEvent.description}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
@ -104,15 +106,18 @@ export default class EventAttendeeMail extends EventMail {
|
||||||
* @protected
|
* @protected
|
||||||
*/
|
*/
|
||||||
protected getLocation(): string {
|
protected getLocation(): string {
|
||||||
if (this.additionInformation?.hangoutLink) {
|
if (this.calEvent.additionInformation?.hangoutLink) {
|
||||||
return `<a href="${this.additionInformation?.hangoutLink}">${this.additionInformation?.hangoutLink}</a><br />`;
|
return `<a href="${this.calEvent.additionInformation?.hangoutLink}">${this.calEvent.additionInformation?.hangoutLink}</a><br />`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.additionInformation?.entryPoints && this.additionInformation?.entryPoints.length > 0) {
|
if (
|
||||||
const locations = this.additionInformation?.entryPoints
|
this.calEvent.additionInformation?.entryPoints &&
|
||||||
|
this.calEvent.additionInformation?.entryPoints.length > 0
|
||||||
|
) {
|
||||||
|
const locations = this.calEvent.additionInformation?.entryPoints
|
||||||
.map((entryPoint) => {
|
.map((entryPoint) => {
|
||||||
return `
|
return `
|
||||||
Join by ${entryPoint.entryPointType}: <br />
|
${this.calEvent.language("join_by_entrypoint", { entryPoint: entryPoint.entryPointType })}: <br />
|
||||||
<a href="${entryPoint.uri}">${entryPoint.label}</a> <br />
|
<a href="${entryPoint.uri}">${entryPoint.label}</a> <br />
|
||||||
`;
|
`;
|
||||||
})
|
})
|
||||||
|
@ -142,15 +147,17 @@ export default class EventAttendeeMail extends EventMail {
|
||||||
to: `${this.calEvent.attendees[0].name} <${this.calEvent.attendees[0].email}>`,
|
to: `${this.calEvent.attendees[0].name} <${this.calEvent.attendees[0].email}>`,
|
||||||
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
|
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
|
||||||
replyTo: this.calEvent.organizer.email,
|
replyTo: this.calEvent.organizer.email,
|
||||||
subject: `Confirmed: ${this.calEvent.type} with ${
|
subject: this.calEvent.language("confirmed_event_type_subject", {
|
||||||
this.calEvent.team?.name || this.calEvent.organizer.name
|
eventType: this.calEvent.type,
|
||||||
} on ${this.getInviteeStart().format("LT dddd, LL")}`,
|
name: this.calEvent.team?.name || this.calEvent.organizer.name,
|
||||||
|
date: this.getInviteeStart().format("LT dddd, LL"),
|
||||||
|
}),
|
||||||
html: this.getHtmlRepresentation(),
|
html: this.getHtmlRepresentation(),
|
||||||
text: this.getPlainTextRepresentation(),
|
text: this.getPlainTextRepresentation(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
protected printNodeMailerError(error: string): void {
|
protected printNodeMailerError(error: Error): void {
|
||||||
console.error("SEND_BOOKING_CONFIRMATION_ERROR", this.calEvent.attendees[0].email, error);
|
console.error("SEND_BOOKING_CONFIRMATION_ERROR", this.calEvent.attendees[0].email, error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -10,12 +10,17 @@ export default class EventAttendeeRescheduledMail extends EventAttendeeMail {
|
||||||
return (
|
return (
|
||||||
`
|
`
|
||||||
<div>
|
<div>
|
||||||
Hi ${this.calEvent.attendees[0].name},<br />
|
${this.calEvent.language("hi_user_name", { userName: this.calEvent.attendees[0].name })},<br />
|
||||||
<br />
|
<br />
|
||||||
Your ${this.calEvent.type} with ${
|
${this.calEvent.language("event_type_has_been_rescheduled_on_time_date", {
|
||||||
this.calEvent.team?.name || this.calEvent.organizer.name
|
eventType: this.calEvent.type,
|
||||||
} has been rescheduled to ${this.getInviteeStart().format("h:mma")}
|
name: this.calEvent.team?.name || this.calEvent.organizer.name,
|
||||||
(${this.calEvent.attendees[0].timeZone}) on ${this.getInviteeStart().format("dddd, LL")}.<br />
|
time: this.getInviteeStart().format("h:mma"),
|
||||||
|
timeZone: this.calEvent.attendees[0].timeZone,
|
||||||
|
date:
|
||||||
|
`${this.calEvent.language(this.getInviteeStart().format("dddd, ").toLowerCase())}` +
|
||||||
|
`${this.calEvent.language(this.getInviteeStart().format("LL").toLowerCase())}`,
|
||||||
|
})}<br />
|
||||||
` +
|
` +
|
||||||
this.getAdditionalFooter() +
|
this.getAdditionalFooter() +
|
||||||
`
|
`
|
||||||
|
@ -34,15 +39,17 @@ export default class EventAttendeeRescheduledMail extends EventAttendeeMail {
|
||||||
to: `${this.calEvent.attendees[0].name} <${this.calEvent.attendees[0].email}>`,
|
to: `${this.calEvent.attendees[0].name} <${this.calEvent.attendees[0].email}>`,
|
||||||
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
|
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
|
||||||
replyTo: this.calEvent.organizer.email,
|
replyTo: this.calEvent.organizer.email,
|
||||||
subject: `Rescheduled: ${this.calEvent.type} with ${
|
subject: this.calEvent.language("rescheduled_event_type_with_organizer", {
|
||||||
this.calEvent.organizer.name
|
eventType: this.calEvent.type,
|
||||||
} on ${this.getInviteeStart().format("dddd, LL")}`,
|
organizerName: this.calEvent.organizer.name,
|
||||||
|
date: this.getInviteeStart().format("dddd, LL"),
|
||||||
|
}),
|
||||||
html: this.getHtmlRepresentation(),
|
html: this.getHtmlRepresentation(),
|
||||||
text: this.getPlainTextRepresentation(),
|
text: this.getPlainTextRepresentation(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
protected printNodeMailerError(error: string): void {
|
protected printNodeMailerError(error: Error): void {
|
||||||
console.error("SEND_RESCHEDULE_CONFIRMATION_ERROR", this.calEvent.attendees[0].email, error);
|
console.error("SEND_RESCHEDULE_CONFIRMATION_ERROR", this.calEvent.attendees[0].email, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,47 +1,25 @@
|
||||||
import nodemailer from "nodemailer";
|
import nodemailer from "nodemailer";
|
||||||
|
|
||||||
|
import { getErrorFromUnknown } from "@lib/errors";
|
||||||
|
|
||||||
import CalEventParser from "../CalEventParser";
|
import CalEventParser from "../CalEventParser";
|
||||||
import { CalendarEvent, ConferenceData } from "../calendarClient";
|
import { CalendarEvent } from "../calendarClient";
|
||||||
import { serverConfig } from "../serverConfig";
|
import { serverConfig } from "../serverConfig";
|
||||||
import { stripHtml } from "./helpers";
|
import { stripHtml } from "./helpers";
|
||||||
|
|
||||||
export interface EntryPoint {
|
|
||||||
entryPointType?: string;
|
|
||||||
uri?: string;
|
|
||||||
label?: string;
|
|
||||||
pin?: string;
|
|
||||||
accessCode?: string;
|
|
||||||
meetingCode?: string;
|
|
||||||
passcode?: string;
|
|
||||||
password?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdditionInformation {
|
|
||||||
conferenceData?: ConferenceData;
|
|
||||||
entryPoints?: EntryPoint[];
|
|
||||||
hangoutLink?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default abstract class EventMail {
|
export default abstract class EventMail {
|
||||||
calEvent: CalendarEvent;
|
calEvent: CalendarEvent;
|
||||||
parser: CalEventParser;
|
parser: CalEventParser;
|
||||||
uid: string;
|
|
||||||
additionInformation?: AdditionInformation;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An EventMail always consists of a CalendarEvent
|
* An EventMail always consists of a CalendarEvent
|
||||||
* that stores the very basic data of the event (like date, title etc).
|
* that stores the data of the event (like date, title, uid etc).
|
||||||
* It also needs the UID of the stored booking in our database.
|
|
||||||
*
|
*
|
||||||
* @param calEvent
|
* @param calEvent
|
||||||
* @param uid
|
|
||||||
* @param additionInformation
|
|
||||||
*/
|
*/
|
||||||
constructor(calEvent: CalendarEvent, uid: string, additionInformation?: AdditionInformation) {
|
constructor(calEvent: CalendarEvent) {
|
||||||
this.calEvent = calEvent;
|
this.calEvent = calEvent;
|
||||||
this.uid = uid;
|
this.parser = new CalEventParser(calEvent);
|
||||||
this.parser = new CalEventParser(calEvent, uid);
|
|
||||||
this.additionInformation = additionInformation;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -74,10 +52,11 @@ export default abstract class EventMail {
|
||||||
new Promise((resolve, reject) =>
|
new Promise((resolve, reject) =>
|
||||||
nodemailer
|
nodemailer
|
||||||
.createTransport(this.getMailerOptions().transport)
|
.createTransport(this.getMailerOptions().transport)
|
||||||
.sendMail(this.getNodeMailerPayload(), (error, info) => {
|
.sendMail(this.getNodeMailerPayload(), (_err, info) => {
|
||||||
if (error) {
|
if (_err) {
|
||||||
this.printNodeMailerError(error);
|
const err = getErrorFromUnknown(_err);
|
||||||
reject(new Error(error));
|
this.printNodeMailerError(err);
|
||||||
|
reject(err);
|
||||||
} else {
|
} else {
|
||||||
resolve(info);
|
resolve(info);
|
||||||
}
|
}
|
||||||
|
@ -117,7 +96,7 @@ export default abstract class EventMail {
|
||||||
* @param error
|
* @param error
|
||||||
* @protected
|
* @protected
|
||||||
*/
|
*/
|
||||||
protected abstract printNodeMailerError(error: string): void;
|
protected abstract printNodeMailerError(error: Error): void;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a link to reschedule the given booking.
|
* Returns a link to reschedule the given booking.
|
||||||
|
|
|
@ -5,6 +5,8 @@ import toArray from "dayjs/plugin/toArray";
|
||||||
import utc from "dayjs/plugin/utc";
|
import utc from "dayjs/plugin/utc";
|
||||||
import { createEvent } from "ics";
|
import { createEvent } from "ics";
|
||||||
|
|
||||||
|
import { Person } from "@lib/calendarClient";
|
||||||
|
|
||||||
import EventMail from "./EventMail";
|
import EventMail from "./EventMail";
|
||||||
import { stripHtml } from "./helpers";
|
import { stripHtml } from "./helpers";
|
||||||
|
|
||||||
|
@ -18,7 +20,7 @@ export default class EventOrganizerMail extends EventMail {
|
||||||
* Returns the instance's event as an iCal event in string representation.
|
* Returns the instance's event as an iCal event in string representation.
|
||||||
* @protected
|
* @protected
|
||||||
*/
|
*/
|
||||||
protected getiCalEventAsString(): string {
|
protected getiCalEventAsString(): string | undefined {
|
||||||
const icsEvent = createEvent({
|
const icsEvent = createEvent({
|
||||||
start: dayjs(this.calEvent.startTime)
|
start: dayjs(this.calEvent.startTime)
|
||||||
.utc()
|
.utc()
|
||||||
|
@ -27,14 +29,17 @@ export default class EventOrganizerMail extends EventMail {
|
||||||
.map((v, i) => (i === 1 ? v + 1 : v)),
|
.map((v, i) => (i === 1 ? v + 1 : v)),
|
||||||
startInputType: "utc",
|
startInputType: "utc",
|
||||||
productId: "calendso/ics",
|
productId: "calendso/ics",
|
||||||
title: `${this.calEvent.type} with ${this.calEvent.attendees[0].name}`,
|
title: this.calEvent.language("organizer_ics_event_title", {
|
||||||
|
eventType: this.calEvent.type,
|
||||||
|
attendeeName: this.calEvent.attendees[0].name,
|
||||||
|
}),
|
||||||
description:
|
description:
|
||||||
this.calEvent.description +
|
this.calEvent.description +
|
||||||
stripHtml(this.getAdditionalBody()) +
|
stripHtml(this.getAdditionalBody()) +
|
||||||
stripHtml(this.getAdditionalFooter()),
|
stripHtml(this.getAdditionalFooter()),
|
||||||
duration: { minutes: dayjs(this.calEvent.endTime).diff(dayjs(this.calEvent.startTime), "minute") },
|
duration: { minutes: dayjs(this.calEvent.endTime).diff(dayjs(this.calEvent.startTime), "minute") },
|
||||||
organizer: { name: this.calEvent.organizer.name, email: this.calEvent.organizer.email },
|
organizer: { name: this.calEvent.organizer.name, email: this.calEvent.organizer.email },
|
||||||
attendees: this.calEvent.attendees.map((attendee: unknown) => ({
|
attendees: this.calEvent.attendees.map((attendee: Person) => ({
|
||||||
name: attendee.name,
|
name: attendee.name,
|
||||||
email: attendee.email,
|
email: attendee.email,
|
||||||
})),
|
})),
|
||||||
|
@ -47,13 +52,15 @@ export default class EventOrganizerMail extends EventMail {
|
||||||
}
|
}
|
||||||
|
|
||||||
protected getBodyHeader(): string {
|
protected getBodyHeader(): string {
|
||||||
return "A new event has been scheduled.";
|
return this.calEvent.language("new_event_scheduled");
|
||||||
}
|
}
|
||||||
|
|
||||||
protected getAdditionalFooter(): string {
|
protected getAdditionalFooter(): string {
|
||||||
return `<p style="color: #4b5563; margin-top: 20px;">Need to make a change? <a href=${
|
return `<p style="color: #4b5563; margin-top: 20px;">${this.calEvent.language(
|
||||||
process.env.BASE_URL + "/bookings"
|
"need_to_make_a_change"
|
||||||
} style="color: #161e2e;">Manage my bookings</a></p>`;
|
)} <a href=${process.env.BASE_URL + "/bookings"} style="color: #161e2e;">${this.calEvent.language(
|
||||||
|
"manage_my_bookings"
|
||||||
|
)}</a></p>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected getImage(): string {
|
protected getImage(): string {
|
||||||
|
@ -103,27 +110,27 @@ export default class EventOrganizerMail extends EventMail {
|
||||||
<col span="1" style="width: 60%;">
|
<col span="1" style="width: 60%;">
|
||||||
</colgroup>
|
</colgroup>
|
||||||
<tr>
|
<tr>
|
||||||
<td>What</td>
|
<td>${this.calEvent.language("what")}</td>
|
||||||
<td>${this.calEvent.type}</td>
|
<td>${this.calEvent.type}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>When</td>
|
<td>${this.calEvent.language("when")}</td>
|
||||||
<td>${this.getOrganizerStart().format("dddd, LL")}<br>${this.getOrganizerStart().format("h:mma")} (${
|
<td>${this.getOrganizerStart().format("dddd, LL")}<br>${this.getOrganizerStart().format("h:mma")} (${
|
||||||
this.calEvent.organizer.timeZone
|
this.calEvent.organizer.timeZone
|
||||||
})</td>
|
})</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Who</td>
|
<td>${this.calEvent.language("who")}</td>
|
||||||
<td>${this.calEvent.attendees[0].name}<br /><small><a href="mailto:${
|
<td>${this.calEvent.attendees[0].name}<br /><small><a href="mailto:${
|
||||||
this.calEvent.attendees[0].email
|
this.calEvent.attendees[0].email
|
||||||
}">${this.calEvent.attendees[0].email}</a></small></td>
|
}">${this.calEvent.attendees[0].email}</a></small></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Where</td>
|
<td>${this.calEvent.language("where")}</td>
|
||||||
<td>${this.getLocation()}</td>
|
<td>${this.getLocation()}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Notes</td>
|
<td>${this.calEvent.language("notes")}</td>
|
||||||
<td>${this.calEvent.description}</td>
|
<td>${this.calEvent.description}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
@ -149,15 +156,18 @@ export default class EventOrganizerMail extends EventMail {
|
||||||
* @protected
|
* @protected
|
||||||
*/
|
*/
|
||||||
protected getLocation(): string {
|
protected getLocation(): string {
|
||||||
if (this.additionInformation?.hangoutLink) {
|
if (this.calEvent.additionInformation?.hangoutLink) {
|
||||||
return `<a href="${this.additionInformation?.hangoutLink}">${this.additionInformation?.hangoutLink}</a><br />`;
|
return `<a href="${this.calEvent.additionInformation?.hangoutLink}">${this.calEvent.additionInformation?.hangoutLink}</a><br />`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.additionInformation?.entryPoints && this.additionInformation?.entryPoints.length > 0) {
|
if (
|
||||||
const locations = this.additionInformation?.entryPoints
|
this.calEvent.additionInformation?.entryPoints &&
|
||||||
|
this.calEvent.additionInformation?.entryPoints.length > 0
|
||||||
|
) {
|
||||||
|
const locations = this.calEvent.additionInformation?.entryPoints
|
||||||
.map((entryPoint) => {
|
.map((entryPoint) => {
|
||||||
return `
|
return `
|
||||||
Join by ${entryPoint.entryPointType}: <br />
|
${this.calEvent.language("join_by_entrypoint", { entryPoint: entryPoint.entryPointType })}: <br />
|
||||||
<a href="${entryPoint.uri}">${entryPoint.label}</a> <br />
|
<a href="${entryPoint.uri}">${entryPoint.label}</a> <br />
|
||||||
`;
|
`;
|
||||||
})
|
})
|
||||||
|
@ -202,12 +212,14 @@ export default class EventOrganizerMail extends EventMail {
|
||||||
}
|
}
|
||||||
|
|
||||||
protected getSubject(): string {
|
protected getSubject(): string {
|
||||||
return `New event: ${this.calEvent.attendees[0].name} - ${this.getOrganizerStart().format(
|
return this.calEvent.language("new_event_subject", {
|
||||||
"LT dddd, LL"
|
attendeeName: this.calEvent.attendees[0].name,
|
||||||
)} - ${this.calEvent.type}`;
|
date: this.getOrganizerStart().format("LT dddd, LL"),
|
||||||
|
eventType: this.calEvent.type,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
protected printNodeMailerError(error: string): void {
|
protected printNodeMailerError(error: Error): void {
|
||||||
console.error("SEND_NEW_EVENT_NOTIFICATION_ERROR", this.calEvent.organizer.email, error);
|
console.error("SEND_NEW_EVENT_NOTIFICATION_ERROR", this.calEvent.organizer.email, error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -13,15 +13,17 @@ dayjs.extend(localizedFormat);
|
||||||
|
|
||||||
export default class EventOrganizerRequestMail extends EventOrganizerMail {
|
export default class EventOrganizerRequestMail extends EventOrganizerMail {
|
||||||
protected getBodyHeader(): string {
|
protected getBodyHeader(): string {
|
||||||
return "A new event is waiting for your approval.";
|
return this.calEvent.language("event_awaiting_approval");
|
||||||
}
|
}
|
||||||
|
|
||||||
protected getBodyText(): string {
|
protected getBodyText(): string {
|
||||||
return "Check your bookings page to confirm or reject the booking.";
|
return this.calEvent.language("check_bookings_page_to_confirm_or_reject");
|
||||||
}
|
}
|
||||||
|
|
||||||
protected getAdditionalBody(): string {
|
protected getAdditionalBody(): string {
|
||||||
return `<a href="${process.env.BASE_URL}/bookings">Confirm or reject the booking</a>`;
|
return `<a href="${process.env.BASE_URL}/bookings">${this.calEvent.language(
|
||||||
|
"confirm_or_reject_booking"
|
||||||
|
)}</a>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected getImage(): string {
|
protected getImage(): string {
|
||||||
|
@ -43,8 +45,10 @@ export default class EventOrganizerRequestMail extends EventOrganizerMail {
|
||||||
|
|
||||||
protected getSubject(): string {
|
protected getSubject(): string {
|
||||||
const organizerStart: Dayjs = dayjs(this.calEvent.startTime).tz(this.calEvent.organizer.timeZone);
|
const organizerStart: Dayjs = dayjs(this.calEvent.startTime).tz(this.calEvent.organizer.timeZone);
|
||||||
return `New event request: ${this.calEvent.attendees[0].name} - ${organizerStart.format(
|
return this.calEvent.language("new_event_request", {
|
||||||
"LT dddd, LL"
|
attendeeName: this.calEvent.attendees[0].name,
|
||||||
)} - ${this.calEvent.type}`;
|
date: organizerStart.format("LT dddd, LL"),
|
||||||
|
eventType: this.calEvent.type,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,32 +12,32 @@ export default class EventOrganizerRescheduledMail extends EventOrganizerMail {
|
||||||
return (
|
return (
|
||||||
`
|
`
|
||||||
<div>
|
<div>
|
||||||
Hi ${this.calEvent.organizer.name},<br />
|
${this.calEvent.language("hi_user_name", { userName: this.calEvent.organizer.name })},<br />
|
||||||
<br />
|
<br />
|
||||||
Your event has been rescheduled.<br />
|
${this.calEvent.language("event_has_been_rescheduled")}<br />
|
||||||
<br />
|
<br />
|
||||||
<strong>Event Type:</strong><br />
|
<strong>${this.calEvent.language("event_type")}:</strong><br />
|
||||||
${this.calEvent.type}<br />
|
${this.calEvent.type}<br />
|
||||||
<br />
|
<br />
|
||||||
<strong>Invitee Email:</strong><br />
|
<strong>${this.calEvent.language("invitee_email")}:</strong><br />
|
||||||
<a href="mailto:${this.calEvent.attendees[0].email}">${this.calEvent.attendees[0].email}</a><br />
|
<a href="mailto:${this.calEvent.attendees[0].email}">${this.calEvent.attendees[0].email}</a><br />
|
||||||
<br />` +
|
<br />` +
|
||||||
this.getAdditionalBody() +
|
this.getAdditionalBody() +
|
||||||
(this.calEvent.location
|
(this.calEvent.location
|
||||||
? `
|
? `
|
||||||
<strong>Location:</strong><br />
|
<strong>${this.calEvent.language("location")}:</strong><br />
|
||||||
${this.calEvent.location}<br />
|
${this.calEvent.location}<br />
|
||||||
<br />
|
<br />
|
||||||
`
|
`
|
||||||
: "") +
|
: "") +
|
||||||
`<strong>Invitee Time Zone:</strong><br />
|
`<strong>${this.calEvent.language("invitee_timezone")}:</strong><br />
|
||||||
${this.calEvent.attendees[0].timeZone}<br />
|
${this.calEvent.attendees[0].timeZone}<br />
|
||||||
<br />
|
<br />
|
||||||
<strong>Additional notes:</strong><br />
|
<strong>${this.calEvent.language("additional_notes")}:</strong><br />
|
||||||
${this.calEvent.description}
|
${this.calEvent.description}
|
||||||
` +
|
` +
|
||||||
this.getAdditionalFooter() +
|
this.getAdditionalFooter() +
|
||||||
`
|
`
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
);
|
);
|
||||||
|
@ -58,15 +58,17 @@ export default class EventOrganizerRescheduledMail extends EventOrganizerMail {
|
||||||
},
|
},
|
||||||
from: `Cal.com <${this.getMailerOptions().from}>`,
|
from: `Cal.com <${this.getMailerOptions().from}>`,
|
||||||
to: this.calEvent.organizer.email,
|
to: this.calEvent.organizer.email,
|
||||||
subject: `Rescheduled event: ${this.calEvent.attendees[0].name} - ${organizerStart.format(
|
subject: this.calEvent.language("rescheduled_event_type_with_attendee", {
|
||||||
"LT dddd, LL"
|
attendeeName: this.calEvent.attendees[0].name,
|
||||||
)} - ${this.calEvent.type}`,
|
date: organizerStart.format("LT dddd, LL"),
|
||||||
|
eventType: this.calEvent.type,
|
||||||
|
}),
|
||||||
html: this.getHtmlRepresentation(),
|
html: this.getHtmlRepresentation(),
|
||||||
text: this.getPlainTextRepresentation(),
|
text: this.getPlainTextRepresentation(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
protected printNodeMailerError(error: string): void {
|
protected printNodeMailerError(error: Error): void {
|
||||||
console.error("SEND_RESCHEDULE_EVENT_NOTIFICATION_ERROR", this.calEvent.organizer.email, error);
|
console.error("SEND_RESCHEDULE_EVENT_NOTIFICATION_ERROR", this.calEvent.organizer.email, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -45,8 +45,8 @@ export default class EventRejectionMail extends EventMail {
|
||||||
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
|
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<h1 style="font-weight: 500; color: #161e2e;">Your meeting request has been rejected</h1>
|
<h1 style="font-weight: 500; color: #161e2e;">${this.calEvent.language("meeting_request_rejected")}</h1>
|
||||||
<p style="color: #4b5563; margin-bottom: 30px;">You and any other attendees have been emailed with this information.</p>
|
<p style="color: #4b5563; margin-bottom: 30px;">${this.calEvent.language("emailed_you_and_attendees")}</p>
|
||||||
<hr />
|
<hr />
|
||||||
` +
|
` +
|
||||||
`
|
`
|
||||||
|
@ -68,24 +68,35 @@ export default class EventRejectionMail extends EventMail {
|
||||||
to: `${this.calEvent.attendees[0].name} <${this.calEvent.attendees[0].email}>`,
|
to: `${this.calEvent.attendees[0].name} <${this.calEvent.attendees[0].email}>`,
|
||||||
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
|
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
|
||||||
replyTo: this.calEvent.organizer.email,
|
replyTo: this.calEvent.organizer.email,
|
||||||
subject: `Rejected: ${this.calEvent.type} with ${
|
subject: this.calEvent.language("rejected_event_type_with_organizer", {
|
||||||
this.calEvent.organizer.name
|
eventType: this.calEvent.type,
|
||||||
} on ${this.getInviteeStart().format("dddd, LL")}`,
|
organizer: this.calEvent.organizer.name,
|
||||||
|
date: this.getInviteeStart().format("dddd, LL"),
|
||||||
|
}),
|
||||||
html: this.getHtmlRepresentation(),
|
html: this.getHtmlRepresentation(),
|
||||||
text: this.getPlainTextRepresentation(),
|
text: this.getPlainTextRepresentation(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
protected printNodeMailerError(error: string): void {
|
protected printNodeMailerError(error: Error): void {
|
||||||
console.error("SEND_BOOKING_CONFIRMATION_ERROR", this.calEvent.attendees[0].email, error);
|
console.error("SEND_BOOKING_CONFIRMATION_ERROR", this.calEvent.attendees[0].email, error);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the inviteeStart value used at multiple points.
|
* Returns the inviteeStart value used at multiple points.
|
||||||
*
|
*
|
||||||
* @private
|
* @protected
|
||||||
*/
|
*/
|
||||||
protected getInviteeStart(): Dayjs {
|
protected getInviteeStart(): Dayjs {
|
||||||
return dayjs(this.calEvent.startTime).tz(this.calEvent.attendees[0].timeZone);
|
return dayjs(this.calEvent.startTime).tz(this.calEvent.attendees[0].timeZone);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds the video call information to the mail body.
|
||||||
|
*
|
||||||
|
* @protected
|
||||||
|
*/
|
||||||
|
protected getLocation(): string {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,45 +1,43 @@
|
||||||
import { AdditionInformation } from "@lib/emails/EventMail";
|
|
||||||
|
|
||||||
import { CalendarEvent } from "../calendarClient";
|
|
||||||
import { VideoCallData } from "../videoClient";
|
|
||||||
import EventAttendeeMail from "./EventAttendeeMail";
|
import EventAttendeeMail from "./EventAttendeeMail";
|
||||||
import { getFormattedMeetingId, getIntegrationName } from "./helpers";
|
import { getFormattedMeetingId, getIntegrationName } from "./helpers";
|
||||||
|
|
||||||
export default class VideoEventAttendeeMail extends EventAttendeeMail {
|
export default class VideoEventAttendeeMail extends EventAttendeeMail {
|
||||||
videoCallData: VideoCallData;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
calEvent: CalendarEvent,
|
|
||||||
uid: string,
|
|
||||||
videoCallData: VideoCallData,
|
|
||||||
additionInformation: AdditionInformation = null
|
|
||||||
) {
|
|
||||||
super(calEvent, uid);
|
|
||||||
this.videoCallData = videoCallData;
|
|
||||||
this.additionInformation = additionInformation;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adds the video call information to the mail body.
|
* Adds the video call information to the mail body.
|
||||||
*
|
*
|
||||||
* @protected
|
* @protected
|
||||||
*/
|
*/
|
||||||
protected getAdditionalBody(): string {
|
protected getAdditionalBody(): string {
|
||||||
const meetingPassword = this.videoCallData.password;
|
if (!this.calEvent.videoCallData) {
|
||||||
const meetingId = getFormattedMeetingId(this.videoCallData);
|
return "";
|
||||||
|
}
|
||||||
|
const meetingPassword = this.calEvent.videoCallData.password;
|
||||||
|
const meetingId = getFormattedMeetingId(this.calEvent.videoCallData);
|
||||||
|
|
||||||
if (meetingId && meetingPassword) {
|
if (meetingId && meetingPassword) {
|
||||||
return `
|
return `
|
||||||
<strong>Video call provider:</strong> ${getIntegrationName(this.videoCallData)}<br />
|
<strong>${this.calEvent.language("video_call_provider")}:</strong> ${getIntegrationName(
|
||||||
<strong>Meeting ID:</strong> ${getFormattedMeetingId(this.videoCallData)}<br />
|
this.calEvent.videoCallData
|
||||||
<strong>Meeting Password:</strong> ${this.videoCallData.password}<br />
|
)}<br />
|
||||||
<strong>Meeting URL:</strong> <a href="${this.videoCallData.url}">${this.videoCallData.url}</a><br />
|
<strong>${this.calEvent.language("meeting_id")}:</strong> ${getFormattedMeetingId(
|
||||||
|
this.calEvent.videoCallData
|
||||||
|
)}<br />
|
||||||
|
<strong>${this.calEvent.language("meeting_password")}:</strong> ${
|
||||||
|
this.calEvent.videoCallData.password
|
||||||
|
}<br />
|
||||||
|
<strong>${this.calEvent.language("meeting_url")}:</strong> <a href="${
|
||||||
|
this.calEvent.videoCallData.url
|
||||||
|
}">${this.calEvent.videoCallData.url}</a><br />
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<strong>Video call provider:</strong> ${getIntegrationName(this.videoCallData)}<br />
|
<strong>${this.calEvent.language("video_call_provider")}:</strong> ${getIntegrationName(
|
||||||
<strong>Meeting URL:</strong> <a href="${this.videoCallData.url}">${this.videoCallData.url}</a><br />
|
this.calEvent.videoCallData
|
||||||
|
)}<br />
|
||||||
|
<strong>${this.calEvent.language("meeting_url")}:</strong> <a href="${
|
||||||
|
this.calEvent.videoCallData.url
|
||||||
|
}">${this.calEvent.videoCallData.url}</a><br />
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,24 +1,7 @@
|
||||||
import { AdditionInformation } from "@lib/emails/EventMail";
|
|
||||||
|
|
||||||
import { CalendarEvent } from "../calendarClient";
|
|
||||||
import { VideoCallData } from "../videoClient";
|
|
||||||
import EventOrganizerMail from "./EventOrganizerMail";
|
import EventOrganizerMail from "./EventOrganizerMail";
|
||||||
import { getFormattedMeetingId, getIntegrationName } from "./helpers";
|
import { getFormattedMeetingId, getIntegrationName } from "./helpers";
|
||||||
|
|
||||||
export default class VideoEventOrganizerMail extends EventOrganizerMail {
|
export default class VideoEventOrganizerMail extends EventOrganizerMail {
|
||||||
videoCallData: VideoCallData;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
calEvent: CalendarEvent,
|
|
||||||
uid: string,
|
|
||||||
videoCallData: VideoCallData,
|
|
||||||
additionInformation: AdditionInformation = null
|
|
||||||
) {
|
|
||||||
super(calEvent, uid);
|
|
||||||
this.videoCallData = videoCallData;
|
|
||||||
this.additionInformation = additionInformation;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adds the video call information to the mail body
|
* Adds the video call information to the mail body
|
||||||
* and calendar event description.
|
* and calendar event description.
|
||||||
|
@ -26,20 +9,33 @@ export default class VideoEventOrganizerMail extends EventOrganizerMail {
|
||||||
* @protected
|
* @protected
|
||||||
*/
|
*/
|
||||||
protected getAdditionalBody(): string {
|
protected getAdditionalBody(): string {
|
||||||
const meetingPassword = this.videoCallData.password;
|
if (!this.calEvent.videoCallData) {
|
||||||
const meetingId = getFormattedMeetingId(this.videoCallData);
|
return "";
|
||||||
|
}
|
||||||
|
const meetingPassword = this.calEvent.videoCallData.password;
|
||||||
|
const meetingId = getFormattedMeetingId(this.calEvent.videoCallData);
|
||||||
// This odd indentation is necessary because otherwise the leading tabs will be applied into the event description.
|
// This odd indentation is necessary because otherwise the leading tabs will be applied into the event description.
|
||||||
if (meetingPassword && meetingId) {
|
if (meetingPassword && meetingId) {
|
||||||
return `
|
return `
|
||||||
<strong>Video call provider:</strong> ${getIntegrationName(this.videoCallData)}<br />
|
<strong>${this.calEvent.language("video_call_provider")}:</strong> ${getIntegrationName(
|
||||||
<strong>Meeting ID:</strong> ${getFormattedMeetingId(this.videoCallData)}<br />
|
this.calEvent.videoCallData
|
||||||
<strong>Meeting Password:</strong> ${this.videoCallData.password}<br />
|
)}<br />
|
||||||
<strong>Meeting URL:</strong> <a href="${this.videoCallData.url}">${this.videoCallData.url}</a><br />
|
<strong>${this.calEvent.language("meeting_id")}:</strong> ${getFormattedMeetingId(
|
||||||
|
this.calEvent.videoCallData
|
||||||
|
)}<br />
|
||||||
|
<strong>${this.calEvent.language("meeting_password")}:</strong> ${this.calEvent.videoCallData.password}<br />
|
||||||
|
<strong>${this.calEvent.language("meeting_url")}:</strong> <a href="${this.calEvent.videoCallData.url}">${
|
||||||
|
this.calEvent.videoCallData.url
|
||||||
|
}</a><br />
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
return `
|
return `
|
||||||
<strong>Video call provider:</strong> ${getIntegrationName(this.videoCallData)}<br />
|
<strong>${this.calEvent.language("video_call_provider")}:</strong> ${getIntegrationName(
|
||||||
<strong>Meeting URL:</strong> <a href="${this.videoCallData.url}">${this.videoCallData.url}</a><br />
|
this.calEvent.videoCallData
|
||||||
|
)}<br />
|
||||||
|
<strong>${this.calEvent.language("meeting_url")}:</strong> <a href="${this.calEvent.videoCallData.url}">${
|
||||||
|
this.calEvent.videoCallData.url
|
||||||
|
}</a><br />
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,15 +1,36 @@
|
||||||
import Handlebars from "handlebars";
|
import Handlebars from "handlebars";
|
||||||
|
import { TFunction } from "next-i18next";
|
||||||
|
|
||||||
|
export type VarType = {
|
||||||
|
language: TFunction;
|
||||||
|
user: {
|
||||||
|
name: string | null;
|
||||||
|
};
|
||||||
|
link: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MessageTemplateTypes = {
|
||||||
|
messageTemplate: string;
|
||||||
|
subjectTemplate: string;
|
||||||
|
vars: VarType;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BuildTemplateResult = {
|
||||||
|
subject: string;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
export const buildMessageTemplate = ({
|
export const buildMessageTemplate = ({
|
||||||
messageTemplate,
|
messageTemplate,
|
||||||
subjectTemplate,
|
subjectTemplate,
|
||||||
vars,
|
vars,
|
||||||
}): { subject: string; message: string } => {
|
}: MessageTemplateTypes): BuildTemplateResult => {
|
||||||
const buildMessage = Handlebars.compile(messageTemplate);
|
const buildMessage = Handlebars.compile(messageTemplate);
|
||||||
const message = buildMessage(vars);
|
const message = buildMessage(vars);
|
||||||
|
|
||||||
const buildSubject = Handlebars.compile(subjectTemplate);
|
const buildSubject = Handlebars.compile(subjectTemplate);
|
||||||
const subject = buildSubject(vars);
|
const subject = buildSubject(vars);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
subject,
|
subject,
|
||||||
message,
|
message,
|
||||||
|
|
|
@ -1,8 +1,12 @@
|
||||||
|
import { TFunction } from "next-i18next";
|
||||||
import nodemailer from "nodemailer";
|
import nodemailer from "nodemailer";
|
||||||
|
|
||||||
|
import { getErrorFromUnknown } from "@lib/errors";
|
||||||
|
|
||||||
import { serverConfig } from "../serverConfig";
|
import { serverConfig } from "../serverConfig";
|
||||||
|
|
||||||
export type Invitation = {
|
export type Invitation = {
|
||||||
|
language: TFunction;
|
||||||
from?: string;
|
from?: string;
|
||||||
toEmail: string;
|
toEmail: string;
|
||||||
teamName: string;
|
teamName: string;
|
||||||
|
@ -26,21 +30,24 @@ const sendEmail = (invitation: Invitation, provider: EmailProvider): Promise<voi
|
||||||
new Promise((resolve, reject) => {
|
new Promise((resolve, reject) => {
|
||||||
const { transport, from } = provider;
|
const { transport, from } = provider;
|
||||||
|
|
||||||
|
const { language: t } = invitation;
|
||||||
const invitationHtml = html(invitation);
|
const invitationHtml = html(invitation);
|
||||||
nodemailer.createTransport(transport).sendMail(
|
nodemailer.createTransport(transport).sendMail(
|
||||||
{
|
{
|
||||||
from: `Cal.com <${from}>`,
|
from: `Cal.com <${from}>`,
|
||||||
to: invitation.toEmail,
|
to: invitation.toEmail,
|
||||||
subject:
|
subject: invitation.from
|
||||||
(invitation.from ? invitation.from + " invited you" : "You have been invited") +
|
? t("user_invited_you", { user: invitation.from, teamName: invitation.teamName })
|
||||||
` to join ${invitation.teamName}`,
|
: t("you_have_been_invited", { teamName: invitation.teamName }),
|
||||||
html: invitationHtml,
|
html: invitationHtml,
|
||||||
text: text(invitationHtml),
|
text: text(invitationHtml),
|
||||||
},
|
},
|
||||||
(error) => {
|
(_err) => {
|
||||||
if (error) {
|
if (_err) {
|
||||||
console.error("SEND_INVITATION_NOTIFICATION_ERROR", invitation.toEmail, error);
|
const err = getErrorFromUnknown(_err);
|
||||||
return reject(new Error(error));
|
console.error("SEND_INVITATION_NOTIFICATION_ERROR", invitation.toEmail, err);
|
||||||
|
reject(err);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
return resolve();
|
return resolve();
|
||||||
}
|
}
|
||||||
|
@ -48,6 +55,7 @@ const sendEmail = (invitation: Invitation, provider: EmailProvider): Promise<voi
|
||||||
});
|
});
|
||||||
|
|
||||||
export function html(invitation: Invitation): string {
|
export function html(invitation: Invitation): string {
|
||||||
|
const { language: t } = invitation;
|
||||||
let url: string = process.env.BASE_URL + "/settings/teams";
|
let url: string = process.env.BASE_URL + "/settings/teams";
|
||||||
if (invitation.token) {
|
if (invitation.token) {
|
||||||
url = `${process.env.BASE_URL}/auth/signup?token=${invitation.token}&callbackUrl=${url}`;
|
url = `${process.env.BASE_URL}/auth/signup?token=${invitation.token}&callbackUrl=${url}`;
|
||||||
|
@ -62,10 +70,12 @@ export function html(invitation: Invitation): string {
|
||||||
<table style="width: 640px; border: 1px solid gray; padding: 15px; margin: 0 auto; text-align: left;">
|
<table style="width: 640px; border: 1px solid gray; padding: 15px; margin: 0 auto; text-align: left;">
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
Hi,<br />
|
${t("hi")},<br />
|
||||||
<br />` +
|
<br />` +
|
||||||
(invitation.from ? invitation.from + " invited you" : "You have been invited") +
|
(invitation.from
|
||||||
` to join the team "${invitation.teamName}" in Cal.com.<br />
|
? t("user_invited_you", { user: invitation.from, teamName: invitation.teamName })
|
||||||
|
: t("you_have_been_invited", { teamName: invitation.teamName })) +
|
||||||
|
`<br />
|
||||||
<br />
|
<br />
|
||||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;line-height:100%;">
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;line-height:100%;">
|
||||||
<tr>
|
<tr>
|
||||||
|
@ -77,13 +87,15 @@ export function html(invitation: Invitation): string {
|
||||||
<center style="color:#ffffff;font-family:Helvetica, sans-serif;font-size:18px; font-weight: 600;">Join team</center>
|
<center style="color:#ffffff;font-family:Helvetica, sans-serif;font-size:18px; font-weight: 600;">Join team</center>
|
||||||
</v:roundrect>
|
</v:roundrect>
|
||||||
<![endif]-->
|
<![endif]-->
|
||||||
<a href="${url}" style="display: inline-block; mso-hide:all; background-color: #19cca3; color: #FFFFFF; border:1px solid #19cca3; border-radius: 6px; line-height: 220%; width: 200px; font-family: Helvetica, sans-serif; font-size:18px; font-weight:600; text-align: center; text-decoration: none; -webkit-text-size-adjust:none; " target="_blank">Join team</a>
|
<a href="${url}" style="display: inline-block; mso-hide:all; background-color: #19cca3; color: #FFFFFF; border:1px solid #19cca3; border-radius: 6px; line-height: 220%; width: 200px; font-family: Helvetica, sans-serif; font-size:18px; font-weight:600; text-align: center; text-decoration: none; -webkit-text-size-adjust:none; " target="_blank">${t(
|
||||||
|
"join_team"
|
||||||
|
)}</a>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table><br />
|
</table><br />
|
||||||
If you prefer not to use "${invitation.toEmail}" as your Cal.com email or already have a Cal.com account, please request another invitation to that email.
|
${t("request_another_invitation_email", { toEmail: invitation.toEmail })}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
export function getErrorFromUnknown(cause: unknown): Error & { statusCode?: number } {
|
export function getErrorFromUnknown(cause: unknown): Error & { statusCode?: number; code?: unknown } {
|
||||||
if (cause instanceof Error) {
|
if (cause instanceof Error) {
|
||||||
return cause;
|
return cause;
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,7 +3,7 @@ import async from "async";
|
||||||
import merge from "lodash/merge";
|
import merge from "lodash/merge";
|
||||||
import { v5 as uuidv5 } from "uuid";
|
import { v5 as uuidv5 } from "uuid";
|
||||||
|
|
||||||
import { CalendarEvent, createEvent, updateEvent } from "@lib/calendarClient";
|
import { CalendarEvent, AdditionInformation, createEvent, updateEvent } from "@lib/calendarClient";
|
||||||
import { dailyCreateMeeting, dailyUpdateMeeting } from "@lib/dailyVideoClient";
|
import { dailyCreateMeeting, dailyUpdateMeeting } from "@lib/dailyVideoClient";
|
||||||
import EventAttendeeMail from "@lib/emails/EventAttendeeMail";
|
import EventAttendeeMail from "@lib/emails/EventAttendeeMail";
|
||||||
import EventAttendeeRescheduledMail from "@lib/emails/EventAttendeeRescheduledMail";
|
import EventAttendeeRescheduledMail from "@lib/emails/EventAttendeeRescheduledMail";
|
||||||
|
@ -15,8 +15,8 @@ export interface EventResult {
|
||||||
type: string;
|
type: string;
|
||||||
success: boolean;
|
success: boolean;
|
||||||
uid: string;
|
uid: string;
|
||||||
createdEvent?: unknown;
|
createdEvent?: AdditionInformation & { name: string; id: string; disableConfirmationEmail?: boolean };
|
||||||
updatedEvent?: unknown;
|
updatedEvent?: AdditionInformation & { name: string; id: string; disableConfirmationEmail?: boolean };
|
||||||
originalEvent: CalendarEvent;
|
originalEvent: CalendarEvent;
|
||||||
videoCallData?: VideoCallData;
|
videoCallData?: VideoCallData;
|
||||||
}
|
}
|
||||||
|
@ -35,9 +35,9 @@ export interface PartialReference {
|
||||||
id?: number;
|
id?: number;
|
||||||
type: string;
|
type: string;
|
||||||
uid: string;
|
uid: string;
|
||||||
meetingId?: string;
|
meetingId?: string | null;
|
||||||
meetingPassword?: string;
|
meetingPassword?: string | null;
|
||||||
meetingUrl?: string;
|
meetingUrl?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GetLocationRequestFromIntegrationRequest {
|
interface GetLocationRequestFromIntegrationRequest {
|
||||||
|
@ -78,55 +78,46 @@ export default class EventManager {
|
||||||
* Takes a CalendarEvent and creates all necessary integration entries for it.
|
* Takes a CalendarEvent and creates all necessary integration entries for it.
|
||||||
* When a video integration is chosen as the event's location, a video integration
|
* When a video integration is chosen as the event's location, a video integration
|
||||||
* event will be scheduled for it as well.
|
* event will be scheduled for it as well.
|
||||||
* An optional uid can be set to override the auto-generated uid.
|
|
||||||
*
|
*
|
||||||
* @param event
|
* @param event
|
||||||
* @param maybeUid
|
|
||||||
*/
|
*/
|
||||||
public async create(event: CalendarEvent, maybeUid?: string): Promise<CreateUpdateResult> {
|
public async create(event: CalendarEvent): Promise<CreateUpdateResult> {
|
||||||
event = EventManager.processLocation(event);
|
let evt = EventManager.processLocation(event);
|
||||||
const isDedicated = EventManager.isDedicatedIntegration(event.location);
|
const isDedicated = evt.location ? EventManager.isDedicatedIntegration(evt.location) : null;
|
||||||
|
|
||||||
let results: Array<EventResult> = [];
|
let results: Array<EventResult> = [];
|
||||||
let optionalVideoCallData: VideoCallData | undefined = undefined;
|
|
||||||
|
|
||||||
// If and only if event type is a dedicated meeting, create a dedicated video meeting.
|
// If and only if event type is a dedicated meeting, create a dedicated video meeting.
|
||||||
if (isDedicated) {
|
if (isDedicated) {
|
||||||
const result = await this.createVideoEvent(event, maybeUid);
|
const result = await this.createVideoEvent(evt);
|
||||||
if (result.videoCallData) {
|
if (result.videoCallData) {
|
||||||
optionalVideoCallData = result.videoCallData;
|
evt = { ...evt, videoCallData: result.videoCallData };
|
||||||
}
|
}
|
||||||
results.push(result);
|
results.push(result);
|
||||||
} else {
|
} else {
|
||||||
await EventManager.sendAttendeeMail("new", results, event, maybeUid);
|
await EventManager.sendAttendeeMail("new", results, evt);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Now create all calendar events. If this is a dedicated integration event,
|
// Now create all calendar events. If this is a dedicated integration event,
|
||||||
// don't send a mail right here, because it has already been sent.
|
// don't send a mail right here, because it has already been sent.
|
||||||
results = results.concat(
|
results = results.concat(await this.createAllCalendarEvents(evt, isDedicated));
|
||||||
await this.createAllCalendarEvents(event, isDedicated, maybeUid, optionalVideoCallData)
|
|
||||||
);
|
|
||||||
|
|
||||||
const referencesToCreate: Array<PartialReference> = results.map((result: EventResult) => {
|
const referencesToCreate: Array<PartialReference> = results.map((result: EventResult) => {
|
||||||
const isDailyResult = result.type === "daily";
|
const isDailyResult = result.type === "daily";
|
||||||
if (isDailyResult) {
|
let uid = "";
|
||||||
return {
|
if (isDailyResult && result.createdEvent) {
|
||||||
type: result.type,
|
uid = result.createdEvent.name.toString();
|
||||||
uid: result.createdEvent.name.toString(),
|
|
||||||
meetingId: result.videoCallData?.id.toString(),
|
|
||||||
meetingPassword: result.videoCallData?.password,
|
|
||||||
meetingUrl: result.videoCallData?.url,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
if (!isDailyResult) {
|
if (!isDailyResult && result.createdEvent) {
|
||||||
return {
|
uid = result.createdEvent.id.toString();
|
||||||
type: result.type,
|
|
||||||
uid: result.createdEvent.id.toString(),
|
|
||||||
meetingId: result.videoCallData?.id.toString(),
|
|
||||||
meetingPassword: result.videoCallData?.password,
|
|
||||||
meetingUrl: result.videoCallData?.url,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
return {
|
||||||
|
type: result.type,
|
||||||
|
uid,
|
||||||
|
meetingId: result.videoCallData?.id.toString(),
|
||||||
|
meetingPassword: result.videoCallData?.password,
|
||||||
|
meetingUrl: result.videoCallData?.url,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
@ -140,15 +131,18 @@ export default class EventManager {
|
||||||
* given uid using the data delivered in the given CalendarEvent.
|
* given uid using the data delivered in the given CalendarEvent.
|
||||||
*
|
*
|
||||||
* @param event
|
* @param event
|
||||||
* @param rescheduleUid
|
|
||||||
*/
|
*/
|
||||||
public async update(event: CalendarEvent, rescheduleUid: string): Promise<CreateUpdateResult> {
|
public async update(event: CalendarEvent): Promise<CreateUpdateResult> {
|
||||||
event = EventManager.processLocation(event);
|
let evt = EventManager.processLocation(event);
|
||||||
|
|
||||||
|
if (!evt.uid) {
|
||||||
|
throw new Error("missing uid");
|
||||||
|
}
|
||||||
|
|
||||||
// Get details of existing booking.
|
// Get details of existing booking.
|
||||||
const booking = await prisma.booking.findFirst({
|
const booking = await prisma.booking.findFirst({
|
||||||
where: {
|
where: {
|
||||||
uid: rescheduleUid,
|
uid: evt.uid,
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
@ -165,28 +159,30 @@ export default class EventManager {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const isDedicated =
|
if (!booking) {
|
||||||
EventManager.isDedicatedIntegration(event.location) || event.location === dailyLocation;
|
throw new Error("booking not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDedicated = evt.location
|
||||||
|
? EventManager.isDedicatedIntegration(evt.location) || evt.location === dailyLocation
|
||||||
|
: null;
|
||||||
|
|
||||||
let results: Array<EventResult> = [];
|
let results: Array<EventResult> = [];
|
||||||
let optionalVideoCallData: VideoCallData | undefined = undefined;
|
|
||||||
|
|
||||||
// If and only if event type is a dedicated meeting, update the dedicated video meeting.
|
// If and only if event type is a dedicated meeting, update the dedicated video meeting.
|
||||||
if (isDedicated) {
|
if (isDedicated) {
|
||||||
const result = await this.updateVideoEvent(event, booking);
|
const result = await this.updateVideoEvent(evt, booking);
|
||||||
if (result.videoCallData) {
|
if (result.videoCallData) {
|
||||||
optionalVideoCallData = result.videoCallData;
|
evt = { ...evt, videoCallData: result.videoCallData };
|
||||||
}
|
}
|
||||||
results.push(result);
|
results.push(result);
|
||||||
} else {
|
} else {
|
||||||
await EventManager.sendAttendeeMail("reschedule", results, event, rescheduleUid);
|
await EventManager.sendAttendeeMail("reschedule", results, evt);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Now update all calendar events. If this is a dedicated integration event,
|
// Now update all calendar events. If this is a dedicated integration event,
|
||||||
// don't send a mail right here, because it has already been sent.
|
// don't send a mail right here, because it has already been sent.
|
||||||
results = results.concat(
|
results = results.concat(await this.updateAllCalendarEvents(evt, booking, isDedicated));
|
||||||
await this.updateAllCalendarEvents(event, booking, isDedicated, optionalVideoCallData)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Now we can delete the old booking and its references.
|
// Now we can delete the old booking and its references.
|
||||||
const bookingReferenceDeletes = prisma.bookingReference.deleteMany({
|
const bookingReferenceDeletes = prisma.bookingReference.deleteMany({
|
||||||
|
@ -199,11 +195,16 @@ export default class EventManager {
|
||||||
bookingId: booking.id,
|
bookingId: booking.id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const bookingDeletes = prisma.booking.delete({
|
|
||||||
where: {
|
let bookingDeletes = null;
|
||||||
uid: rescheduleUid,
|
|
||||||
},
|
if (evt.uid) {
|
||||||
});
|
bookingDeletes = prisma.booking.delete({
|
||||||
|
where: {
|
||||||
|
uid: evt.uid,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Wait for all deletions to be applied.
|
// Wait for all deletions to be applied.
|
||||||
await Promise.all([bookingReferenceDeletes, attendeeDeletes, bookingDeletes]);
|
await Promise.all([bookingReferenceDeletes, attendeeDeletes, bookingDeletes]);
|
||||||
|
@ -224,19 +225,12 @@ export default class EventManager {
|
||||||
*
|
*
|
||||||
* @param event
|
* @param event
|
||||||
* @param noMail
|
* @param noMail
|
||||||
* @param maybeUid
|
|
||||||
* @param optionalVideoCallData
|
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
|
|
||||||
private createAllCalendarEvents(
|
private createAllCalendarEvents(event: CalendarEvent, noMail: boolean | null): Promise<Array<EventResult>> {
|
||||||
event: CalendarEvent,
|
|
||||||
noMail: boolean,
|
|
||||||
maybeUid?: string,
|
|
||||||
optionalVideoCallData?: VideoCallData
|
|
||||||
): Promise<Array<EventResult>> {
|
|
||||||
return async.mapLimit(this.calendarCredentials, 5, async (credential: Credential) => {
|
return async.mapLimit(this.calendarCredentials, 5, async (credential: Credential) => {
|
||||||
return createEvent(credential, event, noMail, maybeUid, optionalVideoCallData);
|
return createEvent(credential, event, noMail);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -248,6 +242,10 @@ export default class EventManager {
|
||||||
*/
|
*/
|
||||||
|
|
||||||
private getVideoCredential(event: CalendarEvent): Credential | undefined {
|
private getVideoCredential(event: CalendarEvent): Credential | undefined {
|
||||||
|
if (!event.location) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
const integrationName = event.location.replace("integrations:", "");
|
const integrationName = event.location.replace("integrations:", "");
|
||||||
|
|
||||||
return this.videoCredentials.find((credential: Credential) => credential.type.includes(integrationName));
|
return this.videoCredentials.find((credential: Credential) => credential.type.includes(integrationName));
|
||||||
|
@ -259,18 +257,17 @@ export default class EventManager {
|
||||||
* When optional uid is set, it will be used instead of the auto generated uid.
|
* When optional uid is set, it will be used instead of the auto generated uid.
|
||||||
*
|
*
|
||||||
* @param event
|
* @param event
|
||||||
* @param maybeUid
|
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
private createVideoEvent(event: CalendarEvent, maybeUid?: string): Promise<EventResult> {
|
private createVideoEvent(event: CalendarEvent): Promise<EventResult> {
|
||||||
const credential = this.getVideoCredential(event);
|
const credential = this.getVideoCredential(event);
|
||||||
|
|
||||||
const isDaily = event.location === dailyLocation;
|
const isDaily = event.location === dailyLocation;
|
||||||
|
|
||||||
if (credential && !isDaily) {
|
if (credential && !isDaily) {
|
||||||
return createMeeting(credential, event, maybeUid);
|
return createMeeting(credential, event);
|
||||||
} else if (isDaily) {
|
} else if (credential && isDaily) {
|
||||||
return dailyCreateMeeting(credential, event, maybeUid);
|
return dailyCreateMeeting(credential, event);
|
||||||
} else {
|
} else {
|
||||||
return Promise.reject("No suitable credentials given for the requested integration name.");
|
return Promise.reject("No suitable credentials given for the requested integration name.");
|
||||||
}
|
}
|
||||||
|
@ -289,13 +286,15 @@ export default class EventManager {
|
||||||
*/
|
*/
|
||||||
private updateAllCalendarEvents(
|
private updateAllCalendarEvents(
|
||||||
event: CalendarEvent,
|
event: CalendarEvent,
|
||||||
booking: PartialBooking,
|
booking: PartialBooking | null,
|
||||||
noMail: boolean,
|
noMail: boolean | null
|
||||||
optionalVideoCallData?: VideoCallData
|
|
||||||
): Promise<Array<EventResult>> {
|
): Promise<Array<EventResult>> {
|
||||||
return async.mapLimit(this.calendarCredentials, 5, async (credential) => {
|
return async.mapLimit(this.calendarCredentials, 5, async (credential) => {
|
||||||
const bookingRefUid = booking.references.filter((ref) => ref.type === credential.type)[0]?.uid;
|
const bookingRefUid = booking
|
||||||
return updateEvent(credential, bookingRefUid, event, noMail, optionalVideoCallData);
|
? booking.references.filter((ref) => ref.type === credential.type)[0]?.uid
|
||||||
|
: null;
|
||||||
|
const evt = { ...event, uid: bookingRefUid };
|
||||||
|
return updateEvent(credential, evt, noMail);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -311,9 +310,9 @@ export default class EventManager {
|
||||||
const isDaily = event.location === dailyLocation;
|
const isDaily = event.location === dailyLocation;
|
||||||
|
|
||||||
if (credential && !isDaily) {
|
if (credential && !isDaily) {
|
||||||
const bookingRef = booking.references.filter((ref) => ref.type === credential.type)[0];
|
const bookingRef = booking ? booking.references.filter((ref) => ref.type === credential.type)[0] : null;
|
||||||
|
const evt = { ...event, uid: bookingRef?.uid };
|
||||||
return updateMeeting(credential, bookingRef.uid, event).then((returnVal: EventResult) => {
|
return updateMeeting(credential, evt).then((returnVal: EventResult) => {
|
||||||
// Some video integrations, such as Zoom, don't return any data about the booking when updating it.
|
// Some video integrations, such as Zoom, don't return any data about the booking when updating it.
|
||||||
if (returnVal.videoCallData == undefined) {
|
if (returnVal.videoCallData == undefined) {
|
||||||
returnVal.videoCallData = EventManager.bookingReferenceToVideoCallData(bookingRef);
|
returnVal.videoCallData = EventManager.bookingReferenceToVideoCallData(bookingRef);
|
||||||
|
@ -321,9 +320,12 @@ export default class EventManager {
|
||||||
return returnVal;
|
return returnVal;
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
if (isDaily) {
|
if (credential && isDaily) {
|
||||||
const bookingRefUid = booking.references.filter((ref) => ref.type === "daily")[0].uid;
|
const bookingRefUid = booking
|
||||||
return dailyUpdateMeeting(credential, bookingRefUid, event);
|
? booking.references.filter((ref) => ref.type === "daily")[0].uid
|
||||||
|
: null;
|
||||||
|
const evt = { ...event, uid: bookingRefUid };
|
||||||
|
return dailyUpdateMeeting(credential, evt);
|
||||||
}
|
}
|
||||||
return Promise.reject("No suitable credentials given for the requested integration name.");
|
return Promise.reject("No suitable credentials given for the requested integration name.");
|
||||||
}
|
}
|
||||||
|
@ -405,9 +407,15 @@ export default class EventManager {
|
||||||
* @param reference
|
* @param reference
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
private static bookingReferenceToVideoCallData(reference: PartialReference): VideoCallData | undefined {
|
private static bookingReferenceToVideoCallData(
|
||||||
|
reference: PartialReference | null
|
||||||
|
): VideoCallData | undefined {
|
||||||
let isComplete = true;
|
let isComplete = true;
|
||||||
|
|
||||||
|
if (!reference) {
|
||||||
|
throw new Error("missing reference");
|
||||||
|
}
|
||||||
|
|
||||||
switch (reference.type) {
|
switch (reference.type) {
|
||||||
case "zoom_video":
|
case "zoom_video":
|
||||||
// Zoom meetings in our system should always have an ID, a password and a join URL. In the
|
// Zoom meetings in our system should always have an ID, a password and a join URL. In the
|
||||||
|
@ -441,33 +449,33 @@ export default class EventManager {
|
||||||
* @param type
|
* @param type
|
||||||
* @param results
|
* @param results
|
||||||
* @param event
|
* @param event
|
||||||
* @param maybeUid
|
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
private static async sendAttendeeMail(
|
private static async sendAttendeeMail(
|
||||||
type: "new" | "reschedule",
|
type: "new" | "reschedule",
|
||||||
results: Array<EventResult>,
|
results: Array<EventResult>,
|
||||||
event: CalendarEvent,
|
event: CalendarEvent
|
||||||
maybeUid?: string
|
|
||||||
) {
|
) {
|
||||||
if (
|
if (
|
||||||
!results.length ||
|
!results.length ||
|
||||||
!results.some((eRes) => (eRes.createdEvent || eRes.updatedEvent).disableConfirmationEmail)
|
!results.some((eRes) => (eRes.createdEvent || eRes.updatedEvent)?.disableConfirmationEmail)
|
||||||
) {
|
) {
|
||||||
const metadata: { hangoutLink?: string; conferenceData?: unknown; entryPoints?: unknown } = {};
|
const metadata: AdditionInformation = {};
|
||||||
if (results.length) {
|
if (results.length) {
|
||||||
// TODO: Handle created event metadata more elegantly
|
// TODO: Handle created event metadata more elegantly
|
||||||
metadata.hangoutLink = results[0].createdEvent?.hangoutLink;
|
metadata.hangoutLink = results[0].createdEvent?.hangoutLink;
|
||||||
metadata.conferenceData = results[0].createdEvent?.conferenceData;
|
metadata.conferenceData = results[0].createdEvent?.conferenceData;
|
||||||
metadata.entryPoints = results[0].createdEvent?.entryPoints;
|
metadata.entryPoints = results[0].createdEvent?.entryPoints;
|
||||||
}
|
}
|
||||||
|
const emailEvent = { ...event, additionInformation: metadata };
|
||||||
|
|
||||||
let attendeeMail;
|
let attendeeMail;
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case "reschedule":
|
case "reschedule":
|
||||||
attendeeMail = new EventAttendeeRescheduledMail(event, maybeUid, metadata);
|
attendeeMail = new EventAttendeeRescheduledMail(emailEvent);
|
||||||
break;
|
break;
|
||||||
case "new":
|
case "new":
|
||||||
attendeeMail = new EventAttendeeMail(event, maybeUid, metadata);
|
attendeeMail = new EventAttendeeMail(emailEvent);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
|
|
@ -1,20 +1,28 @@
|
||||||
import buildMessageTemplate from "../../emails/buildMessageTemplate";
|
import { TFunction } from "next-i18next";
|
||||||
|
|
||||||
export const forgotPasswordSubjectTemplate = "Forgot your password? - Cal.com";
|
import { buildMessageTemplate, VarType } from "../../emails/buildMessageTemplate";
|
||||||
|
|
||||||
export const forgotPasswordMessageTemplate = `Hey there,
|
export const forgotPasswordSubjectTemplate = (t: TFunction): string => {
|
||||||
|
const text = t("forgot_your_password_calcom");
|
||||||
|
return text;
|
||||||
|
};
|
||||||
|
|
||||||
Use the link below to reset your password.
|
export const forgotPasswordMessageTemplate = (t: TFunction): string => {
|
||||||
{{link}}
|
const text = `${t("hey_there")}
|
||||||
|
|
||||||
p.s. It expires in 6 hours.
|
${t("use_link_to_reset_password")}
|
||||||
|
{{link}}
|
||||||
|
|
||||||
- Cal.com`;
|
${t("link_expires", { expiresIn: 6 })}
|
||||||
|
|
||||||
export const buildForgotPasswordMessage = (vars) => {
|
- Cal.com`;
|
||||||
|
return text;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildForgotPasswordMessage = (vars: VarType) => {
|
||||||
return buildMessageTemplate({
|
return buildMessageTemplate({
|
||||||
subjectTemplate: forgotPasswordSubjectTemplate,
|
subjectTemplate: forgotPasswordSubjectTemplate(vars.language),
|
||||||
messageTemplate: forgotPasswordMessageTemplate,
|
messageTemplate: forgotPasswordMessageTemplate(vars.language),
|
||||||
vars,
|
vars,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
|
@ -15,6 +15,7 @@ export type BookingCreateBody = {
|
||||||
timeZone: string;
|
timeZone: string;
|
||||||
users?: string[];
|
users?: string[];
|
||||||
user?: string;
|
user?: string;
|
||||||
|
language: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type BookingResponse = Booking & {
|
export type BookingResponse = Booking & {
|
||||||
|
|
|
@ -3,12 +3,11 @@ import short from "short-uuid";
|
||||||
import { v5 as uuidv5 } from "uuid";
|
import { v5 as uuidv5 } from "uuid";
|
||||||
|
|
||||||
import CalEventParser from "@lib/CalEventParser";
|
import CalEventParser from "@lib/CalEventParser";
|
||||||
import { AdditionInformation, EntryPoint } from "@lib/emails/EventMail";
|
|
||||||
import { getIntegrationName } from "@lib/emails/helpers";
|
import { getIntegrationName } from "@lib/emails/helpers";
|
||||||
import { EventResult } from "@lib/events/EventManager";
|
import { EventResult } from "@lib/events/EventManager";
|
||||||
import logger from "@lib/logger";
|
import logger from "@lib/logger";
|
||||||
|
|
||||||
import { CalendarEvent } from "./calendarClient";
|
import { CalendarEvent, AdditionInformation, EntryPoint } from "./calendarClient";
|
||||||
import EventAttendeeRescheduledMail from "./emails/EventAttendeeRescheduledMail";
|
import EventAttendeeRescheduledMail from "./emails/EventAttendeeRescheduledMail";
|
||||||
import EventOrganizerRescheduledMail from "./emails/EventOrganizerRescheduledMail";
|
import EventOrganizerRescheduledMail from "./emails/EventOrganizerRescheduledMail";
|
||||||
import VideoEventAttendeeMail from "./emails/VideoEventAttendeeMail";
|
import VideoEventAttendeeMail from "./emails/VideoEventAttendeeMail";
|
||||||
|
@ -34,7 +33,7 @@ export interface VideoCallData {
|
||||||
url: string;
|
url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleErrorsJson(response) {
|
function handleErrorsJson(response: Response) {
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
response.json().then(console.log);
|
response.json().then(console.log);
|
||||||
throw Error(response.statusText);
|
throw Error(response.statusText);
|
||||||
|
@ -42,7 +41,7 @@ function handleErrorsJson(response) {
|
||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleErrorsRaw(response) {
|
function handleErrorsRaw(response: Response) {
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
response.text().then(console.log);
|
response.text().then(console.log);
|
||||||
throw Error(response.statusText);
|
throw Error(response.statusText);
|
||||||
|
@ -216,12 +215,8 @@ const getBusyVideoTimes: (withCredentials: Credential[]) => Promise<unknown[]> =
|
||||||
results.reduce((acc, availability) => acc.concat(availability), [])
|
results.reduce((acc, availability) => acc.concat(availability), [])
|
||||||
);
|
);
|
||||||
|
|
||||||
const createMeeting = async (
|
const createMeeting = async (credential: Credential, calEvent: CalendarEvent): Promise<EventResult> => {
|
||||||
credential: Credential,
|
const parser: CalEventParser = new CalEventParser(calEvent);
|
||||||
calEvent: CalendarEvent,
|
|
||||||
maybeUid?: string
|
|
||||||
): Promise<EventResult> => {
|
|
||||||
const parser: CalEventParser = new CalEventParser(calEvent, maybeUid);
|
|
||||||
const uid: string = parser.getUid();
|
const uid: string = parser.getUid();
|
||||||
|
|
||||||
if (!credential) {
|
if (!credential) {
|
||||||
|
@ -249,7 +244,7 @@ const createMeeting = async (
|
||||||
const entryPoint: EntryPoint = {
|
const entryPoint: EntryPoint = {
|
||||||
entryPointType: getIntegrationName(videoCallData),
|
entryPointType: getIntegrationName(videoCallData),
|
||||||
uri: videoCallData.url,
|
uri: videoCallData.url,
|
||||||
label: "Enter Meeting",
|
label: calEvent.language("enter_meeting"),
|
||||||
pin: videoCallData.password,
|
pin: videoCallData.password,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -257,9 +252,10 @@ const createMeeting = async (
|
||||||
entryPoints: [entryPoint],
|
entryPoints: [entryPoint],
|
||||||
};
|
};
|
||||||
|
|
||||||
const organizerMail = new VideoEventOrganizerMail(calEvent, uid, videoCallData, additionInformation);
|
const emailEvent = { ...calEvent, uid, additionInformation, videoCallData };
|
||||||
const attendeeMail = new VideoEventAttendeeMail(calEvent, uid, videoCallData, additionInformation);
|
|
||||||
try {
|
try {
|
||||||
|
const organizerMail = new VideoEventOrganizerMail(emailEvent);
|
||||||
await organizerMail.sendEmail();
|
await organizerMail.sendEmail();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("organizerMail.sendEmail failed", e);
|
console.error("organizerMail.sendEmail failed", e);
|
||||||
|
@ -267,6 +263,7 @@ const createMeeting = async (
|
||||||
|
|
||||||
if (!creationResult || !creationResult.disableConfirmationEmail) {
|
if (!creationResult || !creationResult.disableConfirmationEmail) {
|
||||||
try {
|
try {
|
||||||
|
const attendeeMail = new VideoEventAttendeeMail(emailEvent);
|
||||||
await attendeeMail.sendEmail();
|
await attendeeMail.sendEmail();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("attendeeMail.sendEmail failed", e);
|
console.error("attendeeMail.sendEmail failed", e);
|
||||||
|
@ -283,11 +280,7 @@ const createMeeting = async (
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateMeeting = async (
|
const updateMeeting = async (credential: Credential, calEvent: CalendarEvent): Promise<EventResult> => {
|
||||||
credential: Credential,
|
|
||||||
uidToUpdate: string,
|
|
||||||
calEvent: CalendarEvent
|
|
||||||
): Promise<EventResult> => {
|
|
||||||
const newUid: string = translator.fromUUID(uuidv5(JSON.stringify(calEvent), uuidv5.URL));
|
const newUid: string = translator.fromUUID(uuidv5(JSON.stringify(calEvent), uuidv5.URL));
|
||||||
|
|
||||||
if (!credential) {
|
if (!credential) {
|
||||||
|
@ -298,18 +291,20 @@ const updateMeeting = async (
|
||||||
|
|
||||||
let success = true;
|
let success = true;
|
||||||
|
|
||||||
const updateResult = credential
|
const updateResult =
|
||||||
? await videoIntegrations([credential])[0]
|
credential && calEvent.uid
|
||||||
.updateMeeting(uidToUpdate, calEvent)
|
? await videoIntegrations([credential])[0]
|
||||||
.catch((e) => {
|
.updateMeeting(calEvent.uid, calEvent)
|
||||||
log.error("updateMeeting failed", e, calEvent);
|
.catch((e) => {
|
||||||
success = false;
|
log.error("updateMeeting failed", e, calEvent);
|
||||||
})
|
success = false;
|
||||||
: null;
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const emailEvent = { ...calEvent, uid: newUid };
|
||||||
|
|
||||||
const organizerMail = new EventOrganizerRescheduledMail(calEvent, newUid);
|
|
||||||
const attendeeMail = new EventAttendeeRescheduledMail(calEvent, newUid);
|
|
||||||
try {
|
try {
|
||||||
|
const organizerMail = new EventOrganizerRescheduledMail(emailEvent);
|
||||||
await organizerMail.sendEmail();
|
await organizerMail.sendEmail();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("organizerMail.sendEmail failed", e);
|
console.error("organizerMail.sendEmail failed", e);
|
||||||
|
@ -317,6 +312,7 @@ const updateMeeting = async (
|
||||||
|
|
||||||
if (!updateResult || !updateResult.disableConfirmationEmail) {
|
if (!updateResult || !updateResult.disableConfirmationEmail) {
|
||||||
try {
|
try {
|
||||||
|
const attendeeMail = new EventAttendeeRescheduledMail(emailEvent);
|
||||||
await attendeeMail.sendEmail();
|
await attendeeMail.sendEmail();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("attendeeMail.sendEmail failed", e);
|
console.error("attendeeMail.sendEmail failed", e);
|
||||||
|
|
|
@ -1,17 +1,21 @@
|
||||||
import { User, ResetPasswordRequest } from "@prisma/client";
|
import { ResetPasswordRequest } from "@prisma/client";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import timezone from "dayjs/plugin/timezone";
|
import timezone from "dayjs/plugin/timezone";
|
||||||
import utc from "dayjs/plugin/utc";
|
import utc from "dayjs/plugin/utc";
|
||||||
import { NextApiRequest, NextApiResponse } from "next";
|
import { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
import sendEmail from "../../../lib/emails/sendMail";
|
import sendEmail from "@lib/emails/sendMail";
|
||||||
import { buildForgotPasswordMessage } from "../../../lib/forgot-password/messaging/forgot-password";
|
import { buildForgotPasswordMessage } from "@lib/forgot-password/messaging/forgot-password";
|
||||||
import prisma from "../../../lib/prisma";
|
import prisma from "@lib/prisma";
|
||||||
|
|
||||||
|
import { getTranslation } from "@server/lib/i18n";
|
||||||
|
|
||||||
dayjs.extend(utc);
|
dayjs.extend(utc);
|
||||||
dayjs.extend(timezone);
|
dayjs.extend(timezone);
|
||||||
|
|
||||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
const t = await getTranslation(req.body.language ?? "en", "common");
|
||||||
|
|
||||||
if (req.method !== "POST") {
|
if (req.method !== "POST") {
|
||||||
return res.status(405).json({ message: "" });
|
return res.status(405).json({ message: "" });
|
||||||
}
|
}
|
||||||
|
@ -19,7 +23,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
try {
|
try {
|
||||||
const rawEmail = req.body?.email;
|
const rawEmail = req.body?.email;
|
||||||
|
|
||||||
const maybeUser: User = await prisma.user.findUnique({
|
const maybeUser = await prisma.user.findUnique({
|
||||||
where: {
|
where: {
|
||||||
email: rawEmail,
|
email: rawEmail,
|
||||||
},
|
},
|
||||||
|
@ -59,6 +63,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
|
|
||||||
const passwordResetLink = `${process.env.BASE_URL}/auth/forgot-password/${passwordRequest.id}`;
|
const passwordResetLink = `${process.env.BASE_URL}/auth/forgot-password/${passwordRequest.id}`;
|
||||||
const { subject, message } = buildForgotPasswordMessage({
|
const { subject, message } = buildForgotPasswordMessage({
|
||||||
|
language: t,
|
||||||
user: {
|
user: {
|
||||||
name: maybeUser.name,
|
name: maybeUser.name,
|
||||||
},
|
},
|
||||||
|
|
|
@ -6,12 +6,15 @@ import { getSession } from "@lib/auth";
|
||||||
import { CalendarEvent } from "@lib/calendarClient";
|
import { CalendarEvent } from "@lib/calendarClient";
|
||||||
import EventRejectionMail from "@lib/emails/EventRejectionMail";
|
import EventRejectionMail from "@lib/emails/EventRejectionMail";
|
||||||
import EventManager from "@lib/events/EventManager";
|
import EventManager from "@lib/events/EventManager";
|
||||||
|
import prisma from "@lib/prisma";
|
||||||
|
|
||||||
import prisma from "../../../lib/prisma";
|
import { getTranslation } from "@server/lib/i18n";
|
||||||
|
|
||||||
export default async function handler(req: NextApiRequest, res: NextApiResponse): Promise<void> {
|
export default async function handler(req: NextApiRequest, res: NextApiResponse): Promise<void> {
|
||||||
|
const t = await getTranslation(req.body.language ?? "en", "common");
|
||||||
|
|
||||||
const session = await getSession({ req: req });
|
const session = await getSession({ req: req });
|
||||||
if (!session) {
|
if (!session?.user?.id) {
|
||||||
return res.status(401).json({ message: "Not authenticated" });
|
return res.status(401).json({ message: "Not authenticated" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -33,6 +36,10 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!currentUser) {
|
||||||
|
return res.status(404).json({ message: "User not found" });
|
||||||
|
}
|
||||||
|
|
||||||
if (req.method == "PATCH") {
|
if (req.method == "PATCH") {
|
||||||
const booking = await prisma.booking.findFirst({
|
const booking = await prisma.booking.findFirst({
|
||||||
where: {
|
where: {
|
||||||
|
@ -66,14 +73,16 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
description: booking.description,
|
description: booking.description,
|
||||||
startTime: booking.startTime.toISOString(),
|
startTime: booking.startTime.toISOString(),
|
||||||
endTime: booking.endTime.toISOString(),
|
endTime: booking.endTime.toISOString(),
|
||||||
organizer: { email: currentUser.email, name: currentUser.name, timeZone: currentUser.timeZone },
|
organizer: { email: currentUser.email, name: currentUser.name!, timeZone: currentUser.timeZone },
|
||||||
attendees: booking.attendees,
|
attendees: booking.attendees,
|
||||||
location: booking.location,
|
location: booking.location,
|
||||||
|
uid: booking.uid,
|
||||||
|
language: t,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (req.body.confirmed) {
|
if (req.body.confirmed) {
|
||||||
const eventManager = new EventManager(currentUser.credentials);
|
const eventManager = new EventManager(currentUser.credentials);
|
||||||
const scheduleResult = await eventManager.create(evt, booking.uid);
|
const scheduleResult = await eventManager.create(evt);
|
||||||
|
|
||||||
await prisma.booking.update({
|
await prisma.booking.update({
|
||||||
where: {
|
where: {
|
||||||
|
@ -99,8 +108,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
rejected: true,
|
rejected: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const attendeeMail = new EventRejectionMail(evt, booking.uid);
|
const attendeeMail = new EventRejectionMail(evt);
|
||||||
await attendeeMail.sendEmail();
|
await attendeeMail.sendEmail();
|
||||||
|
|
||||||
res.status(204).json({ message: "ok" });
|
res.status(204).json({ message: "ok" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,7 +6,6 @@ import isBetween from "dayjs/plugin/isBetween";
|
||||||
import timezone from "dayjs/plugin/timezone";
|
import timezone from "dayjs/plugin/timezone";
|
||||||
import utc from "dayjs/plugin/utc";
|
import utc from "dayjs/plugin/utc";
|
||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
import { getErrorFromUnknown } from "pages/_error";
|
|
||||||
import short from "short-uuid";
|
import short from "short-uuid";
|
||||||
import { v5 as uuidv5 } from "uuid";
|
import { v5 as uuidv5 } from "uuid";
|
||||||
|
|
||||||
|
@ -14,6 +13,7 @@ import { handlePayment } from "@ee/lib/stripe/server";
|
||||||
|
|
||||||
import { CalendarEvent, getBusyCalendarTimes } from "@lib/calendarClient";
|
import { CalendarEvent, getBusyCalendarTimes } from "@lib/calendarClient";
|
||||||
import EventOrganizerRequestMail from "@lib/emails/EventOrganizerRequestMail";
|
import EventOrganizerRequestMail from "@lib/emails/EventOrganizerRequestMail";
|
||||||
|
import { getErrorFromUnknown } from "@lib/errors";
|
||||||
import { getEventName } from "@lib/event";
|
import { getEventName } from "@lib/event";
|
||||||
import EventManager, { CreateUpdateResult, EventResult, PartialReference } from "@lib/events/EventManager";
|
import EventManager, { CreateUpdateResult, EventResult, PartialReference } from "@lib/events/EventManager";
|
||||||
import logger from "@lib/logger";
|
import logger from "@lib/logger";
|
||||||
|
@ -23,6 +23,8 @@ import { getBusyVideoTimes } from "@lib/videoClient";
|
||||||
import sendPayload from "@lib/webhooks/sendPayload";
|
import sendPayload from "@lib/webhooks/sendPayload";
|
||||||
import getSubscriberUrls from "@lib/webhooks/subscriberUrls";
|
import getSubscriberUrls from "@lib/webhooks/subscriberUrls";
|
||||||
|
|
||||||
|
import { getTranslation } from "@server/lib/i18n";
|
||||||
|
|
||||||
export interface DailyReturnType {
|
export interface DailyReturnType {
|
||||||
name: string;
|
name: string;
|
||||||
url: string;
|
url: string;
|
||||||
|
@ -126,6 +128,7 @@ function isOutOfBounds(
|
||||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
const reqBody = req.body as BookingCreateBody;
|
const reqBody = req.body as BookingCreateBody;
|
||||||
const eventTypeId = reqBody.eventTypeId;
|
const eventTypeId = reqBody.eventTypeId;
|
||||||
|
const t = await getTranslation(reqBody.language ?? "en", "common");
|
||||||
|
|
||||||
log.debug(`Booking eventType ${eventTypeId} started`);
|
log.debug(`Booking eventType ${eventTypeId} started`);
|
||||||
|
|
||||||
|
@ -273,6 +276,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
},
|
},
|
||||||
attendees: attendeesList,
|
attendees: attendeesList,
|
||||||
location: reqBody.location, // Will be processed by the EventManager later.
|
location: reqBody.location, // Will be processed by the EventManager later.
|
||||||
|
language: t,
|
||||||
|
uid,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (eventType.schedulingType === SchedulingType.COLLECTIVE) {
|
if (eventType.schedulingType === SchedulingType.COLLECTIVE) {
|
||||||
|
@ -425,7 +430,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
|
|
||||||
if (rescheduleUid) {
|
if (rescheduleUid) {
|
||||||
// Use EventManager to conditionally use all needed integrations.
|
// Use EventManager to conditionally use all needed integrations.
|
||||||
const updateResults: CreateUpdateResult = await eventManager.update(evt, rescheduleUid);
|
const eventManagerCalendarEvent = { ...evt, uid: rescheduleUid };
|
||||||
|
const updateResults: CreateUpdateResult = await eventManager.update(eventManagerCalendarEvent);
|
||||||
|
|
||||||
results = updateResults.results;
|
results = updateResults.results;
|
||||||
referencesToCreate = updateResults.referencesToCreate;
|
referencesToCreate = updateResults.referencesToCreate;
|
||||||
|
@ -440,7 +446,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
}
|
}
|
||||||
} else if (!eventType.requiresConfirmation && !eventType.price) {
|
} else if (!eventType.requiresConfirmation && !eventType.price) {
|
||||||
// Use EventManager to conditionally use all needed integrations.
|
// Use EventManager to conditionally use all needed integrations.
|
||||||
const createResults: CreateUpdateResult = await eventManager.create(evt, uid);
|
const createResults: CreateUpdateResult = await eventManager.create(evt);
|
||||||
|
|
||||||
results = createResults.results;
|
results = createResults.results;
|
||||||
referencesToCreate = createResults.referencesToCreate;
|
referencesToCreate = createResults.referencesToCreate;
|
||||||
|
@ -496,7 +502,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (eventType.requiresConfirmation && !rescheduleUid) {
|
if (eventType.requiresConfirmation && !rescheduleUid) {
|
||||||
await new EventOrganizerRequestMail(evt, uid).sendEmail();
|
await new EventOrganizerRequestMail({ ...evt, uid }).sendEmail();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof eventType.price === "number" && eventType.price > 0) {
|
if (typeof eventType.price === "number" && eventType.price > 0) {
|
||||||
|
|
|
@ -2,11 +2,14 @@ import { randomBytes } from "crypto";
|
||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
import { getSession } from "@lib/auth";
|
import { getSession } from "@lib/auth";
|
||||||
|
import { createInvitationEmail } from "@lib/emails/invitation";
|
||||||
|
import prisma from "@lib/prisma";
|
||||||
|
|
||||||
import { createInvitationEmail } from "../../../../lib/emails/invitation";
|
import { getTranslation } from "@server/lib/i18n";
|
||||||
import prisma from "../../../../lib/prisma";
|
|
||||||
|
|
||||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
const t = await getTranslation(req.body.language ?? "en", "common");
|
||||||
|
|
||||||
if (req.method !== "POST") {
|
if (req.method !== "POST") {
|
||||||
return res.status(400).json({ message: "Bad request" });
|
return res.status(400).json({ message: "Bad request" });
|
||||||
}
|
}
|
||||||
|
@ -18,7 +21,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
|
|
||||||
const team = await prisma.team.findFirst({
|
const team = await prisma.team.findFirst({
|
||||||
where: {
|
where: {
|
||||||
id: parseInt(req.query.team),
|
id: parseInt(req.query.team as string),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -68,12 +71,15 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
createInvitationEmail({
|
if (session?.user?.name && team?.name) {
|
||||||
toEmail: req.body.usernameOrEmail,
|
createInvitationEmail({
|
||||||
from: session.user.name,
|
language: t,
|
||||||
teamName: team.name,
|
toEmail: req.body.usernameOrEmail,
|
||||||
token,
|
from: session.user.name,
|
||||||
});
|
teamName: team.name,
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return res.status(201).json({});
|
return res.status(201).json({});
|
||||||
}
|
}
|
||||||
|
@ -87,7 +93,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
role: req.body.role,
|
role: req.body.role,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err: any) {
|
||||||
if (err.code === "P2002") {
|
if (err.code === "P2002") {
|
||||||
// unique constraint violation
|
// unique constraint violation
|
||||||
return res.status(409).json({
|
return res.status(409).json({
|
||||||
|
@ -99,8 +105,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
}
|
}
|
||||||
|
|
||||||
// inform user of membership by email
|
// inform user of membership by email
|
||||||
if (req.body.sendEmailInvitation) {
|
if (req.body.sendEmailInvitation && session?.user?.name && team?.name) {
|
||||||
createInvitationEmail({
|
createInvitationEmail({
|
||||||
|
language: t,
|
||||||
toEmail: invitee.email,
|
toEmail: invitee.email,
|
||||||
from: session.user.name,
|
from: session.user.name,
|
||||||
teamName: team.name,
|
teamName: team.name,
|
||||||
|
|
|
@ -1,29 +1,31 @@
|
||||||
import debounce from "lodash/debounce";
|
import debounce from "lodash/debounce";
|
||||||
|
import { GetServerSidePropsContext } from "next";
|
||||||
import { getCsrfToken } from "next-auth/client";
|
import { getCsrfToken } from "next-auth/client";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import React from "react";
|
import React, { SyntheticEvent } from "react";
|
||||||
|
|
||||||
import { getSession } from "@lib/auth";
|
import { getSession } from "@lib/auth";
|
||||||
import { useLocale } from "@lib/hooks/useLocale";
|
import { useLocale } from "@lib/hooks/useLocale";
|
||||||
|
|
||||||
import { HeadSeo } from "@components/seo/head-seo";
|
import { HeadSeo } from "@components/seo/head-seo";
|
||||||
|
|
||||||
export default function ForgotPassword({ csrfToken }) {
|
export default function ForgotPassword({ csrfToken }: { csrfToken: string }) {
|
||||||
const { t } = useLocale();
|
const { t, i18n } = useLocale();
|
||||||
const [loading, setLoading] = React.useState(false);
|
const [loading, setLoading] = React.useState(false);
|
||||||
const [error, setError] = React.useState(null);
|
const [error, setError] = React.useState<{ message: string } | null>(null);
|
||||||
const [success, setSuccess] = React.useState(false);
|
const [success, setSuccess] = React.useState(false);
|
||||||
const [email, setEmail] = React.useState("");
|
const [email, setEmail] = React.useState("");
|
||||||
|
|
||||||
const handleChange = (e) => {
|
const handleChange = (e: SyntheticEvent) => {
|
||||||
setEmail(e.target.value);
|
const target = e.target as typeof e.target & { value: string };
|
||||||
|
setEmail(target.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
const submitForgotPasswordRequest = async ({ email }) => {
|
const submitForgotPasswordRequest = async ({ email }: { email: string }) => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/auth/forgot-password", {
|
const res = await fetch("/api/auth/forgot-password", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ email: email }),
|
body: JSON.stringify({ email: email, language: i18n.language }),
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
|
@ -46,7 +48,7 @@ export default function ForgotPassword({ csrfToken }) {
|
||||||
|
|
||||||
const debouncedHandleSubmitPasswordRequest = debounce(submitForgotPasswordRequest, 250);
|
const debouncedHandleSubmitPasswordRequest = debounce(submitForgotPasswordRequest, 250);
|
||||||
|
|
||||||
const handleSubmit = async (e) => {
|
const handleSubmit = async (e: SyntheticEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (!email) {
|
if (!email) {
|
||||||
|
@ -157,7 +159,7 @@ export default function ForgotPassword({ csrfToken }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
ForgotPassword.getInitialProps = async (context) => {
|
ForgotPassword.getInitialProps = async (context: GetServerSidePropsContext) => {
|
||||||
const { req, res } = context;
|
const { req, res } = context;
|
||||||
const session = await getSession({ req });
|
const session = await getSession({ req });
|
||||||
|
|
||||||
|
|
|
@ -63,32 +63,35 @@ import * as RadioArea from "@components/ui/form/radio-area";
|
||||||
dayjs.extend(utc);
|
dayjs.extend(utc);
|
||||||
dayjs.extend(timezone);
|
dayjs.extend(timezone);
|
||||||
|
|
||||||
const PERIOD_TYPES = [
|
|
||||||
{
|
|
||||||
type: "rolling",
|
|
||||||
suffix: "into the future",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "range",
|
|
||||||
prefix: "Within a date range",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "unlimited",
|
|
||||||
prefix: "Indefinitely into the future",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||||
|
const { t } = useLocale();
|
||||||
|
const PERIOD_TYPES = [
|
||||||
|
{
|
||||||
|
type: "rolling",
|
||||||
|
suffix: t("into_the_future"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "range",
|
||||||
|
prefix: t("within_date_range"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "unlimited",
|
||||||
|
prefix: t("indefinitely_into_future"),
|
||||||
|
},
|
||||||
|
];
|
||||||
const { eventType, locationOptions, availability, team, teamMembers, hasPaymentIntegration, currency } =
|
const { eventType, locationOptions, availability, team, teamMembers, hasPaymentIntegration, currency } =
|
||||||
props;
|
props;
|
||||||
|
locationOptions.push(
|
||||||
|
{ value: LocationType.InPerson, label: t("in_person_meeting") },
|
||||||
|
{ value: LocationType.Phone, label: t("phone_call") }
|
||||||
|
);
|
||||||
|
|
||||||
const { t } = useLocale();
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const updateMutation = useMutation(updateEventType, {
|
const updateMutation = useMutation(updateEventType, {
|
||||||
onSuccess: async ({ eventType }) => {
|
onSuccess: async ({ eventType }) => {
|
||||||
await router.push("/event-types");
|
await router.push("/event-types");
|
||||||
showToast(`${eventType.title} event type updated successfully`, "success");
|
showToast(t("event_type_updated_successfully", { eventTypeTitle: eventType.title }), "success");
|
||||||
},
|
},
|
||||||
onError: (err: HttpError) => {
|
onError: (err: HttpError) => {
|
||||||
const message = `${err.statusCode}: ${err.message}`;
|
const message = `${err.statusCode}: ${err.message}`;
|
||||||
|
@ -99,7 +102,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||||
const deleteMutation = useMutation(deleteEventType, {
|
const deleteMutation = useMutation(deleteEventType, {
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
await router.push("/event-types");
|
await router.push("/event-types");
|
||||||
showToast("Event type deleted successfully", "success");
|
showToast(t("event_type_deleted_successfully"), "success");
|
||||||
},
|
},
|
||||||
onError: (err: HttpError) => {
|
onError: (err: HttpError) => {
|
||||||
const message = `${err.statusCode}: ${err.message}`;
|
const message = `${err.statusCode}: ${err.message}`;
|
||||||
|
@ -274,13 +277,13 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||||
const schedulingTypeOptions: { value: SchedulingType; label: string; description: string }[] = [
|
const schedulingTypeOptions: { value: SchedulingType; label: string; description: string }[] = [
|
||||||
{
|
{
|
||||||
value: SchedulingType.COLLECTIVE,
|
value: SchedulingType.COLLECTIVE,
|
||||||
label: "Collective",
|
label: t("collective"),
|
||||||
description: "Schedule meetings when all selected team members are available.",
|
description: t("collective_description"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: SchedulingType.ROUND_ROBIN,
|
value: SchedulingType.ROUND_ROBIN,
|
||||||
label: "Round Robin",
|
label: t("round_robin"),
|
||||||
description: "Cycle meetings between multiple team members.",
|
description: t("round_robin_description"),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@ -311,7 +314,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||||
<div>
|
<div>
|
||||||
<Shell
|
<Shell
|
||||||
centered
|
centered
|
||||||
title={`${eventType.title} | Event Type`}
|
title={t("event_type_title", { eventTypeTitle: eventType.title })}
|
||||||
heading={
|
heading={
|
||||||
<div className="relative -mb-2 group" onClick={() => setEditIcon(false)}>
|
<div className="relative -mb-2 group" onClick={() => setEditIcon(false)}>
|
||||||
<input
|
<input
|
||||||
|
@ -321,7 +324,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||||
id="title"
|
id="title"
|
||||||
required
|
required
|
||||||
className="w-full pl-0 text-xl font-bold text-gray-900 bg-transparent border-none cursor-pointer focus:text-black hover:text-gray-700 focus:ring-0 focus:outline-none"
|
className="w-full pl-0 text-xl font-bold text-gray-900 bg-transparent border-none cursor-pointer focus:text-black hover:text-gray-700 focus:ring-0 focus:outline-none"
|
||||||
placeholder="Quick Chat"
|
placeholder={t("quick_chat")}
|
||||||
defaultValue={eventType.title}
|
defaultValue={eventType.title}
|
||||||
/>
|
/>
|
||||||
{editIcon && (
|
{editIcon && (
|
||||||
|
@ -491,7 +494,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||||
fillRule="evenodd"></path>
|
fillRule="evenodd"></path>
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
<span className="ml-2 text-sm"> Daily.co Video</span>
|
<span className="ml-2 text-sm">Daily.co Video</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{location.type === LocationType.Zoom && (
|
{location.type === LocationType.Zoom && (
|
||||||
|
@ -682,7 +685,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="ml-2 text-sm">
|
<span className="ml-2 text-sm">
|
||||||
{customInput.required ? "Required" : "Optional"}
|
{customInput.required ? t("required") : t("optional")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1228,10 +1231,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||||
|
|
||||||
const integrations = getIntegrations(credentials);
|
const integrations = getIntegrations(credentials);
|
||||||
|
|
||||||
const locationOptions: OptionTypeBase[] = [
|
const locationOptions: OptionTypeBase[] = [];
|
||||||
{ value: LocationType.InPerson, label: "Link or In-person meeting" },
|
|
||||||
{ value: LocationType.Phone, label: "Phone call" },
|
|
||||||
];
|
|
||||||
|
|
||||||
if (hasIntegration(integrations, "zoom_video")) {
|
if (hasIntegration(integrations, "zoom_video")) {
|
||||||
locationOptions.push({ value: LocationType.Zoom, label: "Zoom Video", disabled: true });
|
locationOptions.push({ value: LocationType.Zoom, label: "Zoom Video", disabled: true });
|
||||||
|
|
|
@ -344,7 +344,7 @@ const CreateNewEventButton = ({ profiles, canAddEvents }: CreateEventTypeProps)
|
||||||
const createMutation = useMutation(createEventType, {
|
const createMutation = useMutation(createEventType, {
|
||||||
onSuccess: async ({ eventType }) => {
|
onSuccess: async ({ eventType }) => {
|
||||||
await router.push("/event-types/" + eventType.id);
|
await router.push("/event-types/" + eventType.id);
|
||||||
showToast(`${eventType.title} event type created successfully`, "success");
|
showToast(t("event_type_created_successfully", { eventTypeTitle: eventType.title }), "success");
|
||||||
},
|
},
|
||||||
onError: (err: HttpError) => {
|
onError: (err: HttpError) => {
|
||||||
const message = `${err.statusCode}: ${err.message}`;
|
const message = `${err.statusCode}: ${err.message}`;
|
||||||
|
|
|
@ -66,8 +66,10 @@ describe("webhooks", () => {
|
||||||
attendee.timeZone = dynamic;
|
attendee.timeZone = dynamic;
|
||||||
}
|
}
|
||||||
body.payload.organizer.timeZone = dynamic;
|
body.payload.organizer.timeZone = dynamic;
|
||||||
|
body.payload.uid = dynamic;
|
||||||
|
|
||||||
// if we change the shape of our webhooks, we can simply update this by clicking `u`
|
// if we change the shape of our webhooks, we can simply update this by clicking `u`
|
||||||
|
console.log("BODY", body);
|
||||||
expect(body).toMatchInlineSnapshot(`
|
expect(body).toMatchInlineSnapshot(`
|
||||||
Object {
|
Object {
|
||||||
"createdAt": "[redacted/dynamic]",
|
"createdAt": "[redacted/dynamic]",
|
||||||
|
@ -89,6 +91,7 @@ describe("webhooks", () => {
|
||||||
"startTime": "[redacted/dynamic]",
|
"startTime": "[redacted/dynamic]",
|
||||||
"title": "30min with Test Testson",
|
"title": "30min with Test Testson",
|
||||||
"type": "30min",
|
"type": "30min",
|
||||||
|
"uid": "[redacted/dynamic]",
|
||||||
},
|
},
|
||||||
"triggerEvent": "BOOKING_CREATED",
|
"triggerEvent": "BOOKING_CREATED",
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,43 @@
|
||||||
{
|
{
|
||||||
|
"integration_meeting_id": "{{integrationName}} meeting ID: {{meetingId}}",
|
||||||
|
"confirmed_event_type_subject": "Confirmed: {{eventType}} with {{name}} on {{date}}",
|
||||||
|
"new_event_request": "New event request: {{attendeeName}} - {{date}} - {{eventType}}",
|
||||||
|
"confirm_or_reject_booking": "Confirm or reject the booking",
|
||||||
|
"check_bookings_page_to_confirm_or_reject": "Check your bookings page to confirm or reject the booking.",
|
||||||
|
"event_awaiting_approval": "A new event is waiting for your approval",
|
||||||
|
"your_meeting_has_been_booked": "Your meeting has been booked",
|
||||||
|
"event_type_has_been_rescheduled_on_time_date": "Your {{eventType}} with {{name}} has been rescheduled to {{time}} ({{timeZone}}) on {{date}}.",
|
||||||
|
"event_has_been_rescheduled": "Your event has been rescheduled.",
|
||||||
|
"hi_user_name": "Hi {{userName}}",
|
||||||
|
"organizer_ics_event_title": "{{eventType}} with {{attendeeName}}",
|
||||||
|
"new_event_subject": "New event: {{attendeeName}} - {{date}} - {{eventType}}",
|
||||||
|
"join_by_entrypoint": "Join by {{entryPoint}}",
|
||||||
|
"notes": "Notes",
|
||||||
|
"manage_my_bookings": "Manage my bookings",
|
||||||
|
"need_to_make_a_change": "Need to make a change?",
|
||||||
|
"new_event_scheduled": "A new event has been scheduled.",
|
||||||
|
"invitee_email": "Invitee Email",
|
||||||
|
"invitee_timezone": "Invitee Time Zone",
|
||||||
|
"event_type": "Event Type",
|
||||||
|
"enter_meeting": "Enter Meeting",
|
||||||
|
"video_call_provider": "Video call provider",
|
||||||
|
"meeting_id": "Meeting ID",
|
||||||
|
"meeting_password": "Meeting Password",
|
||||||
|
"meeting_url": "Meeting URL",
|
||||||
|
"meeting_request_rejected": "Your meeting request has been rejected",
|
||||||
|
"rescheduled_event_type_with_organizer": "Rescheduled: {{eventType}} with {{organizerName}} on {{date}}",
|
||||||
|
"rescheduled_event_type_with_attendee": "Rescheduled event: {{attendeeName}} - {{date}} - {{eventType}}",
|
||||||
|
"rejected_event_type_with_organizer": "Rejected: {{eventType}} with {{organizer}} on {{date}}",
|
||||||
|
"hi": "Hi",
|
||||||
|
"join_team": "Join team",
|
||||||
|
"request_another_invitation_email": "If you prefer not to use {{toEmail}} as your Cal.com email or already have a Cal.com account, please request another invitation to that email.",
|
||||||
|
"you_have_been_invited": "You have been invited to join the team {{teamName}}",
|
||||||
|
"user_invited_you": "{{user}} invited you to join the team {{teamName}}",
|
||||||
|
"link_expires": "p.s. It expires in {{expiresIn}} hours.",
|
||||||
|
"use_link_to_reset_password": "Use the link below to reset your password",
|
||||||
|
"hey_there": "Hey there,",
|
||||||
|
"forgot_your_password_calcom": "Forgot your password? - Cal.com",
|
||||||
|
"event_type_title": "{{eventTypeTitle}} | Event Type",
|
||||||
"delete_webhook_confirmation_message": "Are you sure you want to delete this webhook? You will no longer receive Cal.com meeting data at a specified URL, in real-time, when an event is scheduled or canceled.",
|
"delete_webhook_confirmation_message": "Are you sure you want to delete this webhook? You will no longer receive Cal.com meeting data at a specified URL, in real-time, when an event is scheduled or canceled.",
|
||||||
"confirm_delete_webhook": "Yes, delete webhook",
|
"confirm_delete_webhook": "Yes, delete webhook",
|
||||||
"edit_webhook": "Edit Webhook",
|
"edit_webhook": "Edit Webhook",
|
||||||
|
@ -36,6 +75,7 @@
|
||||||
"number": "Number",
|
"number": "Number",
|
||||||
"checkbox": "Checkbox",
|
"checkbox": "Checkbox",
|
||||||
"is_required": "Is required",
|
"is_required": "Is required",
|
||||||
|
"required": "Required",
|
||||||
"input_type": "Input type",
|
"input_type": "Input type",
|
||||||
"rejected": "Rejected",
|
"rejected": "Rejected",
|
||||||
"unconfirmed": "Unconfirmed",
|
"unconfirmed": "Unconfirmed",
|
||||||
|
@ -351,6 +391,8 @@
|
||||||
"new_team_event": "Add a new team event type",
|
"new_team_event": "Add a new team event type",
|
||||||
"new_event_description": "Create a new event type for people to book times with.",
|
"new_event_description": "Create a new event type for people to book times with.",
|
||||||
"event_type_created_successfully": "{{eventTypeTitle}} event type created successfully",
|
"event_type_created_successfully": "{{eventTypeTitle}} event type created successfully",
|
||||||
|
"event_type_updated_successfully": "{{eventTypeTitle}} event type updated successfully",
|
||||||
|
"event_type_deleted_successfully": "Event type deleted successfully",
|
||||||
"hours": "Hours",
|
"hours": "Hours",
|
||||||
"your_email": "Your Email",
|
"your_email": "Your Email",
|
||||||
"change_avatar": "Change Avatar",
|
"change_avatar": "Change Avatar",
|
||||||
|
@ -418,7 +460,6 @@
|
||||||
"cal_provide_video_meeting_url": "Cal will provide a Daily video meeting URL.",
|
"cal_provide_video_meeting_url": "Cal will provide a Daily video meeting URL.",
|
||||||
"require_payment": "Require Payment",
|
"require_payment": "Require Payment",
|
||||||
"commission_per_transaction": "commission per transaction",
|
"commission_per_transaction": "commission per transaction",
|
||||||
"event_type_updated_successfully": "Event Type updated successfully",
|
|
||||||
"event_type_updated_successfully_description": "Your event type has been updated successfully.",
|
"event_type_updated_successfully_description": "Your event type has been updated successfully.",
|
||||||
"hide_event_type": "Hide event type",
|
"hide_event_type": "Hide event type",
|
||||||
"edit_location": "Edit location",
|
"edit_location": "Edit location",
|
||||||
|
|
18
server/lib/i18n.ts
Normal file
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 { html, text, Invitation } from "@lib/emails/invitation";
|
||||||
|
|
||||||
|
import { getTranslation } from "@server/lib/i18n";
|
||||||
|
|
||||||
it("email text rendering should strip tags and add new lines", () => {
|
it("email text rendering should strip tags and add new lines", () => {
|
||||||
const result = text("<p>hello world</p><br /><div>welcome to the brave <span>new</span> world");
|
const result = text("<p>hello world</p><br /><div>welcome to the brave <span>new</span> world");
|
||||||
expect(result).toEqual("hello world\nwelcome to the brave new world");
|
expect(result).toEqual("hello world\nwelcome to the brave new world");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("email html should render invite email", () => {
|
it("email html should render invite email", async () => {
|
||||||
|
const t = await getTranslation("en", "common");
|
||||||
const invitation = {
|
const invitation = {
|
||||||
|
language: t,
|
||||||
from: "Huxley",
|
from: "Huxley",
|
||||||
toEmail: "hello@example.com",
|
toEmail: "hello@example.com",
|
||||||
teamName: "Calendar Lovers",
|
teamName: "Calendar Lovers",
|
||||||
token: "invite-token",
|
token: "invite-token",
|
||||||
} as Invitation;
|
} as Invitation;
|
||||||
const result = html(invitation);
|
const result = html(invitation);
|
||||||
expect(result).toContain('<br />Huxley invited you to join the team "Calendar Lovers" in Cal.com.<br />');
|
|
||||||
expect(result).toContain("/auth/signup?token=invite-token&");
|
|
||||||
expect(result).toContain(
|
expect(result).toContain(
|
||||||
'If you prefer not to use "hello@example.com" as your Cal.com email or already have a Cal.com account, please request another invitation to that email.'
|
`<br />${t("user_invited_you", { user: invitation.from, teamName: invitation.teamName })}<br />`
|
||||||
);
|
);
|
||||||
|
expect(result).toContain("/auth/signup?token=invite-token&");
|
||||||
|
expect(result).toContain(`${t("request_another_invitation_email", { toEmail: invitation.toEmail })}`);
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,13 +1,25 @@
|
||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "es5",
|
"target": "es5",
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"esnext"
|
||||||
|
],
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@components/*": ["components/*"],
|
"@components/*": [
|
||||||
"@lib/*": ["lib/*"],
|
"components/*"
|
||||||
"@server/*": ["server/*"],
|
],
|
||||||
"@ee/*": ["ee/*"]
|
"@lib/*": [
|
||||||
|
"lib/*"
|
||||||
|
],
|
||||||
|
"@server/*": [
|
||||||
|
"server/*"
|
||||||
|
],
|
||||||
|
"@ee/*": [
|
||||||
|
"ee/*"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
@ -21,8 +33,19 @@
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"useUnknownInCatchVariables": true,
|
"useUnknownInCatchVariables": true,
|
||||||
"jsx": "preserve",
|
"jsx": "preserve",
|
||||||
"types": ["@types/jest", "jest-playwright-preset", "expect-playwright"]
|
"types": [
|
||||||
|
"@types/jest",
|
||||||
|
"jest-playwright-preset",
|
||||||
|
"expect-playwright"
|
||||||
|
],
|
||||||
|
"allowJs": true
|
||||||
},
|
},
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
|
"include": [
|
||||||
"exclude": ["node_modules"]
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue