Merge branch 'main' into katt/cal-620-edge-fns

This commit is contained in:
Alex van Andel 2021-12-03 12:49:20 +01:00
commit 6b2a4d00e1
94 changed files with 7187 additions and 4727 deletions

View File

@ -0,0 +1,31 @@
## What does this PR do?
<!-- Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. -->
Fixes # (issue)
## Type of change
<!-- Please delete options that are not relevant. -->
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] This change requires a documentation update
## How should this be tested?
<!-- Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration -->
- [ ] Test A
- [ ] Test B
## Checklist:
- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my own code and corrected any misspellings
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my feature works
- [ ] New and existing unit tests pass locally with my changes

View File

@ -86,9 +86,10 @@ You will also need Google API credentials. You can get this from the [Google API
## Development
### Setup
#### Quick start with `yarn dx`
> - **Requires Docker to be installed**
> - **Requires Docker and Docker Compose to be installed**
> - Will start a local Postgres instance with a few test users - the credentials will be logged in the console
```bash
@ -156,7 +157,7 @@ yarn dx
npx prisma studio
```
8. Click on the `User` model to add a new user record.
9. Fill out the fields (remembering to encrypt your password with [BCrypt](https://bcrypt-generator.com/)) and click `Save 1 Record` to create your first user.
9. Fill out the fields `email`, `username`, and `password` (remembering to encrypt your password with [BCrypt](https://bcrypt-generator.com/)) and click `Save 1 Record` to create your first user.
10. Open a browser to [http://localhost:3000](http://localhost:3000) and login with your just created, first user.
11. Set a 32 character random string in your .env file for the CALENDSO_ENCRYPTION_KEY.
@ -217,11 +218,10 @@ yarn test-playwright
### Docker
The Docker configuration for Cal is an effort powered by people within the community. Cal.com, Inc. does not provide official support for Docker, but we will accept fixes and documentation. Use at your own risk.
If you want to contribute to the Docker repository, [reply here](https://github.com/calendso/docker/discussions/32).
The Docker configuration can be found [in our docker repository](https://github.com/calendso/docker).
### Railway
@ -289,11 +289,11 @@ Contributions are what make the open source community such an amazing place to b
## Obtaining Daily API Credentials
1. Open [Daily](https://www.daily.co/) and sign into your account.
2. From within your dashboard, go to the [developers](https://dashboard.daily.co/developers) tab.
3. Copy your API key.
4. Now paste the API key to your .env file into the `DAILY_API_KEY` field in your .env file.
5. If you have a [Daily Scale Plan](https://www.daily.co/pricing) can also enable the ability to record Daily video meetings. To do so, set the `DAILY_SCALE_PLAN` environment variable to `'true'`
1. Open [Daily](https://www.daily.co/) and sign into your account.
2. From within your dashboard, go to the [developers](https://dashboard.daily.co/developers) tab.
3. Copy your API key.
4. Now paste the API key to your .env file into the `DAILY_API_KEY` field in your .env file.
<!-- LICENSE -->

View File

@ -1,13 +1,17 @@
export default function Logo({ small }: { small?: boolean }) {
export default function Logo({ small, icon }: { small?: boolean; icon?: boolean }) {
return (
<h1 className="brand-logo inline">
<h1 className="inline">
<strong>
<img
className={small ? "h-4 w-auto" : "h-5 w-auto"}
alt="Cal"
title="Cal"
src="/calendso-logo-white-word.svg"
/>
{icon ? (
<img className="w-9 mx-auto" alt="Cal" title="Cal" src="/cal-com-icon-white.svg" />
) : (
<img
className={small ? "h-4 w-auto" : "h-5 w-auto"}
alt="Cal"
title="Cal"
src="/calendso-logo-white-word.svg"
/>
)}
</strong>
</h1>
);

View File

@ -196,16 +196,22 @@ export default function Shell(props: {
</div>
<div className="flex h-screen overflow-hidden bg-gray-100">
<div className="hidden md:flex md:flex-shrink-0">
<div className="flex flex-col w-56">
<div className="hidden md:flex lg:flex-shrink-0">
<div className="flex flex-col w-14 lg:w-56">
<div className="flex flex-col flex-1 h-0 bg-white border-r border-gray-200">
<div className="flex flex-col flex-1 pt-5 pb-4 overflow-y-auto">
<div className="flex flex-col flex-1 pt-3 lg:pt-5 pb-4 overflow-y-auto">
<Link href="/event-types">
<a className="px-4">
<a className="px-4 md:hidden lg:inline">
<Logo small />
</a>
</Link>
<nav className="flex-1 px-2 mt-5 space-y-1 bg-white">
{/* logo icon for tablet */}
<Link href="/event-types">
<a className="md:inline lg:hidden">
<Logo small icon />
</a>
</Link>
<nav className="flex-1 px-2 mt-2 lg:mt-5 space-y-1 bg-white">
{navigation.map((item) => (
<Link key={item.name} href={item.href}>
<a
@ -224,14 +230,19 @@ export default function Shell(props: {
)}
aria-hidden="true"
/>
{item.name}
<span className="hidden lg:inline">{item.name}</span>
</a>
</Link>
))}
</nav>
</div>
<div className="p-2 pt-2 pr-2 m-2 rounded-sm hover:bg-gray-100">
<UserDropdown />
<span className="hidden lg:inline">
<UserDropdown />
</span>
<span className="hidden md:inline lg:hidden">
<UserDropdown small />
</span>
</div>
</div>
</div>

View File

@ -11,7 +11,7 @@ import { useSlots } from "@lib/hooks/useSlots";
import Loader from "@components/Loader";
/**
* @returns i.e. `/peer` for users or `/teams/cal` for teams
* @returns i.e. `/peer` for users or `/team/cal` for teams
*/
function useRouterBasePath() {
const router = useRouter();

View File

@ -9,10 +9,11 @@ import { EventTypeCustomInputType } from "@prisma/client";
import dayjs from "dayjs";
import Head from "next/head";
import { useRouter } from "next/router";
import { stringify } from "querystring";
import { useCallback, useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { Controller, useForm, useWatch } from "react-hook-form";
import { FormattedNumber, IntlProvider } from "react-intl";
import { ReactMultiEmail } from "react-multi-email";
import { useMutation } from "react-query";
import { createPaymentLink } from "@ee/lib/stripe/client";
@ -23,10 +24,11 @@ import useTheme from "@lib/hooks/useTheme";
import { LocationType } from "@lib/location";
import createBooking from "@lib/mutations/bookings/create-booking";
import { parseZone } from "@lib/parseZone";
import slugify from "@lib/slugify";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
import { BookingCreateBody } from "@lib/types/booking";
import CustomBranding from "@components/CustomBranding";
import { Form } from "@components/form/fields";
import AvatarGroup from "@components/ui/AvatarGroup";
import { Button } from "@components/ui/Button";
import PhoneInput from "@components/ui/form/PhoneInput";
@ -39,31 +41,78 @@ type BookingPageProps = BookPageProps | TeamBookingPageProps;
const BookingPage = (props: BookingPageProps) => {
const { t, i18n } = useLocale();
const router = useRouter();
const { rescheduleUid } = router.query;
/*
* This was too optimistic
* I started, then I remembered what a beast book/event.ts is
* Gave up shortly after. One day. Maybe.
*
const mutation = trpc.useMutation("viewer.bookEvent", {
onSuccess: ({ booking }) => {
// go to success page.
},
});*/
const mutation = useMutation(createBooking, {
onSuccess: async ({ attendees, paymentUid, ...responseData }) => {
if (paymentUid) {
return await router.push(
createPaymentLink({
paymentUid,
date,
name: attendees[0].name,
absolute: false,
})
);
}
const location = (function humanReadableLocation(location) {
if (!location) {
return;
}
if (location.includes("integration")) {
return t("web_conferencing_details_to_follow");
}
return location;
})(responseData.location);
return router.push({
pathname: "/success",
query: {
date,
type: props.eventType.id,
user: props.profile.slug,
reschedule: !!rescheduleUid,
name: attendees[0].name,
email: attendees[0].email,
location,
},
});
},
});
const rescheduleUid = router.query.rescheduleUid as string;
const { isReady } = useTheme(props.profile.theme);
const date = asStringOrNull(router.query.date);
const timeFormat = asStringOrNull(router.query.clock) === "24h" ? "H:mm" : "h:mma";
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
const [guestToggle, setGuestToggle] = useState(false);
const [guestEmails, setGuestEmails] = useState([]);
const locations = props.eventType.locations || [];
const [selectedLocation, setSelectedLocation] = useState<LocationType>(
locations.length === 1 ? locations[0].type : ""
// it would be nice if Prisma at some point in the future allowed for Json<Location>; as of now this is not the case.
const locations: { type: LocationType }[] = useMemo(
() => (props.eventType.locations as { type: LocationType }[]) || [],
[props.eventType.locations]
);
const telemetry = useTelemetry();
useEffect(() => {
if (router.query.guest) {
setGuestToggle(true);
}
}, [router.query.guest]);
const telemetry = useTelemetry();
useEffect(() => {
telemetry.withJitsu((jitsu) => jitsu.track(telemetryEventTypes.timeSelected, collectPageParameters()));
}, []);
function toggleGuestEmailInput() {
setGuestToggle(!guestToggle);
}
}, [telemetry]);
const locationInfo = (type: LocationType) => locations.find((location) => location.type === type);
@ -76,113 +125,105 @@ const BookingPage = (props: BookingPageProps) => {
[LocationType.Daily]: "Daily.co Video",
};
const _bookingHandler = (event) => {
const book = async () => {
setLoading(true);
setError(false);
let notes = "";
if (props.eventType.customInputs) {
notes = props.eventType.customInputs
.map((input) => {
const data = event.target["custom_" + input.id];
if (data) {
if (input.type === EventTypeCustomInputType.BOOL) {
return input.label + "\n" + (data.checked ? t("yes") : t("no"));
} else {
return input.label + "\n" + data.value;
}
}
})
.join("\n\n");
}
if (!!notes && !!event.target.notes.value) {
notes += `\n\n${t("additional_notes")}:\n` + event.target.notes.value;
} else {
notes += event.target.notes.value;
}
const payload: BookingCreateBody = {
start: dayjs(date).format(),
end: dayjs(date).add(props.eventType.length, "minute").format(),
name: event.target.name.value,
email: event.target.email.value,
notes: notes,
guests: guestEmails,
eventTypeId: props.eventType.id,
timeZone: timeZone(),
language: i18n.language,
};
if (typeof rescheduleUid === "string") payload.rescheduleUid = rescheduleUid;
if (typeof router.query.user === "string") payload.user = router.query.user;
if (selectedLocation) {
switch (selectedLocation) {
case LocationType.Phone:
payload["location"] = event.target.phone.value;
break;
case LocationType.InPerson:
payload["location"] = locationInfo(selectedLocation).address;
break;
// Catches all other location types, such as Google Meet, Zoom etc.
default:
payload["location"] = selectedLocation;
}
}
telemetry.withJitsu((jitsu) =>
jitsu.track(telemetryEventTypes.bookingConfirmed, collectPageParameters())
);
const content = await createBooking(payload).catch((e) => {
console.error(e.message);
setLoading(false);
setError(true);
});
if (content?.id) {
const params: { [k: string]: any } = {
date,
type: props.eventType.id,
user: props.profile.slug,
reschedule: !!rescheduleUid,
name: payload.name,
email: payload.email,
};
if (payload["location"]) {
if (payload["location"].includes("integration")) {
params.location = t("web_conferencing_details_to_follow");
} else {
params.location = payload["location"];
}
}
const query = stringify(params);
let successUrl = `/success?${query}`;
if (content?.paymentUid) {
successUrl = createPaymentLink({
paymentUid: content?.paymentUid,
name: payload.name,
date,
absolute: false,
});
}
await router.push(successUrl);
} else {
setLoading(false);
setError(true);
}
type BookingFormValues = {
name: string;
email: string;
notes?: string;
locationType?: LocationType;
guests?: string[];
phone?: string;
customInputs?: {
[key: string]: string;
};
event.preventDefault();
book();
};
const bookingHandler = useCallback(_bookingHandler, [guestEmails]);
// can be shortened using .filter(), except TypeScript doesn't know what to make of the types.
const guests = router.query.guest
? Array.isArray(router.query.guest)
? router.query.guest
: [router.query.guest]
: [];
const bookingForm = useForm<BookingFormValues>({
defaultValues: {
name: (router.query.name as string) || "",
email: (router.query.email as string) || "",
notes: (router.query.notes as string) || "",
guests,
customInputs: props.eventType.customInputs.reduce(
(customInputs, input) => ({
...customInputs,
[input.id]: router.query[slugify(input.label)],
}),
{}
),
},
});
const selectedLocation = useWatch({
control: bookingForm.control,
name: "locationType",
defaultValue: ((): LocationType | undefined => {
if (router.query.location) {
return router.query.location as LocationType;
}
if (locations.length === 1) {
return locations[0]?.type;
}
})(),
});
const getLocationValue = (booking: Pick<BookingFormValues, "locationType" | "phone">) => {
const { locationType } = booking;
switch (locationType) {
case LocationType.Phone: {
return booking.phone;
}
case LocationType.InPerson: {
return locationInfo(locationType).address;
}
// Catches all other location types, such as Google Meet, Zoom etc.
default:
return selectedLocation;
}
};
const bookEvent = (booking: BookingFormValues) => {
telemetry.withJitsu((jitsu) =>
jitsu.track(telemetryEventTypes.bookingConfirmed, collectPageParameters())
);
// "metadata" is a reserved key to allow for connecting external users without relying on the email address.
// <...url>&metadata[user_id]=123 will be send as a custom input field as the hidden type.
const metadata = Object.keys(router.query)
.filter((key) => key.startsWith("metadata"))
.reduce(
(metadata, key) => ({
...metadata,
[key.substring("metadata[".length, key.length - 1)]: router.query[key],
}),
{}
);
mutation.mutate({
...booking,
start: dayjs(date).format(),
end: dayjs(date).add(props.eventType.length, "minute").format(),
eventTypeId: props.eventType.id,
timeZone: timeZone(),
language: i18n.language,
rescheduleUid,
user: router.query.user as string,
location: getLocationValue(booking),
metadata,
customInputs: Object.keys(booking.customInputs || {}).map((inputId) => ({
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
label: props.eventType.customInputs.find((input) => input.id === parseInt(inputId))!.label,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
value: booking.customInputs![inputId],
})),
});
};
return (
<div>
@ -253,20 +294,20 @@ const BookingPage = (props: BookingPageProps) => {
<p className="mb-8 text-gray-600 dark:text-white">{props.eventType.description}</p>
</div>
<div className="sm:w-1/2 sm:pl-8 sm:pr-4">
<form onSubmit={bookingHandler}>
<Form form={bookingForm} handleSubmit={bookEvent}>
<div className="mb-4">
<label htmlFor="name" className="block text-sm font-medium text-gray-700 dark:text-white">
{t("your_name")}
</label>
<div className="mt-1">
<input
{...bookingForm.register("name")}
type="text"
name="name"
id="name"
required
className="block w-full border-gray-300 rounded-sm shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-brand sm:text-sm"
placeholder="John Doe"
defaultValue={props.booking ? props.booking.attendees[0].name : ""}
/>
</div>
</div>
@ -278,6 +319,7 @@ const BookingPage = (props: BookingPageProps) => {
</label>
<div className="mt-1">
<input
{...bookingForm.register("email")}
type="email"
name="email"
id="email"
@ -285,7 +327,6 @@ const BookingPage = (props: BookingPageProps) => {
required
className="block w-full border-gray-300 rounded-sm shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-brand sm:text-sm"
placeholder="you@example.com"
defaultValue={props.booking ? props.booking.attendees[0].email : ""}
/>
</div>
</div>
@ -294,16 +335,14 @@ const BookingPage = (props: BookingPageProps) => {
<span className="block text-sm font-medium text-gray-700 dark:text-white">
{t("location")}
</span>
{locations.map((location) => (
<label key={location.type} className="block">
{locations.map((location, i) => (
<label key={i} className="block">
<input
type="radio"
required
onChange={(e) => setSelectedLocation(e.target.value)}
className="w-4 h-4 mr-2 text-black border-gray-300 location focus:ring-black"
name="location"
{...bookingForm.register("locationType", { required: true })}
value={location.type}
checked={selectedLocation === location.type}
defaultChecked={selectedLocation === location.type}
/>
<span className="ml-2 text-sm dark:text-gray-500">
{locationLabels[location.type]}
@ -324,74 +363,78 @@ const BookingPage = (props: BookingPageProps) => {
</div>
</div>
)}
{props.eventType.customInputs &&
props.eventType.customInputs
.sort((a, b) => a.id - b.id)
.map((input) => (
<div className="mb-4" key={"input-" + input.label.toLowerCase}>
{input.type !== EventTypeCustomInputType.BOOL && (
{props.eventType.customInputs
.sort((a, b) => a.id - b.id)
.map((input) => (
<div className="mb-4" key={input.id}>
{input.type !== EventTypeCustomInputType.BOOL && (
<label
htmlFor={"custom_" + input.id}
className="block mb-1 text-sm font-medium text-gray-700 dark:text-white">
{input.label}
</label>
)}
{input.type === EventTypeCustomInputType.TEXTLONG && (
<textarea
{...bookingForm.register(`customInputs.${input.id}`, {
required: input.required,
})}
id={"custom_" + input.id}
rows={3}
className="block w-full border-gray-300 rounded-sm shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-brand sm:text-sm"
placeholder={input.placeholder}
/>
)}
{input.type === EventTypeCustomInputType.TEXT && (
<input
type="text"
{...bookingForm.register(`customInputs.${input.id}`, {
required: input.required,
})}
id={"custom_" + input.id}
className="block w-full border-gray-300 rounded-sm shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-brand sm:text-sm"
placeholder={input.placeholder}
/>
)}
{input.type === EventTypeCustomInputType.NUMBER && (
<input
type="number"
{...bookingForm.register(`customInputs.${input.id}`, {
required: input.required,
})}
id={"custom_" + input.id}
className="block w-full border-gray-300 rounded-sm shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-brand sm:text-sm"
placeholder=""
/>
)}
{input.type === EventTypeCustomInputType.BOOL && (
<div className="flex items-center h-5">
<input
type="checkbox"
{...bookingForm.register(`customInputs.${input.id}`, {
required: input.required,
})}
id={"custom_" + input.id}
className="w-4 h-4 mr-2 text-black border-gray-300 rounded focus:ring-black"
placeholder=""
/>
<label
htmlFor={"custom_" + input.id}
className="block mb-1 text-sm font-medium text-gray-700 dark:text-white">
{input.label}
</label>
)}
{input.type === EventTypeCustomInputType.TEXTLONG && (
<textarea
name={"custom_" + input.id}
id={"custom_" + input.id}
required={input.required}
rows={3}
className="block w-full border-gray-300 rounded-sm shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-brand sm:text-sm"
placeholder={input.placeholder}
/>
)}
{input.type === EventTypeCustomInputType.TEXT && (
<input
type="text"
name={"custom_" + input.id}
id={"custom_" + input.id}
required={input.required}
className="block w-full border-gray-300 rounded-sm shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-brand sm:text-sm"
placeholder={input.placeholder}
/>
)}
{input.type === EventTypeCustomInputType.NUMBER && (
<input
type="number"
name={"custom_" + input.id}
id={"custom_" + input.id}
required={input.required}
className="block w-full border-gray-300 rounded-sm shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-brand sm:text-sm"
placeholder=""
/>
)}
{input.type === EventTypeCustomInputType.BOOL && (
<div className="flex items-center h-5">
<input
type="checkbox"
name={"custom_" + input.id}
id={"custom_" + input.id}
className="w-4 h-4 mr-2 text-black border-gray-300 rounded focus:ring-black"
placeholder=""
required={input.required}
/>
<label
htmlFor={"custom_" + input.id}
className="block mb-1 text-sm font-medium text-gray-700 dark:text-white">
{input.label}
</label>
</div>
)}
</div>
))}
</div>
)}
</div>
))}
{!props.eventType.disableGuests && (
<div className="mb-4">
{!guestToggle && (
<label
onClick={toggleGuestEmailInput}
onClick={() => setGuestToggle(!guestToggle)}
htmlFor="guests"
className="block mb-1 text-sm font-medium text-blue-500 dark:text-white hover:cursor-pointer">
className="block mb-1 text-sm font-medium dark:text-white hover:cursor-pointer">
{/*<UserAddIcon className="inline-block w-5 h-5 mr-1 -mt-1" />*/}
{t("additional_guests")}
</label>
)}
@ -402,27 +445,31 @@ const BookingPage = (props: BookingPageProps) => {
className="block mb-1 text-sm font-medium text-gray-700 dark:text-white">
{t("guests")}
</label>
<ReactMultiEmail
className="relative"
placeholder="guest@example.com"
emails={guestEmails}
onChange={(_emails: string[]) => {
setGuestEmails(_emails);
}}
getLabel={(
email: string,
index: number,
removeEmail: (index: number) => void
) => {
return (
<div data-tag key={index}>
{email}
<span data-tag-handle onClick={() => removeEmail(index)}>
×
</span>
</div>
);
}}
<Controller
control={bookingForm.control}
name="guests"
render={({ field: { onChange, value } }) => (
<ReactMultiEmail
className="relative"
placeholder="guest@example.com"
emails={value}
onChange={onChange}
getLabel={(
email: string,
index: number,
removeEmail: (index: number) => void
) => {
return (
<div data-tag key={index}>
{email}
<span data-tag-handle onClick={() => removeEmail(index)}>
×
</span>
</div>
);
}}
/>
)}
/>
</div>
)}
@ -435,25 +482,23 @@ const BookingPage = (props: BookingPageProps) => {
{t("additional_notes")}
</label>
<textarea
name="notes"
{...bookingForm.register("notes")}
id="notes"
rows={3}
className="block w-full border-gray-300 rounded-sm shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-brand sm:text-sm"
placeholder={t("share_additional_notes")}
defaultValue={props.booking ? props.booking.description : ""}
/>
</div>
<div className="flex items-start space-x-2">
{/* TODO: add styling props to <Button variant="" color="" /> and get rid of btn-primary */}
<Button type="submit" loading={loading}>
<Button type="submit" loading={mutation.isLoading}>
{rescheduleUid ? t("reschedule") : t("confirm")}
</Button>
<Button color="secondary" type="button" onClick={() => router.back()}>
{t("cancel")}
</Button>
</div>
</form>
{error && (
</Form>
{mutation.isError && (
<div className="p-4 mt-2 border-l-4 border-yellow-400 bg-yellow-50">
<div className="flex">
<div className="flex-shrink-0">

View File

@ -5,6 +5,8 @@ import Select, { OptionTypeBase } from "react-select";
import { useLocale } from "@lib/hooks/useLocale";
import Button from "@components/ui/Button";
interface Props {
onSubmit: SubmitHandler<IFormInput>;
onCancel: () => void;
@ -82,7 +84,7 @@ const CustomInputTypeForm: FC<Props> = (props) => {
<input
type="text"
id="placeholder"
className="shadow-sm focus:ring-primary-500 focus:border-primary-500 block w-full sm:text-sm border-gray-300 rounded-sm"
className="block w-full border-gray-300 rounded-sm shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
defaultValue={selectedCustomInput?.placeholder}
{...register("placeholder")}
/>
@ -114,12 +116,10 @@ const CustomInputTypeForm: FC<Props> = (props) => {
{...register("id", { valueAsNumber: true })}
/>
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<button type="submit" className="btn btn-primary">
{t("save")}
</button>
<button onClick={onCancel} type="button" className="mr-2 btn btn-white">
<Button type="submit">{t("save")}</Button>
<Button onClick={onCancel} type="button" color="secondary" className="mr-2">
{t("cancel")}
</button>
</Button>
</div>
</form>
);

View File

@ -4,6 +4,8 @@ import { ErrorCode } from "@lib/auth";
import { useLocale } from "@lib/hooks/useLocale";
import showToast from "@lib/notification";
import Button from "@components/ui/Button";
const ChangePasswordSection = () => {
const [oldPassword, setOldPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
@ -97,11 +99,7 @@ const ChangePasswordSection = () => {
</div>
{errorMessage && <p className="mt-1 text-sm text-red-700">{errorMessage}</p>}
<div className="py-8 flex justify-end">
<button
type="submit"
className="ml-2 bg-neutral-900 border border-transparent rounded-sm shadow-sm py-2 px-4 inline-flex justify-center text-sm font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black">
{t("save")}
</button>
<Button type="submit">{t("save")}</Button>
</div>
<hr className="mt-4" />
</div>

View File

@ -141,7 +141,7 @@ export default function EditTeam(props: { team: Team | undefined | null; onClose
</div>
</div>
<hr className="mt-2" />
<h3 className="font-cal font-bold leading-6 text-gray-900 mt-7 text-md">{t("profile")}</h3>
<h3 className="font-bold leading-6 text-gray-900 font-cal mt-7 text-md">{t("profile")}</h3>
<form className="divide-y divide-gray-200 lg:col-span-9" onSubmit={updateTeamHandler}>
{hasErrors && <ErrorAlert message={errorMessage} />}
<div className="py-6 lg:pb-8">
@ -213,7 +213,7 @@ export default function EditTeam(props: { team: Team | undefined | null; onClose
<hr className="mt-6" />
</div>
<div className="flex justify-between mt-7">
<h3 className="font-cal font-bold leading-6 text-gray-900 text-md">{t("members")}</h3>
<h3 className="font-bold leading-6 text-gray-900 font-cal text-md">{t("members")}</h3>
<div className="relative flex items-center">
<Button
type="button"
@ -255,13 +255,10 @@ export default function EditTeam(props: { team: Team | undefined | null; onClose
<div>
<div className="relative flex items-start">
<Dialog>
<DialogTrigger
onClick={(e) => {
e.stopPropagation();
}}
className="btn-sm btn-white">
<TrashIcon className="group-hover:text-red text-gray-700 w-3.5 h-3.5 mr-2 inline-block" />
{t("disband_team")}
<DialogTrigger asChild>
<Button type="button" color="secondary" StartIcon={TrashIcon}>
{t("disband_team")}
</Button>
</DialogTrigger>
<ConfirmationDialogContent
variety="danger"

View File

@ -9,6 +9,8 @@ import TimezoneSelect, { ITimezoneOption } from "react-timezone-select";
import { useLocale } from "@lib/hooks/useLocale";
import { WorkingHours } from "@lib/types/schedule";
import Button from "@components/ui/Button";
import { WeekdaySelect } from "./WeekdaySelect";
import SetTimesModal from "./modal/SetTimesModal";
@ -103,9 +105,9 @@ export const Scheduler = ({ availability, setAvailability, timeZone, setTimeZone
<OpeningHours key={idx} idx={idx} item={item} />
))}
</ul>
<button type="button" onClick={addNewSchedule} className="mt-2 btn-white btn-sm">
<Button type="button" onClick={addNewSchedule} className="mt-2" color="secondary" size="sm">
{t("add_another")}
</button>
</Button>
</div>
</div>
{editSchedule >= 0 && (

View File

@ -8,7 +8,7 @@ const MinutesField = forwardRef<HTMLInputElement, Props>(({ label, ...rest }, re
return (
<div className="block sm:flex">
<div className="mb-4 min-w-48 sm:mb-0">
<label htmlFor={rest.id} className="flex text-sm font-medium text-neutral-700">
<label htmlFor={rest.id} className="flex items-center h-full text-sm font-medium text-neutral-700">
{label}
</label>
</div>

View File

@ -3,6 +3,8 @@ import { useRef } from "react";
import { useLocale } from "@lib/hooks/useLocale";
import Button from "@components/ui/Button";
export default function SetTimesModal(props) {
const { t } = useLocale();
const [startHours, startMinutes] = [Math.floor(props.startTime / 60), props.startTime % 60];
@ -31,26 +33,26 @@ export default function SetTimesModal(props) {
return (
<div
className="fixed z-50 inset-0 overflow-y-auto"
className="fixed inset-0 z-50 overflow-y-auto"
aria-labelledby="modal-title"
role="dialog"
aria-modal="true">
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div className="flex items-end justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
<div
className="fixed inset-0 bg-gray-500 z-0 bg-opacity-75 transition-opacity"
className="fixed inset-0 z-0 transition-opacity bg-gray-500 bg-opacity-75"
aria-hidden="true"></div>
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
&#8203;
</span>
<div className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
<div className="sm:flex sm:items-start mb-4">
<div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-blue-100 sm:mx-0 sm:h-10 sm:w-10">
<ClockIcon className="h-6 w-6 text-black" />
<div className="inline-block px-4 pt-5 pb-4 overflow-hidden text-left align-bottom transition-all transform bg-white rounded-lg shadow-xl sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
<div className="mb-4 sm:flex sm:items-start">
<div className="flex items-center justify-center flex-shrink-0 w-12 h-12 mx-auto bg-blue-100 rounded-full sm:mx-0 sm:h-10 sm:w-10">
<ClockIcon className="w-6 h-6 text-black" />
</div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title">
<h3 className="text-lg font-medium leading-6 text-gray-900" id="modal-title">
{t("change_bookings_availability")}
</h3>
<div>
@ -59,7 +61,7 @@ export default function SetTimesModal(props) {
</div>
</div>
<div className="flex mb-4">
<label className="w-1/4 pt-2 block text-sm font-medium text-gray-700">{t("start_time")}</label>
<label className="block w-1/4 pt-2 text-sm font-medium text-gray-700">{t("start_time")}</label>
<div>
<label htmlFor="startHours" className="sr-only">
{t("hours")}
@ -72,12 +74,12 @@ export default function SetTimesModal(props) {
maxLength="2"
name="hours"
id="startHours"
className="shadow-sm focus:ring-black focus:border-brand block w-full sm:text-sm border-gray-300 rounded-md"
className="block w-full border-gray-300 rounded-md shadow-sm focus:ring-black focus:border-brand sm:text-sm"
placeholder="9"
defaultValue={startHours}
/>
</div>
<span className="mx-2 pt-1">:</span>
<span className="pt-1 mx-2">:</span>
<div>
<label htmlFor="startMinutes" className="sr-only">
{t("minutes")}
@ -91,14 +93,14 @@ export default function SetTimesModal(props) {
maxLength="2"
name="minutes"
id="startMinutes"
className="shadow-sm focus:ring-black focus:border-brand block w-full sm:text-sm border-gray-300 rounded-md"
className="block w-full border-gray-300 rounded-md shadow-sm focus:ring-black focus:border-brand sm:text-sm"
placeholder="30"
defaultValue={startMinutes}
/>
</div>
</div>
<div className="flex">
<label className="w-1/4 pt-2 block text-sm font-medium text-gray-700">{t("end_time")}</label>
<label className="block w-1/4 pt-2 text-sm font-medium text-gray-700">{t("end_time")}</label>
<div>
<label htmlFor="endHours" className="sr-only">
{t("hours")}
@ -111,12 +113,12 @@ export default function SetTimesModal(props) {
maxLength="2"
name="hours"
id="endHours"
className="shadow-sm focus:ring-black focus:border-brand block w-full sm:text-sm border-gray-300 rounded-md"
className="block w-full border-gray-300 rounded-md shadow-sm focus:ring-black focus:border-brand sm:text-sm"
placeholder="17"
defaultValue={endHours}
/>
</div>
<span className="mx-2 pt-1">:</span>
<span className="pt-1 mx-2">:</span>
<div>
<label htmlFor="endMinutes" className="sr-only">
{t("minutes")}
@ -130,19 +132,19 @@ export default function SetTimesModal(props) {
step="15"
name="minutes"
id="endMinutes"
className="shadow-sm focus:ring-black focus:border-brand block w-full sm:text-sm border-gray-300 rounded-md"
className="block w-full border-gray-300 rounded-md shadow-sm focus:ring-black focus:border-brand sm:text-sm"
placeholder="30"
defaultValue={endMinutes}
/>
</div>
</div>
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<button onClick={updateStartEndTimesHandler} type="submit" className="btn btn-primary">
<Button onClick={updateStartEndTimesHandler} type="submit">
{t("save")}
</button>
<button onClick={props.onExit} type="button" className="btn btn-white mr-2">
</Button>
<Button onClick={props.onExit} type="button" color="secondary" className="mr-2">
{t("cancel")}
</button>
</Button>
</div>
</div>
</div>

View File

@ -4,13 +4,18 @@ import { JsonValue } from "type-fest";
import { v4 as uuidv4 } from "uuid";
import { CalendarEvent } from "@lib/calendarClient";
import EventOrganizerRefundFailedMail from "@lib/emails/EventOrganizerRefundFailedMail";
import EventPaymentMail from "@lib/emails/EventPaymentMail";
import { sendAwaitingPaymentEmail, sendOrganizerPaymentRefundFailedEmail } from "@lib/emails/email-manager";
import { getErrorFromUnknown } from "@lib/errors";
import prisma from "@lib/prisma";
import { createPaymentLink } from "./client";
export type PaymentInfo = {
link?: string | null;
reason?: string | null;
id?: string | null;
};
export type PaymentData = Stripe.Response<Stripe.PaymentIntent> & {
stripe_publishable_key: string;
stripeAccount: string;
@ -74,15 +79,16 @@ export async function handlePayment(
},
});
const mail = new EventPaymentMail(
createPaymentLink({
paymentUid: payment.uid,
name: booking.user?.name,
date: booking.startTime.toISOString(),
}),
evt
);
await mail.sendEmail();
await sendAwaitingPaymentEmail({
...evt,
paymentInfo: {
link: createPaymentLink({
paymentUid: payment.uid,
name: booking.user?.name,
date: booking.startTime.toISOString(),
}),
},
});
return payment;
}
@ -153,11 +159,10 @@ export async function refund(
async function handleRefundError(opts: { event: CalendarEvent; reason: string; paymentId: string }) {
console.error(`refund failed: ${opts.reason} for booking '${opts.event.uid}'`);
try {
await new EventOrganizerRefundFailedMail(opts.event, opts.reason, opts.paymentId).sendEmail();
} catch (e) {
console.error("Error while sending refund error email", e);
}
await sendOrganizerPaymentRefundFailedEmail({
...opts.event,
paymentInfo: { reason: opts.reason, id: opts.paymentId },
});
}
export default stripe;

View File

@ -2,6 +2,7 @@ import type { NextApiRequest, NextApiResponse } from "next";
import { stringify } from "querystring";
import { getSession } from "@lib/auth";
import { BASE_URL } from "@lib/config/constants";
import prisma from "@lib/prisma";
const client_id = process.env.STRIPE_CLIENT_ID;
@ -27,7 +28,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
},
});
const redirect_uri = encodeURI(process.env.BASE_URL + "/api/integrations/stripepayment/callback");
const redirect_uri = encodeURI(BASE_URL + "/api/integrations/stripepayment/callback");
const stripeConnectParams = {
client_id,
scope: "read_write",

View File

@ -5,6 +5,7 @@ import Stripe from "stripe";
import stripe from "@ee/lib/stripe/server";
import { CalendarEvent } from "@lib/calendarClient";
import { IS_PRODUCTION } from "@lib/config/constants";
import { HttpError } from "@lib/core/http/error";
import { getErrorFromUnknown } from "@lib/errors";
import EventManager from "@lib/events/EventManager";
@ -110,25 +111,6 @@ type WebhookHandler = (event: Stripe.Event) => Promise<void>;
const webhookHandlers: Record<string, WebhookHandler | undefined> = {
"payment_intent.succeeded": handlePaymentSuccess,
"customer.subscription.deleted": async (event) => {
const object = event.data.object as Stripe.Subscription;
const customerId = typeof object.customer === "string" ? object.customer : object.customer.id;
const customer = (await stripe.customers.retrieve(customerId)) as Stripe.Customer;
if (typeof customer.email !== "string") {
throw new Error(`Couldn't find customer email for ${customerId}`);
}
await prisma.user.update({
where: {
email: customer.email,
},
data: {
plan: "FREE",
},
});
},
};
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
@ -146,7 +128,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}
const requestBuffer = await buffer(req);
const payload = requestBuffer.toString();
// console.log("payload", payload);
const event = stripe.webhooks.constructEvent(payload, sig, process.env.STRIPE_WEBHOOK_SECRET);
@ -154,14 +135,18 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
if (handler) {
await handler(event);
} else {
console.warn(`Unhandled Stripe Webhook event type ${event.type}`);
/** Not really an error, just letting Stripe know that the webhook was received but unhandled */
throw new HttpError({
statusCode: 202,
message: `Unhandled Stripe Webhook event type ${event.type}`,
});
}
} catch (_err) {
const err = getErrorFromUnknown(_err);
console.error(`Webhook Error: ${err.message}`);
res.status(err.statusCode ?? 500).send({
message: err.message,
stack: process.env.NODE_ENV === "production" ? undefined : err.stack,
stack: IS_PRODUCTION ? undefined : err.stack,
});
return;
}

View File

@ -1,151 +1,15 @@
import short from "short-uuid";
import { v5 as uuidv5 } from "uuid";
import { getIntegrationName } from "@lib/integrations";
import { CalendarEvent } from "./calendarClient";
import { stripHtml } from "./emails/helpers";
import { BASE_URL } from "./config/constants";
const translator = short();
export default class CalEventParser {
protected calEvent: CalendarEvent;
export const getUid = (calEvent: CalendarEvent) => {
return calEvent.uid ?? translator.fromUUID(uuidv5(JSON.stringify(calEvent), uuidv5.URL));
};
constructor(calEvent: CalendarEvent) {
this.calEvent = calEvent;
}
/**
* Returns a link to reschedule the given booking.
*/
public getRescheduleLink(): string {
return process.env.BASE_URL + "/reschedule/" + this.getUid();
}
/**
* Returns a link to cancel the given booking.
*/
public getCancelLink(): string {
return process.env.BASE_URL + "/cancel/" + this.getUid();
}
/**
* Returns a unique identifier for the given calendar event.
*/
public getUid(): string {
return this.calEvent.uid ?? translator.fromUUID(uuidv5(JSON.stringify(this.calEvent), uuidv5.URL));
}
/**
* Returns a footer section with links to change the event (as HTML).
*/
public getChangeEventFooterHtml(): string {
return `<p style="color: #4b5563; margin-top: 20px;">${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>`;
}
/**
* Returns a footer section with links to change the event (as plain text).
*/
public getChangeEventFooter(): string {
return stripHtml(this.getChangeEventFooterHtml());
}
/**
* Returns an extended description with all important information (as HTML).
*
* @protected
*/
public getRichDescriptionHtml(): string {
// This odd indentation is necessary because otherwise the leading tabs will be applied into the event description.
return (
`
<strong>${this.calEvent.language("event_type")}:</strong><br />${this.calEvent.type}<br />
<strong>${this.calEvent.language("invitee_email")}:</strong><br /><a href="mailto:${
this.calEvent.attendees[0].email
}">${this.calEvent.attendees[0].email}</a><br />
` +
(this.getLocation()
? `<strong>${this.calEvent.language("location")}:</strong><br />${this.getLocation()}<br />
`
: "") +
`<strong>${this.calEvent.language("invitee_timezone")}:</strong><br />${
this.calEvent.attendees[0].timeZone
}<br />
<strong>${this.calEvent.language("additional_notes")}:</strong><br />${this.getDescriptionText()}<br />` +
this.getChangeEventFooterHtml()
);
}
/**
* Conditionally returns the event's location. When VideoCallData is set,
* it returns the meeting url. Otherwise, the regular location is returned.
* For Daily video calls returns the direct link
* @protected
*/
protected getLocation(): string | null | undefined {
const isDaily = this.calEvent.location === "integrations:daily";
if (this.calEvent.videoCallData) {
return this.calEvent.videoCallData.url;
}
if (isDaily) {
return process.env.BASE_URL + "/call/" + this.getUid();
}
return this.calEvent.location;
}
/**
* Returns the event's description text. If VideoCallData is set, it prepends
* some video call information before the text as well.
*
* @protected
*/
protected getDescriptionText(): string | null | undefined {
if (this.calEvent.videoCallData) {
return `
${this.calEvent.language("integration_meeting_id", {
integrationName: getIntegrationName(this.calEvent.videoCallData.type),
meetingId: this.calEvent.videoCallData.id,
})}
${this.calEvent.language("password")}: ${this.calEvent.videoCallData.password}
${this.calEvent.description}`;
}
return this.calEvent.description;
}
/**
* Returns an extended description with all important information (as plain text).
*
* @protected
*/
public getRichDescription(): string {
return stripHtml(this.getRichDescriptionHtml());
}
/**
* Returns a calendar event with rich description.
*/
public asRichEvent(): CalendarEvent {
const eventCopy: CalendarEvent = { ...this.calEvent };
eventCopy.description = this.getRichDescriptionHtml();
eventCopy.location = this.getLocation();
return eventCopy;
}
/**
* Returns a calendar event with rich description as plain text.
*/
public asRichEventPlain(): CalendarEvent {
const eventCopy: CalendarEvent = { ...this.calEvent };
eventCopy.description = this.getRichDescription();
eventCopy.location = this.getLocation();
return eventCopy;
}
}
export const getCancelLink = (calEvent: CalendarEvent) => {
return BASE_URL + "/cancel/" + getUid(calEvent);
};

View File

@ -1,128 +1,26 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
import { Calendar as OfficeCalendar } from "@microsoft/microsoft-graph-types-beta";
import { Credential, Prisma, SelectedCalendar } from "@prisma/client";
import { GetTokenResponse } from "google-auth-library/build/src/auth/oauth2client";
import { Auth, calendar_v3, google } from "googleapis";
import { Credential, SelectedCalendar } from "@prisma/client";
import { TFunction } from "next-i18next";
import { PaymentInfo } from "@ee/lib/stripe/server";
import { getUid } from "@lib/CalEventParser";
import { Event, EventResult } from "@lib/events/EventManager";
import { AppleCalendar } from "@lib/integrations/Apple/AppleCalendarAdapter";
import { CalDavCalendar } from "@lib/integrations/CalDav/CalDavCalendarAdapter";
import {
GoogleCalendarApiAdapter,
ConferenceData,
} from "@lib/integrations/GoogleCalendar/GoogleCalendarApiAdapter";
import {
Office365CalendarApiAdapter,
BufferedBusyTime,
} from "@lib/integrations/Office365Calendar/Office365CalendarApiAdapter";
import logger from "@lib/logger";
import { VideoCallData } from "@lib/videoClient";
import CalEventParser from "./CalEventParser";
import EventOrganizerMail from "./emails/EventOrganizerMail";
import EventOrganizerRescheduledMail from "./emails/EventOrganizerRescheduledMail";
import { AppleCalendar } from "./integrations/Apple/AppleCalendarAdapter";
import { CalDavCalendar } from "./integrations/CalDav/CalDavCalendarAdapter";
import prisma from "./prisma";
const log = logger.getChildLogger({ prefix: ["[lib] calendarClient"] });
const googleAuth = (credential: Credential) => {
const { client_secret, client_id, redirect_uris } = JSON.parse(process.env.GOOGLE_API_CREDENTIALS!).web;
const myGoogleAuth = new google.auth.OAuth2(client_id, client_secret, redirect_uris[0]);
const googleCredentials = credential.key as Auth.Credentials;
myGoogleAuth.setCredentials(googleCredentials);
// FIXME - type errors IDK Why this is a protected method ¯\_(ツ)_/¯
const isExpired = () => myGoogleAuth.isTokenExpiring();
const refreshAccessToken = () =>
myGoogleAuth
// FIXME - type errors IDK Why this is a protected method ¯\_(ツ)_/¯
.refreshToken(googleCredentials.refresh_token)
.then((res: GetTokenResponse) => {
const token = res.res?.data;
googleCredentials.access_token = token.access_token;
googleCredentials.expiry_date = token.expiry_date;
return prisma.credential
.update({
where: {
id: credential.id,
},
data: {
key: googleCredentials as Prisma.InputJsonValue,
},
})
.then(() => {
myGoogleAuth.setCredentials(googleCredentials);
return myGoogleAuth;
});
})
.catch((err) => {
console.error("Error refreshing google token", err);
return myGoogleAuth;
});
return {
getToken: () => (!isExpired() ? Promise.resolve(myGoogleAuth) : refreshAccessToken()),
};
};
function handleErrorsJson(response: Response) {
if (!response.ok) {
response.json().then((e) => console.error("O365 Error", e));
throw Error(response.statusText);
}
return response.json();
}
function handleErrorsRaw(response: Response) {
if (!response.ok) {
response.text().then((e) => console.error("O365 Error", e));
throw Error(response.statusText);
}
return response.text();
}
type O365AuthCredentials = {
expiry_date: number;
access_token: string;
refresh_token: string;
};
const o365Auth = (credential: Credential) => {
const isExpired = (expiryDate: number) => expiryDate < Math.round(+new Date() / 1000);
const o365AuthCredentials = credential.key as O365AuthCredentials;
const refreshAccessToken = (refreshToken: string) => {
return fetch("https://login.microsoftonline.com/common/oauth2/v2.0/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
// FIXME types - IDK how to type this TBH
body: new URLSearchParams({
scope: "User.Read Calendars.Read Calendars.ReadWrite",
client_id: process.env.MS_GRAPH_CLIENT_ID,
refresh_token: refreshToken,
grant_type: "refresh_token",
client_secret: process.env.MS_GRAPH_CLIENT_SECRET,
}),
})
.then(handleErrorsJson)
.then((responseBody) => {
o365AuthCredentials.access_token = responseBody.access_token;
o365AuthCredentials.expiry_date = Math.round(+new Date() / 1000 + responseBody.expires_in);
return prisma.credential
.update({
where: {
id: credential.id,
},
data: {
key: o365AuthCredentials,
},
})
.then(() => o365AuthCredentials.access_token);
});
};
return {
getToken: () =>
!isExpired(o365AuthCredentials.expiry_date)
? Promise.resolve(o365AuthCredentials.access_token)
: refreshAccessToken(o365AuthCredentials.refresh_token),
};
};
export type Person = { name: string; email: string; timeZone: string };
export interface EntryPoint {
@ -158,20 +56,16 @@ export interface CalendarEvent {
conferenceData?: ConferenceData;
language: TFunction;
additionInformation?: AdditionInformation;
/** If this property exist it we can assume it's a reschedule/update */
uid?: string | null;
videoCallData?: VideoCallData;
paymentInfo?: PaymentInfo | null;
}
export interface ConferenceData {
createRequest: calendar_v3.Schema$CreateConferenceRequest;
}
export interface IntegrationCalendar extends Partial<SelectedCalendar> {
primary?: boolean;
name?: string;
}
type BufferedBusyTime = { start: string; end: string };
export interface CalendarApiAdapter {
createEvent(event: CalendarEvent): Promise<Event>;
@ -188,381 +82,12 @@ export interface CalendarApiAdapter {
listCalendars(): Promise<IntegrationCalendar[]>;
}
const MicrosoftOffice365Calendar = (credential: Credential): CalendarApiAdapter => {
const auth = o365Auth(credential);
const translateEvent = (event: CalendarEvent) => {
return {
subject: event.title,
body: {
contentType: "HTML",
content: event.description,
},
start: {
dateTime: event.startTime,
timeZone: event.organizer.timeZone,
},
end: {
dateTime: event.endTime,
timeZone: event.organizer.timeZone,
},
attendees: event.attendees.map((attendee) => ({
emailAddress: {
address: attendee.email,
name: attendee.name,
},
type: "required",
})),
location: event.location ? { displayName: event.location } : undefined,
};
};
const integrationType = "office365_calendar";
function listCalendars(): Promise<IntegrationCalendar[]> {
return auth.getToken().then((accessToken) =>
fetch("https://graph.microsoft.com/v1.0/me/calendars", {
method: "get",
headers: {
Authorization: "Bearer " + accessToken,
"Content-Type": "application/json",
},
})
.then(handleErrorsJson)
.then((responseBody: { value: OfficeCalendar[] }) => {
return responseBody.value.map((cal) => {
const calendar: IntegrationCalendar = {
externalId: cal.id ?? "No Id",
integration: integrationType,
name: cal.name ?? "No calendar name",
primary: cal.isDefaultCalendar ?? false,
};
return calendar;
});
})
);
}
return {
getAvailability: (dateFrom, dateTo, selectedCalendars) => {
const filter = `?startdatetime=${encodeURIComponent(dateFrom)}&enddatetime=${encodeURIComponent(
dateTo
)}`;
return auth
.getToken()
.then((accessToken) => {
const selectedCalendarIds = selectedCalendars
.filter((e) => e.integration === integrationType)
.map((e) => e.externalId)
.filter(Boolean);
if (selectedCalendarIds.length === 0 && selectedCalendars.length > 0) {
// Only calendars of other integrations selected
return Promise.resolve([]);
}
return (
selectedCalendarIds.length === 0
? listCalendars().then((cals) => cals.map((e) => e.externalId).filter(Boolean) || [])
: Promise.resolve(selectedCalendarIds)
).then((ids) => {
const requests = ids.map((calendarId, id) => ({
id,
method: "GET",
headers: {
Prefer: 'outlook.timezone="Etc/GMT"',
},
url: `/me/calendars/${calendarId}/calendarView${filter}`,
}));
type BatchResponse = {
responses: SubResponse[];
};
type SubResponse = {
body: { value: { start: { dateTime: string }; end: { dateTime: string } }[] };
};
return fetch("https://graph.microsoft.com/v1.0/$batch", {
method: "POST",
headers: {
Authorization: "Bearer " + accessToken,
"Content-Type": "application/json",
},
body: JSON.stringify({ requests }),
})
.then(handleErrorsJson)
.then((responseBody: BatchResponse) =>
responseBody.responses.reduce(
(acc: BufferedBusyTime[], subResponse) =>
acc.concat(
subResponse.body.value.map((evt) => {
return {
start: evt.start.dateTime + "Z",
end: evt.end.dateTime + "Z",
};
})
),
[]
)
);
});
})
.catch((err) => {
console.log(err);
return Promise.reject([]);
});
},
createEvent: (event: CalendarEvent) =>
auth.getToken().then((accessToken) =>
fetch("https://graph.microsoft.com/v1.0/me/calendar/events", {
method: "POST",
headers: {
Authorization: "Bearer " + accessToken,
"Content-Type": "application/json",
},
body: JSON.stringify(translateEvent(event)),
})
.then(handleErrorsJson)
.then((responseBody) => ({
...responseBody,
disableConfirmationEmail: true,
}))
),
deleteEvent: (uid: string) =>
auth.getToken().then((accessToken) =>
fetch("https://graph.microsoft.com/v1.0/me/calendar/events/" + uid, {
method: "DELETE",
headers: {
Authorization: "Bearer " + accessToken,
},
}).then(handleErrorsRaw)
),
updateEvent: (uid: string, event: CalendarEvent) =>
auth.getToken().then((accessToken) =>
fetch("https://graph.microsoft.com/v1.0/me/calendar/events/" + uid, {
method: "PATCH",
headers: {
Authorization: "Bearer " + accessToken,
"Content-Type": "application/json",
},
body: JSON.stringify(translateEvent(event)),
}).then(handleErrorsRaw)
),
listCalendars,
};
};
const GoogleCalendar = (credential: Credential): CalendarApiAdapter => {
const auth = googleAuth(credential);
const integrationType = "google_calendar";
return {
getAvailability: (dateFrom, dateTo, selectedCalendars) =>
new Promise((resolve, reject) =>
auth.getToken().then((myGoogleAuth) => {
const calendar = google.calendar({
version: "v3",
auth: myGoogleAuth,
});
const selectedCalendarIds = selectedCalendars
.filter((e) => e.integration === integrationType)
.map((e) => e.externalId);
if (selectedCalendarIds.length === 0 && selectedCalendars.length > 0) {
// Only calendars of other integrations selected
resolve([]);
return;
}
(selectedCalendarIds.length === 0
? calendar.calendarList
.list()
.then((cals) => cals.data.items?.map((cal) => cal.id).filter(Boolean) || [])
: Promise.resolve(selectedCalendarIds)
)
.then((calsIds) => {
calendar.freebusy.query(
{
requestBody: {
timeMin: dateFrom,
timeMax: dateTo,
items: calsIds.map((id) => ({ id: id })),
},
},
(err, apires) => {
if (err) {
reject(err);
}
// @ts-ignore FIXME
resolve(Object.values(apires.data.calendars).flatMap((item) => item["busy"]));
}
);
})
.catch((err) => {
console.error("There was an error contacting google calendar service: ", err);
reject(err);
});
})
),
createEvent: (event: CalendarEvent) =>
new Promise((resolve, reject) =>
auth.getToken().then((myGoogleAuth) => {
const payload: calendar_v3.Schema$Event = {
summary: event.title,
description: event.description,
start: {
dateTime: event.startTime,
timeZone: event.organizer.timeZone,
},
end: {
dateTime: event.endTime,
timeZone: event.organizer.timeZone,
},
attendees: event.attendees,
reminders: {
useDefault: false,
overrides: [{ method: "email", minutes: 10 }],
},
};
if (event.location) {
payload["location"] = event.location;
}
if (event.conferenceData && event.location === "integrations:google:meet") {
payload["conferenceData"] = event.conferenceData;
}
const calendar = google.calendar({
version: "v3",
auth: myGoogleAuth,
});
calendar.events.insert(
{
auth: myGoogleAuth,
calendarId: "primary",
requestBody: payload,
conferenceDataVersion: 1,
},
function (err, event) {
if (err || !event?.data) {
console.error("There was an error contacting google calendar service: ", err);
return reject(err);
}
// @ts-ignore FIXME
return resolve(event.data);
}
);
})
),
updateEvent: (uid: string, event: CalendarEvent) =>
new Promise((resolve, reject) =>
auth.getToken().then((myGoogleAuth) => {
const payload: calendar_v3.Schema$Event = {
summary: event.title,
description: event.description,
start: {
dateTime: event.startTime,
timeZone: event.organizer.timeZone,
},
end: {
dateTime: event.endTime,
timeZone: event.organizer.timeZone,
},
attendees: event.attendees,
reminders: {
useDefault: false,
overrides: [{ method: "email", minutes: 10 }],
},
};
if (event.location) {
payload["location"] = event.location;
}
const calendar = google.calendar({
version: "v3",
auth: myGoogleAuth,
});
calendar.events.update(
{
auth: myGoogleAuth,
calendarId: "primary",
eventId: uid,
sendNotifications: true,
sendUpdates: "all",
requestBody: payload,
},
function (err, event) {
if (err) {
console.error("There was an error contacting google calendar service: ", err);
return reject(err);
}
return resolve(event?.data);
}
);
})
),
deleteEvent: (uid: string) =>
new Promise((resolve, reject) =>
auth.getToken().then((myGoogleAuth) => {
const calendar = google.calendar({
version: "v3",
auth: myGoogleAuth,
});
calendar.events.delete(
{
auth: myGoogleAuth,
calendarId: "primary",
eventId: uid,
sendNotifications: true,
sendUpdates: "all",
},
function (err, event) {
if (err) {
console.error("There was an error contacting google calendar service: ", err);
return reject(err);
}
return resolve(event?.data);
}
);
})
),
listCalendars: () =>
new Promise((resolve, reject) =>
auth.getToken().then((myGoogleAuth) => {
const calendar = google.calendar({
version: "v3",
auth: myGoogleAuth,
});
calendar.calendarList
.list()
.then((cals) => {
resolve(
cals.data.items?.map((cal) => {
const calendar: IntegrationCalendar = {
externalId: cal.id ?? "No id",
integration: integrationType,
name: cal.summary ?? "No name",
primary: cal.primary ?? false,
};
return calendar;
}) || []
);
})
.catch((err) => {
console.error("There was an error contacting google calendar service: ", err);
reject(err);
});
})
),
};
};
function getCalendarAdapterOrNull(credential: Credential): CalendarApiAdapter | null {
switch (credential.type) {
case "google_calendar":
return GoogleCalendar(credential);
return GoogleCalendarApiAdapter(credential);
case "office365_calendar":
return MicrosoftOffice365Calendar(credential);
return Office365CalendarApiAdapter(credential);
case "caldav_calendar":
// FIXME types wrong & type casting should not be needed
return new CalDavCalendar(credential) as never as CalendarApiAdapter;
@ -581,9 +106,9 @@ const calendars = (withCredentials: Credential[]): CalendarApiAdapter[] =>
.map((cred) => {
switch (cred.type) {
case "google_calendar":
return GoogleCalendar(cred);
return GoogleCalendarApiAdapter(cred);
case "office365_calendar":
return MicrosoftOffice365Calendar(cred);
return Office365CalendarApiAdapter(cred);
case "caldav_calendar":
return new CalDavCalendar(cred);
case "apple_calendar":
@ -616,25 +141,13 @@ const listCalendars = (withCredentials: Credential[]) =>
results.reduce((acc, calendars) => acc.concat(calendars), []).filter((c) => c != null)
);
const createEvent = async (
credential: Credential,
calEvent: CalendarEvent,
noMail: boolean | null = false
): Promise<EventResult> => {
const parser: CalEventParser = new CalEventParser(calEvent);
const uid: string = parser.getUid();
/*
* Matching the credential type is a workaround because the office calendar simply strips away newlines (\n and \r).
* We need HTML there. Google Calendar understands newlines and Apple Calendar cannot show HTML, so no HTML should
* be used for Google and Apple Calendar.
*/
const richEvent: CalendarEvent = parser.asRichEventPlain();
const createEvent = async (credential: Credential, calEvent: CalendarEvent): Promise<EventResult> => {
const uid: string = getUid(calEvent);
let success = true;
const creationResult = credential
? await calendars([credential])[0]
.createEvent(richEvent)
.createEvent(calEvent)
.catch((e) => {
log.error("createEvent failed", e, calEvent);
success = false;
@ -651,26 +164,6 @@ const createEvent = async (
};
}
const metadata: AdditionInformation = {};
if (creationResult) {
// TODO: Handle created event metadata more elegantly
metadata.hangoutLink = creationResult.hangoutLink;
metadata.conferenceData = creationResult.conferenceData;
metadata.entryPoints = creationResult.entryPoints;
}
calEvent.additionInformation = metadata;
if (!noMail) {
const organizerMail = new EventOrganizerMail(calEvent);
try {
await organizerMail.sendEmail();
} catch (e) {
console.error("organizerMail.sendEmail failed", e);
}
}
return {
type: credential.type,
success,
@ -683,27 +176,23 @@ const createEvent = async (
const updateEvent = async (
credential: Credential,
calEvent: CalendarEvent,
noMail: boolean | null = false,
bookingRefUid: string | null
): Promise<EventResult> => {
const parser: CalEventParser = new CalEventParser(calEvent);
const uid = parser.getUid();
const richEvent: CalendarEvent = parser.asRichEventPlain();
const uid = getUid(calEvent);
let success = true;
const updatedResult =
const updationResult =
credential && bookingRefUid
? await calendars([credential])[0]
.updateEvent(bookingRefUid, richEvent)
.updateEvent(bookingRefUid, calEvent)
.catch((e) => {
log.error("updateEvent failed", e, calEvent);
success = false;
return undefined;
})
: null;
: undefined;
if (!updatedResult) {
if (!updationResult) {
return {
type: credential.type,
success,
@ -712,20 +201,11 @@ const updateEvent = async (
};
}
if (!noMail) {
const organizerMail = new EventOrganizerRescheduledMail(calEvent);
try {
await organizerMail.sendEmail();
} catch (e) {
console.error("organizerMail.sendEmail failed", e);
}
}
return {
type: credential.type,
success,
uid,
updatedEvent: updatedResult,
updatedEvent: updationResult,
originalEvent: calEvent,
};
};

2
lib/config/constants.ts Normal file
View File

@ -0,0 +1,2 @@
export const BASE_URL = process.env.BASE_URL || `https://${process.env.VERCEL_URL}`;
export const IS_PRODUCTION = process.env.NODE_ENV === "production";

View File

@ -1,189 +0,0 @@
import dayjs, { Dayjs } from "dayjs";
import localizedFormat from "dayjs/plugin/localizedFormat";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
import EventMail from "./EventMail";
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(localizedFormat);
export default class EventAttendeeMail extends EventMail {
/**
* Returns the email text as HTML representation.
*
* @protected
*/
protected getHtmlRepresentation(): string {
return (
`
<body style="background: #f4f5f7; font-family: Helvetica, sans-serif">
<div
style="
margin: 0 auto;
max-width: 450px;
background: white;
border-radius: 0.75rem;
border: 1px solid #e5e7eb;
padding: 2rem 2rem 2rem 2rem;
text-align: center;
margin-top: 40px;
"
>
<svg
xmlns="http://www.w3.org/2000/svg"
style="height: 60px; width: 60px; color: #31c48d"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<h1 style="font-weight: 500; color: #161e2e;">${this.calEvent.language(
"your_meeting_has_been_booked"
)}</h1>
<p style="color: #4b5563; margin-bottom: 30px;">${this.calEvent.language("emailed_you_and_attendees")}</p>
<hr />
<table style="border-spacing: 20px; color: #161e2e; margin-bottom: 10px;">
<colgroup>
<col span="1" style="width: 40%;">
<col span="1" style="width: 60%;">
</colgroup>
<tr>
<td>${this.calEvent.language("what")}</td>
<td>${this.calEvent.type}</td>
</tr>
<tr>
<td>${this.calEvent.language("when")}</td>
<td>${this.getInviteeStart().format("dddd, LL")}<br>${this.getInviteeStart().format("h:mma")} (${
this.calEvent.attendees[0].timeZone
})</td>
</tr>
<tr>
<td>${this.calEvent.language("who")}</td>
<td>
${this.calEvent.team?.name || this.calEvent.organizer.name}<br />
<small>
${this.calEvent.organizer.email && !this.calEvent.team ? this.calEvent.organizer.email : ""}
${this.calEvent.team ? this.calEvent.team.members.join(", ") : ""}
</small>
</td>
</tr>
${this.getLocation()}
<tr>
<td>${this.calEvent.language("notes")}</td>
<td>${this.calEvent.description}</td>
</tr>
</table>
` +
this.getAdditionalBody() +
"<br />" +
`
<hr />
` +
this.getAdditionalFooter() +
`
</div>
<div style="text-align: center; margin-top: 20px; color: #ccc; font-size: 12px;">
<img style="opacity: 0.25; width: 120px;" src="https://app.cal.com/cal-logo-word.svg" alt="Cal.com Logo"></div>
</body>
`
);
}
/**
* Adds the video call information to the mail body.
*
* @protected
*/
protected getLocation(): string {
if (this.calEvent.additionInformation?.hangoutLink) {
return `<tr>
<td>${this.calEvent.language("where")}</td>
<td><a href="${this.calEvent.additionInformation?.hangoutLink}">${
this.calEvent.additionInformation?.hangoutLink
}</a><br /></td>
</tr>
`;
}
if (
this.calEvent.additionInformation?.entryPoints &&
this.calEvent.additionInformation?.entryPoints.length > 0
) {
const locations = this.calEvent.additionInformation?.entryPoints
.map((entryPoint) => {
return `
${this.calEvent.language("join_by_entrypoint", { entryPoint: entryPoint.entryPointType })}: <br />
<a href="${entryPoint.uri}">${entryPoint.label}</a> <br />
`;
})
.join("<br />");
return `<tr>
<td>${this.calEvent.language("where")}</td>
<td>${locations}</td>
</tr>
`;
}
if (!this.calEvent.location) {
return ``;
}
if (this.calEvent.location === "integrations:zoom" || this.calEvent.location === "integrations:daily") {
return ``;
}
return `<tr><td>${this.calEvent.language("where")}</td><td>${
this.calEvent.location
}<br /><br /></td></tr>`;
}
protected getAdditionalBody(): string {
return ``;
}
protected getAdditionalFooter(): string {
return this.parser.getChangeEventFooterHtml();
}
/**
* Returns the payload object for the nodemailer.
*
* @protected
*/
protected getNodeMailerPayload(): Record<string, unknown> {
return {
to: `${this.calEvent.attendees[0].name} <${this.calEvent.attendees[0].email}>`,
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
replyTo: this.calEvent.organizer.email,
subject: this.calEvent.language("confirmed_event_type_subject", {
eventType: this.calEvent.type,
name: this.calEvent.team?.name || this.calEvent.organizer.name,
date: this.getInviteeStart().format("LT dddd, LL"),
}),
html: this.getHtmlRepresentation(),
text: this.getPlainTextRepresentation(),
};
}
protected printNodeMailerError(error: Error): void {
console.error("SEND_BOOKING_CONFIRMATION_ERROR", this.calEvent.attendees[0].email, error);
}
/**
* Returns the inviteeStart value used at multiple points.
*
* @private
*/
protected getInviteeStart(): Dayjs {
return dayjs(this.calEvent.startTime).tz(this.calEvent.attendees[0].timeZone);
}
}

View File

@ -1,55 +0,0 @@
import EventAttendeeMail from "./EventAttendeeMail";
export default class EventAttendeeRescheduledMail extends EventAttendeeMail {
/**
* Returns the email text as HTML representation.
*
* @protected
*/
protected getHtmlRepresentation(): string {
return (
`
<div>
${this.calEvent.language("hi_user_name", { userName: this.calEvent.attendees[0].name })},<br />
<br />
${this.calEvent.language("event_type_has_been_rescheduled_on_time_date", {
eventType: this.calEvent.type,
name: this.calEvent.team?.name || this.calEvent.organizer.name,
time: this.getInviteeStart().format("h:mma"),
timeZone: this.calEvent.attendees[0].timeZone,
date:
`${this.calEvent.language(this.getInviteeStart().format("dddd, ").toLowerCase())}` +
`${this.calEvent.language(this.getInviteeStart().format("LL").toLowerCase())}`,
})}<br />
` +
this.getAdditionalFooter() +
`
</div>
`
);
}
/**
* Returns the payload object for the nodemailer.
*
* @protected
*/
protected getNodeMailerPayload(): Record<string, unknown> {
return {
to: `${this.calEvent.attendees[0].name} <${this.calEvent.attendees[0].email}>`,
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
replyTo: this.calEvent.organizer.email,
subject: this.calEvent.language("rescheduled_event_type_with_organizer", {
eventType: this.calEvent.type,
organizerName: this.calEvent.organizer.name,
date: this.getInviteeStart().format("dddd, LL"),
}),
html: this.getHtmlRepresentation(),
text: this.getPlainTextRepresentation(),
};
}
protected printNodeMailerError(error: Error): void {
console.error("SEND_RESCHEDULE_CONFIRMATION_ERROR", this.calEvent.attendees[0].email, error);
}
}

View File

@ -1,118 +0,0 @@
import nodemailer from "nodemailer";
import { getErrorFromUnknown } from "@lib/errors";
import CalEventParser from "../CalEventParser";
import { CalendarEvent } from "../calendarClient";
import { serverConfig } from "../serverConfig";
import { stripHtml } from "./helpers";
export default abstract class EventMail {
calEvent: CalendarEvent;
parser: CalEventParser;
/**
* An EventMail always consists of a CalendarEvent
* that stores the data of the event (like date, title, uid etc).
*
* @param calEvent
*/
constructor(calEvent: CalendarEvent) {
this.calEvent = calEvent;
this.parser = new CalEventParser(calEvent);
}
/**
* Returns the email text as HTML representation.
*
* @protected
*/
protected abstract getHtmlRepresentation(): string;
/**
* Returns the email text in a plain text representation
* by stripping off the HTML tags.
*
* @protected
*/
protected getPlainTextRepresentation(): string {
return stripHtml(this.getHtmlRepresentation());
}
/**
* Returns the payload object for the nodemailer.
* @protected
*/
protected abstract getNodeMailerPayload(): Record<string, unknown>;
/**
* Sends the email to the event attendant and returns a Promise.
*/
public sendEmail() {
new Promise((resolve, reject) =>
nodemailer
.createTransport(this.getMailerOptions().transport)
.sendMail(this.getNodeMailerPayload(), (_err, info) => {
if (_err) {
const err = getErrorFromUnknown(_err);
this.printNodeMailerError(err);
reject(err);
} else {
resolve(info);
}
})
).catch((e) => console.error("sendEmail", e));
return new Promise((resolve) => resolve("send mail async"));
}
/**
* Gathers the required provider information from the config.
*
* @protected
*/
protected getMailerOptions() {
return {
transport: serverConfig.transport,
from: serverConfig.from,
};
}
/**
* Can be used to include additional HTML or plain text
* content into the mail body. Leave it to an empty
* string if not desired.
*
* @protected
*/
protected getAdditionalBody(): string {
return "";
}
protected abstract getLocation(): string;
/**
* Prints out the desired information when an error
* occured while sending the mail.
* @param error
* @protected
*/
protected abstract printNodeMailerError(error: Error): void;
/**
* Returns a link to reschedule the given booking.
*
* @protected
*/
protected getRescheduleLink(): string {
return this.parser.getRescheduleLink();
}
/**
* Returns a link to cancel the given booking.
*
* @protected
*/
protected getCancelLink(): string {
return this.parser.getCancelLink();
}
}

View File

@ -1,251 +0,0 @@
import dayjs, { Dayjs } from "dayjs";
import localizedFormat from "dayjs/plugin/localizedFormat";
import timezone from "dayjs/plugin/timezone";
import toArray from "dayjs/plugin/toArray";
import utc from "dayjs/plugin/utc";
import { createEvent, DateArray } from "ics";
import { Person } from "@lib/calendarClient";
import EventMail from "./EventMail";
import { stripHtml } from "./helpers";
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(toArray);
dayjs.extend(localizedFormat);
export default class EventOrganizerMail extends EventMail {
/**
* Returns the instance's event as an iCal event in string representation.
* @protected
*/
protected getiCalEventAsString(): string | undefined {
const icsEvent = createEvent({
start: dayjs(this.calEvent.startTime)
.utc()
.toArray()
.slice(0, 6)
.map((v, i) => (i === 1 ? v + 1 : v)) as DateArray,
startInputType: "utc",
productId: "calendso/ics",
title: this.calEvent.language("organizer_ics_event_title", {
eventType: this.calEvent.type,
attendeeName: this.calEvent.attendees[0].name,
}),
description:
this.calEvent.description +
stripHtml(this.getAdditionalBody()) +
stripHtml(this.getAdditionalFooter()),
duration: { minutes: dayjs(this.calEvent.endTime).diff(dayjs(this.calEvent.startTime), "minute") },
organizer: { name: this.calEvent.organizer.name, email: this.calEvent.organizer.email },
attendees: this.calEvent.attendees.map((attendee: Person) => ({
name: attendee.name,
email: attendee.email,
})),
status: "CONFIRMED",
});
if (icsEvent.error) {
throw icsEvent.error;
}
return icsEvent.value;
}
protected getBodyHeader(): string {
return this.calEvent.language("new_event_scheduled");
}
protected getAdditionalFooter(): string {
return `<p style="color: #4b5563; margin-top: 20px;">${this.calEvent.language(
"need_to_make_a_change"
)} <a href=${process.env.BASE_URL + "/bookings"} style="color: #161e2e;">${this.calEvent.language(
"manage_my_bookings"
)}</a></p>`;
}
protected getImage(): string {
return `<svg
xmlns="http://www.w3.org/2000/svg"
style="height: 60px; width: 60px; color: #31c48d"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>`;
}
/**
* Returns the email text as HTML representation.
*
* @protected
*/
protected getHtmlRepresentation(): string {
return (
`
<body style="background: #f4f5f7; font-family: Helvetica, sans-serif">
<div
style="
margin: 0 auto;
max-width: 450px;
background: white;
border-radius: 0.75rem;
border: 1px solid #e5e7eb;
padding: 2rem 2rem 2rem 2rem;
text-align: center;
margin-top: 40px;
"
>
${this.getImage()}
<h1 style="font-weight: 500; color: #161e2e;">${this.getBodyHeader()}</h1>
<hr />
<table style="border-spacing: 20px; color: #161e2e; margin-bottom: 10px;">
<colgroup>
<col span="1" style="width: 40%;">
<col span="1" style="width: 60%;">
</colgroup>
<tr>
<td>${this.calEvent.language("what")}</td>
<td>${this.calEvent.type}</td>
</tr>
<tr>
<td>${this.calEvent.language("when")}</td>
<td>${this.getOrganizerStart().format("dddd, LL")}<br>${this.getOrganizerStart().format("h:mma")} (${
this.calEvent.organizer.timeZone
})</td>
</tr>
<tr>
<td>${this.calEvent.language("who")}</td>
<td>${this.calEvent.attendees[0].name}<br /><small><a href="mailto:${
this.calEvent.attendees[0].email
}">${this.calEvent.attendees[0].email}</a></small></td>
</tr>
${this.getLocation()}
<tr>
<td>${this.calEvent.language("notes")}</td>
<td>${this.calEvent.description}</td>
</tr>
</table>
` +
this.getAdditionalBody() +
"<br />" +
`
<hr />
` +
this.getAdditionalFooter() +
`
</div>
<div style="text-align: center; margin-top: 20px; color: #ccc; font-size: 12px;">
<img style="opacity: 0.25; width: 120px;" src="https://app.cal.com/cal-logo-word.svg" alt="Cal.com Logo"></div>
</body>
`
);
}
/**
* Adds the video call information to the mail body.
*
* @protected
*/
protected getLocation(): string {
if (this.calEvent.additionInformation?.hangoutLink) {
return `<tr>
<td>${this.calEvent.language("where")}</td>
<td><a href="${this.calEvent.additionInformation?.hangoutLink}">${
this.calEvent.additionInformation?.hangoutLink
}</a><br /></td>
</tr>
`;
}
if (
this.calEvent.additionInformation?.entryPoints &&
this.calEvent.additionInformation?.entryPoints.length > 0
) {
const locations = this.calEvent.additionInformation?.entryPoints
.map((entryPoint) => {
return `
${this.calEvent.language("join_by_entrypoint", { entryPoint: entryPoint.entryPointType })}: <br />
<a href="${entryPoint.uri}">${entryPoint.label}</a> <br />
`;
})
.join("<br />");
return `<tr>
<td>${this.calEvent.language("where")}</td>
<td>${locations}</td>
</tr>
`;
}
if (!this.calEvent.location) {
return ``;
}
if (this.calEvent.location === "integrations:zoom" || this.calEvent.location === "integrations:daily") {
return ``;
}
return `<tr><td>${this.calEvent.language("where")}</td><td>${
this.calEvent.location
}<br /><br /></td></tr>`;
}
protected getAdditionalBody(): string {
return ``;
}
/**
* Returns the payload object for the nodemailer.
*
* @protected
*/
protected getNodeMailerPayload(): Record<string, unknown> {
const toAddresses = [this.calEvent.organizer.email];
if (this.calEvent.team) {
this.calEvent.team.members.forEach((member) => {
const memberAttendee = this.calEvent.attendees.find((attendee) => attendee.name === member);
if (memberAttendee) {
toAddresses.push(memberAttendee.email);
}
});
}
return {
icalEvent: {
filename: "event.ics",
content: this.getiCalEventAsString(),
},
from: `Cal.com <${this.getMailerOptions().from}>`,
to: toAddresses.join(","),
subject: this.getSubject(),
html: this.getHtmlRepresentation(),
text: this.getPlainTextRepresentation(),
};
}
protected getSubject(): string {
return this.calEvent.language("new_event_subject", {
attendeeName: this.calEvent.attendees[0].name,
date: this.getOrganizerStart().format("LT dddd, LL"),
eventType: this.calEvent.type,
});
}
protected printNodeMailerError(error: Error): void {
console.error("SEND_NEW_EVENT_NOTIFICATION_ERROR", this.calEvent.organizer.email, error);
}
/**
* Returns the organizerStart value used at multiple points.
*
* @private
*/
protected getOrganizerStart(): Dayjs {
return dayjs(this.calEvent.startTime).tz(this.calEvent.organizer.timeZone);
}
}

View File

@ -1,71 +0,0 @@
import dayjs, { Dayjs } from "dayjs";
import localizedFormat from "dayjs/plugin/localizedFormat";
import timezone from "dayjs/plugin/timezone";
import toArray from "dayjs/plugin/toArray";
import utc from "dayjs/plugin/utc";
import { CalendarEvent } from "@lib/calendarClient";
import EventOrganizerMail from "@lib/emails/EventOrganizerMail";
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(toArray);
dayjs.extend(localizedFormat);
export default class EventOrganizerRefundFailedMail extends EventOrganizerMail {
reason: string;
paymentId: string;
constructor(calEvent: CalendarEvent, reason: string, paymentId: string) {
super(calEvent);
this.reason = reason;
this.paymentId = paymentId;
}
protected getBodyHeader(): string {
return this.calEvent.language("a_refund_failed");
}
protected getBodyText(): string {
const organizerStart: Dayjs = dayjs(this.calEvent.startTime).tz(this.calEvent.organizer.timeZone);
return `${this.calEvent.language("refund_failed", {
eventType: this.calEvent.type,
userName: this.calEvent.attendees[0].name,
date: organizerStart.format("LT dddd, LL"),
})} ${this.calEvent.language("check_with_provider_and_user", {
userName: this.calEvent.attendees[0].name,
})}<br>${this.calEvent.language("error_message", { errorMessage: this.reason })}<br>PaymentId: '${
this.paymentId
}'`;
}
protected getAdditionalBody(): string {
return "";
}
protected getImage(): string {
return `<svg
xmlns="http://www.w3.org/2000/svg"
style="height: 60px; width: 60px; color: #9b0125"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>`;
}
protected getSubject(): string {
const organizerStart: Dayjs = dayjs(this.calEvent.startTime).tz(this.calEvent.organizer.timeZone);
return this.calEvent.language("refund_failed_subject", {
userName: this.calEvent.attendees[0].name,
date: organizerStart.format("LT dddd, LL"),
eventType: this.calEvent.type,
});
}
}

View File

@ -1,54 +0,0 @@
import dayjs, { Dayjs } from "dayjs";
import localizedFormat from "dayjs/plugin/localizedFormat";
import timezone from "dayjs/plugin/timezone";
import toArray from "dayjs/plugin/toArray";
import utc from "dayjs/plugin/utc";
import EventOrganizerMail from "@lib/emails/EventOrganizerMail";
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(toArray);
dayjs.extend(localizedFormat);
export default class EventOrganizerRequestMail extends EventOrganizerMail {
protected getBodyHeader(): string {
return this.calEvent.language("event_awaiting_approval");
}
protected getBodyText(): string {
return this.calEvent.language("check_bookings_page_to_confirm_or_reject");
}
protected getAdditionalBody(): string {
return `<a href="${process.env.BASE_URL}/bookings">${this.calEvent.language(
"confirm_or_reject_booking"
)}</a>`;
}
protected getImage(): string {
return `<svg
xmlns="http://www.w3.org/2000/svg"
style="height: 60px; width: 60px; color: #01579b"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>`;
}
protected getSubject(): string {
const organizerStart: Dayjs = dayjs(this.calEvent.startTime).tz(this.calEvent.organizer.timeZone);
return this.calEvent.language("new_event_request", {
attendeeName: this.calEvent.attendees[0].name,
date: organizerStart.format("LT dddd, LL"),
eventType: this.calEvent.type,
});
}
}

View File

@ -1,27 +0,0 @@
import dayjs, { Dayjs } from "dayjs";
import localizedFormat from "dayjs/plugin/localizedFormat";
import timezone from "dayjs/plugin/timezone";
import toArray from "dayjs/plugin/toArray";
import utc from "dayjs/plugin/utc";
import EventOrganizerRequestMail from "@lib/emails/EventOrganizerRequestMail";
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(toArray);
dayjs.extend(localizedFormat);
export default class EventOrganizerRequestReminderMail extends EventOrganizerRequestMail {
protected getBodyHeader(): string {
return this.calEvent.language("still_waiting_for_approval");
}
protected getSubject(): string {
const organizerStart: Dayjs = dayjs(this.calEvent.startTime).tz(this.calEvent.organizer.timeZone);
return this.calEvent.language("event_is_still_waiting", {
attendeeName: this.calEvent.attendees[0].name,
date: organizerStart.format("LT dddd, LL"),
eventType: this.calEvent.type,
});
}
}

View File

@ -1,74 +0,0 @@
import dayjs, { Dayjs } from "dayjs";
import EventOrganizerMail from "./EventOrganizerMail";
export default class EventOrganizerRescheduledMail extends EventOrganizerMail {
/**
* Returns the email text as HTML representation.
*
* @protected
*/
protected getHtmlRepresentation(): string {
return (
`
<div>
${this.calEvent.language("hi_user_name", { userName: this.calEvent.organizer.name })},<br />
<br />
${this.calEvent.language("event_has_been_rescheduled")}<br />
<br />
<strong>${this.calEvent.language("event_type")}:</strong><br />
${this.calEvent.type}<br />
<br />
<strong>${this.calEvent.language("invitee_email")}:</strong><br />
<a href="mailto:${this.calEvent.attendees[0].email}">${this.calEvent.attendees[0].email}</a><br />
<br />` +
this.getAdditionalBody() +
(this.calEvent.location
? `
<strong>${this.calEvent.language("location")}:</strong><br />
${this.calEvent.location}<br />
<br />
`
: "") +
`<strong>${this.calEvent.language("invitee_timezone")}:</strong><br />
${this.calEvent.attendees[0].timeZone}<br />
<br />
<strong>${this.calEvent.language("additional_notes")}:</strong><br />
${this.calEvent.description}
` +
this.getAdditionalFooter() +
`
</div>
`
);
}
/**
* Returns the payload object for the nodemailer.
*
* @protected
*/
protected getNodeMailerPayload(): Record<string, unknown> {
const organizerStart: Dayjs = dayjs(this.calEvent.startTime).tz(this.calEvent.organizer.timeZone);
return {
icalEvent: {
filename: "event.ics",
content: this.getiCalEventAsString(),
},
from: `Cal.com <${this.getMailerOptions().from}>`,
to: this.calEvent.organizer.email,
subject: this.calEvent.language("rescheduled_event_type_with_attendee", {
attendeeName: this.calEvent.attendees[0].name,
date: organizerStart.format("LT dddd, LL"),
eventType: this.calEvent.type,
}),
html: this.getHtmlRepresentation(),
text: this.getPlainTextRepresentation(),
};
}
protected printNodeMailerError(error: Error): void {
console.error("SEND_RESCHEDULE_EVENT_NOTIFICATION_ERROR", this.calEvent.organizer.email, error);
}
}

View File

@ -1,168 +0,0 @@
import dayjs, { Dayjs } from "dayjs";
import localizedFormat from "dayjs/plugin/localizedFormat";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
import { CalendarEvent } from "@lib/calendarClient";
import EventMail from "./EventMail";
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(localizedFormat);
export default class EventPaymentMail extends EventMail {
paymentLink: string;
constructor(paymentLink: string, calEvent: CalendarEvent) {
super(calEvent);
this.paymentLink = paymentLink;
}
/**
* Returns the email text as HTML representation.
*
* @protected
*/
protected getHtmlRepresentation(): string {
return (
`
<body style="background: #f4f5f7; font-family: Helvetica, sans-serif">
<div
style="
margin: 0 auto;
max-width: 450px;
background: white;
border-radius: 0.75rem;
border: 1px solid #e5e7eb;
padding: 2rem 2rem 2rem 2rem;
text-align: center;
margin-top: 40px;
"
>
<svg
xmlns="http://www.w3.org/2000/svg"
style="height: 60px; width: 60px; color: #31c48d"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<h1 style="font-weight: 500; color: #161e2e;">${this.calEvent.language("meeting_awaiting_payment")}</h1>
<p style="color: #4b5563; margin-bottom: 30px;">${this.calEvent.language(
"emailed_you_and_any_other_attendees"
)}</p>
<hr />
<table style="border-spacing: 20px; color: #161e2e; margin-bottom: 10px;">
<colgroup>
<col span="1" style="width: 40%;">
<col span="1" style="width: 60%;">
</colgroup>
<tr>
<td>${this.calEvent.language("what")}</td>
<td>${this.calEvent.type}</td>
</tr>
<tr>
<td>${this.calEvent.language("when")}</td>
<td>${this.getInviteeStart().format("dddd, LL")}<br>${this.getInviteeStart().format("h:mma")} (${
this.calEvent.attendees[0].timeZone
})</td>
</tr>
<tr>
<td>${this.calEvent.language("who")}</td>
<td>${this.calEvent.organizer.name}<br /><small>${this.calEvent.organizer.email}</small></td>
</tr>
<tr>
<td>${this.calEvent.language("where")}</td>
<td>${this.getLocation()}</td>
</tr>
<tr>
<td>${this.calEvent.language("notes")}Notes</td>
<td>${this.calEvent.description}</td>
</tr>
</table>
` +
this.getAdditionalBody() +
"<br />" +
`
<hr />
</div>
<div style="text-align: center; margin-top: 20px; color: #ccc; font-size: 12px;">
<img style="opacity: 0.25; width: 120px;" src="https://app.cal.com/cal-logo-word.svg" alt="Cal.com Logo"></div>
</body>
`
);
}
/**
* Adds the video call information to the mail body.
*
* @protected
*/
protected getLocation(): string {
if (this.calEvent.additionInformation?.hangoutLink) {
return `<a href="${this.calEvent.additionInformation?.hangoutLink}">${this.calEvent.additionInformation?.hangoutLink}</a><br />`;
}
if (
this.calEvent.additionInformation?.entryPoints &&
this.calEvent.additionInformation?.entryPoints.length > 0
) {
const locations = this.calEvent.additionInformation?.entryPoints
.map((entryPoint) => {
return `
${this.calEvent.language("join_by_entrypoint", { entryPoint: entryPoint.entryPointType })}: <br />
<a href="${entryPoint.uri}">${entryPoint.label}</a> <br />
`;
})
.join("<br />");
return `${locations}`;
}
return this.calEvent.location ? `${this.calEvent.location}<br /><br />` : "";
}
protected getAdditionalBody(): string {
return `<a href="${this.paymentLink}">${this.calEvent.language("pay_now")}</a>`;
}
/**
* Returns the payload object for the nodemailer.
*
* @protected
*/
protected getNodeMailerPayload(): Record<string, unknown> {
return {
to: `${this.calEvent.attendees[0].name} <${this.calEvent.attendees[0].email}>`,
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
replyTo: this.calEvent.organizer.email,
subject: this.calEvent.language("awaiting_payment", {
eventType: this.calEvent.type,
organizerName: this.calEvent.organizer.name,
date: this.getInviteeStart().format("dddd, LL"),
}),
html: this.getHtmlRepresentation(),
text: this.getPlainTextRepresentation(),
};
}
protected printNodeMailerError(error: Error): void {
console.error("SEND_BOOKING_PAYMENT_ERROR", this.calEvent.attendees[0].email, error);
}
/**
* Returns the inviteeStart value used at multiple points.
*
* @private
*/
protected getInviteeStart(): Dayjs {
return dayjs(this.calEvent.startTime).tz(this.calEvent.attendees[0].timeZone);
}
}

View File

@ -1,102 +0,0 @@
import dayjs, { Dayjs } from "dayjs";
import localizedFormat from "dayjs/plugin/localizedFormat";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
import EventMail from "./EventMail";
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(localizedFormat);
export default class EventRejectionMail extends EventMail {
/**
* Returns the email text as HTML representation.
*
* @protected
*/
protected getHtmlRepresentation(): string {
return (
`
<body style="background: #f4f5f7; font-family: Helvetica, sans-serif">
<div
style="
margin: 0 auto;
max-width: 450px;
background: white;
border-radius: 0.75rem;
border: 1px solid #e5e7eb;
padding: 2rem 2rem 2rem 2rem;
text-align: center;
margin-top: 40px;
"
>
<svg
xmlns="http://www.w3.org/2000/svg"
style="height: 60px; width: 60px; color: #ba2525"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<h1 style="font-weight: 500; color: #161e2e;">${this.calEvent.language("meeting_request_rejected")}</h1>
<p style="color: #4b5563; margin-bottom: 30px;">${this.calEvent.language("emailed_you_and_attendees")}</p>
<hr />
` +
`
</div>
<div style="text-align: center; margin-top: 20px; color: #ccc; font-size: 12px;">
<img style="opacity: 0.25; width: 120px;" src="https://app.cal.com/cal-logo-word.svg" alt="Cal.com Logo"></div>
</body>
`
);
}
/**
* Returns the payload object for the nodemailer.
*
* @protected
*/
protected getNodeMailerPayload(): Record<string, unknown> {
return {
to: `${this.calEvent.attendees[0].name} <${this.calEvent.attendees[0].email}>`,
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
replyTo: this.calEvent.organizer.email,
subject: this.calEvent.language("rejected_event_type_with_organizer", {
eventType: this.calEvent.type,
organizer: this.calEvent.organizer.name,
date: this.getInviteeStart().format("dddd, LL"),
}),
html: this.getHtmlRepresentation(),
text: this.getPlainTextRepresentation(),
};
}
protected printNodeMailerError(error: Error): void {
console.error("SEND_BOOKING_CONFIRMATION_ERROR", this.calEvent.attendees[0].email, error);
}
/**
* Returns the inviteeStart value used at multiple points.
*
* @protected
*/
protected getInviteeStart(): Dayjs {
return dayjs(this.calEvent.startTime).tz(this.calEvent.attendees[0].timeZone);
}
/**
* Adds the video call information to the mail body.
*
* @protected
*/
protected getLocation(): string {
return "";
}
}

View File

@ -1,43 +0,0 @@
import EventAttendeeMail from "./EventAttendeeMail";
import { getFormattedMeetingId, getIntegrationName } from "./helpers";
export default class VideoEventAttendeeMail extends EventAttendeeMail {
/**
* Adds the video call information to the mail body.
*
* @protected
*/
protected getAdditionalBody(): string {
if (!this.calEvent.videoCallData) {
return "";
}
const meetingPassword = this.calEvent.videoCallData.password;
const meetingId = getFormattedMeetingId(this.calEvent.videoCallData);
if (meetingId && meetingPassword) {
return `
<strong>${this.calEvent.language("video_call_provider")}:</strong> ${getIntegrationName(
this.calEvent.videoCallData
)}<br />
<strong>${this.calEvent.language("meeting_id")}:</strong> ${getFormattedMeetingId(
this.calEvent.videoCallData
)}<br />
<strong>${this.calEvent.language("meeting_password")}:</strong> ${
this.calEvent.videoCallData.password
}<br />
<strong>${this.calEvent.language("meeting_url")}:</strong> <a href="${
this.calEvent.videoCallData.url
}">${this.calEvent.videoCallData.url}</a><br />
`;
}
return `
<strong>${this.calEvent.language("video_call_provider")}:</strong> ${getIntegrationName(
this.calEvent.videoCallData
)}<br />
<strong>${this.calEvent.language("meeting_url")}:</strong> <a href="${
this.calEvent.videoCallData.url
}">${this.calEvent.videoCallData.url}</a><br />
`;
}
}

View File

@ -1,41 +0,0 @@
import EventOrganizerMail from "./EventOrganizerMail";
import { getFormattedMeetingId, getIntegrationName } from "./helpers";
export default class VideoEventOrganizerMail extends EventOrganizerMail {
/**
* Adds the video call information to the mail body
* and calendar event description.
*
* @protected
*/
protected getAdditionalBody(): string {
if (!this.calEvent.videoCallData) {
return "";
}
const meetingPassword = this.calEvent.videoCallData.password;
const meetingId = getFormattedMeetingId(this.calEvent.videoCallData);
// This odd indentation is necessary because otherwise the leading tabs will be applied into the event description.
if (meetingPassword && meetingId) {
return `
<strong>${this.calEvent.language("video_call_provider")}:</strong> ${getIntegrationName(
this.calEvent.videoCallData
)}<br />
<strong>${this.calEvent.language("meeting_id")}:</strong> ${getFormattedMeetingId(
this.calEvent.videoCallData
)}<br />
<strong>${this.calEvent.language("meeting_password")}:</strong> ${this.calEvent.videoCallData.password}<br />
<strong>${this.calEvent.language("meeting_url")}:</strong> <a href="${this.calEvent.videoCallData.url}">${
this.calEvent.videoCallData.url
}</a><br />
`;
}
return `
<strong>${this.calEvent.language("video_call_provider")}:</strong> ${getIntegrationName(
this.calEvent.videoCallData
)}<br />
<strong>${this.calEvent.language("meeting_url")}:</strong> <a href="${this.calEvent.videoCallData.url}">${
this.calEvent.videoCallData.url
}</a><br />
`;
}
}

View File

@ -1,40 +0,0 @@
import Handlebars from "handlebars";
import { TFunction } from "next-i18next";
export type VarType = {
language: TFunction;
user: {
name: string | null;
};
link: string;
};
export type MessageTemplateTypes = {
messageTemplate: string;
subjectTemplate: string;
vars: VarType;
};
export type BuildTemplateResult = {
subject: string;
message: string;
};
export const buildMessageTemplate = ({
messageTemplate,
subjectTemplate,
vars,
}: MessageTemplateTypes): BuildTemplateResult => {
const buildMessage = Handlebars.compile(messageTemplate);
const message = buildMessage(vars);
const buildSubject = Handlebars.compile(subjectTemplate);
const subject = buildSubject(vars);
return {
subject,
message,
};
};
export default buildMessageTemplate;

197
lib/emails/email-manager.ts Normal file
View File

@ -0,0 +1,197 @@
import { CalendarEvent } from "@lib/calendarClient";
import AttendeeAwaitingPaymentEmail from "@lib/emails/templates/attendee-awaiting-payment-email";
import AttendeeCancelledEmail from "@lib/emails/templates/attendee-cancelled-email";
import AttendeeDeclinedEmail from "@lib/emails/templates/attendee-declined-email";
import AttendeeRescheduledEmail from "@lib/emails/templates/attendee-rescheduled-email";
import AttendeeScheduledEmail from "@lib/emails/templates/attendee-scheduled-email";
import ForgotPasswordEmail, { PasswordReset } from "@lib/emails/templates/forgot-password-email";
import OrganizerCancelledEmail from "@lib/emails/templates/organizer-cancelled-email";
import OrganizerPaymentRefundFailedEmail from "@lib/emails/templates/organizer-payment-refund-failed-email";
import OrganizerRequestEmail from "@lib/emails/templates/organizer-request-email";
import OrganizerRequestReminderEmail from "@lib/emails/templates/organizer-request-reminder-email";
import OrganizerRescheduledEmail from "@lib/emails/templates/organizer-rescheduled-email";
import OrganizerScheduledEmail from "@lib/emails/templates/organizer-scheduled-email";
import TeamInviteEmail, { TeamInvite } from "@lib/emails/templates/team-invite-email";
export const sendScheduledEmails = async (calEvent: CalendarEvent) => {
const emailsToSend = [];
emailsToSend.push(
calEvent.attendees.map((attendee) => {
return new Promise((resolve, reject) => {
try {
const scheduledEmail = new AttendeeScheduledEmail(calEvent, attendee);
resolve(scheduledEmail.sendEmail());
} catch (e) {
reject(console.error("AttendeeRescheduledEmail.sendEmail failed", e));
}
});
})
);
emailsToSend.push(
new Promise((resolve, reject) => {
try {
const scheduledEmail = new OrganizerScheduledEmail(calEvent);
resolve(scheduledEmail.sendEmail());
} catch (e) {
reject(console.error("OrganizerScheduledEmail.sendEmail failed", e));
}
})
);
await Promise.all(emailsToSend);
};
export const sendRescheduledEmails = async (calEvent: CalendarEvent) => {
const emailsToSend = [];
emailsToSend.push(
calEvent.attendees.map((attendee) => {
return new Promise((resolve, reject) => {
try {
const scheduledEmail = new AttendeeRescheduledEmail(calEvent, attendee);
resolve(scheduledEmail.sendEmail());
} catch (e) {
reject(console.error("AttendeeRescheduledEmail.sendEmail failed", e));
}
});
})
);
emailsToSend.push(
new Promise((resolve, reject) => {
try {
const scheduledEmail = new OrganizerRescheduledEmail(calEvent);
resolve(scheduledEmail.sendEmail());
} catch (e) {
reject(console.error("OrganizerScheduledEmail.sendEmail failed", e));
}
})
);
await Promise.all(emailsToSend);
};
export const sendOrganizerRequestEmail = async (calEvent: CalendarEvent) => {
await new Promise((resolve, reject) => {
try {
const organizerRequestEmail = new OrganizerRequestEmail(calEvent);
resolve(organizerRequestEmail.sendEmail());
} catch (e) {
reject(console.error("OrganizerRequestEmail.sendEmail failed", e));
}
});
};
export const sendDeclinedEmails = async (calEvent: CalendarEvent) => {
const emailsToSend = [];
emailsToSend.push(
calEvent.attendees.map((attendee) => {
return new Promise((resolve, reject) => {
try {
const declinedEmail = new AttendeeDeclinedEmail(calEvent, attendee);
resolve(declinedEmail.sendEmail());
} catch (e) {
reject(console.error("AttendeeRescheduledEmail.sendEmail failed", e));
}
});
})
);
await Promise.all(emailsToSend);
};
export const sendCancelledEmails = async (calEvent: CalendarEvent) => {
const emailsToSend = [];
emailsToSend.push(
calEvent.attendees.map((attendee) => {
return new Promise((resolve, reject) => {
try {
const scheduledEmail = new AttendeeCancelledEmail(calEvent, attendee);
resolve(scheduledEmail.sendEmail());
} catch (e) {
reject(console.error("AttendeeCancelledEmail.sendEmail failed", e));
}
});
})
);
emailsToSend.push(
new Promise((resolve, reject) => {
try {
const scheduledEmail = new OrganizerCancelledEmail(calEvent);
resolve(scheduledEmail.sendEmail());
} catch (e) {
reject(console.error("OrganizerCancelledEmail.sendEmail failed", e));
}
})
);
await Promise.all(emailsToSend);
};
export const sendOrganizerRequestReminderEmail = async (calEvent: CalendarEvent) => {
await new Promise((resolve, reject) => {
try {
const organizerRequestReminderEmail = new OrganizerRequestReminderEmail(calEvent);
resolve(organizerRequestReminderEmail.sendEmail());
} catch (e) {
reject(console.error("OrganizerRequestReminderEmail.sendEmail failed", e));
}
});
};
export const sendAwaitingPaymentEmail = async (calEvent: CalendarEvent) => {
const emailsToSend = [];
emailsToSend.push(
calEvent.attendees.map((attendee) => {
return new Promise((resolve, reject) => {
try {
const paymentEmail = new AttendeeAwaitingPaymentEmail(calEvent, attendee);
resolve(paymentEmail.sendEmail());
} catch (e) {
reject(console.error("AttendeeAwaitingPaymentEmail.sendEmail failed", e));
}
});
})
);
await Promise.all(emailsToSend);
};
export const sendOrganizerPaymentRefundFailedEmail = async (calEvent: CalendarEvent) => {
await new Promise((resolve, reject) => {
try {
const paymentRefundFailedEmail = new OrganizerPaymentRefundFailedEmail(calEvent);
resolve(paymentRefundFailedEmail.sendEmail());
} catch (e) {
reject(console.error("OrganizerPaymentRefundFailedEmail.sendEmail failed", e));
}
});
};
export const sendPasswordResetEmail = async (passwordResetEvent: PasswordReset) => {
await new Promise((resolve, reject) => {
try {
const passwordResetEmail = new ForgotPasswordEmail(passwordResetEvent);
resolve(passwordResetEmail.sendEmail());
} catch (e) {
reject(console.error("OrganizerPaymentRefundFailedEmail.sendEmail failed", e));
}
});
};
export const sendTeamInviteEmail = async (teamInviteEvent: TeamInvite) => {
await new Promise((resolve, reject) => {
try {
const teamInviteEmail = new TeamInviteEmail(teamInviteEvent);
resolve(teamInviteEmail.sendEmail());
} catch (e) {
reject(console.error("TeamInviteEmail.sendEmail failed", e));
}
});
};

View File

@ -1,35 +0,0 @@
import { VideoCallData } from "../videoClient";
export function getIntegrationName(videoCallData: VideoCallData): string {
//TODO: When there are more complex integration type strings, we should consider using an extra field in the DB for that.
const nameProto = videoCallData.type.split("_")[0];
return nameProto.charAt(0).toUpperCase() + nameProto.slice(1);
}
function extractZoom(videoCallData: VideoCallData): string {
const strId = videoCallData.id.toString();
const part1 = strId.slice(0, 3);
const part2 = strId.slice(3, 7);
const part3 = strId.slice(7, 11);
return part1 + " " + part2 + " " + part3;
}
export function getFormattedMeetingId(videoCallData: VideoCallData): string {
switch (videoCallData.type) {
case "zoom_video":
return extractZoom(videoCallData);
default:
return videoCallData.id.toString();
}
}
export function stripHtml(html: string): string {
const aMailToRegExp = /<a[\s\w="_:#;]*href="mailto:([^<>"]*)"[\s\w="_:#;]*>([^<>]*)<\/a>/g;
const aLinkRegExp = /<a[\s\w="_:#;]*href="([^<>"]*)"[\s\w="_:#;]*>([^<>]*)<\/a>/g;
return html
.replace(/<br\s?\/>/g, "\n")
.replace(aMailToRegExp, "$1")
.replace(aLinkRegExp, "$2: $1")
.replace(/<[^>]+>/g, "");
}

View File

@ -1,113 +0,0 @@
import { TFunction } from "next-i18next";
import nodemailer from "nodemailer";
import { getErrorFromUnknown } from "@lib/errors";
import { serverConfig } from "../serverConfig";
export type Invitation = {
language: TFunction;
from?: string;
toEmail: string;
teamName: string;
token?: string;
};
type EmailProvider = {
from: string;
transport: any;
};
export function createInvitationEmail(data: Invitation) {
const provider = {
transport: serverConfig.transport,
from: serverConfig.from,
} as EmailProvider;
return sendEmail(data, provider);
}
const sendEmail = (invitation: Invitation, provider: EmailProvider): Promise<void> =>
new Promise((resolve, reject) => {
const { transport, from } = provider;
const { language: t } = invitation;
const invitationHtml = html(invitation);
nodemailer.createTransport(transport).sendMail(
{
from: `Cal.com <${from}>`,
to: invitation.toEmail,
subject: invitation.from
? t("user_invited_you", { user: invitation.from, teamName: invitation.teamName })
: t("you_have_been_invited", { teamName: invitation.teamName }),
html: invitationHtml,
text: text(invitationHtml),
},
(_err) => {
if (_err) {
const err = getErrorFromUnknown(_err);
console.error("SEND_INVITATION_NOTIFICATION_ERROR", invitation.toEmail, err);
reject(err);
return;
}
return resolve();
}
);
});
export function html(invitation: Invitation): string {
const { language: t } = invitation;
let url: string = process.env.BASE_URL + "/settings/teams";
if (invitation.token) {
url = `${process.env.BASE_URL}/auth/signup?token=${invitation.token}&callbackUrl=${url}`;
}
return (
`
<table style="width: 100%;">
<tr>
<td>
<center>
<table style="width: 640px; border: 1px solid gray; padding: 15px; margin: 0 auto; text-align: left;">
<tr>
<td>
${t("hi")},<br />
<br />` +
(invitation.from
? t("user_invited_you", { user: invitation.from, teamName: invitation.teamName })
: t("you_have_been_invited", { teamName: invitation.teamName })) +
`<br />
<br />
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;line-height:100%;">
<tr>
<td>
<div>
<!--[if mso]>
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="${url}" style="height:40px;v-text-anchor:middle;width:130px;" arcsize="5%" strokecolor="#19cca3" fillcolor="#19cca3;width: 130;">
<w:anchorlock/>
<center style="color:#ffffff;font-family:Helvetica, sans-serif;font-size:18px; font-weight: 600;">Join team</center>
</v:roundrect>
<![endif]-->
<a href="${url}" style="display: inline-block; mso-hide:all; background-color: #19cca3; color: #FFFFFF; border:1px solid #19cca3; border-radius: 6px; line-height: 220%; width: 200px; font-family: Helvetica, sans-serif; font-size:18px; font-weight:600; text-align: center; text-decoration: none; -webkit-text-size-adjust:none; " target="_blank">${t(
"join_team"
)}</a>
</a>
</div>
</td>
</tr>
</table><br />
${t("request_another_invitation_email", { toEmail: invitation.toEmail })}
</td>
</tr>
</table>
</center>
</td>
</tr>
</table>
`
);
}
// just strip all HTML and convert <br /> to \n
export function text(htmlStr: string): string {
return htmlStr.replace("<br />", "\n").replace(/<[^>]+>/g, "");
}

View File

@ -1,32 +0,0 @@
import nodemailer, { SentMessageInfo } from "nodemailer";
import { SendMailOptions } from "nodemailer";
import { serverConfig } from "../serverConfig";
const sendEmail = ({ to, subject, text, html }: SendMailOptions): Promise<string | SentMessageInfo> =>
new Promise((resolve, reject) => {
const { transport, from } = serverConfig;
if (!to || !subject || (!text && !html)) {
return reject("Missing required elements to send email.");
}
nodemailer.createTransport(transport).sendMail(
{
from: `Cal.com ${from}`,
to,
subject,
text,
html,
},
(error, info) => {
if (error) {
console.error("SEND_INVITATION_NOTIFICATION_ERROR", to, error);
return reject(error.message);
}
return resolve(info);
}
);
});
export default sendEmail;

View File

@ -0,0 +1,169 @@
import dayjs from "dayjs";
import localizedFormat from "dayjs/plugin/localizedFormat";
import timezone from "dayjs/plugin/timezone";
import toArray from "dayjs/plugin/toArray";
import utc from "dayjs/plugin/utc";
import AttendeeScheduledEmail from "./attendee-scheduled-email";
import {
emailHead,
emailSchedulingBodyHeader,
emailBodyLogo,
emailScheduledBodyHeaderContent,
emailSchedulingBodyDivider,
linkIcon,
} from "./common";
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(localizedFormat);
dayjs.extend(toArray);
export default class AttendeeAwaitingPaymentEmail extends AttendeeScheduledEmail {
protected getNodeMailerPayload(): Record<string, unknown> {
return {
to: `${this.attendee.name} <${this.attendee.email}>`,
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
replyTo: this.calEvent.organizer.email,
subject: `${this.calEvent.language("awaiting_payment_subject", {
eventType: this.calEvent.type,
name: this.calEvent.team?.name || this.calEvent.organizer.name,
date: `${this.getInviteeStart().format("h:mma")} - ${this.getInviteeEnd().format(
"h:mma"
)}, ${this.calEvent.language(
this.getInviteeStart().format("dddd").toLowerCase()
)}, ${this.calEvent.language(
this.getInviteeStart().format("MMMM").toLowerCase()
)} ${this.getInviteeStart().format("D")}, ${this.getInviteeStart().format("YYYY")}`,
})}`,
html: this.getHtmlBody(),
text: this.getTextBody(),
};
}
protected getTextBody(): string {
return `
${this.calEvent.language("meeting_awaiting_payment")}
${this.calEvent.language("emailed_you_and_any_other_attendees")}
${this.getWhat()}
${this.getWhen()}
${this.getLocation()}
${this.getAdditionalNotes()}
`.replace(/(<([^>]+)>)/gi, "");
}
protected getHtmlBody(): string {
const headerContent = this.calEvent.language("awaiting_payment_subject", {
eventType: this.calEvent.type,
name: this.calEvent.team?.name || this.calEvent.organizer.name,
date: `${this.getInviteeStart().format("h:mma")} - ${this.getInviteeEnd().format(
"h:mma"
)}, ${this.calEvent.language(
this.getInviteeStart().format("dddd").toLowerCase()
)}, ${this.calEvent.language(
this.getInviteeStart().format("MMMM").toLowerCase()
)} ${this.getInviteeStart().format("D")}, ${this.getInviteeStart().format("YYYY")}`,
});
return `
<!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
${emailHead(headerContent)}
<body style="word-spacing:normal;background-color:#F5F5F5;">
<div style="background-color:#F5F5F5;">
${emailSchedulingBodyHeader("calendarCircle")}
${emailScheduledBodyHeaderContent(
this.calEvent.language("meeting_awaiting_payment"),
this.calEvent.language("emailed_you_and_any_other_attendees")
)}
${emailSchedulingBodyDivider()}
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" bgcolor="#FFFFFF" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#FFFFFF;background-color:#FFFFFF;margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#FFFFFF;background-color:#FFFFFF;width:100%;">
<tbody>
<tr>
<td style="border-left:1px solid #E1E1E1;border-right:1px solid #E1E1E1;direction:ltr;font-size:0px;padding:0px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:598px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Roboto, Helvetica, sans-serif;font-size:16px;font-weight:500;line-height:1;text-align:left;color:#3E3E3E;">
${this.getWhat()}
${this.getWhen()}
${this.getWho()}
${this.getLocation()}
${this.getAdditionalNotes()}
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
${emailSchedulingBodyDivider()}
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" bgcolor="#FFFFFF" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#FFFFFF;background-color:#FFFFFF;margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#FFFFFF;background-color:#FFFFFF;width:100%;">
<tbody>
<tr>
<td style="border-bottom:1px solid #E1E1E1;border-left:1px solid #E1E1E1;border-right:1px solid #E1E1E1;direction:ltr;font-size:0px;padding:0px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:598px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="center" vertical-align="middle" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;line-height:100%;">
${this.getManageLink()}
</table>
</td>
</tr>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Roboto, Helvetica, sans-serif;font-size:13px;line-height:1;text-align:left;color:#000000;"></div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
${emailBodyLogo()}
<!--[if mso | IE]></td></tr></table><![endif]-->
</div>
</body>
</html>
`;
}
protected getManageLink(): string {
const manageText = this.calEvent.language("pay_now");
if (this.calEvent.paymentInfo) {
return `
<tr>
<td align="center" bgcolor="#292929" role="presentation" style="border:none;border-radius:3px;cursor:auto;mso-padding-alt:10px 25px;background:#292929;" valign="middle">
<p style="display:inline-block;background:#292929;color:#ffffff;font-family:Roboto, Helvetica, sans-serif;font-size:16px;font-weight:500;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:3px;">
<a style="color: #FFFFFF; text-decoration: none;" href="${
this.calEvent.paymentInfo.link
}" target="_blank">${manageText} <img src="${linkIcon()}" width="12px"></img></a>
</p>
</td>
</tr>
`;
}
return "";
}
}

View File

@ -0,0 +1,129 @@
import dayjs from "dayjs";
import localizedFormat from "dayjs/plugin/localizedFormat";
import timezone from "dayjs/plugin/timezone";
import toArray from "dayjs/plugin/toArray";
import utc from "dayjs/plugin/utc";
import AttendeeScheduledEmail from "./attendee-scheduled-email";
import {
emailHead,
emailSchedulingBodyHeader,
emailBodyLogo,
emailScheduledBodyHeaderContent,
emailSchedulingBodyDivider,
} from "./common";
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(localizedFormat);
dayjs.extend(toArray);
export default class AttendeeCancelledEmail extends AttendeeScheduledEmail {
protected getNodeMailerPayload(): Record<string, unknown> {
return {
to: `${this.attendee.name} <${this.attendee.email}>`,
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
replyTo: this.calEvent.organizer.email,
subject: `${this.calEvent.language("event_cancelled_subject", {
eventType: this.calEvent.type,
name: this.calEvent.team?.name || this.calEvent.organizer.name,
date: `${this.getInviteeStart().format("h:mma")} - ${this.getInviteeEnd().format(
"h:mma"
)}, ${this.calEvent.language(
this.getInviteeStart().format("dddd").toLowerCase()
)}, ${this.calEvent.language(
this.getInviteeStart().format("MMMM").toLowerCase()
)} ${this.getInviteeStart().format("D")}, ${this.getInviteeStart().format("YYYY")}`,
})}`,
html: this.getHtmlBody(),
text: this.getTextBody(),
};
}
protected getTextBody(): string {
return `
${this.calEvent.language("event_request_cancelled")}
${this.calEvent.language("emailed_you_and_any_other_attendees")}
${this.getWhat()}
${this.getWhen()}
${this.getLocation()}
${this.getAdditionalNotes()}
`.replace(/(<([^>]+)>)/gi, "");
}
protected getHtmlBody(): string {
const headerContent = this.calEvent.language("event_cancelled_subject", {
eventType: this.calEvent.type,
name: this.calEvent.team?.name || this.calEvent.organizer.name,
date: `${this.getInviteeStart().format("h:mma")} - ${this.getInviteeEnd().format(
"h:mma"
)}, ${this.calEvent.language(
this.getInviteeStart().format("dddd").toLowerCase()
)}, ${this.calEvent.language(
this.getInviteeStart().format("MMMM").toLowerCase()
)} ${this.getInviteeStart().format("D")}, ${this.getInviteeStart().format("YYYY")}`,
});
return `
<!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
${emailHead(headerContent)}
<body style="word-spacing:normal;background-color:#F5F5F5;">
<div style="background-color:#F5F5F5;">
${emailSchedulingBodyHeader("xCircle")}
${emailScheduledBodyHeaderContent(
this.calEvent.language("event_request_cancelled"),
this.calEvent.language("emailed_you_and_any_other_attendees")
)}
${emailSchedulingBodyDivider()}
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" bgcolor="#FFFFFF" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#FFFFFF;background-color:#FFFFFF;margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#FFFFFF;background-color:#FFFFFF;width:100%;">
<tbody>
<tr>
<td style="border-left:1px solid #E1E1E1;border-right:1px solid #E1E1E1;direction:ltr;font-size:0px;padding:0px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:598px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Roboto, Helvetica, sans-serif;font-size:16px;font-weight:500;line-height:1;text-align:left;color:#3E3E3E;">
${this.getWhat()}
${this.getWhen()}
${this.getWho()}
${this.getLocation()}
${this.getAdditionalNotes()}
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" bgcolor="#FFFFFF" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#FFFFFF;background-color:#FFFFFF;margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#FFFFFF;background-color:#FFFFFF;width:100%;">
<tbody>
<tr>
<td style="border-bottom:1px solid #E1E1E1;border-left:1px solid #E1E1E1;border-right:1px solid #E1E1E1;direction:ltr;font-size:0px;padding:0px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
${emailBodyLogo()}
<!--[if mso | IE]></td></tr></table><![endif]-->
</div>
</body>
</html>
`;
}
}

View File

@ -0,0 +1,129 @@
import dayjs from "dayjs";
import localizedFormat from "dayjs/plugin/localizedFormat";
import timezone from "dayjs/plugin/timezone";
import toArray from "dayjs/plugin/toArray";
import utc from "dayjs/plugin/utc";
import AttendeeScheduledEmail from "./attendee-scheduled-email";
import {
emailHead,
emailSchedulingBodyHeader,
emailBodyLogo,
emailScheduledBodyHeaderContent,
emailSchedulingBodyDivider,
} from "./common";
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(localizedFormat);
dayjs.extend(toArray);
export default class AttendeeDeclinedEmail extends AttendeeScheduledEmail {
protected getNodeMailerPayload(): Record<string, unknown> {
return {
to: `${this.attendee.name} <${this.attendee.email}>`,
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
replyTo: this.calEvent.organizer.email,
subject: `${this.calEvent.language("event_declined_subject", {
eventType: this.calEvent.type,
name: this.calEvent.team?.name || this.calEvent.organizer.name,
date: `${this.getInviteeStart().format("h:mma")} - ${this.getInviteeEnd().format(
"h:mma"
)}, ${this.calEvent.language(
this.getInviteeStart().format("dddd").toLowerCase()
)}, ${this.calEvent.language(
this.getInviteeStart().format("MMMM").toLowerCase()
)} ${this.getInviteeStart().format("D")}, ${this.getInviteeStart().format("YYYY")}`,
})}`,
html: this.getHtmlBody(),
text: this.getTextBody(),
};
}
protected getTextBody(): string {
return `
${this.calEvent.language("event_request_declined")}
${this.calEvent.language("emailed_you_and_any_other_attendees")}
${this.getWhat()}
${this.getWhen()}
${this.getLocation()}
${this.getAdditionalNotes()}
`.replace(/(<([^>]+)>)/gi, "");
}
protected getHtmlBody(): string {
const headerContent = this.calEvent.language("event_declined_subject", {
eventType: this.calEvent.type,
name: this.calEvent.team?.name || this.calEvent.organizer.name,
date: `${this.getInviteeStart().format("h:mma")} - ${this.getInviteeEnd().format(
"h:mma"
)}, ${this.calEvent.language(
this.getInviteeStart().format("dddd").toLowerCase()
)}, ${this.calEvent.language(
this.getInviteeStart().format("MMMM").toLowerCase()
)} ${this.getInviteeStart().format("D")}, ${this.getInviteeStart().format("YYYY")}`,
});
return `
<!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
${emailHead(headerContent)}
<body style="word-spacing:normal;background-color:#F5F5F5;">
<div style="background-color:#F5F5F5;">
${emailSchedulingBodyHeader("xCircle")}
${emailScheduledBodyHeaderContent(
this.calEvent.language("event_request_declined"),
this.calEvent.language("emailed_you_and_any_other_attendees")
)}
${emailSchedulingBodyDivider()}
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" bgcolor="#FFFFFF" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#FFFFFF;background-color:#FFFFFF;margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#FFFFFF;background-color:#FFFFFF;width:100%;">
<tbody>
<tr>
<td style="border-left:1px solid #E1E1E1;border-right:1px solid #E1E1E1;direction:ltr;font-size:0px;padding:0px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:598px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Roboto, Helvetica, sans-serif;font-size:16px;font-weight:500;line-height:1;text-align:left;color:#3E3E3E;">
${this.getWhat()}
${this.getWhen()}
${this.getWho()}
${this.getLocation()}
${this.getAdditionalNotes()}
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" bgcolor="#FFFFFF" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#FFFFFF;background-color:#FFFFFF;margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#FFFFFF;background-color:#FFFFFF;width:100%;">
<tbody>
<tr>
<td style="border-bottom:1px solid #E1E1E1;border-left:1px solid #E1E1E1;border-right:1px solid #E1E1E1;direction:ltr;font-size:0px;padding:0px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
${emailBodyLogo()}
<!--[if mso | IE]></td></tr></table><![endif]-->
</div>
</body>
</html>
`;
}
}

View File

@ -0,0 +1,164 @@
import dayjs from "dayjs";
import localizedFormat from "dayjs/plugin/localizedFormat";
import timezone from "dayjs/plugin/timezone";
import toArray from "dayjs/plugin/toArray";
import utc from "dayjs/plugin/utc";
import { getCancelLink } from "@lib/CalEventParser";
import AttendeeScheduledEmail from "./attendee-scheduled-email";
import {
emailHead,
emailSchedulingBodyHeader,
emailBodyLogo,
emailScheduledBodyHeaderContent,
emailSchedulingBodyDivider,
} from "./common";
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(localizedFormat);
dayjs.extend(toArray);
export default class AttendeeRescheduledEmail extends AttendeeScheduledEmail {
protected getNodeMailerPayload(): Record<string, unknown> {
return {
icalEvent: {
filename: "event.ics",
content: this.getiCalEventAsString(),
},
to: `${this.attendee.name} <${this.attendee.email}>`,
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
replyTo: this.calEvent.organizer.email,
subject: `${this.calEvent.language("rescheduled_event_type_subject", {
eventType: this.calEvent.type,
name: this.calEvent.team?.name || this.calEvent.organizer.name,
date: `${this.getInviteeStart().format("h:mma")} - ${this.getInviteeEnd().format(
"h:mma"
)}, ${this.calEvent.language(
this.getInviteeStart().format("dddd").toLowerCase()
)}, ${this.calEvent.language(
this.getInviteeStart().format("MMMM").toLowerCase()
)} ${this.getInviteeStart().format("D")}, ${this.getInviteeStart().format("YYYY")}`,
})}`,
html: this.getHtmlBody(),
text: this.getTextBody(),
};
}
protected getTextBody(): string {
// Only the original attendee can make changes to the event
// Guests cannot
if (this.attendee === this.calEvent.attendees[0]) {
return `
${this.calEvent.language("event_has_been_rescheduled")}
${this.calEvent.language("emailed_you_and_any_other_attendees")}
${this.getWhat()}
${this.getWhen()}
${this.getLocation()}
${this.getAdditionalNotes()}
${this.calEvent.language("need_to_reschedule_or_cancel")}
${getCancelLink(this.calEvent)}
`.replace(/(<([^>]+)>)/gi, "");
}
return `
${this.calEvent.language("event_has_been_rescheduled")}
${this.calEvent.language("emailed_you_and_any_other_attendees")}
${this.getWhat()}
${this.getWhen()}
${this.getLocation()}
${this.getAdditionalNotes()}
`.replace(/(<([^>]+)>)/gi, "");
}
protected getHtmlBody(): string {
const headerContent = this.calEvent.language("rescheduled_event_type_subject", {
eventType: this.calEvent.type,
name: this.calEvent.team?.name || this.calEvent.organizer.name,
date: `${this.getInviteeStart().format("h:mma")} - ${this.getInviteeEnd().format(
"h:mma"
)}, ${this.calEvent.language(
this.getInviteeStart().format("dddd").toLowerCase()
)}, ${this.calEvent.language(
this.getInviteeStart().format("MMMM").toLowerCase()
)} ${this.getInviteeStart().format("D")}, ${this.getInviteeStart().format("YYYY")}`,
});
return `
<!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
${emailHead(headerContent)}
<body style="word-spacing:normal;background-color:#F5F5F5;">
<div style="background-color:#F5F5F5;">
${emailSchedulingBodyHeader("calendarCircle")}
${emailScheduledBodyHeaderContent(
this.calEvent.language("event_has_been_rescheduled"),
this.calEvent.language("emailed_you_and_any_other_attendees")
)}
${emailSchedulingBodyDivider()}
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" bgcolor="#FFFFFF" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#FFFFFF;background-color:#FFFFFF;margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#FFFFFF;background-color:#FFFFFF;width:100%;">
<tbody>
<tr>
<td style="border-left:1px solid #E1E1E1;border-right:1px solid #E1E1E1;direction:ltr;font-size:0px;padding:0px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:598px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Roboto, Helvetica, sans-serif;font-size:16px;font-weight:500;line-height:1;text-align:left;color:#3E3E3E;">
${this.getWhat()}
${this.getWhen()}
${this.getWho()}
${this.getLocation()}
${this.getAdditionalNotes()}
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
${emailSchedulingBodyDivider()}
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" bgcolor="#FFFFFF" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#FFFFFF;background-color:#FFFFFF;margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#FFFFFF;background-color:#FFFFFF;width:100%;">
<tbody>
<tr>
<td style="border-bottom:1px solid #E1E1E1;border-left:1px solid #E1E1E1;border-right:1px solid #E1E1E1;direction:ltr;font-size:0px;padding:0px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:598px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Roboto, Helvetica, sans-serif;font-size:16px;font-weight:500;line-height:0px;text-align:left;color:#3E3E3E;">
${this.getManageLink()}
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
${emailBodyLogo()}
<!--[if mso | IE]></td></tr></table><![endif]-->
</div>
</body>
</html>
`;
}
}

View File

@ -0,0 +1,401 @@
import dayjs, { Dayjs } from "dayjs";
import localizedFormat from "dayjs/plugin/localizedFormat";
import timezone from "dayjs/plugin/timezone";
import toArray from "dayjs/plugin/toArray";
import utc from "dayjs/plugin/utc";
import { createEvent, DateArray } from "ics";
import nodemailer from "nodemailer";
import { getCancelLink } from "@lib/CalEventParser";
import { CalendarEvent, Person } from "@lib/calendarClient";
import { getErrorFromUnknown } from "@lib/errors";
import { getIntegrationName } from "@lib/integrations";
import { serverConfig } from "@lib/serverConfig";
import {
emailHead,
emailSchedulingBodyHeader,
emailBodyLogo,
emailScheduledBodyHeaderContent,
emailSchedulingBodyDivider,
linkIcon,
} from "./common";
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(localizedFormat);
dayjs.extend(toArray);
export default class AttendeeScheduledEmail {
calEvent: CalendarEvent;
attendee: Person;
constructor(calEvent: CalendarEvent, attendee: Person) {
this.calEvent = calEvent;
this.attendee = attendee;
}
public sendEmail() {
new Promise((resolve, reject) =>
nodemailer
.createTransport(this.getMailerOptions().transport)
.sendMail(this.getNodeMailerPayload(), (_err, info) => {
if (_err) {
const err = getErrorFromUnknown(_err);
this.printNodeMailerError(err);
reject(err);
} else {
resolve(info);
}
})
).catch((e) => console.error("sendEmail", e));
return new Promise((resolve) => resolve("send mail async"));
}
protected getiCalEventAsString(): string | undefined {
const icsEvent = createEvent({
start: dayjs(this.calEvent.startTime)
.utc()
.toArray()
.slice(0, 6)
.map((v, i) => (i === 1 ? v + 1 : v)) as DateArray,
startInputType: "utc",
productId: "calendso/ics",
title: this.calEvent.language("ics_event_title", {
eventType: this.calEvent.type,
name: this.calEvent.attendees[0].name,
}),
description: this.getTextBody(),
duration: { minutes: dayjs(this.calEvent.endTime).diff(dayjs(this.calEvent.startTime), "minute") },
organizer: { name: this.calEvent.organizer.name, email: this.calEvent.organizer.email },
attendees: this.calEvent.attendees.map((attendee: Person) => ({
name: attendee.name,
email: attendee.email,
})),
status: "CONFIRMED",
});
if (icsEvent.error) {
throw icsEvent.error;
}
return icsEvent.value;
}
protected getNodeMailerPayload(): Record<string, unknown> {
return {
icalEvent: {
filename: "event.ics",
content: this.getiCalEventAsString(),
},
to: `${this.attendee.name} <${this.attendee.email}>`,
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
replyTo: this.calEvent.organizer.email,
subject: `${this.calEvent.language("confirmed_event_type_subject", {
eventType: this.calEvent.type,
name: this.calEvent.team?.name || this.calEvent.organizer.name,
date: `${this.getInviteeStart().format("h:mma")} - ${this.getInviteeEnd().format(
"h:mma"
)}, ${this.calEvent.language(
this.getInviteeStart().format("dddd").toLowerCase()
)}, ${this.calEvent.language(
this.getInviteeStart().format("MMMM").toLowerCase()
)} ${this.getInviteeStart().format("D")}, ${this.getInviteeStart().format("YYYY")}`,
})}`,
html: this.getHtmlBody(),
text: this.getTextBody(),
};
}
protected getMailerOptions() {
return {
transport: serverConfig.transport,
from: serverConfig.from,
};
}
protected getTextBody(): string {
// Only the original attendee can make changes to the event
// Guests cannot
if (this.attendee === this.calEvent.attendees[0]) {
return `
${this.calEvent.language("your_event_has_been_scheduled")}
${this.calEvent.language("emailed_you_and_any_other_attendees")}
${this.getWhat()}
${this.getWhen()}
${this.getLocation()}
${this.getAdditionalNotes()}
${this.calEvent.language("need_to_reschedule_or_cancel")}
${getCancelLink(this.calEvent)}
`.replace(/(<([^>]+)>)/gi, "");
}
return `
${this.calEvent.language("your_event_has_been_scheduled")}
${this.calEvent.language("emailed_you_and_any_other_attendees")}
${this.getWhat()}
${this.getWhen()}
${this.getLocation()}
${this.getAdditionalNotes()}
`.replace(/(<([^>]+)>)/gi, "");
}
protected printNodeMailerError(error: Error): void {
console.error("SEND_BOOKING_CONFIRMATION_ERROR", this.attendee.email, error);
}
protected getHtmlBody(): string {
const headerContent = this.calEvent.language("confirmed_event_type_subject", {
eventType: this.calEvent.type,
name: this.calEvent.team?.name || this.calEvent.organizer.name,
date: `${this.getInviteeStart().format("h:mma")} - ${this.getInviteeEnd().format(
"h:mma"
)}, ${this.calEvent.language(
this.getInviteeStart().format("dddd").toLowerCase()
)}, ${this.calEvent.language(
this.getInviteeStart().format("MMMM").toLowerCase()
)} ${this.getInviteeStart().format("D")}, ${this.getInviteeStart().format("YYYY")}`,
});
return `
<!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
${emailHead(headerContent)}
<body style="word-spacing:normal;background-color:#F5F5F5;">
<div style="background-color:#F5F5F5;">
${emailSchedulingBodyHeader("checkCircle")}
${emailScheduledBodyHeaderContent(
this.calEvent.language("your_event_has_been_scheduled"),
this.calEvent.language("emailed_you_and_any_other_attendees")
)}
${emailSchedulingBodyDivider()}
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" bgcolor="#FFFFFF" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#FFFFFF;background-color:#FFFFFF;margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#FFFFFF;background-color:#FFFFFF;width:100%;">
<tbody>
<tr>
<td style="border-left:1px solid #E1E1E1;border-right:1px solid #E1E1E1;direction:ltr;font-size:0px;padding:0px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:598px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Roboto, Helvetica, sans-serif;font-size:16px;font-weight:500;line-height:1;text-align:left;color:#3E3E3E;">
${this.getWhat()}
${this.getWhen()}
${this.getWho()}
${this.getLocation()}
${this.getAdditionalNotes()}
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
${emailSchedulingBodyDivider()}
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" bgcolor="#FFFFFF" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#FFFFFF;background-color:#FFFFFF;margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#FFFFFF;background-color:#FFFFFF;width:100%;">
<tbody>
<tr>
<td style="border-bottom:1px solid #E1E1E1;border-left:1px solid #E1E1E1;border-right:1px solid #E1E1E1;direction:ltr;font-size:0px;padding:0px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:598px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Roboto, Helvetica, sans-serif;font-size:16px;font-weight:500;line-height:0px;text-align:left;color:#3E3E3E;">
${this.getManageLink()}
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
${emailBodyLogo()}
<!--[if mso | IE]></td></tr></table><![endif]-->
</div>
</body>
</html>
`;
}
protected getManageLink(): string {
// Only the original attendee can make changes to the event
// Guests cannot
if (this.attendee === this.calEvent.attendees[0]) {
const manageText = this.calEvent.language("manage_this_event");
return `<p>${this.calEvent.language(
"need_to_reschedule_or_cancel"
)}</p><p style="font-weight: 400; line-height: 24px;"><a href="${getCancelLink(
this.calEvent
)}" style="color: #3E3E3E;" alt="${manageText}">${manageText}</a></p>`;
}
return "";
}
protected getWhat(): string {
return `
<div style="line-height: 6px;">
<p style="color: #494949;">${this.calEvent.language("what")}</p>
<p style="color: #494949; font-weight: 400; line-height: 24px;">${this.calEvent.type}</p>
</div>`;
}
protected getWhen(): string {
return `
<p style="height: 6px"></p>
<div style="line-height: 6px;">
<p style="color: #494949;">${this.calEvent.language("when")}</p>
<p style="color: #494949; font-weight: 400; line-height: 24px;">
${this.calEvent.language(
this.getInviteeStart().format("dddd").toLowerCase()
)}, ${this.calEvent.language(
this.getInviteeStart().format("MMMM").toLowerCase()
)} ${this.getInviteeStart().format("D")}, ${this.getInviteeStart().format(
"YYYY"
)} | ${this.getInviteeStart().format("h:mma")} - ${this.getInviteeEnd().format(
"h:mma"
)} <span style="color: #888888">(${this.getTimezone()})</span>
</p>
</div>`;
}
protected getWho(): string {
const attendees = this.calEvent.attendees
.map((attendee) => {
return `<div style="color: #494949; font-weight: 400; line-height: 24px;">${
attendee?.name || `${this.calEvent.language("guest")}`
} <span style="color: #888888"><a href="mailto:${attendee.email}" style="color: #888888;">${
attendee.email
}</a></span></div>`;
})
.join("");
const organizer = `<div style="color: #494949; font-weight: 400; line-height: 24px;">${
this.calEvent.organizer.name
} - ${this.calEvent.language("organizer")} <span style="color: #888888"><a href="mailto:${
this.calEvent.organizer.email
}" style="color: #888888;">${this.calEvent.organizer.email}</a></span></div>`;
return `
<p style="height: 6px"></p>
<div style="line-height: 6px;">
<p style="color: #494949;">${this.calEvent.language("who")}</p>
${organizer + attendees}
</div>`;
}
protected getAdditionalNotes(): string {
return `
<p style="height: 6px"></p>
<div style="line-height: 6px;">
<p style="color: #494949;">${this.calEvent.language("additional_notes")}</p>
<p style="color: #494949; font-weight: 400; line-height: 24px;">${this.calEvent.description}</p>
</div>
`;
}
protected getLocation(): string {
let providerName = this.calEvent.location ? getIntegrationName(this.calEvent.location) : "";
if (this.calEvent.location && this.calEvent.location.includes("integrations:")) {
const location = this.calEvent.location.split(":")[1];
providerName = location[0].toUpperCase() + location.slice(1);
}
if (this.calEvent.videoCallData) {
const meetingId = this.calEvent.videoCallData.id;
const meetingPassword = this.calEvent.videoCallData.password;
const meetingUrl = this.calEvent.videoCallData.url;
return `
<p style="height: 6px"></p>
<div style="line-height: 6px;">
<p style="color: #494949;">${this.calEvent.language("where")}</p>
<p style="color: #494949; font-weight: 400; line-height: 24px;">${providerName} ${
meetingUrl &&
`<a href="${meetingUrl}" target="_blank" alt="${this.calEvent.language(
"meeting_url"
)}"><img src="${linkIcon()}" width="12px"></img></a>`
}</p>
${
meetingId &&
`<div style="color: #494949; font-weight: 400; line-height: 24px;">${this.calEvent.language(
"meeting_id"
)}: <span>${meetingId}</span></div>`
}
${
meetingPassword &&
`<div style="color: #494949; font-weight: 400; line-height: 24px;">${this.calEvent.language(
"meeting_password"
)}: <span>${meetingPassword}</span></div>`
}
${
meetingUrl &&
`<div style="color: #494949; font-weight: 400; line-height: 24px;">${this.calEvent.language(
"meeting_url"
)}: <a href="${meetingUrl}" alt="${this.calEvent.language(
"meeting_url"
)}" style="color: #3E3E3E" target="_blank">${meetingUrl}</a></div>`
}
</div>
`;
}
if (this.calEvent.additionInformation?.hangoutLink) {
const hangoutLink: string = this.calEvent.additionInformation.hangoutLink;
return `
<p style="height: 6px"></p>
<div style="line-height: 6px;">
<p style="color: #494949;">${this.calEvent.language("where")}</p>
<p style="color: #494949; font-weight: 400; line-height: 24px;">${
hangoutLink &&
`<a href="${hangoutLink}" target="_blank" alt="${this.calEvent.language(
"meeting_url"
)}"><img src="${linkIcon()}" width="12px"></img></a>`
}</p>
<div style="color: #494949; font-weight: 400; line-height: 24px;"><a href="${hangoutLink}" alt="${this.calEvent.language(
"meeting_url"
)}" style="color: #3E3E3E" target="_blank">${hangoutLink}</a></div>
</div>
`;
}
return `
<p style="height: 6px"></p>
<div style="line-height: 6px;">
<p style="color: #494949;">${this.calEvent.language("where")}</p>
<p style="color: #494949; font-weight: 400; line-height: 24px;">${providerName}</p>
</div>
`;
}
protected getTimezone(): string {
// Timezone is based on the first attendee in the attendee list
// as the first attendee is the one who created the booking
return this.calEvent.attendees[0].timeZone;
}
protected getInviteeStart(): Dayjs {
return dayjs(this.calEvent.startTime).tz(this.getTimezone());
}
protected getInviteeEnd(): Dayjs {
return dayjs(this.calEvent.endTime).tz(this.getTimezone());
}
}

View File

@ -0,0 +1,44 @@
import { IS_PRODUCTION, BASE_URL } from "@lib/config/constants";
export const emailBodyLogo = (): string => {
const image = IS_PRODUCTION
? BASE_URL + "/emails/CalLogo@2x.png"
: "https://app.cal.com/emails/CalLogo@2x.png";
return `
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:0px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="center" style="font-size:0px;padding:10px 25px;padding-top:32px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
<tbody>
<tr>
<td style="width:89px;">
<a href="${BASE_URL}" target="_blank">
<img height="19" src="${image}" style="border:0;display:block;outline:none;text-decoration:none;height:19px;width:100%;font-size:13px;" width="89" />
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
`;
};

View File

@ -0,0 +1,91 @@
export const emailHead = (headerContent: string): string => {
return `
<head>
<title>${headerContent}</title>
<!--[if !mso]><!-->
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<!--<![endif]-->
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style type="text/css">
#outlook a {
padding: 0;
}
body {
margin: 0;
padding: 0;
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
table,
td {
border-collapse: collapse;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
img {
border: 0;
height: auto;
line-height: 100%;
outline: none;
text-decoration: none;
-ms-interpolation-mode: bicubic;
}
p {
display: block;
margin: 13px 0;
}
</style>
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG/>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<![endif]-->
<!--[if lte mso 11]>
<style type="text/css">
.mj-outlook-group-fix { width:100% !important; }
</style>
<![endif]-->
<!--[if !mso]><!-->
<link href="https://fonts.googleapis.com/css?family=Roboto:400,500,700" rel="stylesheet" type="text/css">
<style type="text/css">
@import url(https://fonts.googleapis.com/css?family=Roboto:400,500,700);
</style>
<!--<![endif]-->
<style type="text/css">
@media only screen and (min-width:480px) {
.mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
}
</style>
<style media="screen and (min-width:480px)">
.moz-text-html .mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
</style>
<style type="text/css">
@media only screen and (max-width:480px) {
table.mj-full-width-mobile {
width: 100% !important;
}
td.mj-full-width-mobile {
width: auto !important;
}
}
</style>
</head>
`;
};

View File

@ -0,0 +1,6 @@
export { emailHead } from "./head";
export { emailSchedulingBodyHeader } from "./scheduling-body-head";
export { emailBodyLogo } from "./body-logo";
export { emailScheduledBodyHeaderContent } from "./scheduling-body-head-content";
export { emailSchedulingBodyDivider } from "./scheduling-body-divider";
export { linkIcon } from "./link-icon";

View File

@ -0,0 +1,5 @@
import { IS_PRODUCTION, BASE_URL } from "@lib/config/constants";
export const linkIcon = (): string => {
return IS_PRODUCTION ? BASE_URL + "/emails/linkIcon.png" : "https://app.cal.com/emails/linkIcon.png";
};

View File

@ -0,0 +1,31 @@
export const emailSchedulingBodyDivider = (): string => {
return `
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" bgcolor="#FFFFFF" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#FFFFFF;background-color:#FFFFFF;margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#FFFFFF;background-color:#FFFFFF;width:100%;">
<tbody>
<tr>
<td style="border-left:1px solid #E1E1E1;border-right:1px solid #E1E1E1;direction:ltr;font-size:0px;padding:15px 0px 0 0px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:598px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="center" style="font-size:0px;padding:10px 25px;padding-bottom:15px;word-break:break-word;">
<p style="border-top:solid 1px #E1E1E1;font-size:1px;margin:0px auto;width:100%;">
</p>
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" style="border-top:solid 1px #E1E1E1;font-size:1px;margin:0px auto;width:548px;" role="presentation" width="548px" ><tr><td style="height:0;line-height:0;"> &nbsp;
</td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
`;
};

View File

@ -0,0 +1,33 @@
export const emailScheduledBodyHeaderContent = (title: string, subtitle: string): string => {
return `
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" bgcolor="#FFFFFF" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#FFFFFF;background-color:#FFFFFF;margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#FFFFFF;background-color:#FFFFFF;width:100%;">
<tbody>
<tr>
<td style="border-left:1px solid #E1E1E1;border-right:1px solid #E1E1E1;direction:ltr;font-size:0px;padding:0px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:598px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="center" style="font-size:0px;padding:10px 25px;padding-top:24px;padding-bottom:0px;word-break:break-word;">
<div style="font-family:Roboto, Helvetica, sans-serif;font-size:24px;font-weight:700;line-height:24px;text-align:center;color:#292929;">${title}</div>
</td>
</tr>
<tr>
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Roboto, Helvetica, sans-serif;font-size:16px;font-weight:400;line-height:24px;text-align:center;color:#494949;">${subtitle}</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
`;
};

View File

@ -0,0 +1,71 @@
import { IS_PRODUCTION, BASE_URL } from "@lib/config/constants";
export type BodyHeadType = "checkCircle" | "xCircle" | "calendarCircle";
export const getHeadImage = (headerType: BodyHeadType): string => {
switch (headerType) {
case "checkCircle":
return IS_PRODUCTION
? BASE_URL + "/emails/checkCircle@2x.png"
: "https://app.cal.com/emails/checkCircle@2x.png";
case "xCircle":
return IS_PRODUCTION
? BASE_URL + "/emails/xCircle@2x.png"
: "https://app.cal.com/emails/xCircle@2x.png";
case "calendarCircle":
return IS_PRODUCTION
? BASE_URL + "/emails/calendarCircle@2x.png"
: "https://app.cal.com/emails/calendarCircle@2x.png";
}
};
export const emailSchedulingBodyHeader = (headerType: BodyHeadType): string => {
const image = getHeadImage(headerType);
return `
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:0px;padding-top:40px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" bgcolor="#FFFFFF" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#FFFFFF;background-color:#FFFFFF;margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#FFFFFF;background-color:#FFFFFF;width:100%;">
<tbody>
<tr>
<td style="border-left:1px solid #E1E1E1;border-right:1px solid #E1E1E1;border-top:1px solid #E1E1E1;direction:ltr;font-size:0px;padding:30px 20px 0 20px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:558px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
<tbody>
<tr>
<td style="width:64px;">
<img height="64" src="${image}" style="border:0;display:block;outline:none;text-decoration:none;height:64px;width:100%;font-size:13px;" width="64" />
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
`;
};

View File

@ -0,0 +1,217 @@
import { TFunction } from "next-i18next";
import nodemailer from "nodemailer";
import { getErrorFromUnknown } from "@lib/errors";
import { serverConfig } from "@lib/serverConfig";
import { emailHead, linkIcon, emailBodyLogo } from "./common";
export type PasswordReset = {
language: TFunction;
user: {
name?: string | null;
email: string;
};
resetLink: string;
};
export const PASSWORD_RESET_EXPIRY_HOURS = 6;
export default class ForgotPasswordEmail {
passwordEvent: PasswordReset;
constructor(passwordEvent: PasswordReset) {
this.passwordEvent = passwordEvent;
}
public sendEmail() {
new Promise((resolve, reject) =>
nodemailer
.createTransport(this.getMailerOptions().transport)
.sendMail(this.getNodeMailerPayload(), (_err, info) => {
if (_err) {
const err = getErrorFromUnknown(_err);
this.printNodeMailerError(err);
reject(err);
} else {
resolve(info);
}
})
).catch((e) => console.error("sendEmail", e));
return new Promise((resolve) => resolve("send mail async"));
}
protected getMailerOptions() {
return {
transport: serverConfig.transport,
from: serverConfig.from,
};
}
protected getNodeMailerPayload(): Record<string, unknown> {
return {
to: `${this.passwordEvent.user.name} <${this.passwordEvent.user.email}>`,
from: `Cal.com <${this.getMailerOptions().from}>`,
subject: this.passwordEvent.language("reset_password_subject"),
html: this.getHtmlBody(),
text: this.getTextBody(),
};
}
protected printNodeMailerError(error: Error): void {
console.error("SEND_PASSWORD_RESET_EMAIL_ERROR", this.passwordEvent.user.email, error);
}
protected getTextBody(): string {
return `
${this.passwordEvent.language("reset_password_subject")}
${this.passwordEvent.language("hi_user_name", { user: this.passwordEvent.user.name })},
${this.passwordEvent.language("someone_requested_password_reset")}
${this.passwordEvent.language("change_password")}: ${this.passwordEvent.resetLink}
${this.passwordEvent.language("password_reset_instructions")}
${this.passwordEvent.language("have_any_questions")} ${this.passwordEvent.language(
"contact_our_support_team"
)}
`.replace(/(<([^>]+)>)/gi, "");
}
protected getHtmlBody(): string {
const headerContent = this.passwordEvent.language("reset_password_subject");
return `
<!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
${emailHead(headerContent)}
<body style="word-spacing:normal;background-color:#F5F5F5;">
<div style="background-color:#F5F5F5;">
${emailBodyLogo()}
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:0px;padding-top:40px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" bgcolor="#FFFFFF" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#FFFFFF;background-color:#FFFFFF;margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#FFFFFF;background-color:#FFFFFF;width:100%;">
<tbody>
<tr>
<td style="border-left:1px solid #E1E1E1;border-right:1px solid #E1E1E1;border-top:1px solid #E1E1E1;direction:ltr;font-size:0px;padding:30px 20px 0 20px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" bgcolor="#FFFFFF" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#FFFFFF;background-color:#FFFFFF;margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#FFFFFF;background-color:#FFFFFF;width:100%;">
<tbody>
<tr>
<td style="border-left:1px solid #E1E1E1;border-right:1px solid #E1E1E1;direction:ltr;font-size:0px;padding:0px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:598px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Roboto, Helvetica, sans-serif;font-size:16px;font-weight:500;line-height:1;text-align:left;color:#3E3E3E;">
<div style="line-height: 6px;">
<p>${this.passwordEvent.language("hi_user_name", {
user: this.passwordEvent.user.name,
})},</p>
<p style="font-weight: 400; line-height: 24px;">${this.passwordEvent.language(
"someone_requested_password_reset"
)}</p>
</div>
</div>
</td>
</tr>
<tr>
<td align="left" vertical-align="middle" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;line-height:100%;">
<tr>
<td align="center" bgcolor="#292929" role="presentation" style="border:none;border-radius:3px;cursor:auto;mso-padding-alt:10px 25px;background:#292929;" valign="middle">
<p style="display:inline-block;background:#292929;color:#292929;font-family:Roboto, Helvetica, sans-serif;font-size:13px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:3px;">
<a href="${
this.passwordEvent.resetLink
}" target="_blank" style="color: #FFFFFF; text-decoration: none">${this.passwordEvent.language(
"change_password"
)} <img src="${linkIcon()}" width="12px"></img></a>
</p>
</td>
</tr>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" bgcolor="#FFFFFF" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#FFFFFF;background-color:#FFFFFF;margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#FFFFFF;background-color:#FFFFFF;width:100%;">
<tbody>
<tr>
<td style="border-left:1px solid #E1E1E1;border-right:1px solid #E1E1E1;direction:ltr;font-size:0px;padding:0px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:598px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Roboto, Helvetica, sans-serif;font-size:16px;font-weight:500;line-height:1;text-align:left;color:#3E3E3E;">
<div style="line-height: 6px;">
<p style="font-weight: 400; line-height: 24px;">${this.passwordEvent.language(
"password_reset_instructions"
)}</p>
</div>
<p style="height: 6px"></p>
<div style="line-height: 6px;">
<p style="font-weight: 400; line-height: 24px;">${this.passwordEvent.language(
"have_any_questions"
)} <a href="mailto:support@cal.com" style="color: #3E3E3E" target="_blank">${this.passwordEvent.language(
"contact_our_support_team"
)}</a></p>
</div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" bgcolor="#FFFFFF" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#FFFFFF;background-color:#FFFFFF;margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#FFFFFF;background-color:#FFFFFF;width:100%;">
<tbody>
<tr>
<td style="border-bottom:1px solid #E1E1E1;border-left:1px solid #E1E1E1;border-right:1px solid #E1E1E1;direction:ltr;font-size:0px;padding:0px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</div>
</body>
</html>
`;
}
}

View File

@ -0,0 +1,137 @@
import dayjs from "dayjs";
import localizedFormat from "dayjs/plugin/localizedFormat";
import timezone from "dayjs/plugin/timezone";
import toArray from "dayjs/plugin/toArray";
import utc from "dayjs/plugin/utc";
import {
emailHead,
emailSchedulingBodyHeader,
emailBodyLogo,
emailScheduledBodyHeaderContent,
emailSchedulingBodyDivider,
} from "./common";
import OrganizerScheduledEmail from "./organizer-scheduled-email";
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(localizedFormat);
dayjs.extend(toArray);
export default class OrganizerCancelledEmail extends OrganizerScheduledEmail {
protected getNodeMailerPayload(): Record<string, unknown> {
const toAddresses = [this.calEvent.organizer.email];
if (this.calEvent.team) {
this.calEvent.team.members.forEach((member) => {
const memberAttendee = this.calEvent.attendees.find((attendee) => attendee.name === member);
if (memberAttendee) {
toAddresses.push(memberAttendee.email);
}
});
}
return {
from: `Cal.com <${this.getMailerOptions().from}>`,
to: toAddresses.join(","),
subject: `${this.calEvent.language("event_cancelled_subject", {
eventType: this.calEvent.type,
name: this.calEvent.attendees[0].name,
date: `${this.getOrganizerStart().format("h:mma")} - ${this.getOrganizerEnd().format(
"h:mma"
)}, ${this.calEvent.language(
this.getOrganizerStart().format("dddd").toLowerCase()
)}, ${this.calEvent.language(
this.getOrganizerStart().format("MMMM").toLowerCase()
)} ${this.getOrganizerStart().format("D")}, ${this.getOrganizerStart().format("YYYY")}`,
})}`,
html: this.getHtmlBody(),
text: this.getTextBody(),
};
}
protected getTextBody(): string {
return `
${this.calEvent.language("event_request_cancelled")}
${this.calEvent.language("emailed_you_and_any_other_attendees")}
${this.getWhat()}
${this.getWhen()}
${this.getLocation()}
${this.getAdditionalNotes()}
`.replace(/(<([^>]+)>)/gi, "");
}
protected getHtmlBody(): string {
const headerContent = this.calEvent.language("event_cancelled_subject", {
eventType: this.calEvent.type,
name: this.calEvent.attendees[0].name,
date: `${this.getOrganizerStart().format("h:mma")} - ${this.getOrganizerEnd().format(
"h:mma"
)}, ${this.calEvent.language(
this.getOrganizerStart().format("dddd").toLowerCase()
)}, ${this.calEvent.language(
this.getOrganizerStart().format("MMMM").toLowerCase()
)} ${this.getOrganizerStart().format("D")}, ${this.getOrganizerStart().format("YYYY")}`,
});
return `
<!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
${emailHead(headerContent)}
<body style="word-spacing:normal;background-color:#F5F5F5;">
<div style="background-color:#F5F5F5;">
${emailSchedulingBodyHeader("xCircle")}
${emailScheduledBodyHeaderContent(
this.calEvent.language("event_request_cancelled"),
this.calEvent.language("emailed_you_and_any_other_attendees")
)}
${emailSchedulingBodyDivider()}
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" bgcolor="#FFFFFF" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#FFFFFF;background-color:#FFFFFF;margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#FFFFFF;background-color:#FFFFFF;width:100%;">
<tbody>
<tr>
<td style="border-left:1px solid #E1E1E1;border-right:1px solid #E1E1E1;direction:ltr;font-size:0px;padding:0px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:598px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Roboto, Helvetica, sans-serif;font-size:16px;font-weight:500;line-height:1;text-align:left;color:#3E3E3E;">
${this.getWhat()}
${this.getWhen()}
${this.getWho()}
${this.getLocation()}
${this.getAdditionalNotes()}
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" bgcolor="#FFFFFF" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#FFFFFF;background-color:#FFFFFF;margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#FFFFFF;background-color:#FFFFFF;width:100%;">
<tbody>
<tr>
<td style="border-bottom:1px solid #E1E1E1;border-left:1px solid #E1E1E1;border-right:1px solid #E1E1E1;direction:ltr;font-size:0px;padding:0px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
${emailBodyLogo()}
<!--[if mso | IE]></td></tr></table><![endif]-->
</div>
</body>
</html>
`;
}
}

View File

@ -0,0 +1,199 @@
import dayjs from "dayjs";
import localizedFormat from "dayjs/plugin/localizedFormat";
import timezone from "dayjs/plugin/timezone";
import toArray from "dayjs/plugin/toArray";
import utc from "dayjs/plugin/utc";
import { emailHead, emailSchedulingBodyHeader, emailBodyLogo, emailSchedulingBodyDivider } from "./common";
import OrganizerScheduledEmail from "./organizer-scheduled-email";
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(localizedFormat);
dayjs.extend(toArray);
export default class OrganizerPaymentRefundFailedEmail extends OrganizerScheduledEmail {
protected getNodeMailerPayload(): Record<string, unknown> {
const toAddresses = [this.calEvent.organizer.email];
if (this.calEvent.team) {
this.calEvent.team.members.forEach((member) => {
const memberAttendee = this.calEvent.attendees.find((attendee) => attendee.name === member);
if (memberAttendee) {
toAddresses.push(memberAttendee.email);
}
});
}
return {
from: `Cal.com <${this.getMailerOptions().from}>`,
to: toAddresses.join(","),
subject: `${this.calEvent.language("refund_failed_subject", {
eventType: this.calEvent.type,
name: this.calEvent.attendees[0].name,
date: `${this.getOrganizerStart().format("h:mma")} - ${this.getOrganizerEnd().format(
"h:mma"
)}, ${this.calEvent.language(
this.getOrganizerStart().format("dddd").toLowerCase()
)}, ${this.calEvent.language(
this.getOrganizerStart().format("MMMM").toLowerCase()
)} ${this.getOrganizerStart().format("D")}, ${this.getOrganizerStart().format("YYYY")}`,
})}`,
html: this.getHtmlBody(),
text: this.getTextBody(),
};
}
protected getTextBody(): string {
return `
${this.calEvent.language("a_refund_failed")}
${this.calEvent.language("check_with_provider_and_user", { user: this.calEvent.attendees[0].name })}
${
this.calEvent.paymentInfo &&
this.calEvent.language("error_message", { errorMessage: this.calEvent.paymentInfo.reason })
}
${this.getWhat()}
${this.getWhen()}
${this.getLocation()}
${this.getAdditionalNotes()}
`.replace(/(<([^>]+)>)/gi, "");
}
protected getHtmlBody(): string {
const headerContent = this.calEvent.language("refund_failed_subject", {
eventType: this.calEvent.type,
name: this.calEvent.attendees[0].name,
date: `${this.getOrganizerStart().format("h:mma")} - ${this.getOrganizerEnd().format(
"h:mma"
)}, ${this.calEvent.language(
this.getOrganizerStart().format("dddd").toLowerCase()
)}, ${this.calEvent.language(
this.getOrganizerStart().format("MMMM").toLowerCase()
)} ${this.getOrganizerStart().format("D")}, ${this.getOrganizerStart().format("YYYY")}`,
});
return `
<!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
${emailHead(headerContent)}
<body style="word-spacing:normal;background-color:#F5F5F5;">
<div style="background-color:#F5F5F5;">
${emailSchedulingBodyHeader("xCircle")}
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" bgcolor="#FFFFFF" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#FFFFFF;background-color:#FFFFFF;margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#FFFFFF;background-color:#FFFFFF;width:100%;">
<tbody>
<tr>
<td style="border-left:1px solid #E1E1E1;border-right:1px solid #E1E1E1;direction:ltr;font-size:0px;padding:0px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:598px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="center" style="font-size:0px;padding:10px 25px;padding-top:24px;padding-bottom:0px;word-break:break-word;">
<div style="font-family:Roboto, Helvetica, sans-serif;font-size:24px;font-weight:700;line-height:24px;text-align:center;color:#292929;">${this.calEvent.language(
"a_refund_failed"
)}</div>
</td>
</tr>
<tr>
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Roboto, Helvetica, sans-serif;font-size:16px;font-weight:400;line-height:24px;text-align:center;color:#494949;">${this.calEvent.language(
"check_with_provider_and_user",
{ user: this.calEvent.attendees[0].name }
)}</div>
</td>
</tr>
${this.getRefundInformation()}
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
${emailSchedulingBodyDivider()}
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" bgcolor="#FFFFFF" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#FFFFFF;background-color:#FFFFFF;margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#FFFFFF;background-color:#FFFFFF;width:100%;">
<tbody>
<tr>
<td style="border-left:1px solid #E1E1E1;border-right:1px solid #E1E1E1;direction:ltr;font-size:0px;padding:0px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:598px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Roboto, Helvetica, sans-serif;font-size:16px;font-weight:500;line-height:1;text-align:left;color:#3E3E3E;">
${this.getWhat()}
${this.getWhen()}
${this.getWho()}
${this.getLocation()}
${this.getAdditionalNotes()}
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" bgcolor="#FFFFFF" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#FFFFFF;background-color:#FFFFFF;margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#FFFFFF;background-color:#FFFFFF;width:100%;">
<tbody>
<tr>
<td style="border-bottom:1px solid #E1E1E1;border-left:1px solid #E1E1E1;border-right:1px solid #E1E1E1;direction:ltr;font-size:0px;padding:0px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
${emailBodyLogo()}
<!--[if mso | IE]></td></tr></table><![endif]-->
</div>
</body>
</html>
`;
}
protected getRefundInformation(): string {
const paymentInfo = this.calEvent.paymentInfo;
let refundInformation = "";
if (paymentInfo) {
if (paymentInfo.reason) {
refundInformation = `
<tr>
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Roboto, Helvetica, sans-serif;font-size:16px;font-weight:400;line-height:24px;text-align:center;color:#494949;">${this.calEvent.language(
"error_message",
{ errorMessage: paymentInfo.reason }
)}</div>
</td>
</tr>
`;
}
if (paymentInfo.id) {
refundInformation += `
<tr>
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Roboto, Helvetica, sans-serif;font-size:16px;font-weight:400;line-height:24px;text-align:center;color:#494949;">Payment ${paymentInfo.id}</div>
</td>
</tr>
`;
}
}
return refundInformation;
}
}

View File

@ -0,0 +1,173 @@
import dayjs from "dayjs";
import localizedFormat from "dayjs/plugin/localizedFormat";
import timezone from "dayjs/plugin/timezone";
import toArray from "dayjs/plugin/toArray";
import utc from "dayjs/plugin/utc";
import {
emailHead,
emailSchedulingBodyHeader,
emailBodyLogo,
emailScheduledBodyHeaderContent,
emailSchedulingBodyDivider,
linkIcon,
} from "./common";
import OrganizerScheduledEmail from "./organizer-scheduled-email";
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(localizedFormat);
dayjs.extend(toArray);
export default class OrganizerRequestEmail extends OrganizerScheduledEmail {
protected getNodeMailerPayload(): Record<string, unknown> {
const toAddresses = [this.calEvent.organizer.email];
if (this.calEvent.team) {
this.calEvent.team.members.forEach((member) => {
const memberAttendee = this.calEvent.attendees.find((attendee) => attendee.name === member);
if (memberAttendee) {
toAddresses.push(memberAttendee.email);
}
});
}
return {
from: `Cal.com <${this.getMailerOptions().from}>`,
to: toAddresses.join(","),
subject: `${this.calEvent.language("event_awaiting_approval_subject", {
eventType: this.calEvent.type,
name: this.calEvent.attendees[0].name,
date: `${this.getOrganizerStart().format("h:mma")} - ${this.getOrganizerEnd().format(
"h:mma"
)}, ${this.calEvent.language(
this.getOrganizerStart().format("dddd").toLowerCase()
)}, ${this.calEvent.language(
this.getOrganizerStart().format("MMMM").toLowerCase()
)} ${this.getOrganizerStart().format("D")}, ${this.getOrganizerStart().format("YYYY")}`,
})}`,
html: this.getHtmlBody(),
text: this.getTextBody(),
};
}
protected getTextBody(): string {
return `
${this.calEvent.language("event_awaiting_approval")}
${this.calEvent.language("someone_requested_an_event")}
${this.getWhat()}
${this.getWhen()}
${this.getLocation()}
${this.getAdditionalNotes()}
${this.calEvent.language("confirm_or_reject_request")}
${process.env.BASE_URL} + "/bookings/upcoming"
`.replace(/(<([^>]+)>)/gi, "");
}
protected getHtmlBody(): string {
const headerContent = this.calEvent.language("event_awaiting_approval_subject", {
eventType: this.calEvent.type,
name: this.calEvent.attendees[0].name,
date: `${this.getOrganizerStart().format("h:mma")} - ${this.getOrganizerEnd().format(
"h:mma"
)}, ${this.calEvent.language(
this.getOrganizerStart().format("dddd").toLowerCase()
)}, ${this.calEvent.language(
this.getOrganizerStart().format("MMMM").toLowerCase()
)} ${this.getOrganizerStart().format("D")}, ${this.getOrganizerStart().format("YYYY")}`,
});
return `
<!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
${emailHead(headerContent)}
<body style="word-spacing:normal;background-color:#F5F5F5;">
<div style="background-color:#F5F5F5;">
${emailSchedulingBodyHeader("calendarCircle")}
${emailScheduledBodyHeaderContent(
this.calEvent.language("event_awaiting_approval"),
this.calEvent.language("someone_requested_an_event")
)}
${emailSchedulingBodyDivider()}
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" bgcolor="#FFFFFF" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#FFFFFF;background-color:#FFFFFF;margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#FFFFFF;background-color:#FFFFFF;width:100%;">
<tbody>
<tr>
<td style="border-left:1px solid #E1E1E1;border-right:1px solid #E1E1E1;direction:ltr;font-size:0px;padding:0px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:598px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Roboto, Helvetica, sans-serif;font-size:16px;font-weight:500;line-height:1;text-align:left;color:#3E3E3E;">
${this.getWhat()}
${this.getWhen()}
${this.getWho()}
${this.getLocation()}
${this.getAdditionalNotes()}
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
${emailSchedulingBodyDivider()}
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" bgcolor="#FFFFFF" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#FFFFFF;background-color:#FFFFFF;margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#FFFFFF;background-color:#FFFFFF;width:100%;">
<tbody>
<tr>
<td style="border-bottom:1px solid #E1E1E1;border-left:1px solid #E1E1E1;border-right:1px solid #E1E1E1;direction:ltr;font-size:0px;padding:0px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:598px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="center" vertical-align="middle" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;line-height:100%;">
<tr>
<td align="center" bgcolor="#292929" role="presentation" style="border:none;border-radius:3px;cursor:auto;mso-padding-alt:10px 25px;background:#292929;" valign="middle">
<p style="display:inline-block;background:#292929;color:#ffffff;font-family:Roboto, Helvetica, sans-serif;font-size:16px;font-weight:500;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:3px;">
${this.getManageLink()}
</p>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Roboto, Helvetica, sans-serif;font-size:13px;line-height:1;text-align:left;color:#000000;"></div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
${emailBodyLogo()}
<!--[if mso | IE]></td></tr></table><![endif]-->
</div>
</body>
</html>
`;
}
protected getManageLink(): string {
const manageText = this.calEvent.language("confirm_or_reject_request");
const manageLink = process.env.BASE_URL + "/bookings/upcoming";
return `<a style="color: #FFFFFF; text-decoration: none;" href="${manageLink}" target="_blank">${manageText} <img src="${linkIcon()}" width="12px"></img></a>`;
}
}

View File

@ -0,0 +1,172 @@
import dayjs from "dayjs";
import localizedFormat from "dayjs/plugin/localizedFormat";
import timezone from "dayjs/plugin/timezone";
import toArray from "dayjs/plugin/toArray";
import utc from "dayjs/plugin/utc";
import {
emailHead,
emailSchedulingBodyHeader,
emailBodyLogo,
emailScheduledBodyHeaderContent,
emailSchedulingBodyDivider,
linkIcon,
} from "./common";
import OrganizerScheduledEmail from "./organizer-scheduled-email";
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(localizedFormat);
dayjs.extend(toArray);
export default class OrganizerRequestReminderEmail extends OrganizerScheduledEmail {
protected getNodeMailerPayload(): Record<string, unknown> {
const toAddresses = [this.calEvent.organizer.email];
if (this.calEvent.team) {
this.calEvent.team.members.forEach((member) => {
const memberAttendee = this.calEvent.attendees.find((attendee) => attendee.name === member);
if (memberAttendee) {
toAddresses.push(memberAttendee.email);
}
});
}
return {
from: `Cal.com <${this.getMailerOptions().from}>`,
to: toAddresses.join(","),
subject: `${this.calEvent.language("event_awaiting_approval_subject", {
eventType: this.calEvent.type,
name: this.calEvent.attendees[0].name,
date: `${this.getOrganizerStart().format("h:mma")} - ${this.getOrganizerEnd().format(
"h:mma"
)}, ${this.calEvent.language(
this.getOrganizerStart().format("dddd").toLowerCase()
)}, ${this.calEvent.language(
this.getOrganizerStart().format("MMMM").toLowerCase()
)} ${this.getOrganizerStart().format("D")}, ${this.getOrganizerStart().format("YYYY")}`,
})}`,
html: this.getHtmlBody(),
text: this.getTextBody(),
};
}
protected getTextBody(): string {
return `
${this.calEvent.language("event_still_awaiting_approval")}
${this.calEvent.language("someone_requested_an_event")}
${this.getWhat()}
${this.getWhen()}
${this.getLocation()}
${this.getAdditionalNotes()}
${this.calEvent.language("confirm_or_reject_request")}
${process.env.BASE_URL} + "/bookings/upcoming"
`.replace(/(<([^>]+)>)/gi, "");
}
protected getHtmlBody(): string {
const headerContent = this.calEvent.language("event_awaiting_approval_subject", {
eventType: this.calEvent.type,
name: this.calEvent.attendees[0].name,
date: `${this.getOrganizerStart().format("h:mma")} - ${this.getOrganizerEnd().format(
"h:mma"
)}, ${this.calEvent.language(
this.getOrganizerStart().format("dddd").toLowerCase()
)}, ${this.calEvent.language(
this.getOrganizerStart().format("MMMM").toLowerCase()
)} ${this.getOrganizerStart().format("D")}, ${this.getOrganizerStart().format("YYYY")}`,
});
return `
<!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
${emailHead(headerContent)}
<body style="word-spacing:normal;background-color:#F5F5F5;">
<div style="background-color:#F5F5F5;">
${emailSchedulingBodyHeader("calendarCircle")}
${emailScheduledBodyHeaderContent(
this.calEvent.language("event_still_awaiting_approval"),
this.calEvent.language("someone_requested_an_event")
)}
${emailSchedulingBodyDivider()}
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" bgcolor="#FFFFFF" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#FFFFFF;background-color:#FFFFFF;margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#FFFFFF;background-color:#FFFFFF;width:100%;">
<tbody>
<tr>
<td style="border-left:1px solid #E1E1E1;border-right:1px solid #E1E1E1;direction:ltr;font-size:0px;padding:0px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:598px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Roboto, Helvetica, sans-serif;font-size:16px;font-weight:500;line-height:1;text-align:left;color:#3E3E3E;">
${this.getWhat()}
${this.getWhen()}
${this.getWho()}
${this.getLocation()}
${this.getAdditionalNotes()}
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
${emailSchedulingBodyDivider()}
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" bgcolor="#FFFFFF" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#FFFFFF;background-color:#FFFFFF;margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#FFFFFF;background-color:#FFFFFF;width:100%;">
<tbody>
<tr>
<td style="border-bottom:1px solid #E1E1E1;border-left:1px solid #E1E1E1;border-right:1px solid #E1E1E1;direction:ltr;font-size:0px;padding:0px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:598px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="center" vertical-align="middle" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;line-height:100%;">
<tr>
<td align="center" bgcolor="#292929" role="presentation" style="border:none;border-radius:3px;cursor:auto;mso-padding-alt:10px 25px;background:#292929;" valign="middle">
<p style="display:inline-block;background:#292929;color:#ffffff;font-family:Roboto, Helvetica, sans-serif;font-size:16px;font-weight:500;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:3px;">
${this.getManageLink()}
</p>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Roboto, Helvetica, sans-serif;font-size:13px;line-height:1;text-align:left;color:#000000;"></div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
${emailBodyLogo()}
<!--[if mso | IE]></td></tr></table><![endif]-->
</div>
</body>
</html>
`;
}
protected getManageLink(): string {
const manageText = this.calEvent.language("confirm_or_reject_request");
const manageLink = process.env.BASE_URL + "/bookings/upcoming";
return `<a style="color: #FFFFFF; text-decoration: none;" href="${manageLink}" target="_blank">${manageText} <img src="${linkIcon()}" width="12px"></img></a>`;
}
}

View File

@ -0,0 +1,160 @@
import dayjs from "dayjs";
import localizedFormat from "dayjs/plugin/localizedFormat";
import timezone from "dayjs/plugin/timezone";
import toArray from "dayjs/plugin/toArray";
import utc from "dayjs/plugin/utc";
import { getCancelLink } from "@lib/CalEventParser";
import {
emailHead,
emailSchedulingBodyHeader,
emailBodyLogo,
emailScheduledBodyHeaderContent,
emailSchedulingBodyDivider,
} from "./common";
import OrganizerScheduledEmail from "./organizer-scheduled-email";
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(localizedFormat);
dayjs.extend(toArray);
export default class OrganizerRescheduledEmail extends OrganizerScheduledEmail {
protected getNodeMailerPayload(): Record<string, unknown> {
const toAddresses = [this.calEvent.organizer.email];
if (this.calEvent.team) {
this.calEvent.team.members.forEach((member) => {
const memberAttendee = this.calEvent.attendees.find((attendee) => attendee.name === member);
if (memberAttendee) {
toAddresses.push(memberAttendee.email);
}
});
}
return {
icalEvent: {
filename: "event.ics",
content: this.getiCalEventAsString(),
},
from: `Cal.com <${this.getMailerOptions().from}>`,
to: toAddresses.join(","),
subject: `${this.calEvent.language("rescheduled_event_type_subject", {
eventType: this.calEvent.type,
name: this.calEvent.attendees[0].name,
date: `${this.getOrganizerStart().format("h:mma")} - ${this.getOrganizerEnd().format(
"h:mma"
)}, ${this.calEvent.language(
this.getOrganizerStart().format("dddd").toLowerCase()
)}, ${this.calEvent.language(
this.getOrganizerStart().format("MMMM").toLowerCase()
)} ${this.getOrganizerStart().format("D")}, ${this.getOrganizerStart().format("YYYY")}`,
})}`,
html: this.getHtmlBody(),
text: this.getTextBody(),
};
}
protected getTextBody(): string {
return `
${this.calEvent.language("event_has_been_rescheduled")}
${this.calEvent.language("emailed_you_and_any_other_attendees")}
${this.getWhat()}
${this.getWhen()}
${this.getLocation()}
${this.getAdditionalNotes()}
${this.calEvent.language("need_to_reschedule_or_cancel")}
${getCancelLink(this.calEvent)}
`.replace(/(<([^>]+)>)/gi, "");
}
protected getHtmlBody(): string {
const headerContent = this.calEvent.language("rescheduled_event_type_subject", {
eventType: this.calEvent.type,
name: this.calEvent.attendees[0].name,
date: `${this.getOrganizerStart().format("h:mma")} - ${this.getOrganizerEnd().format(
"h:mma"
)}, ${this.calEvent.language(
this.getOrganizerStart().format("dddd").toLowerCase()
)}, ${this.calEvent.language(
this.getOrganizerStart().format("MMMM").toLowerCase()
)} ${this.getOrganizerStart().format("D")}, ${this.getOrganizerStart().format("YYYY")}`,
});
return `
<!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
${emailHead(headerContent)}
<body style="word-spacing:normal;background-color:#F5F5F5;">
<div style="background-color:#F5F5F5;">
${emailSchedulingBodyHeader("calendarCircle")}
${emailScheduledBodyHeaderContent(
this.calEvent.language("event_has_been_rescheduled"),
this.calEvent.language("emailed_you_and_any_other_attendees")
)}
${emailSchedulingBodyDivider()}
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" bgcolor="#FFFFFF" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#FFFFFF;background-color:#FFFFFF;margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#FFFFFF;background-color:#FFFFFF;width:100%;">
<tbody>
<tr>
<td style="border-left:1px solid #E1E1E1;border-right:1px solid #E1E1E1;direction:ltr;font-size:0px;padding:0px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:598px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Roboto, Helvetica, sans-serif;font-size:16px;font-weight:500;line-height:1;text-align:left;color:#3E3E3E;">
${this.getWhat()}
${this.getWhen()}
${this.getWho()}
${this.getLocation()}
${this.getAdditionalNotes()}
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
${emailSchedulingBodyDivider()}
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" bgcolor="#FFFFFF" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#FFFFFF;background-color:#FFFFFF;margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#FFFFFF;background-color:#FFFFFF;width:100%;">
<tbody>
<tr>
<td style="border-bottom:1px solid #E1E1E1;border-left:1px solid #E1E1E1;border-right:1px solid #E1E1E1;direction:ltr;font-size:0px;padding:0px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:598px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Roboto, Helvetica, sans-serif;font-size:16px;font-weight:500;line-height:0px;text-align:left;color:#3E3E3E;">
${this.getManageLink()}
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
${emailBodyLogo()}
<!--[if mso | IE]></td></tr></table><![endif]-->
</div>
</body>
</html>
`;
}
}

View File

@ -0,0 +1,387 @@
import dayjs, { Dayjs } from "dayjs";
import localizedFormat from "dayjs/plugin/localizedFormat";
import timezone from "dayjs/plugin/timezone";
import toArray from "dayjs/plugin/toArray";
import utc from "dayjs/plugin/utc";
import { createEvent, DateArray } from "ics";
import nodemailer from "nodemailer";
import { getCancelLink } from "@lib/CalEventParser";
import { CalendarEvent, Person } from "@lib/calendarClient";
import { getErrorFromUnknown } from "@lib/errors";
import { getIntegrationName } from "@lib/integrations";
import { serverConfig } from "@lib/serverConfig";
import {
emailHead,
emailSchedulingBodyHeader,
emailBodyLogo,
emailScheduledBodyHeaderContent,
emailSchedulingBodyDivider,
linkIcon,
} from "./common";
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(localizedFormat);
dayjs.extend(toArray);
export default class OrganizerScheduledEmail {
calEvent: CalendarEvent;
constructor(calEvent: CalendarEvent) {
this.calEvent = calEvent;
}
public sendEmail() {
new Promise((resolve, reject) =>
nodemailer
.createTransport(this.getMailerOptions().transport)
.sendMail(this.getNodeMailerPayload(), (_err, info) => {
if (_err) {
const err = getErrorFromUnknown(_err);
this.printNodeMailerError(err);
reject(err);
} else {
resolve(info);
}
})
).catch((e) => console.error("sendEmail", e));
return new Promise((resolve) => resolve("send mail async"));
}
protected getiCalEventAsString(): string | undefined {
const icsEvent = createEvent({
start: dayjs(this.calEvent.startTime)
.utc()
.toArray()
.slice(0, 6)
.map((v, i) => (i === 1 ? v + 1 : v)) as DateArray,
startInputType: "utc",
productId: "calendso/ics",
title: this.calEvent.language("ics_event_title", {
eventType: this.calEvent.type,
name: this.calEvent.attendees[0].name,
}),
description: this.getTextBody(),
duration: { minutes: dayjs(this.calEvent.endTime).diff(dayjs(this.calEvent.startTime), "minute") },
organizer: { name: this.calEvent.organizer.name, email: this.calEvent.organizer.email },
attendees: this.calEvent.attendees.map((attendee: Person) => ({
name: attendee.name,
email: attendee.email,
})),
status: "CONFIRMED",
});
if (icsEvent.error) {
throw icsEvent.error;
}
return icsEvent.value;
}
protected getNodeMailerPayload(): Record<string, unknown> {
const toAddresses = [this.calEvent.organizer.email];
if (this.calEvent.team) {
this.calEvent.team.members.forEach((member) => {
const memberAttendee = this.calEvent.attendees.find((attendee) => attendee.name === member);
if (memberAttendee) {
toAddresses.push(memberAttendee.email);
}
});
}
return {
icalEvent: {
filename: "event.ics",
content: this.getiCalEventAsString(),
},
from: `Cal.com <${this.getMailerOptions().from}>`,
to: toAddresses.join(","),
subject: `${this.calEvent.language("confirmed_event_type_subject", {
eventType: this.calEvent.type,
name: this.calEvent.attendees[0].name,
date: `${this.getOrganizerStart().format("h:mma")} - ${this.getOrganizerEnd().format(
"h:mma"
)}, ${this.calEvent.language(
this.getOrganizerStart().format("dddd").toLowerCase()
)}, ${this.calEvent.language(
this.getOrganizerStart().format("MMMM").toLowerCase()
)} ${this.getOrganizerStart().format("D")}, ${this.getOrganizerStart().format("YYYY")}`,
})}`,
html: this.getHtmlBody(),
text: this.getTextBody(),
};
}
protected getMailerOptions() {
return {
transport: serverConfig.transport,
from: serverConfig.from,
};
}
protected getTextBody(): string {
return `
${this.calEvent.language("new_event_scheduled")}
${this.calEvent.language("emailed_you_and_any_other_attendees")}
${this.getWhat()}
${this.getWhen()}
${this.getLocation()}
${this.getAdditionalNotes()}
${this.calEvent.language("need_to_reschedule_or_cancel")}
${getCancelLink(this.calEvent)}
`.replace(/(<([^>]+)>)/gi, "");
}
protected printNodeMailerError(error: Error): void {
console.error("SEND_BOOKING_CONFIRMATION_ERROR", this.calEvent.organizer.email, error);
}
protected getHtmlBody(): string {
const headerContent = this.calEvent.language("confirmed_event_type_subject", {
eventType: this.calEvent.type,
name: this.calEvent.attendees[0].name,
date: `${this.getOrganizerStart().format("h:mma")} - ${this.getOrganizerEnd().format(
"h:mma"
)}, ${this.calEvent.language(
this.getOrganizerStart().format("dddd").toLowerCase()
)}, ${this.calEvent.language(
this.getOrganizerStart().format("MMMM").toLowerCase()
)} ${this.getOrganizerStart().format("D")}, ${this.getOrganizerStart().format("YYYY")}`,
});
return `
<!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
${emailHead(headerContent)}
<body style="word-spacing:normal;background-color:#F5F5F5;">
<div style="background-color:#F5F5F5;">
${emailSchedulingBodyHeader("checkCircle")}
${emailScheduledBodyHeaderContent(
this.calEvent.language("new_event_scheduled"),
this.calEvent.language("emailed_you_and_any_other_attendees")
)}
${emailSchedulingBodyDivider()}
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" bgcolor="#FFFFFF" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#FFFFFF;background-color:#FFFFFF;margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#FFFFFF;background-color:#FFFFFF;width:100%;">
<tbody>
<tr>
<td style="border-left:1px solid #E1E1E1;border-right:1px solid #E1E1E1;direction:ltr;font-size:0px;padding:0px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:598px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Roboto, Helvetica, sans-serif;font-size:16px;font-weight:500;line-height:1;text-align:left;color:#3E3E3E;">
${this.getWhat()}
${this.getWhen()}
${this.getWho()}
${this.getLocation()}
${this.getAdditionalNotes()}
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
${emailSchedulingBodyDivider()}
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" bgcolor="#FFFFFF" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#FFFFFF;background-color:#FFFFFF;margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#FFFFFF;background-color:#FFFFFF;width:100%;">
<tbody>
<tr>
<td style="border-bottom:1px solid #E1E1E1;border-left:1px solid #E1E1E1;border-right:1px solid #E1E1E1;direction:ltr;font-size:0px;padding:0px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:598px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Roboto, Helvetica, sans-serif;font-size:16px;font-weight:500;line-height:0px;text-align:left;color:#3E3E3E;">
${this.getManageLink()}
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
${emailBodyLogo()}
<!--[if mso | IE]></td></tr></table><![endif]-->
</div>
</body>
</html>
`;
}
protected getManageLink(): string {
const manageText = this.calEvent.language("manage_this_event");
return `<p>${this.calEvent.language(
"need_to_reschedule_or_cancel"
)}</p><p style="font-weight: 400; line-height: 24px;"><a href="${getCancelLink(
this.calEvent
)}" style="color: #3E3E3E;" alt="${manageText}">${manageText}</a></p>`;
}
protected getWhat(): string {
return `
<div style="line-height: 6px;">
<p style="color: #494949;">${this.calEvent.language("what")}</p>
<p style="color: #494949; font-weight: 400; line-height: 24px;">${this.calEvent.type}</p>
</div>`;
}
protected getWhen(): string {
return `
<p style="height: 6px"></p>
<div style="line-height: 6px;">
<p style="color: #494949;">${this.calEvent.language("when")}</p>
<p style="color: #494949; font-weight: 400; line-height: 24px;">
${this.calEvent.language(
this.getOrganizerStart().format("dddd").toLowerCase()
)}, ${this.calEvent.language(
this.getOrganizerStart().format("MMMM").toLowerCase()
)} ${this.getOrganizerStart().format("D")}, ${this.getOrganizerStart().format(
"YYYY"
)} | ${this.getOrganizerStart().format("h:mma")} - ${this.getOrganizerEnd().format(
"h:mma"
)} <span style="color: #888888">(${this.getTimezone()})</span>
</p>
</div>`;
}
protected getWho(): string {
const attendees = this.calEvent.attendees
.map((attendee) => {
return `<div style="color: #494949; font-weight: 400; line-height: 24px;">${
attendee?.name || `${this.calEvent.language("guest")}`
} <span style="color: #888888"><a href="mailto:${attendee.email}" style="color: #888888;">${
attendee.email
}</a></span></div>`;
})
.join("");
const organizer = `<div style="color: #494949; font-weight: 400; line-height: 24px;">${
this.calEvent.organizer.name
} - ${this.calEvent.language("organizer")} <span style="color: #888888"><a href="mailto:${
this.calEvent.organizer.email
}" style="color: #888888;">${this.calEvent.organizer.email}</a></span></div>`;
return `
<p style="height: 6px"></p>
<div style="line-height: 6px;">
<p style="color: #494949;">${this.calEvent.language("who")}</p>
${organizer + attendees}
</div>`;
}
protected getAdditionalNotes(): string {
return `
<p style="height: 6px"></p>
<div style="line-height: 6px;">
<p style="color: #494949;">${this.calEvent.language("additional_notes")}</p>
<p style="color: #494949; font-weight: 400; line-height: 24px;">${this.calEvent.description}</p>
</div>
`;
}
protected getLocation(): string {
let providerName = this.calEvent.location ? getIntegrationName(this.calEvent.location) : "";
if (this.calEvent.location && this.calEvent.location.includes("integrations:")) {
const location = this.calEvent.location.split(":")[1];
providerName = location[0].toUpperCase() + location.slice(1);
}
if (this.calEvent.videoCallData) {
const meetingId = this.calEvent.videoCallData.id;
const meetingPassword = this.calEvent.videoCallData.password;
const meetingUrl = this.calEvent.videoCallData.url;
return `
<p style="height: 6px"></p>
<div style="line-height: 6px;">
<p style="color: #494949;">${this.calEvent.language("where")}</p>
<p style="color: #494949; font-weight: 400; line-height: 24px;">${providerName} ${
meetingUrl &&
`<a href="${meetingUrl}" target="_blank" alt="${this.calEvent.language(
"meeting_url"
)}"><img src="${linkIcon()}" width="12px"></img></a>`
}</p>
${
meetingId &&
`<div style="color: #494949; font-weight: 400; line-height: 24px;">${this.calEvent.language(
"meeting_id"
)}: <span>${meetingId}</span></div>`
}
${
meetingPassword &&
`<div style="color: #494949; font-weight: 400; line-height: 24px;">${this.calEvent.language(
"meeting_password"
)}: <span>${meetingPassword}</span></div>`
}
${
meetingUrl &&
`<div style="color: #494949; font-weight: 400; line-height: 24px;">${this.calEvent.language(
"meeting_url"
)}: <a href="${meetingUrl}" alt="${this.calEvent.language(
"meeting_url"
)}" style="color: #3E3E3E" target="_blank">${meetingUrl}</a></div>`
}
</div>
`;
}
if (this.calEvent.additionInformation?.hangoutLink) {
const hangoutLink: string = this.calEvent.additionInformation.hangoutLink;
return `
<p style="height: 6px"></p>
<div style="line-height: 6px;">
<p style="color: #494949;">${this.calEvent.language("where")}</p>
<p style="color: #494949; font-weight: 400; line-height: 24px;">${
hangoutLink &&
`<a href="${hangoutLink}" target="_blank" alt="${this.calEvent.language(
"meeting_url"
)}"><img src="${linkIcon()}" width="12px"></img></a>`
}</p>
<div style="color: #494949; font-weight: 400; line-height: 24px;"><a href="${hangoutLink}" alt="${this.calEvent.language(
"meeting_url"
)}" style="color: #3E3E3E" target="_blank">${hangoutLink}</a></div>
</div>
`;
}
return `
<p style="height: 6px"></p>
<div style="line-height: 6px;">
<p style="color: #494949;">${this.calEvent.language("where")}</p>
<p style="color: #494949; font-weight: 400; line-height: 24px;">${providerName}</p>
</div>
`;
}
protected getTimezone(): string {
return this.calEvent.organizer.timeZone;
}
protected getOrganizerStart(): Dayjs {
return dayjs(this.calEvent.startTime).tz(this.getTimezone());
}
protected getOrganizerEnd(): Dayjs {
return dayjs(this.calEvent.endTime).tz(this.getTimezone());
}
}

View File

@ -0,0 +1,207 @@
import { TFunction } from "next-i18next";
import nodemailer from "nodemailer";
import { getErrorFromUnknown } from "@lib/errors";
import { serverConfig } from "@lib/serverConfig";
import { emailHead, linkIcon, emailBodyLogo } from "./common";
export type TeamInvite = {
language: TFunction;
from: string;
to: string;
teamName: string;
joinLink: string;
};
export default class TeamInviteEmail {
teamInviteEvent: TeamInvite;
constructor(teamInviteEvent: TeamInvite) {
this.teamInviteEvent = teamInviteEvent;
}
public sendEmail() {
new Promise((resolve, reject) =>
nodemailer
.createTransport(this.getMailerOptions().transport)
.sendMail(this.getNodeMailerPayload(), (_err, info) => {
if (_err) {
const err = getErrorFromUnknown(_err);
this.printNodeMailerError(err);
reject(err);
} else {
resolve(info);
}
})
).catch((e) => console.error("sendEmail", e));
return new Promise((resolve) => resolve("send mail async"));
}
protected getMailerOptions() {
return {
transport: serverConfig.transport,
from: serverConfig.from,
};
}
protected getNodeMailerPayload(): Record<string, unknown> {
return {
to: this.teamInviteEvent.to,
from: `Cal.com <${this.getMailerOptions().from}>`,
subject: this.teamInviteEvent.language("user_invited_you", {
user: this.teamInviteEvent.from,
team: this.teamInviteEvent.teamName,
}),
html: this.getHtmlBody(),
text: this.getTextBody(),
};
}
protected printNodeMailerError(error: Error): void {
console.error("SEND_TEAM_INVITE_EMAIL_ERROR", this.teamInviteEvent.to, error);
}
protected getTextBody(): string {
return "";
}
protected getHtmlBody(): string {
const headerContent = this.teamInviteEvent.language("user_invited_you", {
user: this.teamInviteEvent.from,
team: this.teamInviteEvent.teamName,
});
return `
<!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
${emailHead(headerContent)}
<body style="word-spacing:normal;background-color:#F5F5F5;">
<div style="background-color:#F5F5F5;">
${emailBodyLogo()}
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:0px;padding-top:40px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" bgcolor="#FFFFFF" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#FFFFFF;background-color:#FFFFFF;margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#FFFFFF;background-color:#FFFFFF;width:100%;">
<tbody>
<tr>
<td style="border-left:1px solid #E1E1E1;border-right:1px solid #E1E1E1;border-top:1px solid #E1E1E1;direction:ltr;font-size:0px;padding:30px 20px 0 20px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" bgcolor="#FFFFFF" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#FFFFFF;background-color:#FFFFFF;margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#FFFFFF;background-color:#FFFFFF;width:100%;">
<tbody>
<tr>
<td style="border-left:1px solid #E1E1E1;border-right:1px solid #E1E1E1;direction:ltr;font-size:0px;padding:0px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:598px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Roboto, Helvetica, sans-serif;font-size:16px;font-weight:500;line-height:1;text-align:left;color:#3E3E3E;">
<div style="line-height: 6px;">
<p>${this.teamInviteEvent.language("user_invited_you", {
user: this.teamInviteEvent.from,
team: this.teamInviteEvent.teamName,
})}!</p>
<p style="font-weight: 400; line-height: 24px;">${this.teamInviteEvent.language(
"calcom_explained"
)}</p>
</div>
</div>
</td>
</tr>
<tr>
<td align="left" vertical-align="middle" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;line-height:100%;">
<tr>
<td align="center" bgcolor="#292929" role="presentation" style="border:none;border-radius:3px;cursor:auto;mso-padding-alt:10px 25px;background:#292929;" valign="middle">
<p style="display:inline-block;background:#292929;color:#292929;font-family:Roboto, Helvetica, sans-serif;font-size:13px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:3px;">
<a href="${
this.teamInviteEvent.joinLink
}" target="_blank" style="color: #FFFFFF; text-decoration: none">${this.teamInviteEvent.language(
"accept_invitation"
)} <img src="${linkIcon()}" width="12px"></img></a>
</p>
</td>
</tr>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" bgcolor="#FFFFFF" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#FFFFFF;background-color:#FFFFFF;margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#FFFFFF;background-color:#FFFFFF;width:100%;">
<tbody>
<tr>
<td style="border-left:1px solid #E1E1E1;border-right:1px solid #E1E1E1;direction:ltr;font-size:0px;padding:0px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:598px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Roboto, Helvetica, sans-serif;font-size:16px;font-weight:500;line-height:1;text-align:left;color:#3E3E3E;">
<div style="line-height: 6px;">
<p style="font-weight: 400; line-height: 24px;">${this.teamInviteEvent.language(
"have_any_questions"
)} <a href="mailto:support@cal.com" style="color: #3E3E3E" target="_blank">${this.teamInviteEvent.language(
"contact_our_support_team"
)}</a></p>
</div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" bgcolor="#FFFFFF" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#FFFFFF;background-color:#FFFFFF;margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#FFFFFF;background-color:#FFFFFF;width:100%;">
<tbody>
<tr>
<td style="border-bottom:1px solid #E1E1E1;border-left:1px solid #E1E1E1;border-right:1px solid #E1E1E1;direction:ltr;font-size:0px;padding:0px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</div>
</body>
</html>
`;
}
}

View File

@ -4,19 +4,13 @@ import merge from "lodash/merge";
import { v5 as uuidv5 } from "uuid";
import { AdditionInformation, CalendarEvent, createEvent, updateEvent } from "@lib/calendarClient";
import EventAttendeeMail from "@lib/emails/EventAttendeeMail";
import EventAttendeeRescheduledMail from "@lib/emails/EventAttendeeRescheduledMail";
import { DailyEventResult, FAKE_DAILY_CREDENTIAL } from "@lib/integrations/Daily/DailyVideoApiAdapter";
import { ZoomEventResult } from "@lib/integrations/Zoom/ZoomVideoApiAdapter";
import { FAKE_DAILY_CREDENTIAL } from "@lib/integrations/Daily/DailyVideoApiAdapter";
import { LocationType } from "@lib/location";
import prisma from "@lib/prisma";
import { Ensure } from "@lib/types/utils";
import { createMeeting, updateMeeting, VideoCallData } from "@lib/videoClient";
export type Event = AdditionInformation & { name: string; id: string; disableConfirmationEmail?: boolean } & (
| ZoomEventResult
| DailyEventResult
);
export type Event = AdditionInformation & VideoCallData;
export interface EventResult {
type: string;
@ -25,7 +19,6 @@ export interface EventResult {
createdEvent?: Event;
updatedEvent?: Event;
originalEvent: CalendarEvent;
videoCallData?: VideoCallData;
}
export interface CreateUpdateResult {
@ -47,9 +40,51 @@ export interface PartialReference {
meetingUrl?: string | null;
}
interface GetLocationRequestFromIntegrationRequest {
location: string;
}
export const isZoom = (location: string): boolean => {
return location === "integrations:zoom";
};
export const isDaily = (location: string): boolean => {
return location === "integrations:daily";
};
export const isDedicatedIntegration = (location: string): boolean => {
return isZoom(location) || isDaily(location);
};
export const getLocationRequestFromIntegration = (location: string) => {
if (
location === LocationType.GoogleMeet.valueOf() ||
location === LocationType.Zoom.valueOf() ||
location === LocationType.Daily.valueOf()
) {
const requestId = uuidv5(location, uuidv5.URL);
return {
conferenceData: {
createRequest: {
requestId: requestId,
},
},
location,
};
}
return null;
};
export const processLocation = (event: CalendarEvent): CalendarEvent => {
// If location is set to an integration location
// Build proper transforms for evt object
// Extend evt object with those transformations
if (event.location?.includes("integration")) {
const maybeLocationRequestObject = getLocationRequestFromIntegration(event.location);
event = merge(event, maybeLocationRequestObject);
}
return event;
};
export default class EventManager {
calendarCredentials: Array<Credential>;
@ -79,38 +114,31 @@ export default class EventManager {
* @param event
*/
public async create(event: Ensure<CalendarEvent, "language">): Promise<CreateUpdateResult> {
const evt = EventManager.processLocation(event);
const isDedicated = evt.location ? EventManager.isDedicatedIntegration(evt.location) : null;
const evt = processLocation(event);
const isDedicated = evt.location ? isDedicatedIntegration(evt.location) : null;
// First, create all calendar events. If this is a dedicated integration event, don't send a mail right here.
const results: Array<EventResult> = await this.createAllCalendarEvents(evt, isDedicated);
const results: Array<EventResult> = [];
// If and only if event type is a dedicated meeting, create a dedicated video meeting.
if (isDedicated) {
const result = await this.createVideoEvent(evt);
if (result.videoCallData) {
evt.videoCallData = result.videoCallData;
if (result.createdEvent) {
evt.videoCallData = result.createdEvent;
evt.location = result.createdEvent.url;
}
results.push(result);
} else {
await EventManager.sendAttendeeMail("new", results, evt);
}
// Create the calendar event with the proper video call data
results.push(...(await this.createAllCalendarEvents(evt)));
const referencesToCreate: Array<PartialReference> = results.map((result: EventResult) => {
let uid = "";
if (result.createdEvent) {
const isDailyResult = result.type === "daily_video";
if (isDailyResult) {
uid = (result.createdEvent as DailyEventResult).name.toString();
} else {
uid = (result.createdEvent as ZoomEventResult).id.toString();
}
}
return {
type: result.type,
uid,
meetingId: result.videoCallData?.id.toString(),
meetingPassword: result.videoCallData?.password,
meetingUrl: result.videoCallData?.url,
uid: result.createdEvent?.id.toString() ?? "",
meetingId: result.createdEvent?.id.toString(),
meetingPassword: result.createdEvent?.password,
meetingUrl: result.createdEvent?.url,
};
});
@ -130,7 +158,7 @@ export default class EventManager {
event: Ensure<CalendarEvent, "language">,
rescheduleUid: string
): Promise<CreateUpdateResult> {
const evt = EventManager.processLocation(event);
const evt = processLocation(event);
if (!rescheduleUid) {
throw new Error("You called eventManager.update without an `rescheduleUid`. This should never happen.");
@ -160,19 +188,21 @@ export default class EventManager {
throw new Error("booking not found");
}
const isDedicated = evt.location ? EventManager.isDedicatedIntegration(evt.location) : null;
// First, create all calendar events. If this is a dedicated integration event, don't send a mail right here.
const results: Array<EventResult> = await this.updateAllCalendarEvents(evt, booking, isDedicated);
const isDedicated = evt.location ? isDedicatedIntegration(evt.location) : null;
const results: Array<EventResult> = [];
// If and only if event type is a dedicated meeting, update the dedicated video meeting.
if (isDedicated) {
const result = await this.updateVideoEvent(evt, booking);
if (result.videoCallData) {
evt.videoCallData = result.videoCallData;
if (result.updatedEvent) {
evt.videoCallData = result.updatedEvent;
evt.location = result.updatedEvent.url;
}
results.push(result);
} else {
await EventManager.sendAttendeeMail("reschedule", results, evt);
}
// Update all calendar events.
results.push(...(await this.updateAllCalendarEvents(evt, booking)));
// Now we can delete the old booking and its references.
const bookingReferenceDeletes = prisma.bookingReference.deleteMany({
where: {
@ -213,15 +243,12 @@ export default class EventManager {
* @private
*/
private async createAllCalendarEvents(
event: CalendarEvent,
noMail: boolean | null
): Promise<Array<EventResult>> {
private async createAllCalendarEvents(event: CalendarEvent): Promise<Array<EventResult>> {
const [firstCalendar] = this.calendarCredentials;
if (!firstCalendar) {
return [];
}
return [await createEvent(firstCalendar, event, noMail)];
return [await createEvent(firstCalendar, event)];
}
/**
@ -267,19 +294,18 @@ export default class EventManager {
*
* @param event
* @param booking
* @param noMail
* @private
*/
private updateAllCalendarEvents(
event: CalendarEvent,
booking: PartialBooking | null,
noMail: boolean | null
booking: PartialBooking
): Promise<Array<EventResult>> {
return async.mapLimit(this.calendarCredentials, 5, async (credential) => {
return async.mapLimit(this.calendarCredentials, 5, async (credential: Credential) => {
const bookingRefUid = booking
? booking.references.filter((ref) => ref.type === credential.type)[0]?.uid
: null;
return updateEvent(credential, event, noMail, bookingRefUid);
return updateEvent(credential, event, bookingRefUid);
});
}
@ -295,172 +321,9 @@ export default class EventManager {
if (credential) {
const bookingRef = booking ? booking.references.filter((ref) => ref.type === credential.type)[0] : null;
const bookingRefUid = bookingRef ? bookingRef.uid : null;
return updateMeeting(credential, event, bookingRefUid).then((returnVal: EventResult) => {
// Some video integrations, such as Zoom, don't return any data about the booking when updating it.
if (returnVal.videoCallData === undefined) {
returnVal.videoCallData = EventManager.bookingReferenceToVideoCallData(bookingRef);
}
return returnVal;
});
return updateMeeting(credential, event, bookingRef);
} else {
return Promise.reject("No suitable credentials given for the requested integration name.");
}
}
/**
* Returns true if the given location describes a dedicated integration that
* delivers meeting credentials. Zoom, for example, is dedicated, because it
* needs to be called independently from any calendar APIs to receive meeting
* credentials. Google Meetings, in contrast, are not dedicated, because they
* are created while scheduling a regular calendar event by simply adding some
* attributes to the payload JSON.
*
* @param location
* @private
*/
private static isDedicatedIntegration(location: string): boolean {
// Hard-coded for now, because Zoom and Google Meet are both integrations, but one is dedicated, the other one isn't.
return location === "integrations:zoom" || location === "integrations:daily";
}
/**
* Helper function for processLocation: Returns the conferenceData object to be merged
* with the CalendarEvent.
*
* @param locationObj
* @private
*/
private static getLocationRequestFromIntegration(locationObj: GetLocationRequestFromIntegrationRequest) {
const location = locationObj.location;
if (
location === LocationType.GoogleMeet.valueOf() ||
location === LocationType.Zoom.valueOf() ||
location === LocationType.Daily.valueOf()
) {
const requestId = uuidv5(location, uuidv5.URL);
return {
conferenceData: {
createRequest: {
requestId: requestId,
},
},
location,
};
}
return null;
}
/**
* Takes a CalendarEvent and adds a ConferenceData object to the event
* if the event has an integration-related location.
*
* @param event
* @private
*/
private static processLocation<T extends CalendarEvent>(event: T): T {
// If location is set to an integration location
// Build proper transforms for evt object
// Extend evt object with those transformations
if (event.location?.includes("integration")) {
const maybeLocationRequestObject = EventManager.getLocationRequestFromIntegration({
location: event.location,
});
event = merge(event, maybeLocationRequestObject);
}
return event;
}
/**
* Accepts a PartialReference object and, if all data is complete,
* returns a VideoCallData object containing the meeting information.
*
* @param reference
* @private
*/
private static bookingReferenceToVideoCallData(
reference: PartialReference | null
): VideoCallData | undefined {
let isComplete = true;
if (!reference) {
throw new Error("missing reference");
}
switch (reference.type) {
case "zoom_video":
// Zoom meetings in our system should always have an ID, a password and a join URL. In the
// future, it might happen that we consider making passwords for Zoom meetings optional.
// Then, this part below (where the password existence is checked) needs to be adapted.
isComplete =
reference.meetingId !== undefined &&
reference.meetingPassword !== undefined &&
reference.meetingUrl !== undefined;
break;
default:
isComplete = true;
}
if (isComplete) {
return {
type: reference.type,
// The null coalescing operator should actually never be used here, because we checked if it's defined beforehand.
id: reference.meetingId ?? "",
password: reference.meetingPassword ?? "",
url: reference.meetingUrl ?? "",
};
} else {
return undefined;
}
}
/**
* Conditionally sends an email to the attendee.
*
* @param type
* @param results
* @param event
* @private
*/
private static async sendAttendeeMail(
type: "new" | "reschedule",
results: Array<EventResult>,
event: CalendarEvent
) {
if (
!results.length ||
!results.some((eRes) => (eRes.createdEvent || eRes.updatedEvent)?.disableConfirmationEmail)
) {
const metadata: AdditionInformation = {};
if (results.length) {
// TODO: Handle created event metadata more elegantly
metadata.hangoutLink = results[0].createdEvent?.hangoutLink;
metadata.conferenceData = results[0].createdEvent?.conferenceData;
metadata.entryPoints = results[0].createdEvent?.entryPoints;
}
event.additionInformation = metadata;
let attendeeMail;
switch (type) {
case "reschedule":
attendeeMail = new EventAttendeeRescheduledMail(event);
break;
case "new":
attendeeMail = new EventAttendeeMail(event);
break;
}
try {
await attendeeMail.sendEmail();
} catch (e) {
console.error("attendeeMail.sendEmail failed", e);
}
}
}
}

View File

@ -1,28 +0,0 @@
import { TFunction } from "next-i18next";
import { buildMessageTemplate, VarType } from "../../emails/buildMessageTemplate";
export const forgotPasswordSubjectTemplate = (t: TFunction): string => {
const text = t("forgot_your_password_calcom");
return text;
};
export const forgotPasswordMessageTemplate = (t: TFunction): string => {
const text = `${t("hey_there")}
${t("use_link_to_reset_password")}
{{link}}
${t("link_expires", { expiresIn: 6 })}
- Cal.com`;
return text;
};
export const buildForgotPasswordMessage = (vars: VarType) => {
return buildMessageTemplate({
subjectTemplate: forgotPasswordSubjectTemplate(vars.language),
messageTemplate: forgotPasswordMessageTemplate(vars.language),
vars,
});
};

View File

@ -18,7 +18,6 @@ import { symmetricDecrypt } from "@lib/crypto";
import logger from "@lib/logger";
import { IntegrationCalendar, CalendarApiAdapter, CalendarEvent } from "../../calendarClient";
import { stripHtml } from "../../emails/helpers";
dayjs.extend(utc);
@ -80,7 +79,7 @@ export class AppleCalendar implements CalendarApiAdapter {
start: this.convertDate(event.startTime),
duration: this.getDuration(event.startTime, event.endTime),
title: event.title,
description: stripHtml(event.description ?? ""),
description: event.description ?? "",
location: event.location,
organizer: { email: event.organizer.email, name: event.organizer.name },
attendees: this.getAttendees(event.attendees),
@ -138,7 +137,7 @@ export class AppleCalendar implements CalendarApiAdapter {
start: this.convertDate(event.startTime),
duration: this.getDuration(event.startTime, event.endTime),
title: event.title,
description: stripHtml(event.description ?? ""),
description: event.description ?? "",
location: event.location,
organizer: { email: event.organizer.email, name: event.organizer.name },
attendees: this.getAttendees(event.attendees),

View File

@ -18,7 +18,6 @@ import { symmetricDecrypt } from "@lib/crypto";
import logger from "@lib/logger";
import { CalendarApiAdapter, CalendarEvent, IntegrationCalendar } from "../../calendarClient";
import { stripHtml } from "../../emails/helpers";
dayjs.extend(utc);
@ -83,7 +82,7 @@ export class CalDavCalendar implements CalendarApiAdapter {
start: this.convertDate(event.startTime),
duration: this.getDuration(event.startTime, event.endTime),
title: event.title,
description: stripHtml(event.description ?? ""),
description: event.description ?? "",
location: event.location,
organizer: { email: event.organizer.email, name: event.organizer.name },
attendees: this.getAttendees(event.attendees),
@ -142,7 +141,7 @@ export class CalDavCalendar implements CalendarApiAdapter {
start: this.convertDate(event.startTime),
duration: this.getDuration(event.startTime, event.endTime),
title: event.title,
description: stripHtml(event.description ?? ""),
description: event.description ?? "",
location: event.location,
organizer: { email: event.organizer.email, name: event.organizer.name },
attendees: this.getAttendees(event.attendees),

View File

@ -1,9 +1,11 @@
import { Credential } from "@prisma/client";
import { CalendarEvent } from "@lib/calendarClient";
import { BASE_URL } from "@lib/config/constants";
import { handleErrorsJson } from "@lib/errors";
import { PartialReference } from "@lib/events/EventManager";
import prisma from "@lib/prisma";
import { VideoApiAdapter } from "@lib/videoClient";
import { VideoApiAdapter, VideoCallData } from "@lib/videoClient";
export interface DailyReturnType {
/** Long UID string ie: 987b5eb5-d116-4a4e-8e2c-14fcb5710966 */
@ -67,7 +69,7 @@ const DailyVideoApiAdapter = (credential: Credential): VideoApiAdapter => {
});
}
async function createOrUpdateMeeting(endpoint: string, event: CalendarEvent) {
async function createOrUpdateMeeting(endpoint: string, event: CalendarEvent): Promise<VideoCallData> {
if (!event.uid) {
throw new Error("We need need the booking uid to create the Daily reference in DB");
}
@ -89,7 +91,12 @@ const DailyVideoApiAdapter = (credential: Credential): VideoApiAdapter => {
},
});
return dailyEvent;
return Promise.resolve({
type: "daily_video",
id: dailyEvent.name,
password: "",
url: BASE_URL + "/call/" + event.uid,
});
}
const translateEvent = (event: CalendarEvent) => {
@ -133,15 +140,20 @@ const DailyVideoApiAdapter = (credential: Credential): VideoApiAdapter => {
getAvailability: () => {
return Promise.resolve([]);
},
createMeeting: async (event: CalendarEvent) => createOrUpdateMeeting("/rooms", event),
deleteMeeting: (uid: string) =>
fetch("https://api.daily.co/v1/rooms/" + uid, {
createMeeting: async (event: CalendarEvent): Promise<VideoCallData> =>
createOrUpdateMeeting("/rooms", event),
deleteMeeting: async (uid: string): Promise<void> => {
await fetch("https://api.daily.co/v1/rooms/" + uid, {
method: "DELETE",
headers: {
Authorization: "Bearer " + dailyApiToken,
},
}).then(handleErrorsJson),
updateMeeting: (uid: string, event: CalendarEvent) => createOrUpdateMeeting("/rooms/" + uid, event),
}).then(handleErrorsJson);
return Promise.resolve();
},
updateMeeting: (bookingRef: PartialReference, event: CalendarEvent): Promise<VideoCallData> =>
createOrUpdateMeeting("/rooms/" + bookingRef.uid, event),
};
};

View File

@ -0,0 +1,255 @@
import { Credential, Prisma } from "@prisma/client";
import { GetTokenResponse } from "google-auth-library/build/src/auth/oauth2client";
import { Auth, calendar_v3, google } from "googleapis";
import { CalendarApiAdapter, CalendarEvent, IntegrationCalendar } from "@lib/calendarClient";
import prisma from "@lib/prisma";
export interface ConferenceData {
createRequest: calendar_v3.Schema$CreateConferenceRequest;
}
const googleAuth = (credential: Credential) => {
const { client_secret, client_id, redirect_uris } = JSON.parse(process.env.GOOGLE_API_CREDENTIALS!).web;
const myGoogleAuth = new google.auth.OAuth2(client_id, client_secret, redirect_uris[0]);
const googleCredentials = credential.key as Auth.Credentials;
myGoogleAuth.setCredentials(googleCredentials);
// FIXME - type errors IDK Why this is a protected method ¯\_(ツ)_/¯
const isExpired = () => myGoogleAuth.isTokenExpiring();
const refreshAccessToken = () =>
myGoogleAuth
// FIXME - type errors IDK Why this is a protected method ¯\_(ツ)_/¯
.refreshToken(googleCredentials.refresh_token)
.then((res: GetTokenResponse) => {
const token = res.res?.data;
googleCredentials.access_token = token.access_token;
googleCredentials.expiry_date = token.expiry_date;
return prisma.credential
.update({
where: {
id: credential.id,
},
data: {
key: googleCredentials as Prisma.InputJsonValue,
},
})
.then(() => {
myGoogleAuth.setCredentials(googleCredentials);
return myGoogleAuth;
});
})
.catch((err) => {
console.error("Error refreshing google token", err);
return myGoogleAuth;
});
return {
getToken: () => (!isExpired() ? Promise.resolve(myGoogleAuth) : refreshAccessToken()),
};
};
export const GoogleCalendarApiAdapter = (credential: Credential): CalendarApiAdapter => {
const auth = googleAuth(credential);
const integrationType = "google_calendar";
return {
getAvailability: (dateFrom, dateTo, selectedCalendars) =>
new Promise((resolve, reject) =>
auth.getToken().then((myGoogleAuth) => {
const calendar = google.calendar({
version: "v3",
auth: myGoogleAuth,
});
const selectedCalendarIds = selectedCalendars
.filter((e) => e.integration === integrationType)
.map((e) => e.externalId);
if (selectedCalendarIds.length === 0 && selectedCalendars.length > 0) {
// Only calendars of other integrations selected
resolve([]);
return;
}
(selectedCalendarIds.length === 0
? calendar.calendarList
.list()
.then((cals) => cals.data.items?.map((cal) => cal.id).filter(Boolean) || [])
: Promise.resolve(selectedCalendarIds)
)
.then((calsIds) => {
calendar.freebusy.query(
{
requestBody: {
timeMin: dateFrom,
timeMax: dateTo,
items: calsIds.map((id) => ({ id: id })),
},
},
(err, apires) => {
if (err) {
reject(err);
}
resolve(Object.values(apires.data.calendars).flatMap((item) => item["busy"]));
}
);
})
.catch((err) => {
console.error("There was an error contacting google calendar service: ", err);
reject(err);
});
})
),
createEvent: (event: CalendarEvent) =>
new Promise((resolve, reject) =>
auth.getToken().then((myGoogleAuth) => {
const payload: calendar_v3.Schema$Event = {
summary: event.title,
description: event.description,
start: {
dateTime: event.startTime,
timeZone: event.organizer.timeZone,
},
end: {
dateTime: event.endTime,
timeZone: event.organizer.timeZone,
},
attendees: event.attendees,
reminders: {
useDefault: false,
overrides: [{ method: "email", minutes: 10 }],
},
};
if (event.location) {
payload["location"] = event.location;
}
if (event.conferenceData && event.location === "integrations:google:meet") {
payload["conferenceData"] = event.conferenceData;
}
const calendar = google.calendar({
version: "v3",
auth: myGoogleAuth,
});
calendar.events.insert(
{
auth: myGoogleAuth,
calendarId: "primary",
requestBody: payload,
conferenceDataVersion: 1,
},
function (err, event) {
if (err || !event?.data) {
console.error("There was an error contacting google calendar service: ", err);
return reject(err);
}
return resolve(event.data);
}
);
})
),
updateEvent: (uid: string, event: CalendarEvent) =>
new Promise((resolve, reject) =>
auth.getToken().then((myGoogleAuth) => {
const payload: calendar_v3.Schema$Event = {
summary: event.title,
description: event.description,
start: {
dateTime: event.startTime,
timeZone: event.organizer.timeZone,
},
end: {
dateTime: event.endTime,
timeZone: event.organizer.timeZone,
},
attendees: event.attendees,
reminders: {
useDefault: false,
overrides: [{ method: "email", minutes: 10 }],
},
};
if (event.location) {
payload["location"] = event.location;
}
const calendar = google.calendar({
version: "v3",
auth: myGoogleAuth,
});
calendar.events.update(
{
auth: myGoogleAuth,
calendarId: "primary",
eventId: uid,
sendNotifications: true,
sendUpdates: "all",
requestBody: payload,
},
function (err, event) {
if (err) {
console.error("There was an error contacting google calendar service: ", err);
return reject(err);
}
return resolve(event?.data);
}
);
})
),
deleteEvent: (uid: string) =>
new Promise((resolve, reject) =>
auth.getToken().then((myGoogleAuth) => {
const calendar = google.calendar({
version: "v3",
auth: myGoogleAuth,
});
calendar.events.delete(
{
auth: myGoogleAuth,
calendarId: "primary",
eventId: uid,
sendNotifications: true,
sendUpdates: "all",
},
function (err, event) {
if (err) {
console.error("There was an error contacting google calendar service: ", err);
return reject(err);
}
return resolve(event?.data);
}
);
})
),
listCalendars: () =>
new Promise((resolve, reject) =>
auth.getToken().then((myGoogleAuth) => {
const calendar = google.calendar({
version: "v3",
auth: myGoogleAuth,
});
calendar.calendarList
.list()
.then((cals) => {
resolve(
cals.data.items?.map((cal) => {
const calendar: IntegrationCalendar = {
externalId: cal.id ?? "No id",
integration: integrationType,
name: cal.summary ?? "No name",
primary: cal.primary ?? false,
};
return calendar;
}) || []
);
})
.catch((err) => {
console.error("There was an error contacting google calendar service: ", err);
reject(err);
});
})
),
};
};

View File

@ -0,0 +1,216 @@
import { Calendar as OfficeCalendar } from "@microsoft/microsoft-graph-types-beta";
import { Credential } from "@prisma/client";
import { CalendarApiAdapter, CalendarEvent, IntegrationCalendar } from "@lib/calendarClient";
import { handleErrorsJson, handleErrorsRaw } from "@lib/errors";
import prisma from "@lib/prisma";
export type BufferedBusyTime = {
start: string;
end: string;
};
type O365AuthCredentials = {
expiry_date: number;
access_token: string;
refresh_token: string;
};
const o365Auth = (credential: Credential) => {
const isExpired = (expiryDate: number) => expiryDate < Math.round(+new Date() / 1000);
const o365AuthCredentials = credential.key as O365AuthCredentials;
const refreshAccessToken = (refreshToken: string) => {
return fetch("https://login.microsoftonline.com/common/oauth2/v2.0/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
scope: "User.Read Calendars.Read Calendars.ReadWrite",
client_id: process.env.MS_GRAPH_CLIENT_ID!,
refresh_token: refreshToken,
grant_type: "refresh_token",
client_secret: process.env.MS_GRAPH_CLIENT_SECRET!,
}),
})
.then(handleErrorsJson)
.then((responseBody) => {
o365AuthCredentials.access_token = responseBody.access_token;
o365AuthCredentials.expiry_date = Math.round(+new Date() / 1000 + responseBody.expires_in);
return prisma.credential
.update({
where: {
id: credential.id,
},
data: {
key: o365AuthCredentials,
},
})
.then(() => o365AuthCredentials.access_token);
});
};
return {
getToken: () =>
!isExpired(o365AuthCredentials.expiry_date)
? Promise.resolve(o365AuthCredentials.access_token)
: refreshAccessToken(o365AuthCredentials.refresh_token),
};
};
export const Office365CalendarApiAdapter = (credential: Credential): CalendarApiAdapter => {
const auth = o365Auth(credential);
const translateEvent = (event: CalendarEvent) => {
return {
subject: event.title,
body: {
contentType: "HTML",
content: event.description,
},
start: {
dateTime: event.startTime,
timeZone: event.organizer.timeZone,
},
end: {
dateTime: event.endTime,
timeZone: event.organizer.timeZone,
},
attendees: event.attendees.map((attendee) => ({
emailAddress: {
address: attendee.email,
name: attendee.name,
},
type: "required",
})),
location: event.location ? { displayName: event.location } : undefined,
};
};
const integrationType = "office365_calendar";
function listCalendars(): Promise<IntegrationCalendar[]> {
return auth.getToken().then((accessToken) =>
fetch("https://graph.microsoft.com/v1.0/me/calendars", {
method: "get",
headers: {
Authorization: "Bearer " + accessToken,
"Content-Type": "application/json",
},
})
.then(handleErrorsJson)
.then((responseBody: { value: OfficeCalendar[] }) => {
return responseBody.value.map((cal) => {
const calendar: IntegrationCalendar = {
externalId: cal.id ?? "No Id",
integration: integrationType,
name: cal.name ?? "No calendar name",
primary: cal.isDefaultCalendar ?? false,
};
return calendar;
});
})
);
}
return {
getAvailability: (dateFrom, dateTo, selectedCalendars) => {
const filter = `?startdatetime=${encodeURIComponent(dateFrom)}&enddatetime=${encodeURIComponent(
dateTo
)}`;
return auth
.getToken()
.then((accessToken) => {
const selectedCalendarIds = selectedCalendars
.filter((e) => e.integration === integrationType)
.map((e) => e.externalId)
.filter(Boolean);
if (selectedCalendarIds.length === 0 && selectedCalendars.length > 0) {
// Only calendars of other integrations selected
return Promise.resolve([]);
}
return (
selectedCalendarIds.length === 0
? listCalendars().then((cals) => cals.map((e) => e.externalId).filter(Boolean) || [])
: Promise.resolve(selectedCalendarIds)
).then((ids) => {
const requests = ids.map((calendarId, id) => ({
id,
method: "GET",
headers: {
Prefer: 'outlook.timezone="Etc/GMT"',
},
url: `/me/calendars/${calendarId}/calendarView${filter}`,
}));
type BatchResponse = {
responses: SubResponse[];
};
type SubResponse = {
body: { value: { start: { dateTime: string }; end: { dateTime: string } }[] };
};
return fetch("https://graph.microsoft.com/v1.0/$batch", {
method: "POST",
headers: {
Authorization: "Bearer " + accessToken,
"Content-Type": "application/json",
},
body: JSON.stringify({ requests }),
})
.then(handleErrorsJson)
.then((responseBody: BatchResponse) =>
responseBody.responses.reduce(
(acc: BufferedBusyTime[], subResponse) =>
acc.concat(
subResponse.body.value.map((evt) => {
return {
start: evt.start.dateTime + "Z",
end: evt.end.dateTime + "Z",
};
})
),
[]
)
);
});
})
.catch((err) => {
console.log(err);
return Promise.reject([]);
});
},
createEvent: (event: CalendarEvent) =>
auth.getToken().then((accessToken) =>
fetch("https://graph.microsoft.com/v1.0/me/calendar/events", {
method: "POST",
headers: {
Authorization: "Bearer " + accessToken,
"Content-Type": "application/json",
},
body: JSON.stringify(translateEvent(event)),
}).then(handleErrorsJson)
),
deleteEvent: (uid: string) =>
auth.getToken().then((accessToken) =>
fetch("https://graph.microsoft.com/v1.0/me/calendar/events/" + uid, {
method: "DELETE",
headers: {
Authorization: "Bearer " + accessToken,
},
}).then(handleErrorsRaw)
),
updateEvent: (uid: string, event: CalendarEvent) =>
auth.getToken().then((accessToken) =>
fetch("https://graph.microsoft.com/v1.0/me/calendar/events/" + uid, {
method: "PATCH",
headers: {
Authorization: "Bearer " + accessToken,
"Content-Type": "application/json",
},
body: JSON.stringify(translateEvent(event)),
}).then(handleErrorsRaw)
),
listCalendars,
};
};

View File

@ -2,11 +2,13 @@ import { Credential } from "@prisma/client";
import { CalendarEvent } from "@lib/calendarClient";
import { handleErrorsJson, handleErrorsRaw } from "@lib/errors";
import { PartialReference } from "@lib/events/EventManager";
import prisma from "@lib/prisma";
import { VideoApiAdapter } from "@lib/videoClient";
import { VideoApiAdapter, VideoCallData } from "@lib/videoClient";
/** @link https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/meetingcreate */
export interface ZoomEventResult {
password: string;
created_at: string;
duration: number;
host_id: string;
@ -168,37 +170,56 @@ const ZoomVideoApiAdapter = (credential: Credential): VideoApiAdapter => {
return [];
});
},
createMeeting: (event: CalendarEvent) =>
auth.getToken().then((accessToken) =>
fetch("https://api.zoom.us/v2/users/me/meetings", {
method: "POST",
headers: {
Authorization: "Bearer " + accessToken,
"Content-Type": "application/json",
},
body: JSON.stringify(translateEvent(event)),
}).then(handleErrorsJson)
),
deleteMeeting: (uid: string) =>
auth.getToken().then((accessToken) =>
fetch("https://api.zoom.us/v2/meetings/" + uid, {
method: "DELETE",
headers: {
Authorization: "Bearer " + accessToken,
},
}).then(handleErrorsRaw)
),
updateMeeting: (uid: string, event: CalendarEvent) =>
auth.getToken().then((accessToken: string) =>
fetch("https://api.zoom.us/v2/meetings/" + uid, {
method: "PATCH",
headers: {
Authorization: "Bearer " + accessToken,
"Content-Type": "application/json",
},
body: JSON.stringify(translateEvent(event)),
}).then(handleErrorsRaw)
),
createMeeting: async (event: CalendarEvent): Promise<VideoCallData> => {
const accessToken = await auth.getToken();
const result = await fetch("https://api.zoom.us/v2/users/me/meetings", {
method: "POST",
headers: {
Authorization: "Bearer " + accessToken,
"Content-Type": "application/json",
},
body: JSON.stringify(translateEvent(event)),
}).then(handleErrorsJson);
return Promise.resolve({
type: "zoom_video",
id: result.id as string,
password: result.password ?? "",
url: result.join_url,
});
},
deleteMeeting: async (uid: string): Promise<void> => {
const accessToken = await auth.getToken();
await fetch("https://api.zoom.us/v2/meetings/" + uid, {
method: "DELETE",
headers: {
Authorization: "Bearer " + accessToken,
},
}).then(handleErrorsRaw);
return Promise.resolve();
},
updateMeeting: async (bookingRef: PartialReference, event: CalendarEvent): Promise<VideoCallData> => {
const accessToken = await auth.getToken();
await fetch("https://api.zoom.us/v2/meetings/" + bookingRef.uid, {
method: "PATCH",
headers: {
Authorization: "Bearer " + accessToken,
"Content-Type": "application/json",
},
body: JSON.stringify(translateEvent(event)),
}).then(handleErrorsRaw);
return Promise.resolve({
type: "zoom_video",
id: bookingRef.meetingId as string,
password: bookingRef.meetingPassword as string,
url: bookingRef.meetingUrl as string,
});
},
};
};

View File

@ -1,12 +1,12 @@
import { Logger } from "tslog";
const isProduction = process.env.NODE_ENV === "production";
import { IS_PRODUCTION } from "@lib/config/constants";
const logger = new Logger({
dateTimePattern: "hour:minute:second.millisecond timeZoneName",
displayFunctionName: false,
displayFilePath: "hidden",
dateTimeTimezone: isProduction ? "utc" : Intl.DateTimeFormat().resolvedOptions().timeZone,
dateTimeTimezone: IS_PRODUCTION ? "utc" : Intl.DateTimeFormat().resolvedOptions().timeZone,
prettyInspectHighlightStyles: {
name: "yellow",
number: "blue",
@ -14,7 +14,7 @@ const logger = new Logger({
boolean: "blue",
},
maskValuesOfKeys: ["password", "passwordConfirmation", "credentials", "credential"],
exposeErrorCodeFrame: !isProduction,
exposeErrorCodeFrame: !IS_PRODUCTION,
});
export default logger;

View File

@ -1,5 +1,7 @@
import { PrismaClient } from "@prisma/client";
import { IS_PRODUCTION } from "@lib/config/constants";
declare global {
// eslint-disable-next-line no-var
var prisma: PrismaClient | undefined;
@ -11,7 +13,7 @@ export const prisma =
// log: ["query", "error", "warn"],
});
if (process.env.NODE_ENV !== "production") {
if (!IS_PRODUCTION) {
globalThis.prisma = prisma;
}

View File

@ -1,10 +1,12 @@
import dayjs, { Dayjs } from "dayjs";
import isBetween from "dayjs/plugin/isBetween";
import isToday from "dayjs/plugin/isToday";
import utc from "dayjs/plugin/utc";
import { getWorkingHours } from "./availability";
import { WorkingHours } from "./types/schedule";
dayjs.extend(isToday);
dayjs.extend(utc);
dayjs.extend(isBetween);
@ -17,37 +19,41 @@ export type GetSlots = {
const getMinuteOffset = (date: Dayjs, step: number) => {
// Diffs the current time with the given date and iff same day; (handled by 1440) - return difference; otherwise 0
const minuteOffset = Math.min(date.diff(dayjs().startOf("day"), "minutes"), 1440) % 1440;
const minuteOffset = Math.min(date.diff(dayjs().startOf("day"), "minute"), 1440) % 1440;
// round down to nearest step
return Math.floor(minuteOffset / step) * step;
return Math.ceil(minuteOffset / step) * step;
};
const getSlots = ({ inviteeDate, frequency, minimumBookingNotice, workingHours }: GetSlots) => {
// current date in invitee tz
const startDate = dayjs(inviteeDate).add(minimumBookingNotice, "minutes"); // + minimum notice period
let startDate = dayjs(inviteeDate).add(minimumBookingNotice, "minute");
// checks if the start date is in the past
if (startDate.isBefore(dayjs(), "day")) {
return [];
}
// Add the current time to the startDate if the day is today
if (startDate.isToday()) {
startDate = startDate.add(dayjs().diff(startDate, "minute"), "minute");
}
const localWorkingHours = getWorkingHours(
{ utcOffset: -inviteeDate.utcOffset() },
workingHours.map((schedule) => ({
days: schedule.days,
startTime: dayjs.utc().startOf("day").add(schedule.startTime, "minutes"),
endTime: dayjs.utc().startOf("day").add(schedule.endTime, "minutes"),
startTime: dayjs.utc().startOf("day").add(schedule.startTime, "minute"),
endTime: dayjs.utc().startOf("day").add(schedule.endTime, "minute"),
}))
).filter((hours) => hours.days.includes(inviteeDate.day()));
const slots: Dayjs[] = [];
for (let minutes = getMinuteOffset(inviteeDate, frequency); minutes < 1440; minutes += frequency) {
const slot = inviteeDate.startOf("day").add(minutes, "minutes");
for (let minutes = getMinuteOffset(startDate, frequency); minutes < 1440; minutes += frequency) {
const slot = startDate.startOf("day").add(minutes, "minute");
// add slots to available slots if it is found to be between the start and end time of the checked working hours.
if (
localWorkingHours.some((hours) =>
slot.isBetween(
inviteeDate.startOf("day").add(hours.startTime, "minutes"),
inviteeDate.startOf("day").add(hours.endTime, "minutes"),
startDate.startOf("day").add(hours.startTime, "minute"),
startDate.startOf("day").add(hours.endTime, "minute"),
null,
"[)"
)

View File

@ -1,6 +1,4 @@
import { Booking } from "@prisma/client";
import { LocationType } from "@lib/location";
import { Attendee, Booking } from "@prisma/client";
export type BookingConfirmBody = {
confirmed: boolean;
@ -11,18 +9,23 @@ export type BookingCreateBody = {
email: string;
end: string;
eventTypeId: number;
guests: string[];
location: LocationType;
guests?: string[];
location: string;
name: string;
notes: string;
notes?: string;
rescheduleUid?: string;
start: string;
timeZone: string;
users?: string[];
user?: string;
language: string;
customInputs: { label: string; value: string }[];
metadata: {
[key: string]: string;
};
};
export type BookingResponse = Booking & {
paymentUid?: string;
attendees: Attendee[];
};

View File

@ -15,15 +15,10 @@ export type AdvancedOptions = {
price?: number;
currency?: string;
schedulingType?: SchedulingType;
users?: {
value: number;
label: string;
avatar: string;
}[];
users?: string[];
availability?: { openingHours: WorkingHours[]; dateOverrides: WorkingHours[] };
customInputs?: EventTypeCustomInput[];
timeZone: string;
hidden: boolean;
timeZone?: string;
};
export type EventTypeCustomInput = {
@ -55,7 +50,5 @@ export type EventTypeInput = AdvancedOptions & {
length: number;
hidden: boolean;
locations: unknown;
customInputs: EventTypeCustomInput[];
timeZone: string;
availability?: { openingHours: WorkingHours[]; dateOverrides: WorkingHours[] };
};

View File

@ -2,17 +2,12 @@ import { Credential } from "@prisma/client";
import short from "short-uuid";
import { v5 as uuidv5 } from "uuid";
import CalEventParser from "@lib/CalEventParser";
import "@lib/emails/EventMail";
import { getIntegrationName } from "@lib/emails/helpers";
import { getUid } from "@lib/CalEventParser";
import { EventResult } from "@lib/events/EventManager";
import { PartialReference } from "@lib/events/EventManager";
import logger from "@lib/logger";
import { AdditionInformation, CalendarEvent, EntryPoint } from "./calendarClient";
import EventAttendeeRescheduledMail from "./emails/EventAttendeeRescheduledMail";
import EventOrganizerRescheduledMail from "./emails/EventOrganizerRescheduledMail";
import VideoEventAttendeeMail from "./emails/VideoEventAttendeeMail";
import VideoEventOrganizerMail from "./emails/VideoEventOrganizerMail";
import { CalendarEvent } from "./calendarClient";
import DailyVideoApiAdapter from "./integrations/Daily/DailyVideoApiAdapter";
import ZoomVideoApiAdapter from "./integrations/Zoom/ZoomVideoApiAdapter";
import { Ensure } from "./types/utils";
@ -31,9 +26,9 @@ export interface VideoCallData {
type EventBusyDate = Record<"start" | "end", Date>;
export interface VideoApiAdapter {
createMeeting(event: CalendarEvent): Promise<any>;
createMeeting(event: CalendarEvent): Promise<VideoCallData>;
updateMeeting(uid: string, event: CalendarEvent): Promise<any>;
updateMeeting(bookingRef: PartialReference, event: CalendarEvent): Promise<VideoCallData>;
deleteMeeting(uid: string): Promise<unknown>;
@ -65,8 +60,7 @@ const createMeeting = async (
credential: Credential,
calEvent: Ensure<CalendarEvent, "language">
): Promise<EventResult> => {
const parser: CalEventParser = new CalEventParser(calEvent);
const uid: string = parser.getUid();
const uid: string = getUid(calEvent);
if (!credential) {
throw new Error(
@ -92,98 +86,41 @@ const createMeeting = async (
};
}
const videoCallData: VideoCallData = {
type: credential.type,
id: createdMeeting.id,
password: createdMeeting.password,
url: createdMeeting.join_url,
};
if (credential.type === "daily_video") {
videoCallData.type = "Daily.co Video";
videoCallData.id = createdMeeting.name;
videoCallData.url = process.env.BASE_URL + "/call/" + uid;
}
const entryPoint: EntryPoint = {
entryPointType: getIntegrationName(videoCallData),
uri: videoCallData.url,
label: calEvent.language("enter_meeting"),
pin: videoCallData.password,
};
const additionInformation: AdditionInformation = {
entryPoints: [entryPoint],
};
calEvent.additionInformation = additionInformation;
calEvent.videoCallData = videoCallData;
try {
const organizerMail = new VideoEventOrganizerMail(calEvent);
await organizerMail.sendEmail();
} catch (e) {
console.error("organizerMail.sendEmail failed", e);
}
if (!createdMeeting || !createdMeeting.disableConfirmationEmail) {
try {
const attendeeMail = new VideoEventAttendeeMail(calEvent);
await attendeeMail.sendEmail();
} catch (e) {
console.error("attendeeMail.sendEmail failed", e);
}
}
return {
type: credential.type,
success,
uid,
createdEvent: createdMeeting,
originalEvent: calEvent,
videoCallData: videoCallData,
};
};
const updateMeeting = async (
credential: Credential,
calEvent: CalendarEvent,
bookingRefUid: string | null
bookingRef: PartialReference | null
): Promise<EventResult> => {
const uid: string = translator.fromUUID(uuidv5(JSON.stringify(calEvent), uuidv5.URL));
if (!credential) {
throw new Error(
"Credentials must be set! Video platforms are optional, so this method shouldn't even be called when no video credentials are set."
);
}
const uid = translator.fromUUID(uuidv5(JSON.stringify(calEvent), uuidv5.URL));
let success = true;
const [firstVideoAdapter] = getVideoAdapters([credential]);
const updatedMeeting =
credential && bookingRefUid
? await firstVideoAdapter.updateMeeting(bookingRefUid, calEvent).catch((e) => {
credential && bookingRef
? await firstVideoAdapter.updateMeeting(bookingRef, calEvent).catch((e) => {
log.error("updateMeeting failed", e, calEvent);
success = false;
return undefined;
})
: undefined;
try {
const organizerMail = new EventOrganizerRescheduledMail(calEvent);
await organizerMail.sendEmail();
} catch (e) {
console.error("organizerMail.sendEmail failed", e);
}
if (!updatedMeeting.disableConfirmationEmail) {
try {
const attendeeMail = new EventAttendeeRescheduledMail(calEvent);
await attendeeMail.sendEmail();
} catch (e) {
console.error("attendeeMail.sendEmail failed", e);
}
if (!updatedMeeting) {
return {
type: credential.type,
success,
uid,
originalEvent: calEvent,
};
}
return {

View File

@ -25,7 +25,7 @@ const sendPayload = async (
triggerEvent: string,
createdAt: string,
subscriberUrl: string,
data: Omit<CalendarEvent, "language">,
data: Omit<CalendarEvent, "language"> & { metadata?: { [key: string]: string } },
template?: string | null
) => {
if (!subscriberUrl || !data) {

View File

@ -31,7 +31,7 @@
},
"dependencies": {
"@daily-co/daily-js": "^0.21.0",
"@headlessui/react": "^1.4.1",
"@headlessui/react": "^1.4.2",
"@heroicons/react": "^1.0.5",
"@hookform/resolvers": "^2.8.3",
"@jitsu/sdk-js": "^2.2.4",
@ -42,16 +42,17 @@
"@radix-ui/react-dialog": "^0.1.0",
"@radix-ui/react-dropdown-menu": "^0.1.1",
"@radix-ui/react-id": "^0.1.0",
"@radix-ui/react-radio-group": "^0.1.1",
"@radix-ui/react-slider": "^0.1.1",
"@radix-ui/react-switch": "^0.1.1",
"@radix-ui/react-tooltip": "^0.1.0",
"@stripe/react-stripe-js": "^1.4.1",
"@stripe/stripe-js": "^1.16.0",
"@tailwindcss/forms": "^0.3.4",
"@trpc/client": "^9.12.0",
"@trpc/next": "^9.12.0",
"@trpc/react": "^9.12.0",
"@trpc/server": "^9.12.0",
"@trpc/client": "^9.15.0",
"@trpc/next": "^9.15.0",
"@trpc/react": "^9.15.0",
"@trpc/server": "^9.15.0",
"@vercel/edge-functions-ui": "^0.2.1",
"@wojtekmaj/react-daterange-picker": "^3.3.1",
"accept-language-parser": "^1.5.0",
@ -67,31 +68,31 @@
"jimp": "^0.16.1",
"lodash": "^4.17.21",
"micro": "^9.3.4",
"next": "^12.0.2",
"next": "^12.0.4",
"next-auth": "^3.29.0",
"next-i18next": "^8.9.0",
"next-seo": "^4.26.0",
"next-transpile-modules": "^9.0.0",
"nodemailer": "^6.6.3",
"nodemailer": "^6.7.1",
"otplib": "^12.0.1",
"qrcode": "^1.4.4",
"qrcode": "^1.5.0",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-easy-crop": "^3.5.2",
"react-hook-form": "^7.17.5",
"react-hook-form": "^7.20.2",
"react-hot-toast": "^2.1.0",
"react-intl": "^5.20.7",
"react-intl": "^5.22.0",
"react-multi-email": "^0.5.3",
"react-phone-number-input": "^3.1.25",
"react-query": "^3.30.0",
"react-phone-number-input": "^3.1.41",
"react-query": "^3.33.5",
"react-router-dom": "^5.2.0",
"react-select": "^5.1.0",
"react-timezone-select": "^1.1.13",
"react-select": "^5.2.1",
"react-timezone-select": "^1.1.15",
"react-use-intercom": "1.4.0",
"short-uuid": "^4.2.0",
"stripe": "^8.168.0",
"stripe": "^8.191.0",
"tsdav": "^1.1.5",
"superjson": "1.8.0",
"tsdav": "1.0.6",
"tslog": "^3.2.1",
"uuid": "^8.3.2",
"zod": "^3.8.2"
@ -100,15 +101,15 @@
"@microsoft/microsoft-graph-types-beta": "0.15.0-preview",
"@trivago/prettier-plugin-sort-imports": "2.0.4",
"@types/accept-language-parser": "1.5.2",
"@types/async": "^3.2.9",
"@types/async": "^3.2.10",
"@types/bcryptjs": "^2.4.2",
"@types/jest": "^27.0.1",
"@types/lodash": "^4.14.176",
"@types/jest": "^27.0.3",
"@types/lodash": "^4.14.177",
"@types/micro": "^7.3.6",
"@types/node": "^16.11.5",
"@types/node": "^16.11.10",
"@types/nodemailer": "^6.4.4",
"@types/qrcode": "^1.4.1",
"@types/react": "^17.0.18",
"@types/react": "^17.0.37",
"@types/react-phone-number-input": "^3.0.13",
"@types/stripe": "^8.0.417",
"@types/uuid": "8.3.1",
@ -120,8 +121,8 @@
"eslint": "^7.32.0",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-prettier": "^3.4.0",
"eslint-plugin-react": "^7.26.1",
"eslint-plugin-react-hooks": "^4.2.0",
"eslint-plugin-react": "^7.27.1",
"eslint-plugin-react-hooks": "^4.3.0",
"husky": "^7.0.1",
"jest": "^27.2.2",
"jest-playwright": "^0.0.1",
@ -131,13 +132,13 @@
"mockdate": "^3.0.5",
"npm-run-all": "^4.1.5",
"playwright": "^1.16.2",
"postcss": "^8.3.11",
"postcss": "^8.4.1",
"prettier": "^2.3.2",
"prisma": "^2.30.2",
"tailwindcss": "^2.2.19",
"ts-jest": "^27.0.7",
"ts-node": "^10.2.1",
"typescript": "^4.4.4"
"typescript": "^4.5.2"
},
"lint-staged": {
"./{*,{ee,pages,components,lib}/**/*}.{js,ts,jsx,tsx}": [
@ -148,4 +149,4 @@
"prisma format"
]
}
}
}

View File

@ -2,6 +2,7 @@ import { ArrowRightIcon } from "@heroicons/react/outline";
import { GetStaticPaths, GetStaticPropsContext } from "next";
import { i18n } from "next-i18next.config";
import Link from "next/link";
import { useRouter } from "next/router";
import React, { useEffect } from "react";
import { useLocale } from "@lib/hooks/useLocale";
@ -25,6 +26,8 @@ export default function User(props: inferSSRProps<typeof getStaticProps>) {
const query = trpc.useQuery(["booking.userEventTypes", { username }], { enabled: !!username });
const { t } = useLocale();
const router = useRouter();
const { isReady } = useTheme(query.data?.user.theme);
useEffect(() => {
if (!query.data || !username) {
@ -37,9 +40,10 @@ export default function User(props: inferSSRProps<typeof getStaticProps>) {
if (!query.data) {
return <Loader />;
}
const { user, eventTypes } = query.data;
const { user, eventTypes } = query.data;
const nameOrUsername = user.name || user.username || "";
return (
<>
<HeadSeo
@ -49,16 +53,16 @@ export default function User(props: inferSSRProps<typeof getStaticProps>) {
avatar={user.avatar || ""}
/>
{isReady && (
<div className="bg-neutral-50 dark:bg-black h-screen">
<main className="max-w-3xl mx-auto py-24 px-4">
<div className="h-screen bg-neutral-50 dark:bg-black">
<main className="max-w-3xl px-4 py-24 mx-auto">
<div className="mb-8 text-center">
<Avatar
imageSrc={user.avatar}
className="mx-auto w-24 h-24 rounded-full mb-4"
className="w-24 h-24 mx-auto mb-4 rounded-full"
alt={nameOrUsername}
/>
<h1 className="font-cal text-3xl font-bold text-neutral-900 dark:text-white mb-1">
{user.name || user.username}
<h1 className="mb-1 text-3xl font-bold font-cal text-neutral-900 dark:text-white">
{nameOrUsername}
</h1>
<p className="text-neutral-500 dark:text-white">{user.bio}</p>
</div>
@ -66,9 +70,15 @@ export default function User(props: inferSSRProps<typeof getStaticProps>) {
{eventTypes.map((type) => (
<div
key={type.id}
className="group relative dark:bg-neutral-900 dark:border-0 dark:hover:border-neutral-600 bg-white hover:bg-gray-50 border border-neutral-200 hover:border-brand rounded-sm">
<ArrowRightIcon className="absolute transition-opacity h-4 w-4 right-3 top-3 text-black dark:text-white opacity-0 group-hover:opacity-100" />
<Link href={`/${user.username}/${type.slug}`}>
className="relative bg-white border rounded-sm group dark:bg-neutral-900 dark:border-0 dark:hover:border-neutral-600 hover:bg-gray-50 border-neutral-200 hover:border-brand">
<ArrowRightIcon className="absolute w-4 h-4 text-black transition-opacity opacity-0 right-3 top-3 dark:text-white group-hover:opacity-100" />
<Link
href={{
pathname: `/${user.username}/${type.slug}`,
query: {
...router.query,
},
}}>
<a className="block px-6 py-4" data-testid="event-type-link">
<h2 className="font-semibold text-neutral-900 dark:text-white">{type.title}</h2>
<EventTypeDescription eventType={type} />
@ -78,9 +88,9 @@ export default function User(props: inferSSRProps<typeof getStaticProps>) {
))}
</div>
{eventTypes.length === 0 && (
<div className="shadow overflow-hidden rounded-sm">
<div className="overflow-hidden rounded-sm shadow">
<div className="p-8 text-center text-gray-400 dark:text-white">
<h2 className="font-cal font-semibold text-3xl text-gray-600 dark:text-white">
<h2 className="text-3xl font-semibold text-gray-600 font-cal dark:text-white">
{t("uh_oh")}
</h2>
<p className="max-w-md mx-auto">{t("no_event_types_have_been_setup")}</p>

View File

@ -4,8 +4,8 @@ import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
import { NextApiRequest, NextApiResponse } from "next";
import sendEmail from "@lib/emails/sendMail";
import { buildForgotPasswordMessage } from "@lib/forgot-password/messaging/forgot-password";
import { sendPasswordResetEmail } from "@lib/emails/email-manager";
import { PasswordReset, PASSWORD_RESET_EXPIRY_HOURS } from "@lib/emails/templates/forgot-password-email";
import prisma from "@lib/prisma";
import { getTranslation } from "@server/lib/i18n";
@ -51,7 +51,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
if (maybePreviousRequest && maybePreviousRequest?.length >= 1) {
passwordRequest = maybePreviousRequest[0];
} else {
const expiry = dayjs().add(6, "hours").toDate();
const expiry = dayjs().add(PASSWORD_RESET_EXPIRY_HOURS, "hours").toDate();
const createdResetPasswordRequest = await prisma.resetPasswordRequest.create({
data: {
email: rawEmail,
@ -61,20 +61,16 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
passwordRequest = createdResetPasswordRequest;
}
const passwordResetLink = `${process.env.BASE_URL}/auth/forgot-password/${passwordRequest.id}`;
const { subject, message } = buildForgotPasswordMessage({
const passwordEmail: PasswordReset = {
language: t,
user: {
name: maybeUser.name,
email: rawEmail,
},
link: passwordResetLink,
});
resetLink: `${process.env.BASE_URL}/auth/forgot-password/${passwordRequest.id}`,
};
await sendEmail({
to: rawEmail,
subject: subject,
text: message,
});
await sendPasswordResetEmail(passwordEmail);
return res.status(201).json({ message: "Reset Requested" });
} catch (reason) {

View File

@ -4,9 +4,11 @@ import type { NextApiRequest, NextApiResponse } from "next";
import { refund } from "@ee/lib/stripe/server";
import { getSession } from "@lib/auth";
import { CalendarEvent } from "@lib/calendarClient";
import EventRejectionMail from "@lib/emails/EventRejectionMail";
import { CalendarEvent, AdditionInformation } from "@lib/calendarClient";
import { sendDeclinedEmails } from "@lib/emails/email-manager";
import { sendScheduledEmails } from "@lib/emails/email-manager";
import EventManager from "@lib/events/EventManager";
import logger from "@lib/logger";
import prisma from "@lib/prisma";
import { BookingConfirmBody } from "@lib/types/booking";
@ -38,6 +40,8 @@ const authorized = async (
return false;
};
const log = logger.getChildLogger({ prefix: ["[api] book:user"] });
export default async function handler(req: NextApiRequest, res: NextApiResponse): Promise<void> {
const t = await getTranslation(req.body.language ?? "en", "common");
@ -63,6 +67,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
timeZone: true,
email: true,
name: true,
username: true,
},
});
@ -124,6 +129,27 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const eventManager = new EventManager(currentUser.credentials);
const scheduleResult = await eventManager.create(evt);
const results = scheduleResult.results;
if (results.length > 0 && results.every((res) => !res.success)) {
const error = {
errorCode: "BookingCreatingMeetingFailed",
message: "Booking failed",
};
log.error(`Booking ${currentUser.username} failed`, error, results);
} else {
const metadata: AdditionInformation = {};
if (results.length) {
// TODO: Handle created event metadata more elegantly
metadata.hangoutLink = results[0].createdEvent?.hangoutLink;
metadata.conferenceData = results[0].createdEvent?.conferenceData;
metadata.entryPoints = results[0].createdEvent?.entryPoints;
}
await sendScheduledEmails({ ...evt, additionInformation: metadata });
}
await prisma.booking.update({
where: {
id: bookingId,
@ -149,8 +175,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
status: BookingStatus.REJECTED,
},
});
const attendeeMail = new EventRejectionMail(evt);
await attendeeMail.sendEmail();
await sendDeclinedEmails(evt);
res.status(204).end();
}

View File

@ -11,11 +11,16 @@ import { v5 as uuidv5 } from "uuid";
import { handlePayment } from "@ee/lib/stripe/server";
import { CalendarEvent, getBusyCalendarTimes } from "@lib/calendarClient";
import EventOrganizerRequestMail from "@lib/emails/EventOrganizerRequestMail";
import { CalendarEvent, AdditionInformation, getBusyCalendarTimes } from "@lib/calendarClient";
import {
sendScheduledEmails,
sendRescheduledEmails,
sendOrganizerRequestEmail,
} from "@lib/emails/email-manager";
import { getErrorFromUnknown } from "@lib/errors";
import { getEventName } from "@lib/event";
import EventManager, { EventResult, PartialReference } from "@lib/events/EventManager";
import { BufferedBusyTime } from "@lib/integrations/Office365Calendar/Office365CalendarApiAdapter";
import logger from "@lib/logger";
import prisma from "@lib/prisma";
import { BookingCreateBody } from "@lib/types/booking";
@ -25,13 +30,6 @@ import getSubscribers from "@lib/webhooks/subscriptions";
import { getTranslation } from "@server/lib/i18n";
export interface DailyReturnType {
name: string;
url: string;
id: string;
created_at: string;
}
dayjs.extend(dayjsBusinessTime);
dayjs.extend(utc);
dayjs.extend(isBetween);
@ -40,7 +38,7 @@ dayjs.extend(timezone);
const translator = short();
const log = logger.getChildLogger({ prefix: ["[api] book:user"] });
type BufferedBusyTimes = { start: string; end: string }[];
type BufferedBusyTimes = BufferedBusyTime[];
/**
* Refreshes a Credential with fresh data from the database.
@ -240,7 +238,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}
const invitee = [{ email: reqBody.email, name: reqBody.name, timeZone: reqBody.timeZone }];
const guests = reqBody.guests.map((guest) => {
const guests = (reqBody.guests || []).map((guest) => {
const g = {
email: guest,
name: "",
@ -271,10 +269,16 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
t,
};
const description =
reqBody.customInputs.reduce((str, input) => str + input.label + "\n" + input.value + "\n\n", "") +
t("additional_notes") +
":\n" +
reqBody.notes;
const evt: CalendarEvent = {
type: eventType.title,
title: getEventName(eventNameObject),
description: reqBody.notes,
description,
startTime: reqBody.start,
endTime: reqBody.end,
organizer: {
@ -285,7 +289,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
attendees: attendeesList,
location: reqBody.location, // Will be processed by the EventManager later.
language: t,
uid,
};
if (eventType.schedulingType === SchedulingType.COLLECTIVE) {
@ -439,10 +442,10 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
if (rescheduleUid) {
// Use EventManager to conditionally use all needed integrations.
const updateResults = await eventManager.update(evt, rescheduleUid);
const updateManager = await eventManager.update(evt, rescheduleUid);
results = updateResults.results;
referencesToCreate = updateResults.referencesToCreate;
results = updateManager.results;
referencesToCreate = updateManager.referencesToCreate;
if (results.length > 0 && results.every((res) => !res.success)) {
const error = {
@ -451,15 +454,26 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
};
log.error(`Booking ${user.name} failed`, error, results);
} else {
const metadata: AdditionInformation = {};
if (results.length) {
// TODO: Handle created event metadata more elegantly
metadata.hangoutLink = results[0].updatedEvent?.hangoutLink;
metadata.conferenceData = results[0].updatedEvent?.conferenceData;
metadata.entryPoints = results[0].updatedEvent?.entryPoints;
}
await sendRescheduledEmails({ ...evt, additionInformation: metadata });
}
// If it's not a reschedule, doesn't require confirmation and there's no price,
// Create a booking
} else if (!eventType.requiresConfirmation && !eventType.price) {
// Use EventManager to conditionally use all needed integrations.
const createResults = await eventManager.create(evt);
const createManager = await eventManager.create(evt);
results = createResults.results;
referencesToCreate = createResults.referencesToCreate;
results = createManager.results;
referencesToCreate = createManager.referencesToCreate;
if (results.length > 0 && results.every((res) => !res.success)) {
const error = {
@ -468,11 +482,21 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
};
log.error(`Booking ${user.username} failed`, error, results);
} else {
const metadata: AdditionInformation = {};
if (results.length) {
// TODO: Handle created event metadata more elegantly
metadata.hangoutLink = results[0].createdEvent?.hangoutLink;
metadata.conferenceData = results[0].createdEvent?.conferenceData;
metadata.entryPoints = results[0].createdEvent?.entryPoints;
}
await sendScheduledEmails({ ...evt, additionInformation: metadata });
}
}
if (eventType.requiresConfirmation && !rescheduleUid) {
await new EventOrganizerRequestMail(evt).sendEmail();
await sendOrganizerRequestEmail(evt);
}
if (typeof eventType.price === "number" && eventType.price > 0) {
@ -495,13 +519,23 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const eventTrigger = rescheduleUid ? "BOOKING_RESCHEDULED" : "BOOKING_CREATED";
// Send Webhook call if hooked to BOOKING_CREATED & BOOKING_RESCHEDULED
const subscribers = await getSubscribers(user.id, eventTrigger);
console.log("evt:", evt);
console.log("evt:", {
...evt,
metadata: reqBody.metadata,
});
const promises = subscribers.map((sub) =>
sendPayload(eventTrigger, new Date().toISOString(), sub.subscriberUrl, evt, sub.payloadTemplate).catch(
(e) => {
console.error(`Error executing webhook for event: ${eventTrigger}, URL: ${sub.subscriberUrl}`, e);
}
)
sendPayload(
eventTrigger,
new Date().toISOString(),
sub.subscriberUrl,
{
...evt,
metadata: reqBody.metadata,
},
sub.payloadTemplate
).catch((e) => {
console.error(`Error executing webhook for event: ${eventTrigger}, URL: ${sub.subscriberUrl}`, e);
})
);
await Promise.all(promises);

View File

@ -7,6 +7,7 @@ import { refund } from "@ee/lib/stripe/server";
import { asStringOrNull } from "@lib/asStringOrNull";
import { getSession } from "@lib/auth";
import { CalendarEvent, deleteEvent } from "@lib/calendarClient";
import { sendCancelledEmails } from "@lib/emails/email-manager";
import { FAKE_DAILY_CREDENTIAL } from "@lib/integrations/Daily/DailyVideoApiAdapter";
import prisma from "@lib/prisma";
import { deleteMeeting } from "@lib/videoClient";
@ -101,6 +102,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return retObj;
}),
uid: bookingToDelete?.uid,
location: bookingToDelete?.location,
language: t,
};
@ -189,7 +191,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
await Promise.all([apiDeletes, attendeeDeletes, bookingReferenceDeletes]);
//TODO Perhaps send emails to user and client to tell about the cancellation
await sendCancelledEmails(evt);
res.status(204).end();
}

View File

@ -3,7 +3,7 @@ import dayjs from "dayjs";
import type { NextApiRequest, NextApiResponse } from "next";
import { CalendarEvent } from "@lib/calendarClient";
import EventOrganizerRequestReminderMail from "@lib/emails/EventOrganizerRequestReminderMail";
import { sendOrganizerRequestReminderEmail } from "@lib/emails/email-manager";
import prisma from "@lib/prisma";
import { getTranslation } from "@server/lib/i18n";
@ -90,7 +90,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
language: t,
};
await new EventOrganizerRequestReminderMail(evt).sendEmail();
await sendOrganizerRequestReminderEmail(evt);
await prisma.reminderMail.create({
data: {
referenceId: booking.id,

View File

@ -2,7 +2,9 @@ import { randomBytes } from "crypto";
import type { NextApiRequest, NextApiResponse } from "next";
import { getSession } from "@lib/auth";
import { createInvitationEmail } from "@lib/emails/invitation";
import { BASE_URL } from "@lib/config/constants";
import { sendTeamInviteEmail } from "@lib/emails/email-manager";
import { TeamInvite } from "@lib/emails/templates/team-invite-email";
import prisma from "@lib/prisma";
import { getTranslation } from "@server/lib/i18n";
@ -72,13 +74,15 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
});
if (session?.user?.name && team?.name) {
createInvitationEmail({
const teamInviteEvent: TeamInvite = {
language: t,
toEmail: req.body.usernameOrEmail,
from: session.user.name,
to: req.body.usernameOrEmail,
teamName: team.name,
token,
});
joinLink: `${BASE_URL}/auth/signup?token=${token}&callbackUrl=${BASE_URL + "/settings/teams"}`,
};
await sendTeamInviteEmail(teamInviteEvent);
}
return res.status(201).json({});
@ -106,12 +110,15 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
// inform user of membership by email
if (req.body.sendEmailInvitation && session?.user?.name && team?.name) {
createInvitationEmail({
const teamInviteEvent: TeamInvite = {
language: t,
toEmail: invitee.email,
from: session.user.name,
to: req.body.usernameOrEmail,
teamName: team.name,
});
joinLink: BASE_URL + "/settings/teams",
};
await sendTeamInviteEmail(teamInviteEvent);
}
res.status(201).json({});

View File

@ -1,4 +1,3 @@
import Link from "next/link";
import { useForm } from "react-hook-form";
import { QueryCell } from "@lib/QueryCell";
@ -68,9 +67,9 @@ export function AvailabilityForm(props: inferQueryOutput<"viewer.availability">)
<p>{t("troubleshoot_availability")}</p>
</div>
<div className="mt-5">
<Link href="/availability/troubleshoot">
<a className="btn btn-white">{t("launch_troubleshooter")}</a>
</Link>
<Button href="/availability/troubleshoot" color="secondary">
{t("launch_troubleshooter")}
</Button>
</div>
</div>
</div>

File diff suppressed because one or more lines are too long

View File

@ -124,7 +124,7 @@ const EventTypeList = ({ readOnly, types, profile }: EventTypeListProps): JSX.El
"hover:bg-neutral-50 flex justify-between items-center ",
type.$disabled && "pointer-events-none"
)}>
<div className="group flex items-center justify-between w-full px-4 py-4 sm:px-6 hover:bg-neutral-50">
<div className="flex items-center justify-between w-full px-4 py-4 group sm:px-6 hover:bg-neutral-50">
<button
className="absolute mb-8 left-1/2 -ml-4 sm:ml-0 sm:left-[19px] border hover:border-transparent text-gray-400 transition-all hover:text-black hover:shadow group-hover:scale-100 scale-0 w-7 h-7 p-1 invisible group-hover:visible bg-white rounded-full"
onClick={() => moveEventType(index, -1)}>
@ -583,7 +583,7 @@ const CreateNewEventButton = ({ profiles, canAddEvents }: CreateEventTypeProps)
</RadioArea.Group>
</div>
)}
<div className="mt-8 sm:flex sm:flex-row-reverse gap-x-2">
<div className="flex flex-row-reverse mt-8 gap-x-2">
<Button type="submit" loading={createMutation.isLoading}>
{t("continue")}
</Button>

View File

@ -81,9 +81,7 @@ function HideBrandingInput(props: { hideBrandingRef: RefObject<HTMLInputElement>
</div>
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse gap-x-2">
<DialogClose asChild>
<Button
className="table-cell text-center btn-wide btn-primary"
onClick={() => setModalOpen(false)}>
<Button className="table-cell text-center btn-wide" onClick={() => setModalOpen(false)}>
{t("dismiss")}
</Button>
</DialogClose>

View File

@ -98,10 +98,7 @@ export default function Teams() {
)}
</div>
<div className="flex items-start mb-4">
<Button
type="button"
onClick={() => setShowCreateTeamModal(true)}
className="btn btn-white">
<Button type="button" onClick={() => setShowCreateTeamModal(true)} color="secondary">
<PlusIcon className="group-hover:text-black text-gray-700 w-3.5 h-3.5 mr-2 inline-block" />
{t("new_team")}
</Button>
@ -114,7 +111,7 @@ export default function Teams() {
{!!invites.length && (
<div>
<h2 className="font-cal text-lg font-medium leading-6 text-gray-900">Open Invitations</h2>
<h2 className="text-lg font-medium leading-6 text-gray-900 font-cal">Open Invitations</h2>
<ul className="px-4 mt-4 mb-2 bg-white border divide-y divide-gray-200 rounded">
{invites.map((team: Team) => (
<TeamListItem
@ -176,15 +173,14 @@ export default function Teams() {
/>
</div>
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<button type="submit" className="btn btn-primary">
{t("create_team")}
</button>
<button
<Button type="submit">{t("create_team")}</Button>
<Button
onClick={() => setShowCreateTeamModal(false)}
type="button"
className="mr-2 btn btn-white">
className="mr-2"
color="secondary">
{t("cancel")}
</button>
</Button>
</div>
</form>
</div>

View File

@ -0,0 +1 @@
<svg width="56" height="56" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M18.628 36.781c2.846 0 4.946-1.096 6.183-2.59l-2.38-2.03c-.957 1.05-2.147 1.587-3.733 1.587-3.22 0-5.204-2.427-5.204-5.413 0-2.987 1.984-5.46 5.134-5.46 1.47 0 2.683.513 3.663 1.54l2.31-2.007c-1.47-1.75-3.313-2.567-5.973-2.567-5.04 0-8.517 3.803-8.517 8.493 0 4.667 3.663 8.447 8.517 8.447ZM31.69 36.781c2.17 0 3.267-.91 3.92-2.286v1.983h3.057V24.344H35.54v1.914c-.653-1.307-1.75-2.17-3.85-2.17-3.337 0-5.997 2.87-5.997 6.37s2.66 6.323 5.997 6.323Zm-2.847-6.346c0-1.89 1.354-3.5 3.36-3.5 2.077 0 3.407 1.633 3.407 3.523 0 1.89-1.33 3.477-3.407 3.477-2.006 0-3.36-1.657-3.36-3.5ZM41.472 36.478h3.15V19.444h-3.15v17.034Z" fill="#292929"/></svg>

After

Width:  |  Height:  |  Size: 726 B

1
public/cal-com-icon.svg Normal file
View File

@ -0,0 +1 @@
<svg width="56" height="56" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="56" height="56" rx="12" fill="#292929"/><path d="M18.628 36.781c2.846 0 4.946-1.096 6.183-2.59l-2.38-2.03c-.957 1.05-2.147 1.587-3.733 1.587-3.22 0-5.204-2.427-5.204-5.413 0-2.987 1.984-5.46 5.134-5.46 1.47 0 2.683.513 3.663 1.54l2.31-2.007c-1.47-1.75-3.313-2.567-5.973-2.567-5.04 0-8.517 3.803-8.517 8.493 0 4.667 3.663 8.447 8.517 8.447ZM31.69 36.781c2.17 0 3.267-.91 3.92-2.286v1.983h3.057V24.344H35.54v1.914c-.653-1.307-1.75-2.17-3.85-2.17-3.337 0-5.997 2.87-5.997 6.37s2.66 6.323 5.997 6.323Zm-2.847-6.346c0-1.89 1.354-3.5 3.36-3.5 2.077 0 3.407 1.633 3.407 3.523 0 1.89-1.33 3.477-3.407 3.477-2.006 0-3.36-1.657-3.36-3.5ZM41.472 36.478h3.15V19.444h-3.15v17.034Z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 776 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

BIN
public/emails/checkCircle@2x.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

BIN
public/emails/linkIcon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 467 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@ -1,12 +1,24 @@
{
"accept_invitation": "Accept Invitation",
"calcom_explained": "Cal.com is the open source Calendly alternative putting you in control of your own data, workflow and appearance.",
"have_any_questions": "Have questions? We're here to help.",
"reset_password_subject": "Cal.com: Reset password instructions",
"event_declined_subject": "Declined: {{eventType}} with {{name}} at {{date}}",
"event_cancelled_subject": "Cancelled: {{eventType}} with {{name}} at {{date}}",
"event_request_declined": "Your event request has been declined",
"event_request_cancelled": "Your scheduled event was cancelled",
"organizer": "Organizer",
"need_to_reschedule_or_cancel": "Need to reschedule or cancel?",
"manage_this_event": "Manage this event",
"your_event_has_been_scheduled": "Your event has been scheduled",
"accept_our_license": "Accept our license by changing the .env variable <1>NEXT_PUBLIC_LICENSE_CONSENT</1> to '{{agree}}'.",
"remove_banner_instructions": "To remove this banner, please open your .env file and change the <1>NEXT_PUBLIC_LICENSE_CONSENT</1> variable to '{{agree}}'.",
"error_message": "The error message was: '{{errorMessage}}'",
"refund_failed_subject": "Refund failed: {{userName}} - {{date}} - {{eventType}}",
"refund_failed_subject": "Refund failed: {{name}} - {{date}} - {{eventType}}",
"refund_failed": "The refund for the event {{eventType}} with {{userName}} on {{date}} failed.",
"check_with_provider_and_user": "Please check with your payment provider and {{userName}} how to handle this.",
"check_with_provider_and_user": "Please check with your payment provider and {{user}} how to handle this.",
"a_refund_failed": "A refund failed",
"awaiting_payment": "Awaiting Payment: {{eventType}} with {{organizerName}} on {{date}}",
"awaiting_payment_subject": "Awaiting Payment: {{eventType}} with {{name}} on {{date}}",
"meeting_awaiting_payment": "Your meeting is awaiting payment",
"help": "Help",
"price": "Price",
@ -28,16 +40,21 @@
"no_more_results": "No more results",
"load_more_results": "Load more results",
"integration_meeting_id": "{{integrationName}} meeting ID: {{meetingId}}",
"confirmed_event_type_subject": "Confirmed: {{eventType}} with {{name}} on {{date}}",
"confirmed_event_type_subject": "Confirmed: {{eventType}} with {{name}} at {{date}}",
"new_event_request": "New event request: {{attendeeName}} - {{date}} - {{eventType}}",
"confirm_or_reject_booking": "Confirm or reject the booking",
"confirm_or_reject_request": "Confirm or reject the request",
"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",
"event_awaiting_approval": "An event is waiting for your approval",
"someone_requested_an_event": "Someone has requested to schedule an event on your calendar.",
"someone_requested_password_reset": "Someone has requested a link to change your password.",
"password_reset_instructions": "If you didn't request this, you can safely ignore this email and your password will not be changed.",
"event_awaiting_approval_subject": "Awaiting Approval: {{eventType}} with {{name}} at {{date}}",
"event_still_awaiting_approval": "An event is still 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}}",
"event_has_been_rescheduled": "Updated - Your event has been rescheduled",
"hi_user_name": "Hi {{name}}",
"ics_event_title": "{{eventType}} with {{name}}",
"new_event_subject": "New event: {{attendeeName}} - {{date}} - {{eventType}}",
"join_by_entrypoint": "Join by {{entryPoint}}",
"notes": "Notes",
@ -53,14 +70,13 @@
"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}}",
"rescheduled_event_type_subject": "Rescheduled: {{eventType}} with {{name}} at {{date}}",
"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}}",
"user_invited_you": "{{user}} invited you to join the team {{team}} on Cal.com",
"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,",
@ -113,6 +129,7 @@
"rejected": "Rejected",
"unconfirmed": "Unconfirmed",
"guests": "Guests",
"guest": "Guest",
"web_conferencing_details_to_follow": "Web conferencing details to follow.",
"the_username": "The username",
"username": "Username",

View File

@ -1 +1,537 @@
{}
{
"accept_our_license": "Acceptez notre licence en modifiant la variable .env <1>NEXT_PUBLIC_LICENSE_CONSENT</1> vers '{{agree}}'.",
"remove_banner_instructions": "Pour retirer cette bannière, merci d'ouvrir le fichier .env et de changer la variable <1>NEXT_PUBLIC_LICENSE_CONSENT</1> vers '{{agree}}'.",
"error_message": "Le message d'erreur était : '{{errorMessage}}'",
"refund_failed_subject": "Le remboursement a échoué : {{userName}} - {{date}} - {{eventType}}",
"refund_failed": "Le remboursement pour l'évènement {{eventType}} avec {{userName}} le {{date}} a échoué.",
"check_with_provider_and_user": "Merci de vérifier avec votre intermédiaire de paiement et {{userName}} comment corriger ceci.",
"a_refund_failed": "Un remboursement a échoué.",
"awaiting_payment": "En attente de paiement : {{eventType}} avec {{organizerName}} le {{date}}",
"meeting_awaiting_payment": "Votre rendez-vous est en attente de paiement.",
"help": "Aide",
"price": "Tarifs",
"paid": "Payé",
"refunded": "Remboursé",
"pay_later_instructions": "Vous avez également reçu un e-mail avec ce lien, si vous souhaitez payer plus tard. ",
"payment": "Paiement",
"missing_card_fields": "Champs de carte bancaire manquants",
"pay_now": "Payer maintenant",
"codebase_has_to_stay_opensource": "Le code source doit rester open source, qu'il ait été modifié ou non",
"cannot_repackage_codebase": "Vous ne pouvez pas repackager ou vendre le code source",
"acquire_license": "Obtenez une licence commerciale pour retirer ces conditions en envoyant un mail",
"terms_summary": "Résumé des conditions",
"open_env": "Ouvrez .env et acceptez la licence",
"env_changed": "J'ai modifié mon .env",
"accept_license": "Accepter la licence",
"still_waiting_for_approval": "Un évènement est toujours en attente de validation",
"event_is_still_waiting": "Une demande d'évènement est en attente : {{attendeeName}} - {{date}} - {{eventType}}",
"no_more_results": "Pas de résultats",
"load_more_results": "Charger plus de résultats",
"integration_meeting_id": "L'ID de rendez-vous pour {{integrationName}} est : {{meetingId}}",
"confirmed_event_type_subject": "Confirmé : {{eventType}} avec {{name}} le {{date}}",
"new_event_request": "Nouvelle demande d'évènement : {{attendeeName}} - {{date}} - {{eventType}}",
"confirm_or_reject_booking": "Confirmez ou refusez la réservation",
"check_bookings_page_to_confirm_or_reject": "Vérifiez votre page de rendez-vous pour confirmer ou refuser le rendez-vous.",
"event_awaiting_approval": "Un nouvel évènement attend votre validation",
"your_meeting_has_been_booked": "Votre rendez-vous a été enregistré",
"event_type_has_been_rescheduled_on_time_date": "Votre {{eventType}} avec {{name}} a été reprogrammé à {{time}} ({{timeZone}}) le {{date}}.",
"event_has_been_rescheduled": "Votre évènement a été reprogrammé.",
"hi_user_name": "Bonjour {{userName}}",
"organizer_ics_event_title": "{{eventType}} avec {{attendeeName}}",
"new_event_subject": "Nouvel évènement : {{attendeeName}} - {{date}} - {{eventType}}",
"join_by_entrypoint": "Rejoindre via {{entryPoint}}",
"notes": "Notes",
"manage_my_bookings": "Gérer mes rendez-vous",
"need_to_make_a_change": "Besoin de faire une modification ?",
"new_event_scheduled": "Un nouvel évènement a été programmé.",
"invitee_email": "Email de l'invité",
"invitee_timezone": "Fuseau horaire de l'invité",
"event_type": "Type d'évènement",
"enter_meeting": "Entrer le rendez-vous",
"video_call_provider": "Fournisseur d'appel visio",
"meeting_id": "ID du rendez-vous",
"meeting_password": "Mot de passe du rendez-vous",
"meeting_url": "Lien du rendez-vous",
"meeting_request_rejected": "Votre demande de rendez-vous a été refusée",
"rescheduled_event_type_with_organizer": "Reprogrammation: {{eventType}} avec {{organizerName}} le {{date}}",
"rescheduled_event_type_with_attendee": "Reprogrammation de l'évènement: {{attendeeName}} - {{date}} - {{eventType}}",
"rejected_event_type_with_organizer": "Refusé : {{eventType}} avec {{organizer}} le {{date}}",
"hi": "Bonjour",
"join_team": "Rejoindre l'équipe",
"request_another_invitation_email": "Si vous ne préférez pas utiliser {{toEmail}} comme e-mail sur Cal.com ou si vous avez déjà un compte Cal.com, merci d'envoyer une nouvelle invitation à cet e-mail",
"you_have_been_invited": "Vous avez été invité à rejoindre l'équipe {{teamName}}",
"user_invited_you": "{{user}} vous a invité à rejoindre l'équipe {{teamName}}",
"link_expires": "p.s. Expire dans {{expiresIn}} heures.",
"use_link_to_reset_password": "Utilisez le lien ci-dessous pour réinitialiser le mot de passe",
"hey_there": "Salut,",
"forgot_your_password_calcom": "Mot de passe oublié ? - Cal.com",
"event_type_title": "{{eventTypeTitle}} | Type d'évènement",
"delete_webhook_confirmation_message": "Êtes-vous sûr de vouloir supprimer ce webhook? Vous ne recevrez plus les données de rendez-vous de Cal.com à l'URL spécifiée, en temps réel, quand un rendez-vous est pris ou annulé",
"confirm_delete_webhook": "Oui, supprimer le webhook",
"edit_webhook": "Éditer le webhook",
"delete_webhook": "Supprimer le webhook",
"webhook_status": "Statut du webhook",
"webhook_enabled": "Webhook activé",
"webhook_disabled": "Webhook désactivé",
"webhook_response": "Réponse du webhook",
"webhook_test": "Test du webhook",
"manage_your_webhook": "Gérer votre webhook",
"webhook_created_successfully": "Webhook créé avec succès !",
"webhook_updated_successfully": "Webhook mis à jour avec succès !",
"webhook_removed_successfully": "Webhook supprimé avec succès !",
"payload_template": "Template de payload",
"dismiss": "Annuler",
"no_data_yet": "Pas encore de données",
"ping_test": "Test de ping",
"add_to_homescreen": "Ajouter cette application à votre page d'accueil pour plus de réactivité.",
"upcoming": "À venir",
"past": "Passé",
"choose_a_file": "Choississez un fichier...",
"upload_image": "Uploadez une image",
"upload_target": "Uploadez {{target}}",
"no_target": "Pas de {{target}}",
"slide_zoom_drag_instructions": "Glissez pour zoomer, déplacer ou repositionner",
"view_notifications": "Voir les notifications",
"view_public_page": "Voir votre page publique",
"sign_out": "Se déconnecter",
"add_another": "Ajouter un autre",
"until": "jusqu'à",
"powered_by": "Boosté par",
"unavailable": "Indisponible",
"set_work_schedule": "Définissez vos horaires de travail",
"change_bookings_availability": "Modifiez vos disponibilités pour les réservations",
"select": "Sélectionnez...",
"2fa_confirm_current_password": "Confirmez votre mot de passe actuel pour commencer.",
"2fa_scan_image_or_use_code": "Scannez l'image suivante avec votre application d'authentification sur votre téléphone ou en entrant manuellement le code textuel.",
"text": "Texte",
"multiline_text": "Zone de texte",
"number": "Numéro",
"checkbox": "Checkbox",
"is_required": "Est obligatoire",
"required": "Obligatoire",
"input_type": "Type de champ",
"rejected": "Refusé",
"unconfirmed": "Non-confirmé",
"guests": "Invités",
"web_conferencing_details_to_follow": "Détails de la conférence Web à suivre.",
"the_username": "Le nom d'utilisateur",
"username": "Nom d'utilisateur",
"is_still_available": "est toujours disponible.",
"documentation": "Documentation",
"documentation_description": "Apprenez comment intégrer nos outils à votre application",
"api_reference": "Documentation de l'API",
"api_reference_description": "Une documentation de l'API pour nos outils",
"blog": "Blog",
"blog_description": "Découvrez nos articles",
"join_our_community": "Rejoignez notre communauté",
"join_our_slack": "Rejoignez-nous sur Slack",
"claim_username_and_schedule_events": "Réservez votre nom d'utilisateur et programmez des événements",
"popular_pages": "Pages populaires",
"register_now": "Créer un compte maintenant",
"register": "Créer un compte",
"page_doesnt_exist": "Cette page n'existe pas.",
"check_spelling_mistakes_or_go_back": "Vérifiez les erreurs de frappe ou retourez à la page précédente.",
"404_page_not_found": "404: Cette page n'a pas été trouvée.",
"getting_started": "Commencer ",
"15min_meeting": "Rendez-vous de 15 min",
"30min_meeting": "Rendez-vous de 30 min",
"secret_meeting": "Rendez-vous secret",
"login_instead": "Se connecter à la place",
"create_account": "Créer un compte",
"confirm_password": "Confirmer le mot de passe",
"create_your_account": "Créez votre compte",
"sign_up": "S'inscrire",
"youve_been_logged_out": "Vous avez été déconnecté",
"hope_to_see_you_soon": "Nous espérons vous revoir bientôt !",
"logged_out": "Déconnecté",
"please_try_again_and_contact_us": "Merci de tenter à nouveau et de nous contacter si le problème persiste.",
"incorrect_2fa_code": "Le code Two-factor est incorrect.",
"no_account_exists": "Aucun compte ne correspond à cette adresse e-mail",
"2fa_enabled_instructions": "Authentification à deux facteur activée. Merci d'entrez le code à 6 chiffre de votre application d'authentification.",
"2fa_enter_six_digit_code": "Entrez le code à 6 chiffres de votre application d'authentification ci-dessous.",
"create_an_account": "Créer un compte",
"dont_have_an_account": "Pas de compte ?",
"2fa_code": "Code deux facteur",
"sign_in_account": "Se connecter à votre compte",
"sign_in": "Se connecter",
"go_back_login": "Retourner à la page de connexion",
"error_during_login": "Une erreur a eu lieu. Retournez sur la page de connexion et tentez à nouveau.",
"request_password_reset": "Réinitialiser le mot de passe",
"forgot_password": "Mot de passe oublié",
"forgot": "Oublié ?",
"done": "Terminé",
"check_email_reset_password": "Vérifiez vos mails. Nous vous avons envoyé un lien pour réinitialiser votre mot de passe.",
"finish": "Finir",
"few_sentences_about_yourself": "Quelques mots à propos de vous. Cela apparaîtra sur votre page publique.",
"nearly_there": "On y est presque",
"nearly_there_instructions": "Une dernière chose, une brève description de vous et une photo aident vraiment à obtenir des rendez-vous et permettent aux gens de savoir qui vous êtes.",
"set_availability_instructions": "Définissez les plages horaires pendant lesquelles vous êtes disponible régulièrement. Vous pouvez en créer plus par la suite, et les assigner à différents agendas.",
"set_availability": "Définissez votre disponibilité ",
"continue_without_calendar": "Continuer sans agenda",
"connect_your_calendar": "Connectez votre agenda",
"connect_your_calendar_instructions": "Connectez votre agenda pour vérifier automatiquement les heures où vous êtes indisponible et les nouveaux événements lorsqu'ils sont programmés.",
"set_up_later": "Configurer plus tard",
"current_time": "Heure actuelle",
"welcome": "Bienvenue",
"welcome_to_calcom": "Bienvenue sur Cal.com",
"welcome_instructions": "Dites-nous comment vous appeler et indiquez-nous dans quel fuseau horaire vous vous trouvez. Vous pourrez modifier cela plus tard. ",
"connect_caldav": "Connecter au server CalDav",
"credentials_stored_and_encrypted": "Vos informations d'identification seront stockées et cryptées.",
"connect": "Connecter",
"try_for_free": "Essayez gratuitement",
"create_booking_link_with_calcom": "Créez votre propre lien de prise de rendez-vous avec Cal.com",
"who": "Qui",
"what": "Quoi",
"when": "Quand",
"where": "Où",
"add_to_calendar": "Ajouter au agenda",
"other": "Autre",
"emailed_you_and_attendees": "Nous vous avons envoyé, à vous et aux autres participants, une invitation d'agenda avec tous les détails.",
"emailed_you_and_any_other_attendees": "Vous et tous les autres participants avez reçu ces informations par e-mail.",
"needs_to_be_confirmed_or_rejected": "Votre réservation doit encore être confirmée ou refusée.",
"user_needs_to_confirm_or_reject_booking": "{{user}} doit encore confirmer ou refuser la réservation.",
"meeting_is_scheduled": "Le rendez-vous est enregistré",
"submitted": "Envoyé",
"booking_submitted": "Rendez-vous envoyé",
"booking_confirmed": "Rendez-vous confirmé",
"enter_new_password": "Entrez le nouveau mot de passe que vous souhaitez pour votre compte.",
"reset_password": "Réinitialiser le mot de passe",
"change_your_password": "Changez votre mot de passe",
"try_again": "Essayez à nouveau",
"request_is_expired": "Cette requête a expiré.",
"reset_instructions": "Entrez l'adresse e-mail associée à votre compte et nous vous enverrons un lien pour réinitialiser votre mot de passe.",
"request_is_expired_instructions": "Cette demande est expirée. Revenez en arrière et entrez l'e-mail associé à votre compte et nous vous enverrons un autre lien pour réinitialiser votre mot de passe.",
"whoops": "Oups",
"login": "Connexion ",
"success": "Succès",
"failed": "Échoué",
"password_has_been_reset_login": "Votre mot de passe a été réinitialisé. Vous pouvez maintenant vous connecter avec votre mot de passe nouvellement créé.",
"unexpected_error_try_again": "Une erreur inattendue est apparue. Réessayer.",
"back_to_bookings": "Retourner aux réservations",
"free_to_pick_another_event_type": "N'hésitez pas à choisir un autre événement à tout moment.",
"cancelled": "Annulé",
"cancellation_successful": "Annulation réussie ",
"really_cancel_booking": "Voulez-vous vraiment annuler votre réservation ?",
"cannot_cancel_booking": "Vous ne pouvez pas annuler cette réservation",
"reschedule_instead": "Au lieu de cela, vous pouvez la reprogrammer.",
"event_is_in_the_past": "Cet évènement est dans le passé",
"error_with_status_code_occured": "Une erreur avec le code {{status}} est arrivée.",
"booking_already_cancelled": "Cette réservation a déjà été annulée",
"go_back_home": "Retournez à l'accueil",
"or_go_back_home": "Ou retourner à l'accueil",
"no_availability": "Indisponible",
"no_meeting_found": "Pas de rendez-vous trouvé",
"no_meeting_found_description": "Ce rendez-vous n'existe pas. Contactez le propriétaire de la réunion pour obtenir un lien mis à jour.",
"no_status_bookings_yet": "Pas de rendez-vous {{status}}, pour le moment",
"no_status_bookings_yet_description": "Vous n'avez pas de rendez-vous {{status}}. {{description}}",
"event_between_users": "{{eventName}} entre {{host}} et {{attendeeName}}",
"bookings": "Rendez-vous",
"bookings_description": "Voir les événements à venir et passés réservés via vos liens de type d'événement.",
"upcoming_bookings": "Dès que quelqu'un réserve un rendez-vous avec vous, il apparaîtra ici.",
"past_bookings": "Vos réservations passées s'afficheront ici.",
"cancelled_bookings": "Vos réservations annulées apparaîtront ici.",
"on": "sur",
"and": "et",
"calendar_shows_busy_between": "Votre agenda indique que vous êtes occupé entre",
"troubleshoot": "Problème",
"troubleshoot_description": "Comprendre pourquoi certains horaires sont disponibles et d'autres bloqués.",
"overview_of_day": "Voici un aperçu de votre journée sur",
"hover_over_bold_times_tip": "Astuce : Survolez les heures en gras pour obtenir un horodatage complet",
"start_time": "Heure de début",
"end_time": "Heure de fin",
"buffer": "Temps entre deux réunions",
"your_day_starts_at": "Votre journée commence à",
"your_day_ends_at": "Votre journée se terminé à",
"launch_troubleshooter": "Lancer l'utilitaire de résolution des problèmes",
"troubleshoot_availability": "Corrigez votre disponibilité pour découvrir pourquoi elles s'affichent comme celà.",
"change_available_times": "Modifier les disponibilités",
"change_your_available_times": "Modifiez vos disponibilités",
"change_start_end": "Modifier l'heure de début et de fin de vos journées",
"change_start_end_buffer": "Définissez l'heure de début et de fin de votre journée et la durée minimale entre vos réunions.",
"current_start_date": "Actuellement, votre journée est réglée pour commencer à",
"start_end_changed_successfully": "Les heures de début et de fin de votre journée ont été modifiées avec succès.",
"and_end_at": "et termine à",
"light": "Clair",
"dark": "Sombre",
"automatically_adjust_theme": "Ajustez automatiquement le thème en fonction des préférences des invités",
"email": "E-mail",
"email_placeholder": "adrien@example.com",
"full_name": "Nom complet",
"browse_api_documentation": "Parcourez la documentation de l'API",
"leverage_our_api": "Tirez parti de notre API pour un contrôle total.",
"create_webhook": "Créer un webhook",
"booking_cancelled": "Réservation annulée",
"booking_rescheduled": "Réservation reprogrammée",
"booking_created": "Réservation créée",
"event_triggers": "Déclencheurs d'événements",
"subscriber_url": "URL de l'abonné",
"create_new_webhook": "Créer un nouveau webhook",
"create_new_webhook_to_account": "Créez un nouveau webhook pour votre compte",
"new_webhook": "Nouveau webhook",
"receive_cal_meeting_data": "Recevez les données de réunion Cal à une URL spécifiée, en temps réel, lorsqu'un événement est programmé ou annulé.",
"responsive_fullscreen_iframe": "Iframe responsive full-screen",
"loading": "Chargement...",
"standard_iframe": "Iframe standard",
"iframe_embed": "Iframe intégrée",
"embed_calcom": "Le moyen le plus simple d'intégrer Cal.com sur votre site Web.",
"integrate_using_embed_or_webhooks": "Intégrez à votre site Web à l'aide de nos options d'intégration ou obtenez des informations de réservation en temps réel à l'aide de webhooks personnalisés.",
"schedule_a_meeting": "Planifier une réunion",
"view_and_manage_billing_details": "Afficher et gérer vos informations de facturation",
"view_and_edit_billing_details": "Affichez et modifiez vos informations de facturation, ainsi que résiliez votre abonnement.",
"go_to_billing_portal": "Accédez au portail de facturation",
"need_anything_else": "Besoin d'autre chose ?",
"further_billing_help": "Si vous avez besoin d'aide supplémentaire pour la facturation, notre équipe d'assistance est là pour vous aider.",
"contact_our_support_team": "Contactez notre équipe d'assistance",
"uh_oh": "Oh, oh!",
"no_event_types_have_been_setup": "Cet utilisateur n'a encore configuré aucun type d'événement.",
"edit_logo": "Modifier le logo",
"upload_a_logo": "Uploader un logo",
"enable": "Activer",
"code": "Code",
"code_is_incorrect": "Le code est incorrect",
"add_an_extra_layer_of_security": "Ajoutez une couche de sécurité supplémentaire à votre compte en cas de vol de votre mot de passe.",
"2fa": "Authentification à deux facteurs",
"enable_2fa": "Activer l'authentification à deux facteurs",
"disable_2fa": "Désactiver l'authentification à deux facteurs",
"disable_2fa_recommendation": "Si vous devez désactiver l'authentification à deux facteurs, nous vous recommendons de la réactiver rapidement",
"error_disabling_2fa": "Erreur de désactivation de l'authentification à deux facteurs",
"error_enabling_2fa": "Erreur lord de l'activation de l'authentification à deux facteurs",
"security": "Sécurité",
"manage_account_security": "Gérer la sécurité de votre compte",
"password": "Mot de passe",
"password_updated_successfully": "Mot de passe mis à jour avec succès",
"password_has_been_changed": "Votre mot de passe a été modifié avec succès.",
"error_changing_password": "Erreur lors de la modification de votre mot de passe",
"something_went_wrong": "Quelque chose a déraillé.",
"something_doesnt_look_right": "Quelque chose semble anormal ?",
"please_try_again": "Merci de réessayer.",
"super_secure_new_password": "Votre nouveau mot de passe super sécurisé",
"new_password": "Nouveau mot de passe",
"your_old_password": "Votre ancien mot de passe",
"current_password": "Mot de passe actuel",
"change_password": "Changer le mot de passe",
"new_password_matches_old_password": "Le nouveau mot de passe correspond à votre ancien mot de passe. Veuillez choisir un autre mot de passe.",
"current_incorrect_password": "Le mot de passe actuel est incorrect",
"incorrect_password": "Le mot de passe est incorrect.",
"1_on_1": "Individuel",
"24_h": "24h",
"use_setting": "Utiliser le paramètre",
"am_pm": "am/pm",
"time_options": "Options de temps",
"january": "Janvier",
"february": "Février",
"march": "Mars",
"april": "Avril",
"may": "Mai",
"june": "Juin",
"july": "Juillet",
"august": "Août",
"september": "Septembre",
"october": "Octobre",
"november": "Novembre",
"december": "Decembre",
"monday": "Lundi",
"tuesday": "Mardi",
"wednesday": "Mercredi",
"thursday": "Jeudi",
"friday": "Vendredi",
"saturday": "Samedi",
"sunday": "Dimanche",
"all_booked_today": "Tout est réservé aujourd'hui.",
"slots_load_fail": "Impossible de charger les plages horaires disponibles.",
"additional_guests": "+ Invités supplémentaires",
"your_name": "Votre nom",
"email_address": "Votre adresse e-mail",
"location": "Lieu",
"yes": "oui",
"no": "non",
"additional_notes": "Notes complémentaires",
"booking_fail": "Impossible de réserver la réunion.",
"reschedule_fail": "Impossible de reprogrammer la réunion.",
"share_additional_notes": "Veuillez partager tout ce qui aidera à préparer notre réunion.",
"booking_confirmation": "Confirmez votre {{eventTypeTitle}} avec {{profileName}}",
"booking_reschedule_confirmation": "Reprogrammer votre {{eventTypeTitle}} avec {{profileName}}",
"in_person_meeting": "Lien ou réunion en personne",
"phone_call": "Appel téléphonique",
"phone_number": "Numéro de téléphone",
"enter_phone_number": "Entrez le numéro de téléphone",
"reschedule": "Reprogrammer",
"book_a_team_member": "Choisir un membre de l'équipe à la place",
"or": "OU",
"go_back": "Revenir en arrière",
"email_or_username": "E-mail ou nom d'utilisateur",
"send_invite_email": "Envoyer une invitation par e-mail",
"role": "Rôle",
"edit_team": "Modifier l'équipe",
"reject": "Refuser",
"accept": "Accepter",
"leave": "Quitter",
"profile": "Profil",
"my_team_url": "Lien vers mon équipe",
"team_name": "Nom de l'équipe",
"your_team_name": "Le nom de votre équipe",
"team_updated_successfully": "Équipe mise à jour avec succès",
"your_team_updated_successfully": "Votre équipe a bien été mise à jour.",
"about": "À propos",
"team_description": "Quelques mots à propos de votre équipe. Cela apparaîtra sur la page publique",
"members": "Membres",
"member": "Membre",
"owner": "Propriétaire",
"new_member": "Nouveau membre",
"invite": "Inviter",
"invite_new_member": "Inviter un nouveau membre",
"invite_new_team_member": "Inviter quelqu'un à rejoindre votre équipe.",
"disable_cal_branding": "Désactiver le logo Cal.com",
"disable_cal_branding_description": "Cacher le logo Cal.com des pages publiques.",
"danger_zone": "Zone de danger",
"back": "Retour",
"cancel": "Annuler",
"continue": "Continuer",
"confirm": "Confirmer",
"disband_team": "Dissoudre l'équipe",
"disband_team_confirmation_message": "Êtes-vous sûr de vouloir dissoudre cette équipe ? Toute personne avec qui vous avez partagé ce lien d'équipe ne pourra plus réserver en l'utilisant.",
"remove_member_confirmation_message": "Voulez-vous vraiment supprimer ce membre de l'équipe ?",
"confirm_disband_team": "Oui, dissoudre l'équipe",
"confirm_remove_member": "Oui, retirer le membre",
"remove_member": "Retirer le membre",
"manage_your_team": "Gérer votre équipe",
"submit": "Envoyer",
"delete": "Supprimer",
"update": "Mettre à jour",
"save": "Enregistrer",
"pending": "En attente",
"open_options": "Ouvrir les options",
"copy_link": "Copier le lien de l'évènement",
"preview": "Prévisualiser",
"link_copied": "Lien copié !",
"title": "Titre",
"description": "Description",
"quick_video_meeting": "Une conversation vidéo rapide.",
"scheduling_type": "Type de planification",
"preview_team": "Prévisualiser l'équipe",
"collective": "Collective",
"collective_description": "Planifier les rendez-vous quand tous les membres sont disponibles.",
"duration": "Durée",
"minutes": "Minutes",
"round_robin": "À tour de rôle",
"round_robin_description": "Un cycle de réunions entre plusieurs membres de l'équipe.",
"url": "URL",
"hidden": "Caché",
"readonly": "Lecture seule",
"plan_upgrade": "Vous devez mettre à niveau votre abonnement pour avoir plus d'un type d'événement actif.",
"plan_upgrade_instructions": "Pour mettre à niveau, rendez-vous sur <a href=\"https://cal.com/upgrade\" className=\"underline\">https://cal.com/upgrade</a>",
"event_types_page_title": "Types d'évènements",
"event_types_page_subtitle": "Créez des événements à partager pour que les personnes réservent sur votre agenda.",
"new_event_type_btn": "Nouveau type d'évènement",
"new_event_type_heading": "Créez votre premier type d'évènement",
"new_event_type_description": "Les types d'événements vous permettent de partager des liens qui indiquent vos disponibilités et permettent aux gens de prendre rendez-vous avec vous.",
"new_event_title": "Ajouter un nouveau type d'évènement",
"new_event_subtitle": "Créez un type d'évènement pour vous ou votre équipe.",
"new_team_event": "Ajouter un nouvel évènement d'équipe",
"new_event_description": "Créez un nouveau type d'évènement pour que les gens commencent à prendre rendez-vous.",
"event_type_created_successfully": "Type d'évènement {{eventTypeTitle}} créé avec succès",
"event_type_updated_successfully": "Type d'évènement {{eventTypeTitle}} mis à jour avec succès",
"event_type_deleted_successfully": "Type d'évènement supprimé",
"hours": "Heures",
"your_email": "Votre e-mail",
"change_avatar": "Changer l'avatar",
"language": "Langage",
"timezone": "Fuseau horaire",
"first_day_of_week": "Premier jour de la semaine",
"single_theme": "Thème",
"brand_color": "Couleur de la marque",
"file_not_named": "Le fichier n'est pas nommé [idOrSlug]/[user]",
"create_team": "Créer une équipe",
"name": "Nom",
"create_new_team_description": "Créer une nouvelle équipe pour collaborer avec les utilisateurs.",
"create_new_team": "Créer une nouvelle équipe",
"open_invitations": "Invitations ouvertes",
"new_team": "Nouvelle équipe",
"create_first_team_and_invite_others": "Créez votre première équipe et invitez d'autres utilisateurs à travailler avec vous.",
"create_team_to_get_started": "Créer une équipe pour commencer",
"teams": "Équipes",
"create_manage_teams_collaborative": "Créez et gérez des équipes pour utiliser les fonctionnalités collaboratives.",
"only_available_on_pro_plan": "Cette fonctionnalité n'est disponible que dans l'abonnement Pro",
"remove_cal_branding_description": "Afin de supprimer la marque Cal de vos pages de réservation, vous devez passer à un compte Pro.",
"to_upgrade_go_to": "Pour mettre à niveau, rendez-vous sur",
"edit_profile_info_description": "Modifiez les informations de votre profil, qui s'affichent sur votre lien de réservation.",
"change_email_contact": "Pour changer votre e-mail, contactez",
"little_something_about": "Un petit quelque chose à propos de vous.",
"profile_updated_successfully": "Profil mis à jour avec succès",
"your_user_profile_updated_successfully": "Votre profil d'utilisateur a été mis à jour avec succès.",
"user_cannot_found_db": "L'utilisateur semble connecté mais n'est pas trouvé dans la base de données",
"embed_and_webhooks": "Intégrations & Webhooks",
"enabled": "Activé",
"disabled": "Désactivé",
"disable": "Désactiver",
"billing": "Facturation",
"manage_your_billing_info": "Gérez vos informations de facturation et annulez votre abonnement.",
"availability": "Disponibilité",
"availability_updated_successfully": "Disponibilité mise à jour avec succès",
"configure_availability": "Déclarez les moments où vous êtes disponible pour un rendez-vous.",
"change_weekly_schedule": "Modifier vos disponibilités hebdomadaires",
"logo": "Logo",
"error": "Erreur",
"team_logo": "Logo de l'équipe",
"add_location": "Ajouter un lieu",
"attendees": "Participants",
"add_attendees": "Ajouter des participants",
"show_advanced_settings": "Voir les paramètres avancés",
"event_name": "Nom de l'évènement",
"meeting_with_user": "Rendez-vous avec {USER}",
"additional_inputs": "Champs supplémentaires",
"label": "Label",
"placeholder": "Placeholder",
"type": "Type",
"edit": "Éditer",
"add_input": "Ajouter un champ",
"opt_in_booking": "Réservation manuelle",
"opt_in_booking_description": "La réservation doit être confirmée manuellement avant d'être transmise aux intégrations tierces, puis un e-mail de confirmation est envoyé.",
"disable_guests": "Désactiver les invités",
"disable_guests_description": "Désactiver l'ajout d'invités supplémentaires lors de la réservation.",
"invitees_can_schedule": "Les invités peuvent réserver",
"date_range": "Plage de dates",
"calendar_days": "jours calendaires",
"business_days": "jours ouvrés",
"set_address_place": "Définir une adresse ou un lieu",
"cal_invitee_phone_number_scheduling": "Cal demandera à votre invité d'entrer un numéro de téléphone avant de planifier.",
"cal_provide_google_meet_location": "Cal fournira une salle Google Meet.",
"cal_provide_zoom_meeting_url": "Cal fournira une URL de rendez-vous Zoom.",
"cal_provide_video_meeting_url": "Cal fournira une URL de rendez-vous sur Daily.",
"require_payment": "Paiement requis",
"commission_per_transaction": "commission par transaction",
"event_type_updated_successfully_description": "Votre type d'évènement a été mis à jour avec succès.",
"hide_event_type": "Cacher le type d'évènement",
"edit_location": "Éditer le lieu",
"into_the_future": "dans le futur",
"within_date_range": "Dans une plage de date",
"indefinitely_into_future": "Indéfiniment dans le futur",
"this_input_will_shown_booking_this_event": "Ce champ sera affiché pour la réservation de cet évènement",
"add_new_custom_input_field": "Ajouter un nouveau champ personnalisé",
"quick_chat": "Conversation rapide",
"add_new_team_event_type": "Ajouter un nouveau type d'évènement d'équipe",
"add_new_event_type": "Ajouter un nouveau type d'évènement",
"new_event_type_to_book_description": "Créer un nouveau type d'évènement pour que les personnes puissent prendre rendez-vous.",
"length": "Durée",
"minimum_booking_notice": "Info de réservations minimum",
"delete_event_type_description": "Voulez-vous vraiment supprimer ce type d'événement ? Toute personne avec qui vous avez partagé ce lien ne pourra plus réserver en l'utilisant.",
"delete_event_type": "Supprimer le type d'évènement",
"confirm_delete_event_type": "Oui, supprimer le type d'évènement",
"integrations": "Intégrations",
"settings": "Paramètres",
"event_type_moved_successfully": "Le type d'évènement a été déplacé avec succès",
"next_step": "Passer l'étape",
"prev_step": "Étape précédente",
"installed": "Installé",
"disconnect": "Déconnecter",
"embed_your_calendar": "Intégrez l'agenda sur votre page web",
"connect_your_favourite_apps": "Connectez vos applications favorites.",
"automation": "Automatisation",
"configure_how_your_event_types_interact": "Configurez la façon dont vos types d'événements doivent interagir avec vos agendas.",
"connect_an_additional_calendar": "Connecter un agenda supplémentaire",
"conferencing": "Conférence",
"calendar": "Agenda",
"not_installed": "Pas installé",
"error_password_mismatch": "Les mots de passe ne correspondent pas.",
"error_required_field": "Ce champ est requis."
}

View File

@ -1,27 +0,0 @@
import { expect, it } from "@jest/globals";
import { html, text, Invitation } from "@lib/emails/invitation";
import { getTranslation } from "@server/lib/i18n";
it("email text rendering should strip tags and add new lines", () => {
const result = text("<p>hello world</p><br /><div>welcome to the brave <span>new</span> world");
expect(result).toEqual("hello world\nwelcome to the brave new world");
});
it("email html should render invite email", async () => {
const t = await getTranslation("en", "common");
const invitation = {
language: t,
from: "Huxley",
toEmail: "hello@example.com",
teamName: "Calendar Lovers",
token: "invite-token",
} as Invitation;
const result = html(invitation);
expect(result).toContain(
`<br />${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 })}`);
});

2399
yarn.lock

File diff suppressed because it is too large Load Diff