diff --git a/components/booking/BookingListItem.tsx b/components/booking/BookingListItem.tsx
index 3c2cab39..209371bf 100644
--- a/components/booking/BookingListItem.tsx
+++ b/components/booking/BookingListItem.tsx
@@ -9,7 +9,7 @@ import { inferQueryOutput, trpc } from "@lib/trpc";
import TableActions, { ActionType } from "@components/ui/TableActions";
-type BookingItem = inferQueryOutput<"viewer.bookings">[number];
+type BookingItem = inferQueryOutput<"viewer.bookings">["bookings"][number];
function BookingListItem(booking: BookingItem) {
const { t, i18n } = useLocale();
@@ -73,20 +73,17 @@ function BookingListItem(booking: BookingItem) {
const startTime = dayjs(booking.startTime).format(isUpcoming ? "ddd, D MMM" : "D MMMM YYYY");
return (
-
+
{startTime}
{dayjs(booking.startTime).format("HH:mm")} - {dayjs(booking.endTime).format("HH:mm")}
-
+
- {!booking.confirmed && !booking.rejected && (
-
- {t("unconfirmed")}
-
- )}
+ {!booking.confirmed && !booking.rejected &&
{t("unconfirmed")} }
+ {!!booking?.eventType?.price && !booking.paid &&
Pending payment }
{startTime}:{" "}
@@ -94,13 +91,16 @@ function BookingListItem(booking: BookingItem) {
-
+
{booking.eventType?.team && {booking.eventType.team.name}: }
{booking.title}
+ {!!booking?.eventType?.price && !booking.paid && (
+ Pending payment
+ )}
{!booking.confirmed && !booking.rejected && (
-
- {t("unconfirmed")}
-
+ {t("unconfirmed")}
)}
{booking.description && (
@@ -115,7 +115,7 @@ function BookingListItem(booking: BookingItem) {
)}
-
+
{isUpcoming && !isCancelled ? (
<>
{!booking.confirmed && !booking.rejected && }
@@ -130,4 +130,13 @@ function BookingListItem(booking: BookingItem) {
);
}
+const Tag = ({ children, className = "" }: React.PropsWithChildren<{ className?: string }>) => {
+ return (
+
+ {children}
+
+ );
+};
+
export default BookingListItem;
diff --git a/components/booking/DatePicker.tsx b/components/booking/DatePicker.tsx
index 6cde2012..cc42b4e6 100644
--- a/components/booking/DatePicker.tsx
+++ b/components/booking/DatePicker.tsx
@@ -1,66 +1,115 @@
import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/solid";
+import { EventType, PeriodType } from "@prisma/client";
import dayjs, { Dayjs } from "dayjs";
-// Then, include dayjs-business-time
import dayjsBusinessTime from "dayjs-business-time";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
-import { useEffect, useState } from "react";
+import { useEffect, useMemo, useState } from "react";
import classNames from "@lib/classNames";
+import { timeZone } from "@lib/clock";
+import { weekdayNames } from "@lib/core/i18n/weekday";
import { useLocale } from "@lib/hooks/useLocale";
import getSlots from "@lib/slots";
+import { WorkingHours } from "@lib/types/schedule";
+
+import Loader from "@components/Loader";
dayjs.extend(dayjsBusinessTime);
dayjs.extend(utc);
dayjs.extend(timezone);
-// FIXME prop types
+type DatePickerProps = {
+ weekStart: string;
+ onDatePicked: (pickedDate: Dayjs) => void;
+ workingHours: WorkingHours[];
+ eventLength: number;
+ date: Dayjs | null;
+ periodType: PeriodType;
+ periodStartDate: Date | null;
+ periodEndDate: Date | null;
+ periodDays: number | null;
+ periodCountCalendarDays: boolean | null;
+ minimumBookingNotice: number;
+};
+
+function isOutOfBounds(
+ time: dayjs.ConfigType,
+ {
+ periodType,
+ periodDays,
+ periodCountCalendarDays,
+ periodStartDate,
+ periodEndDate,
+ }: Pick<
+ EventType,
+ "periodType" | "periodDays" | "periodCountCalendarDays" | "periodStartDate" | "periodEndDate"
+ >
+) {
+ const date = dayjs(time);
+
+ switch (periodType) {
+ case PeriodType.ROLLING: {
+ const periodRollingEndDay = periodCountCalendarDays
+ ? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ dayjs().utcOffset(date.utcOffset()).add(periodDays!, "days").endOf("day")
+ : // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ dayjs().utcOffset(date.utcOffset()).addBusinessTime(periodDays!, "days").endOf("day");
+ return date.endOf("day").isAfter(periodRollingEndDay);
+ }
+
+ case PeriodType.RANGE: {
+ const periodRangeStartDay = dayjs(periodStartDate).utcOffset(date.utcOffset()).endOf("day");
+ const periodRangeEndDay = dayjs(periodEndDate).utcOffset(date.utcOffset()).endOf("day");
+ return date.endOf("day").isBefore(periodRangeStartDay) || date.endOf("day").isAfter(periodRangeEndDay);
+ }
+
+ case PeriodType.UNLIMITED:
+ default:
+ return false;
+ }
+}
+
function DatePicker({
weekStart,
onDatePicked,
workingHours,
- organizerTimeZone,
eventLength,
date,
- periodType = "unlimited",
+ periodType = PeriodType.UNLIMITED,
periodStartDate,
periodEndDate,
periodDays,
periodCountCalendarDays,
minimumBookingNotice,
-}: any): JSX.Element {
- const { t } = useLocale();
- const [days, setDays] = useState<({ disabled: boolean; date: number } | null)[]>([]);
-
- const [selectedMonth, setSelectedMonth] = useState(
- date
- ? periodType === "range"
- ? dayjs(periodStartDate).utcOffset(date.utcOffset()).month()
- : date.month()
- : dayjs().month() /* High chance server is going to have the same month */
- );
+}: DatePickerProps): JSX.Element {
+ const { i18n } = useLocale();
+ const [browsingDate, setBrowsingDate] = useState(date);
+
+ const [month, setMonth] = useState("");
+ const [year, setYear] = useState("");
+ const [isFirstMonth, setIsFirstMonth] = useState(false);
useEffect(() => {
- if (dayjs().month() !== selectedMonth) {
- setSelectedMonth(dayjs().month());
+ if (!browsingDate || (date && browsingDate.utcOffset() !== date?.utcOffset())) {
+ setBrowsingDate(date || dayjs().tz(timeZone()));
}
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, []);
-
- // Handle month changes
- const incrementMonth = () => {
- setSelectedMonth((selectedMonth ?? 0) + 1);
- };
-
- const decrementMonth = () => {
- setSelectedMonth((selectedMonth ?? 0) - 1);
- };
-
- const inviteeDate = (): Dayjs => (date || dayjs()).month(selectedMonth);
+ }, [date, browsingDate]);
useEffect(() => {
+ if (browsingDate) {
+ setMonth(browsingDate.toDate().toLocaleString(i18n.language, { month: "long" }));
+ setYear(browsingDate.format("YYYY"));
+ setIsFirstMonth(browsingDate.startOf("month").isBefore(dayjs()));
+ }
+ }, [browsingDate, i18n.language]);
+
+ const days = useMemo(() => {
+ if (!browsingDate) {
+ return [];
+ }
// Create placeholder elements for empty days in first week
- let weekdayOfFirst = inviteeDate().date(1).day();
+ let weekdayOfFirst = browsingDate.date(1).day();
if (weekStart === "Monday") {
weekdayOfFirst -= 1;
if (weekdayOfFirst < 0) weekdayOfFirst = 6;
@@ -69,65 +118,45 @@ function DatePicker({
const days = Array(weekdayOfFirst).fill(null);
const isDisabled = (day: number) => {
- const date: Dayjs = inviteeDate().date(day);
- switch (periodType) {
- case "rolling": {
- const periodRollingEndDay = periodCountCalendarDays
- ? dayjs().tz(organizerTimeZone).add(periodDays, "days").endOf("day")
- : dayjs().tz(organizerTimeZone).addBusinessTime(periodDays, "days").endOf("day");
- return (
- date.endOf("day").isBefore(dayjs().utcOffset(date.utcOffset())) ||
- date.endOf("day").isAfter(periodRollingEndDay) ||
- !getSlots({
- inviteeDate: date,
- frequency: eventLength,
- minimumBookingNotice,
- workingHours,
- organizerTimeZone,
- }).length
- );
- }
-
- case "range": {
- const periodRangeStartDay = dayjs(periodStartDate).tz(organizerTimeZone).endOf("day");
- const periodRangeEndDay = dayjs(periodEndDate).tz(organizerTimeZone).endOf("day");
- return (
- date.endOf("day").isBefore(dayjs().utcOffset(date.utcOffset())) ||
- date.endOf("day").isBefore(periodRangeStartDay) ||
- date.endOf("day").isAfter(periodRangeEndDay) ||
- !getSlots({
- inviteeDate: date,
- frequency: eventLength,
- minimumBookingNotice,
- workingHours,
- organizerTimeZone,
- }).length
- );
- }
-
- case "unlimited":
- default:
- return (
- date.endOf("day").isBefore(dayjs().utcOffset(date.utcOffset())) ||
- !getSlots({
- inviteeDate: date,
- frequency: eventLength,
- minimumBookingNotice,
- workingHours,
- organizerTimeZone,
- }).length
- );
- }
+ const date = browsingDate.startOf("day").date(day);
+ return (
+ isOutOfBounds(date, {
+ periodType,
+ periodStartDate,
+ periodEndDate,
+ periodCountCalendarDays,
+ periodDays,
+ }) ||
+ !getSlots({
+ inviteeDate: date,
+ frequency: eventLength,
+ minimumBookingNotice,
+ workingHours,
+ }).length
+ );
};
- const daysInMonth = inviteeDate().daysInMonth();
+ const daysInMonth = browsingDate.daysInMonth();
for (let i = 1; i <= daysInMonth; i++) {
days.push({ disabled: isDisabled(i), date: i });
}
- setDays(days);
+ return days;
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [selectedMonth]);
+ }, [browsingDate]);
+
+ if (!browsingDate) {
+ return ;
+ }
+
+ // Handle month changes
+ const incrementMonth = () => {
+ setBrowsingDate(browsingDate?.add(1, "month"));
+ };
+
+ const decrementMonth = () => {
+ setBrowsingDate(browsingDate?.subtract(1, "month"));
+ };
return (
-
- {t(inviteeDate().format("MMMM").toLowerCase())}
- {" "}
- {inviteeDate().format("YYYY")}
+ {month} {" "}
+ {year}
+ className={classNames("group mr-2 p-1", isFirstMonth && "text-gray-400 dark:text-gray-600")}
+ disabled={isFirstMonth}
+ data-testid="decrementMonth">
-
+
- {["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
- .sort((a, b) => (weekStart.startsWith(a) ? -1 : weekStart.startsWith(b) ? 1 : 0))
- .map((weekDay) => (
-
- {t(weekDay.toLowerCase()).substring(0, 3)}
-
- ))}
+ {weekdayNames(i18n.language, weekStart === "Sunday" ? 0 : 1, "short").map((weekDay) => (
+
+ {weekDay}
+
+ ))}
{days.map((day, idx) => (
@@ -182,19 +203,17 @@ function DatePicker({
) : (
onDatePicked(inviteeDate().date(day.date))}
+ onClick={() => onDatePicked(browsingDate.date(day.date))}
disabled={day.disabled}
className={classNames(
- "absolute w-full top-0 left-0 right-0 bottom-0 text-center mx-auto rounded-lg",
- "hover:border-4 hover:border-blue-600 dark:hover:border-white",
- day.disabled
- ? "text-gray-400 font-light hover:border-0 cursor-default"
- : "dark:text-white text-primary-500 font-medium",
- date && date.isSame(inviteeDate().date(day.date), "day")
- ? "bg-blue-600 text-white-important"
+ "absolute w-full top-0 left-0 right-0 bottom-0 rounded-sm text-center mx-auto",
+ "hover:border hover:border-brand dark:hover:border-white",
+ day.disabled ? "text-gray-400 font-light hover:border-0 cursor-default" : "font-medium",
+ date && date.isSame(browsingDate.date(day.date), "day")
+ ? "bg-brand text-brandcontrast"
: !day.disabled
- ? ""
- : ""
+ ? " bg-gray-100 dark:bg-gray-600 dark:text-white"
+ : ""
)}
data-testid="day"
data-disabled={day.disabled}>
diff --git a/components/booking/TimeOptions.tsx b/components/booking/TimeOptions.tsx
index 123746d8..8b6c79af 100644
--- a/components/booking/TimeOptions.tsx
+++ b/components/booking/TimeOptions.tsx
@@ -35,19 +35,19 @@ const TimeOptions: FC = (props) => {
};
return selectedTimeZone !== "" ? (
-
+
-
{t("time_options")}
+
{t("time_options")}
- {t("am_pm")}
+ {t("am_pm")}
{t("use_setting")}
@@ -60,7 +60,7 @@ const TimeOptions: FC = (props) => {
/>
- {t("24_h")}
+ {t("24_h")}
@@ -69,7 +69,7 @@ const TimeOptions: FC
= (props) => {
id="timeZone"
value={selectedTimeZone}
onChange={(tz: ITimezoneOption) => setSelectedTimeZone(tz.value)}
- className="mb-2 shadow-sm focus:ring-black focus:border-black mt-1 block w-full sm:text-sm border-gray-300 rounded-md"
+ className="block w-full mt-1 mb-2 border-gray-300 rounded-md shadow-sm focus:ring-black focus:border-brand sm:text-sm"
/>
) : null;
diff --git a/components/booking/pages/AvailabilityPage.tsx b/components/booking/pages/AvailabilityPage.tsx
index 1a712241..eb197cc3 100644
--- a/components/booking/pages/AvailabilityPage.tsx
+++ b/components/booking/pages/AvailabilityPage.tsx
@@ -15,6 +15,7 @@ import useTheme from "@lib/hooks/useTheme";
import { isBrandingHidden } from "@lib/isBrandingHidden";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
+import CustomBranding from "@components/CustomBranding";
import AvailableTimes from "@components/booking/AvailableTimes";
import DatePicker from "@components/booking/DatePicker";
import TimeOptions from "@components/booking/TimeOptions";
@@ -60,7 +61,6 @@ const AvailabilityPage = ({ profile, eventType, workingHours }: Props) => {
}, [telemetry]);
const changeDate = (newDate: Dayjs) => {
- telemetry.withJitsu((jitsu) => jitsu.track(telemetryEventTypes.dateSelected, collectPageParameters()));
router.replace(
{
query: {
@@ -92,9 +92,11 @@ const AvailabilityPage = ({ profile, eventType, workingHours }: Props) => {
+
{
user.name !== profile.name)
- .map((user) => ({
- title: user.name,
- image: user.avatar,
- }))
- )}
+ items={
+ [
+ { image: profile.image, alt: profile.name, title: profile.name },
+ ...eventType.users
+ .filter((user) => user.name !== profile.name)
+ .map((user) => ({
+ title: user.name,
+ image: user.avatar || undefined,
+ alt: user.name || undefined,
+ })),
+ ].filter((item) => !!item.image) as { image: string; alt?: string; title?: string }[]
+ }
size={9}
truncateAfter={5}
/>
@@ -151,14 +157,18 @@ const AvailabilityPage = ({ profile, eventType, workingHours }: Props) => {
(selectedDate ? "sm:w-1/3" : "sm:w-1/2")
}>
user.name !== profile.name)
- .map((user) => ({
- title: user.name,
- image: user.avatar,
- }))
- )}
+ items={
+ [
+ { image: profile.image, alt: profile.name, title: profile.name },
+ ...eventType.users
+ .filter((user) => user.name !== profile.name)
+ .map((user) => ({
+ title: user.name,
+ alt: user.name,
+ image: user.avatar,
+ })),
+ ].filter((item) => !!item.image) as { image: string; alt?: string; title?: string }[]
+ }
size={10}
truncateAfter={3}
/>
@@ -207,10 +217,10 @@ const AvailabilityPage = ({ profile, eventType, workingHours }: Props) => {
{selectedDate && (
import("@components/ui/form/PhoneInput"));
+
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 [guestToggle, setGuestToggle] = useState(props.booking && props.booking.attendees.length > 1);
- const [selectedLocation, setSelectedLocation] = useState(
- locations.length === 1 ? locations[0].type : ""
+ type Location = { type: LocationType; address?: string };
+ // it would be nice if Prisma at some point in the future allowed for Json; as of now this is not the case.
+ const locations: Location[] = useMemo(
+ () => (props.eventType.locations as Location[]) || [],
+ [props.eventType.locations]
);
- const telemetry = useTelemetry();
-
useEffect(() => {
- telemetry.withJitsu((jitsu) => jitsu.track(telemetryEventTypes.timeSelected, collectPageParameters()));
- }, []);
+ if (router.query.guest) {
+ setGuestToggle(true);
+ }
+ }, [router.query.guest]);
- function toggleGuestEmailInput() {
- setGuestToggle(!guestToggle);
- }
+ const telemetry = useTelemetry();
const locationInfo = (type: LocationType) => locations.find((location) => location.type === type);
@@ -75,114 +127,125 @@ 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;
- }
+ type BookingFormValues = {
+ name: string;
+ email: string;
+ notes?: string;
+ locationType?: LocationType;
+ guests?: string[];
+ phone?: string;
+ customInputs?: {
+ [key: string]: string;
+ };
+ };
- 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,
+ const defaultValues = () => {
+ if (!rescheduleUid) {
+ return {
+ name: (router.query.name as string) || "",
+ email: (router.query.email as string) || "",
+ notes: (router.query.notes as string) || "",
+ guests: ensureArray(router.query.guest) as string[],
+ customInputs: props.eventType.customInputs.reduce(
+ (customInputs, input) => ({
+ ...customInputs,
+ [input.id]: router.query[slugify(input.label)],
+ }),
+ {}
+ ),
};
- 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;
+ }
+ if (!props.booking || !props.booking.attendees.length) {
+ return {};
+ }
+ const primaryAttendee = props.booking.attendees[0];
+ if (!primaryAttendee) {
+ return {};
+ }
+ return {
+ name: primaryAttendee.name || "",
+ email: primaryAttendee.email || "",
+ guests: props.booking.attendees.slice(1).map((attendee) => attendee.email),
+ };
+ };
- case LocationType.InPerson:
- payload["location"] = locationInfo(selectedLocation).address;
- break;
+ const bookingForm = useForm({
+ defaultValues: defaultValues(),
+ });
- // Catches all other location types, such as Google Meet, Zoom etc.
- default:
- payload["location"] = selectedLocation;
- }
+ 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;
+ }
+ })(),
+ });
- 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 getLocationValue = (booking: Pick) => {
+ 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 query = stringify(params);
- let successUrl = `/success?${query}`;
+ const parseDate = (date: string | null) => {
+ if (!date) return "No date";
+ const parsedZone = parseZone(date);
+ if (!parsedZone?.isValid()) return "Invalid date";
+ const formattedTime = parsedZone?.format(timeFormat);
+ return formattedTime + ", " + dayjs(date).toDate().toLocaleString(i18n.language, { dateStyle: "full" });
+ };
- if (content?.paymentUid) {
- successUrl = createPaymentLink({
- paymentUid: content?.paymentUid,
- name: payload.name,
- date,
- absolute: false,
- });
- }
+ const bookEvent = (booking: BookingFormValues) => {
+ telemetry.withJitsu((jitsu) =>
+ jitsu.track(telemetryEventTypes.bookingConfirmed, collectPageParameters())
+ );
- await router.push(successUrl);
- } else {
- setLoading(false);
- setError(true);
- }
- };
+ // "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],
+ }),
+ {}
+ );
- event.preventDefault();
- book();
+ 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,
+ location: getLocationValue(booking.locationType ? booking : { locationType: selectedLocation }),
+ 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],
+ })),
+ });
};
- const bookingHandler = useCallback(_bookingHandler, [guestEmails]);
-
return (
@@ -200,24 +263,24 @@ const BookingPage = (props: BookingPageProps) => {
-
-
+
+
{isReady && (
-
+
user.name !== props.profile.name)
.map((user) => ({
- image: user.avatar,
- title: user.name,
+ image: user.avatar || "",
+ alt: user.name || "",
}))
)}
/>
-
+
{props.profile.name}
@@ -242,30 +305,30 @@ const BookingPage = (props: BookingPageProps) => {
{selectedLocation === LocationType.InPerson && (
- {locationInfo(selectedLocation).address}
+ {getLocationValue({ locationType: selectedLocation })}
)}
- {parseZone(date).format(timeFormat + ", dddd DD MMMM YYYY")}
+ {parseDate(date)}
{props.eventType.description}
@@ -293,16 +352,14 @@ const BookingPage = (props: BookingPageProps) => {
{t("location")}
- {locations.map((location) => (
-
+ {locations.map((location, i) => (
+
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}
/>
{locationLabels[location.type]}
@@ -323,74 +380,78 @@ const BookingPage = (props: BookingPageProps) => {
)}
- {props.eventType.customInputs &&
- props.eventType.customInputs
- .sort((a, b) => a.id - b.id)
- .map((input) => (
-
- {input.type !== EventTypeCustomInputType.BOOL && (
+ {props.eventType.customInputs
+ .sort((a, b) => a.id - b.id)
+ .map((input) => (
+
+ {input.type !== EventTypeCustomInputType.BOOL && (
+
+ {input.label}
+
+ )}
+ {input.type === EventTypeCustomInputType.TEXTLONG && (
+
+ )}
+ {input.type === EventTypeCustomInputType.TEXT && (
+
+ )}
+ {input.type === EventTypeCustomInputType.NUMBER && (
+
+ )}
+ {input.type === EventTypeCustomInputType.BOOL && (
+
- ))}
+
+ )}
+
+ ))}
{!props.eventType.disableGuests && (
{!guestToggle && (
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">
+ {/* */}
{t("additional_guests")}
)}
@@ -401,27 +462,31 @@ const BookingPage = (props: BookingPageProps) => {
className="block mb-1 text-sm font-medium text-gray-700 dark:text-white">
{t("guests")}
-
{
- setGuestEmails(_emails);
- }}
- getLabel={(
- email: string,
- index: number,
- removeEmail: (index: number) => void
- ) => {
- return (
-
- {email}
- removeEmail(index)}>
- ×
-
-
- );
- }}
+ (
+ void
+ ) => {
+ return (
+
+ {email}
+ removeEmail(index)}>
+ ×
+
+
+ );
+ }}
+ />
+ )}
/>
)}
@@ -434,25 +499,23 @@ const BookingPage = (props: BookingPageProps) => {
{t("additional_notes")}
- {/* TODO: add styling props to and get rid of btn-primary */}
-
+
{rescheduleUid ? t("reschedule") : t("confirm")}
router.back()}>
{t("cancel")}
-
- {error && (
+
+ {mutation.isError && (
diff --git a/components/dialog/ConfirmationDialogContent.tsx b/components/dialog/ConfirmationDialogContent.tsx
index ec1097c5..cbc648ef 100644
--- a/components/dialog/ConfirmationDialogContent.tsx
+++ b/components/dialog/ConfirmationDialogContent.tsx
@@ -1,7 +1,7 @@
import { ExclamationIcon } from "@heroicons/react/outline";
import { CheckIcon } from "@heroicons/react/solid";
import * as DialogPrimitive from "@radix-ui/react-dialog";
-import React, { PropsWithChildren } from "react";
+import React, { PropsWithChildren, ReactNode } from "react";
import { useLocale } from "@lib/hooks/useLocale";
@@ -9,6 +9,7 @@ import { DialogClose, DialogContent } from "@components/Dialog";
import { Button } from "@components/ui/Button";
export type ConfirmationDialogContentProps = {
+ confirmBtn?: ReactNode;
confirmBtnText?: string;
cancelBtnText?: string;
onConfirm?: (event: React.MouseEvent
) => void;
@@ -21,6 +22,7 @@ export default function ConfirmationDialogContent(props: PropsWithChildren
{variety === "danger" && (
-
+
)}
{variety === "warning" && (
-
+
)}
{variety === "success" && (
-
)}
-
+
{title}
-
+
{children}
- {confirmBtnText}
+ {confirmBtn || {confirmBtnText} }
{cancelBtnText}
diff --git a/components/eventtype/CreateEventType.tsx b/components/eventtype/CreateEventType.tsx
new file mode 100644
index 00000000..9dc38cc6
--- /dev/null
+++ b/components/eventtype/CreateEventType.tsx
@@ -0,0 +1,250 @@
+import { ChevronDownIcon, PlusIcon } from "@heroicons/react/solid";
+import { zodResolver } from "@hookform/resolvers/zod/dist/zod";
+import { SchedulingType } from "@prisma/client";
+import { useRouter } from "next/router";
+import { createEventTypeInput } from "prisma/zod/eventtypeCustom";
+import React, { useEffect } from "react";
+import { useForm } from "react-hook-form";
+import type { z } from "zod";
+
+import { HttpError } from "@lib/core/http/error";
+import { useLocale } from "@lib/hooks/useLocale";
+import { useToggleQuery } from "@lib/hooks/useToggleQuery";
+import showToast from "@lib/notification";
+import { trpc } from "@lib/trpc";
+
+import { Dialog, DialogClose, DialogContent } from "@components/Dialog";
+import { Form, InputLeading, TextAreaField, TextField } from "@components/form/fields";
+import { Alert } from "@components/ui/Alert";
+import Avatar from "@components/ui/Avatar";
+import { Button } from "@components/ui/Button";
+import Dropdown, {
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@components/ui/Dropdown";
+import * as RadioArea from "@components/ui/form/radio-area";
+
+// this describes the uniform data needed to create a new event type on Profile or Team
+interface EventTypeParent {
+ teamId: number | null | undefined; // if undefined, then it's a profile
+ name?: string | null;
+ slug?: string | null;
+ image?: string | null;
+}
+
+interface Props {
+ // set true for use on the team settings page
+ canAddEvents: boolean;
+ // set true when in use on the team settings page
+ isIndividualTeam?: boolean;
+ // EventTypeParent can be a profile (as first option) or a team for the rest.
+ options: EventTypeParent[];
+}
+
+export default function CreateEventTypeButton(props: Props) {
+ const { t } = useLocale();
+ const router = useRouter();
+ const modalOpen = useToggleQuery("new");
+
+ // URL encoded params
+ const teamId: number | undefined =
+ typeof router.query.teamId === "string" && router.query.teamId
+ ? parseInt(router.query.teamId)
+ : undefined;
+ const pageSlug = router.query.eventPage || props.options[0].slug;
+ const hasTeams = !!props.options.find((option) => option.teamId);
+
+ const form = useForm>({
+ resolver: zodResolver(createEventTypeInput),
+ defaultValues: { length: 15 },
+ });
+ const { setValue, watch, register } = form;
+
+ useEffect(() => {
+ const subscription = watch((value, { name, type }) => {
+ if (name === "title" && type === "change") {
+ if (value.title) setValue("slug", value.title.replace(/\s+/g, "-").toLowerCase());
+ else setValue("slug", "");
+ }
+ });
+ return () => subscription.unsubscribe();
+ }, [watch, setValue]);
+
+ const createMutation = trpc.useMutation("viewer.eventTypes.create", {
+ onSuccess: async ({ eventType }) => {
+ await router.push("/event-types/" + eventType.id);
+ showToast(t("event_type_created_successfully", { eventTypeTitle: eventType.title }), "success");
+ },
+ onError: (err) => {
+ if (err instanceof HttpError) {
+ const message = `${err.statusCode}: ${err.message}`;
+ showToast(message, "error");
+ }
+ },
+ });
+
+ // inject selection data into url for correct router history
+ const openModal = (option: EventTypeParent) => {
+ // setTimeout fixes a bug where the url query params are removed immediately after opening the modal
+ setTimeout(() => {
+ router.push(
+ {
+ pathname: router.pathname,
+ query: {
+ ...router.query,
+ new: "1",
+ eventPage: option.slug,
+ teamId: option.teamId || undefined,
+ },
+ },
+ undefined,
+ { shallow: true }
+ );
+ });
+ };
+
+ // remove url params after close modal to reset state
+ const closeModal = () => {
+ router.replace({
+ pathname: router.pathname,
+ query: { id: router.query.id || undefined },
+ });
+ };
+
+ return (
+ {
+ if (!isOpen) closeModal();
+ }}>
+ {!hasTeams || props.isIndividualTeam ? (
+ openModal(props.options[0])}
+ data-testid="new-event-type"
+ StartIcon={PlusIcon}
+ {...(props.canAddEvents ? { href: modalOpen.hrefOn } : { disabled: true })}>
+ {t("new_event_type_btn")}
+
+ ) : (
+
+
+ {t("new_event_type_btn")}
+
+
+ {t("new_event_subtitle")}
+
+ {props.options.map((option) => (
+ openModal(option)}>
+
+ {option.name ? option.name : option.slug}
+
+ ))}
+
+
+ )}
+
+
+
+
+ {teamId ? t("add_new_team_event_type") : t("add_new_event_type")}
+
+
+
{t("new_event_type_to_book_description")}
+
+
+
+
+
+ );
+}
diff --git a/components/eventtype/EventTypeDescription.tsx b/components/eventtype/EventTypeDescription.tsx
index 73e4d4b2..87510fef 100644
--- a/components/eventtype/EventTypeDescription.tsx
+++ b/components/eventtype/EventTypeDescription.tsx
@@ -32,8 +32,9 @@ export const EventTypeDescription = ({ eventType, className }: EventTypeDescript
<>
{eventType.description && (
-
+
{eventType.description.substring(0, 100)}
+ {eventType.description.length > 100 && "..."}
)}
diff --git a/components/form/fields.tsx b/components/form/fields.tsx
index f4b27cc8..e3eb99cb 100644
--- a/components/form/fields.tsx
+++ b/components/form/fields.tsx
@@ -1,17 +1,23 @@
import { useId } from "@radix-ui/react-id";
-import { forwardRef, ReactNode } from "react";
-import { FormProvider, UseFormReturn } from "react-hook-form";
+import { forwardRef, ReactElement, ReactNode, Ref } from "react";
+import { FieldValues, FormProvider, SubmitHandler, useFormContext, UseFormReturn } from "react-hook-form";
import classNames from "@lib/classNames";
+import { getErrorFromUnknown } from "@lib/errors";
+import { useLocale } from "@lib/hooks/useLocale";
+import showToast from "@lib/notification";
+
+import { Alert } from "@components/ui/Alert";
type InputProps = Omit & { name: string };
+
export const Input = forwardRef(function Input(props, ref) {
return (
@@ -26,42 +32,172 @@ export function Label(props: JSX.IntrinsicElements["label"]) {
);
}
-export const TextField = forwardRef<
- HTMLInputElement,
- {
- label: ReactNode;
- } & React.ComponentProps & {
- labelProps?: React.ComponentProps;
- }
->(function TextField(props, ref) {
+export function InputLeading(props: JSX.IntrinsicElements["div"]) {
+ return (
+
+ {props.children}
+
+ );
+}
+
+type InputFieldProps = {
+ label?: ReactNode;
+ addOnLeading?: ReactNode;
+} & React.ComponentProps & {
+ labelProps?: React.ComponentProps;
+ };
+
+const InputField = forwardRef(function InputField(props, ref) {
const id = useId();
- const { label, ...passThroughToInput } = props;
+ const { t } = useLocale();
+ const methods = useFormContext();
+ const {
+ label = t(props.name),
+ labelProps,
+ placeholder = t(props.name + "_placeholder") !== props.name + "_placeholder"
+ ? t(props.name + "_placeholder")
+ : "",
+ className,
+ addOnLeading,
+ ...passThrough
+ } = props;
+ return (
+
+ {!!props.name && (
+
+ {label}
+
+ )}
+ {addOnLeading ? (
+
+ {addOnLeading}
+
+
+ ) : (
+
+ )}
+ {methods?.formState?.errors[props.name] && (
+
+ )}
+
+ );
+});
+
+export const TextField = forwardRef(function TextField(props, ref) {
+ return ;
+});
+
+export const PasswordField = forwardRef(function PasswordField(
+ props,
+ ref
+) {
+ return ;
+});
- // TODO: use `useForm()` from RHF and get error state here too!
+export const EmailInput = forwardRef(function EmailInput(props, ref) {
+ return (
+
+ );
+});
+
+export const EmailField = forwardRef(function EmailField(props, ref) {
+ return ;
+});
+
+type TextAreaProps = Omit & { name: string };
+
+export const TextArea = forwardRef(function TextAreaInput(props, ref) {
+ return (
+
+ );
+});
+
+type TextAreaFieldProps = {
+ label?: ReactNode;
+} & React.ComponentProps & {
+ labelProps?: React.ComponentProps;
+ };
+
+export const TextAreaField = forwardRef(function TextField(
+ props,
+ ref
+) {
+ const id = useId();
+ const { t } = useLocale();
+ const methods = useFormContext();
+ const {
+ label = t(props.name as string),
+ labelProps,
+ placeholder = t(props.name + "_placeholder") !== props.name + "_placeholder"
+ ? t(props.name + "_placeholder")
+ : "",
+ ...passThrough
+ } = props;
return (
-
- {label}
-
-
+ {!!props.name && (
+
+ {label}
+
+ )}
+
+ {methods?.formState?.errors[props.name] && (
+
+ )}
);
});
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
-export const Form = forwardRef } & JSX.IntrinsicElements["form"]>(
- function Form(props, ref) {
- const { form, ...passThrough } = props;
-
- return (
-
-
-
- );
- }
-);
+type FormProps = { form: UseFormReturn; handleSubmit: SubmitHandler } & Omit<
+ JSX.IntrinsicElements["form"],
+ "onSubmit"
+>;
+
+const PlainForm = (props: FormProps, ref: Ref) => {
+ const { form, handleSubmit, ...passThrough } = props;
+
+ return (
+
+
+
+ );
+};
+
+export const Form = forwardRef(PlainForm) as (
+ p: FormProps & { ref?: Ref }
+) => ReactElement;
export function FieldsetLegend(props: JSX.IntrinsicElements["legend"]) {
return (
diff --git a/components/integrations/CalendarListContainer.tsx b/components/integrations/CalendarListContainer.tsx
index 14244690..65dad1b8 100644
--- a/components/integrations/CalendarListContainer.tsx
+++ b/components/integrations/CalendarListContainer.tsx
@@ -2,9 +2,11 @@ import React, { Fragment } from "react";
import { useMutation } from "react-query";
import { QueryCell } from "@lib/QueryCell";
+import { useLocale } from "@lib/hooks/useLocale";
import showToast from "@lib/notification";
import { trpc } from "@lib/trpc";
+import DestinationCalendarSelector from "@components/DestinationCalendarSelector";
import { List } from "@components/List";
import { ShellSubHeading } from "@components/Shell";
import { Alert } from "@components/ui/Alert";
@@ -90,70 +92,77 @@ function CalendarSwitch(props: {
}
function ConnectedCalendarsList(props: Props) {
+ const { t } = useLocale();
const query = trpc.useQuery(["viewer.connectedCalendars"], { suspense: true });
return (
null}
- success={({ data }) => (
-
- {data.map((item) => (
-
- {item.calendars ? (
- (
-
- Disconnect
-
- )}
- onOpenChange={props.onChanged}
- />
- }>
-
- {item.calendars.map((cal) => (
- {
+ if (!data.connectedCalendars.length) {
+ return null;
+ }
+ return (
+
+ {data.connectedCalendars.map((item) => (
+
+ {item.calendars ? (
+ (
+
+ {t("disconnect")}
+
+ )}
+ onOpenChange={props.onChanged}
/>
- ))}
-
-
- ) : (
- (
-
- Disconnect
-
- )}
- onOpenChange={() => props.onChanged()}
- />
- }
- />
- )}
-
- ))}
-
- )}
+ }>
+
+ {item.calendars.map((cal) => (
+
+ ))}
+
+
+ ) : (
+ (
+
+ Disconnect
+
+ )}
+ onOpenChange={() => props.onChanged()}
+ />
+ }
+ />
+ )}
+
+ ))}
+
+ );
+ }}
/>
);
}
function CalendarList(props: Props) {
+ const { t } = useLocale();
const query = trpc.useQuery(["viewer.integrations"]);
return (
@@ -169,8 +178,8 @@ function CalendarList(props: Props) {
(
-
- Connect
+
+ {t("connect")}
)}
onOpenChange={() => props.onChanged()}
@@ -184,6 +193,7 @@ function CalendarList(props: Props) {
);
}
export function CalendarListContainer(props: { heading?: false }) {
+ const { t } = useLocale();
const { heading = true } = props;
const utils = trpc.useContext();
const onChanged = () =>
@@ -192,26 +202,36 @@ export function CalendarListContainer(props: { heading?: false }) {
utils.invalidateQueries(["viewer.connectedCalendars"]),
]);
const query = trpc.useQuery(["viewer.connectedCalendars"]);
+ const mutation = trpc.useMutation("viewer.setDestinationCalendar");
+
return (
<>
{heading && (
}
- subtitle={
- <>
- Configure how your links integrate with your calendars.
-
- You can override these settings on a per event basis.
- >
+ className="mt-10 mb-0"
+ title={
+
+ }
+ subtitle={t("configure_how_your_event_types_interact")}
+ actions={
+
+
+
}
/>
)}
- {!!query.data?.length && (
+ {!!query.data?.connectedCalendars.length && (
}
+ title={ }
/>
)}
diff --git a/components/integrations/ConnectIntegrations.tsx b/components/integrations/ConnectIntegrations.tsx
index 3e96f7e7..3a30c9a5 100644
--- a/components/integrations/ConnectIntegrations.tsx
+++ b/components/integrations/ConnectIntegrations.tsx
@@ -1,8 +1,9 @@
+import type { IntegrationOAuthCallbackState } from "pages/api/integrations/types";
import { useState } from "react";
import { useMutation } from "react-query";
-import { AddAppleIntegrationModal } from "@lib/integrations/Apple/components/AddAppleIntegration";
-import { AddCalDavIntegrationModal } from "@lib/integrations/CalDav/components/AddCalDavIntegration";
+import { AddAppleIntegrationModal } from "@lib/integrations/calendar/components/AddAppleIntegration";
+import { AddCalDavIntegrationModal } from "@lib/integrations/calendar/components/AddCalDavIntegration";
import { ButtonBaseProps } from "@components/ui/Button";
@@ -13,8 +14,14 @@ export default function ConnectIntegration(props: {
}) {
const { type } = props;
const [isLoading, setIsLoading] = useState(false);
+
const mutation = useMutation(async () => {
- const res = await fetch("/api/integrations/" + type.replace("_", "") + "/add");
+ const state: IntegrationOAuthCallbackState = {
+ returnTo: location.pathname + location.search,
+ };
+ const stateStr = encodeURIComponent(JSON.stringify(state));
+ const searchParams = `?state=${stateStr}`;
+ const res = await fetch("/api/integrations/" + type.replace("_", "") + "/add" + searchParams);
if (!res.ok) {
throw new Error("Something went wrong");
}
diff --git a/components/pages/eventtypes/CustomInputTypeForm.tsx b/components/pages/eventtypes/CustomInputTypeForm.tsx
index ca25567c..37043849 100644
--- a/components/pages/eventtypes/CustomInputTypeForm.tsx
+++ b/components/pages/eventtypes/CustomInputTypeForm.tsx
@@ -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;
onCancel: () => void;
@@ -82,7 +84,7 @@ const CustomInputTypeForm: FC = (props) => {
@@ -114,12 +116,10 @@ const CustomInputTypeForm: FC = (props) => {
{...register("id", { valueAsNumber: true })}
/>
-
- {t("save")}
-
-
+ {t("save")}
+
{t("cancel")}
-
+
);
diff --git a/components/security/ChangePasswordSection.tsx b/components/security/ChangePasswordSection.tsx
index 492f132c..49b3867a 100644
--- a/components/security/ChangePasswordSection.tsx
+++ b/components/security/ChangePasswordSection.tsx
@@ -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("");
@@ -72,7 +74,7 @@ const ChangePasswordSection = () => {
name="current_password"
id="current_password"
required
- className="shadow-sm focus:ring-black focus:border-black block w-full sm:text-sm border-gray-300 rounded-sm"
+ className="shadow-sm focus:ring-black focus:border-brand block w-full sm:text-sm border-gray-300 rounded-sm"
placeholder={t("your_old_password")}
/>
@@ -89,7 +91,7 @@ const ChangePasswordSection = () => {
value={newPassword}
required
onInput={(e) => setNewPassword(e.currentTarget.value)}
- className="shadow-sm focus:ring-black focus:border-black block w-full sm:text-sm border-gray-300 rounded-sm"
+ className="shadow-sm focus:ring-black focus:border-brand block w-full sm:text-sm border-gray-300 rounded-sm"
placeholder={t("super_secure_new_password")}
/>
@@ -97,11 +99,7 @@ const ChangePasswordSection = () => {
{errorMessage && {errorMessage}
}
-
- {t("save")}
-
+ {t("save")}
diff --git a/components/security/TwoFactorModalHeader.tsx b/components/security/TwoFactorModalHeader.tsx
index 73c3632e..b2c763fe 100644
--- a/components/security/TwoFactorModalHeader.tsx
+++ b/components/security/TwoFactorModalHeader.tsx
@@ -4,11 +4,11 @@ import React from "react";
const TwoFactorModalHeader = ({ title, description }: { title: string; description: string }) => {
return (
-
+
-
+
{title}
{description}
diff --git a/components/seo/head-seo.tsx b/components/seo/head-seo.tsx
index d7abcc76..9ee4a10d 100644
--- a/components/seo/head-seo.tsx
+++ b/components/seo/head-seo.tsx
@@ -10,8 +10,8 @@ export type HeadSeoProps = {
description: string;
siteName?: string;
name?: string;
- avatar?: string;
url?: string;
+ username?: string;
canonical?: string;
nextSeoProps?: NextSeoProps;
};
@@ -39,9 +39,6 @@ const buildSeoMeta = (pageProps: {
images: [
{
url: image,
- //width: 1077,
- //height: 565,
- //alt: "Alt image"
},
],
},
@@ -66,11 +63,14 @@ const buildSeoMeta = (pageProps: {
};
};
-const constructImage = (name: string, avatar: string, description: string): string => {
+const constructImage = (name: string, description: string, username: string): string => {
return (
encodeURIComponent("Meet **" + name + "**
" + description).replace(/'/g, "%27") +
".png?md=1&images=https%3A%2F%2Fcal.com%2Flogo-white.svg&images=" +
- encodeURIComponent(avatar)
+ (process.env.NEXT_PUBLIC_APP_URL || process.env.BASE_URL) +
+ "/" +
+ username +
+ "/avatar.png"
);
};
@@ -82,18 +82,31 @@ export const HeadSeo: React.FC
= (props) =>
title,
description,
name = null,
- avatar = null,
+ username = null,
siteName,
canonical = defaultUrl,
nextSeoProps = {},
} = props;
+ const truncatedDescription = description.length > 24 ? description.substring(0, 23) + "..." : description;
const pageTitle = title + " | Cal.gatego.io";
- let seoObject = buildSeoMeta({ title: pageTitle, image, description, canonical, siteName });
+ let seoObject = buildSeoMeta({
+ title: pageTitle,
+ image,
+ description: truncatedDescription,
+ canonical,
+ siteName,
+ });
- if (name && avatar) {
- const pageImage = getSeoImage("ogImage") + constructImage(name, avatar, description);
- seoObject = buildSeoMeta({ title: pageTitle, description, image: pageImage, canonical, siteName });
+ if (name && username) {
+ const pageImage = getSeoImage("ogImage") + constructImage(name, truncatedDescription, username);
+ seoObject = buildSeoMeta({
+ title: pageTitle,
+ description: truncatedDescription,
+ image: pageImage,
+ canonical,
+ siteName,
+ });
}
const seoProps: NextSeoProps = merge(nextSeoProps, seoObject);
diff --git a/components/team/EditTeam.tsx b/components/team/EditTeam.tsx
deleted file mode 100644
index 709208d4..00000000
--- a/components/team/EditTeam.tsx
+++ /dev/null
@@ -1,292 +0,0 @@
-import { ArrowLeftIcon, PlusIcon, TrashIcon } from "@heroicons/react/outline";
-import React, { useEffect, useRef, useState } from "react";
-
-import { useLocale } from "@lib/hooks/useLocale";
-import { Member } from "@lib/member";
-import showToast from "@lib/notification";
-import { Team } from "@lib/team";
-
-import { Dialog, DialogTrigger } from "@components/Dialog";
-import ImageUploader from "@components/ImageUploader";
-import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
-import MemberInvitationModal from "@components/team/MemberInvitationModal";
-import Avatar from "@components/ui/Avatar";
-import Button from "@components/ui/Button";
-import { UsernameInput } from "@components/ui/UsernameInput";
-import ErrorAlert from "@components/ui/alerts/Error";
-
-import MemberList from "./MemberList";
-
-export default function EditTeam(props: { team: Team | undefined | null; onCloseEdit: () => void }) {
- const [members, setMembers] = useState([]);
-
- const nameRef = useRef() as React.MutableRefObject;
- const teamUrlRef = useRef() as React.MutableRefObject;
- const descriptionRef = useRef() as React.MutableRefObject;
- const hideBrandingRef = useRef() as React.MutableRefObject;
- const logoRef = useRef() as React.MutableRefObject;
- const [hasErrors, setHasErrors] = useState(false);
- const [showMemberInvitationModal, setShowMemberInvitationModal] = useState(false);
- const [inviteModalTeam, setInviteModalTeam] = useState();
- const [errorMessage, setErrorMessage] = useState("");
- const [imageSrc, setImageSrc] = useState("");
- const { t } = useLocale();
-
- const loadMembers = () =>
- fetch("/api/teams/" + props.team?.id + "/membership")
- .then((res) => res.json())
- .then((data) => setMembers(data.members));
-
- useEffect(() => {
- loadMembers();
- }, []);
-
- const deleteTeam = () => {
- return fetch("/api/teams/" + props.team?.id, {
- method: "DELETE",
- }).then(props.onCloseEdit());
- };
-
- const onRemoveMember = (member: Member) => {
- return fetch("/api/teams/" + props.team?.id + "/membership", {
- method: "DELETE",
- body: JSON.stringify({ userId: member.id }),
- headers: {
- "Content-Type": "application/json",
- },
- }).then(loadMembers);
- };
-
- const onInviteMember = (team: Team | null | undefined) => {
- setShowMemberInvitationModal(true);
- setInviteModalTeam(team);
- };
-
- const handleError = async (resp: Response) => {
- if (!resp.ok) {
- const error = await resp.json();
- throw new Error(error.message);
- }
- };
-
- async function updateTeamHandler(event) {
- event.preventDefault();
-
- const enteredUsername = teamUrlRef?.current?.value.toLowerCase();
- const enteredName = nameRef?.current?.value;
- const enteredDescription = descriptionRef?.current?.value;
- const enteredLogo = logoRef?.current?.value;
- const enteredHideBranding = hideBrandingRef?.current?.checked;
-
- // TODO: Add validation
-
- await fetch("/api/teams/" + props.team?.id + "/profile", {
- method: "PATCH",
- body: JSON.stringify({
- username: enteredUsername,
- name: enteredName,
- description: enteredDescription,
- logo: enteredLogo,
- hideBranding: enteredHideBranding,
- }),
- headers: {
- "Content-Type": "application/json",
- },
- })
- .then(handleError)
- .then(() => {
- showToast(t("your_team_updated_successfully"), "success");
- setHasErrors(false); // dismiss any open errors
- })
- .catch((err) => {
- setHasErrors(true);
- setErrorMessage(err.message);
- });
- }
-
- const onMemberInvitationModalExit = () => {
- loadMembers();
- setShowMemberInvitationModal(false);
- };
-
- const handleLogoChange = (newLogo: string) => {
- logoRef.current.value = newLogo;
- const nativeInputValueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement?.prototype, "value").set;
- nativeInputValueSetter?.call(logoRef.current, newLogo);
- const ev2 = new Event("input", { bubbles: true });
- logoRef?.current?.dispatchEvent(ev2);
- updateTeamHandler(ev2);
- setImageSrc(newLogo);
- };
-
- return (
-
-
-
- props.onCloseEdit()}>
- {t("back")}
-
-
-
-
-
{props.team?.name}
-
-
{t("manage_your_team")}
-
-
-
-
-
{t("profile")}
-
- {showMemberInvitationModal && (
-
- )}
-
-
- );
-}
diff --git a/components/team/MemberChangeRoleModal.tsx b/components/team/MemberChangeRoleModal.tsx
new file mode 100644
index 00000000..f28c28f7
--- /dev/null
+++ b/components/team/MemberChangeRoleModal.tsx
@@ -0,0 +1,86 @@
+import { MembershipRole } from "@prisma/client";
+import { useState } from "react";
+import React, { SyntheticEvent } from "react";
+
+import { useLocale } from "@lib/hooks/useLocale";
+import { trpc } from "@lib/trpc";
+
+import Button from "@components/ui/Button";
+import ModalContainer from "@components/ui/ModalContainer";
+
+export default function MemberChangeRoleModal(props: {
+ memberId: number;
+ teamId: number;
+ initialRole: MembershipRole;
+ onExit: () => void;
+}) {
+ const [role, setRole] = useState(props.initialRole || MembershipRole.MEMBER);
+ const [errorMessage, setErrorMessage] = useState("");
+ const { t } = useLocale();
+ const utils = trpc.useContext();
+
+ const changeRoleMutation = trpc.useMutation("viewer.teams.changeMemberRole", {
+ async onSuccess() {
+ await utils.invalidateQueries(["viewer.teams.get"]);
+ props.onExit();
+ },
+ async onError(err) {
+ setErrorMessage(err.message);
+ },
+ });
+
+ function changeRole(e: SyntheticEvent) {
+ e.preventDefault();
+
+ changeRoleMutation.mutate({
+ teamId: props.teamId,
+ memberId: props.memberId,
+ role,
+ });
+ }
+
+ return (
+
+ <>
+
+
+
+ {t("change_member_role")}
+
+
+
+
+
+
+ {t("role")}
+
+ setRole(e.target.value as MembershipRole)}
+ id="role"
+ className="block w-full mt-1 border-gray-300 rounded-md shadow-sm focus:ring-black focus:border-brand sm:text-sm">
+ {t("member")}
+ {t("admin")}
+ {/*{t("owner")} - needs dialog to confirm change of ownership */}
+
+
+
+ {errorMessage && (
+
+ Error:
+ {errorMessage}
+
+ )}
+
+
+ {t("save")}
+
+
+ {t("cancel")}
+
+
+
+ >
+
+ );
+}
diff --git a/components/team/MemberInvitationModal.tsx b/components/team/MemberInvitationModal.tsx
index f9a602f6..30f43f22 100644
--- a/components/team/MemberInvitationModal.tsx
+++ b/components/team/MemberInvitationModal.tsx
@@ -1,58 +1,50 @@
-import { UsersIcon } from "@heroicons/react/outline";
+import { UserIcon } from "@heroicons/react/outline";
+import { MembershipRole } from "@prisma/client";
import { useState } from "react";
import React, { SyntheticEvent } from "react";
import { useLocale } from "@lib/hooks/useLocale";
-import { Team } from "@lib/team";
+import { TeamWithMembers } from "@lib/queries/teams";
+import { trpc } from "@lib/trpc";
+import { EmailInput } from "@components/form/fields";
import Button from "@components/ui/Button";
-export default function MemberInvitationModal(props: { team: Team | undefined | null; onExit: () => void }) {
+export default function MemberInvitationModal(props: { team: TeamWithMembers | null; onExit: () => void }) {
const [errorMessage, setErrorMessage] = useState("");
const { t, i18n } = useLocale();
+ const utils = trpc.useContext();
- const handleError = async (res: Response) => {
- const responseData = await res.json();
+ const inviteMemberMutation = trpc.useMutation("viewer.teams.inviteMember", {
+ async onSuccess() {
+ await utils.invalidateQueries(["viewer.teams.get"]);
+ props.onExit();
+ },
+ async onError(err) {
+ setErrorMessage(err.message);
+ },
+ });
- if (res.ok === false) {
- setErrorMessage(responseData.message);
- throw new Error(responseData.message);
- }
-
- return responseData;
- };
-
- const inviteMember = (e: SyntheticEvent) => {
+ function inviteMember(e: SyntheticEvent) {
e.preventDefault();
+ if (!props.team) return;
const target = e.target as typeof e.target & {
elements: {
- role: { value: string };
+ role: { value: MembershipRole };
inviteUser: { value: string };
sendInviteEmail: { checked: boolean };
};
};
- const payload = {
+ inviteMemberMutation.mutate({
+ teamId: props.team.id,
language: i18n.language,
role: target.elements["role"].value,
usernameOrEmail: target.elements["inviteUser"].value,
sendEmailInvitation: target.elements["sendInviteEmail"].checked,
- };
-
- return fetch("/api/teams/" + props?.team?.id + "/invite", {
- method: "POST",
- body: JSON.stringify(payload),
- headers: {
- "Content-Type": "application/json",
- },
- })
- .then(handleError)
- .then(props.onExit)
- .catch(() => {
- // do nothing.
- });
- };
+ });
+ }
return (
-
-
+
+
@@ -89,13 +81,13 @@ export default function MemberInvitationModal(props: { team: Team | undefined |
{t("email_or_username")}
-
@@ -104,9 +96,9 @@ export default function MemberInvitationModal(props: { team: Team | undefined |
+ className="block w-full mt-1 border-gray-300 rounded-md shadow-sm focus:ring-black focus:border-brand sm:text-sm">
{t("member")}
- {t("owner")}
+ {t("admin")}
@@ -116,7 +108,7 @@ export default function MemberInvitationModal(props: { team: Team | undefined |
name="sendInviteEmail"
defaultChecked
id="sendInviteEmail"
- className="text-black border-gray-300 rounded-md shadow-sm focus:ring-black focus:border-black sm:text-sm"
+ className="text-black border-gray-300 rounded-md shadow-sm focus:ring-black focus:border-brand sm:text-sm"
/>
@@ -133,7 +125,7 @@ export default function MemberInvitationModal(props: { team: Team | undefined |
)}
-
+
{t("invite")}
diff --git a/components/team/MemberList.tsx b/components/team/MemberList.tsx
index 40aa6138..f76c3e08 100644
--- a/components/team/MemberList.tsx
+++ b/components/team/MemberList.tsx
@@ -1,30 +1,20 @@
-import { Member } from "@lib/member";
+import { inferQueryOutput } from "@lib/trpc";
import MemberListItem from "./MemberListItem";
-export default function MemberList(props: {
- members: Member[];
- onRemoveMember: (text: Member) => void;
- onChange: (text: string) => void;
-}) {
- const selectAction = (action: string, member: Member) => {
- switch (action) {
- case "remove":
- props.onRemoveMember(member);
- break;
- }
- };
+interface Props {
+ team: inferQueryOutput<"viewer.teams.get">;
+ members: inferQueryOutput<"viewer.teams.get">["members"];
+}
+
+export default function MemberList(props: Props) {
+ if (!props.members.length) return <>>;
return (
-
- {props.members.map((member) => (
- selectAction(action, member)}
- />
+
+ {props.members?.map((member) => (
+
))}
diff --git a/components/team/MemberListItem.tsx b/components/team/MemberListItem.tsx
index 87f965d8..576f048f 100644
--- a/components/team/MemberListItem.tsx
+++ b/components/team/MemberListItem.tsx
@@ -1,104 +1,182 @@
-import { DotsHorizontalIcon, UserRemoveIcon } from "@heroicons/react/outline";
-import { useState } from "react";
+import { UserRemoveIcon, PencilIcon } from "@heroicons/react/outline";
+import { ClockIcon, ExternalLinkIcon, DotsHorizontalIcon } from "@heroicons/react/solid";
+import Link from "next/link";
+import React, { useState } from "react";
+import TeamAvailabilityModal from "@ee/components/team/availability/TeamAvailabilityModal";
+
+import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar";
import { useLocale } from "@lib/hooks/useLocale";
-import { Member } from "@lib/member";
+import showToast from "@lib/notification";
+import { trpc, inferQueryOutput } from "@lib/trpc";
import { Dialog, DialogTrigger } from "@components/Dialog";
+import { Tooltip } from "@components/Tooltip";
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
import Avatar from "@components/ui/Avatar";
import Button from "@components/ui/Button";
+import ModalContainer from "@components/ui/ModalContainer";
+
+import Dropdown, {
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "../ui/Dropdown";
+import MemberChangeRoleModal from "./MemberChangeRoleModal";
+import TeamRole from "./TeamRole";
+import { MembershipRole } from ".prisma/client";
-import Dropdown, { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../ui/Dropdown";
+interface Props {
+ team: inferQueryOutput<"viewer.teams.get">;
+ member: inferQueryOutput<"viewer.teams.get">["members"][number];
+}
-export default function MemberListItem(props: {
- member: Member;
- onActionSelect: (text: string) => void;
- onChange: (text: string) => void;
-}) {
- const [member] = useState(props.member);
+export default function MemberListItem(props: Props) {
const { t } = useLocale();
+ const utils = trpc.useContext();
+ const [showChangeMemberRoleModal, setShowChangeMemberRoleModal] = useState(false);
+ const [showTeamAvailabilityModal, setShowTeamAvailabilityModal] = useState(false);
+
+ const removeMemberMutation = trpc.useMutation("viewer.teams.removeMember", {
+ async onSuccess() {
+ await utils.invalidateQueries(["viewer.teams.get"]);
+ showToast(t("success"), "success");
+ },
+ async onError(err) {
+ showToast(err.message, "error");
+ },
+ });
+
+ const name =
+ props.member.name ||
+ (() => {
+ const emailName = props.member.email.split("@")[0];
+ return emailName.charAt(0).toUpperCase() + emailName.slice(1);
+ })();
+
+ const removeMember = () =>
+ removeMemberMutation.mutate({ teamId: props.team?.id, memberId: props.member.id });
+
return (
- member && (
-
-
-
-
-
-
- {props.member.name}
- {props.member.email}
-
+
+
+
+
+
+
+ {name}
+
+ {props.member.email}
+
-
- {props.member.role === "INVITEE" && (
+
+
+ {!props.member.accepted && }
+
+
+
+
+
+ (props.member.accepted ? setShowTeamAvailabilityModal(true) : null)}
+ color="minimal"
+ className="hidden w-10 h-10 p-0 border border-transparent group text-neutral-400 hover:border-gray-200 hover:bg-white sm:block">
+
+
+
+
+
+
+
+
+
+
+
+
+ {t("view_public_page")}
+
+
+
+
+
+ {(props.team.membership.role === MembershipRole.OWNER ||
+ props.team.membership.role === MembershipRole.ADMIN) && (
<>
-
- {t("pending")}
-
-
- {t("member")}
-
+
+ setShowChangeMemberRoleModal(true)}
+ color="minimal"
+ StartIcon={PencilIcon}
+ className="flex-shrink-0 w-full font-normal">
+ {t("edit_role")}
+
+
+
+
+
+
+ {
+ e.stopPropagation();
+ }}
+ color="warn"
+ StartIcon={UserRemoveIcon}
+ className="w-full font-normal">
+ {t("remove_member")}
+
+
+
+ {t("remove_member_confirmation_message")}
+
+
+
>
)}
- {props.member.role === "MEMBER" && (
-
- {t("member")}
-
- )}
- {props.member.role === "OWNER" && (
-
- {t("owner")}
-
- )}
-
-
-
- {/*
*/}
-
-
-
-
-
-
-
-
-
- {
- e.stopPropagation();
- }}
- color="warn"
- StartIcon={UserRemoveIcon}
- className="w-full">
- {t("remove_member")}
-
-
- props.onActionSelect("remove")}>
- {t("remove_member_confirmation_message")}
-
-
-
-
-
- {/*
*/}
-
+
+
-
- )
+
+ {showChangeMemberRoleModal && (
+
setShowChangeMemberRoleModal(false)}
+ />
+ )}
+ {showTeamAvailabilityModal && (
+
+
+
+ setShowTeamAvailabilityModal(false)}>{t("done")}
+ {props.team.membership.role !== MembershipRole.MEMBER && (
+
+ {t("Open Team Availability")}
+
+ )}
+
+
+ )}
+
);
}
diff --git a/components/team/TeamCreateModal.tsx b/components/team/TeamCreateModal.tsx
new file mode 100644
index 00000000..a768f3c1
--- /dev/null
+++ b/components/team/TeamCreateModal.tsx
@@ -0,0 +1,86 @@
+import { UsersIcon } from "@heroicons/react/outline";
+import { useRef } from "react";
+
+import { useLocale } from "@lib/hooks/useLocale";
+import { trpc } from "@lib/trpc";
+
+interface Props {
+ onClose: () => void;
+}
+
+export default function TeamCreate(props: Props) {
+ const { t } = useLocale();
+ const utils = trpc.useContext();
+
+ const nameRef = useRef() as React.MutableRefObject;
+
+ const createTeamMutation = trpc.useMutation("viewer.teams.create", {
+ onSuccess: () => {
+ utils.invalidateQueries(["viewer.teams.list"]);
+ props.onClose();
+ },
+ });
+
+ const createTeam = (e: React.FormEvent) => {
+ e.preventDefault();
+ createTeamMutation.mutate({ name: nameRef?.current?.value });
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {t("create_new_team")}
+
+
+
{t("create_new_team_description")}
+
+
+
+
+
+
+ {t("name")}
+
+
+
+
+
+ {t("create_team")}
+
+
+ {t("cancel")}
+
+
+
+
+
+
+ );
+}
diff --git a/components/team/TeamList.tsx b/components/team/TeamList.tsx
index 6a6ae787..2c71cb97 100644
--- a/components/team/TeamList.tsx
+++ b/components/team/TeamList.tsx
@@ -1,39 +1,44 @@
-import { Team } from "@lib/team";
+import showToast from "@lib/notification";
+import { trpc, inferQueryOutput } from "@lib/trpc";
import TeamListItem from "./TeamListItem";
-export default function TeamList(props: {
- teams: Team[];
- onChange: () => void;
- onEditTeam: (text: Team) => void;
-}) {
- const selectAction = (action: string, team: Team) => {
+interface Props {
+ teams: inferQueryOutput<"viewer.teams.list">;
+}
+
+export default function TeamList(props: Props) {
+ const utils = trpc.useContext();
+
+ function selectAction(action: string, teamId: number) {
switch (action) {
- case "edit":
- props.onEditTeam(team);
- break;
case "disband":
- deleteTeam(team);
+ deleteTeam(teamId);
break;
}
- };
+ }
+
+ const deleteTeamMutation = trpc.useMutation("viewer.teams.delete", {
+ async onSuccess() {
+ await utils.invalidateQueries(["viewer.teams.list"]);
+ },
+ async onError(err) {
+ showToast(err.message, "error");
+ },
+ });
- const deleteTeam = async (team: Team) => {
- await fetch("/api/teams/" + team.id, {
- method: "DELETE",
- });
- return props.onChange();
- };
+ function deleteTeam(teamId: number) {
+ deleteTeamMutation.mutate({ teamId });
+ }
return (
-
- {props.teams.map((team: Team) => (
+
+ {props.teams.map((team) => (
selectAction(action, team)}>
+ onActionSelect={(action: string) => selectAction(action, team?.id as number)}>
))}
diff --git a/components/team/TeamListItem.tsx b/components/team/TeamListItem.tsx
index c6f34b96..8677e65d 100644
--- a/components/team/TeamListItem.tsx
+++ b/components/team/TeamListItem.tsx
@@ -1,173 +1,212 @@
-import {
- DotsHorizontalIcon,
- ExternalLinkIcon,
- LinkIcon,
- PencilAltIcon,
- TrashIcon,
-} from "@heroicons/react/outline";
+import { ExternalLinkIcon, TrashIcon, LogoutIcon, PencilIcon } from "@heroicons/react/outline";
+import { LinkIcon, DotsHorizontalIcon } from "@heroicons/react/solid";
import Link from "next/link";
-import { useState } from "react";
+import classNames from "@lib/classNames";
+import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar";
import { useLocale } from "@lib/hooks/useLocale";
import showToast from "@lib/notification";
+import { trpc, inferQueryOutput } from "@lib/trpc";
import { Dialog, DialogTrigger } from "@components/Dialog";
import { Tooltip } from "@components/Tooltip";
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
import Avatar from "@components/ui/Avatar";
import Button from "@components/ui/Button";
+import Dropdown, {
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+ DropdownMenuSeparator,
+} from "@components/ui/Dropdown";
-import Dropdown, { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../ui/Dropdown";
+import TeamRole from "./TeamRole";
+import { MembershipRole } from ".prisma/client";
-interface Team {
- id: number;
- name: string | null;
- slug: string | null;
- logo: string | null;
- bio: string | null;
- role: string | null;
- hideBranding: boolean;
- prevState: null;
-}
-
-export default function TeamListItem(props: {
- onChange: () => void;
+interface Props {
+ team: inferQueryOutput<"viewer.teams.list">[number];
key: number;
- team: Team;
onActionSelect: (text: string) => void;
-}) {
- const [team, setTeam] = useState(props.team);
- const { t } = useLocale();
+}
- const acceptInvite = () => invitationResponse(true);
- const declineInvite = () => invitationResponse(false);
+export default function TeamListItem(props: Props) {
+ const { t } = useLocale();
+ const utils = trpc.useContext();
+ const team = props.team;
- const invitationResponse = (accept: boolean) =>
- fetch("/api/user/membership", {
- method: accept ? "PATCH" : "DELETE",
- body: JSON.stringify({ teamId: props.team.id }),
- headers: {
- "Content-Type": "application/json",
- },
- }).then(() => {
- // success
- setTeam(null);
- props.onChange();
+ const acceptOrLeaveMutation = trpc.useMutation("viewer.teams.acceptOrLeave", {
+ onSuccess: () => {
+ utils.invalidateQueries(["viewer.teams.list"]);
+ },
+ });
+ function acceptOrLeave(accept: boolean) {
+ acceptOrLeaveMutation.mutate({
+ teamId: team?.id as number,
+ accept,
});
+ }
+ const acceptInvite = () => acceptOrLeave(true);
+ const declineInvite = () => acceptOrLeave(false);
+
+ const isOwner = props.team.role === MembershipRole.OWNER;
+ const isInvitee = !props.team.accepted;
+ const isAdmin = props.team.role === MembershipRole.OWNER || props.team.role === MembershipRole.ADMIN;
+
+ if (!team) return <>>;
+
+ const teamInfo = (
+
+
+
+ {team.name}
+
+ {process.env.NEXT_PUBLIC_APP_URL}/team/{team.slug}
+
+
+
+ );
return (
- team && (
-
-
-
-
-
- {props.team.name}
-
- {process.env.NEXT_PUBLIC_APP_URL}/team/{props.team.slug}
-
-
-
- {props.team.role === "INVITEE" && (
-
+
+
+ {!isInvitee ? (
+
+
+ {teamInfo}
+
+
+ ) : (
+ teamInfo
+ )}
+
+ {isInvitee && (
+ <>
{t("reject")}
-
+
{t("accept")}
-
+ >
)}
- {props.team.role === "MEMBER" && (
-
-
- {t("leave")}
-
-
- )}
- {props.team.role === "OWNER" && (
-
-
- {t("owner")}
-
-
+ {!isInvitee && (
+
+
+
+
{
- navigator.clipboard.writeText(
- process.env.NEXT_PUBLIC_APP_URL + "/team/" + props.team.slug
- );
+ navigator.clipboard.writeText(process.env.NEXT_PUBLIC_APP_URL + "/team/" + team.slug);
showToast(t("link_copied"), "success");
}}
+ className="w-10 h-10 transition-none"
size="icon"
color="minimal"
- StartIcon={LinkIcon}
- type="button"
- />
+ type="button">
+
+
-
-
+
+
+ {isAdmin && (
+
+
+
+
+ {t("edit_team")}
+
+
+
+
+ )}
+ {isAdmin && }
- props.onActionSelect("edit")}
- StartIcon={PencilAltIcon}>
- {" "}
- {t("edit_team")}
-
-
-
-
+
-
+
{" "}
{t("preview_team")}
-
-
-
- {
- e.stopPropagation();
- }}
- color="warn"
- StartIcon={TrashIcon}
- className="w-full">
- {t("disband_team")}
-
-
- props.onActionSelect("disband")}>
- {t("disband_team_confirmation_message")}
-
-
-
+
+ {isOwner && (
+
+
+
+ {
+ e.stopPropagation();
+ }}
+ color="warn"
+ StartIcon={TrashIcon}
+ className="w-full font-normal">
+ {t("disband_team")}
+
+
+ props.onActionSelect("disband")}>
+ {t("disband_team_confirmation_message")}
+
+
+
+ )}
+
+ {!isOwner && (
+
+
+
+ {
+ e.stopPropagation();
+ }}>
+ {t("leave_team")}
+
+
+
+ {t("leave_team_confirmation_message")}
+
+
+
+ )}
)}
-
- )
+
+
);
}
diff --git a/components/team/TeamRole.tsx b/components/team/TeamRole.tsx
new file mode 100644
index 00000000..a5a04076
--- /dev/null
+++ b/components/team/TeamRole.tsx
@@ -0,0 +1,37 @@
+import { MembershipRole } from "@prisma/client";
+import classNames from "classnames";
+
+import { useLocale } from "@lib/hooks/useLocale";
+
+interface Props {
+ role?: MembershipRole;
+ invitePending?: boolean;
+}
+
+export default function TeamRole(props: Props) {
+ const { t } = useLocale();
+
+ return (
+
+ {(() => {
+ if (props.invitePending) return t("invitee");
+ switch (props.role) {
+ case "OWNER":
+ return t("owner");
+ case "ADMIN":
+ return t("admin");
+ case "MEMBER":
+ return t("member");
+ default:
+ return "";
+ }
+ })()}
+
+ );
+}
diff --git a/components/team/TeamSettings.tsx b/components/team/TeamSettings.tsx
new file mode 100644
index 00000000..7b98e57d
--- /dev/null
+++ b/components/team/TeamSettings.tsx
@@ -0,0 +1,210 @@
+import { HashtagIcon, InformationCircleIcon, LinkIcon, PhotographIcon } from "@heroicons/react/solid";
+import React, { useRef, useState } from "react";
+
+import { useLocale } from "@lib/hooks/useLocale";
+import showToast from "@lib/notification";
+import { TeamWithMembers } from "@lib/queries/teams";
+import { trpc } from "@lib/trpc";
+
+import ImageUploader from "@components/ImageUploader";
+import { TextField } from "@components/form/fields";
+import { Alert } from "@components/ui/Alert";
+import Button from "@components/ui/Button";
+import SettingInputContainer from "@components/ui/SettingInputContainer";
+
+interface Props {
+ team: TeamWithMembers | null | undefined;
+}
+
+export default function TeamSettings(props: Props) {
+ const { t } = useLocale();
+
+ const [hasErrors, setHasErrors] = useState(false);
+ const [errorMessage, setErrorMessage] = useState("");
+
+ const team = props.team;
+ const hasLogo = !!team?.logo;
+
+ const utils = trpc.useContext();
+ const mutation = trpc.useMutation("viewer.teams.update", {
+ onError: (err) => {
+ setHasErrors(true);
+ setErrorMessage(err.message);
+ },
+ async onSuccess() {
+ await utils.invalidateQueries(["viewer.teams.get"]);
+ showToast(t("your_team_updated_successfully"), "success");
+ setHasErrors(false);
+ },
+ });
+
+ const nameRef = useRef
() as React.MutableRefObject;
+ const teamUrlRef = useRef() as React.MutableRefObject;
+ const descriptionRef = useRef() as React.MutableRefObject;
+ const hideBrandingRef = useRef() as React.MutableRefObject;
+ const logoRef = useRef() as React.MutableRefObject;
+
+ function updateTeamData() {
+ if (!team) return;
+ const variables = {
+ name: nameRef.current?.value,
+ slug: teamUrlRef.current?.value,
+ bio: descriptionRef.current?.value,
+ hideBranding: hideBrandingRef.current?.checked,
+ };
+ // remove unchanged variables
+ for (const key in variables) {
+ //@ts-expect-error will fix types
+ if (variables[key] === team?.[key]) delete variables[key];
+ }
+ mutation.mutate({ id: team.id, ...variables });
+ }
+
+ function updateLogo(newLogo: string) {
+ if (!team) return;
+ logoRef.current.value = newLogo;
+ mutation.mutate({ id: team.id, logo: newLogo });
+ }
+
+ const removeLogo = () => updateLogo("");
+
+ return (
+
+
+ {hasErrors &&
}
+
{
+ e.preventDefault();
+ updateTeamData();
+ }}>
+
+
+
+
+ {process.env.NEXT_PUBLIC_APP_URL}/{"team/"}
+
+ }
+ ref={teamUrlRef}
+ defaultValue={team?.slug as string}
+ />
+ }
+ />
+
+ }
+ />
+
+
+
+
+ {t("team_description")}
+ >
+ }
+ />
+
+
+
+
+
+
+ {hasLogo && (
+
+ {t("remove_logo")}
+
+ )}
+
+ >
+ }
+ />
+
+
+
+
+
+
+
+
+
+
+ {t("disable_cal_branding")}
+
+
{t("disable_cal_branding_description")}
+
+
+
+
+
+
+
+ {t("save")}
+
+
+
+
+
+ );
+}
diff --git a/components/team/TeamSettingsRightSidebar.tsx b/components/team/TeamSettingsRightSidebar.tsx
new file mode 100644
index 00000000..f2635c2c
--- /dev/null
+++ b/components/team/TeamSettingsRightSidebar.tsx
@@ -0,0 +1,130 @@
+import { ClockIcon, ExternalLinkIcon, LinkIcon, LogoutIcon, TrashIcon } from "@heroicons/react/solid";
+import Link from "next/link";
+import { useRouter } from "next/router";
+import React from "react";
+
+import { useLocale } from "@lib/hooks/useLocale";
+import showToast from "@lib/notification";
+import { TeamWithMembers } from "@lib/queries/teams";
+import { trpc } from "@lib/trpc";
+
+import { Dialog, DialogTrigger } from "@components/Dialog";
+import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
+import CreateEventTypeButton from "@components/eventtype/CreateEventType";
+import LinkIconButton from "@components/ui/LinkIconButton";
+
+import { MembershipRole } from ".prisma/client";
+
+// import Switch from "@components/ui/Switch";
+
+export default function TeamSettingsRightSidebar(props: { team: TeamWithMembers; role: MembershipRole }) {
+ const { t } = useLocale();
+ const utils = trpc.useContext();
+ const router = useRouter();
+
+ const permalink = `${process.env.NEXT_PUBLIC_APP_URL}/team/${props.team?.slug}`;
+
+ const deleteTeamMutation = trpc.useMutation("viewer.teams.delete", {
+ async onSuccess() {
+ await utils.invalidateQueries(["viewer.teams.get"]);
+ showToast(t("your_team_updated_successfully"), "success");
+ },
+ });
+ const acceptOrLeaveMutation = trpc.useMutation("viewer.teams.acceptOrLeave", {
+ onSuccess: () => {
+ utils.invalidateQueries(["viewer.teams.list"]);
+ router.push(`/settings/teams`);
+ },
+ });
+
+ function deleteTeam() {
+ if (props.team?.id) deleteTeamMutation.mutate({ teamId: props.team.id });
+ }
+ function leaveTeam() {
+ if (props.team?.id)
+ acceptOrLeaveMutation.mutate({
+ teamId: props.team.id,
+ accept: false,
+ });
+ }
+
+ return (
+
+
+ {/*
*/}
+
+
+
+ {t("preview")}
+
+
+
{
+ navigator.clipboard.writeText(permalink);
+ showToast("Copied to clipboard", "success");
+ }}>
+ {t("copy_link_team")}
+
+ {props.role === "OWNER" ? (
+
+
+ {
+ e.stopPropagation();
+ }}
+ Icon={TrashIcon}>
+ {t("disband_team")}
+
+
+
+ {t("disband_team_confirmation_message")}
+
+
+ ) : (
+
+
+ {
+ e.stopPropagation();
+ }}>
+ {t("leave_team")}
+
+
+
+ {t("leave_team_confirmation_message")}
+
+
+ )}
+
+ {props.team?.id && props.role !== MembershipRole.MEMBER && (
+
+
+
{"View Availability"}
+
See your team members availability at a glance.
+
+
+ )}
+
+ );
+}
diff --git a/components/team/screens/Team.tsx b/components/team/screens/Team.tsx
index 13c0bae0..4fbcf49f 100644
--- a/components/team/screens/Team.tsx
+++ b/components/team/screens/Team.tsx
@@ -2,34 +2,41 @@ import { ArrowRightIcon } from "@heroicons/react/outline";
import { ArrowLeftIcon } from "@heroicons/react/solid";
import classnames from "classnames";
import Link from "next/link";
+import { TeamPageProps } from "pages/team/[slug]";
import React from "react";
+import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar";
import { useLocale } from "@lib/hooks/useLocale";
import Avatar from "@components/ui/Avatar";
import Button from "@components/ui/Button";
import Text from "@components/ui/Text";
-const Team = ({ team }) => {
+type TeamType = TeamPageProps["team"];
+type MembersType = TeamType["members"];
+type MemberType = MembersType[number];
+
+const Team = ({ team }: TeamPageProps) => {
const { t } = useLocale();
- const Member = ({ member }) => {
+ const Member = ({ member }: { member: MemberType }) => {
const classes = classnames(
"group",
"relative",
"flex flex-col",
"space-y-4",
"p-4",
+ "min-w-full sm:min-w-64 sm:max-w-64",
"bg-white dark:bg-neutral-900 dark:border-0 dark:bg-opacity-8",
"border border-neutral-200",
"hover:cursor-pointer",
- "hover:border-black dark:border-neutral-700 dark:hover:border-neutral-600",
+ "hover:border-brand dark:border-neutral-700 dark:hover:border-neutral-600",
"rounded-sm",
"hover:shadow-md"
);
return (
-
+
{
/>
-
-
- {member.user.name}
-
- {member.user.bio}
+
+
+ {member.name}
+
+ {member.bio || t("user_from_team", { user: member.name, team: team.name })}
@@ -55,15 +66,15 @@ const Team = ({ team }) => {
);
};
- const Members = ({ members }) => {
+ const Members = ({ members }: { members: MembersType }) => {
if (!members || members.length === 0) {
return null;
}
return (
-
+
{members.map((member) => {
- return member.user.username !== null && ;
+ return member.username !== null && ;
})}
);
@@ -73,7 +84,7 @@ const Team = ({ team }) => {
{team.eventTypes.length > 0 && (
-
+
{t("go_back")}
diff --git a/components/ui/Alert.tsx b/components/ui/Alert.tsx
index b38fa48e..f717ccc0 100644
--- a/components/ui/Alert.tsx
+++ b/components/ui/Alert.tsx
@@ -33,7 +33,7 @@ export function Alert(props: AlertProps) {
)}
-
+
{props.title}
{props.message}
diff --git a/components/ui/AuthContainer.tsx b/components/ui/AuthContainer.tsx
new file mode 100644
index 00000000..253ea3fe
--- /dev/null
+++ b/components/ui/AuthContainer.tsx
@@ -0,0 +1,40 @@
+import React from "react";
+
+import Loader from "@components/Loader";
+import { HeadSeo } from "@components/seo/head-seo";
+
+interface Props {
+ title: string;
+ description: string;
+ footerText?: React.ReactNode | string;
+ showLogo?: boolean;
+ heading?: string;
+ loading?: boolean;
+}
+
+export default function AuthContainer(props: React.PropsWithChildren
) {
+ return (
+
+
+
+ {props.showLogo && (
+
+ )}
+ {props.heading && (
+
{props.heading}
+ )}
+
+ {props.loading && (
+
+
+
+ )}
+
+
+ {props.children}
+
+
{props.footerText}
+
+
+ );
+}
diff --git a/components/ui/Avatar.tsx b/components/ui/Avatar.tsx
index cbaf5f0f..f1a3734c 100644
--- a/components/ui/Avatar.tsx
+++ b/components/ui/Avatar.tsx
@@ -36,7 +36,7 @@ export default function Avatar(props: AvatarProps) {
return title ? (
{avatar}
-
+
{title}
diff --git a/components/ui/AvatarGroup.tsx b/components/ui/AvatarGroup.tsx
index f4ce1f57..958d116f 100644
--- a/components/ui/AvatarGroup.tsx
+++ b/components/ui/AvatarGroup.tsx
@@ -12,7 +12,7 @@ export type AvatarGroupProps = {
items: {
image: string;
title?: string;
- alt: string;
+ alt?: string;
}[];
className?: string;
};
@@ -28,19 +28,23 @@ export const AvatarGroup = function AvatarGroup(props: AvatarGroupProps) {
return (
- {props.items.slice(0, props.truncateAfter).map((item, idx) => (
-
-
-
- ))}
+ {props.items.slice(0, props.truncateAfter).map((item, idx) => {
+ if (item.image != null) {
+ return (
+
+
+
+ );
+ }
+ })}
{/*props.items.length > props.truncateAfter && (
-
+
+1
{truncatedAvatars.length !== 0 && (
-
+
{truncatedAvatars.map((title) => (
diff --git a/components/ui/Button.tsx b/components/ui/Button.tsx
index c303b28c..7ec90c14 100644
--- a/components/ui/Button.tsx
+++ b/components/ui/Button.tsx
@@ -54,16 +54,17 @@ export const Button = forwardRef
{StartIcon && (
-
+
)}
{props.children}
{loading && (
diff --git a/components/ui/Dropdown.tsx b/components/ui/Dropdown.tsx
index 7f5dc8f0..c092087d 100644
--- a/components/ui/Dropdown.tsx
+++ b/components/ui/Dropdown.tsx
@@ -26,7 +26,7 @@ export const DropdownMenuContent = forwardRef
{children}
diff --git a/components/ui/InfoBadge.tsx b/components/ui/InfoBadge.tsx
new file mode 100644
index 00000000..a79a9fd2
--- /dev/null
+++ b/components/ui/InfoBadge.tsx
@@ -0,0 +1,15 @@
+import { InformationCircleIcon } from "@heroicons/react/solid";
+
+import { Tooltip } from "@components/Tooltip";
+
+export default function InfoBadge({ content }: { content: string }) {
+ return (
+ <>
+
+
+
+
+
+ >
+ );
+}
diff --git a/components/ui/LinkIconButton.tsx b/components/ui/LinkIconButton.tsx
new file mode 100644
index 00000000..009715ac
--- /dev/null
+++ b/components/ui/LinkIconButton.tsx
@@ -0,0 +1,21 @@
+import React from "react";
+
+import { SVGComponent } from "@lib/types/SVGComponent";
+
+interface LinkIconButtonProps extends React.ButtonHTMLAttributes {
+ Icon: SVGComponent;
+}
+
+export default function LinkIconButton(props: LinkIconButtonProps) {
+ return (
+
+
+
+ {props.children}
+
+
+ );
+}
diff --git a/components/ui/ModalContainer.tsx b/components/ui/ModalContainer.tsx
new file mode 100644
index 00000000..1fa130f7
--- /dev/null
+++ b/components/ui/ModalContainer.tsx
@@ -0,0 +1,39 @@
+import classNames from "classnames";
+import React from "react";
+
+interface Props extends React.PropsWithChildren {
+ wide?: boolean;
+ scroll?: boolean;
+ noPadding?: boolean;
+}
+
+export default function ModalContainer(props: Props) {
+ return (
+
+
+
+
+
+
+
+ {props.children}
+
+
+
+ );
+}
diff --git a/components/ui/Schedule/Schedule.tsx b/components/ui/Schedule/Schedule.tsx
deleted file mode 100644
index 3642c482..00000000
--- a/components/ui/Schedule/Schedule.tsx
+++ /dev/null
@@ -1,337 +0,0 @@
-import { PlusIcon, TrashIcon } from "@heroicons/react/outline";
-import classnames from "classnames";
-import dayjs, { Dayjs } from "dayjs";
-import React from "react";
-
-import { useLocale } from "@lib/hooks/useLocale";
-
-import Text from "@components/ui/Text";
-
-export const SCHEDULE_FORM_ID = "SCHEDULE_FORM_ID";
-export const toCalendsoAvailabilityFormat = (schedule: Schedule) => {
- return schedule;
-};
-
-export const _24_HOUR_TIME_FORMAT = `HH:mm:ss`;
-
-const DEFAULT_START_TIME = "09:00:00";
-const DEFAULT_END_TIME = "17:00:00";
-
-/** Begin Time Increments For Select */
-const increment = 15;
-
-/**
- * Creates an array of times on a 15 minute interval from
- * 00:00:00 (Start of day) to
- * 23:45:00 (End of day with enough time for 15 min booking)
- */
-const TIMES = (() => {
- const starting_time = dayjs().startOf("day");
- const ending_time = dayjs().endOf("day");
-
- const times = [];
- let t: Dayjs = starting_time;
-
- while (t.isBefore(ending_time)) {
- times.push(t);
- t = t.add(increment, "minutes");
- }
- return times;
-})();
-/** End Time Increments For Select */
-
-const DEFAULT_SCHEDULE: Schedule = {
- monday: [{ start: "09:00:00", end: "17:00:00" }],
- tuesday: [{ start: "09:00:00", end: "17:00:00" }],
- wednesday: [{ start: "09:00:00", end: "17:00:00" }],
- thursday: [{ start: "09:00:00", end: "17:00:00" }],
- friday: [{ start: "09:00:00", end: "17:00:00" }],
- saturday: null,
- sunday: null,
-};
-
-type DayOfWeek = "monday" | "tuesday" | "wednesday" | "thursday" | "friday" | "saturday" | "sunday";
-export type TimeRange = {
- start: string;
- end: string;
-};
-
-export type FreeBusyTime = TimeRange[];
-
-export type Schedule = {
- monday?: FreeBusyTime | null;
- tuesday?: FreeBusyTime | null;
- wednesday?: FreeBusyTime | null;
- thursday?: FreeBusyTime | null;
- friday?: FreeBusyTime | null;
- saturday?: FreeBusyTime | null;
- sunday?: FreeBusyTime | null;
-};
-
-type ScheduleBlockProps = {
- day: DayOfWeek;
- ranges?: FreeBusyTime | null;
- selected?: boolean;
-};
-
-type Props = {
- schedule?: Schedule;
- onChange?: (data: Schedule) => void;
- onSubmit: (data: Schedule) => void;
-};
-
-const SchedulerForm = ({ schedule = DEFAULT_SCHEDULE, onSubmit }: Props) => {
- const { t } = useLocale();
- const ref = React.useRef(null);
-
- const transformElementsToSchedule = (elements: HTMLFormControlsCollection): Schedule => {
- const schedule: Schedule = {};
- const formElements = Array.from(elements)
- .map((element) => {
- return element.id;
- })
- .filter((value) => value);
-
- /**
- * elementId either {day} or {day.N.start} or {day.N.end}
- * If elementId in DAYS_ARRAY add elementId to scheduleObj
- * then element is the checkbox and can be ignored
- *
- * If elementId starts with a day in DAYS_ARRAY
- * the elementId should be split by "." resulting in array length 3
- * [day, rangeIndex, "start" | "end"]
- */
- formElements.forEach((elementId) => {
- const [day, rangeIndex, rangeId] = elementId.split(".");
- if (rangeIndex && rangeId) {
- if (!schedule[day]) {
- schedule[day] = [];
- }
-
- if (!schedule[day][parseInt(rangeIndex)]) {
- schedule[day][parseInt(rangeIndex)] = {};
- }
-
- schedule[day][parseInt(rangeIndex)][rangeId] = elements[elementId].value;
- }
- });
-
- return schedule;
- };
-
- const handleSubmit = (event: React.FormEvent) => {
- event.preventDefault();
- const elements = ref.current?.elements;
- if (elements) {
- const schedule = transformElementsToSchedule(elements);
- onSubmit && typeof onSubmit === "function" && onSubmit(schedule);
- }
- };
-
- const ScheduleBlock = ({ day, ranges: defaultRanges, selected: defaultSelected }: ScheduleBlockProps) => {
- const [ranges, setRanges] = React.useState(defaultRanges);
- const [selected, setSelected] = React.useState(defaultSelected);
- React.useEffect(() => {
- if (!ranges || ranges.length === 0) {
- setSelected(false);
- } else {
- setSelected(true);
- }
- }, [ranges]);
-
- const handleSelectedChange = () => {
- if (!selected && (!ranges || ranges.length === 0)) {
- setRanges([
- {
- start: "09:00:00",
- end: "17:00:00",
- },
- ]);
- }
- setSelected(!selected);
- };
-
- const handleAddRange = () => {
- let rangeToAdd;
- if (!ranges || ranges?.length === 0) {
- rangeToAdd = {
- start: DEFAULT_START_TIME,
- end: DEFAULT_END_TIME,
- };
- setRanges([rangeToAdd]);
- } else {
- const lastRange = ranges[ranges.length - 1];
-
- const [hour, minute, second] = lastRange.end.split(":");
- const date = dayjs()
- .set("hour", parseInt(hour))
- .set("minute", parseInt(minute))
- .set("second", parseInt(second));
- const nextStartTime = date.add(1, "hour");
- const nextEndTime = date.add(2, "hour");
-
- /**
- * If next range goes over into "tomorrow"
- * i.e. time greater that last value in Times
- * return
- */
- if (nextStartTime.isAfter(date.endOf("day"))) {
- return;
- }
-
- rangeToAdd = {
- start: nextStartTime.format(_24_HOUR_TIME_FORMAT),
- end: nextEndTime.format(_24_HOUR_TIME_FORMAT),
- };
- setRanges([...ranges, rangeToAdd]);
- }
- };
-
- const handleDeleteRange = (range: TimeRange) => {
- if (ranges && ranges.length > 0) {
- setRanges(
- ranges.filter((r: TimeRange) => {
- return r.start != range.start;
- })
- );
- }
- };
-
- /**
- * Should update ranges values
- */
- const handleSelectRangeChange = (event: React.ChangeEvent) => {
- const [day, rangeIndex, rangeId] = event.currentTarget.name.split(".");
-
- if (day && ranges) {
- const newRanges = ranges.map((range, index) => {
- const newRange = {
- ...range,
- [rangeId]: event.currentTarget.value,
- };
- return index === parseInt(rangeIndex) ? newRange : range;
- });
-
- setRanges(newRanges);
- }
- };
-
- const TimeRangeField = ({ range, day, index }: { range: TimeRange; day: DayOfWeek; index: number }) => {
- const timeOptions = (type: "start" | "end") =>
- TIMES.map((time) => (
-
- {time.toDate().toLocaleTimeString(undefined, { minute: "numeric", hour: "numeric" })}
-
- ));
- return (
-
-
-
- {timeOptions("start")}
-
- -
-
- {timeOptions("end")}
-
-
-
-
-
-
- );
- };
-
- const Actions = () => {
- return (
-
-
handleAddRange()}>
-
-
-
- );
- };
-
- const DeleteAction = ({ range }: { range: TimeRange }) => {
- return (
- handleDeleteRange(range)}>
-
-
- );
- };
-
- return (
-
- 1 ? "sm:items-start" : "sm:items-center"
- )}>
-
-
-
- {selected && ranges && ranges.length != 0 ? (
- ranges.map((range, index) => (
-
- ))
- ) : (
-
- {t("unavailable")}
-
- )}
-
-
-
-
-
- );
- };
-
- return (
- <>
-
- {Object.keys(schedule).map((day) => {
- const selected = schedule[day as DayOfWeek] != null;
- return (
-
- );
- })}
-
- >
- );
-};
-
-export default SchedulerForm;
diff --git a/components/ui/Scheduler.tsx b/components/ui/Scheduler.tsx
index 10a1f750..d773c42b 100644
--- a/components/ui/Scheduler.tsx
+++ b/components/ui/Scheduler.tsx
@@ -4,57 +4,50 @@ import dayjs from "dayjs";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
import React, { useEffect, useState } from "react";
-import TimezoneSelect from "react-timezone-select";
+import TimezoneSelect, { ITimezoneOption } from "react-timezone-select";
import { useLocale } from "@lib/hooks/useLocale";
+import Button from "@components/ui/Button";
+
import { WeekdaySelect } from "./WeekdaySelect";
import SetTimesModal from "./modal/SetTimesModal";
dayjs.extend(utc);
dayjs.extend(timezone);
+type AvailabilityInput = Pick;
+
type Props = {
timeZone: string;
availability: Availability[];
- setTimeZone: unknown;
+ setTimeZone: (timeZone: string) => void;
+ setAvailability: (schedule: {
+ openingHours: AvailabilityInput[];
+ dateOverrides: AvailabilityInput[];
+ }) => void;
};
-export const Scheduler = ({
- availability,
- setAvailability,
- timeZone: selectedTimeZone,
- setTimeZone,
-}: Props) => {
- const { t } = useLocale();
+/**
+ * @deprecated
+ */
+export const Scheduler = ({ availability, setAvailability, timeZone, setTimeZone }: Props) => {
+ const { t, i18n } = useLocale();
const [editSchedule, setEditSchedule] = useState(-1);
- const [dateOverrides, setDateOverrides] = useState([]);
- const [openingHours, setOpeningHours] = useState([]);
+ const [openingHours, setOpeningHours] = useState([]);
useEffect(() => {
- setOpeningHours(
- availability
- .filter((item: Availability) => item.days.length !== 0)
- .map((item) => {
- item.startDate = dayjs().utc().startOf("day").add(item.startTime, "minutes");
- item.endDate = dayjs().utc().startOf("day").add(item.endTime, "minutes");
- return item;
- })
- );
- setDateOverrides(availability.filter((item: Availability) => item.date));
+ setOpeningHours(availability.filter((item: Availability) => item.days.length !== 0));
}, []);
- // updates availability to how it should be formatted outside this component.
useEffect(() => {
- setAvailability({
- dateOverrides: dateOverrides,
- openingHours: openingHours,
- });
- }, [dateOverrides, openingHours]);
+ setAvailability({ openingHours, dateOverrides: [] });
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [openingHours]);
const addNewSchedule = () => setEditSchedule(openingHours.length);
- const applyEditSchedule = (changed) => {
+ const applyEditSchedule = (changed: Availability) => {
// new entry
if (!changed.days) {
changed.days = [1, 2, 3, 4, 5]; // Mon - Fri
@@ -63,39 +56,33 @@ export const Scheduler = ({
// update
const replaceWith = { ...openingHours[editSchedule], ...changed };
openingHours.splice(editSchedule, 1, replaceWith);
- setOpeningHours([].concat(openingHours));
+ setOpeningHours([...openingHours]);
}
};
const removeScheduleAt = (toRemove: number) => {
openingHours.splice(toRemove, 1);
- setOpeningHours([].concat(openingHours));
+ setOpeningHours([...openingHours]);
};
- const OpeningHours = ({ idx, item }) => (
-
+ const OpeningHours = ({ idx, item }: { idx: number; item: Availability }) => (
+
(item.days = selected)} />
setEditSchedule(idx)}>
- {dayjs()
- .startOf("day")
- .add(item.startTime, "minutes")
- .format(item.startTime % 60 === 0 ? "ha" : "h:mma")}
+ {item.startTime.toLocaleTimeString(i18n.language, { hour: "numeric", minute: "2-digit" })}
{t("until")}
- {dayjs()
- .startOf("day")
- .add(item.endTime, "minutes")
- .format(item.endTime % 60 === 0 ? "ha" : "h:mma")}
+ {item.endTime.toLocaleTimeString(i18n.language, { hour: "numeric", minute: "2-digit" })}
removeScheduleAt(idx)}
- className="btn-sm bg-transparent px-2 py-1 ml-1">
-
+ className="px-2 py-1 ml-1 bg-transparent btn-sm">
+
);
@@ -111,9 +98,9 @@ export const Scheduler = ({
setTimeZone(tz.value)}
- className="shadow-sm focus:ring-black focus:border-black mt-1 block w-full sm:text-sm border-gray-300 rounded-md"
+ value={timeZone}
+ onChange={(tz: ITimezoneOption) => setTimeZone(tz.value)}
+ className="block w-full mt-1 border-gray-300 rounded-md shadow-sm focus:ring-black focus:border-brand sm:text-sm"
/>
@@ -122,16 +109,36 @@ export const Scheduler = ({
))}
-
+
{t("add_another")}
-
+
{editSchedule >= 0 && (
applyEditSchedule({ ...(openingHours[editSchedule] || {}), ...times })}
+ startTime={
+ openingHours[editSchedule]
+ ? new Date(openingHours[editSchedule].startTime).getHours() * 60 +
+ new Date(openingHours[editSchedule].startTime).getMinutes()
+ : 540
+ }
+ endTime={
+ openingHours[editSchedule]
+ ? new Date(openingHours[editSchedule].endTime).getHours() * 60 +
+ new Date(openingHours[editSchedule].endTime).getMinutes()
+ : 1020
+ }
+ onChange={(times: { startTime: number; endTime: number }) =>
+ applyEditSchedule({
+ ...(openingHours[editSchedule] || {}),
+ startTime: new Date(
+ new Date().setHours(Math.floor(times.startTime / 60), times.startTime % 60, 0, 0)
+ ),
+ endTime: new Date(
+ new Date().setHours(Math.floor(times.endTime / 60), times.endTime % 60, 0, 0)
+ ),
+ })
+ }
onExit={() => setEditSchedule(-1)}
/>
)}
diff --git a/components/ui/SettingInputContainer.tsx b/components/ui/SettingInputContainer.tsx
new file mode 100644
index 00000000..9cd938dc
--- /dev/null
+++ b/components/ui/SettingInputContainer.tsx
@@ -0,0 +1,25 @@
+export default function SettingInputContainer({
+ Input,
+ Icon,
+ label,
+ htmlFor,
+}: {
+ Input: React.ReactNode;
+ Icon: (props: React.SVGProps) => JSX.Element;
+ label: string;
+ htmlFor?: string;
+}) {
+ return (
+
+
+
+
+
+ {label}
+
+
+
{Input}
+
+
+ );
+}
diff --git a/components/ui/UsernameInput.tsx b/components/ui/UsernameInput.tsx
deleted file mode 100644
index cd3b615f..00000000
--- a/components/ui/UsernameInput.tsx
+++ /dev/null
@@ -1,33 +0,0 @@
-import React from "react";
-
-interface UsernameInputProps extends React.ComponentPropsWithRef<"input"> {
- label?: string;
-}
-
-const UsernameInput = React.forwardRef((props, ref) => (
- // todo, check if username is already taken here?
-
-
- {props.label ? props.label : "Username"}
-
-
-
- {process.env.NEXT_PUBLIC_APP_URL}/{props.label && "team/"}
-
-
-
-
-));
-
-UsernameInput.displayName = "UsernameInput";
-
-export { UsernameInput };
diff --git a/components/ui/WeekdaySelect.tsx b/components/ui/WeekdaySelect.tsx
index 83fdddfc..37684bd6 100644
--- a/components/ui/WeekdaySelect.tsx
+++ b/components/ui/WeekdaySelect.tsx
@@ -34,7 +34,7 @@ export const WeekdaySelect = (props: WeekdaySelectProps) => {
}}
className={`
w-10 h-10
- bg-black text-white focus:outline-none px-3 py-1 rounded
+ bg-brand text-brandcontrast focus:outline-none px-3 py-1 rounded
${activeDays[idx + 1] ? "rounded-r-none" : ""}
${activeDays[idx - 1] ? "rounded-l-none" : ""}
${idx === 0 ? "rounded-l" : ""}
diff --git a/components/ui/form/CheckboxField.tsx b/components/ui/form/CheckboxField.tsx
index 1e687c68..fcb66f15 100644
--- a/components/ui/form/CheckboxField.tsx
+++ b/components/ui/form/CheckboxField.tsx
@@ -1,7 +1,7 @@
import React, { forwardRef, InputHTMLAttributes } from "react";
type Props = InputHTMLAttributes & {
- label: string;
+ label: React.ReactNode;
description: string;
};
diff --git a/components/ui/form/DatePicker.tsx b/components/ui/form/DatePicker.tsx
new file mode 100644
index 00000000..f785cbcf
--- /dev/null
+++ b/components/ui/form/DatePicker.tsx
@@ -0,0 +1,28 @@
+import { CalendarIcon } from "@heroicons/react/solid";
+import React from "react";
+import "react-calendar/dist/Calendar.css";
+import "react-date-picker/dist/DatePicker.css";
+import PrimitiveDatePicker from "react-date-picker/dist/entry.nostyle";
+
+import classNames from "@lib/classNames";
+
+type Props = {
+ date: Date;
+ onDatesChange?: ((date: Date) => void) | undefined;
+ className?: string;
+};
+
+export const DatePicker = ({ date, onDatesChange, className }: Props) => {
+ return (
+ }
+ value={date}
+ onChange={onDatesChange}
+ />
+ );
+};
diff --git a/components/ui/form/MinutesField.tsx b/components/ui/form/MinutesField.tsx
index 50cd3caf..20cded63 100644
--- a/components/ui/form/MinutesField.tsx
+++ b/components/ui/form/MinutesField.tsx
@@ -1,24 +1,30 @@
+import classNames from "classnames";
import React, { forwardRef, InputHTMLAttributes, ReactNode } from "react";
type Props = InputHTMLAttributes & {
- label: ReactNode;
+ label?: ReactNode;
};
const MinutesField = forwardRef(({ label, ...rest }, ref) => {
return (
-
-
- {label}
-
-
+ {!!label && (
+
+
+ {label}
+
+
+ )}
diff --git a/components/ui/form/PhoneInput.tsx b/components/ui/form/PhoneInput.tsx
index 4c3c00a4..29c9600a 100644
--- a/components/ui/form/PhoneInput.tsx
+++ b/components/ui/form/PhoneInput.tsx
@@ -1,14 +1,15 @@
import React from "react";
-import { default as BasePhoneInput, PhoneInputProps } from "react-phone-number-input";
+import BasePhoneInput, { Props as PhoneInputProps } from "react-phone-number-input";
import "react-phone-number-input/style.css";
import classNames from "@lib/classNames";
+import { Optional } from "@lib/types/utils";
-export const PhoneInput = (props: PhoneInputProps) => (
+export const PhoneInput = (props: Optional) => (
{
diff --git a/components/ui/form/Schedule.tsx b/components/ui/form/Schedule.tsx
new file mode 100644
index 00000000..f30198da
--- /dev/null
+++ b/components/ui/form/Schedule.tsx
@@ -0,0 +1,178 @@
+import { PlusIcon, TrashIcon } from "@heroicons/react/outline";
+import dayjs, { Dayjs, ConfigType } from "dayjs";
+import timezone from "dayjs/plugin/timezone";
+import utc from "dayjs/plugin/utc";
+import React, { useCallback, useState } from "react";
+import { Controller, useFieldArray } from "react-hook-form";
+
+import { defaultDayRange } from "@lib/availability";
+import { weekdayNames } from "@lib/core/i18n/weekday";
+import { useLocale } from "@lib/hooks/useLocale";
+import { TimeRange } from "@lib/types/schedule";
+
+import Button from "@components/ui/Button";
+import Select from "@components/ui/form/Select";
+
+dayjs.extend(utc);
+dayjs.extend(timezone);
+
+/** Begin Time Increments For Select */
+const increment = 15;
+/**
+ * Creates an array of times on a 15 minute interval from
+ * 00:00:00 (Start of day) to
+ * 23:45:00 (End of day with enough time for 15 min booking)
+ */
+const TIMES = (() => {
+ const end = dayjs().endOf("day");
+ let t: Dayjs = dayjs().startOf("day");
+
+ const times = [];
+ while (t.isBefore(end)) {
+ times.push(t);
+ t = t.add(increment, "minutes");
+ }
+ return times;
+})();
+/** End Time Increments For Select */
+
+type Option = {
+ readonly label: string;
+ readonly value: number;
+};
+
+type TimeRangeFieldProps = {
+ name: string;
+};
+
+const TimeRangeField = ({ name }: TimeRangeFieldProps) => {
+ // Lazy-loaded options, otherwise adding a field has a noticable redraw delay.
+ const [options, setOptions] = useState([]);
+ // const { i18n } = useLocale();
+ const getOption = (time: ConfigType) => ({
+ value: dayjs(time).toDate().valueOf(),
+ label: dayjs(time).utc().format("HH:mm"),
+ // .toLocaleTimeString(i18n.language, { minute: "numeric", hour: "numeric" }),
+ });
+
+ const timeOptions = useCallback((offsetOrLimit: { offset?: number; limit?: number } = {}) => {
+ const { limit, offset } = offsetOrLimit;
+ return TIMES.filter((time) => (!limit || time.isBefore(limit)) && (!offset || time.isAfter(offset))).map(
+ (t) => getOption(t)
+ );
+ }, []);
+
+ return (
+ <>
+ (
+ setOptions(timeOptions())}
+ onBlur={() => setOptions([])}
+ defaultValue={getOption(value)}
+ onChange={(option) => onChange(new Date(option?.value as number))}
+ />
+ )}
+ />
+ -
+ (
+ setOptions(timeOptions())}
+ onBlur={() => setOptions([])}
+ defaultValue={getOption(value)}
+ onChange={(option) => onChange(new Date(option?.value as number))}
+ />
+ )}
+ />
+ >
+ );
+};
+
+type ScheduleBlockProps = {
+ day: number;
+ weekday: string;
+ name: string;
+};
+
+const ScheduleBlock = ({ name, day, weekday }: ScheduleBlockProps) => {
+ const { t } = useLocale();
+ const { fields, append, remove, replace } = useFieldArray({
+ name: `${name}.${day}`,
+ });
+
+ const handleAppend = () => {
+ // FIXME: Fix type-inference, can't get this to work. @see https://github.com/react-hook-form/react-hook-form/issues/4499
+ const nextRangeStart = dayjs((fields[fields.length - 1] as unknown as TimeRange).end);
+ const nextRangeEnd = dayjs(nextRangeStart).add(1, "hour");
+
+ if (nextRangeEnd.isBefore(nextRangeStart.endOf("day"))) {
+ return append({
+ start: nextRangeStart.toDate(),
+ end: nextRangeEnd.toDate(),
+ });
+ }
+ };
+
+ return (
+
+
+
+ 0}
+ onChange={(e) => (e.target.checked ? replace([defaultDayRange]) : replace([]))}
+ className="inline-block border-gray-300 rounded-sm focus:ring-neutral-500 text-neutral-900"
+ />
+ {weekday}
+
+
+
+ {fields.map((field, index) => (
+
+
+
+
+
remove(index)}
+ />
+
+ ))}
+
{!fields.length && t("no_availability")}
+
+
+ 0 ? "visible" : "invisible"}
+ StartIcon={PlusIcon}
+ onClick={handleAppend}
+ />
+
+
+ );
+};
+
+const Schedule = ({ name }: { name: string }) => {
+ const { i18n } = useLocale();
+ return (
+
+ {weekdayNames(i18n.language).map((weekday, num) => (
+
+ ))}
+
+ );
+};
+
+export default Schedule;
diff --git a/components/ui/form/Select.tsx b/components/ui/form/Select.tsx
index a7f768df..c5fa0fa1 100644
--- a/components/ui/form/Select.tsx
+++ b/components/ui/form/Select.tsx
@@ -1,29 +1,33 @@
-import React, { PropsWithChildren } from "react";
-import Select, { components, NamedProps } from "react-select";
+import React from "react";
+import ReactSelect, { components, GroupBase, Props } from "react-select";
import classNames from "@lib/classNames";
-export const SelectComp = (props: PropsWithChildren) => (
- ({
- ...theme,
- borderRadius: 2,
- colors: {
- ...theme.colors,
- primary: "rgba(17, 17, 17, var(--tw-bg-opacity))",
- primary50: "rgba(17, 17, 17, var(--tw-bg-opacity))",
- primary25: "rgba(244, 245, 246, var(--tw-bg-opacity))",
- },
- })}
- components={{
- ...components,
- IndicatorSeparator: () => null,
- }}
- className={classNames("text-sm shadow-sm focus:border-primary-500", props.className)}
- {...props}
- />
-);
+function Select<
+ Option,
+ IsMulti extends boolean = false,
+ Group extends GroupBase = GroupBase
+>({ className, ...props }: Props ) {
+ return (
+ ({
+ ...theme,
+ borderRadius: 2,
+ colors: {
+ ...theme.colors,
+ primary: "rgba(17, 17, 17, var(--tw-bg-opacity))",
+ primary50: "rgba(17, 17, 17, var(--tw-bg-opacity))",
+ primary25: "rgba(244, 245, 246, var(--tw-bg-opacity))",
+ },
+ })}
+ components={{
+ ...components,
+ IndicatorSeparator: () => null,
+ }}
+ className={classNames("text-sm shadow-sm focus:border-primary-500", className)}
+ {...props}
+ />
+ );
+}
-SelectComp.displayName = "Select";
-
-export default SelectComp;
+export default Select;
diff --git a/components/ui/form/radio-area/RadioAreaGroup.tsx b/components/ui/form/radio-area/RadioAreaGroup.tsx
index e22f1a91..eace7064 100644
--- a/components/ui/form/radio-area/RadioAreaGroup.tsx
+++ b/components/ui/form/radio-area/RadioAreaGroup.tsx
@@ -12,7 +12,7 @@ const RadioArea = (props: RadioAreaProps) => {
-
+
-
-
-
-
+
+
+
+
-
+
{t("change_bookings_availability")}
@@ -59,7 +61,7 @@ export default function SetTimesModal(props) {
-
{t("start_time")}
+
{t("start_time")}
{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-black 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}
/>
-
:
+
:
{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-black 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}
/>
-
{t("end_time")}
+
{t("end_time")}
{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-black 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}
/>
-
:
+
:
{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-black 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}
/>
-
+
{t("save")}
-
-
+
+
{t("cancel")}
-
+
diff --git a/docker-compose.yml b/docker-compose.yml
index 55d88a66..7e091126 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -10,6 +10,7 @@ services:
volumes:
- db_data:/var/lib/postgresql/data
environment:
+ POSTGRES_DB: "cal-saml"
POSTGRES_PASSWORD: ""
POSTGRES_HOST_AUTH_METHOD: trust
volumes:
diff --git a/docs/saml-setup.md b/docs/saml-setup.md
new file mode 100644
index 00000000..29792aec
--- /dev/null
+++ b/docs/saml-setup.md
@@ -0,0 +1,27 @@
+# SAML Registration with Identity Providers
+
+This guide explains the settings you’d need to use to configure SAML with your Identity Provider. Once this is set up you should get an XML metadata file that should then be uploaded on your Cal.com self-hosted instance.
+
+> **Note:** Please do not add a trailing slash at the end of the URLs. Create them exactly as shown below.
+
+**Assertion consumer service URL / Single Sign-On URL / Destination URL:** [http://localhost:3000/api/auth/saml/callback](http://localhost:3000/api/auth/saml/callback) [Replace this with the URL for your self-hosted Cal instance]
+
+**Entity ID / Identifier / Audience URI / Audience Restriction:** https://saml.cal.com
+
+**Response:** Signed
+
+**Assertion Signature:** Signed
+
+**Signature Algorithm:** RSA-SHA256
+
+**Assertion Encryption:** Unencrypted
+
+**Mapping Attributes / Attribute Statements:**
+
+id -> user.id
+
+email -> user.email
+
+firstName -> user.firstName
+
+lastName -> user.lastName
diff --git a/ee/LICENSE b/ee/LICENSE
index f63f1ebe..60f0a2a1 100644
--- a/ee/LICENSE
+++ b/ee/LICENSE
@@ -23,10 +23,10 @@ Subject to the foregoing, it is forbidden to copy, merge, publish, distribute, s
and/or sell the Software.
This EE License applies only to the part of this Software that is not distributed under
-the MIT license. Any part of this Software distributed under the MIT license or which
+the AGPLv3 license. Any part of this Software distributed under the MIT license or which
is served client-side as an image, font, cascading stylesheet (CSS), file which produces
or is compiled, arranged, augmented, or combined into client-side JavaScript, in whole or
-in part, is copyrighted under the MIT license. The full text of this EE License shall
+in part, is copyrighted under the AGPLv3 license. The full text of this EE License shall
be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
@@ -39,4 +39,4 @@ SOFTWARE.
For all third party components incorporated into the Cal.com Software, those
components are licensed under the original license provided by the owner of the
-applicable component.
\ No newline at end of file
+applicable component.
diff --git a/ee/README.md b/ee/README.md
index b67429ee..f2f9aa48 100644
--- a/ee/README.md
+++ b/ee/README.md
@@ -25,3 +25,14 @@ The [/ee](https://github.com/calendso/calendso/tree/main/ee) subfolder is the pl
6. Open [Stripe Webhooks](https://dashboard.stripe.com/webhooks) and add `
/api/integrations/stripepayment/webhook` as webhook for connected applications.
7. Select all `payment_intent` events for the webhook.
8. Copy the webhook secret (`whsec_...`) to `STRIPE_WEBHOOK_SECRET` in the .env file.
+
+## Setting up SAML login
+
+1. Set SAML_DATABASE_URL to a postgres database. Please use a different database than the main Cal instance since the migrations are separate for this database. For example `postgresql://postgres:@localhost:5450/cal-saml`
+2. Set SAML_ADMINS to a comma separated list of admin emails from where the SAML metadata can be uploaded and configured.
+3. Create a SAML application with your Identity Provider (IdP) using the instructions here - [SAML Setup](../docs/saml-setup.md)
+4. Remember to configure access to the IdP SAML app for all your users (who need access to Cal).
+5. You will need the XML metadata from your IdP later, so keep it accessible.
+6. Log in to one of the admin accounts configured in SAML_ADMINS and then navigate to Settings -> Security.
+7. You should see a SAML configuration section, copy and paste the XML metadata from step 5 and click on Save.
+8. Your provisioned users can now log into Cal using SAML.
diff --git a/ee/components/TrialBanner.tsx b/ee/components/TrialBanner.tsx
new file mode 100644
index 00000000..eedb0cb5
--- /dev/null
+++ b/ee/components/TrialBanner.tsx
@@ -0,0 +1,35 @@
+import dayjs from "dayjs";
+
+import { TRIAL_LIMIT_DAYS } from "@lib/config/constants";
+import { useLocale } from "@lib/hooks/useLocale";
+
+import { useMeQuery } from "@components/Shell";
+import Button from "@components/ui/Button";
+
+const TrialBanner = () => {
+ const { t } = useLocale();
+ const query = useMeQuery();
+ const user = query.data;
+
+ if (!user || user.plan !== "TRIAL") return null;
+
+ const trialDaysLeft = dayjs(user.createdDate)
+ .add(TRIAL_LIMIT_DAYS + 1, "day")
+ .diff(dayjs(), "day");
+
+ return (
+
+
{t("trial_days_left", { days: trialDaysLeft })}
+
+ {t("upgrade_now")}
+
+
+ );
+};
+
+export default TrialBanner;
diff --git a/ee/components/saml/Configuration.tsx b/ee/components/saml/Configuration.tsx
new file mode 100644
index 00000000..9482199b
--- /dev/null
+++ b/ee/components/saml/Configuration.tsx
@@ -0,0 +1,162 @@
+import React, { useEffect, useState, useRef } from "react";
+
+import { useLocale } from "@lib/hooks/useLocale";
+import showToast from "@lib/notification";
+import { trpc } from "@lib/trpc";
+
+import { Dialog, DialogTrigger } from "@components/Dialog";
+import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
+import { Alert } from "@components/ui/Alert";
+import Badge from "@components/ui/Badge";
+import Button from "@components/ui/Button";
+
+export default function SAMLConfiguration({
+ teamsView,
+ teamId,
+}: {
+ teamsView: boolean;
+ teamId: null | undefined | number;
+}) {
+ const [isSAMLLoginEnabled, setIsSAMLLoginEnabled] = useState(false);
+ const [samlConfig, setSAMLConfig] = useState(null);
+
+ const query = trpc.useQuery(["viewer.showSAMLView", { teamsView, teamId }]);
+
+ useEffect(() => {
+ const data = query.data;
+ setIsSAMLLoginEnabled(data?.isSAMLLoginEnabled ?? false);
+ setSAMLConfig(data?.provider ?? null);
+ }, [query.data]);
+
+ const mutation = trpc.useMutation("viewer.updateSAMLConfig", {
+ onSuccess: (data: { provider: string | undefined }) => {
+ showToast(t("saml_config_updated_successfully"), "success");
+ setHasErrors(false); // dismiss any open errors
+ setSAMLConfig(data?.provider ?? null);
+ samlConfigRef.current.value = "";
+ },
+ onError: () => {
+ setHasErrors(true);
+ setErrorMessage(t("saml_configuration_update_failed"));
+ document?.getElementsByTagName("main")[0]?.scrollTo({ top: 0, behavior: "smooth" });
+ },
+ });
+
+ const deleteMutation = trpc.useMutation("viewer.deleteSAMLConfig", {
+ onSuccess: () => {
+ showToast(t("saml_config_deleted_successfully"), "success");
+ setHasErrors(false); // dismiss any open errors
+ setSAMLConfig(null);
+ samlConfigRef.current.value = "";
+ },
+ onError: () => {
+ setHasErrors(true);
+ setErrorMessage(t("saml_configuration_delete_failed"));
+ document?.getElementsByTagName("main")[0]?.scrollTo({ top: 0, behavior: "smooth" });
+ },
+ });
+
+ const samlConfigRef = useRef() as React.MutableRefObject;
+
+ const [hasErrors, setHasErrors] = useState(false);
+ const [errorMessage, setErrorMessage] = useState("");
+
+ async function updateSAMLConfigHandler(event: React.FormEvent) {
+ event.preventDefault();
+
+ const rawMetadata = samlConfigRef.current.value;
+
+ mutation.mutate({
+ rawMetadata: rawMetadata,
+ teamId,
+ });
+ }
+
+ async function deleteSAMLConfigHandler(event: React.MouseEvent) {
+ event.preventDefault();
+
+ deleteMutation.mutate({
+ teamId,
+ });
+ }
+
+ const { t } = useLocale();
+ return (
+ <>
+
+
+ {isSAMLLoginEnabled ? (
+ <>
+
+
+ {t("saml_configuration")}
+
+ {samlConfig ? t("enabled") : t("disabled")}
+
+ {samlConfig ? (
+ <>
+
+ {samlConfig ? samlConfig : ""}
+
+ >
+ ) : null}
+
+
+
+ {samlConfig ? (
+
+
+
+ {
+ e.stopPropagation();
+ }}>
+ {t("delete_saml_configuration")}
+
+
+
+ {t("delete_saml_configuration_confirmation_message")}
+
+
+
+ ) : (
+ {!samlConfig ? t("saml_not_configured_yet") : ""}
+ )}
+
+ {t("saml_configuration_description")}
+
+
+ {hasErrors && }
+
+
+
+
+
+ {t("save")}
+
+
+
+
+ >
+ ) : null}
+ >
+ );
+}
diff --git a/ee/components/stripe/Payment.tsx b/ee/components/stripe/Payment.tsx
index b547bb18..d5a3d32d 100644
--- a/ee/components/stripe/Payment.tsx
+++ b/ee/components/stripe/Payment.tsx
@@ -1,26 +1,24 @@
-import { CardElement, useStripe, useElements } from "@stripe/react-stripe-js";
-import { StripeCardElementChangeEvent } from "@stripe/stripe-js";
+import { CardElement, useElements, useStripe } from "@stripe/react-stripe-js";
+import stripejs, { StripeCardElementChangeEvent, StripeElementLocale } from "@stripe/stripe-js";
import { useRouter } from "next/router";
import { stringify } from "querystring";
-import React, { useState } from "react";
-import { SyntheticEvent } from "react";
+import React, { SyntheticEvent, useEffect, useState } from "react";
import { PaymentData } from "@ee/lib/stripe/server";
-import useDarkMode from "@lib/core/browser/useDarkMode";
import { useLocale } from "@lib/hooks/useLocale";
import Button from "@components/ui/Button";
-const CARD_OPTIONS = {
+const CARD_OPTIONS: stripejs.StripeCardElementOptions = {
iconStyle: "solid" as const,
classes: {
- base: "block p-2 w-full border-solid border-2 border-gray-300 rounded-md shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus-within:ring-black focus-within:border-black sm:text-sm",
+ base: "block p-2 w-full border-solid border-2 border-gray-300 rounded-md shadow-sm dark:bg-black dark:text-white dark:border-black focus-within:ring-black focus-within:border-black sm:text-sm",
},
style: {
base: {
- color: "#000",
- iconColor: "#000",
+ color: "#666",
+ iconColor: "#666",
fontFamily: "ui-sans-serif, system-ui",
fontSmoothing: "antialiased",
fontSize: "16px",
@@ -29,7 +27,7 @@ const CARD_OPTIONS = {
},
},
},
-};
+} as const;
type Props = {
payment: {
@@ -47,19 +45,16 @@ type States =
| { status: "ok" };
export default function PaymentComponent(props: Props) {
- const { t } = useLocale();
+ const { t, i18n } = useLocale();
const router = useRouter();
const { name, date } = router.query;
const [state, setState] = useState({ status: "idle" });
const stripe = useStripe();
const elements = useElements();
- const { isDarkMode } = useDarkMode();
- if (isDarkMode) {
- CARD_OPTIONS.style.base.color = "#fff";
- CARD_OPTIONS.style.base.iconColor = "#fff";
- CARD_OPTIONS.style.base["::placeholder"].color = "#fff";
- }
+ useEffect(() => {
+ elements?.update({ locale: i18n.language as StripeElementLocale });
+ }, [elements, i18n.language]);
const handleChange = async (event: StripeCardElementChangeEvent) => {
// Listen for changes in the CardElement
diff --git a/ee/components/team/availability/TeamAvailabilityModal.tsx b/ee/components/team/availability/TeamAvailabilityModal.tsx
new file mode 100644
index 00000000..7cd287a8
--- /dev/null
+++ b/ee/components/team/availability/TeamAvailabilityModal.tsx
@@ -0,0 +1,95 @@
+import dayjs from "dayjs";
+import utc from "dayjs/plugin/utc";
+import React, { useState, useEffect } from "react";
+import TimezoneSelect, { ITimezone } from "react-timezone-select";
+
+import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar";
+import { trpc, inferQueryOutput } from "@lib/trpc";
+
+import Avatar from "@components/ui/Avatar";
+import { DatePicker } from "@components/ui/form/DatePicker";
+import Select from "@components/ui/form/Select";
+
+import TeamAvailabilityTimes from "./TeamAvailabilityTimes";
+
+dayjs.extend(utc);
+
+interface Props {
+ team?: inferQueryOutput<"viewer.teams.get">;
+ member?: inferQueryOutput<"viewer.teams.get">["members"][number];
+}
+
+export default function TeamAvailabilityModal(props: Props) {
+ const utils = trpc.useContext();
+ const [selectedDate, setSelectedDate] = useState(dayjs());
+ const [selectedTimeZone, setSelectedTimeZone] = useState(
+ localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()
+ );
+ const [frequency, setFrequency] = useState<15 | 30 | 60>(30);
+
+ useEffect(() => {
+ utils.invalidateQueries(["viewer.teams.getMemberAvailability"]);
+ }, [utils, selectedTimeZone, selectedDate]);
+
+ return (
+
+
+
+
+
+ {props.member?.name}
+ {props.member?.email}
+
+
+
+ Date
+ {
+ setSelectedDate(dayjs(newDate));
+ }}
+ />
+
+
+ Timezone
+ setSelectedTimeZone(timezone.value)}
+ classNamePrefix="react-select"
+ className="block w-full mt-1 border border-gray-300 rounded-sm shadow-sm react-select-container focus:ring-neutral-800 focus:border-neutral-800 sm:text-sm"
+ />
+
+
+ Slot Length
+ setFrequency(newFrequency?.value ?? 30)}
+ />
+
+
+ {props.team && props.member && (
+
+ )}
+
+ );
+}
diff --git a/ee/components/team/availability/TeamAvailabilityScreen.tsx b/ee/components/team/availability/TeamAvailabilityScreen.tsx
new file mode 100644
index 00000000..e3653e07
--- /dev/null
+++ b/ee/components/team/availability/TeamAvailabilityScreen.tsx
@@ -0,0 +1,119 @@
+import dayjs from "dayjs";
+import React, { useState, useEffect, CSSProperties } from "react";
+import TimezoneSelect, { ITimezone } from "react-timezone-select";
+import AutoSizer from "react-virtualized-auto-sizer";
+import { FixedSizeList as List } from "react-window";
+
+import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar";
+import { trpc, inferQueryOutput } from "@lib/trpc";
+
+import Avatar from "@components/ui/Avatar";
+import { DatePicker } from "@components/ui/form/DatePicker";
+import Select from "@components/ui/form/Select";
+
+import TeamAvailabilityTimes from "./TeamAvailabilityTimes";
+
+interface Props {
+ team?: inferQueryOutput<"viewer.teams.get">;
+}
+
+export default function TeamAvailabilityScreen(props: Props) {
+ const utils = trpc.useContext();
+ const [selectedDate, setSelectedDate] = useState(dayjs());
+ const [selectedTimeZone, setSelectedTimeZone] = useState(
+ localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()
+ );
+ const [frequency, setFrequency] = useState<15 | 30 | 60>(30);
+
+ useEffect(() => {
+ utils.invalidateQueries(["viewer.teams.getMemberAvailability"]);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [selectedTimeZone, selectedDate]);
+
+ const Item = ({ index, style }: { index: number; style: CSSProperties }) => {
+ const member = props.team?.members?.[index];
+ if (!member) return <>>;
+
+ return (
+
+
+
+
+ {member?.name}
+ {member?.email}
+
+
+ }
+ />
+
+ );
+ };
+
+ return (
+
+
+
+ Date
+ {
+ setSelectedDate(dayjs(newDate));
+ }}
+ />
+
+
+ Timezone
+ setSelectedTimeZone(timezone.value)}
+ classNamePrefix="react-select"
+ className="w-full border border-gray-300 rounded-sm shadow-sm react-select-container focus:ring-neutral-800 focus:border-neutral-800 sm:text-sm"
+ />
+
+
+ Slot Length
+ setFrequency(newFrequency?.value ?? 30)}
+ />
+
+
+
+
+ {({ height, width }) => (
+
+ {Item}
+
+ )}
+
+
+
+ );
+}
diff --git a/ee/components/team/availability/TeamAvailabilityTimes.tsx b/ee/components/team/availability/TeamAvailabilityTimes.tsx
new file mode 100644
index 00000000..3190e960
--- /dev/null
+++ b/ee/components/team/availability/TeamAvailabilityTimes.tsx
@@ -0,0 +1,70 @@
+import classNames from "classnames";
+import dayjs, { Dayjs } from "dayjs";
+import utc from "dayjs/plugin/utc";
+import React from "react";
+import { ITimezone } from "react-timezone-select";
+
+import getSlots from "@lib/slots";
+import { trpc } from "@lib/trpc";
+
+import Loader from "@components/Loader";
+
+interface Props {
+ teamId: number;
+ memberId: number;
+ selectedDate: Dayjs;
+ selectedTimeZone: ITimezone;
+ frequency: number;
+ HeaderComponent?: React.ReactNode;
+ className?: string;
+}
+
+dayjs.extend(utc);
+
+export default function TeamAvailabilityTimes(props: Props) {
+ const { data, isLoading } = trpc.useQuery(
+ [
+ "viewer.teams.getMemberAvailability",
+ {
+ teamId: props.teamId,
+ memberId: props.memberId,
+ dateFrom: props.selectedDate.toString(),
+ dateTo: props.selectedDate.add(1, "day").toString(),
+ timezone: `${props.selectedTimeZone.toString()}`,
+ },
+ ],
+ {
+ refetchOnWindowFocus: false,
+ }
+ );
+
+ const times = !isLoading
+ ? getSlots({
+ frequency: props.frequency,
+ inviteeDate: props.selectedDate,
+ workingHours: data?.workingHours || [],
+ minimumBookingNotice: 0,
+ })
+ : [];
+
+ return (
+
+ {props.HeaderComponent}
+ {isLoading && times.length === 0 &&
}
+ {!isLoading && times.length === 0 && (
+
+ No Available Slots
+
+ )}
+ {times.map((time) => (
+
+ ))}
+
+ );
+}
diff --git a/ee/lib/stripe/client.ts b/ee/lib/stripe/client.ts
index f326e54f..a89840f0 100644
--- a/ee/lib/stripe/client.ts
+++ b/ee/lib/stripe/client.ts
@@ -1,4 +1,5 @@
-import { loadStripe, Stripe } from "@stripe/stripe-js";
+import { Stripe } from "@stripe/stripe-js";
+import { loadStripe } from "@stripe/stripe-js/pure";
import { stringify } from "querystring";
import { Maybe } from "@trpc/server";
diff --git a/ee/lib/stripe/server.ts b/ee/lib/stripe/server.ts
index ca953266..93eee207 100644
--- a/ee/lib/stripe/server.ts
+++ b/ee/lib/stripe/server.ts
@@ -1,16 +1,20 @@
-import { PaymentType } from "@prisma/client";
+import { PaymentType, Prisma } from "@prisma/client";
import Stripe from "stripe";
-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 { CalendarEvent } from "@lib/integrations/calendar/interfaces/Calendar";
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_publishable_key: string;
stripeAccount: string;
@@ -34,7 +38,7 @@ export async function handlePayment(
price: number;
currency: string;
},
- stripeCredential: { key: JsonValue },
+ stripeCredential: { key: Prisma.JsonValue },
booking: {
user: { email: string | null; name: string | null; timeZone: string } | null;
id: number;
@@ -60,7 +64,11 @@ export async function handlePayment(
data: {
type: PaymentType.STRIPE,
uid: uuidv4(),
- bookingId: booking.id,
+ booking: {
+ connect: {
+ id: booking.id,
+ },
+ },
amount: selectedEventType.price,
fee: paymentFee,
currency: selectedEventType.currency,
@@ -69,20 +77,21 @@ export async function handlePayment(
data: Object.assign({}, paymentIntent, {
stripe_publishable_key,
stripeAccount: stripe_user_id,
- }) as PaymentData as unknown as JsonValue,
+ }) /* We should treat this */ as PaymentData /* but Prisma doesn't know how to handle it, so it we treat it */ as unknown /* and then */ as Prisma.InputJsonValue,
externalId: paymentIntent.id,
},
});
- 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;
}
@@ -97,7 +106,7 @@ export async function refund(
success: boolean;
refunded: boolean;
externalId: string;
- data: JsonValue;
+ data: Prisma.JsonValue;
type: PaymentType;
}[];
},
@@ -107,7 +116,7 @@ export async function refund(
const payment = booking.payment.find((e) => e.success && !e.refunded);
if (!payment) return;
- if (payment.type != PaymentType.STRIPE) {
+ if (payment.type !== PaymentType.STRIPE) {
await handleRefundError({
event: calEvent,
reason: "cannot refund non Stripe payment",
@@ -153,11 +162,51 @@ 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 },
+ });
+}
+
+const userType = Prisma.validator()({
+ select: {
+ email: true,
+ metadata: true,
+ },
+});
+
+type UserType = Prisma.UserGetPayload;
+export async function getStripeCustomerId(user: UserType): Promise {
+ let customerId: string | null = null;
+
+ if (user?.metadata && typeof user.metadata === "object" && "stripeCustomerId" in user.metadata) {
+ customerId = (user?.metadata as Prisma.JsonObject).stripeCustomerId as string;
+ } else {
+ /* We fallback to finding the customer by email (which is not optimal) */
+ const customersReponse = await stripe.customers.list({
+ email: user.email,
+ limit: 1,
+ });
+ if (customersReponse.data[0]?.id) {
+ customerId = customersReponse.data[0].id;
+ }
}
+
+ return customerId;
+}
+
+export async function deleteStripeCustomer(user: UserType): Promise {
+ const customerId = await getStripeCustomerId(user);
+
+ if (!customerId) {
+ console.warn("No stripe customer found for user:" + user.email);
+ return null;
+ }
+
+ //delete stripe customer
+ const deletedCustomer = await stripe.customers.del(customerId);
+
+ return deletedCustomer.id;
}
export default stripe;
diff --git a/ee/pages/api/integrations/stripepayment/add.ts b/ee/pages/api/integrations/stripepayment/add.ts
index 58046bf0..76eed6d4 100644
--- a/ee/pages/api/integrations/stripepayment/add.ts
+++ b/ee/pages/api/integrations/stripepayment/add.ts
@@ -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",
diff --git a/ee/pages/api/integrations/stripepayment/portal.ts b/ee/pages/api/integrations/stripepayment/portal.ts
index 17c68085..ec0dc3d6 100644
--- a/ee/pages/api/integrations/stripepayment/portal.ts
+++ b/ee/pages/api/integrations/stripepayment/portal.ts
@@ -1,6 +1,6 @@
import type { NextApiRequest, NextApiResponse } from "next";
-import stripe from "@ee/lib/stripe/server";
+import stripe, { getStripeCustomerId } from "@ee/lib/stripe/server";
import { getSession } from "@lib/auth";
import prisma from "@lib/prisma";
@@ -23,6 +23,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
select: {
email: true,
name: true,
+ metadata: true,
},
});
@@ -31,26 +32,16 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
message: "User email not found",
});
- /**
- * TODO: We need to find a better way to get our users customer id from Stripe,
- * since the email is not an unique field in Stripe and we don't save them
- * in our DB as of now.
- **/
- const customersReponse = await stripe.customers.list({
- email: user?.email || "",
- limit: 1,
- });
-
- const [customer] = customersReponse.data;
+ const customerId = await getStripeCustomerId(user);
- if (!customer?.id)
+ if (!customerId)
return res.status(404).json({
message: "Stripe customer id not found",
});
const return_url = `${process.env.BASE_URL}/settings/billing`;
const stripeSession = await stripe.billingPortal.sessions.create({
- customer: customer.id,
+ customer: customerId,
return_url,
});
diff --git a/ee/pages/api/integrations/stripepayment/webhook.ts b/ee/pages/api/integrations/stripepayment/webhook.ts
index b68e80b1..817c4922 100644
--- a/ee/pages/api/integrations/stripepayment/webhook.ts
+++ b/ee/pages/api/integrations/stripepayment/webhook.ts
@@ -4,12 +4,12 @@ import Stripe from "stripe";
import stripe from "@ee/lib/stripe/server";
-import { CalendarEvent } from "@lib/calendarClient";
-import { HttpError } from "@lib/core/http/error";
+import { IS_PRODUCTION } from "@lib/config/constants";
+import { HttpError as HttpCode } from "@lib/core/http/error";
import { getErrorFromUnknown } from "@lib/errors";
import EventManager from "@lib/events/EventManager";
+import { CalendarEvent } from "@lib/integrations/calendar/interfaces/Calendar";
import prisma from "@lib/prisma";
-import { Ensure } from "@lib/types/utils";
import { getTranslation } from "@server/lib/i18n";
@@ -30,6 +30,7 @@ async function handlePaymentSuccess(event: Stripe.Event) {
booking: {
update: {
paid: true,
+ confirmed: true,
},
},
},
@@ -48,6 +49,7 @@ async function handlePaymentSuccess(event: Stripe.Event) {
id: true,
uid: true,
paid: true,
+ destinationCalendar: true,
user: {
select: {
id: true,
@@ -56,6 +58,7 @@ async function handlePaymentSuccess(event: Stripe.Event) {
email: true,
name: true,
locale: true,
+ destinationCalendar: true,
},
},
},
@@ -74,28 +77,46 @@ async function handlePaymentSuccess(event: Stripe.Event) {
if (!user) throw new Error("No user found");
const t = await getTranslation(user.locale ?? "en", "common");
+ const attendeesListPromises = booking.attendees.map(async (attendee) => {
+ return {
+ name: attendee.name,
+ email: attendee.email,
+ timeZone: attendee.timeZone,
+ language: {
+ translate: await getTranslation(attendee.locale ?? "en", "common"),
+ locale: attendee.locale ?? "en",
+ },
+ };
+ });
- const evt: Ensure = {
+ const attendeesList = await Promise.all(attendeesListPromises);
+
+ const evt: CalendarEvent = {
type: booking.title,
title: booking.title,
description: booking.description || undefined,
startTime: booking.startTime.toISOString(),
endTime: booking.endTime.toISOString(),
- organizer: { email: user.email!, name: user.name!, timeZone: user.timeZone },
- attendees: booking.attendees,
+ organizer: {
+ email: user.email!,
+ name: user.name!,
+ timeZone: user.timeZone,
+ language: { translate: t, locale: user.locale ?? "en" },
+ },
+ attendees: attendeesList,
uid: booking.uid,
- language: t,
+ destinationCalendar: booking.destinationCalendar || user.destinationCalendar,
};
if (booking.location) evt.location = booking.location;
if (booking.confirmed) {
- const eventManager = new EventManager(user.credentials);
+ const eventManager = new EventManager(user);
const scheduleResult = await eventManager.create(evt);
await prisma.booking.update({
where: {
- id: payment.bookingId,
+ id: booking.id,
},
data: {
references: {
@@ -104,49 +125,34 @@ async function handlePaymentSuccess(event: Stripe.Event) {
},
});
}
+
+ throw new HttpCode({
+ statusCode: 200,
+ message: `Booking with id '${booking.id}' was paid and confirmed.`,
+ });
}
type WebhookHandler = (event: Stripe.Event) => Promise;
const webhookHandlers: Record = {
"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) {
try {
if (req.method !== "POST") {
- throw new HttpError({ statusCode: 405, message: "Method Not Allowed" });
+ throw new HttpCode({ statusCode: 405, message: "Method Not Allowed" });
}
const sig = req.headers["stripe-signature"];
if (!sig) {
- throw new HttpError({ statusCode: 400, message: "Missing stripe-signature" });
+ throw new HttpCode({ statusCode: 400, message: "Missing stripe-signature" });
}
if (!process.env.STRIPE_WEBHOOK_SECRET) {
- throw new HttpError({ statusCode: 500, message: "Missing process.env.STRIPE_WEBHOOK_SECRET" });
+ throw new HttpCode({ statusCode: 500, message: "Missing process.env.STRIPE_WEBHOOK_SECRET" });
}
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 +160,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 HttpCode({
+ 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;
}
diff --git a/ee/pages/settings/teams/[id]/availability.tsx b/ee/pages/settings/teams/[id]/availability.tsx
new file mode 100644
index 00000000..c96c457d
--- /dev/null
+++ b/ee/pages/settings/teams/[id]/availability.tsx
@@ -0,0 +1,66 @@
+import { useRouter } from "next/router";
+import { useMemo, useState } from "react";
+
+import TeamAvailabilityScreen from "@ee/components/team/availability/TeamAvailabilityScreen";
+
+import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar";
+import { trpc } from "@lib/trpc";
+
+import Loader from "@components/Loader";
+import Shell, { useMeQuery } from "@components/Shell";
+import { Alert } from "@components/ui/Alert";
+import Avatar from "@components/ui/Avatar";
+
+export function TeamAvailabilityPage() {
+ const router = useRouter();
+
+ const [errorMessage, setErrorMessage] = useState("");
+
+ const me = useMeQuery();
+ const isFreeUser = me.data?.plan === "FREE";
+
+ const { data: team, isLoading } = trpc.useQuery(["viewer.teams.get", { teamId: Number(router.query.id) }], {
+ refetchOnWindowFocus: false,
+ onError: (e) => {
+ setErrorMessage(e.message);
+ },
+ });
+
+ // prevent unnecessary re-renders due to shell queries
+ const TeamAvailability = useMemo(() => {
+ return ;
+ }, [team]);
+
+ return (
+
+ )
+ }>
+ {!!errorMessage && }
+ {isLoading && }
+ {isFreeUser ? (
+
+ ) : (
+ TeamAvailability
+ )}
+
+ );
+}
+
+export default TeamAvailabilityPage;
diff --git a/jest.config.ts b/jest.config.ts
index 13aeb6d6..2f719b82 100644
--- a/jest.config.ts
+++ b/jest.config.ts
@@ -14,6 +14,7 @@ const config: Config.InitialOptions = {
"^@components(.*)$": "/components$1",
"^@lib(.*)$": "/lib$1",
"^@server(.*)$": "/server$1",
+ "^@ee(.*)$": "/ee$1",
},
};
diff --git a/jest.playwright.config.js b/jest.playwright.config.js
deleted file mode 100644
index 509a4e6f..00000000
--- a/jest.playwright.config.js
+++ /dev/null
@@ -1,33 +0,0 @@
-const opts = {
- // launch headless on CI, in browser locally
- headless: !!process.env.CI || !!process.env.PLAYWRIGHT_HEADLESS,
- collectCoverage: !!process.env.PLAYWRIGHT_HEADLESS,
- executablePath: process.env.PLAYWRIGHT_CHROME_EXECUTABLE_PATH,
-};
-
-console.log("⚙️ Playwright options:", JSON.stringify(opts, null, 4));
-
-module.exports = {
- verbose: true,
- preset: "jest-playwright-preset",
- transform: {
- "^.+\\.ts$": "ts-jest",
- },
- testMatch: ["/playwright/**/*(*.)@(spec|test).[jt]s?(x)"],
- testEnvironmentOptions: {
- "jest-playwright": {
- browsers: ["chromium" /*, 'firefox', 'webkit'*/],
- exitOnPageError: false,
- launchOptions: {
- headless: opts.headless,
- executablePath: opts.executablePath,
- },
- contextOptions: {
- recordVideo: {
- dir: "playwright/videos",
- },
- },
- collectCoverage: opts.collectCoverage,
- },
- },
-};
diff --git a/lib/CalEventParser.ts b/lib/CalEventParser.ts
index 1b8ca779..b8bbb681 100644
--- a/lib/CalEventParser.ts
+++ b/lib/CalEventParser.ts
@@ -1,151 +1,114 @@
+import { Person } from "ics";
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";
+import { CalendarEvent } from "./integrations/calendar/interfaces/Calendar";
const translator = short();
-export default class CalEventParser {
- protected calEvent: CalendarEvent;
-
- 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 `${this.calEvent.language(
- "need_to_make_a_change"
- )} ${this.calEvent.language(
- "cancel"
- )} ${this.calEvent
- .language("or")
- .toLowerCase()} ${this.calEvent.language(
- "reschedule"
- )}
`;
- }
-
- /**
- * 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 (
- `
-${this.calEvent.language("event_type")}: ${this.calEvent.type}
-${this.calEvent.language("invitee_email")}: ${this.calEvent.attendees[0].email}
-` +
- (this.getLocation()
- ? `${this.calEvent.language("location")}: ${this.getLocation()}
-`
- : "") +
- `${this.calEvent.language("invitee_timezone")}: ${
- this.calEvent.attendees[0].timeZone
- }
-${this.calEvent.language("additional_notes")}: ${this.getDescriptionText()} ` +
- 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) {
+// The odd indentation in this file is necessary because otherwise the leading tabs will be applied into the event description.
+
+export const getWhat = (calEvent: CalendarEvent) => {
+ return `
+${calEvent.organizer.language.translate("what")}:
+${calEvent.type}
+ `;
+};
+
+export const getWhen = (calEvent: CalendarEvent) => {
+ return `
+${calEvent.organizer.language.translate("invitee_timezone")}:
+${calEvent.attendees[0].timeZone}
+ `;
+};
+
+export const getWho = (calEvent: CalendarEvent) => {
+ const attendees = calEvent.attendees
+ .map((attendee) => {
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;
+${attendee?.name || calEvent.organizer.language.translate("guest")}
+${attendee.email}
+ `;
+ })
+ .join("");
+
+ const organizer = `
+${calEvent.organizer.name} - ${calEvent.organizer.language.translate("organizer")}
+${calEvent.organizer.email}
+ `;
+
+ return `
+${calEvent.organizer.language.translate("who")}:
+${organizer + attendees}
+ `;
+};
+
+export const getAdditionalNotes = (calEvent: CalendarEvent) => {
+ return `
+${calEvent.organizer.language.translate("additional_notes")}:
+${calEvent.description}
+ `;
+};
+
+export const getLocation = (calEvent: CalendarEvent) => {
+ let providerName = calEvent.location ? getIntegrationName(calEvent.location) : "";
+
+ if (calEvent.location && calEvent.location.includes("integrations:")) {
+ const location = calEvent.location.split(":")[1];
+ providerName = location[0].toUpperCase() + location.slice(1);
}
- /**
- * Returns an extended description with all important information (as plain text).
- *
- * @protected
- */
- public getRichDescription(): string {
- return stripHtml(this.getRichDescriptionHtml());
+ if (calEvent.videoCallData) {
+ return calEvent.videoCallData.url;
}
- /**
- * 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;
+ if (calEvent.additionInformation?.hangoutLink) {
+ return calEvent.additionInformation.hangoutLink;
}
- /**
- * 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;
+ return providerName || calEvent.location || "";
+};
+
+export const getManageLink = (calEvent: CalendarEvent) => {
+ return `
+${calEvent.organizer.language.translate("need_to_reschedule_or_cancel")}
+${getCancelLink(calEvent)}
+ `;
+};
+
+export const getUid = (calEvent: CalendarEvent): string => {
+ return calEvent.uid ?? translator.fromUUID(uuidv5(JSON.stringify(calEvent), uuidv5.URL));
+};
+
+export const getCancelLink = (calEvent: CalendarEvent): string => {
+ return BASE_URL + "/cancel/" + getUid(calEvent);
+};
+
+export const getRichDescription = (calEvent: CalendarEvent, attendee?: Person) => {
+ // Only the original attendee can make changes to the event
+ // Guests cannot
+
+ if (attendee && attendee === calEvent.attendees[0]) {
+ return `
+${getWhat(calEvent)}
+${getWhen(calEvent)}
+${getWho(calEvent)}
+${calEvent.organizer.language.translate("where")}:
+${getLocation(calEvent)}
+${getAdditionalNotes(calEvent)}
+ `.trim();
}
-}
+
+ return `
+${getWhat(calEvent)}
+${getWhen(calEvent)}
+${getWho(calEvent)}
+${calEvent.organizer.language.translate("where")}:
+${getLocation(calEvent)}
+${getAdditionalNotes(calEvent)}
+${getManageLink(calEvent)}
+ `.trim();
+};
diff --git a/lib/app-providers.tsx b/lib/app-providers.tsx
index 34d83909..47280b98 100644
--- a/lib/app-providers.tsx
+++ b/lib/app-providers.tsx
@@ -1,5 +1,5 @@
import { IdProvider } from "@radix-ui/react-id";
-import { Provider } from "next-auth/client";
+import { SessionProvider } from "next-auth/react";
import { appWithTranslation } from "next-i18next";
import type { AppProps as NextAppProps } from "next/app";
import React, { ComponentProps, ReactNode } from "react";
@@ -23,7 +23,9 @@ type AppPropsWithChildren = AppProps & {
};
const CustomI18nextProvider = (props: AppPropsWithChildren) => {
- const { i18n, locale } = trpc.useQuery(["viewer.i18n"]).data ?? {};
+ const { i18n, locale } = trpc.useQuery(["viewer.i18n"]).data ?? {
+ locale: "en",
+ };
const passedProps = {
...props,
@@ -42,9 +44,9 @@ const AppProviders = (props: AppPropsWithChildren) => {
-
+
{props.children}
-
+
diff --git a/lib/auth.ts b/lib/auth.ts
index b3b262f4..ab23777a 100644
--- a/lib/auth.ts
+++ b/lib/auth.ts
@@ -1,6 +1,7 @@
+import { IdentityProvider } from "@prisma/client";
import { compare, hash } from "bcryptjs";
-import { DefaultSession } from "next-auth";
-import { getSession as getSessionInner, GetSessionOptions } from "next-auth/client";
+import { Session } from "next-auth";
+import { getSession as getSessionInner, GetSessionParams } from "next-auth/react";
export async function hashPassword(password: string) {
const hashedPassword = await hash(password, 12);
@@ -12,16 +13,7 @@ export async function verifyPassword(password: string, hashedPassword: string) {
return isValid;
}
-type DefaultSessionUser = NonNullable;
-type CalendsoSessionUser = DefaultSessionUser & {
- id: number;
- username: string;
-};
-export interface Session extends DefaultSession {
- user?: CalendsoSessionUser;
-}
-
-export async function getSession(options: GetSessionOptions): Promise {
+export async function getSession(options: GetSessionParams): Promise {
const session = await getSessionInner(options);
// that these are equal are ensured in `[...nextauth]`'s callback
@@ -39,4 +31,11 @@ export enum ErrorCode {
IncorrectTwoFactorCode = "incorrect-two-factor-code",
InternalServerError = "internal-server-error",
NewPasswordMatchesOld = "new-password-matches-old",
+ ThirdPartyIdentityProviderEnabled = "third-party-identity-provider-enabled",
}
+
+export const identityProviderNameMap: { [key in IdentityProvider]: string } = {
+ [IdentityProvider.CAL]: "Cal",
+ [IdentityProvider.GOOGLE]: "Google",
+ [IdentityProvider.SAML]: "SAML",
+};
diff --git a/lib/availability.ts b/lib/availability.ts
new file mode 100644
index 00000000..6a8c67ca
--- /dev/null
+++ b/lib/availability.ts
@@ -0,0 +1,126 @@
+import { Availability } from "@prisma/client";
+import dayjs, { ConfigType } from "dayjs";
+import customParseFormat from "dayjs/plugin/customParseFormat";
+import timezone from "dayjs/plugin/timezone";
+import utc from "dayjs/plugin/utc";
+
+import { Schedule, TimeRange, WorkingHours } from "./types/schedule";
+
+dayjs.extend(utc);
+dayjs.extend(timezone);
+dayjs.extend(customParseFormat);
+// sets the desired time in current date, needs to be current date for proper DST translation
+export const defaultDayRange: TimeRange = {
+ start: new Date(new Date().setUTCHours(9, 0, 0, 0)),
+ end: new Date(new Date().setUTCHours(17, 0, 0, 0)),
+};
+
+export const DEFAULT_SCHEDULE: Schedule = [
+ [],
+ [defaultDayRange],
+ [defaultDayRange],
+ [defaultDayRange],
+ [defaultDayRange],
+ [defaultDayRange],
+ [],
+];
+
+export function getAvailabilityFromSchedule(schedule: Schedule): Availability[] {
+ return schedule.reduce((availability: Availability[], times: TimeRange[], day: number) => {
+ const addNewTime = (time: TimeRange) =>
+ ({
+ days: [day],
+ startTime: time.start,
+ endTime: time.end,
+ } as Availability);
+
+ const filteredTimes = times.filter((time) => {
+ let idx;
+ if (
+ (idx = availability.findIndex(
+ (schedule) => schedule.startTime === time.start && schedule.endTime === time.end
+ )) !== -1
+ ) {
+ availability[idx].days.push(day);
+ return false;
+ }
+ return true;
+ });
+ filteredTimes.forEach((time) => {
+ availability.push(addNewTime(time));
+ });
+ return availability;
+ }, [] as Availability[]);
+}
+
+export const MINUTES_IN_DAY = 60 * 24;
+export const MINUTES_DAY_END = MINUTES_IN_DAY - 1;
+export const MINUTES_DAY_START = 0;
+
+/**
+ * Allows "casting" availability (days, startTime, endTime) given in UTC to a timeZone or utcOffset
+ */
+export function getWorkingHours(
+ relativeTimeUnit: {
+ timeZone?: string;
+ utcOffset?: number;
+ },
+ availability: { days: number[]; startTime: ConfigType; endTime: ConfigType }[]
+) {
+ // clearly bail when availability is not set, set everything available.
+ if (!availability.length) {
+ return [
+ {
+ days: [0, 1, 2, 3, 4, 5, 6],
+ // shorthand for: dayjs().startOf("day").tz(timeZone).diff(dayjs.utc().startOf("day"), "minutes")
+ startTime: MINUTES_DAY_START,
+ endTime: MINUTES_DAY_END,
+ },
+ ];
+ }
+
+ const utcOffset = relativeTimeUnit.utcOffset ?? dayjs().tz(relativeTimeUnit.timeZone).utcOffset();
+
+ const workingHours = availability.reduce((workingHours: WorkingHours[], schedule) => {
+ // Get times localised to the given utcOffset/timeZone
+ const startTime =
+ dayjs.utc(schedule.startTime).get("hour") * 60 +
+ dayjs.utc(schedule.startTime).get("minute") -
+ utcOffset;
+ const endTime =
+ dayjs.utc(schedule.endTime).get("hour") * 60 + dayjs.utc(schedule.endTime).get("minute") - utcOffset;
+
+ // add to working hours, keeping startTime and endTimes between bounds (0-1439)
+ const sameDayStartTime = Math.max(MINUTES_DAY_START, Math.min(MINUTES_DAY_END, startTime));
+ const sameDayEndTime = Math.max(MINUTES_DAY_START, Math.min(MINUTES_DAY_END, endTime));
+ if (sameDayStartTime !== sameDayEndTime) {
+ workingHours.push({
+ days: schedule.days,
+ startTime: sameDayStartTime,
+ endTime: sameDayEndTime,
+ });
+ }
+ // check for overflow to the previous day
+ if (startTime < MINUTES_DAY_START || endTime < MINUTES_DAY_START) {
+ workingHours.push({
+ days: schedule.days.map((day) => day - 1),
+ startTime: startTime + MINUTES_IN_DAY,
+ endTime: Math.min(endTime + MINUTES_IN_DAY, MINUTES_DAY_END),
+ });
+ }
+ // else, check for overflow in the next day
+ else if (startTime > MINUTES_DAY_END || endTime > MINUTES_DAY_END) {
+ workingHours.push({
+ days: schedule.days.map((day) => day + 1),
+ startTime: Math.max(startTime - MINUTES_IN_DAY, MINUTES_DAY_START),
+ endTime: endTime - MINUTES_IN_DAY,
+ });
+ }
+
+ return workingHours;
+ }, []);
+
+ workingHours.sort((a, b) => a.startTime - b.startTime);
+
+ return workingHours;
+}
diff --git a/lib/calendarClient.ts b/lib/calendarClient.ts
deleted file mode 100644
index 0e45b834..00000000
--- a/lib/calendarClient.ts
+++ /dev/null
@@ -1,729 +0,0 @@
-/* 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 { TFunction } from "next-i18next";
-
-import { Event, EventResult } from "@lib/events/EventManager";
-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 {
- entryPointType?: string;
- uri?: string;
- label?: string;
- pin?: string;
- accessCode?: string;
- meetingCode?: string;
- passcode?: string;
- password?: string;
-}
-
-export interface AdditionInformation {
- conferenceData?: ConferenceData;
- entryPoints?: EntryPoint[];
- hangoutLink?: string;
-}
-
-export interface CalendarEvent {
- type: string;
- title: string;
- startTime: string;
- endTime: string;
- description?: string | null;
- team?: {
- name: string;
- members: string[];
- };
- location?: string | null;
- organizer: Person;
- attendees: Person[];
- conferenceData?: ConferenceData;
- language: TFunction;
- additionInformation?: AdditionInformation;
- /** If this property exist it we can assume it's a reschedule/update */
- uid?: string | null;
- videoCallData?: VideoCallData;
-}
-
-export interface ConferenceData {
- createRequest: calendar_v3.Schema$CreateConferenceRequest;
-}
-export interface IntegrationCalendar extends Partial {
- primary?: boolean;
- name?: string;
-}
-
-type BufferedBusyTime = { start: string; end: string };
-export interface CalendarApiAdapter {
- createEvent(event: CalendarEvent): Promise;
-
- updateEvent(uid: string, event: CalendarEvent): Promise;
-
- deleteEvent(uid: string): Promise;
-
- getAvailability(
- dateFrom: string,
- dateTo: string,
- selectedCalendars: IntegrationCalendar[]
- ): Promise;
-
- listCalendars(): Promise;
-}
-
-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 {
- 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);
- case "office365_calendar":
- return MicrosoftOffice365Calendar(credential);
- case "caldav_calendar":
- // FIXME types wrong & type casting should not be needed
- return new CalDavCalendar(credential) as never as CalendarApiAdapter;
- case "apple_calendar":
- // FIXME types wrong & type casting should not be needed
- return new AppleCalendar(credential) as never as CalendarApiAdapter;
- }
- return null;
-}
-
-/**
- * @deprecated
- */
-const calendars = (withCredentials: Credential[]): CalendarApiAdapter[] =>
- withCredentials
- .map((cred) => {
- switch (cred.type) {
- case "google_calendar":
- return GoogleCalendar(cred);
- case "office365_calendar":
- return MicrosoftOffice365Calendar(cred);
- case "caldav_calendar":
- return new CalDavCalendar(cred);
- case "apple_calendar":
- return new AppleCalendar(cred);
- default:
- return; // unknown credential, could be legacy? In any case, ignore
- }
- })
- .flatMap((item) => (item ? [item as CalendarApiAdapter] : []));
-
-const getBusyCalendarTimes = (
- withCredentials: Credential[],
- dateFrom: string,
- dateTo: string,
- selectedCalendars: SelectedCalendar[]
-) =>
- Promise.all(
- calendars(withCredentials).map((c) => c.getAvailability(dateFrom, dateTo, selectedCalendars))
- ).then((results) => {
- return results.reduce((acc, availability) => acc.concat(availability), []);
- });
-
-/**
- *
- * @param withCredentials
- * @deprecated
- */
-const listCalendars = (withCredentials: Credential[]) =>
- Promise.all(calendars(withCredentials).map((c) => c.listCalendars())).then((results) =>
- results.reduce((acc, calendars) => acc.concat(calendars), []).filter((c) => c != null)
- );
-
-const createEvent = async (
- credential: Credential,
- calEvent: CalendarEvent,
- noMail: boolean | null = false
-): Promise => {
- 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();
-
- let success = true;
-
- const creationResult = credential
- ? await calendars([credential])[0]
- .createEvent(richEvent)
- .catch((e) => {
- log.error("createEvent failed", e, calEvent);
- success = false;
- return undefined;
- })
- : undefined;
-
- const metadata: AdditionInformation = {};
- if (creationResult) {
- // TODO: Handle created event metadata more elegantly
- metadata.hangoutLink = creationResult.hangoutLink;
- metadata.conferenceData = creationResult.conferenceData;
- metadata.entryPoints = creationResult.entryPoints;
- }
-
- const emailEvent = { ...calEvent, additionInformation: metadata };
-
- if (!noMail) {
- const organizerMail = new EventOrganizerMail(emailEvent);
-
- try {
- await organizerMail.sendEmail();
- } catch (e) {
- console.error("organizerMail.sendEmail failed", e);
- }
- }
-
- return {
- type: credential.type,
- success,
- uid,
- createdEvent: creationResult,
- originalEvent: calEvent,
- };
-};
-
-const updateEvent = async (
- credential: Credential,
- calEvent: CalendarEvent,
- noMail: boolean | null = false
-): Promise => {
- const parser: CalEventParser = new CalEventParser(calEvent);
- const newUid: string = parser.getUid();
- const richEvent: CalendarEvent = parser.asRichEventPlain();
-
- let success = true;
-
- const updateResult =
- credential && calEvent.uid
- ? await calendars([credential])[0]
- .updateEvent(calEvent.uid, richEvent)
- .catch((e) => {
- log.error("updateEvent failed", e, calEvent);
- success = false;
- })
- : null;
-
- if (!noMail) {
- const emailEvent = { ...calEvent, uid: newUid };
- const organizerMail = new EventOrganizerRescheduledMail(emailEvent);
- try {
- await organizerMail.sendEmail();
- } catch (e) {
- console.error("organizerMail.sendEmail failed", e);
- }
- }
-
- return {
- type: credential.type,
- success,
- uid: newUid,
- updatedEvent: updateResult,
- originalEvent: calEvent,
- };
-};
-
-const deleteEvent = (credential: Credential, uid: string): Promise => {
- if (credential) {
- return calendars([credential])[0].deleteEvent(uid);
- }
-
- return Promise.resolve({});
-};
-
-export {
- getBusyCalendarTimes,
- createEvent,
- updateEvent,
- deleteEvent,
- listCalendars,
- getCalendarAdapterOrNull,
-};
diff --git a/lib/config/constants.ts b/lib/config/constants.ts
new file mode 100644
index 00000000..e69f03ed
--- /dev/null
+++ b/lib/config/constants.ts
@@ -0,0 +1,4 @@
+export const BASE_URL = process.env.BASE_URL || `https://${process.env.VERCEL_URL}`;
+export const WEBSITE_URL = process.env.NEXT_PUBLIC_APP_URL || "https://cal.com";
+export const IS_PRODUCTION = process.env.NODE_ENV === "production";
+export const TRIAL_LIMIT_DAYS = 14;
diff --git a/lib/cropImage.ts b/lib/cropImage.ts
index 1efedc0e..45a39ea4 100644
--- a/lib/cropImage.ts
+++ b/lib/cropImage.ts
@@ -45,7 +45,7 @@ export async function getCroppedImg(imageSrc: string, pixelCrop: Area): Promise<
// on very low ratios, the quality of the resize becomes awful. For this reason the resizeRatio is limited to 0.75
if (resizeRatio <= 0.75) {
// With a smaller image, thus improved ratio. Keep doing this until the resizeRatio > 0.75.
- return getCroppedImg(canvas.toDataURL("image/jpeg"), {
+ return getCroppedImg(canvas.toDataURL("image/png"), {
width: canvas.width,
height: canvas.height,
x: 0,
@@ -53,5 +53,5 @@ export async function getCroppedImg(imageSrc: string, pixelCrop: Area): Promise<
});
}
- return canvas.toDataURL("image/jpeg");
+ return canvas.toDataURL("image/png");
}
diff --git a/lib/emails/EventAttendeeMail.ts b/lib/emails/EventAttendeeMail.ts
deleted file mode 100644
index 46c8bcfc..00000000
--- a/lib/emails/EventAttendeeMail.ts
+++ /dev/null
@@ -1,172 +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 (
- `
-
-
-
-
-
-
${this.calEvent.language(
- "your_meeting_has_been_booked"
- )}
-
${this.calEvent.language("emailed_you_and_attendees")}
-
-
-
-
-
-
-
- ${this.calEvent.language("what")}
- ${this.calEvent.type}
-
-
- ${this.calEvent.language("when")}
- ${this.getInviteeStart().format("dddd, LL")} ${this.getInviteeStart().format("h:mma")} (${
- this.calEvent.attendees[0].timeZone
- })
-
-
- ${this.calEvent.language("who")}
-
- ${this.calEvent.team?.name || this.calEvent.organizer.name}
-
- ${this.calEvent.organizer.email && !this.calEvent.team ? this.calEvent.organizer.email : ""}
- ${this.calEvent.team ? this.calEvent.team.members.join(", ") : ""}
-
-
-
-
- ${this.calEvent.language("where")}
- ${this.getLocation()}
-
-
- ${this.calEvent.language("notes")}
- ${this.calEvent.description}
-
-
- ` +
- this.getAdditionalBody() +
- "
" +
- `
-
- ` +
- this.getAdditionalFooter() +
- `
-
-
-
-
- `
- );
- }
-
- /**
- * Adds the video call information to the mail body.
- *
- * @protected
- */
- protected getLocation(): string {
- if (this.calEvent.additionInformation?.hangoutLink) {
- return `${this.calEvent.additionInformation?.hangoutLink} `;
- }
-
- 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 })}:
- ${entryPoint.label}
- `;
- })
- .join(" ");
-
- return `${locations}`;
- }
-
- return this.calEvent.location ? `${this.calEvent.location} ` : "";
- }
-
- protected getAdditionalBody(): string {
- return ``;
- }
-
- protected getAdditionalFooter(): string {
- return this.parser.getChangeEventFooterHtml();
- }
-
- /**
- * Returns the payload object for the nodemailer.
- *
- * @protected
- */
- protected getNodeMailerPayload(): Record {
- 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);
- }
-}
diff --git a/lib/emails/EventAttendeeRescheduledMail.ts b/lib/emails/EventAttendeeRescheduledMail.ts
deleted file mode 100644
index a8eb6647..00000000
--- a/lib/emails/EventAttendeeRescheduledMail.ts
+++ /dev/null
@@ -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 (
- `
-
- ${this.calEvent.language("hi_user_name", { userName: this.calEvent.attendees[0].name })},
-
- ${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())}`,
- })}
- ` +
- this.getAdditionalFooter() +
- `
-
- `
- );
- }
-
- /**
- * Returns the payload object for the nodemailer.
- *
- * @protected
- */
- protected getNodeMailerPayload(): Record {
- 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);
- }
-}
diff --git a/lib/emails/EventMail.ts b/lib/emails/EventMail.ts
deleted file mode 100644
index 833ace99..00000000
--- a/lib/emails/EventMail.ts
+++ /dev/null
@@ -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;
-
- /**
- * 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();
- }
-}
diff --git a/lib/emails/EventOrganizerMail.ts b/lib/emails/EventOrganizerMail.ts
deleted file mode 100644
index a08e40f6..00000000
--- a/lib/emails/EventOrganizerMail.ts
+++ /dev/null
@@ -1,234 +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 } 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)),
- 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 `${this.calEvent.language(
- "need_to_make_a_change"
- )} ${this.calEvent.language(
- "manage_my_bookings"
- )}
`;
- }
-
- protected getImage(): string {
- return `
-
- `;
- }
-
- /**
- * Returns the email text as HTML representation.
- *
- * @protected
- */
- protected getHtmlRepresentation(): string {
- return (
- `
-
-
- ${this.getImage()}
-
${this.getBodyHeader()}
-
-
-
-
-
-
-
- ${this.calEvent.language("what")}
- ${this.calEvent.type}
-
-
- ${this.calEvent.language("when")}
- ${this.getOrganizerStart().format("dddd, LL")} ${this.getOrganizerStart().format("h:mma")} (${
- this.calEvent.organizer.timeZone
- })
-
-
- ${this.calEvent.language("who")}
- ${this.calEvent.attendees[0].name}${this.calEvent.attendees[0].email}
-
-
- ${this.calEvent.language("where")}
- ${this.getLocation()}
-
-
- ${this.calEvent.language("notes")}
- ${this.calEvent.description}
-
-
- ` +
- this.getAdditionalBody() +
- "
" +
- `
-
- ` +
- this.getAdditionalFooter() +
- `
-
-
-
-
- `
- );
- }
-
- /**
- * Adds the video call information to the mail body.
- *
- * @protected
- */
- protected getLocation(): string {
- if (this.calEvent.additionInformation?.hangoutLink) {
- return `${this.calEvent.additionInformation?.hangoutLink} `;
- }
-
- 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 })}:
- ${entryPoint.label}
- `;
- })
- .join(" ");
-
- return `${locations}`;
- }
-
- return this.calEvent.location ? `${this.calEvent.location} ` : "";
- }
-
- protected getAdditionalBody(): string {
- return ``;
- }
- /**
- * Returns the payload object for the nodemailer.
- *
- * @protected
- */
- protected getNodeMailerPayload(): Record {
- 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);
- }
-}
diff --git a/lib/emails/EventOrganizerRefundFailedMail.ts b/lib/emails/EventOrganizerRefundFailedMail.ts
deleted file mode 100644
index 6ac5d00c..00000000
--- a/lib/emails/EventOrganizerRefundFailedMail.ts
+++ /dev/null
@@ -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,
- })} ${this.calEvent.language("error_message", { errorMessage: this.reason })} PaymentId: '${
- this.paymentId
- }'`;
- }
-
- protected getAdditionalBody(): string {
- return "";
- }
-
- protected getImage(): string {
- return `
-
- `;
- }
-
- 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,
- });
- }
-}
diff --git a/lib/emails/EventOrganizerRequestMail.ts b/lib/emails/EventOrganizerRequestMail.ts
deleted file mode 100644
index f7cd2621..00000000
--- a/lib/emails/EventOrganizerRequestMail.ts
+++ /dev/null
@@ -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 `${this.calEvent.language(
- "confirm_or_reject_booking"
- )} `;
- }
-
- protected getImage(): string {
- return `
-
- `;
- }
-
- 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,
- });
- }
-}
diff --git a/lib/emails/EventOrganizerRequestReminderMail.ts b/lib/emails/EventOrganizerRequestReminderMail.ts
deleted file mode 100644
index 53322333..00000000
--- a/lib/emails/EventOrganizerRequestReminderMail.ts
+++ /dev/null
@@ -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,
- });
- }
-}
diff --git a/lib/emails/EventOrganizerRescheduledMail.ts b/lib/emails/EventOrganizerRescheduledMail.ts
deleted file mode 100644
index 5a77e766..00000000
--- a/lib/emails/EventOrganizerRescheduledMail.ts
+++ /dev/null
@@ -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 (
- `
-
- ${this.calEvent.language("hi_user_name", { userName: this.calEvent.organizer.name })},
-
- ${this.calEvent.language("event_has_been_rescheduled")}
-
-
${this.calEvent.language("event_type")}:
- ${this.calEvent.type}
-
-
${this.calEvent.language("invitee_email")}:
-
${this.calEvent.attendees[0].email}
-
` +
- this.getAdditionalBody() +
- (this.calEvent.location
- ? `
-
${this.calEvent.language("location")}:
- ${this.calEvent.location}
-
- `
- : "") +
- `
${this.calEvent.language("invitee_timezone")}:
- ${this.calEvent.attendees[0].timeZone}
-
-
${this.calEvent.language("additional_notes")}:
- ${this.calEvent.description}
- ` +
- this.getAdditionalFooter() +
- `
-
- `
- );
- }
-
- /**
- * Returns the payload object for the nodemailer.
- *
- * @protected
- */
- protected getNodeMailerPayload(): Record {
- 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);
- }
-}
diff --git a/lib/emails/EventPaymentMail.ts b/lib/emails/EventPaymentMail.ts
deleted file mode 100644
index 6eb7edc4..00000000
--- a/lib/emails/EventPaymentMail.ts
+++ /dev/null
@@ -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 (
- `
-
-
-
-
-
-
${this.calEvent.language("meeting_awaiting_payment")}
-
${this.calEvent.language(
- "emailed_you_and_any_other_attendees"
- )}
-
-
-
-
-
-
-
- ${this.calEvent.language("what")}
- ${this.calEvent.type}
-
-
- ${this.calEvent.language("when")}
- ${this.getInviteeStart().format("dddd, LL")} ${this.getInviteeStart().format("h:mma")} (${
- this.calEvent.attendees[0].timeZone
- })
-
-
- ${this.calEvent.language("who")}
- ${this.calEvent.organizer.name}${this.calEvent.organizer.email}
-
-
- ${this.calEvent.language("where")}
- ${this.getLocation()}
-
-
- ${this.calEvent.language("notes")}Notes
- ${this.calEvent.description}
-
-
- ` +
- this.getAdditionalBody() +
- "
" +
- `
-
-
-
-
-
- `
- );
- }
-
- /**
- * Adds the video call information to the mail body.
- *
- * @protected
- */
- protected getLocation(): string {
- if (this.calEvent.additionInformation?.hangoutLink) {
- return `${this.calEvent.additionInformation?.hangoutLink} `;
- }
-
- 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 })}:
- ${entryPoint.label}
- `;
- })
- .join(" ");
-
- return `${locations}`;
- }
-
- return this.calEvent.location ? `${this.calEvent.location} ` : "";
- }
-
- protected getAdditionalBody(): string {
- return `${this.calEvent.language("pay_now")} `;
- }
-
- /**
- * Returns the payload object for the nodemailer.
- *
- * @protected
- */
- protected getNodeMailerPayload(): Record {
- 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);
- }
-}
diff --git a/lib/emails/EventRejectionMail.ts b/lib/emails/EventRejectionMail.ts
deleted file mode 100644
index 53067bbc..00000000
--- a/lib/emails/EventRejectionMail.ts
+++ /dev/null
@@ -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 (
- `
-
-
-
-
-
-
${this.calEvent.language("meeting_request_rejected")}
-
${this.calEvent.language("emailed_you_and_attendees")}
-
- ` +
- `
-
-
-
-
- `
- );
- }
-
- /**
- * Returns the payload object for the nodemailer.
- *
- * @protected
- */
- protected getNodeMailerPayload(): Record {
- 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 "";
- }
-}
diff --git a/lib/emails/VideoEventAttendeeMail.ts b/lib/emails/VideoEventAttendeeMail.ts
deleted file mode 100644
index 6c723ea0..00000000
--- a/lib/emails/VideoEventAttendeeMail.ts
+++ /dev/null
@@ -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 `
- ${this.calEvent.language("video_call_provider")}: ${getIntegrationName(
- this.calEvent.videoCallData
- )}
- ${this.calEvent.language("meeting_id")}: ${getFormattedMeetingId(
- this.calEvent.videoCallData
- )}
- ${this.calEvent.language("meeting_password")}: ${
- this.calEvent.videoCallData.password
- }
- ${this.calEvent.language("meeting_url")}: ${this.calEvent.videoCallData.url}
- `;
- }
-
- return `
- ${this.calEvent.language("video_call_provider")}: ${getIntegrationName(
- this.calEvent.videoCallData
- )}
- ${this.calEvent.language("meeting_url")}: ${this.calEvent.videoCallData.url}
- `;
- }
-}
diff --git a/lib/emails/VideoEventOrganizerMail.ts b/lib/emails/VideoEventOrganizerMail.ts
deleted file mode 100644
index c4ab3a3d..00000000
--- a/lib/emails/VideoEventOrganizerMail.ts
+++ /dev/null
@@ -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 `
-${this.calEvent.language("video_call_provider")}: ${getIntegrationName(
- this.calEvent.videoCallData
- )}
-${this.calEvent.language("meeting_id")}: ${getFormattedMeetingId(
- this.calEvent.videoCallData
- )}
-${this.calEvent.language("meeting_password")}: ${this.calEvent.videoCallData.password}
-${this.calEvent.language("meeting_url")}: ${
- this.calEvent.videoCallData.url
- }
- `;
- }
- return `
-${this.calEvent.language("video_call_provider")}: ${getIntegrationName(
- this.calEvent.videoCallData
- )}
-${this.calEvent.language("meeting_url")}: ${
- this.calEvent.videoCallData.url
- }
- `;
- }
-}
diff --git a/lib/emails/buildMessageTemplate.ts b/lib/emails/buildMessageTemplate.ts
deleted file mode 100644
index 68e49e07..00000000
--- a/lib/emails/buildMessageTemplate.ts
+++ /dev/null
@@ -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;
diff --git a/lib/emails/email-manager.ts b/lib/emails/email-manager.ts
new file mode 100644
index 00000000..f2fef7b6
--- /dev/null
+++ b/lib/emails/email-manager.ts
@@ -0,0 +1,197 @@
+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";
+import { CalendarEvent } from "@lib/integrations/calendar/interfaces/Calendar";
+
+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));
+ }
+ });
+};
diff --git a/lib/emails/helpers.ts b/lib/emails/helpers.ts
deleted file mode 100644
index 5c07dbbe..00000000
--- a/lib/emails/helpers.ts
+++ /dev/null
@@ -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 = /"]*)"[\s\w="_:#;]*>([^<>]*)<\/a>/g;
- const aLinkRegExp = / "]*)"[\s\w="_:#;]*>([^<>]*)<\/a>/g;
- return html
- .replace(/ /g, "\n")
- .replace(aMailToRegExp, "$1")
- .replace(aLinkRegExp, "$2: $1")
- .replace(/<[^>]+>/g, "");
-}
diff --git a/lib/emails/invitation.ts b/lib/emails/invitation.ts
deleted file mode 100644
index 47c8bdda..00000000
--- a/lib/emails/invitation.ts
+++ /dev/null
@@ -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 =>
- 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 (
- `
-
-
-
-
-
-
-
- ${t("hi")},
- ` +
- (invitation.from
- ? t("user_invited_you", { user: invitation.from, teamName: invitation.teamName })
- : t("you_have_been_invited", { teamName: invitation.teamName })) +
- `
-
-
- ${t("request_another_invitation_email", { toEmail: invitation.toEmail })}
-
-
-
-
-
-
-
- `
- );
-}
-
-// just strip all HTML and convert to \n
-export function text(htmlStr: string): string {
- return htmlStr.replace(" ", "\n").replace(/<[^>]+>/g, "");
-}
diff --git a/lib/emails/sendMail.ts b/lib/emails/sendMail.ts
deleted file mode 100644
index 3a0ee942..00000000
--- a/lib/emails/sendMail.ts
+++ /dev/null
@@ -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 =>
- 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;
diff --git a/lib/emails/templates/attendee-awaiting-payment-email.ts b/lib/emails/templates/attendee-awaiting-payment-email.ts
new file mode 100644
index 00000000..7455ecfd
--- /dev/null
+++ b/lib/emails/templates/attendee-awaiting-payment-email.ts
@@ -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 {
+ return {
+ to: `${this.attendee.name} <${this.attendee.email}>`,
+ from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
+ replyTo: this.calEvent.organizer.email,
+ subject: `${this.calEvent.attendees[0].language.translate("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.attendees[0].language.translate(
+ this.getInviteeStart().format("dddd").toLowerCase()
+ )}, ${this.calEvent.attendees[0].language.translate(
+ 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.attendees[0].language.translate("meeting_awaiting_payment")}
+${this.calEvent.attendees[0].language.translate("emailed_you_and_any_other_attendees")}
+${this.getWhat()}
+${this.getWhen()}
+${this.getLocation()}
+${this.getAdditionalNotes()}
+`.replace(/(<([^>]+)>)/gi, "");
+ }
+
+ protected getHtmlBody(): string {
+ const headerContent = this.calEvent.attendees[0].language.translate("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.attendees[0].language.translate(
+ this.getInviteeStart().format("dddd").toLowerCase()
+ )}, ${this.calEvent.attendees[0].language.translate(
+ this.getInviteeStart().format("MMMM").toLowerCase()
+ )} ${this.getInviteeStart().format("D")}, ${this.getInviteeStart().format("YYYY")}`,
+ });
+
+ return `
+
+
+ ${emailHead(headerContent)}
+
+
+ ${emailSchedulingBodyHeader("calendarCircle")}
+ ${emailScheduledBodyHeaderContent(
+ this.calEvent.attendees[0].language.translate("meeting_awaiting_payment"),
+ this.calEvent.attendees[0].language.translate("emailed_you_and_any_other_attendees")
+ )}
+ ${emailSchedulingBodyDivider()}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${this.getWhat()}
+ ${this.getWhen()}
+ ${this.getWho()}
+ ${this.getLocation()}
+ ${this.getAdditionalNotes()}
+
+
+
+
+
+
+
+
+
+
+
+
+ ${emailSchedulingBodyDivider()}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${this.getManageLink()}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${emailBodyLogo()}
+
+
+
+
+ `;
+ }
+
+ protected getManageLink(): string {
+ const manageText = this.calEvent.attendees[0].language.translate("pay_now");
+
+ if (this.calEvent.paymentInfo) {
+ return `
+
+
+
+ ${manageText}
+
+
+
+ `;
+ }
+
+ return "";
+ }
+}
diff --git a/lib/emails/templates/attendee-cancelled-email.ts b/lib/emails/templates/attendee-cancelled-email.ts
new file mode 100644
index 00000000..25762e24
--- /dev/null
+++ b/lib/emails/templates/attendee-cancelled-email.ts
@@ -0,0 +1,140 @@
+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 {
+ return {
+ to: `${this.attendee.name} <${this.attendee.email}>`,
+ from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
+ replyTo: this.calEvent.organizer.email,
+ subject: `${this.calEvent.attendees[0].language.translate("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.attendees[0].language.translate(
+ this.getInviteeStart().format("dddd").toLowerCase()
+ )}, ${this.calEvent.attendees[0].language.translate(
+ 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.attendees[0].language.translate("event_request_cancelled")}
+${this.calEvent.attendees[0].language.translate("emailed_you_and_any_other_attendees")}
+${this.getWhat()}
+${this.getWhen()}
+${this.getLocation()}
+${this.getAdditionalNotes()}
+${this.calEvent.cancellationReason && this.getCancellationReason()}
+`.replace(/(<([^>]+)>)/gi, "");
+ }
+
+ protected getHtmlBody(): string {
+ const headerContent = this.calEvent.attendees[0].language.translate("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.attendees[0].language.translate(
+ this.getInviteeStart().format("dddd").toLowerCase()
+ )}, ${this.calEvent.attendees[0].language.translate(
+ this.getInviteeStart().format("MMMM").toLowerCase()
+ )} ${this.getInviteeStart().format("D")}, ${this.getInviteeStart().format("YYYY")}`,
+ });
+
+ return `
+
+
+ ${emailHead(headerContent)}
+
+
+
+ ${emailSchedulingBodyHeader("xCircle")}
+ ${emailScheduledBodyHeaderContent(
+ this.calEvent.attendees[0].language.translate("event_request_cancelled"),
+ this.calEvent.attendees[0].language.translate("emailed_you_and_any_other_attendees")
+ )}
+ ${emailSchedulingBodyDivider()}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${this.getWhat()}
+ ${this.getWhen()}
+ ${this.getWho()}
+ ${this.getLocation()}
+ ${this.getAdditionalNotes()}
+ ${this.calEvent.cancellationReason && this.getCancellationReason()}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${emailBodyLogo()}
+
+
+
+
+ `;
+ }
+
+ protected getCancellationReason(): string {
+ return `
+
+
+
${this.calEvent.attendees[0].language.translate("cancellation_reason")}
+
${this.calEvent.cancellationReason}
+
`;
+ }
+}
diff --git a/lib/emails/templates/attendee-declined-email.ts b/lib/emails/templates/attendee-declined-email.ts
new file mode 100644
index 00000000..32c920e6
--- /dev/null
+++ b/lib/emails/templates/attendee-declined-email.ts
@@ -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 {
+ return {
+ to: `${this.attendee.name} <${this.attendee.email}>`,
+ from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
+ replyTo: this.calEvent.organizer.email,
+ subject: `${this.calEvent.attendees[0].language.translate("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.attendees[0].language.translate(
+ this.getInviteeStart().format("dddd").toLowerCase()
+ )}, ${this.calEvent.attendees[0].language.translate(
+ 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.attendees[0].language.translate("event_request_declined")}
+${this.calEvent.attendees[0].language.translate("emailed_you_and_any_other_attendees")}
+${this.getWhat()}
+${this.getWhen()}
+${this.getLocation()}
+${this.getAdditionalNotes()}
+`.replace(/(<([^>]+)>)/gi, "");
+ }
+
+ protected getHtmlBody(): string {
+ const headerContent = this.calEvent.attendees[0].language.translate("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.attendees[0].language.translate(
+ this.getInviteeStart().format("dddd").toLowerCase()
+ )}, ${this.calEvent.attendees[0].language.translate(
+ this.getInviteeStart().format("MMMM").toLowerCase()
+ )} ${this.getInviteeStart().format("D")}, ${this.getInviteeStart().format("YYYY")}`,
+ });
+
+ return `
+
+
+ ${emailHead(headerContent)}
+
+
+
+ ${emailSchedulingBodyHeader("xCircle")}
+ ${emailScheduledBodyHeaderContent(
+ this.calEvent.attendees[0].language.translate("event_request_declined"),
+ this.calEvent.attendees[0].language.translate("emailed_you_and_any_other_attendees")
+ )}
+ ${emailSchedulingBodyDivider()}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${this.getWhat()}
+ ${this.getWhen()}
+ ${this.getWho()}
+ ${this.getLocation()}
+ ${this.getAdditionalNotes()}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${emailBodyLogo()}
+
+
+
+
+ `;
+ }
+}
diff --git a/lib/emails/templates/attendee-rescheduled-email.ts b/lib/emails/templates/attendee-rescheduled-email.ts
new file mode 100644
index 00000000..bf5e7d54
--- /dev/null
+++ b/lib/emails/templates/attendee-rescheduled-email.ts
@@ -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 {
+ 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.attendees[0].language.translate("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.attendees[0].language.translate(
+ this.getInviteeStart().format("dddd").toLowerCase()
+ )}, ${this.calEvent.attendees[0].language.translate(
+ 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.attendees[0].language.translate("event_has_been_rescheduled")}
+ ${this.calEvent.attendees[0].language.translate("emailed_you_and_any_other_attendees")}
+ ${this.getWhat()}
+ ${this.getWhen()}
+ ${this.getLocation()}
+ ${this.getAdditionalNotes()}
+ ${this.calEvent.attendees[0].language.translate("need_to_reschedule_or_cancel")}
+ ${getCancelLink(this.calEvent)}
+ `.replace(/(<([^>]+)>)/gi, "");
+ }
+
+ return `
+${this.calEvent.attendees[0].language.translate("event_has_been_rescheduled")}
+${this.calEvent.attendees[0].language.translate("emailed_you_and_any_other_attendees")}
+${this.getWhat()}
+${this.getWhen()}
+${this.getLocation()}
+${this.getAdditionalNotes()}
+`.replace(/(<([^>]+)>)/gi, "");
+ }
+
+ protected getHtmlBody(): string {
+ const headerContent = this.calEvent.attendees[0].language.translate("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.attendees[0].language.translate(
+ this.getInviteeStart().format("dddd").toLowerCase()
+ )}, ${this.calEvent.attendees[0].language.translate(
+ this.getInviteeStart().format("MMMM").toLowerCase()
+ )} ${this.getInviteeStart().format("D")}, ${this.getInviteeStart().format("YYYY")}`,
+ });
+
+ return `
+
+
+ ${emailHead(headerContent)}
+
+
+ ${emailSchedulingBodyHeader("calendarCircle")}
+ ${emailScheduledBodyHeaderContent(
+ this.calEvent.attendees[0].language.translate("event_has_been_rescheduled"),
+ this.calEvent.attendees[0].language.translate("emailed_you_and_any_other_attendees")
+ )}
+ ${emailSchedulingBodyDivider()}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${this.getWhat()}
+ ${this.getWhen()}
+ ${this.getWho()}
+ ${this.getLocation()}
+ ${this.getAdditionalNotes()}
+
+
+
+
+
+
+
+
+
+
+
+
+ ${emailSchedulingBodyDivider()}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${this.getManageLink()}
+
+
+
+
+
+
+
+
+
+
+
+
+ ${emailBodyLogo()}
+
+
+
+
+ `;
+ }
+}
diff --git a/lib/emails/templates/attendee-scheduled-email.ts b/lib/emails/templates/attendee-scheduled-email.ts
new file mode 100644
index 00000000..2ce4a0a0
--- /dev/null
+++ b/lib/emails/templates/attendee-scheduled-email.ts
@@ -0,0 +1,390 @@
+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, Person } from "ics";
+import nodemailer from "nodemailer";
+
+import { getCancelLink, getRichDescription } from "@lib/CalEventParser";
+import { getErrorFromUnknown } from "@lib/errors";
+import { getIntegrationName } from "@lib/integrations";
+import { CalendarEvent } from "@lib/integrations/calendar/interfaces/Calendar";
+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.attendees[0].language.translate("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 {
+ 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.attendees[0].language.translate("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.attendees[0].language.translate(
+ this.getInviteeStart().format("dddd").toLowerCase()
+ )}, ${this.calEvent.attendees[0].language.translate(
+ 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 {
+ return `
+${this.calEvent.attendees[0].language.translate("your_event_has_been_scheduled")}
+${this.calEvent.attendees[0].language.translate("emailed_you_and_any_other_attendees")}
+
+${getRichDescription(this.calEvent)}
+`.trim();
+ }
+
+ protected printNodeMailerError(error: Error): void {
+ console.error("SEND_BOOKING_CONFIRMATION_ERROR", this.attendee.email, error);
+ }
+
+ protected getHtmlBody(): string {
+ const headerContent = this.calEvent.attendees[0].language.translate("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.attendees[0].language.translate(
+ this.getInviteeStart().format("dddd").toLowerCase()
+ )}, ${this.calEvent.attendees[0].language.translate(
+ this.getInviteeStart().format("MMMM").toLowerCase()
+ )} ${this.getInviteeStart().format("D")}, ${this.getInviteeStart().format("YYYY")}`,
+ });
+
+ return `
+
+
+ ${emailHead(headerContent)}
+
+
+ ${emailSchedulingBodyHeader("checkCircle")}
+ ${emailScheduledBodyHeaderContent(
+ this.calEvent.attendees[0].language.translate("your_event_has_been_scheduled"),
+ this.calEvent.attendees[0].language.translate("emailed_you_and_any_other_attendees")
+ )}
+ ${emailSchedulingBodyDivider()}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${this.getWhat()}
+ ${this.getWhen()}
+ ${this.getWho()}
+ ${this.getLocation()}
+ ${this.getAdditionalNotes()}
+
+
+
+
+
+
+
+
+
+
+
+
+ ${emailSchedulingBodyDivider()}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${this.getManageLink()}
+
+
+
+
+
+
+
+
+
+
+
+
+ ${emailBodyLogo()}
+
+
+
+
+ `;
+ }
+
+ 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.attendees[0].language.translate("manage_this_event");
+ return `${this.calEvent.attendees[0].language.translate(
+ "need_to_reschedule_or_cancel"
+ )}
${manageText}
`;
+ }
+
+ return "";
+ }
+
+ protected getWhat(): string {
+ return `
+
+
${this.calEvent.attendees[0].language.translate("what")}
+
${this.calEvent.type}
+
`;
+ }
+
+ protected getWhen(): string {
+ return `
+
+
+
${this.calEvent.attendees[0].language.translate("when")}
+
+ ${this.calEvent.attendees[0].language.translate(
+ this.getInviteeStart().format("dddd").toLowerCase()
+ )}, ${this.calEvent.attendees[0].language.translate(
+ this.getInviteeStart().format("MMMM").toLowerCase()
+ )} ${this.getInviteeStart().format("D")}, ${this.getInviteeStart().format(
+ "YYYY"
+ )} | ${this.getInviteeStart().format("h:mma")} - ${this.getInviteeEnd().format(
+ "h:mma"
+ )} (${this.getTimezone()})
+
+
`;
+ }
+
+ protected getWho(): string {
+ const attendees = this.calEvent.attendees
+ .map((attendee) => {
+ return ``;
+ })
+ .join("");
+
+ const organizer = ``;
+
+ return `
+
+
+
${this.calEvent.attendees[0].language.translate("who")}
+ ${organizer + attendees}
+
`;
+ }
+
+ protected getAdditionalNotes(): string {
+ return `
+
+
+
${this.calEvent.attendees[0].language.translate("additional_notes")}
+
${
+ this.calEvent.description
+ }
+
+ `;
+ }
+
+ 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 `
+
+
+
${this.calEvent.attendees[0].language.translate("where")}
+
${providerName} ${
+ meetingUrl &&
+ ` `
+ }
+ ${
+ meetingId &&
+ `
${this.calEvent.attendees[0].language.translate(
+ "meeting_id"
+ )}: ${meetingId}
`
+ }
+ ${
+ meetingPassword &&
+ `
${this.calEvent.attendees[0].language.translate(
+ "meeting_password"
+ )}: ${meetingPassword}
`
+ }
+ ${
+ meetingUrl &&
+ `
${this.calEvent.attendees[0].language.translate(
+ "meeting_url"
+ )}:
${meetingUrl} `
+ }
+
+ `;
+ }
+
+ if (this.calEvent.additionInformation?.hangoutLink) {
+ const hangoutLink: string = this.calEvent.additionInformation.hangoutLink;
+
+ return `
+
+
+
${this.calEvent.attendees[0].language.translate("where")}
+
${providerName} ${
+ hangoutLink &&
+ ` `
+ }
+
+
+ `;
+ }
+
+ return `
+
+
+
${this.calEvent.attendees[0].language.translate("where")}
+
${
+ providerName || this.calEvent.location
+ }
+
+ `;
+ }
+
+ 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());
+ }
+}
diff --git a/lib/emails/templates/common/body-logo.ts b/lib/emails/templates/common/body-logo.ts
new file mode 100644
index 00000000..3b5b143e
--- /dev/null
+++ b/lib/emails/templates/common/body-logo.ts
@@ -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 `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+};
diff --git a/lib/emails/templates/common/head.ts b/lib/emails/templates/common/head.ts
new file mode 100644
index 00000000..be224038
--- /dev/null
+++ b/lib/emails/templates/common/head.ts
@@ -0,0 +1,91 @@
+export const emailHead = (headerContent: string): string => {
+ return `
+
+ ${headerContent}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+};
diff --git a/lib/emails/templates/common/index.ts b/lib/emails/templates/common/index.ts
new file mode 100644
index 00000000..686d871f
--- /dev/null
+++ b/lib/emails/templates/common/index.ts
@@ -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";
diff --git a/lib/emails/templates/common/link-icon.ts b/lib/emails/templates/common/link-icon.ts
new file mode 100644
index 00000000..434ae2cb
--- /dev/null
+++ b/lib/emails/templates/common/link-icon.ts
@@ -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";
+};
diff --git a/lib/emails/templates/common/scheduling-body-divider.ts b/lib/emails/templates/common/scheduling-body-divider.ts
new file mode 100644
index 00000000..b8723c3e
--- /dev/null
+++ b/lib/emails/templates/common/scheduling-body-divider.ts
@@ -0,0 +1,31 @@
+export const emailSchedulingBodyDivider = (): string => {
+ return `
+
+
+ `;
+};
diff --git a/lib/emails/templates/common/scheduling-body-head-content.ts b/lib/emails/templates/common/scheduling-body-head-content.ts
new file mode 100644
index 00000000..31515fc7
--- /dev/null
+++ b/lib/emails/templates/common/scheduling-body-head-content.ts
@@ -0,0 +1,33 @@
+export const emailScheduledBodyHeaderContent = (title: string, subtitle: string): string => {
+ return `
+
+
+
+
+
+
+
+
+
+
+
+
+ ${title}
+
+
+
+
+ ${subtitle}
+
+
+
+
+
+
+
+
+
+
+
+ `;
+};
diff --git a/lib/emails/templates/common/scheduling-body-head.ts b/lib/emails/templates/common/scheduling-body-head.ts
new file mode 100644
index 00000000..7f3a6d66
--- /dev/null
+++ b/lib/emails/templates/common/scheduling-body-head.ts
@@ -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 `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+};
diff --git a/lib/emails/templates/forgot-password-email.ts b/lib/emails/templates/forgot-password-email.ts
new file mode 100644
index 00000000..7bd0e024
--- /dev/null
+++ b/lib/emails/templates/forgot-password-email.ts
@@ -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 {
+ 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 `
+
+
+ ${emailHead(headerContent)}
+
+
+ ${emailBodyLogo()}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
${this.passwordEvent.language("hi_user_name", {
+ user: this.passwordEvent.user.name,
+ })},
+
${this.passwordEvent.language(
+ "someone_requested_password_reset"
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
${this.passwordEvent.language(
+ "password_reset_instructions"
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+ }
+}
diff --git a/lib/emails/templates/organizer-cancelled-email.ts b/lib/emails/templates/organizer-cancelled-email.ts
new file mode 100644
index 00000000..639e4a8f
--- /dev/null
+++ b/lib/emails/templates/organizer-cancelled-email.ts
@@ -0,0 +1,148 @@
+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 {
+ 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.organizer.language.translate("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.organizer.language.translate(
+ this.getOrganizerStart().format("dddd").toLowerCase()
+ )}, ${this.calEvent.organizer.language.translate(
+ 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.organizer.language.translate("event_request_cancelled")}
+${this.calEvent.organizer.language.translate("emailed_you_and_any_other_attendees")}
+${this.getWhat()}
+${this.getWhen()}
+${this.getLocation()}
+${this.getAdditionalNotes()}
+${this.calEvent.cancellationReason && this.getCancellationReason()}
+`.replace(/(<([^>]+)>)/gi, "");
+ }
+
+ protected getHtmlBody(): string {
+ const headerContent = this.calEvent.organizer.language.translate("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.organizer.language.translate(
+ this.getOrganizerStart().format("dddd").toLowerCase()
+ )}, ${this.calEvent.organizer.language.translate(
+ this.getOrganizerStart().format("MMMM").toLowerCase()
+ )} ${this.getOrganizerStart().format("D")}, ${this.getOrganizerStart().format("YYYY")}`,
+ });
+
+ return `
+
+
+ ${emailHead(headerContent)}
+
+
+ ${emailSchedulingBodyHeader("xCircle")}
+ ${emailScheduledBodyHeaderContent(
+ this.calEvent.organizer.language.translate("event_request_cancelled"),
+ this.calEvent.organizer.language.translate("emailed_you_and_any_other_attendees")
+ )}
+ ${emailSchedulingBodyDivider()}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${this.getWhat()}
+ ${this.getWhen()}
+ ${this.getWho()}
+ ${this.getLocation()}
+ ${this.getAdditionalNotes()}
+ ${this.calEvent.cancellationReason && this.getCancellationReason()}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${emailBodyLogo()}
+
+
+
+
+ `;
+ }
+
+ protected getCancellationReason(): string {
+ return `
+
+
+
${this.calEvent.organizer.language.translate("cancellation_reason")}
+
${this.calEvent.cancellationReason}
+
`;
+ }
+}
diff --git a/lib/emails/templates/organizer-payment-refund-failed-email.ts b/lib/emails/templates/organizer-payment-refund-failed-email.ts
new file mode 100644
index 00000000..6b254980
--- /dev/null
+++ b/lib/emails/templates/organizer-payment-refund-failed-email.ts
@@ -0,0 +1,203 @@
+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 {
+ 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.organizer.language.translate("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.organizer.language.translate(
+ this.getOrganizerStart().format("dddd").toLowerCase()
+ )}, ${this.calEvent.organizer.language.translate(
+ 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.organizer.language.translate("a_refund_failed")}
+${this.calEvent.organizer.language.translate("check_with_provider_and_user", {
+ user: this.calEvent.attendees[0].name,
+})}
+${
+ this.calEvent.paymentInfo &&
+ this.calEvent.organizer.language.translate("error_message", {
+ errorMessage: this.calEvent.paymentInfo.reason,
+ })
+}
+${this.getWhat()}
+${this.getWhen()}
+${this.getLocation()}
+${this.getAdditionalNotes()}
+`.replace(/(<([^>]+)>)/gi, "");
+ }
+
+ protected getHtmlBody(): string {
+ const headerContent = this.calEvent.organizer.language.translate("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.organizer.language.translate(
+ this.getOrganizerStart().format("dddd").toLowerCase()
+ )}, ${this.calEvent.organizer.language.translate(
+ this.getOrganizerStart().format("MMMM").toLowerCase()
+ )} ${this.getOrganizerStart().format("D")}, ${this.getOrganizerStart().format("YYYY")}`,
+ });
+
+ return `
+
+
+ ${emailHead(headerContent)}
+
+
+
+ ${emailSchedulingBodyHeader("xCircle")}
+
+
+
+
+
+
+
+
+
+
+
+
+ ${this.calEvent.organizer.language.translate(
+ "a_refund_failed"
+ )}
+
+
+
+
+ ${this.calEvent.organizer.language.translate(
+ "check_with_provider_and_user",
+ { user: this.calEvent.attendees[0].name }
+ )}
+
+
+ ${this.getRefundInformation()}
+
+
+
+
+
+
+
+
+
+ ${emailSchedulingBodyDivider()}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${this.getWhat()}
+ ${this.getWhen()}
+ ${this.getWho()}
+ ${this.getLocation()}
+ ${this.getAdditionalNotes()}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${emailBodyLogo()}
+
+
+
+
+ `;
+ }
+
+ protected getRefundInformation(): string {
+ const paymentInfo = this.calEvent.paymentInfo;
+ let refundInformation = "";
+
+ if (paymentInfo) {
+ if (paymentInfo.reason) {
+ refundInformation = `
+
+
+ ${this.calEvent.organizer.language.translate(
+ "error_message",
+ { errorMessage: paymentInfo.reason }
+ )}
+
+
+ `;
+ }
+
+ if (paymentInfo.id) {
+ refundInformation += `
+
+
+ Payment ${paymentInfo.id}
+
+
+ `;
+ }
+ }
+
+ return refundInformation;
+ }
+}
diff --git a/lib/emails/templates/organizer-request-email.ts b/lib/emails/templates/organizer-request-email.ts
new file mode 100644
index 00000000..36c2817a
--- /dev/null
+++ b/lib/emails/templates/organizer-request-email.ts
@@ -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 {
+ 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.organizer.language.translate("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.organizer.language.translate(
+ this.getOrganizerStart().format("dddd").toLowerCase()
+ )}, ${this.calEvent.organizer.language.translate(
+ 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.organizer.language.translate("event_awaiting_approval")}
+${this.calEvent.organizer.language.translate("someone_requested_an_event")}
+${this.getWhat()}
+${this.getWhen()}
+${this.getLocation()}
+${this.getAdditionalNotes()}
+${this.calEvent.organizer.language.translate("confirm_or_reject_request")}
+${process.env.BASE_URL} + "/bookings/upcoming"
+`.replace(/(<([^>]+)>)/gi, "");
+ }
+
+ protected getHtmlBody(): string {
+ const headerContent = this.calEvent.organizer.language.translate("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.organizer.language.translate(
+ this.getOrganizerStart().format("dddd").toLowerCase()
+ )}, ${this.calEvent.organizer.language.translate(
+ this.getOrganizerStart().format("MMMM").toLowerCase()
+ )} ${this.getOrganizerStart().format("D")}, ${this.getOrganizerStart().format("YYYY")}`,
+ });
+
+ return `
+
+
+ ${emailHead(headerContent)}
+
+
+
+ ${emailSchedulingBodyHeader("calendarCircle")}
+ ${emailScheduledBodyHeaderContent(
+ this.calEvent.organizer.language.translate("event_awaiting_approval"),
+ this.calEvent.organizer.language.translate("someone_requested_an_event")
+ )}
+ ${emailSchedulingBodyDivider()}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${this.getWhat()}
+ ${this.getWhen()}
+ ${this.getWho()}
+ ${this.getLocation()}
+ ${this.getAdditionalNotes()}
+
+
+
+
+
+
+
+
+
+
+
+
+ ${emailSchedulingBodyDivider()}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${this.getManageLink()}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${emailBodyLogo()}
+
+
+
+
+ `;
+ }
+
+ protected getManageLink(): string {
+ const manageText = this.calEvent.organizer.language.translate("confirm_or_reject_request");
+ const manageLink = process.env.BASE_URL + "/bookings/upcoming";
+ return `${manageText} `;
+ }
+}
diff --git a/lib/emails/templates/organizer-request-reminder-email.ts b/lib/emails/templates/organizer-request-reminder-email.ts
new file mode 100644
index 00000000..3d7c8baf
--- /dev/null
+++ b/lib/emails/templates/organizer-request-reminder-email.ts
@@ -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 {
+ 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.organizer.language.translate("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.organizer.language.translate(
+ this.getOrganizerStart().format("dddd").toLowerCase()
+ )}, ${this.calEvent.organizer.language.translate(
+ 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.organizer.language.translate("event_still_awaiting_approval")}
+${this.calEvent.organizer.language.translate("someone_requested_an_event")}
+${this.getWhat()}
+${this.getWhen()}
+${this.getLocation()}
+${this.getAdditionalNotes()}
+${this.calEvent.organizer.language.translate("confirm_or_reject_request")}
+${process.env.BASE_URL} + "/bookings/upcoming"
+`.replace(/(<([^>]+)>)/gi, "");
+ }
+
+ protected getHtmlBody(): string {
+ const headerContent = this.calEvent.organizer.language.translate("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.organizer.language.translate(
+ this.getOrganizerStart().format("dddd").toLowerCase()
+ )}, ${this.calEvent.organizer.language.translate(
+ this.getOrganizerStart().format("MMMM").toLowerCase()
+ )} ${this.getOrganizerStart().format("D")}, ${this.getOrganizerStart().format("YYYY")}`,
+ });
+
+ return `
+
+
+ ${emailHead(headerContent)}
+
+
+ ${emailSchedulingBodyHeader("calendarCircle")}
+ ${emailScheduledBodyHeaderContent(
+ this.calEvent.organizer.language.translate("event_still_awaiting_approval"),
+ this.calEvent.organizer.language.translate("someone_requested_an_event")
+ )}
+ ${emailSchedulingBodyDivider()}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${this.getWhat()}
+ ${this.getWhen()}
+ ${this.getWho()}
+ ${this.getLocation()}
+ ${this.getAdditionalNotes()}
+
+
+
+
+
+
+
+
+
+
+
+
+ ${emailSchedulingBodyDivider()}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${this.getManageLink()}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${emailBodyLogo()}
+
+
+
+
+ `;
+ }
+
+ protected getManageLink(): string {
+ const manageText = this.calEvent.organizer.language.translate("confirm_or_reject_request");
+ const manageLink = process.env.BASE_URL + "/bookings/upcoming";
+ return `${manageText} `;
+ }
+}
diff --git a/lib/emails/templates/organizer-rescheduled-email.ts b/lib/emails/templates/organizer-rescheduled-email.ts
new file mode 100644
index 00000000..da267249
--- /dev/null
+++ b/lib/emails/templates/organizer-rescheduled-email.ts
@@ -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 {
+ 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.organizer.language.translate("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.organizer.language.translate(
+ this.getOrganizerStart().format("dddd").toLowerCase()
+ )}, ${this.calEvent.organizer.language.translate(
+ 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.organizer.language.translate("event_has_been_rescheduled")}
+${this.calEvent.organizer.language.translate("emailed_you_and_any_other_attendees")}
+${this.getWhat()}
+${this.getWhen()}
+${this.getLocation()}
+${this.getAdditionalNotes()}
+${this.calEvent.organizer.language.translate("need_to_reschedule_or_cancel")}
+${getCancelLink(this.calEvent)}
+`.replace(/(<([^>]+)>)/gi, "");
+ }
+
+ protected getHtmlBody(): string {
+ const headerContent = this.calEvent.organizer.language.translate("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.organizer.language.translate(
+ this.getOrganizerStart().format("dddd").toLowerCase()
+ )}, ${this.calEvent.organizer.language.translate(
+ this.getOrganizerStart().format("MMMM").toLowerCase()
+ )} ${this.getOrganizerStart().format("D")}, ${this.getOrganizerStart().format("YYYY")}`,
+ });
+
+ return `
+
+
+ ${emailHead(headerContent)}
+
+
+ ${emailSchedulingBodyHeader("calendarCircle")}
+ ${emailScheduledBodyHeaderContent(
+ this.calEvent.organizer.language.translate("event_has_been_rescheduled"),
+ this.calEvent.organizer.language.translate("emailed_you_and_any_other_attendees")
+ )}
+ ${emailSchedulingBodyDivider()}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${this.getWhat()}
+ ${this.getWhen()}
+ ${this.getWho()}
+ ${this.getLocation()}
+ ${this.getAdditionalNotes()}
+
+
+
+
+
+
+
+
+
+
+
+
+ ${emailSchedulingBodyDivider()}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${this.getManageLink()}
+
+
+
+
+
+
+
+
+
+
+
+
+ ${emailBodyLogo()}
+
+
+
+
+ `;
+ }
+}
diff --git a/lib/emails/templates/organizer-scheduled-email.ts b/lib/emails/templates/organizer-scheduled-email.ts
new file mode 100644
index 00000000..259bc89f
--- /dev/null
+++ b/lib/emails/templates/organizer-scheduled-email.ts
@@ -0,0 +1,389 @@
+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, Person } from "ics";
+import nodemailer from "nodemailer";
+
+import { getCancelLink, getRichDescription } from "@lib/CalEventParser";
+import { getErrorFromUnknown } from "@lib/errors";
+import { getIntegrationName } from "@lib/integrations";
+import { CalendarEvent } from "@lib/integrations/calendar/interfaces/Calendar";
+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.organizer.language.translate("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 {
+ 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.organizer.language.translate("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.organizer.language.translate(
+ this.getOrganizerStart().format("dddd").toLowerCase()
+ )}, ${this.calEvent.organizer.language.translate(
+ 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.organizer.language.translate("new_event_scheduled")}
+${this.calEvent.organizer.language.translate("emailed_you_and_any_other_attendees")}
+
+${getRichDescription(this.calEvent)}
+`.trim();
+ }
+
+ protected printNodeMailerError(error: Error): void {
+ console.error("SEND_BOOKING_CONFIRMATION_ERROR", this.calEvent.organizer.email, error);
+ }
+
+ protected getHtmlBody(): string {
+ const headerContent = this.calEvent.organizer.language.translate("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.organizer.language.translate(
+ this.getOrganizerStart().format("dddd").toLowerCase()
+ )}, ${this.calEvent.organizer.language.translate(
+ this.getOrganizerStart().format("MMMM").toLowerCase()
+ )} ${this.getOrganizerStart().format("D")}, ${this.getOrganizerStart().format("YYYY")}`,
+ });
+
+ return `
+
+
+ ${emailHead(headerContent)}
+
+
+ ${emailSchedulingBodyHeader("checkCircle")}
+ ${emailScheduledBodyHeaderContent(
+ this.calEvent.organizer.language.translate("new_event_scheduled"),
+ this.calEvent.organizer.language.translate("emailed_you_and_any_other_attendees")
+ )}
+ ${emailSchedulingBodyDivider()}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${this.getWhat()}
+ ${this.getWhen()}
+ ${this.getWho()}
+ ${this.getLocation()}
+ ${this.getAdditionalNotes()}
+
+
+
+
+
+
+
+
+
+
+
+
+ ${emailSchedulingBodyDivider()}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${this.getManageLink()}
+
+
+
+
+
+
+
+
+
+
+
+
+ ${emailBodyLogo()}
+
+
+
+
+ `;
+ }
+
+ protected getManageLink(): string {
+ const manageText = this.calEvent.organizer.language.translate("manage_this_event");
+ return `${this.calEvent.organizer.language.translate(
+ "need_to_reschedule_or_cancel"
+ )}
${manageText}
`;
+ }
+
+ protected getWhat(): string {
+ return `
+
+
${this.calEvent.organizer.language.translate("what")}
+
${this.calEvent.type}
+
`;
+ }
+
+ protected getWhen(): string {
+ return `
+
+
+
${this.calEvent.organizer.language.translate("when")}
+
+ ${this.calEvent.organizer.language.translate(
+ this.getOrganizerStart().format("dddd").toLowerCase()
+ )}, ${this.calEvent.organizer.language.translate(
+ this.getOrganizerStart().format("MMMM").toLowerCase()
+ )} ${this.getOrganizerStart().format("D")}, ${this.getOrganizerStart().format(
+ "YYYY"
+ )} | ${this.getOrganizerStart().format("h:mma")} - ${this.getOrganizerEnd().format(
+ "h:mma"
+ )} (${this.getTimezone()})
+
+
`;
+ }
+
+ protected getWho(): string {
+ const attendees = this.calEvent.attendees
+ .map((attendee) => {
+ return ``;
+ })
+ .join("");
+
+ const organizer = ``;
+
+ return `
+
+
+
${this.calEvent.organizer.language.translate("who")}
+ ${organizer + attendees}
+
`;
+ }
+
+ protected getAdditionalNotes(): string {
+ return `
+
+
+
${this.calEvent.organizer.language.translate("additional_notes")}
+
${
+ this.calEvent.description
+ }
+
+ `;
+ }
+
+ 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 `
+
+
+
${this.calEvent.organizer.language.translate("where")}
+
${providerName} ${
+ meetingUrl &&
+ ` `
+ }
+ ${
+ meetingId &&
+ `
${this.calEvent.organizer.language.translate(
+ "meeting_id"
+ )}: ${meetingId}
`
+ }
+ ${
+ meetingPassword &&
+ `
${this.calEvent.organizer.language.translate(
+ "meeting_password"
+ )}: ${meetingPassword}
`
+ }
+ ${
+ meetingUrl &&
+ `
${this.calEvent.organizer.language.translate(
+ "meeting_url"
+ )}:
${meetingUrl} `
+ }
+
+ `;
+ }
+
+ if (this.calEvent.additionInformation?.hangoutLink) {
+ const hangoutLink: string = this.calEvent.additionInformation.hangoutLink;
+
+ return `
+
+
+
${this.calEvent.organizer.language.translate("where")}
+
${providerName} ${
+ hangoutLink &&
+ ` `
+ }
+
+
+ `;
+ }
+
+ return `
+
+
+
${this.calEvent.organizer.language.translate("where")}
+
${
+ providerName || this.calEvent.location
+ }
+
+ `;
+ }
+
+ 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());
+ }
+}
diff --git a/lib/emails/templates/team-invite-email.ts b/lib/emails/templates/team-invite-email.ts
new file mode 100644
index 00000000..90763d8a
--- /dev/null
+++ b/lib/emails/templates/team-invite-email.ts
@@ -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 {
+ 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 `
+
+
+ ${emailHead(headerContent)}
+
+
+ ${emailBodyLogo()}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
${this.teamInviteEvent.language("user_invited_you", {
+ user: this.teamInviteEvent.from,
+ team: this.teamInviteEvent.teamName,
+ })}!
+
${this.teamInviteEvent.language(
+ "calcom_explained"
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+ }
+}
diff --git a/lib/ensureArray.ts b/lib/ensureArray.ts
new file mode 100644
index 00000000..538bdcfa
--- /dev/null
+++ b/lib/ensureArray.ts
@@ -0,0 +1,9 @@
+export function ensureArray(val: unknown): T[] {
+ if (Array.isArray(val)) {
+ return val;
+ }
+ if (typeof val === "undefined") {
+ return [];
+ }
+ return [val] as T[];
+}
diff --git a/lib/event.ts b/lib/event.ts
index 82b969d2..b261b627 100644
--- a/lib/event.ts
+++ b/lib/event.ts
@@ -1,8 +1,19 @@
-export function getEventName(
- name: string | string[] | undefined,
- eventTitle: string,
- eventNameTemplate: string | null
-) {
- if (!name || !(typeof name === "string")) name = ""; // If name is not set or is not of proper type
- return eventNameTemplate ? eventNameTemplate.replace("{USER}", name) : eventTitle + " with " + name;
+import { TFunction } from "next-i18next";
+
+type EventNameObjectType = {
+ attendeeName: string;
+ eventType: string;
+ eventName?: string | null;
+ host: string;
+ t: TFunction;
+};
+
+export function getEventName(eventNameObj: EventNameObjectType) {
+ return eventNameObj.eventName
+ ? eventNameObj.eventName.replace("{USER}", eventNameObj.attendeeName)
+ : eventNameObj.t("event_between_users", {
+ eventName: eventNameObj.eventType,
+ host: eventNameObj.host,
+ attendeeName: eventNameObj.attendeeName,
+ });
}
diff --git a/lib/events/EventManager.ts b/lib/events/EventManager.ts
index e0be3109..f1561b0d 100644
--- a/lib/events/EventManager.ts
+++ b/lib/events/EventManager.ts
@@ -1,22 +1,16 @@
-import { Credential } from "@prisma/client";
+import { Credential, DestinationCalendar } from "@prisma/client";
import async from "async";
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 { createEvent, updateEvent } from "@lib/integrations/calendar/CalendarManager";
+import { AdditionInformation, CalendarEvent } from "@lib/integrations/calendar/interfaces/Calendar";
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,22 +40,68 @@ 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;
+};
+
+type EventManagerUser = {
+ credentials: Credential[];
+ destinationCalendar: DestinationCalendar | null;
+};
export default class EventManager {
- calendarCredentials: Array;
- videoCredentials: Array;
+ calendarCredentials: Credential[];
+ videoCredentials: Credential[];
/**
* Takes an array of credentials and initializes a new instance of the EventManager.
*
* @param credentials
*/
- constructor(credentials: Array) {
- this.calendarCredentials = credentials.filter((cred) => cred.type.endsWith("_calendar"));
- this.videoCredentials = credentials.filter((cred) => cred.type.endsWith("_video"));
+ constructor(user: EventManagerUser) {
+ this.calendarCredentials = user.credentials.filter((cred) => cred.type.endsWith("_calendar"));
+ this.videoCredentials = user.credentials.filter((cred) => cred.type.endsWith("_video"));
//for Daily.co video, temporarily pushes a credential for the daily-video-client
const hasDailyIntegration = process.env.DAILY_API_KEY;
@@ -78,39 +117,31 @@ export default class EventManager {
*
* @param event
*/
- public async create(event: Ensure): Promise {
- let evt = EventManager.processLocation(event);
- const isDedicated = evt.location ? EventManager.isDedicatedIntegration(evt.location) : null;
+ public async create(event: CalendarEvent): Promise {
+ 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 = await this.createAllCalendarEvents(evt, isDedicated);
+ const results: Array = [];
// 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 = { ...evt, videoCallData: result.videoCallData };
+ if (result.createdEvent) {
+ evt.videoCallData = result.createdEvent;
}
+
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 = 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,
};
});
@@ -126,17 +157,17 @@ export default class EventManager {
*
* @param event
*/
- public async update(event: Ensure): Promise {
- let evt = EventManager.processLocation(event);
+ public async update(event: CalendarEvent, rescheduleUid: string): Promise {
+ const evt = processLocation(event);
- if (!evt.uid) {
- throw new Error("You called eventManager.update without an `uid`. This should never happen.");
+ if (!rescheduleUid) {
+ throw new Error("You called eventManager.update without an `rescheduleUid`. This should never happen.");
}
// Get details of existing booking.
const booking = await prisma.booking.findFirst({
where: {
- uid: evt.uid,
+ uid: rescheduleUid,
},
select: {
id: true,
@@ -150,6 +181,7 @@ export default class EventManager {
meetingUrl: true,
},
},
+ destinationCalendar: true,
},
});
@@ -157,19 +189,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 = await this.updateAllCalendarEvents(evt, booking, isDedicated);
+ const isDedicated = evt.location ? isDedicatedIntegration(evt.location) : null;
+ const results: Array = [];
// 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 = { ...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: {
@@ -182,15 +216,11 @@ export default class EventManager {
},
});
- let bookingDeletes = null;
-
- if (evt.uid) {
- bookingDeletes = prisma.booking.delete({
- where: {
- uid: evt.uid,
- },
- });
- }
+ const bookingDeletes = prisma.booking.delete({
+ where: {
+ id: booking.id,
+ },
+ });
// Wait for all deletions to be applied.
await Promise.all([bookingReferenceDeletes, attendeeDeletes, bookingDeletes]);
@@ -213,11 +243,25 @@ export default class EventManager {
* @param noMail
* @private
*/
+ private async createAllCalendarEvents(event: CalendarEvent): Promise> {
+ /** Can I use destinationCalendar here? */
+ /* How can I link a DC to a cred? */
+ if (event.destinationCalendar) {
+ const destinationCalendarCredentials = this.calendarCredentials.filter(
+ (c) => c.type === event.destinationCalendar?.integration
+ );
+ return Promise.all(destinationCalendarCredentials.map(async (c) => await createEvent(c, event)));
+ }
- private createAllCalendarEvents(event: CalendarEvent, noMail: boolean | null): Promise> {
- return async.mapLimit(this.calendarCredentials, 5, async (credential: Credential) => {
- return createEvent(credential, event, noMail);
- });
+ /**
+ * Not ideal but, if we don't find a destination calendar,
+ * fallback to the first connected calendar
+ */
+ const [credential] = this.calendarCredentials;
+ if (!credential) {
+ return [];
+ }
+ return [await createEvent(credential, event)];
}
/**
@@ -245,7 +289,7 @@ export default class EventManager {
* @param event
* @private
*/
- private createVideoEvent(event: Ensure): Promise {
+ private createVideoEvent(event: CalendarEvent): Promise {
const credential = this.getVideoCredential(event);
if (credential) {
@@ -263,20 +307,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> {
- 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;
- const evt = { ...event, uid: bookingRefUid };
- return updateEvent(credential, evt, noMail);
+
+ return updateEvent(credential, event, bookingRefUid);
});
}
@@ -292,171 +334,9 @@ export default class EventManager {
if (credential) {
const bookingRef = booking ? booking.references.filter((ref) => ref.type === credential.type)[0] : null;
- const evt = { ...event, uid: bookingRef?.uid };
- return updateMeeting(credential, evt).then((returnVal: EventResult) => {
- // Some video integrations, such as Zoom, don't return any data about the booking when updating it.
- if (returnVal.videoCallData == undefined) {
- returnVal.videoCallData = EventManager.bookingReferenceToVideoCallData(bookingRef);
- }
- 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(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,
- 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;
- }
- const emailEvent = { ...event, additionInformation: metadata };
-
- let attendeeMail;
- switch (type) {
- case "reschedule":
- attendeeMail = new EventAttendeeRescheduledMail(emailEvent);
- break;
- case "new":
- attendeeMail = new EventAttendeeMail(emailEvent);
- break;
- }
- try {
- await attendeeMail.sendEmail();
- } catch (e) {
- console.error("attendeeMail.sendEmail failed", e);
- }
- }
- }
}
diff --git a/lib/forgot-password/messaging/forgot-password.ts b/lib/forgot-password/messaging/forgot-password.ts
deleted file mode 100644
index 46348096..00000000
--- a/lib/forgot-password/messaging/forgot-password.ts
+++ /dev/null
@@ -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,
- });
-};
diff --git a/lib/getPlaceholderAvatar.tsx b/lib/getPlaceholderAvatar.tsx
new file mode 100644
index 00000000..80a09cb1
--- /dev/null
+++ b/lib/getPlaceholderAvatar.tsx
@@ -0,0 +1,6 @@
+export function getPlaceholderAvatar(avatar: string | null | undefined, name: string | null) {
+ return avatar
+ ? avatar
+ : "https://eu.ui-avatars.com/api/?background=fff&color=f9f9f9&bold=true&background=000000&name=" +
+ encodeURIComponent(name || "");
+}
diff --git a/lib/hooks/useSlots.ts b/lib/hooks/useSlots.ts
index ab4b6fd3..196651f5 100644
--- a/lib/hooks/useSlots.ts
+++ b/lib/hooks/useSlots.ts
@@ -1,4 +1,4 @@
-import { Availability, SchedulingType } from "@prisma/client";
+import { SchedulingType } from "@prisma/client";
import dayjs, { Dayjs } from "dayjs";
import isBetween from "dayjs/plugin/isBetween";
import utc from "dayjs/plugin/utc";
@@ -6,16 +6,15 @@ import { stringify } from "querystring";
import { useEffect, useState } from "react";
import getSlots from "@lib/slots";
-
-import { FreeBusyTime } from "@components/ui/Schedule/Schedule";
+import { TimeRange, WorkingHours } from "@lib/types/schedule";
dayjs.extend(isBetween);
dayjs.extend(utc);
type AvailabilityUserResponse = {
- busy: FreeBusyTime;
+ busy: TimeRange[];
timeZone: string;
- workingHours: Availability[];
+ workingHours: WorkingHours[];
};
type Slot = {
@@ -24,21 +23,17 @@ type Slot = {
};
type UseSlotsProps = {
+ slotInterval: number | null;
eventLength: number;
eventTypeId: number;
minimumBookingNotice?: number;
date: Dayjs;
- workingHours: {
- days: number[];
- startTime: number;
- endTime: number;
- }[];
users: { username: string | null }[];
schedulingType: SchedulingType | null;
};
export const useSlots = (props: UseSlotsProps) => {
- const { eventLength, minimumBookingNotice = 0, date, users, eventTypeId } = props;
+ const { slotInterval, eventLength, minimumBookingNotice = 0, date, users, eventTypeId } = props;
const [slots, setSlots] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
@@ -52,67 +47,67 @@ export const useSlots = (props: UseSlotsProps) => {
const dateTo = date.endOf("day").format();
const query = stringify({ dateFrom, dateTo, eventTypeId });
- Promise.all(
- users.map((user) =>
- fetch(`/api/availability/${user.username}?${query}`)
- .then(handleAvailableSlots)
- .catch((e) => {
- console.error(e);
- setError(e);
- })
- )
- ).then((results) => {
- let loadedSlots: Slot[] = results[0];
- if (results.length === 1) {
- loadedSlots = loadedSlots?.sort((a, b) => (a.time.isAfter(b.time) ? 1 : -1));
+ Promise.all(
+ users.map((user) => fetch(`/api/availability/${user.username}?${query}`).then(handleAvailableSlots))
+ )
+ .then((results) => {
+ let loadedSlots: Slot[] = results[0] || [];
+ if (results.length === 1) {
+ loadedSlots = loadedSlots?.sort((a, b) => (a.time.isAfter(b.time) ? 1 : -1));
+ setSlots(loadedSlots);
+ setLoading(false);
+ return;
+ }
+
+ let poolingMethod;
+ switch (props.schedulingType) {
+ // intersect by time, does not take into account eventLength (yet)
+ case SchedulingType.COLLECTIVE:
+ poolingMethod = (slots: Slot[], compareWith: Slot[]) =>
+ slots.filter((slot) => compareWith.some((compare) => compare.time.isSame(slot.time)));
+ break;
+ case SchedulingType.ROUND_ROBIN:
+ // TODO: Create a Reservation (lock this slot for X minutes)
+ // this will make the following code redundant
+ poolingMethod = (slots: Slot[], compareWith: Slot[]) => {
+ compareWith.forEach((compare) => {
+ const match = slots.findIndex((slot) => slot.time.isSame(compare.time));
+ if (match !== -1) {
+ slots[match].users?.push(compare.users![0]);
+ } else {
+ slots.push(compare);
+ }
+ });
+ return slots;
+ };
+ break;
+ }
+
+ if (!poolingMethod) {
+ throw Error(`No poolingMethod found for schedulingType: "${props.schedulingType}""`);
+ }
+
+ for (let i = 1; i < results.length; i++) {
+ loadedSlots = poolingMethod(loadedSlots, results[i]);
+ }
+ loadedSlots = loadedSlots.sort((a, b) => (a.time.isAfter(b.time) ? 1 : -1));
setSlots(loadedSlots);
setLoading(false);
- return;
- }
-
- let poolingMethod;
- switch (props.schedulingType) {
- // intersect by time, does not take into account eventLength (yet)
- case SchedulingType.COLLECTIVE:
- poolingMethod = (slots, compareWith) =>
- slots.filter((slot) => compareWith.some((compare) => compare.time.isSame(slot.time)));
- break;
- case SchedulingType.ROUND_ROBIN:
- // TODO: Create a Reservation (lock this slot for X minutes)
- // this will make the following code redundant
- poolingMethod = (slots, compareWith) => {
- compareWith.forEach((compare) => {
- const match = slots.findIndex((slot) => slot.time.isSame(compare.time));
- if (match !== -1) {
- slots[match].users.push(compare.users[0]);
- } else {
- slots.push(compare);
- }
- });
- return slots;
- };
- break;
- }
-
- for (let i = 1; i < results.length; i++) {
- loadedSlots = poolingMethod(loadedSlots, results[i]);
- }
- loadedSlots = loadedSlots.sort((a, b) => (a.time.isAfter(b.time) ? 1 : -1));
- setSlots(loadedSlots);
- setLoading(false);
- });
+ })
+ .catch((e) => {
+ console.error(e);
+ setError(e);
+ });
}, [date]);
- const handleAvailableSlots = async (res) => {
+ const handleAvailableSlots = async (res: Response) => {
const responseBody: AvailabilityUserResponse = await res.json();
const times = getSlots({
- frequency: eventLength,
+ frequency: slotInterval || eventLength,
inviteeDate: date,
workingHours: responseBody.workingHours,
minimumBookingNotice,
- organizerTimeZone: responseBody.timeZone,
});
-
// Check for conflicts
for (let i = times.length - 1; i >= 0; i -= 1) {
responseBody.busy.every((busyTime): boolean => {
diff --git a/lib/integrations/Apple/AppleCalendarAdapter.ts b/lib/integrations/Apple/AppleCalendarAdapter.ts
deleted file mode 100644
index 5a30e7c4..00000000
--- a/lib/integrations/Apple/AppleCalendarAdapter.ts
+++ /dev/null
@@ -1,361 +0,0 @@
-import { Credential } from "@prisma/client";
-import dayjs from "dayjs";
-import utc from "dayjs/plugin/utc";
-import ICAL from "ical.js";
-import { createEvent, DurationObject, Attendee, Person } from "ics";
-import {
- createAccount,
- fetchCalendars,
- fetchCalendarObjects,
- getBasicAuthHeaders,
- createCalendarObject,
- updateCalendarObject,
- deleteCalendarObject,
-} from "tsdav";
-import { v4 as uuidv4 } from "uuid";
-
-import { symmetricDecrypt } from "@lib/crypto";
-import logger from "@lib/logger";
-
-import { IntegrationCalendar, CalendarApiAdapter, CalendarEvent } from "../../calendarClient";
-import { stripHtml } from "../../emails/helpers";
-
-dayjs.extend(utc);
-
-const log = logger.getChildLogger({ prefix: ["[[lib] apple calendar"] });
-
-type EventBusyDate = Record<"start" | "end", Date>;
-
-export class AppleCalendar implements CalendarApiAdapter {
- private url: string;
- private credentials: Record;
- private headers: Record;
- private readonly integrationName: string = "apple_calendar";
-
- constructor(credential: Credential) {
- const decryptedCredential = JSON.parse(
- symmetricDecrypt(credential.key, process.env.CALENDSO_ENCRYPTION_KEY)
- );
- const username = decryptedCredential.username;
- const password = decryptedCredential.password;
-
- this.url = "https://caldav.icloud.com";
-
- this.credentials = {
- username,
- password,
- };
-
- this.headers = getBasicAuthHeaders({
- username,
- password,
- });
- }
-
- convertDate(date: string): number[] {
- return dayjs(date)
- .utc()
- .toArray()
- .slice(0, 6)
- .map((v, i) => (i === 1 ? v + 1 : v));
- }
-
- getDuration(start: string, end: string): DurationObject {
- return {
- minutes: dayjs(end).diff(dayjs(start), "minute"),
- };
- }
-
- getAttendees(attendees: Person[]): Attendee[] {
- return attendees.map(({ email, name }) => ({ name, email, partstat: "NEEDS-ACTION" }));
- }
-
- async createEvent(event: CalendarEvent): Promise> {
- try {
- const calendars = await this.listCalendars();
- const uid = uuidv4();
- const { error, value: iCalString } = await createEvent({
- uid,
- startInputType: "utc",
- start: this.convertDate(event.startTime),
- duration: this.getDuration(event.startTime, event.endTime),
- title: event.title,
- description: stripHtml(event.description ?? ""),
- location: event.location,
- organizer: { email: event.organizer.email, name: event.organizer.name },
- attendees: this.getAttendees(event.attendees),
- });
-
- if (error) {
- log.debug("Error creating iCalString");
- return {};
- }
-
- if (!iCalString) {
- log.debug("Error creating iCalString");
- return {};
- }
-
- await Promise.all(
- calendars.map((calendar) => {
- return createCalendarObject({
- calendar: {
- url: calendar.externalId,
- },
- filename: `${uid}.ics`,
- iCalString: iCalString,
- headers: this.headers,
- });
- })
- );
-
- return {
- uid,
- id: uid,
- };
- } catch (reason) {
- console.error(reason);
- throw reason;
- }
- }
-
- async updateEvent(uid: string, event: CalendarEvent): Promise {
- try {
- const calendars = await this.listCalendars();
- const events = [];
-
- for (const cal of calendars) {
- const calEvents = await this.getEvents(cal.externalId, null, null, [`${cal.externalId}${uid}.ics`]);
-
- for (const ev of calEvents) {
- events.push(ev);
- }
- }
-
- const { error, value: iCalString } = await createEvent({
- uid,
- startInputType: "utc",
- start: this.convertDate(event.startTime),
- duration: this.getDuration(event.startTime, event.endTime),
- title: event.title,
- description: stripHtml(event.description ?? ""),
- location: event.location,
- organizer: { email: event.organizer.email, name: event.organizer.name },
- attendees: this.getAttendees(event.attendees),
- });
-
- if (error) {
- log.debug("Error creating iCalString");
- return {};
- }
-
- const eventsToUpdate = events.filter((event) => event.uid === uid);
-
- return await Promise.all(
- eventsToUpdate.map((event) => {
- return updateCalendarObject({
- calendarObject: {
- url: event.url,
- data: iCalString,
- etag: event?.etag,
- },
- headers: this.headers,
- });
- })
- );
- } catch (reason) {
- console.error(reason);
- throw reason;
- }
- }
-
- async deleteEvent(uid: string): Promise {
- try {
- const calendars = await this.listCalendars();
- const events = [];
-
- for (const cal of calendars) {
- const calEvents = await this.getEvents(cal.externalId, null, null, [`${cal.externalId}${uid}.ics`]);
-
- for (const ev of calEvents) {
- events.push(ev);
- }
- }
-
- const eventsToUpdate = events.filter((event) => event.uid === uid);
-
- await Promise.all(
- eventsToUpdate.map((event) => {
- return deleteCalendarObject({
- calendarObject: {
- url: event.url,
- etag: event?.etag,
- },
- headers: this.headers,
- });
- })
- );
- } catch (reason) {
- console.error(reason);
- throw reason;
- }
- }
-
- async getAvailability(
- dateFrom: string,
- dateTo: string,
- selectedCalendars: IntegrationCalendar[]
- ): Promise {
- try {
- const selectedCalendarIds = selectedCalendars
- .filter((e) => e.integration === this.integrationName)
- .map((e) => e.externalId);
- if (selectedCalendarIds.length == 0 && selectedCalendars.length > 0) {
- // Only calendars of other integrations selected
- return Promise.resolve([]);
- }
-
- return (
- selectedCalendarIds.length === 0
- ? this.listCalendars().then((calendars) => calendars.map((calendar) => calendar.externalId))
- : Promise.resolve(selectedCalendarIds)
- ).then(async (ids: string[]) => {
- if (ids.length === 0) {
- return Promise.resolve([]);
- }
-
- return (
- await Promise.all(
- ids.map(async (calId) => {
- return (await this.getEvents(calId, dateFrom, dateTo)).map((event) => {
- return {
- start: event.startDate,
- end: event.endDate,
- };
- });
- })
- )
- ).flatMap((event) => event);
- });
- } catch (reason) {
- log.error(reason);
- throw reason;
- }
- }
-
- async listCalendars(): Promise {
- try {
- const account = await this.getAccount();
- const calendars = await fetchCalendars({
- account,
- headers: this.headers,
- });
-
- return calendars
- .filter((calendar) => {
- return calendar.components?.includes("VEVENT");
- })
- .map((calendar, index) => ({
- externalId: calendar.url,
- name: calendar.displayName ?? "",
- // FIXME Find a better way to set the primary calendar
- primary: index === 0,
- integration: this.integrationName,
- }));
- } catch (reason) {
- console.error(reason);
- throw reason;
- }
- }
-
- async getEvents(
- calId: string,
- dateFrom: string | null,
- dateTo: string | null,
- objectUrls: string[] | null
- ): Promise {
- try {
- const objects = await fetchCalendarObjects({
- calendar: {
- url: calId,
- },
- objectUrls: objectUrls ? objectUrls : undefined,
- timeRange:
- dateFrom && dateTo
- ? {
- start: dayjs(dateFrom).utc().format("YYYY-MM-DDTHH:mm:ss[Z]"),
- end: dayjs(dateTo).utc().format("YYYY-MM-DDTHH:mm:ss[Z]"),
- }
- : undefined,
- headers: this.headers,
- });
-
- const events =
- objects &&
- objects?.length > 0 &&
- objects
- .map((object) => {
- if (object?.data) {
- const jcalData = ICAL.parse(object.data);
- const vcalendar = new ICAL.Component(jcalData);
- const vevent = vcalendar.getFirstSubcomponent("vevent");
- const event = new ICAL.Event(vevent);
-
- const calendarTimezone = vcalendar.getFirstSubcomponent("vtimezone")
- ? vcalendar.getFirstSubcomponent("vtimezone").getFirstPropertyValue("tzid")
- : "";
-
- const startDate = calendarTimezone
- ? dayjs(event.startDate).tz(calendarTimezone)
- : new Date(event.startDate.toUnixTime() * 1000);
- const endDate = calendarTimezone
- ? dayjs(event.endDate).tz(calendarTimezone)
- : new Date(event.endDate.toUnixTime() * 1000);
-
- return {
- uid: event.uid,
- etag: object.etag,
- url: object.url,
- summary: event.summary,
- description: event.description,
- location: event.location,
- sequence: event.sequence,
- startDate,
- endDate,
- duration: {
- weeks: event.duration.weeks,
- days: event.duration.days,
- hours: event.duration.hours,
- minutes: event.duration.minutes,
- seconds: event.duration.seconds,
- isNegative: event.duration.isNegative,
- },
- organizer: event.organizer,
- attendees: event.attendees.map((a) => a.getValues()),
- recurrenceId: event.recurrenceId,
- timezone: calendarTimezone,
- };
- }
- })
- .filter((e) => e != null);
-
- return events;
- } catch (reason) {
- console.error(reason);
- throw reason;
- }
- }
-
- private async getAccount() {
- const account = await createAccount({
- account: {
- serverUrl: this.url,
- accountType: "caldav",
- credentials: this.credentials,
- },
- headers: this.headers,
- });
-
- return account;
- }
-}
diff --git a/lib/integrations/CalDav/CalDavCalendarAdapter.ts b/lib/integrations/CalDav/CalDavCalendarAdapter.ts
deleted file mode 100644
index 62dc8ad9..00000000
--- a/lib/integrations/CalDav/CalDavCalendarAdapter.ts
+++ /dev/null
@@ -1,362 +0,0 @@
-import { Credential } from "@prisma/client";
-import dayjs from "dayjs";
-import utc from "dayjs/plugin/utc";
-import ICAL from "ical.js";
-import { Attendee, createEvent, DurationObject, Person } from "ics";
-import {
- createAccount,
- createCalendarObject,
- deleteCalendarObject,
- fetchCalendarObjects,
- fetchCalendars,
- getBasicAuthHeaders,
- updateCalendarObject,
-} from "tsdav";
-import { v4 as uuidv4 } from "uuid";
-
-import { symmetricDecrypt } from "@lib/crypto";
-import logger from "@lib/logger";
-
-import { CalendarApiAdapter, CalendarEvent, IntegrationCalendar } from "../../calendarClient";
-import { stripHtml } from "../../emails/helpers";
-
-dayjs.extend(utc);
-
-const log = logger.getChildLogger({ prefix: ["[lib] caldav"] });
-
-type EventBusyDate = Record<"start" | "end", Date>;
-
-export class CalDavCalendar implements CalendarApiAdapter {
- private url: string;
- private credentials: Record;
- private headers: Record;
- private readonly integrationName: string = "caldav_calendar";
-
- constructor(credential: Credential) {
- const decryptedCredential = JSON.parse(
- symmetricDecrypt(credential.key, process.env.CALENDSO_ENCRYPTION_KEY)
- );
- const username = decryptedCredential.username;
- const url = decryptedCredential.url;
- const password = decryptedCredential.password;
-
- this.url = url;
-
- this.credentials = {
- username,
- password,
- };
-
- this.headers = getBasicAuthHeaders({
- username,
- password,
- });
- }
-
- convertDate(date: string): number[] {
- return dayjs(date)
- .utc()
- .toArray()
- .slice(0, 6)
- .map((v, i) => (i === 1 ? v + 1 : v));
- }
-
- getDuration(start: string, end: string): DurationObject {
- return {
- minutes: dayjs(end).diff(dayjs(start), "minute"),
- };
- }
-
- getAttendees(attendees: Person[]): Attendee[] {
- return attendees.map(({ email, name }) => ({ name, email, partstat: "NEEDS-ACTION" }));
- }
-
- async createEvent(event: CalendarEvent): Promise> {
- try {
- const calendars = await this.listCalendars();
- const uid = uuidv4();
-
- const { error, value: iCalString } = await createEvent({
- uid,
- startInputType: "utc",
- // FIXME types
- start: this.convertDate(event.startTime),
- duration: this.getDuration(event.startTime, event.endTime),
- title: event.title,
- description: stripHtml(event.description ?? ""),
- location: event.location,
- organizer: { email: event.organizer.email, name: event.organizer.name },
- attendees: this.getAttendees(event.attendees),
- });
-
- if (error) {
- log.debug("Error creating iCalString");
- return {};
- }
-
- if (!iCalString) {
- log.debug("Error creating iCalString");
- return {};
- }
-
- await Promise.all(
- calendars.map((calendar) => {
- return createCalendarObject({
- calendar: {
- url: calendar.externalId,
- },
- filename: `${uid}.ics`,
- iCalString: iCalString,
- headers: this.headers,
- });
- })
- );
-
- return {
- uid,
- id: uid,
- };
- } catch (reason) {
- log.error(reason);
- throw reason;
- }
- }
-
- async updateEvent(uid: string, event: CalendarEvent): Promise {
- try {
- const calendars = await this.listCalendars();
- const events = [];
-
- for (const cal of calendars) {
- const calEvents = await this.getEvents(cal.externalId, null, null);
-
- for (const ev of calEvents) {
- events.push(ev);
- }
- }
-
- const { error, value: iCalString } = await createEvent({
- uid,
- startInputType: "utc",
- // FIXME - types wrong
- start: this.convertDate(event.startTime),
- duration: this.getDuration(event.startTime, event.endTime),
- title: event.title,
- description: stripHtml(event.description ?? ""),
- location: event.location,
- organizer: { email: event.organizer.email, name: event.organizer.name },
- attendees: this.getAttendees(event.attendees),
- });
-
- if (error) {
- log.debug("Error creating iCalString");
- return {};
- }
-
- const eventsToUpdate = events.filter((event) => event.uid === uid);
-
- return await Promise.all(
- eventsToUpdate.map((event) => {
- return updateCalendarObject({
- calendarObject: {
- url: event.url,
- data: iCalString,
- etag: event?.etag,
- },
- headers: this.headers,
- });
- })
- );
- } catch (reason) {
- log.error(reason);
- throw reason;
- }
- }
-
- async deleteEvent(uid: string): Promise {
- try {
- const calendars = await this.listCalendars();
- const events = [];
-
- for (const cal of calendars) {
- const calEvents = await this.getEvents(cal.externalId, null, null);
-
- for (const ev of calEvents) {
- events.push(ev);
- }
- }
-
- const eventsToUpdate = events.filter((event) => event.uid === uid);
-
- await Promise.all(
- eventsToUpdate.map((event) => {
- return deleteCalendarObject({
- calendarObject: {
- url: event.url,
- etag: event?.etag,
- },
- headers: this.headers,
- });
- })
- );
- } catch (reason) {
- log.error(reason);
- throw reason;
- }
- }
-
- // FIXME - types wrong
- async getAvailability(
- dateFrom: string,
- dateTo: string,
- selectedCalendars: IntegrationCalendar[]
- ): Promise {
- try {
- const selectedCalendarIds = selectedCalendars
- .filter((e) => e.integration === this.integrationName)
- .map((e) => e.externalId);
-
- if (selectedCalendarIds.length == 0 && selectedCalendars.length > 0) {
- // Only calendars of other integrations selected
- return Promise.resolve([]);
- }
-
- return (
- selectedCalendarIds.length === 0
- ? this.listCalendars().then((calendars) => calendars.map((calendar) => calendar.externalId))
- : Promise.resolve(selectedCalendarIds)
- ).then(async (ids: string[]) => {
- if (ids.length === 0) {
- return Promise.resolve([]);
- }
-
- return (
- await Promise.all(
- ids.map(async (calId) => {
- return (await this.getEvents(calId, dateFrom, dateTo)).map((event) => {
- return {
- start: event.startDate,
- end: event.endDate,
- };
- });
- })
- )
- ).flatMap((event) => event);
- });
- } catch (reason) {
- log.error(reason);
- throw reason;
- }
- }
-
- async listCalendars(): Promise {
- try {
- const account = await this.getAccount();
- const calendars = await fetchCalendars({
- account,
- headers: this.headers,
- });
-
- return calendars
- .filter((calendar) => {
- return calendar.components?.includes("VEVENT");
- })
- .map((calendar, index) => ({
- externalId: calendar.url,
- name: calendar.displayName ?? "",
- // FIXME Find a better way to set the primary calendar
- primary: index === 0,
- integration: this.integrationName,
- }));
- } catch (reason) {
- log.error(reason);
- throw reason;
- }
- }
-
- async getEvents(calId: string, dateFrom: string | null, dateTo: string | null): Promise {
- try {
- const objects = await fetchCalendarObjects({
- calendar: {
- url: calId,
- },
- timeRange:
- dateFrom && dateTo
- ? {
- start: dayjs(dateFrom).utc().format("YYYY-MM-DDTHH:mm:ss[Z]"),
- end: dayjs(dateTo).utc().format("YYYY-MM-DDTHH:mm:ss[Z]"),
- }
- : undefined,
- headers: this.headers,
- });
-
- if (!objects || objects?.length === 0) {
- return [];
- }
-
- const events = objects
- .map((object) => {
- if (object?.data) {
- const jcalData = ICAL.parse(object.data);
- const vcalendar = new ICAL.Component(jcalData);
- const vevent = vcalendar.getFirstSubcomponent("vevent");
- const event = new ICAL.Event(vevent);
-
- const calendarTimezone = vcalendar.getFirstSubcomponent("vtimezone")
- ? vcalendar.getFirstSubcomponent("vtimezone").getFirstPropertyValue("tzid")
- : "";
-
- const startDate = calendarTimezone
- ? dayjs(event.startDate).tz(calendarTimezone)
- : new Date(event.startDate.toUnixTime() * 1000);
- const endDate = calendarTimezone
- ? dayjs(event.endDate).tz(calendarTimezone)
- : new Date(event.endDate.toUnixTime() * 1000);
-
- return {
- uid: event.uid,
- etag: object.etag,
- url: object.url,
- summary: event.summary,
- description: event.description,
- location: event.location,
- sequence: event.sequence,
- startDate,
- endDate,
- duration: {
- weeks: event.duration.weeks,
- days: event.duration.days,
- hours: event.duration.hours,
- minutes: event.duration.minutes,
- seconds: event.duration.seconds,
- isNegative: event.duration.isNegative,
- },
- organizer: event.organizer,
- attendees: event.attendees.map((a) => a.getValues()),
- recurrenceId: event.recurrenceId,
- timezone: calendarTimezone,
- };
- }
- })
- .filter((e) => e != null);
-
- return events;
- } catch (reason) {
- log.error(reason);
- throw reason;
- }
- }
-
- private async getAccount() {
- const account = await createAccount({
- account: {
- serverUrl: `${this.url}`,
- accountType: "caldav",
- credentials: this.credentials,
- },
- headers: this.headers,
- });
-
- return account;
- }
-}
diff --git a/lib/integrations/Daily/DailyVideoApiAdapter.ts b/lib/integrations/Daily/DailyVideoApiAdapter.ts
index dd4c11af..c1d912c3 100644
--- a/lib/integrations/Daily/DailyVideoApiAdapter.ts
+++ b/lib/integrations/Daily/DailyVideoApiAdapter.ts
@@ -1,9 +1,12 @@
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";
+
+import { CalendarEvent } from "../calendar/interfaces/Calendar";
export interface DailyReturnType {
/** Long UID string ie: 987b5eb5-d116-4a4e-8e2c-14fcb5710966 */
@@ -67,7 +70,7 @@ const DailyVideoApiAdapter = (credential: Credential): VideoApiAdapter => {
});
}
- async function createOrUpdateMeeting(endpoint: string, event: CalendarEvent) {
+ async function createOrUpdateMeeting(endpoint: string, event: CalendarEvent): Promise {
if (!event.uid) {
throw new Error("We need need the booking uid to create the Daily reference in DB");
}
@@ -89,7 +92,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) => {
@@ -97,8 +105,25 @@ const DailyVideoApiAdapter = (credential: Credential): VideoApiAdapter => {
// added a 1 hour buffer for room expiration and room entry
const exp = Math.round(new Date(event.endTime).getTime() / 1000) + 60 * 60;
const nbf = Math.round(new Date(event.startTime).getTime() / 1000) - 60 * 60;
+ const scalePlan = process.env.DAILY_SCALE_PLAN;
+
+ if (scalePlan === "true") {
+ return {
+ privacy: "public",
+ properties: {
+ enable_new_call_ui: true,
+ enable_prejoin_ui: true,
+ enable_knocking: true,
+ enable_screenshare: true,
+ enable_chat: true,
+ exp: exp,
+ nbf: nbf,
+ enable_recording: "local",
+ },
+ };
+ }
return {
- privacy: "private",
+ privacy: "public",
properties: {
enable_new_call_ui: true,
enable_prejoin_ui: true,
@@ -116,15 +141,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 =>
+ createOrUpdateMeeting("/rooms", event),
+ deleteMeeting: async (uid: string): Promise => {
+ 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 =>
+ createOrUpdateMeeting("/rooms/" + bookingRef.uid, event),
};
};
diff --git a/lib/integrations/Zoom/ZoomVideoApiAdapter.ts b/lib/integrations/Zoom/ZoomVideoApiAdapter.ts
index ffb4e306..ed514612 100644
--- a/lib/integrations/Zoom/ZoomVideoApiAdapter.ts
+++ b/lib/integrations/Zoom/ZoomVideoApiAdapter.ts
@@ -1,12 +1,15 @@
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";
+
+import { CalendarEvent } from "../calendar/interfaces/Calendar";
/** @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;
@@ -58,7 +61,8 @@ export interface ZoomEventResult {
interface ZoomToken {
scope: "meeting:write";
- expires_in: number;
+ expiry_date: number;
+ expires_in?: number; // deprecated, purely for backwards compatibility; superseeded by expiry_date.
token_type: "bearer";
access_token: string;
refresh_token: string;
@@ -66,7 +70,8 @@ interface ZoomToken {
const zoomAuth = (credential: Credential) => {
const credentialKey = credential.key as unknown as ZoomToken;
- const isExpired = (expiryDate: number) => expiryDate < +new Date();
+ const isTokenValid = (token: ZoomToken) =>
+ token && token.token_type && token.access_token && (token.expires_in || token.expiry_date) < Date.now();
const authHeader =
"Basic " +
Buffer.from(process.env.ZOOM_CLIENT_ID + ":" + process.env.ZOOM_CLIENT_SECRET).toString("base64");
@@ -85,6 +90,9 @@ const zoomAuth = (credential: Credential) => {
})
.then(handleErrorsJson)
.then(async (responseBody) => {
+ // set expiry date as offset from current time.
+ responseBody.expiry_date = Math.round(Date.now() + responseBody.expires_in * 1000);
+ delete responseBody.expires_in;
// Store new tokens in database.
await prisma.credential.update({
where: {
@@ -94,14 +102,14 @@ const zoomAuth = (credential: Credential) => {
key: responseBody,
},
});
+ credentialKey.expiry_date = responseBody.expiry_date;
credentialKey.access_token = responseBody.access_token;
- credentialKey.expires_in = Math.round(+new Date() / 1000 + responseBody.expires_in);
return credentialKey.access_token;
});
return {
getToken: () =>
- !isExpired(credentialKey.expires_in)
+ !isTokenValid(credentialKey)
? Promise.resolve(credentialKey.access_token)
: refreshAccessToken(credentialKey.refresh_token),
};
@@ -168,37 +176,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 => {
+ 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 => {
+ 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 => {
+ 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,
+ });
+ },
};
};
diff --git a/lib/integrations/calendar/CalendarManager.ts b/lib/integrations/calendar/CalendarManager.ts
new file mode 100644
index 00000000..7f24ae8d
--- /dev/null
+++ b/lib/integrations/calendar/CalendarManager.ts
@@ -0,0 +1,180 @@
+import { Credential, SelectedCalendar } from "@prisma/client";
+import _ from "lodash";
+
+import { getUid } from "@lib/CalEventParser";
+import { getErrorFromUnknown } from "@lib/errors";
+import { EventResult } from "@lib/events/EventManager";
+import logger from "@lib/logger";
+import notEmpty from "@lib/notEmpty";
+
+import { ALL_INTEGRATIONS } from "../getIntegrations";
+import { CALENDAR_INTEGRATIONS_TYPES } from "./constants/generals";
+import { CalendarServiceType, EventBusyDate } from "./constants/types";
+import { Calendar, CalendarEvent } from "./interfaces/Calendar";
+import AppleCalendarService from "./services/AppleCalendarService";
+import CalDavCalendarService from "./services/CalDavCalendarService";
+import GoogleCalendarService from "./services/GoogleCalendarService";
+import Office365CalendarService from "./services/Office365CalendarService";
+
+const CALENDARS: Record = {
+ [CALENDAR_INTEGRATIONS_TYPES.apple]: AppleCalendarService,
+ [CALENDAR_INTEGRATIONS_TYPES.caldav]: CalDavCalendarService,
+ [CALENDAR_INTEGRATIONS_TYPES.google]: GoogleCalendarService,
+ [CALENDAR_INTEGRATIONS_TYPES.office365]: Office365CalendarService,
+};
+
+const log = logger.getChildLogger({ prefix: ["CalendarManager"] });
+
+export const getCalendar = (credential: Credential): Calendar | null => {
+ const { type: calendarType } = credential;
+
+ const calendar = CALENDARS[calendarType];
+ if (!calendar) {
+ log.warn(`calendar of type ${calendarType} does not implemented`);
+ return null;
+ }
+
+ return new calendar(credential);
+};
+
+export const getCalendarCredentials = (credentials: Array>, userId: number) => {
+ const calendarCredentials = credentials
+ .filter((credential) => credential.type.endsWith("_calendar"))
+ .flatMap((credential) => {
+ const integration = ALL_INTEGRATIONS.find((integration) => integration.type === credential.type);
+
+ const calendar = getCalendar({
+ ...credential,
+ userId,
+ });
+ return integration && calendar && integration.variant === "calendar"
+ ? [{ integration, credential, calendar }]
+ : [];
+ });
+
+ return calendarCredentials;
+};
+
+export const getConnectedCalendars = async (
+ calendarCredentials: ReturnType,
+ selectedCalendars: { externalId: string }[]
+) => {
+ const connectedCalendars = await Promise.all(
+ calendarCredentials.map(async (item) => {
+ const { calendar, integration, credential } = item;
+
+ const credentialId = credential.id;
+ try {
+ const cals = await calendar.listCalendars();
+ const calendars = _(cals)
+ .map((cal) => ({
+ ...cal,
+ primary: cal.primary || null,
+ isSelected: selectedCalendars.some((selected) => selected.externalId === cal.externalId),
+ }))
+ .sortBy(["primary"])
+ .value();
+ const primary = calendars.find((item) => item.primary) ?? calendars[0];
+ if (!primary) {
+ throw new Error("No primary calendar found");
+ }
+ return {
+ integration,
+ credentialId,
+ primary,
+ calendars,
+ };
+ } catch (_error) {
+ const error = getErrorFromUnknown(_error);
+ return {
+ integration,
+ credentialId,
+ error: {
+ message: error.message,
+ },
+ };
+ }
+ })
+ );
+
+ return connectedCalendars;
+};
+
+export const getBusyCalendarTimes = async (
+ withCredentials: Credential[],
+ dateFrom: string,
+ dateTo: string,
+ selectedCalendars: SelectedCalendar[]
+) => {
+ const calendars = withCredentials
+ .filter((credential) => credential.type.endsWith("_calendar"))
+ .map((credential) => getCalendar(credential))
+ .filter(notEmpty);
+
+ let results: EventBusyDate[][] = [];
+ try {
+ results = await Promise.all(calendars.map((c) => c.getAvailability(dateFrom, dateTo, selectedCalendars)));
+ } catch (error) {
+ log.warn(error);
+ }
+
+ return results.reduce((acc, availability) => acc.concat(availability), []);
+};
+
+export const createEvent = async (credential: Credential, calEvent: CalendarEvent): Promise => {
+ const uid: string = getUid(calEvent);
+ const calendar = getCalendar(credential);
+ let success = true;
+
+ const creationResult = calendar
+ ? await calendar.createEvent(calEvent).catch((e) => {
+ log.error("createEvent failed", e, calEvent);
+ success = false;
+ return undefined;
+ })
+ : undefined;
+
+ return {
+ type: credential.type,
+ success,
+ uid,
+ createdEvent: creationResult,
+ originalEvent: calEvent,
+ };
+};
+
+export const updateEvent = async (
+ credential: Credential,
+ calEvent: CalendarEvent,
+ bookingRefUid: string | null
+): Promise => {
+ const uid = getUid(calEvent);
+ const calendar = getCalendar(credential);
+ let success = true;
+
+ const updatedResult =
+ calendar && bookingRefUid
+ ? await calendar.updateEvent(bookingRefUid, calEvent).catch((e) => {
+ log.error("updateEvent failed", e, calEvent);
+ success = false;
+ return undefined;
+ })
+ : undefined;
+
+ return {
+ type: credential.type,
+ success,
+ uid,
+ updatedEvent: updatedResult,
+ originalEvent: calEvent,
+ };
+};
+
+export const deleteEvent = (credential: Credential, uid: string, event: CalendarEvent): Promise => {
+ const calendar = getCalendar(credential);
+ if (calendar) {
+ return calendar.deleteEvent(uid, event);
+ }
+
+ return Promise.resolve({});
+};
diff --git a/lib/integrations/Apple/components/AddAppleIntegration.tsx b/lib/integrations/calendar/components/AddAppleIntegration.tsx
similarity index 97%
rename from lib/integrations/Apple/components/AddAppleIntegration.tsx
rename to lib/integrations/calendar/components/AddAppleIntegration.tsx
index ab901d22..8cdcbd78 100644
--- a/lib/integrations/Apple/components/AddAppleIntegration.tsx
+++ b/lib/integrations/calendar/components/AddAppleIntegration.tsx
@@ -45,7 +45,7 @@ export function AddAppleIntegrationModal(props: DialogProps) {
{
+ handleSubmit={async (values) => {
setErrorMessage("");
const res = await fetch("/api/integrations/apple/add", {
method: "POST",
@@ -60,7 +60,7 @@ export function AddAppleIntegrationModal(props: DialogProps) {
} else {
props.onOpenChange?.(false);
}
- })}>
+ }}>
{
+ handleSubmit={async (values) => {
setErrorMessage("");
const res = await fetch("/api/integrations/caldav/add", {
method: "POST",
@@ -58,7 +58,7 @@ export function AddCalDavIntegrationModal(props: DialogProps) {
} else {
props.onOpenChange?.(false);
}
- })}>
+ }}>
((props, re
Calendar URL
-
@@ -144,7 +144,7 @@ const AddCalDavIntegration = React.forwardRef((props, re
name="username"
id="username"
placeholder="rickroll"
- className="mt-1 block w-full border border-gray-300 rounded-sm shadow-sm py-2 px-3 focus:outline-none focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm"
+ className="block w-full px-3 py-2 mt-1 border border-gray-300 rounded-sm shadow-sm focus:outline-none focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm"
/>
@@ -157,7 +157,7 @@ const AddCalDavIntegration = React.forwardRef((props, re
name="password"
id="password"
placeholder="•••••••••••••"
- className="mt-1 block w-full border border-gray-300 rounded-sm shadow-sm py-2 px-3 focus:outline-none focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm"
+ className="block w-full px-3 py-2 mt-1 border border-gray-300 rounded-sm shadow-sm focus:outline-none focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm"
/>
diff --git a/lib/integrations/calendar/constants/formats.ts b/lib/integrations/calendar/constants/formats.ts
new file mode 100644
index 00000000..0d5ca6fe
--- /dev/null
+++ b/lib/integrations/calendar/constants/formats.ts
@@ -0,0 +1 @@
+export const TIMEZONE_FORMAT = "YYYY-MM-DDTHH:mm:ss[Z]";
diff --git a/lib/integrations/calendar/constants/generals.ts b/lib/integrations/calendar/constants/generals.ts
new file mode 100644
index 00000000..079c86b2
--- /dev/null
+++ b/lib/integrations/calendar/constants/generals.ts
@@ -0,0 +1,10 @@
+export const CALDAV_CALENDAR_TYPE = "caldav";
+
+export const APPLE_CALENDAR_URL = "https://caldav.icloud.com";
+
+export const CALENDAR_INTEGRATIONS_TYPES = {
+ apple: "apple_calendar",
+ caldav: "caldav_calendar",
+ google: "google_calendar",
+ office365: "office365_calendar",
+};
diff --git a/lib/integrations/calendar/constants/types.ts b/lib/integrations/calendar/constants/types.ts
new file mode 100644
index 00000000..1a3cb2aa
--- /dev/null
+++ b/lib/integrations/calendar/constants/types.ts
@@ -0,0 +1,57 @@
+import dayjs from "dayjs";
+import ICAL from "ical.js";
+
+import AppleCalendarService from "../services/AppleCalendarService";
+import CalDavCalendarService from "../services/CalDavCalendarService";
+import GoogleCalendarService from "../services/GoogleCalendarService";
+import Office365CalendarService from "../services/Office365CalendarService";
+
+export type EventBusyDate = Record<"start" | "end", Date | string>;
+
+export type CalendarServiceType =
+ | typeof AppleCalendarService
+ | typeof CalDavCalendarService
+ | typeof GoogleCalendarService
+ | typeof Office365CalendarService;
+
+export type NewCalendarEventType = {
+ uid: string;
+ id: string;
+ type: string;
+ password: string;
+ url: string;
+ additionalInfo: Record
;
+};
+
+export type CalendarEventType = {
+ uid: string;
+ etag: string;
+ /** This is the actual caldav event url, not the location url. */
+ url: string;
+ summary: string;
+ description: string;
+ location: string;
+ sequence: number;
+ startDate: Date | dayjs.Dayjs;
+ endDate: Date | dayjs.Dayjs;
+ duration: {
+ weeks: number;
+ days: number;
+ hours: number;
+ minutes: number;
+ seconds: number;
+ isNegative: boolean;
+ };
+ organizer: string;
+ attendees: any[][];
+ recurrenceId: ICAL.Time;
+ timezone: any;
+};
+
+export type BatchResponse = {
+ responses: SubResponse[];
+};
+
+export type SubResponse = {
+ body: { value: { start: { dateTime: string }; end: { dateTime: string } }[] };
+};
diff --git a/lib/integrations/calendar/interfaces/Calendar.ts b/lib/integrations/calendar/interfaces/Calendar.ts
new file mode 100644
index 00000000..0b6c1b61
--- /dev/null
+++ b/lib/integrations/calendar/interfaces/Calendar.ts
@@ -0,0 +1,79 @@
+import { DestinationCalendar, SelectedCalendar } from "@prisma/client";
+import { TFunction } from "next-i18next";
+
+import { PaymentInfo } from "@ee/lib/stripe/server";
+
+import { Ensure } from "@lib/types/utils";
+import { VideoCallData } from "@lib/videoClient";
+
+import { NewCalendarEventType } from "../constants/types";
+import { ConferenceData } from "./GoogleCalendar";
+
+export type Person = {
+ name: string;
+ email: string;
+ timeZone: string;
+ language: { translate: TFunction; locale: string };
+};
+
+export interface EntryPoint {
+ entryPointType?: string;
+ uri?: string;
+ label?: string;
+ pin?: string;
+ accessCode?: string;
+ meetingCode?: string;
+ passcode?: string;
+ password?: string;
+}
+
+export interface AdditionInformation {
+ conferenceData?: ConferenceData;
+ entryPoints?: EntryPoint[];
+ hangoutLink?: string;
+}
+
+export interface CalendarEvent {
+ type: string;
+ title: string;
+ startTime: string;
+ endTime: string;
+ description?: string | null;
+ team?: {
+ name: string;
+ members: string[];
+ };
+ location?: string | null;
+ organizer: Person;
+ attendees: Person[];
+ conferenceData?: ConferenceData;
+ additionInformation?: AdditionInformation;
+ uid?: string | null;
+ videoCallData?: VideoCallData;
+ paymentInfo?: PaymentInfo | null;
+ destinationCalendar?: DestinationCalendar | null;
+ cancellationReason?: string | null;
+}
+
+export interface IntegrationCalendar extends Ensure, "externalId"> {
+ primary?: boolean;
+ name?: string;
+}
+
+type EventBusyDate = Record<"start" | "end", Date | string>;
+
+export interface Calendar {
+ createEvent(event: CalendarEvent): Promise;
+
+ updateEvent(uid: string, event: CalendarEvent): Promise;
+
+ deleteEvent(uid: string, event: CalendarEvent): Promise;
+
+ getAvailability(
+ dateFrom: string,
+ dateTo: string,
+ selectedCalendars: IntegrationCalendar[]
+ ): Promise;
+
+ listCalendars(event?: CalendarEvent): Promise;
+}
diff --git a/lib/integrations/calendar/interfaces/GoogleCalendar.ts b/lib/integrations/calendar/interfaces/GoogleCalendar.ts
new file mode 100644
index 00000000..a76d26a5
--- /dev/null
+++ b/lib/integrations/calendar/interfaces/GoogleCalendar.ts
@@ -0,0 +1,5 @@
+import { calendar_v3 } from "googleapis";
+
+export interface ConferenceData {
+ createRequest?: calendar_v3.Schema$CreateConferenceRequest;
+}
diff --git a/lib/integrations/calendar/interfaces/Office365Calendar.ts b/lib/integrations/calendar/interfaces/Office365Calendar.ts
new file mode 100644
index 00000000..599d2b9a
--- /dev/null
+++ b/lib/integrations/calendar/interfaces/Office365Calendar.ts
@@ -0,0 +1,10 @@
+export type BufferedBusyTime = {
+ start: string;
+ end: string;
+};
+
+export type O365AuthCredentials = {
+ expiry_date: number;
+ access_token: string;
+ refresh_token: string;
+};
diff --git a/lib/integrations/calendar/services/AppleCalendarService.ts b/lib/integrations/calendar/services/AppleCalendarService.ts
new file mode 100644
index 00000000..1ba55ca2
--- /dev/null
+++ b/lib/integrations/calendar/services/AppleCalendarService.ts
@@ -0,0 +1,10 @@
+import { Credential } from "@prisma/client";
+
+import { APPLE_CALENDAR_URL, CALENDAR_INTEGRATIONS_TYPES } from "../constants/generals";
+import CalendarService from "./BaseCalendarService";
+
+export default class AppleCalendarService extends CalendarService {
+ constructor(credential: Credential) {
+ super(credential, CALENDAR_INTEGRATIONS_TYPES.apple, APPLE_CALENDAR_URL);
+ }
+}
diff --git a/lib/integrations/calendar/services/BaseCalendarService.ts b/lib/integrations/calendar/services/BaseCalendarService.ts
new file mode 100644
index 00000000..11a3292b
--- /dev/null
+++ b/lib/integrations/calendar/services/BaseCalendarService.ts
@@ -0,0 +1,367 @@
+import { Credential } from "@prisma/client";
+import dayjs from "dayjs";
+import ICAL from "ical.js";
+import { createEvent } from "ics";
+import {
+ createAccount,
+ createCalendarObject,
+ DAVAccount,
+ deleteCalendarObject,
+ fetchCalendarObjects,
+ fetchCalendars,
+ getBasicAuthHeaders,
+ updateCalendarObject,
+} from "tsdav";
+import { v4 as uuidv4 } from "uuid";
+
+import { getLocation, getRichDescription } from "@lib/CalEventParser";
+import { symmetricDecrypt } from "@lib/crypto";
+import logger from "@lib/logger";
+
+import { TIMEZONE_FORMAT } from "../constants/formats";
+import { CALDAV_CALENDAR_TYPE } from "../constants/generals";
+import { CalendarEventType, EventBusyDate, NewCalendarEventType } from "../constants/types";
+import { Calendar, CalendarEvent, IntegrationCalendar } from "../interfaces/Calendar";
+import { convertDate, getAttendees, getDuration } from "../utils/CalendarUtils";
+
+const CALENDSO_ENCRYPTION_KEY = process.env.CALENDSO_ENCRYPTION_KEY || "";
+
+export default abstract class BaseCalendarService implements Calendar {
+ private url = "";
+ private credentials: Record = {};
+ private headers: Record = {};
+ protected integrationName = "";
+ private log: typeof logger;
+
+ constructor(credential: Credential, integrationName: string, url?: string) {
+ this.integrationName = integrationName;
+
+ const {
+ username,
+ password,
+ url: credentialURL,
+ } = JSON.parse(symmetricDecrypt(credential.key as string, CALENDSO_ENCRYPTION_KEY));
+
+ this.url = url || credentialURL;
+
+ this.credentials = { username, password };
+ this.headers = getBasicAuthHeaders({ username, password });
+
+ this.log = logger.getChildLogger({ prefix: [`[[lib] ${this.integrationName}`] });
+ }
+
+ async createEvent(event: CalendarEvent): Promise {
+ try {
+ const calendars = await this.listCalendars(event);
+
+ const uid = uuidv4();
+
+ // We create local ICS files
+ const { error, value: iCalString } = createEvent({
+ uid,
+ startInputType: "utc",
+ start: convertDate(event.startTime),
+ duration: getDuration(event.startTime, event.endTime),
+ title: event.title,
+ description: getRichDescription(event),
+ location: getLocation(event),
+ organizer: { email: event.organizer.email, name: event.organizer.name },
+ /** according to https://datatracker.ietf.org/doc/html/rfc2446#section-3.2.1, in a published iCalendar component.
+ * "Attendees" MUST NOT be present
+ * `attendees: this.getAttendees(event.attendees),`
+ */
+ });
+
+ if (error || !iCalString) throw new Error("Error creating iCalString");
+
+ // We create the event directly on iCal
+ const responses = await Promise.all(
+ calendars
+ .filter((c) =>
+ event.destinationCalendar?.externalId
+ ? c.externalId === event.destinationCalendar.externalId
+ : true
+ )
+ .map((calendar) =>
+ createCalendarObject({
+ calendar: {
+ url: calendar.externalId,
+ },
+ filename: `${uid}.ics`,
+ // according to https://datatracker.ietf.org/doc/html/rfc4791#section-4.1, Calendar object resources contained in calendar collections MUST NOT specify the iCalendar METHOD property.
+ iCalString: iCalString.replace(/METHOD:[^\r\n]+\r\n/g, ""),
+ headers: this.headers,
+ })
+ )
+ );
+
+ if (responses.some((r) => !r.ok)) {
+ throw new Error(
+ `Error creating event: ${(await Promise.all(responses.map((r) => r.text()))).join(", ")}`
+ );
+ }
+
+ return {
+ uid,
+ id: uid,
+ type: this.integrationName,
+ password: "",
+ url: "",
+ additionalInfo: {},
+ };
+ } catch (reason) {
+ logger.error(reason);
+
+ throw reason;
+ }
+ }
+
+ async updateEvent(uid: string, event: CalendarEvent): Promise {
+ try {
+ const events = await this.getEventsByUID(uid);
+
+ /** We generate the ICS files */
+ const { error, value: iCalString } = createEvent({
+ uid,
+ startInputType: "utc",
+ start: convertDate(event.startTime),
+ duration: getDuration(event.startTime, event.endTime),
+ title: event.title,
+ description: getRichDescription(event),
+ location: getLocation(event),
+ organizer: { email: event.organizer.email, name: event.organizer.name },
+ attendees: getAttendees(event.attendees),
+ });
+
+ if (error) {
+ this.log.debug("Error creating iCalString");
+
+ return {};
+ }
+
+ const eventsToUpdate = events.filter((e) => e.uid === uid);
+
+ return Promise.all(
+ eventsToUpdate.map((e) => {
+ return updateCalendarObject({
+ calendarObject: {
+ url: e.url,
+ data: iCalString,
+ etag: e?.etag,
+ },
+ headers: this.headers,
+ });
+ })
+ );
+ } catch (reason) {
+ this.log.error(reason);
+
+ throw reason;
+ }
+ }
+
+ async deleteEvent(uid: string): Promise {
+ try {
+ const events = await this.getEventsByUID(uid);
+
+ const eventsToDelete = events.filter((event) => event.uid === uid);
+
+ await Promise.all(
+ eventsToDelete.map((event) => {
+ return deleteCalendarObject({
+ calendarObject: {
+ url: event.url,
+ etag: event?.etag,
+ },
+ headers: this.headers,
+ });
+ })
+ );
+ } catch (reason) {
+ this.log.error(reason);
+
+ throw reason;
+ }
+ }
+
+ async getAvailability(
+ dateFrom: string,
+ dateTo: string,
+ selectedCalendars: IntegrationCalendar[]
+ ): Promise {
+ const objects = (
+ await Promise.all(
+ selectedCalendars.map((sc) =>
+ fetchCalendarObjects({
+ calendar: {
+ url: sc.externalId,
+ },
+ headers: this.headers,
+ expand: true,
+ timeRange: {
+ start: new Date(dateFrom).toISOString(),
+ end: new Date(dateTo).toISOString(),
+ },
+ })
+ )
+ )
+ ).flat();
+
+ const events = objects
+ .filter((e) => !!e.data)
+ .map((object) => {
+ const jcalData = ICAL.parse(object.data);
+ const vcalendar = new ICAL.Component(jcalData);
+ const vevent = vcalendar.getFirstSubcomponent("vevent");
+ const event = new ICAL.Event(vevent);
+ const calendarTimezone =
+ vcalendar.getFirstSubcomponent("vtimezone")?.getFirstPropertyValue("tzid") || "";
+
+ const startDate = calendarTimezone
+ ? dayjs(event.startDate.toJSDate()).tz(calendarTimezone)
+ : new Date(event.startDate.toUnixTime() * 1000);
+
+ const endDate = calendarTimezone
+ ? dayjs(event.endDate.toJSDate()).tz(calendarTimezone)
+ : new Date(event.endDate.toUnixTime() * 1000);
+
+ return {
+ start: startDate.toISOString(),
+ end: endDate.toISOString(),
+ };
+ });
+
+ return Promise.resolve(events);
+ }
+
+ async listCalendars(event?: CalendarEvent): Promise {
+ try {
+ const account = await this.getAccount();
+
+ const calendars = await fetchCalendars({
+ account,
+ headers: this.headers,
+ });
+
+ return calendars.reduce((newCalendars, calendar) => {
+ if (!calendar.components?.includes("VEVENT")) return newCalendars;
+
+ newCalendars.push({
+ externalId: calendar.url,
+ name: calendar.displayName ?? "",
+ primary: event?.destinationCalendar?.externalId
+ ? event.destinationCalendar.externalId === calendar.url
+ : false,
+ integration: this.integrationName,
+ });
+ return newCalendars;
+ }, []);
+ } catch (reason) {
+ logger.error(reason);
+
+ throw reason;
+ }
+ }
+
+ private async getEvents(
+ calId: string,
+ dateFrom: string | null,
+ dateTo: string | null,
+ objectUrls?: string[] | null
+ ) {
+ try {
+ const objects = await fetchCalendarObjects({
+ calendar: {
+ url: calId,
+ },
+ objectUrls: objectUrls ? objectUrls : undefined,
+ timeRange:
+ dateFrom && dateTo
+ ? {
+ start: dayjs(dateFrom).utc().format(TIMEZONE_FORMAT),
+ end: dayjs(dateTo).utc().format(TIMEZONE_FORMAT),
+ }
+ : undefined,
+ headers: this.headers,
+ });
+
+ const events = objects
+ .filter((e) => !!e.data)
+ .map((object) => {
+ const jcalData = ICAL.parse(object.data);
+
+ const vcalendar = new ICAL.Component(jcalData);
+
+ const vevent = vcalendar.getFirstSubcomponent("vevent");
+ const event = new ICAL.Event(vevent);
+
+ const calendarTimezone =
+ vcalendar.getFirstSubcomponent("vtimezone")?.getFirstPropertyValue("tzid") || "";
+
+ const startDate = calendarTimezone
+ ? dayjs(event.startDate.toJSDate()).tz(calendarTimezone)
+ : new Date(event.startDate.toUnixTime() * 1000);
+
+ const endDate = calendarTimezone
+ ? dayjs(event.endDate.toJSDate()).tz(calendarTimezone)
+ : new Date(event.endDate.toUnixTime() * 1000);
+
+ return {
+ uid: event.uid,
+ etag: object.etag,
+ url: object.url,
+ summary: event.summary,
+ description: event.description,
+ location: event.location,
+ sequence: event.sequence,
+ startDate,
+ endDate,
+ duration: {
+ weeks: event.duration.weeks,
+ days: event.duration.days,
+ hours: event.duration.hours,
+ minutes: event.duration.minutes,
+ seconds: event.duration.seconds,
+ isNegative: event.duration.isNegative,
+ },
+ organizer: event.organizer,
+ attendees: event.attendees.map((a) => a.getValues()),
+ recurrenceId: event.recurrenceId,
+ timezone: calendarTimezone,
+ };
+ });
+
+ return events;
+ } catch (reason) {
+ console.error(reason);
+ throw reason;
+ }
+ }
+
+ private async getEventsByUID(uid: string): Promise {
+ const events = [];
+
+ const calendars = await this.listCalendars();
+
+ for (const cal of calendars) {
+ const calEvents = await this.getEvents(cal.externalId, null, null, [`${cal.externalId}${uid}.ics`]);
+
+ for (const ev of calEvents) {
+ events.push(ev);
+ }
+ }
+
+ return events;
+ }
+
+ private async getAccount(): Promise {
+ return createAccount({
+ account: {
+ serverUrl: this.url,
+ accountType: CALDAV_CALENDAR_TYPE,
+ credentials: this.credentials,
+ },
+ headers: this.headers,
+ });
+ }
+}
diff --git a/lib/integrations/calendar/services/CalDavCalendarService.ts b/lib/integrations/calendar/services/CalDavCalendarService.ts
new file mode 100644
index 00000000..f6bc2f72
--- /dev/null
+++ b/lib/integrations/calendar/services/CalDavCalendarService.ts
@@ -0,0 +1,10 @@
+import { Credential } from "@prisma/client";
+
+import { CALENDAR_INTEGRATIONS_TYPES } from "../constants/generals";
+import CalendarService from "./BaseCalendarService";
+
+export default class CalDavCalendarService extends CalendarService {
+ constructor(credential: Credential) {
+ super(credential, CALENDAR_INTEGRATIONS_TYPES.caldav);
+ }
+}
diff --git a/lib/integrations/calendar/services/GoogleCalendarService.ts b/lib/integrations/calendar/services/GoogleCalendarService.ts
new file mode 100644
index 00000000..9eb2b96a
--- /dev/null
+++ b/lib/integrations/calendar/services/GoogleCalendarService.ts
@@ -0,0 +1,327 @@
+import { Credential, Prisma } from "@prisma/client";
+import { GetTokenResponse } from "google-auth-library/build/src/auth/oauth2client";
+import { Auth, calendar_v3, google } from "googleapis";
+
+import { getLocation, getRichDescription } from "@lib/CalEventParser";
+import { CALENDAR_INTEGRATIONS_TYPES } from "@lib/integrations/calendar/constants/generals";
+import logger from "@lib/logger";
+import prisma from "@lib/prisma";
+
+import { EventBusyDate, NewCalendarEventType } from "../constants/types";
+import { Calendar, CalendarEvent, IntegrationCalendar } from "../interfaces/Calendar";
+import CalendarService from "./BaseCalendarService";
+
+const GOOGLE_API_CREDENTIALS = process.env.GOOGLE_API_CREDENTIALS || "";
+
+export default class GoogleCalendarService implements Calendar {
+ private url = "";
+ private integrationName = "";
+ private auth: { getToken: () => Promise };
+ private log: typeof logger;
+
+ constructor(credential: Credential) {
+ this.integrationName = CALENDAR_INTEGRATIONS_TYPES.google;
+
+ this.auth = this.googleAuth(credential);
+
+ this.log = logger.getChildLogger({ prefix: [`[[lib] ${this.integrationName}`] });
+ }
+
+ private googleAuth = (credential: Credential) => {
+ const { client_secret, client_id, redirect_uris } = JSON.parse(GOOGLE_API_CREDENTIALS).web;
+
+ const myGoogleAuth = new MyGoogleAuth(client_id, client_secret, redirect_uris[0]);
+
+ const googleCredentials = credential.key as Auth.Credentials;
+ myGoogleAuth.setCredentials(googleCredentials);
+
+ const isExpired = () => myGoogleAuth.isTokenExpiring();
+
+ const refreshAccessToken = () =>
+ myGoogleAuth
+ .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) => {
+ this.log.error("Error refreshing google token", err);
+
+ return myGoogleAuth;
+ });
+
+ return {
+ getToken: () => (!isExpired() ? Promise.resolve(myGoogleAuth) : refreshAccessToken()),
+ };
+ };
+
+ async createEvent(event: CalendarEvent): Promise {
+ return new Promise((resolve, reject) =>
+ this.auth.getToken().then((myGoogleAuth) => {
+ const payload: calendar_v3.Schema$Event = {
+ summary: event.title,
+ description: getRichDescription(event),
+ 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"] = getLocation(event);
+ }
+
+ 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: event.destinationCalendar?.externalId
+ ? event.destinationCalendar.externalId
+ : "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({
+ uid: "",
+ ...event.data,
+ id: event.data.id || "",
+ additionalInfo: {
+ hangoutLink: event.data.hangoutLink || "",
+ },
+ type: "google_calendar",
+ password: "",
+ url: "",
+ });
+ }
+ );
+ })
+ );
+ }
+
+ async updateEvent(uid: string, event: CalendarEvent): Promise {
+ return new Promise((resolve, reject) =>
+ this.auth.getToken().then((myGoogleAuth) => {
+ const payload: calendar_v3.Schema$Event = {
+ summary: event.title,
+ description: getRichDescription(event),
+ start: {
+ dateTime: event.startTime,
+ timeZone: event.organizer.timeZone,
+ },
+ end: {
+ dateTime: event.endTime,
+ timeZone: event.organizer.timeZone,
+ },
+ attendees: event.attendees,
+ reminders: {
+ useDefault: true,
+ },
+ };
+
+ if (event.location) {
+ payload["location"] = getLocation(event);
+ }
+
+ const calendar = google.calendar({
+ version: "v3",
+ auth: myGoogleAuth,
+ });
+ calendar.events.update(
+ {
+ auth: myGoogleAuth,
+ calendarId: event.destinationCalendar?.externalId
+ ? event.destinationCalendar.externalId
+ : "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);
+ }
+ );
+ })
+ );
+ }
+
+ async deleteEvent(uid: string, event: CalendarEvent): Promise {
+ return new Promise((resolve, reject) =>
+ this.auth.getToken().then((myGoogleAuth) => {
+ const calendar = google.calendar({
+ version: "v3",
+ auth: myGoogleAuth,
+ });
+ calendar.events.delete(
+ {
+ auth: myGoogleAuth,
+ calendarId: event.destinationCalendar?.externalId
+ ? event.destinationCalendar.externalId
+ : "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);
+ }
+ );
+ })
+ );
+ }
+
+ async getAvailability(
+ dateFrom: string,
+ dateTo: string,
+ selectedCalendars: IntegrationCalendar[]
+ ): Promise {
+ return new Promise((resolve, reject) =>
+ this.auth.getToken().then((myGoogleAuth) => {
+ const calendar = google.calendar({
+ version: "v3",
+ auth: myGoogleAuth,
+ });
+ const selectedCalendarIds = selectedCalendars
+ .filter((e) => e.integration === this.integrationName)
+ .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);
+ }
+ let result: Prisma.PromiseReturnType = [];
+
+ if (apires?.data.calendars) {
+ result = Object.values(apires.data.calendars).reduce((c, i) => {
+ i.busy?.forEach((busyTime) => {
+ c.push({
+ start: busyTime.start || "",
+ end: busyTime.end || "",
+ });
+ });
+ return c;
+ }, [] as typeof result);
+ }
+ resolve(result);
+ }
+ );
+ })
+ .catch((err) => {
+ this.log.error("There was an error contacting google calendar service: ", err);
+
+ reject(err);
+ });
+ })
+ );
+ }
+
+ async listCalendars(): Promise {
+ return new Promise((resolve, reject) =>
+ this.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: this.integrationName,
+ name: cal.summary ?? "No name",
+ primary: cal.primary ?? false,
+ };
+ return calendar;
+ }) || []
+ );
+ })
+ .catch((err: Error) => {
+ this.log.error("There was an error contacting google calendar service: ", err);
+
+ reject(err);
+ });
+ })
+ );
+ }
+}
+
+class MyGoogleAuth extends google.auth.OAuth2 {
+ constructor(client_id: string, client_secret: string, redirect_uri: string) {
+ super(client_id, client_secret, redirect_uri);
+ }
+
+ isTokenExpiring() {
+ return super.isTokenExpiring();
+ }
+
+ async refreshToken(token: string | null | undefined) {
+ return super.refreshToken(token);
+ }
+}
diff --git a/lib/integrations/calendar/services/Office365CalendarService.ts b/lib/integrations/calendar/services/Office365CalendarService.ts
new file mode 100644
index 00000000..f3b7a46d
--- /dev/null
+++ b/lib/integrations/calendar/services/Office365CalendarService.ts
@@ -0,0 +1,251 @@
+import { Calendar as OfficeCalendar } from "@microsoft/microsoft-graph-types-beta";
+import { Credential } from "@prisma/client";
+
+import { getLocation, getRichDescription } from "@lib/CalEventParser";
+import { handleErrorsJson, handleErrorsRaw } from "@lib/errors";
+import { CALENDAR_INTEGRATIONS_TYPES } from "@lib/integrations/calendar/constants/generals";
+import logger from "@lib/logger";
+import prisma from "@lib/prisma";
+
+import { BatchResponse, EventBusyDate, NewCalendarEventType } from "../constants/types";
+import { Calendar, CalendarEvent, IntegrationCalendar } from "../interfaces/Calendar";
+import { BufferedBusyTime, O365AuthCredentials } from "../interfaces/Office365Calendar";
+
+const MS_GRAPH_CLIENT_ID = process.env.MS_GRAPH_CLIENT_ID || "";
+const MS_GRAPH_CLIENT_SECRET = process.env.MS_GRAPH_CLIENT_SECRET || "";
+
+export default class Office365CalendarService implements Calendar {
+ private url = "";
+ private integrationName = "";
+ private log: typeof logger;
+ auth: { getToken: () => Promise };
+
+ constructor(credential: Credential) {
+ this.integrationName = CALENDAR_INTEGRATIONS_TYPES.office365;
+ this.auth = this.o365Auth(credential);
+
+ this.log = logger.getChildLogger({ prefix: [`[[lib] ${this.integrationName}`] });
+ }
+
+ async createEvent(event: CalendarEvent): Promise {
+ try {
+ const accessToken = await this.auth.getToken();
+
+ const calendarId = event.destinationCalendar?.externalId
+ ? `${event.destinationCalendar.externalId}/`
+ : "";
+
+ const response = await fetch(`https://graph.microsoft.com/v1.0/me/calendar/${calendarId}events`, {
+ method: "POST",
+ headers: {
+ Authorization: "Bearer " + accessToken,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(this.translateEvent(event)),
+ });
+
+ return handleErrorsJson(response);
+ } catch (error) {
+ this.log.error(error);
+
+ throw error;
+ }
+ }
+
+ async updateEvent(uid: string, event: CalendarEvent): Promise {
+ try {
+ const accessToken = await this.auth.getToken();
+
+ const response = await fetch("https://graph.microsoft.com/v1.0/me/calendar/events/" + uid, {
+ method: "PATCH",
+ headers: {
+ Authorization: "Bearer " + accessToken,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(this.translateEvent(event)),
+ });
+
+ return handleErrorsRaw(response);
+ } catch (error) {
+ this.log.error(error);
+
+ throw error;
+ }
+ }
+
+ async deleteEvent(uid: string): Promise {
+ try {
+ const accessToken = await this.auth.getToken();
+
+ const response = await fetch("https://graph.microsoft.com/v1.0/me/calendar/events/" + uid, {
+ method: "DELETE",
+ headers: {
+ Authorization: "Bearer " + accessToken,
+ },
+ });
+
+ handleErrorsRaw(response);
+ } catch (error) {
+ this.log.error(error);
+
+ throw error;
+ }
+ }
+
+ async getAvailability(
+ dateFrom: string,
+ dateTo: string,
+ selectedCalendars: IntegrationCalendar[]
+ ): Promise {
+ const dateFromParsed = new Date(dateFrom);
+ const dateToParsed = new Date(dateTo);
+
+ const filter = `?startdatetime=${encodeURIComponent(
+ dateFromParsed.toISOString()
+ )}&enddatetime=${encodeURIComponent(dateToParsed.toISOString())}`;
+ return this.auth
+ .getToken()
+ .then((accessToken) => {
+ const selectedCalendarIds = selectedCalendars
+ .filter((e) => e.integration === this.integrationName)
+ .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
+ ? this.listCalendars().then((cals) => cals.map((e) => e.externalId).filter(Boolean) || [])
+ : Promise.resolve(selectedCalendarIds)
+ ).then((ids) => {
+ const requests = ids.map((calendarId, id) => ({
+ id,
+ method: "GET",
+ url: `/me/calendars/${calendarId}/calendarView${filter}`,
+ }));
+
+ 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([]);
+ });
+ }
+
+ async listCalendars(): Promise {
+ return this.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: this.integrationName,
+ name: cal.name ?? "No calendar name",
+ primary: cal.isDefaultCalendar ?? false,
+ };
+ return calendar;
+ });
+ })
+ );
+ }
+
+ private 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: MS_GRAPH_CLIENT_ID,
+ refresh_token: refreshToken,
+ grant_type: "refresh_token",
+ client_secret: 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),
+ };
+ };
+
+ private translateEvent = (event: CalendarEvent) => {
+ return {
+ subject: event.title,
+ body: {
+ contentType: "HTML",
+ content: getRichDescription(event),
+ },
+ 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: getLocation(event) } : undefined,
+ };
+ };
+}
diff --git a/lib/integrations/calendar/utils/CalendarUtils.ts b/lib/integrations/calendar/utils/CalendarUtils.ts
new file mode 100644
index 00000000..a5927e54
--- /dev/null
+++ b/lib/integrations/calendar/utils/CalendarUtils.ts
@@ -0,0 +1,16 @@
+import dayjs from "dayjs";
+import { Attendee, DateArray, DurationObject, Person } from "ics";
+
+export const convertDate = (date: string): DateArray =>
+ dayjs(date)
+ .utc()
+ .toArray()
+ .slice(0, 6)
+ .map((v, i) => (i === 1 ? v + 1 : v)) as DateArray;
+
+export const getDuration = (start: string, end: string): DurationObject => ({
+ minutes: dayjs(end).diff(dayjs(start), "minute"),
+});
+
+export const getAttendees = (attendees: Person[]): Attendee[] =>
+ attendees.map(({ email, name }) => ({ name, email, partstat: "NEEDS-ACTION" }));
diff --git a/lib/integrations/getIntegrations.ts b/lib/integrations/getIntegrations.ts
index 0f5c6290..9ebedd67 100644
--- a/lib/integrations/getIntegrations.ts
+++ b/lib/integrations/getIntegrations.ts
@@ -1,7 +1,11 @@
import { Prisma } from "@prisma/client";
import _ from "lodash";
-import { validJson } from "@lib/jsonUtils";
+/**
+ * We can't use aliases in playwright tests (yet)
+ * https://github.com/microsoft/playwright/issues/7121
+ */
+import { validJson } from "../../lib/jsonUtils";
const credentialData = Prisma.validator()({
select: { id: true, type: true },
@@ -9,6 +13,22 @@ const credentialData = Prisma.validator()({
type CredentialData = Prisma.CredentialGetPayload;
+export type Integration = {
+ installed: boolean;
+ type:
+ | "google_calendar"
+ | "office365_calendar"
+ | "zoom_video"
+ | "daily_video"
+ | "caldav_calendar"
+ | "apple_calendar"
+ | "stripe_payment";
+ title: string;
+ imageSrc: string;
+ description: string;
+ variant: "calendar" | "conferencing" | "payment";
+};
+
export const ALL_INTEGRATIONS = [
{
installed: !!(process.env.GOOGLE_API_CREDENTIALS && validJson(process.env.GOOGLE_API_CREDENTIALS)),
@@ -70,7 +90,7 @@ export const ALL_INTEGRATIONS = [
description: "Collect payments",
variant: "payment",
},
-] as const;
+] as Integration[];
function getIntegrations(userCredentials: CredentialData[]) {
const integrations = ALL_INTEGRATIONS.map((integration) => {
@@ -99,5 +119,8 @@ export function hasIntegration(integrations: IntegrationMeta, type: string): boo
(i) => i.type === type && !!i.installed && (type === "daily_video" || i.credentials.length > 0)
);
}
+export function hasIntegrationInstalled(type: Integration["type"]): boolean {
+ return ALL_INTEGRATIONS.some((i) => i.type === type && !!i.installed);
+}
export default getIntegrations;
diff --git a/lib/jackson.ts b/lib/jackson.ts
new file mode 100644
index 00000000..0a85d4ef
--- /dev/null
+++ b/lib/jackson.ts
@@ -0,0 +1,41 @@
+import jackson, { IAPIController, IOAuthController, JacksonOption } from "@boxyhq/saml-jackson";
+
+import { BASE_URL } from "@lib/config/constants";
+import { samlDatabaseUrl } from "@lib/saml";
+
+// Set the required options. Refer to https://github.com/boxyhq/jackson#configuration for the full list
+const opts: JacksonOption = {
+ externalUrl: BASE_URL,
+ samlPath: "/api/auth/saml/callback",
+ db: {
+ engine: "sql",
+ type: "postgres",
+ url: samlDatabaseUrl,
+ encryptionKey: process.env.CALENDSO_ENCRYPTION_KEY,
+ },
+ samlAudience: "https://saml.cal.com",
+};
+
+let apiController: IAPIController;
+let oauthController: IOAuthController;
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+const g = global as any;
+
+export default async function init() {
+ if (!g.apiController || !g.oauthController) {
+ const ret = await jackson(opts);
+ apiController = ret.apiController;
+ oauthController = ret.oauthController;
+ g.apiController = apiController;
+ g.oauthController = oauthController;
+ } else {
+ apiController = g.apiController;
+ oauthController = g.oauthController;
+ }
+
+ return {
+ apiController,
+ oauthController,
+ };
+}
diff --git a/lib/logger.ts b/lib/logger.ts
index a4ce3e7c..ff1114ec 100644
--- a/lib/logger.ts
+++ b/lib/logger.ts
@@ -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;
diff --git a/lib/mutations/event-types/create-event-type.ts b/lib/mutations/event-types/create-event-type.ts
index 9849da3e..fb3deaec 100644
--- a/lib/mutations/event-types/create-event-type.ts
+++ b/lib/mutations/event-types/create-event-type.ts
@@ -1,6 +1,9 @@
import * as fetch from "@lib/core/http/fetch-wrapper";
import { CreateEventType, CreateEventTypeResponse } from "@lib/types/event-type";
+/**
+ * @deprecated Use `trpc.useMutation("viewer.eventTypes.create")` instead.
+ */
const createEventType = async (data: CreateEventType) => {
const response = await fetch.post(
"/api/availability/eventtype",
diff --git a/lib/mutations/event-types/delete-event-type.ts b/lib/mutations/event-types/delete-event-type.ts
index 66256992..82f77c45 100644
--- a/lib/mutations/event-types/delete-event-type.ts
+++ b/lib/mutations/event-types/delete-event-type.ts
@@ -1,5 +1,8 @@
import * as fetch from "@lib/core/http/fetch-wrapper";
+/**
+ * @deprecated Use `trpc.useMutation("viewer.eventTypes.delete")` instead.
+ */
const deleteEventType = async (data: { id: number }) => {
const response = await fetch.remove<{ id: number }, Record>(
"/api/availability/eventtype",
diff --git a/lib/mutations/event-types/update-event-type.ts b/lib/mutations/event-types/update-event-type.ts
index 0f06280a..02deaf5c 100644
--- a/lib/mutations/event-types/update-event-type.ts
+++ b/lib/mutations/event-types/update-event-type.ts
@@ -7,6 +7,9 @@ type EventTypeResponse = {
eventType: EventType;
};
+/**
+ * @deprecated Use `trpc.useMutation("viewer.eventTypes.update")` instead.
+ */
const updateEventType = async (data: EventTypeInput) => {
const response = await fetch.patch("/api/availability/eventtype", data);
return response;
diff --git a/lib/notEmpty.ts b/lib/notEmpty.ts
new file mode 100644
index 00000000..ed8480b5
--- /dev/null
+++ b/lib/notEmpty.ts
@@ -0,0 +1,3 @@
+const notEmpty = (value: T): value is NonNullable => !!value;
+
+export default notEmpty;
diff --git a/lib/prisma.ts b/lib/prisma.ts
index 1cb75893..0a6cf581 100644
--- a/lib/prisma.ts
+++ b/lib/prisma.ts
@@ -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;
}
diff --git a/lib/queries/availability/index.ts b/lib/queries/availability/index.ts
new file mode 100644
index 00000000..3833c05d
--- /dev/null
+++ b/lib/queries/availability/index.ts
@@ -0,0 +1,88 @@
+// import { getBusyVideoTimes } from "@lib/videoClient";
+import { Prisma } from "@prisma/client";
+import dayjs from "dayjs";
+
+import { asStringOrNull } from "@lib/asStringOrNull";
+import { getWorkingHours } from "@lib/availability";
+import { getBusyCalendarTimes } from "@lib/integrations/calendar/CalendarManager";
+import prisma from "@lib/prisma";
+
+export async function getUserAvailability(query: {
+ username: string;
+ dateFrom: string;
+ dateTo: string;
+ eventTypeId?: number;
+ timezone?: string;
+}) {
+ const username = asStringOrNull(query.username);
+ const dateFrom = dayjs(asStringOrNull(query.dateFrom));
+ const dateTo = dayjs(asStringOrNull(query.dateTo));
+
+ if (!username) throw new Error("Missing username");
+ if (!dateFrom.isValid() || !dateTo.isValid()) throw new Error("Invalid time range given.");
+
+ const rawUser = await prisma.user.findUnique({
+ where: {
+ username: username,
+ },
+ select: {
+ credentials: true,
+ timeZone: true,
+ bufferTime: true,
+ availability: true,
+ id: true,
+ startTime: true,
+ endTime: true,
+ selectedCalendars: true,
+ },
+ });
+
+ const getEventType = (id: number) =>
+ prisma.eventType.findUnique({
+ where: { id },
+ select: {
+ timeZone: true,
+ availability: {
+ select: {
+ startTime: true,
+ endTime: true,
+ days: true,
+ },
+ },
+ },
+ });
+
+ type EventType = Prisma.PromiseReturnType;
+ let eventType: EventType | null = null;
+ if (query.eventTypeId) eventType = await getEventType(query.eventTypeId);
+
+ if (!rawUser) throw new Error("No user found");
+
+ const { selectedCalendars, ...currentUser } = rawUser;
+
+ const busyTimes = await getBusyCalendarTimes(
+ currentUser.credentials,
+ dateFrom.format(),
+ dateTo.format(),
+ selectedCalendars
+ );
+
+ // busyTimes.push(...await getBusyVideoTimes(currentUser.credentials, dateFrom.format(), dateTo.format()));
+
+ const bufferedBusyTimes = busyTimes.map((a) => ({
+ start: dayjs(a.start).subtract(currentUser.bufferTime, "minute").toString(),
+ end: dayjs(a.end).add(currentUser.bufferTime, "minute").toString(),
+ }));
+
+ const timeZone = query.timezone || eventType?.timeZone || currentUser.timeZone;
+ const workingHours = getWorkingHours(
+ { timeZone },
+ eventType?.availability.length ? eventType.availability : currentUser.availability
+ );
+
+ return {
+ busy: bufferedBusyTimes,
+ timeZone,
+ workingHours,
+ };
+}
diff --git a/lib/queries/teams/index.ts b/lib/queries/teams/index.ts
new file mode 100644
index 00000000..005636b3
--- /dev/null
+++ b/lib/queries/teams/index.ts
@@ -0,0 +1,99 @@
+import { Prisma } from "@prisma/client";
+
+import prisma from "@lib/prisma";
+
+type AsyncReturnType Promise> = T extends (...args: any) => Promise
+ ? R
+ : any;
+
+export type TeamWithMembers = AsyncReturnType;
+
+export async function getTeamWithMembers(id?: number, slug?: string) {
+ const userSelect = Prisma.validator()({
+ username: true,
+ avatar: true,
+ email: true,
+ name: true,
+ id: true,
+ bio: true,
+ });
+
+ const teamSelect = Prisma.validator()({
+ id: true,
+ name: true,
+ slug: true,
+ logo: true,
+ bio: true,
+ hideBranding: true,
+ members: {
+ select: {
+ user: {
+ select: userSelect,
+ },
+ },
+ },
+ eventTypes: {
+ where: {
+ hidden: false,
+ },
+ select: {
+ id: true,
+ title: true,
+ description: true,
+ length: true,
+ slug: true,
+ schedulingType: true,
+ price: true,
+ currency: true,
+ users: {
+ select: userSelect,
+ },
+ },
+ },
+ });
+
+ const team = await prisma.team.findUnique({
+ where: id ? { id } : { slug },
+ select: teamSelect,
+ });
+
+ if (!team) return null;
+
+ const memberships = await prisma.membership.findMany({
+ where: {
+ teamId: team.id,
+ },
+ });
+
+ const members = team.members.map((obj) => {
+ const membership = memberships.find((membership) => obj.user.id === membership.userId);
+ return {
+ ...obj.user,
+ role: membership?.role,
+ accepted: membership?.role === "OWNER" ? true : membership?.accepted,
+ };
+ });
+
+ return { ...team, members };
+}
+// also returns team
+export async function isTeamAdmin(userId: number, teamId: number) {
+ return (
+ (await prisma.membership.findFirst({
+ where: {
+ userId,
+ teamId,
+ OR: [{ role: "ADMIN" }, { role: "OWNER" }],
+ },
+ })) || false
+ );
+}
+export async function isTeamOwner(userId: number, teamId: number) {
+ return !!(await prisma.membership.findFirst({
+ where: {
+ userId,
+ teamId,
+ role: "OWNER",
+ },
+ }));
+}
diff --git a/lib/random.ts b/lib/random.ts
new file mode 100644
index 00000000..b817524f
--- /dev/null
+++ b/lib/random.ts
@@ -0,0 +1,9 @@
+export const randomString = function (length = 12) {
+ let result = "";
+ const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
+ const charactersLength = characters.length;
+ for (let i = 0; i < length; i++) {
+ result += characters.charAt(Math.floor(Math.random() * charactersLength));
+ }
+ return result;
+};
diff --git a/lib/saml.ts b/lib/saml.ts
new file mode 100644
index 00000000..8dda87d1
--- /dev/null
+++ b/lib/saml.ts
@@ -0,0 +1,59 @@
+import { PrismaClient } from "@prisma/client";
+
+import { BASE_URL } from "@lib/config/constants";
+
+import { TRPCError } from "@trpc/server";
+
+export const samlDatabaseUrl = process.env.SAML_DATABASE_URL || "";
+export const samlLoginUrl = BASE_URL;
+
+export const isSAMLLoginEnabled = samlDatabaseUrl.length > 0;
+
+export const samlTenantID = "Cal.com";
+export const samlProductID = "Cal.com";
+
+const samlAdmins = (process.env.SAML_ADMINS || "").split(",");
+export const hostedCal = BASE_URL === "https://app.cal.com";
+export const tenantPrefix = "team-";
+
+export const isSAMLAdmin = (email: string) => {
+ for (const admin of samlAdmins) {
+ if (admin.toLowerCase() === email.toLowerCase() && admin.toUpperCase() === email.toUpperCase()) {
+ return true;
+ }
+ }
+
+ return false;
+};
+
+export const samlTenantProduct = async (prisma: PrismaClient, email: string) => {
+ const user = await prisma.user.findUnique({
+ where: {
+ email,
+ },
+ select: {
+ id: true,
+ invitedTo: true,
+ },
+ });
+
+ if (!user) {
+ throw new TRPCError({
+ code: "UNAUTHORIZED",
+ message: "Unauthorized Request",
+ });
+ }
+
+ if (!user.invitedTo) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message:
+ "Could not find a SAML Identity Provider for your email. Please contact your admin to ensure you have been given access to Cal",
+ });
+ }
+
+ return {
+ tenant: tenantPrefix + user.invitedTo,
+ product: samlProductID,
+ };
+};
diff --git a/lib/slots.ts b/lib/slots.ts
index 3d07d8cd..98bc7551 100644
--- a/lib/slots.ts
+++ b/lib/slots.ts
@@ -1,137 +1,70 @@
import dayjs, { Dayjs } from "dayjs";
-import timezone from "dayjs/plugin/timezone";
+import isBetween from "dayjs/plugin/isBetween";
+import isToday from "dayjs/plugin/isToday";
import utc from "dayjs/plugin/utc";
-dayjs.extend(utc);
-dayjs.extend(timezone);
+import { getWorkingHours } from "./availability";
+import { WorkingHours } from "./types/schedule";
-type WorkingHour = {
- days: number[];
- startTime: number;
- endTime: number;
-};
+dayjs.extend(isToday);
+dayjs.extend(utc);
+dayjs.extend(isBetween);
-type GetSlots = {
+export type GetSlots = {
inviteeDate: Dayjs;
frequency: number;
- workingHours: WorkingHour[];
- minimumBookingNotice?: number;
- organizerTimeZone: string;
+ workingHours: WorkingHours[];
+ minimumBookingNotice: number;
};
-type Boundary = {
- lowerBound: number;
- upperBound: number;
+const getMinuteOffset = (date: Dayjs, frequency: 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().utc(), "minute"), 1440) % 1440;
+ // round down to nearest step
+ return Math.ceil(minuteOffset / frequency) * frequency;
};
-const freqApply = (cb, value: number, frequency: number): number => cb(value / frequency) * frequency;
-
-const intersectBoundary = (a: Boundary, b: Boundary) => {
- if (a.upperBound < b.lowerBound || a.lowerBound > b.upperBound) {
- return;
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+const getSlots = ({ inviteeDate, frequency, minimumBookingNotice, workingHours }: GetSlots) => {
+ // current date in invitee tz
+ const startDate = dayjs().add(minimumBookingNotice, "minute");
+ // checks if the start date is in the past
+ if (inviteeDate.isBefore(startDate, "day")) {
+ return [];
}
- return {
- lowerBound: Math.max(b.lowerBound, a.lowerBound),
- upperBound: Math.min(b.upperBound, a.upperBound),
- };
-};
-// say invitee is -60,1380, and boundary is -120,240 - the overlap is -60,240
-const getOverlaps = (inviteeBoundary: Boundary, boundaries: Boundary[]) =>
- boundaries.map((boundary) => intersectBoundary(inviteeBoundary, boundary)).filter(Boolean);
-
-const organizerBoundaries = (
- workingHours: [],
- inviteeDate: Dayjs,
- inviteeBounds: Boundary,
- organizerTimeZone
-): Boundary[] => {
- const boundaries: Boundary[] = [];
-
- const startDay: number = +inviteeDate.startOf("d").add(inviteeBounds.lowerBound, "minutes").format("d");
- const endDay: number = +inviteeDate.startOf("d").add(inviteeBounds.upperBound, "minutes").format("d");
-
- workingHours.forEach((item) => {
- const lowerBound: number = item.startTime - dayjs().tz(organizerTimeZone).utcOffset();
- const upperBound: number = item.endTime - dayjs().tz(organizerTimeZone).utcOffset();
- if (startDay !== endDay) {
- if (inviteeBounds.lowerBound < 0) {
- // lowerBound edges into the previous day
- if (item.days.includes(startDay)) {
- boundaries.push({ lowerBound: lowerBound - 1440, upperBound: upperBound - 1440 });
- }
- if (item.days.includes(endDay)) {
- boundaries.push({ lowerBound, upperBound });
- }
- } else {
- // upperBound edges into the next day
- if (item.days.includes(endDay)) {
- boundaries.push({ lowerBound: lowerBound + 1440, upperBound: upperBound + 1440 });
- }
- if (item.days.includes(startDay)) {
- boundaries.push({ lowerBound, upperBound });
- }
- }
- } else {
- if (item.days.includes(startDay)) {
- boundaries.push({ lowerBound, upperBound });
- }
- }
- });
-
- return boundaries;
-};
+ const localWorkingHours = getWorkingHours(
+ { utcOffset: -inviteeDate.utcOffset() },
+ workingHours.map((schedule) => ({
+ days: schedule.days,
+ 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 inviteeBoundary = (startTime: number, utcOffset: number, frequency: number): Boundary => {
- const upperBound: number = freqApply(Math.floor, 1440 - utcOffset, frequency);
- const lowerBound: number = freqApply(Math.ceil, startTime - utcOffset, frequency);
- return {
- lowerBound,
- upperBound,
- };
-};
-
-const getSlotsBetweenBoundary = (frequency: number, { lowerBound, upperBound }: Boundary) => {
const slots: Dayjs[] = [];
- for (let minutes = 0; lowerBound + minutes <= upperBound - frequency; minutes += frequency) {
- slots.push(
- dayjs
- .utc()
- .startOf("d")
- .add(lowerBound + minutes, "minutes")
- );
+ for (let minutes = getMinuteOffset(inviteeDate, frequency); minutes < 1440; minutes += frequency) {
+ const slot = dayjs(inviteeDate).startOf("day").add(minutes, "minute");
+ // check if slot happened already
+ if (slot.isBefore(startDate)) {
+ continue;
+ }
+ // 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, "minute"),
+ inviteeDate.startOf("day").add(hours.endTime, "minute"),
+ null,
+ "[)"
+ )
+ )
+ ) {
+ slots.push(slot);
+ }
}
- return slots;
-};
-const getSlots = ({
- inviteeDate,
- frequency,
- minimumBookingNotice,
- workingHours,
- organizerTimeZone,
-}: GetSlots): Dayjs[] => {
- // current date in invitee tz
- const currentDate = dayjs().utcOffset(inviteeDate.utcOffset());
- const startDate = currentDate.add(minimumBookingNotice, "minutes"); // + minimum notice period
-
- const startTime = startDate.isAfter(inviteeDate)
- ? // block out everything when inviteeDate is less than startDate
- startDate.diff(inviteeDate, "day") > 0
- ? 1440
- : startDate.hour() * 60 + startDate.minute()
- : 0;
-
- const inviteeBounds = inviteeBoundary(startTime, inviteeDate.utcOffset(), frequency);
-
- return getOverlaps(
- inviteeBounds,
- organizerBoundaries(workingHours, inviteeDate, inviteeBounds, organizerTimeZone)
- )
- .reduce((slots, boundary: Boundary) => [...slots, ...getSlotsBetweenBoundary(frequency, boundary)], [])
- .map((slot) =>
- slot.utcOffset(inviteeDate.utcOffset()).month(inviteeDate.month()).date(inviteeDate.date())
- );
+ return slots;
};
export default getSlots;
diff --git a/lib/team.ts b/lib/team.ts
deleted file mode 100644
index e6e290e0..00000000
--- a/lib/team.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-export interface Team {
- id: number;
- name: string | null;
- slug: string | null;
- logo: string | null;
- bio: string | null;
- role: string | null;
- hideBranding: boolean;
- prevState: null;
-}
diff --git a/lib/telemetry.ts b/lib/telemetry.ts
index fe8ac8ca..9f2cd643 100644
--- a/lib/telemetry.ts
+++ b/lib/telemetry.ts
@@ -7,10 +7,9 @@ import React, { useContext } from "react";
*/
export const telemetryEventTypes = {
pageView: "page_view",
- dateSelected: "date_selected",
- timeSelected: "time_selected",
bookingConfirmed: "booking_confirmed",
bookingCancelled: "booking_cancelled",
+ importSubmitted: "import_submitted",
};
/**
@@ -72,7 +71,15 @@ function createTelemetryClient(): TelemetryClient {
if (!window) {
console.warn("Jitsu has been called during SSR, this scenario isn't supported yet");
return;
- } else if (!window["jitsu"]) {
+ } else if (
+ // FIXME
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ !window["jitsu"]
+ ) {
+ // FIXME
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
window["jitsu"] = jitsuClient({
log_level: "ERROR",
tracking_host: "https://t.calendso.com",
@@ -81,6 +88,9 @@ function createTelemetryClient(): TelemetryClient {
capture_3rd_party_cookies: false,
});
}
+ // FIXME
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
const res = callback(window["jitsu"]);
if (res && typeof res["catch"] === "function") {
res.catch((e) => {
diff --git a/lib/types/booking.ts b/lib/types/booking.ts
index 8be00d4f..eac385b3 100644
--- a/lib/types/booking.ts
+++ b/lib/types/booking.ts
@@ -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,22 @@ 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;
+ user?: string | string[];
language: string;
+ customInputs: { label: string; value: string }[];
+ metadata: {
+ [key: string]: string;
+ };
};
export type BookingResponse = Booking & {
paymentUid?: string;
+ attendees: Attendee[];
};
diff --git a/lib/types/event-type.ts b/lib/types/event-type.ts
index 337d83b6..38b2148c 100644
--- a/lib/types/event-type.ts
+++ b/lib/types/event-type.ts
@@ -1,16 +1,6 @@
-import { SchedulingType, EventType } from "@prisma/client";
+import { EventType, SchedulingType } from "@prisma/client";
-export type OpeningHours = {
- days: number[];
- startTime: number;
- endTime: number;
-};
-
-export type DateOverride = {
- date: string;
- startTime: number;
- endTime: number;
-};
+import { WorkingHours } from "./schedule";
export type AdvancedOptions = {
eventName?: string;
@@ -22,18 +12,20 @@ export type AdvancedOptions = {
requiresConfirmation?: boolean;
disableGuests?: boolean;
minimumBookingNotice?: number;
+ slotInterval?: number | null;
price?: number;
currency?: string;
schedulingType?: SchedulingType;
- users?: {
- value: number;
- label: string;
- avatar: string;
- }[];
- availability?: { openingHours: OpeningHours[]; dateOverrides: DateOverride[] };
+ users?: string[];
+ availability?: { openingHours: WorkingHours[]; dateOverrides: WorkingHours[] };
customInputs?: EventTypeCustomInput[];
- timeZone: string;
- hidden: boolean;
+ timeZone?: string;
+ destinationCalendar?: {
+ userId?: number;
+ eventTypeId?: number;
+ integration: string;
+ externalId: string;
+ };
};
export type EventTypeCustomInput = {
@@ -63,9 +55,8 @@ export type EventTypeInput = AdvancedOptions & {
slug: string;
description: string;
length: number;
+ teamId?: number;
hidden: boolean;
locations: unknown;
- customInputs: EventTypeCustomInput[];
- timeZone: string;
- availability?: { openingHours: OpeningHours[]; dateOverrides: DateOverride[] };
+ availability?: { openingHours: WorkingHours[]; dateOverrides: WorkingHours[] };
};
diff --git a/lib/types/schedule.ts b/lib/types/schedule.ts
new file mode 100644
index 00000000..ba5e74b4
--- /dev/null
+++ b/lib/types/schedule.ts
@@ -0,0 +1,18 @@
+export type TimeRange = {
+ start: Date;
+ end: Date;
+};
+
+export type Schedule = TimeRange[][];
+
+/**
+ * ```text
+ * Ensure startTime and endTime in minutes since midnight; serialized to UTC by using the organizer timeZone, either by using the schedule timeZone or the user timeZone.
+ * @see lib/availability.ts getWorkingHours(timeZone: string, availability: Availability[])
+ * ```
+ */
+export type WorkingHours = {
+ days: number[];
+ startTime: number;
+ endTime: number;
+};
diff --git a/lib/types/utils.ts b/lib/types/utils.ts
index 3b8a0544..a55cb60d 100644
--- a/lib/types/utils.ts
+++ b/lib/types/utils.ts
@@ -1,3 +1,7 @@
+/** Makes selected props from a record non optional */
export type Ensure = Omit & {
[EK in K]-?: NonNullable;
};
+
+/** Makes selected props from a record optional */
+export type Optional = Pick, K> & Omit;
diff --git a/lib/videoClient.ts b/lib/videoClient.ts
index ac9dc8eb..e8869188 100644
--- a/lib/videoClient.ts
+++ b/lib/videoClient.ts
@@ -2,20 +2,14 @@ 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 DailyVideoApiAdapter from "./integrations/Daily/DailyVideoApiAdapter";
import ZoomVideoApiAdapter from "./integrations/Zoom/ZoomVideoApiAdapter";
-import { Ensure } from "./types/utils";
+import { CalendarEvent } from "./integrations/calendar/interfaces/Calendar";
const log = logger.getChildLogger({ prefix: ["[lib] videoClient"] });
@@ -31,9 +25,9 @@ export interface VideoCallData {
type EventBusyDate = Record<"start" | "end", Date>;
export interface VideoApiAdapter {
- createMeeting(event: CalendarEvent): Promise;
+ createMeeting(event: CalendarEvent): Promise;
- updateMeeting(uid: string, event: CalendarEvent): Promise;
+ updateMeeting(bookingRef: PartialReference, event: CalendarEvent): Promise;
deleteMeeting(uid: string): Promise;
@@ -56,17 +50,13 @@ const getVideoAdapters = (withCredentials: Credential[]): VideoApiAdapter[] =>
return acc;
}, []);
-const getBusyVideoTimes: (withCredentials: Credential[]) => Promise = (withCredentials) =>
+const getBusyVideoTimes = (withCredentials: Credential[]) =>
Promise.all(getVideoAdapters(withCredentials).map((c) => c.getAvailability())).then((results) =>
results.reduce((acc, availability) => acc.concat(availability), [])
);
-const createMeeting = async (
- credential: Credential,
- calEvent: Ensure
-): Promise => {
- const parser: CalEventParser = new CalEventParser(calEvent);
- const uid: string = parser.getUid();
+const createMeeting = async (credential: Credential, calEvent: CalendarEvent): Promise => {
+ const uid: string = getUid(calEvent);
if (!credential) {
throw new Error(
@@ -74,128 +64,62 @@ const createMeeting = async (
);
}
- let success = true;
-
const videoAdapters = getVideoAdapters([credential]);
const [firstVideoAdapter] = videoAdapters;
const createdMeeting = await firstVideoAdapter.createMeeting(calEvent).catch((e) => {
log.error("createMeeting failed", e, calEvent);
- success = false;
});
if (!createdMeeting) {
return {
type: credential.type,
- success,
+ success: false,
uid,
originalEvent: calEvent,
};
}
- 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],
- };
-
- const emailEvent = { ...calEvent, uid, additionInformation, videoCallData };
-
- try {
- const organizerMail = new VideoEventOrganizerMail(emailEvent);
- await organizerMail.sendEmail();
- } catch (e) {
- console.error("organizerMail.sendEmail failed", e);
- }
-
- if (!createdMeeting || !createdMeeting.disableConfirmationEmail) {
- try {
- const attendeeMail = new VideoEventAttendeeMail(emailEvent);
- await attendeeMail.sendEmail();
- } catch (e) {
- console.error("attendeeMail.sendEmail failed", e);
- }
- }
-
return {
type: credential.type,
- success,
+ success: true,
uid,
createdEvent: createdMeeting,
originalEvent: calEvent,
- videoCallData: videoCallData,
};
};
-const updateMeeting = async (credential: Credential, calEvent: CalendarEvent): Promise => {
- const newUid: 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."
- );
- }
-
- if (!calEvent.uid) {
- throw new Error("You can't update an meeting without it's UID.");
- }
+const updateMeeting = async (
+ credential: Credential,
+ calEvent: CalendarEvent,
+ bookingRef: PartialReference | null
+): Promise => {
+ const uid = translator.fromUUID(uuidv5(JSON.stringify(calEvent), uuidv5.URL));
let success = true;
const [firstVideoAdapter] = getVideoAdapters([credential]);
- const updatedMeeting = await firstVideoAdapter.updateMeeting(calEvent.uid, calEvent).catch((e) => {
- log.error("updateMeeting failed", e, calEvent);
- success = false;
- });
+ const updatedMeeting =
+ credential && bookingRef
+ ? await firstVideoAdapter.updateMeeting(bookingRef, calEvent).catch((e) => {
+ log.error("updateMeeting failed", e, calEvent);
+ success = false;
+ return undefined;
+ })
+ : undefined;
if (!updatedMeeting) {
return {
type: credential.type,
success,
- uid: calEvent.uid,
+ uid,
originalEvent: calEvent,
};
}
- const emailEvent = { ...calEvent, uid: newUid };
-
- try {
- const organizerMail = new EventOrganizerRescheduledMail(emailEvent);
- await organizerMail.sendEmail();
- } catch (e) {
- console.error("organizerMail.sendEmail failed", e);
- }
-
- if (!updatedMeeting.disableConfirmationEmail) {
- try {
- const attendeeMail = new EventAttendeeRescheduledMail(emailEvent);
- await attendeeMail.sendEmail();
- } catch (e) {
- console.error("attendeeMail.sendEmail failed", e);
- }
- }
-
return {
type: credential.type,
success,
- uid: newUid,
+ uid,
updatedEvent: updatedMeeting,
originalEvent: calEvent,
};
diff --git a/lib/webhooks/sendPayload.tsx b/lib/webhooks/sendPayload.tsx
index fc95dffd..4eb97437 100644
--- a/lib/webhooks/sendPayload.tsx
+++ b/lib/webhooks/sendPayload.tsx
@@ -1,38 +1,65 @@
-import { CalendarEvent } from "@lib/calendarClient";
+import { compile } from "handlebars";
-const sendPayload = (
+import { CalendarEvent } from "@lib/integrations/calendar/interfaces/Calendar";
+
+type ContentType = "application/json" | "application/x-www-form-urlencoded";
+
+function applyTemplate(template: string, data: CalendarEvent, contentType: ContentType) {
+ const compiled = compile(template)(data);
+ if (contentType === "application/json") {
+ return jsonParse(compiled);
+ }
+ return compiled;
+}
+
+function jsonParse(jsonString: string) {
+ try {
+ return JSON.parse(jsonString);
+ } catch (e) {
+ // don't do anything.
+ }
+ return false;
+}
+
+const sendPayload = async (
triggerEvent: string,
createdAt: string,
subscriberUrl: string,
- payload: CalendarEvent
-): Promise =>
- new Promise((resolve, reject) => {
- if (!subscriberUrl || !payload) {
- return reject(new Error("Missing required elements to send webhook payload."));
- }
- const body = {
- triggerEvent: triggerEvent,
- createdAt: createdAt,
- payload: payload,
- };
-
- fetch(subscriberUrl, {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- },
- body: JSON.stringify(body),
- })
- .then((response) => {
- if (!response.ok) {
- reject(new Error(`Response code ${response.status}`));
- return;
- }
- resolve(response);
- })
- .catch((err) => {
- reject(err);
+ data: CalendarEvent & {
+ metadata?: { [key: string]: string };
+ },
+ template?: string | null
+) => {
+ if (!subscriberUrl || !data) {
+ throw new Error("Missing required elements to send webhook payload.");
+ }
+
+ const contentType =
+ !template || jsonParse(template) ? "application/json" : "application/x-www-form-urlencoded";
+
+ const body = template
+ ? applyTemplate(template, data, contentType)
+ : JSON.stringify({
+ triggerEvent: triggerEvent,
+ createdAt: createdAt,
+ payload: data,
});
+
+ const response = await fetch(subscriberUrl, {
+ method: "POST",
+ headers: {
+ "Content-Type": contentType,
+ },
+ body,
});
+ const text = await response.text();
+
+ return {
+ ok: response.ok,
+ status: response.status,
+ message: text,
+ };
+};
+
export default sendPayload;
diff --git a/lib/webhooks/subscriberUrls.tsx b/lib/webhooks/subscriptions.tsx
similarity index 60%
rename from lib/webhooks/subscriberUrls.tsx
rename to lib/webhooks/subscriptions.tsx
index aeab311c..7336fb3c 100644
--- a/lib/webhooks/subscriberUrls.tsx
+++ b/lib/webhooks/subscriptions.tsx
@@ -2,7 +2,7 @@ import { WebhookTriggerEvents } from "@prisma/client";
import prisma from "@lib/prisma";
-const getSubscriberUrls = async (userId: number, triggerEvent: WebhookTriggerEvents): Promise => {
+const getSubscribers = async (userId: number, triggerEvent: WebhookTriggerEvents) => {
const allWebhooks = await prisma.webhook.findMany({
where: {
userId: userId,
@@ -17,11 +17,11 @@ const getSubscriberUrls = async (userId: number, triggerEvent: WebhookTriggerEve
},
select: {
subscriberUrl: true,
+ payloadTemplate: true,
},
});
- const subscriberUrls = allWebhooks.map(({ subscriberUrl }) => subscriberUrl);
- return subscriberUrls;
+ return allWebhooks;
};
-export default getSubscriberUrls;
+export default getSubscribers;
diff --git a/next-env.d.ts b/next-env.d.ts
index 9bc3dd46..4f11a03d 100644
--- a/next-env.d.ts
+++ b/next-env.d.ts
@@ -1,5 +1,4 @@
///
-///
///
// NOTE: This file should not be edited
diff --git a/next-i18next.config.js b/next-i18next.config.js
index 723a6744..db9f37cd 100644
--- a/next-i18next.config.js
+++ b/next-i18next.config.js
@@ -4,7 +4,26 @@ const path = require("path");
module.exports = {
i18n: {
defaultLocale: "en",
- locales: ["en", "fr", "it", "ru", "es", "de", "pt", "ro", "nl", "pt-BR", "es-419", "ko"],
+ locales: [
+ "en",
+ "fr",
+ "it",
+ "ru",
+ "es",
+ "de",
+ "pt",
+ "ro",
+ "nl",
+ "pt-BR",
+ "es-419",
+ "ko",
+ "ja",
+ "pl",
+ "ar",
+ "iw",
+ "zh-CN",
+ ],
},
localePath: path.resolve("./public/static/locales"),
+ reloadOnPrerender: process.env.NODE_ENV !== "production",
};
diff --git a/next.config.js b/next.config.js
index 8bcd99b1..762e55bb 100644
--- a/next.config.js
+++ b/next.config.js
@@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/no-var-requires */
-const withTM = require("next-transpile-modules")(["react-timezone-select"]);
+const withTM = require("@vercel/edge-functions-ui/transpile")(["react-timezone-select"]);
const { i18n } = require("./next-i18next.config");
// So we can test deploy previews preview
@@ -72,6 +72,14 @@ module.exports = () => plugins.reduce((acc, next) => next(acc), {
return config;
},
+ async rewrites() {
+ return [
+ {
+ source: "/:user/avatar.png",
+ destination: "/api/user/avatar?username=:user",
+ },
+ ]
+ },
async redirects() {
return [
{
diff --git a/package.json b/package.json
index a91f59ff..d244da1f 100644
--- a/package.json
+++ b/package.json
@@ -8,51 +8,57 @@
"analyze:browser": "BUNDLE_ANALYZE=browser next build",
"dev": "next dev",
"db-up": "docker-compose up -d",
- "db-migrate": "yarn prisma migrate dev",
- "db-seed": "yarn ts-node scripts/seed.ts",
+ "db-migrate": "yarn prisma migrate dev && yarn format-schemas",
+ "db-deploy": "yarn prisma migrate deploy",
+ "db-seed": "yarn prisma db seed",
"db-nuke": "docker-compose down --volumes --remove-orphans",
- "dx": "cross-env BASE_URL=http://localhost:3000 JWT_SECRET=secret DATABASE_URL=postgresql://postgres:@localhost:5450/calendso run-s db-up db-migrate db-seed dev",
+ "db-setup": "run-s db-up db-migrate db-seed",
+ "db-reset": "run-s db-nuke db-setup",
+ "deploy": "run-s build db-deploy",
+ "dx": "env-cmd run-s db-setup dev",
"test": "jest",
- "test-playwright": "jest --config jest.playwright.config.js",
- "test-playwright-lcov": "cross-env PLAYWRIGHT_HEADLESS=1 PLAYWRIGHT_COVERAGE=1 yarn test-playwright && nyc report --reporter=lcov",
+ "test-playwright": "playwright test",
"test-codegen": "yarn playwright codegen http://localhost:3000",
"type-check": "tsc --pretty --noEmit",
"build": "next build",
"start": "next start",
- "ts-node": "ts-node --compiler-options \"{\\\"module\\\":\\\"commonjs\\\"}\"",
- "postinstall": "prisma generate",
+ "format-schemas": "prettier --write ./prisma",
+ "generate-schemas": "prisma generate && yarn format-schemas",
+ "postinstall": "yarn generate-schemas",
"pre-commit": "lint-staged",
"lint": "eslint . --ext .ts,.js,.tsx,.jsx",
"prepare": "husky install",
- "check-changed-files": "yarn ts-node scripts/ts-check-changed-files.ts"
+ "check-changed-files": "ts-node scripts/ts-check-changed-files.ts"
},
"engines": {
- "node": "14.x",
+ "node": "^16.13.0",
"yarn": ">=1.19.0 < 2.0.0"
},
"dependencies": {
+ "@boxyhq/saml-jackson": "0.3.3",
"@daily-co/daily-js": "^0.21.0",
- "@headlessui/react": "^1.4.1",
- "@heroicons/react": "^1.0.4",
- "@hookform/resolvers": "^2.8.2",
+ "@headlessui/react": "^1.4.2",
+ "@heroicons/react": "^1.0.5",
+ "@hookform/resolvers": "^2.8.3",
"@jitsu/sdk-js": "^2.2.4",
"@next/bundle-analyzer": "11.1.2",
- "@prisma/client": "^2.30.2",
+ "@prisma/client": "3.0.2",
"@radix-ui/react-avatar": "^0.1.0",
- "@radix-ui/react-collapsible": "^0.1.1",
+ "@radix-ui/react-collapsible": "^0.1.0",
"@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.0",
+ "@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.10.1",
- "@trpc/next": "^9.10.1",
- "@trpc/react": "^9.10.1",
- "@trpc/server": "^9.10.1",
+ "@trpc/client": "^9.16.0",
+ "@trpc/next": "^9.16.0",
+ "@trpc/react": "^9.16.0",
+ "@trpc/server": "^9.16.0",
+ "@vercel/edge-functions-ui": "^0.2.1",
"@wojtekmaj/react-daterange-picker": "^3.3.1",
"accept-language-parser": "^1.5.0",
"async": "^3.2.1",
@@ -67,81 +73,84 @@
"jimp": "^0.16.1",
"lodash": "^4.17.21",
"micro": "^9.3.4",
- "next": "^11.1.1",
- "next-auth": "^3.28.0",
- "next-i18next": "^8.8.0",
+ "next": "^12.0.9",
+ "next-auth": "^4.0.6",
+ "next-i18next": "^8.9.0",
"next-seo": "^4.26.0",
- "next-transpile-modules": "^8.0.0",
- "nodemailer": "^6.6.3",
+ "next-transpile-modules": "^9.0.0",
+ "nodemailer": "^6.7.2",
"otplib": "^12.0.1",
- "qrcode": "^1.4.4",
+ "qrcode": "^1.5.0",
"react": "^17.0.2",
+ "react-date-picker": "^8.3.6",
"react-dom": "^17.0.2",
"react-easy-crop": "^3.5.2",
- "react-hook-form": "^7.17.5",
+ "react-hook-form": "^7.20.4",
"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.38",
- "react-query": "^3.32.1",
+ "react-phone-number-input": "^3.1.41",
+ "react-query": "^3.33.7",
"react-router-dom": "^5.2.0",
- "react-select": "^4.3.1",
- "react-timezone-select": "^1.1.12",
+ "react-select": "^5.2.1",
+ "react-timezone-select": "^1.1.15",
"react-use-intercom": "1.4.0",
+ "react-virtualized-auto-sizer": "^1.0.6",
+ "react-window": "^1.8.6",
"short-uuid": "^4.2.0",
- "stripe": "^8.168.0",
- "superjson": "1.7.5",
- "tsdav": "1.0.6",
+ "stripe": "^8.191.0",
+ "superjson": "1.8.0",
+ "tsdav": "2.0.0-rc.3",
"tslog": "^3.2.1",
"uuid": "^8.3.2",
"zod": "^3.8.2"
},
"devDependencies": {
"@microsoft/microsoft-graph-types-beta": "0.15.0-preview",
+ "@playwright/test": "^1.17.1",
+ "@tailwindcss/forms": "^0.4.0",
"@trivago/prettier-plugin-sort-imports": "2.0.4",
"@types/accept-language-parser": "1.5.2",
- "@types/async": "^3.2.7",
+ "@types/async": "^3.2.10",
"@types/bcryptjs": "^2.4.2",
- "@types/jest": "^27.0.1",
- "@types/lodash": "^4.14.175",
+ "@types/jest": "^27.0.3",
+ "@types/lodash": "^4.14.177",
"@types/micro": "^7.3.6",
- "@types/node": "^16.11.5",
+ "@types/module-alias": "^2.0.1",
+ "@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/react-select": "^4.0.17",
+ "@types/react-virtualized-auto-sizer": "^1.0.1",
+ "@types/react-window": "^1.8.5",
"@types/stripe": "^8.0.417",
"@types/uuid": "8.3.1",
"@typescript-eslint/eslint-plugin": "^4.33.0",
- "@typescript-eslint/parser": "^4.29.2",
- "autoprefixer": "^10.3.1",
- "babel": "^6.23.0",
- "babel-jest": "^27.2.4",
- "babel-plugin-istanbul": "^6.1.1",
- "cross-env": "^7.0.3",
+ "@typescript-eslint/parser": "^4.33.0",
+ "autoprefixer": "^10.4.0",
+ "babel-jest": "^27.3.1",
+ "env-cmd": "10.1.0",
"eslint": "^7.32.0",
"eslint-config-prettier": "^8.3.0",
+ "eslint-plugin-playwright": "^0.7.1",
"eslint-plugin-prettier": "^3.4.0",
- "eslint-plugin-react": "^7.25.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",
- "jest-playwright-preset": "^1.7.0",
- "kont": "^0.5.1",
+ "jest": "^26.0.0",
"lint-staged": "^11.1.2",
"mockdate": "^3.0.5",
+ "module-alias": "^2.2.2",
"npm-run-all": "^4.1.5",
- "nyc": "^15.1.0",
- "playwright": "^1.16.2",
- "postcss": "^8.3.11",
+ "postcss": "^8.4.4",
"prettier": "^2.3.2",
- "prisma": "^2.30.2",
- "tailwindcss": "^2.2.16",
- "ts-jest": "^27.0.7",
+ "prisma": "3.0.2",
+ "tailwindcss": "^3.0.0",
+ "ts-jest": "^26.0.0",
"ts-node": "^10.2.1",
- "typescript": "^4.4.4"
+ "typescript": "^4.5.2",
+ "zod-prisma": "^0.5.4"
},
"lint-staged": {
"./{*,{ee,pages,components,lib}/**/*}.{js,ts,jsx,tsx}": [
@@ -151,5 +160,8 @@
"./prisma/schema.prisma": [
"prisma format"
]
+ },
+ "prisma": {
+ "seed": "ts-node ./prisma/seed.ts"
}
}
diff --git a/pages/404.tsx b/pages/404.tsx
index 7bcf2938..4492030f 100644
--- a/pages/404.tsx
+++ b/pages/404.tsx
@@ -1,5 +1,6 @@
import { BookOpenIcon, CheckIcon, DesktopComputerIcon, DocumentTextIcon } from "@heroicons/react/outline";
import { ChevronRightIcon } from "@heroicons/react/solid";
+import { GetStaticPropsContext } from "next";
import Link from "next/link";
import { useRouter } from "next/router";
import React from "react";
@@ -8,6 +9,8 @@ import { useLocale } from "@lib/hooks/useLocale";
import { HeadSeo } from "@components/seo/head-seo";
+import { ssgInit } from "@server/lib/ssg";
+
export default function Custom404() {
const { t } = useLocale();
const router = useRouter();
@@ -33,7 +36,7 @@ export default function Custom404() {
},
];
- const isEventType404 = router.asPath.includes("/event-types");
+ const isSubpage = router.asPath.includes("/", 2);
return (
<>
@@ -100,3 +103,13 @@ export default function Custom404() {
>
);
}
+
+export const getStaticProps = async (context: GetStaticPropsContext) => {
+ const ssr = await ssgInit(context);
+
+ return {
+ props: {
+ trpcState: ssr.dehydrate(),
+ },
+ };
+};
diff --git a/pages/[user].tsx b/pages/[user].tsx
index 342e22f6..0e411b16 100644
--- a/pages/[user].tsx
+++ b/pages/[user].tsx
@@ -1,6 +1,8 @@
import { ArrowRightIcon } from "@heroicons/react/outline";
+import { MoonIcon } from "@heroicons/react/solid";
import { GetServerSidePropsContext } from "next";
import Link from "next/link";
+import { useRouter } from "next/router";
import React from "react";
import { useLocale } from "@lib/hooks/useLocale";
@@ -18,6 +20,9 @@ export default function User(props: inferSSRProps) {
const { isReady } = useTheme(props.user.theme);
const { user, eventTypes } = props;
const { t } = useLocale();
+ const router = useRouter();
+ const query = { ...router.query };
+ delete query.user; // So it doesn't display in the Link (and make tests fail)
const nameOrUsername = user.name || user.username || "";
@@ -25,43 +30,56 @@ export default function User(props: inferSSRProps) {
<>
{isReady && (
-
-
+
+
-
+
{nameOrUsername}
{user.bio}
- {eventTypes.map((type) => (
-
-
-
-
- {type.title}
-
-
-
+ {user.away && (
+
+
+
{t("user_away")}
+
{t("user_away_description")}
- ))}
+ )}
+ {!user.away &&
+ eventTypes.map((type) => (
+
+ ))}
{eventTypes.length === 0 && (
-
+
-
+
{t("uh_oh")}
{t("no_event_types_have_been_setup")}
@@ -93,6 +111,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
avatar: true,
theme: true,
plan: true,
+ away: true,
},
});
@@ -124,6 +143,14 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
},
],
},
+ orderBy: [
+ {
+ position: "desc",
+ },
+ {
+ id: "asc",
+ },
+ ],
select: {
id: true,
slug: true,
diff --git a/pages/[user]/[type].tsx b/pages/[user]/[type].tsx
index 2b03a970..6702d1e2 100644
--- a/pages/[user]/[type].tsx
+++ b/pages/[user]/[type].tsx
@@ -2,6 +2,7 @@ import { Prisma } from "@prisma/client";
import { GetServerSidePropsContext } from "next";
import { asStringOrNull } from "@lib/asStringOrNull";
+import { getWorkingHours } from "@lib/availability";
import prisma from "@lib/prisma";
import { inferSSRProps } from "@lib/types/inferSSRProps";
@@ -42,6 +43,8 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
periodCountCalendarDays: true,
schedulingType: true,
minimumBookingNotice: true,
+ timeZone: true,
+ slotInterval: true,
users: {
select: {
avatar: true,
@@ -49,6 +52,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
username: true,
hideBranding: true,
plan: true,
+ timeZone: true,
},
},
});
@@ -70,6 +74,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
weekStart: true,
availability: true,
hideBranding: true,
+ brandColor: true,
theme: true,
plan: true,
eventTypes: {
@@ -119,6 +124,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
username: user.username,
hideBranding: user.hideBranding,
plan: user.plan,
+ timeZone: user.timeZone,
});
user.eventTypes.push(eventTypeBackwardsCompat);
}
@@ -155,27 +161,21 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
} as const;
}
}*/
- const getWorkingHours = (availability: typeof user.availability | typeof eventType.availability) =>
- availability && availability.length ? availability : null;
-
- const workingHours =
- getWorkingHours(eventType.availability) ||
- getWorkingHours(user.availability) ||
- [
- {
- days: [0, 1, 2, 3, 4, 5, 6],
- startTime: user.startTime,
- endTime: user.endTime,
- },
- ].filter((availability): boolean => typeof availability["days"] !== "undefined");
-
- workingHours.sort((a, b) => a.startTime - b.startTime);
const eventTypeObject = Object.assign({}, eventType, {
periodStartDate: eventType.periodStartDate?.toString() ?? null,
periodEndDate: eventType.periodEndDate?.toString() ?? null,
});
+ const workingHours = getWorkingHours(
+ {
+ timeZone: eventType.timeZone || user.timeZone,
+ },
+ eventType.availability.length ? eventType.availability : user.availability
+ );
+
+ eventTypeObject.availability = [];
+
return {
props: {
profile: {
@@ -184,6 +184,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
slug: user.username,
theme: user.theme,
weekStart: user.weekStart,
+ brandColor: user.brandColor,
},
date: dateParam,
eventType: eventTypeObject,
diff --git a/pages/[user]/book.tsx b/pages/[user]/book.tsx
index 304f73fd..54482310 100644
--- a/pages/[user]/book.tsx
+++ b/pages/[user]/book.tsx
@@ -34,6 +34,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
bio: true,
avatar: true,
theme: true,
+ brandColor: true,
},
});
@@ -108,6 +109,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
name: user.name,
image: user.avatar,
theme: user.theme,
+ brandColor: user.brandColor,
},
eventType: eventTypeObject,
booking,
diff --git a/pages/_app.tsx b/pages/_app.tsx
index c1f8ae3c..c65bf95e 100644
--- a/pages/_app.tsx
+++ b/pages/_app.tsx
@@ -13,6 +13,7 @@ import { withTRPC } from "@trpc/next";
import type { TRPCClientErrorLike } from "@trpc/react";
import { Maybe } from "@trpc/server";
+import "../styles/fonts.css";
import "../styles/globals.css";
function MyApp(props: AppProps) {
diff --git a/pages/api/auth/[...nextauth].tsx b/pages/api/auth/[...nextauth].tsx
index c1317469..ef4bbf2f 100644
--- a/pages/api/auth/[...nextauth].tsx
+++ b/pages/api/auth/[...nextauth].tsx
@@ -1,98 +1,203 @@
-import NextAuth from "next-auth";
-import Providers from "next-auth/providers";
+import { IdentityProvider } from "@prisma/client";
+import NextAuth, { Session } from "next-auth";
+import { Provider } from "next-auth/providers";
+import CredentialsProvider from "next-auth/providers/credentials";
+import GoogleProvider from "next-auth/providers/google";
import { authenticator } from "otplib";
-import { ErrorCode, Session, verifyPassword } from "@lib/auth";
+import { ErrorCode, verifyPassword } from "@lib/auth";
import { symmetricDecrypt } from "@lib/crypto";
import prisma from "@lib/prisma";
+import { randomString } from "@lib/random";
+import { isSAMLLoginEnabled, samlLoginUrl } from "@lib/saml";
+import slugify from "@lib/slugify";
-export default NextAuth({
- session: {
- jwt: true,
- },
- jwt: {
- secret: process.env.JWT_SECRET,
- },
- pages: {
- signIn: "/auth/login",
- signOut: "/auth/logout",
- error: "/auth/error", // Error code passed in query string as ?error=
- },
- providers: [
- Providers.Credentials({
- name: "Cal.com",
- credentials: {
- email: { label: "Email Address", type: "email", placeholder: "john.doe@example.com" },
- password: { label: "Password", type: "password", placeholder: "Your super secure password" },
- totpCode: { label: "Two-factor Code", type: "input", placeholder: "Code from authenticator app" },
- },
- async authorize(credentials) {
- const user = await prisma.user.findUnique({
- where: {
- email: credentials.email.toLowerCase(),
- },
- });
+import { GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, IS_GOOGLE_LOGIN_ENABLED } from "@server/lib/constants";
- if (!user) {
- throw new Error(ErrorCode.UserNotFound);
+const providers: Provider[] = [
+ CredentialsProvider({
+ id: "credentials",
+ name: "Cal.com",
+ type: "credentials",
+ credentials: {
+ email: { label: "Email Address", type: "email", placeholder: "john.doe@example.com" },
+ password: { label: "Password", type: "password", placeholder: "Your super secure password" },
+ totpCode: { label: "Two-factor Code", type: "input", placeholder: "Code from authenticator app" },
+ },
+ async authorize(credentials) {
+ if (!credentials) {
+ console.error(`For some reason credentials are missing`);
+ throw new Error(ErrorCode.InternalServerError);
+ }
+
+ const user = await prisma.user.findUnique({
+ where: {
+ email: credentials.email.toLowerCase(),
+ },
+ });
+
+ if (!user) {
+ throw new Error(ErrorCode.UserNotFound);
+ }
+
+ if (user.identityProvider !== IdentityProvider.CAL) {
+ throw new Error(ErrorCode.ThirdPartyIdentityProviderEnabled);
+ }
+
+ if (!user.password) {
+ throw new Error(ErrorCode.UserMissingPassword);
+ }
+
+ const isCorrectPassword = await verifyPassword(credentials.password, user.password);
+ if (!isCorrectPassword) {
+ throw new Error(ErrorCode.IncorrectPassword);
+ }
+
+ if (user.twoFactorEnabled) {
+ if (!credentials.totpCode) {
+ throw new Error(ErrorCode.SecondFactorRequired);
}
- if (!user.password) {
- throw new Error(ErrorCode.UserMissingPassword);
+ if (!user.twoFactorSecret) {
+ console.error(`Two factor is enabled for user ${user.id} but they have no secret`);
+ throw new Error(ErrorCode.InternalServerError);
}
- const isCorrectPassword = await verifyPassword(credentials.password, user.password);
- if (!isCorrectPassword) {
- throw new Error(ErrorCode.IncorrectPassword);
+ if (!process.env.CALENDSO_ENCRYPTION_KEY) {
+ console.error(`"Missing encryption key; cannot proceed with two factor login."`);
+ throw new Error(ErrorCode.InternalServerError);
}
- if (user.twoFactorEnabled) {
- if (!credentials.totpCode) {
- throw new Error(ErrorCode.SecondFactorRequired);
- }
+ const secret = symmetricDecrypt(user.twoFactorSecret, process.env.CALENDSO_ENCRYPTION_KEY);
+ if (secret.length !== 32) {
+ console.error(
+ `Two factor secret decryption failed. Expected key with length 32 but got ${secret.length}`
+ );
+ throw new Error(ErrorCode.InternalServerError);
+ }
- if (!user.twoFactorSecret) {
- console.error(`Two factor is enabled for user ${user.id} but they have no secret`);
- throw new Error(ErrorCode.InternalServerError);
- }
+ const isValidToken = authenticator.check(credentials.totpCode, secret);
+ if (!isValidToken) {
+ throw new Error(ErrorCode.IncorrectTwoFactorCode);
+ }
+ }
- if (!process.env.CALENDSO_ENCRYPTION_KEY) {
- console.error(`"Missing encryption key; cannot proceed with two factor login."`);
- throw new Error(ErrorCode.InternalServerError);
- }
+ return {
+ id: user.id,
+ username: user.username,
+ email: user.email,
+ name: user.name,
+ };
+ },
+ }),
+];
- const secret = symmetricDecrypt(user.twoFactorSecret, process.env.CALENDSO_ENCRYPTION_KEY);
- if (secret.length !== 32) {
- console.error(
- `Two factor secret decryption failed. Expected key with length 32 but got ${secret.length}`
- );
- throw new Error(ErrorCode.InternalServerError);
- }
+if (IS_GOOGLE_LOGIN_ENABLED) {
+ providers.push(
+ GoogleProvider({
+ clientId: GOOGLE_CLIENT_ID,
+ clientSecret: GOOGLE_CLIENT_SECRET,
+ })
+ );
+}
- const isValidToken = authenticator.check(credentials.totpCode, secret);
- if (!isValidToken) {
- throw new Error(ErrorCode.IncorrectTwoFactorCode);
- }
- }
+if (isSAMLLoginEnabled) {
+ providers.push({
+ id: "saml",
+ name: "BoxyHQ",
+ type: "oauth",
+ version: "2.0",
+ checks: ["pkce", "state"],
+ authorization: {
+ url: `${samlLoginUrl}/api/auth/saml/authorize`,
+ params: {
+ scope: "",
+ response_type: "code",
+ provider: "saml",
+ },
+ },
+ token: {
+ url: `${samlLoginUrl}/api/auth/saml/token`,
+ params: { grant_type: "authorization_code" },
+ },
+ userinfo: `${samlLoginUrl}/api/auth/saml/userinfo`,
+ profile: (profile) => {
+ return {
+ id: profile.id || "",
+ firstName: profile.first_name || "",
+ lastName: profile.last_name || "",
+ email: profile.email || "",
+ name: `${profile.firstName} ${profile.lastName}`,
+ email_verified: true,
+ };
+ },
+ options: {
+ clientId: "dummy",
+ clientSecret: "dummy",
+ },
+ });
+}
+
+export default NextAuth({
+ session: {
+ strategy: "jwt",
+ },
+ secret: process.env.JWT_SECRET,
+ pages: {
+ signIn: "/auth/login",
+ signOut: "/auth/logout",
+ error: "/auth/error", // Error code passed in query string as ?error=
+ },
+ providers,
+ callbacks: {
+ async jwt({ token, user, account }) {
+ if (!user) {
+ return token;
+ }
+ if (account && account.type === "credentials") {
return {
id: user.id,
username: user.username,
email: user.email,
- name: user.name,
};
- },
- }),
- ],
- callbacks: {
- async jwt(token, user) {
- if (user) {
- token.id = user.id;
- token.username = user.username;
}
+
+ // The arguments above are from the provider so we need to look up the
+ // user based on those values in order to construct a JWT.
+ if (account && account.type === "oauth" && account.provider && account.providerAccountId) {
+ let idP: IdentityProvider = IdentityProvider.GOOGLE;
+ if (account.provider === "saml") {
+ idP = IdentityProvider.SAML;
+ }
+
+ const existingUser = await prisma.user.findFirst({
+ where: {
+ AND: [
+ {
+ identityProvider: idP,
+ },
+ {
+ identityProviderId: account.providerAccountId as string,
+ },
+ ],
+ },
+ });
+
+ if (!existingUser) {
+ return token;
+ }
+
+ return {
+ id: existingUser.id,
+ username: existingUser.username,
+ email: existingUser.email,
+ };
+ }
+
return token;
},
- async session(session, token) {
+ async session({ session, token }) {
const calendsoSession: Session = {
...session,
user: {
@@ -103,5 +208,118 @@ export default NextAuth({
};
return calendsoSession;
},
+ async signIn({ user, account, profile }) {
+ // In this case we've already verified the credentials in the authorize
+ // callback so we can sign the user in.
+ if (account.type === "credentials") {
+ return true;
+ }
+
+ if (account.type !== "oauth") {
+ return false;
+ }
+
+ if (!user.email) {
+ return false;
+ }
+
+ if (!user.name) {
+ return false;
+ }
+
+ if (account.provider) {
+ let idP: IdentityProvider = IdentityProvider.GOOGLE;
+ if (account.provider === "saml") {
+ idP = IdentityProvider.SAML;
+ }
+ user.email_verified = user.email_verified || profile.email_verified;
+
+ if (!user.email_verified) {
+ return "/auth/error?error=unverified-email";
+ }
+
+ const existingUser = await prisma.user.findFirst({
+ where: {
+ AND: [{ identityProvider: idP }, { identityProviderId: user.id as string }],
+ },
+ });
+
+ if (existingUser) {
+ // In this case there's an existing user and their email address
+ // hasn't changed since they last logged in.
+ if (existingUser.email === user.email) {
+ return true;
+ }
+
+ // If the email address doesn't match, check if an account already exists
+ // with the new email address. If it does, for now we return an error. If
+ // not, update the email of their account and log them in.
+ const userWithNewEmail = await prisma.user.findFirst({
+ where: { email: user.email },
+ });
+
+ if (!userWithNewEmail) {
+ await prisma.user.update({ where: { id: existingUser.id }, data: { email: user.email } });
+ return true;
+ } else {
+ return "/auth/error?error=new-email-conflict";
+ }
+ }
+
+ // If there's no existing user for this identity provider and id, create
+ // a new account. If an account already exists with the incoming email
+ // address return an error for now.
+ const existingUserWithEmail = await prisma.user.findFirst({
+ where: { email: user.email },
+ });
+
+ if (existingUserWithEmail) {
+ // check if user was invited
+ if (
+ !existingUserWithEmail.password &&
+ !existingUserWithEmail.emailVerified &&
+ !existingUserWithEmail.username
+ ) {
+ await prisma.user.update({
+ where: { email: user.email },
+ data: {
+ // Slugify the incoming name and append a few random characters to
+ // prevent conflicts for users with the same name.
+ username: slugify(user.name) + "-" + randomString(6),
+ emailVerified: new Date(Date.now()),
+ name: user.name,
+ identityProvider: idP,
+ identityProviderId: user.id as string,
+ },
+ });
+
+ return true;
+ }
+
+ if (existingUserWithEmail.identityProvider === IdentityProvider.CAL) {
+ return "/auth/error?error=use-password-login";
+ }
+
+ return "/auth/error?error=use-identity-login";
+ }
+
+ await prisma.user.create({
+ data: {
+ // Slugify the incoming name and append a few random characters to
+ // prevent conflicts for users with the same name.
+ username: slugify(user.name) + "-" + randomString(6),
+ emailVerified: new Date(Date.now()),
+ name: user.name,
+ email: user.email,
+ identityProvider: idP,
+ identityProviderId: user.id as string,
+ },
+ });
+
+ return true;
+ }
+
+ return false;
+ },
},
});
diff --git a/pages/api/auth/changepw.ts b/pages/api/auth/changepw.ts
index ad4fc615..1a0c7fe6 100644
--- a/pages/api/auth/changepw.ts
+++ b/pages/api/auth/changepw.ts
@@ -1,14 +1,15 @@
+import { IdentityProvider } from "@prisma/client";
import type { NextApiRequest, NextApiResponse } from "next";
import { getSession } from "@lib/auth";
+import prisma from "@lib/prisma";
import { ErrorCode, hashPassword, verifyPassword } from "../../../lib/auth";
-import prisma from "../../../lib/prisma";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const session = await getSession({ req: req });
- if (!session) {
+ if (!session || !session.user || !session.user.email) {
res.status(401).json({ message: "Not authenticated" });
return;
}
@@ -20,6 +21,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
select: {
id: true,
password: true,
+ identityProvider: true,
},
});
@@ -28,6 +30,10 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return;
}
+ if (user.identityProvider !== IdentityProvider.CAL) {
+ return res.status(400).json({ error: ErrorCode.ThirdPartyIdentityProviderEnabled });
+ }
+
const oldPassword = req.body.oldPassword;
const newPassword = req.body.newPassword;
diff --git a/pages/api/auth/forgot-password.ts b/pages/api/auth/forgot-password.ts
index d8787749..c0ad8ca6 100644
--- a/pages/api/auth/forgot-password.ts
+++ b/pages/api/auth/forgot-password.ts
@@ -1,34 +1,29 @@
import { ResetPasswordRequest } from "@prisma/client";
import dayjs from "dayjs";
-import timezone from "dayjs/plugin/timezone";
-import utc from "dayjs/plugin/utc";
import { NextApiRequest, NextApiResponse } from "next";
-import sendEmail from "@lib/emails/sendMail";
-import { buildForgotPasswordMessage } from "@lib/forgot-password/messaging/forgot-password";
+import { 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";
-dayjs.extend(utc);
-dayjs.extend(timezone);
-
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const t = await getTranslation(req.body.language ?? "en", "common");
if (req.method !== "POST") {
- return res.status(405).json({ message: "" });
+ return res.status(405).end();
}
try {
- const rawEmail = req.body?.email;
-
const maybeUser = await prisma.user.findUnique({
where: {
- email: rawEmail,
+ email: req.body?.email?.toLowerCase(),
},
select: {
name: true,
+ identityProvider: true,
+ email: true,
},
});
@@ -36,12 +31,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return res.status(400).json({ message: "Couldn't find an account for this email" });
}
- const now = dayjs().toDate();
const maybePreviousRequest = await prisma.resetPasswordRequest.findMany({
where: {
- email: rawEmail,
+ email: maybeUser.email,
expires: {
- gt: now,
+ gt: new Date(),
},
},
});
@@ -51,34 +45,36 @@ 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,
+ email: maybeUser.email,
expires: expiry,
},
});
passwordRequest = createdResetPasswordRequest;
}
- const passwordResetLink = `${process.env.BASE_URL}/auth/forgot-password/${passwordRequest.id}`;
- const { subject, message } = buildForgotPasswordMessage({
+ const resetLink = `${process.env.BASE_URL}/auth/forgot-password/${passwordRequest.id}`;
+ const passwordEmail: PasswordReset = {
language: t,
- user: {
- name: maybeUser.name,
- },
- link: passwordResetLink,
- });
+ user: maybeUser,
+ resetLink,
+ };
- await sendEmail({
- to: rawEmail,
- subject: subject,
- text: message,
- });
+ await sendPasswordResetEmail(passwordEmail);
+
+ /** So we can test the password reset flow on CI */
+ if (
+ process.env.PLAYWRIGHT_SECRET &&
+ req.headers["x-playwright-secret"] === process.env.PLAYWRIGHT_SECRET
+ ) {
+ return res.status(201).json({ message: "Reset Requested", resetLink });
+ }
return res.status(201).json({ message: "Reset Requested" });
} catch (reason) {
- console.error(reason);
+ // console.error(reason);
return res.status(500).json({ message: "Unable to create password reset request" });
}
}
diff --git a/pages/api/auth/reset-password.ts b/pages/api/auth/reset-password.ts
index 6f692586..c401afd7 100644
--- a/pages/api/auth/reset-password.ts
+++ b/pages/api/auth/reset-password.ts
@@ -1,14 +1,7 @@
-import { User, ResetPasswordRequest } from "@prisma/client";
-import dayjs from "dayjs";
-import timezone from "dayjs/plugin/timezone";
-import utc from "dayjs/plugin/utc";
import { NextApiRequest, NextApiResponse } from "next";
-import { hashPassword } from "../../../lib/auth";
-import prisma from "../../../lib/prisma";
-
-dayjs.extend(utc);
-dayjs.extend(timezone);
+import { hashPassword } from "@lib/auth";
+import prisma from "@lib/prisma";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== "POST") {
@@ -23,7 +16,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return res.status(400).json({ message: "Couldn't find an account for this email" });
}
- const maybeRequest: ResetPasswordRequest = await prisma.resetPasswordRequest.findUnique({
+ const maybeRequest = await prisma.resetPasswordRequest.findUnique({
where: {
id: rawRequestId,
},
@@ -33,7 +26,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return res.status(400).json({ message: "Couldn't find an account for this email" });
}
- const maybeUser: User = await prisma.user.findUnique({
+ const maybeUser = await prisma.user.findUnique({
where: {
email: maybeRequest.email,
},
diff --git a/pages/api/auth/saml/authorize.ts b/pages/api/auth/saml/authorize.ts
new file mode 100644
index 00000000..3ed076e9
--- /dev/null
+++ b/pages/api/auth/saml/authorize.ts
@@ -0,0 +1,21 @@
+import { OAuthReqBody } from "@boxyhq/saml-jackson";
+import { NextApiRequest, NextApiResponse } from "next";
+
+import jackson from "@lib/jackson";
+
+export default async function handler(req: NextApiRequest, res: NextApiResponse) {
+ try {
+ if (req.method !== "GET") {
+ throw new Error("Method not allowed");
+ }
+
+ const { oauthController } = await jackson();
+ const { redirect_url } = await oauthController.authorize(req.query as unknown as OAuthReqBody);
+ res.redirect(302, redirect_url);
+ } catch (err: any) {
+ console.error("authorize error:", err);
+ const { message, statusCode = 500 } = err;
+
+ res.status(statusCode).send(message);
+ }
+}
diff --git a/pages/api/auth/saml/callback.ts b/pages/api/auth/saml/callback.ts
new file mode 100644
index 00000000..2b74d1f2
--- /dev/null
+++ b/pages/api/auth/saml/callback.ts
@@ -0,0 +1,21 @@
+import { NextApiRequest, NextApiResponse } from "next";
+
+import jackson from "@lib/jackson";
+
+export default async function handler(req: NextApiRequest, res: NextApiResponse) {
+ try {
+ if (req.method !== "POST") {
+ throw new Error("Method not allowed");
+ }
+
+ const { oauthController } = await jackson();
+ const { redirect_url } = await oauthController.samlResponse(req.body);
+
+ res.redirect(302, redirect_url);
+ } catch (err: any) {
+ console.error("callback error:", err);
+ const { message, statusCode = 500 } = err;
+
+ res.status(statusCode).send(message);
+ }
+}
diff --git a/pages/api/auth/saml/token.ts b/pages/api/auth/saml/token.ts
new file mode 100644
index 00000000..3a49c4df
--- /dev/null
+++ b/pages/api/auth/saml/token.ts
@@ -0,0 +1,21 @@
+import { NextApiRequest, NextApiResponse } from "next";
+
+import jackson from "@lib/jackson";
+
+export default async function handler(req: NextApiRequest, res: NextApiResponse) {
+ try {
+ if (req.method !== "POST") {
+ throw new Error("Method not allowed");
+ }
+
+ const { oauthController } = await jackson();
+ const result = await oauthController.token(req.body);
+
+ res.json(result);
+ } catch (err: any) {
+ console.error("token error:", err);
+ const { message, statusCode = 500 } = err;
+
+ res.status(statusCode).send(message);
+ }
+}
diff --git a/pages/api/auth/saml/userinfo.ts b/pages/api/auth/saml/userinfo.ts
new file mode 100644
index 00000000..db4cca0a
--- /dev/null
+++ b/pages/api/auth/saml/userinfo.ts
@@ -0,0 +1,47 @@
+import { NextApiRequest, NextApiResponse } from "next";
+
+import jackson from "@lib/jackson";
+
+const extractAuthToken = (req: NextApiRequest) => {
+ const authHeader = req.headers["authorization"];
+ const parts = (authHeader || "").split(" ");
+ if (parts.length > 1) {
+ return parts[1];
+ }
+
+ return null;
+};
+
+export default async function handler(req: NextApiRequest, res: NextApiResponse) {
+ try {
+ if (req.method !== "GET") {
+ throw new Error("Method not allowed");
+ }
+
+ const { oauthController } = await jackson();
+ let token: string | null = extractAuthToken(req);
+
+ // check for query param
+ if (!token) {
+ let arr: string[] = [];
+ arr = arr.concat(req.query.access_token);
+ if (arr[0].length > 0) {
+ token = arr[0];
+ }
+ }
+
+ if (!token) {
+ res.status(401).json({ message: "Unauthorized" });
+ return;
+ }
+
+ const profile = await oauthController.userInfo(token);
+
+ res.json(profile);
+ } catch (err: any) {
+ console.error("userinfo error:", err);
+ const { message, statusCode = 500 } = err;
+
+ res.status(statusCode).json({ message });
+ }
+}
diff --git a/pages/api/auth/signup.ts b/pages/api/auth/signup.ts
index b78fc1d2..ed43e98e 100644
--- a/pages/api/auth/signup.ts
+++ b/pages/api/auth/signup.ts
@@ -4,6 +4,8 @@ import { hashPassword } from "@lib/auth";
import prisma from "@lib/prisma";
import slugify from "@lib/slugify";
+import { IdentityProvider } from ".prisma/client";
+
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== "POST") {
return;
@@ -29,14 +31,14 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return;
}
+ // There is actually an existingUser if username matches
+ // OR if email matches and both username and password are set
const existingUser = await prisma.user.findFirst({
where: {
OR: [
+ { username },
{
- username: username,
- },
- {
- email: userEmail,
+ AND: [{ email: userEmail }, { password: { not: null } }, { username: { not: null } }],
},
],
},
@@ -51,19 +53,33 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const hashedPassword = await hashPassword(password);
- await prisma.user.upsert({
+ const user = await prisma.user.upsert({
where: { email: userEmail },
update: {
username,
password: hashedPassword,
emailVerified: new Date(Date.now()),
+ identityProvider: IdentityProvider.CAL,
},
create: {
username,
email: userEmail,
password: hashedPassword,
+ identityProvider: IdentityProvider.CAL,
},
});
+ // If user has been invitedTo a team, we accept the membership
+ if (user.invitedTo) {
+ await prisma.membership.update({
+ where: {
+ userId_teamId: { userId: user.id, teamId: user.invitedTo },
+ },
+ data: {
+ accepted: true,
+ },
+ });
+ }
+
res.status(201).json({ message: "Created user" });
}
diff --git a/pages/api/auth/two-factor/totp/setup.ts b/pages/api/auth/two-factor/totp/setup.ts
index a879e6b7..c98051de 100644
--- a/pages/api/auth/two-factor/totp/setup.ts
+++ b/pages/api/auth/two-factor/totp/setup.ts
@@ -6,6 +6,8 @@ import { ErrorCode, getSession, verifyPassword } from "@lib/auth";
import { symmetricEncrypt } from "@lib/crypto";
import prisma from "@lib/prisma";
+import { IdentityProvider } from ".prisma/client";
+
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== "POST") {
return res.status(405).json({ message: "Method not allowed" });
@@ -27,6 +29,10 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return res.status(401).json({ message: "Not authenticated" });
}
+ if (user.identityProvider !== IdentityProvider.CAL) {
+ return res.status(400).json({ error: ErrorCode.ThirdPartyIdentityProviderEnabled });
+ }
+
if (!user.password) {
return res.status(400).json({ error: ErrorCode.UserMissingPassword });
}
diff --git a/pages/api/availability/[user].ts b/pages/api/availability/[user].ts
index 05b1a135..fc6bca48 100644
--- a/pages/api/availability/[user].ts
+++ b/pages/api/availability/[user].ts
@@ -1,12 +1,18 @@
// import { getBusyVideoTimes } from "@lib/videoClient";
import { Prisma } from "@prisma/client";
import dayjs from "dayjs";
+import timezone from "dayjs/plugin/timezone";
+import utc from "dayjs/plugin/utc";
import type { NextApiRequest, NextApiResponse } from "next";
import { asStringOrNull } from "@lib/asStringOrNull";
-import { getBusyCalendarTimes } from "@lib/calendarClient";
+import { getWorkingHours } from "@lib/availability";
+import { getBusyCalendarTimes } from "@lib/integrations/calendar/CalendarManager";
import prisma from "@lib/prisma";
+dayjs.extend(utc);
+dayjs.extend(timezone);
+
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const user = asStringOrNull(req.query.user);
const dateFrom = dayjs(asStringOrNull(req.query.dateFrom));
@@ -71,15 +77,10 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}));
const timeZone = eventType?.timeZone || currentUser.timeZone;
- const defaultAvailability = {
- startTime: currentUser.startTime,
- endTime: currentUser.endTime,
- days: [0, 1, 2, 3, 4, 5, 6],
- };
- const workingHours = eventType?.availability.length
- ? eventType.availability
- : // currentUser.availability /* note(zomars) There's no UI nor default for this as of today */
- [defaultAvailability]; /* note(zomars) For now, make every day available as fallback */
+ const workingHours = getWorkingHours(
+ { timeZone },
+ eventType?.availability.length ? eventType.availability : currentUser.availability
+ );
res.status(200).json({
busy: bufferedBusyTimes,
diff --git a/pages/api/availability/calendar.ts b/pages/api/availability/calendar.ts
index 6f976a70..a8f4c64d 100644
--- a/pages/api/availability/calendar.ts
+++ b/pages/api/availability/calendar.ts
@@ -1,19 +1,19 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { getSession } from "@lib/auth";
-
-import { IntegrationCalendar, listCalendars } from "../../../lib/calendarClient";
-import prisma from "../../../lib/prisma";
+import { getCalendarCredentials, getConnectedCalendars } from "@lib/integrations/calendar/CalendarManager";
+import notEmpty from "@lib/notEmpty";
+import prisma from "@lib/prisma";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
- const session = await getSession({ req: req });
+ const session = await getSession({ req });
if (!session?.user?.id) {
res.status(401).json({ message: "Not authenticated" });
return;
}
- const currentUser = await prisma.user.findUnique({
+ const user = await prisma.user.findUnique({
where: {
id: session.user.id,
},
@@ -21,25 +21,26 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
credentials: true,
timeZone: true,
id: true,
+ selectedCalendars: true,
},
});
- if (!currentUser) {
+ if (!user) {
res.status(401).json({ message: "Not authenticated" });
return;
}
- if (req.method == "POST") {
+ if (req.method === "POST") {
await prisma.selectedCalendar.upsert({
where: {
userId_integration_externalId: {
- userId: currentUser.id,
+ userId: user.id,
integration: req.body.integration,
externalId: req.body.externalId,
},
},
create: {
- userId: currentUser.id,
+ userId: user.id,
integration: req.body.integration,
externalId: req.body.externalId,
},
@@ -49,11 +50,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
res.status(200).json({ message: "Calendar Selection Saved" });
}
- if (req.method == "DELETE") {
+ if (req.method === "DELETE") {
await prisma.selectedCalendar.delete({
where: {
userId_integration_externalId: {
- userId: currentUser.id,
+ userId: user.id,
externalId: req.body.externalId,
integration: req.body.integration,
},
@@ -63,17 +64,21 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
res.status(200).json({ message: "Calendar Selection Saved" });
}
- if (req.method == "GET") {
+ if (req.method === "GET") {
const selectedCalendarIds = await prisma.selectedCalendar.findMany({
where: {
- userId: currentUser.id,
+ userId: user.id,
},
select: {
externalId: true,
},
});
- const calendars: IntegrationCalendar[] = await listCalendars(currentUser.credentials);
+ // get user's credentials + their connected integrations
+ const calendarCredentials = getCalendarCredentials(user.credentials, user.id);
+ // get all the connected integrations' calendars (from third party)
+ const connectedCalendars = await getConnectedCalendars(calendarCredentials, user.selectedCalendars);
+ const calendars = connectedCalendars.flatMap((c) => c.calendars).filter(notEmpty);
const selectableCalendars = calendars.map((cal) => {
return { selected: selectedCalendarIds.findIndex((s) => s.externalId === cal.externalId) > -1, ...cal };
});
diff --git a/pages/api/availability/eventtype.ts b/pages/api/availability/eventtype.ts
index c8050d18..be214855 100644
--- a/pages/api/availability/eventtype.ts
+++ b/pages/api/availability/eventtype.ts
@@ -1,197 +1,32 @@
-import { EventTypeCustomInput, Prisma } from "@prisma/client";
import type { NextApiRequest, NextApiResponse } from "next";
import { getSession } from "@lib/auth";
-import prisma from "@lib/prisma";
-function handleCustomInputs(customInputs: EventTypeCustomInput[], eventTypeId: number) {
- if (!customInputs || !customInputs?.length) return undefined;
- const cInputsIdsToDelete = customInputs.filter((input) => input.id > 0).map((e) => e.id);
- const cInputsToCreate = customInputs
- .filter((input) => input.id < 0)
- .map((input) => ({
- type: input.type,
- label: input.label,
- required: input.required,
- placeholder: input.placeholder,
- }));
- const cInputsToUpdate = customInputs
- .filter((input) => input.id > 0)
- .map((input) => ({
- data: {
- type: input.type,
- label: input.label,
- required: input.required,
- placeholder: input.placeholder,
- },
- where: {
- id: input.id,
- },
- }));
-
- return {
- deleteMany: {
- eventTypeId,
- NOT: {
- id: { in: cInputsIdsToDelete },
- },
- },
- createMany: {
- data: cInputsToCreate,
- },
- update: cInputsToUpdate,
- };
-}
+import { createContext } from "@server/createContext";
+import { viewerRouter } from "@server/routers/viewer";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const session = await getSession({ req });
+ /** So we can reuse tRCP queries */
+ const trpcCtx = await createContext({ req, res });
- if (!session) {
+ if (!session?.user?.id) {
res.status(401).json({ message: "Not authenticated" });
return;
}
- if (!session.user?.id) {
- console.error("Session is missing a user id");
- return res.status(500).json({ message: "Something went wrong" });
+ if (req.method === "POST") {
+ const eventType = await viewerRouter.createCaller(trpcCtx).mutation("eventTypes.create", req.body);
+ res.status(201).json({ eventType });
}
- if (req.method !== "POST") {
- const event = await prisma.eventType.findUnique({
- where: { id: req.body.id },
- include: {
- users: true,
- },
- });
-
- if (!event) {
- return res.status(404).json({ message: "No event exists matching that id." });
- }
-
- const isAuthorized =
- event.userId === session.user.id ||
- event.users.find((user) => {
- return user.id === session.user?.id;
- });
-
- if (!isAuthorized) {
- console.warn(`User ${session.user.id} attempted to an access an event ${event.id} they do not own.`);
- return res.status(404).json({ message: "No event exists matching that id." });
- }
+ if (req.method === "PATCH") {
+ const eventType = await viewerRouter.createCaller(trpcCtx).mutation("eventTypes.update", req.body);
+ res.status(201).json({ eventType });
}
- if (req.method == "PATCH" || req.method == "POST") {
- const data: Prisma.EventTypeUpdateInput = {
- title: req.body.title,
- slug: req.body.slug.trim(),
- description: req.body.description,
- length: parseInt(req.body.length),
- hidden: req.body.hidden,
- requiresConfirmation: req.body.requiresConfirmation,
- disableGuests: req.body.disableGuests,
- locations: req.body.locations,
- eventName: req.body.eventName,
- customInputs: handleCustomInputs(req.body.customInputs as EventTypeCustomInput[], req.body.id),
- periodType: req.body.periodType,
- periodDays: req.body.periodDays,
- periodStartDate: req.body.periodStartDate,
- periodEndDate: req.body.periodEndDate,
- periodCountCalendarDays: req.body.periodCountCalendarDays,
- minimumBookingNotice: req.body.minimumBookingNotice
- ? parseInt(req.body.minimumBookingNotice)
- : undefined,
- price: req.body.price,
- currency: req.body.currency,
- };
-
- if (req.body.schedulingType) {
- data.schedulingType = req.body.schedulingType;
- }
-
- if (req.method == "POST") {
- if (req.body.teamId) {
- data.team = {
- connect: {
- id: req.body.teamId,
- },
- };
- }
-
- const eventType = await prisma.eventType.create({
- data: {
- ...data,
- users: {
- connect: {
- id: parseInt(session.user.id),
- },
- },
- },
- });
- res.status(201).json({ eventType });
- } else if (req.method == "PATCH") {
- if (req.body.users) {
- data.users = {
- set: [],
- connect: req.body.users.map((id: string) => ({ id: parseInt(id) })),
- };
- }
-
- if (req.body.timeZone) {
- data.timeZone = req.body.timeZone;
- }
-
- if (req.body.availability) {
- const openingHours = req.body.availability.openingHours || [];
- // const overrides = req.body.availability.dateOverrides || [];
-
- const eventTypeId = +req.body.id;
- if (eventTypeId) {
- await prisma.availability.deleteMany({
- where: {
- eventTypeId,
- },
- });
- }
-
- Promise.all(
- openingHours.map((schedule) =>
- prisma.availability.create({
- data: {
- eventTypeId: +req.body.id,
- days: schedule.days,
- startTime: schedule.startTime,
- endTime: schedule.endTime,
- },
- })
- )
- ).catch((error) => {
- console.log(error);
- });
- }
-
- const eventType = await prisma.eventType.update({
- where: {
- id: req.body.id,
- },
- data,
- });
- res.status(200).json({ eventType });
- }
- }
-
- if (req.method == "DELETE") {
- await prisma.eventTypeCustomInput.deleteMany({
- where: {
- eventTypeId: req.body.id,
- },
- });
-
- await prisma.eventType.delete({
- where: {
- id: req.body.id,
- },
- });
-
- res.status(200).json({});
+ if (req.method === "DELETE") {
+ await viewerRouter.createCaller(trpcCtx).mutation("eventTypes.delete", { id: req.body.id });
+ res.status(200).json({ id: req.body.id, message: "Event Type deleted" });
}
}
diff --git a/pages/api/book/confirm.ts b/pages/api/book/confirm.ts
index d7440d1d..a2d43fe3 100644
--- a/pages/api/book/confirm.ts
+++ b/pages/api/book/confirm.ts
@@ -1,12 +1,14 @@
-import { User, Booking, SchedulingType } from "@prisma/client";
+import { Prisma, User, Booking, SchedulingType, BookingStatus } from "@prisma/client";
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 { sendDeclinedEmails } from "@lib/emails/email-manager";
+import { sendScheduledEmails } from "@lib/emails/email-manager";
import EventManager from "@lib/events/EventManager";
+import { CalendarEvent, AdditionInformation } from "@lib/integrations/calendar/interfaces/Calendar";
+import logger from "@lib/logger";
import prisma from "@lib/prisma";
import { BookingConfirmBody } from "@lib/types/booking";
@@ -38,9 +40,9 @@ const authorized = async (
return false;
};
-export default async function handler(req: NextApiRequest, res: NextApiResponse): Promise
{
- const t = await getTranslation(req.body.language ?? "en", "common");
+const log = logger.getChildLogger({ prefix: ["[api] book:user"] });
+export default async function handler(req: NextApiRequest, res: NextApiResponse): Promise {
const session = await getSession({ req: req });
if (!session?.user?.id) {
return res.status(401).json({ message: "Not authenticated" });
@@ -59,10 +61,15 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
},
select: {
id: true,
- credentials: true,
+ credentials: {
+ orderBy: { id: "desc" as Prisma.SortOrder },
+ },
timeZone: true,
email: true,
name: true,
+ username: true,
+ destinationCalendar: true,
+ locale: true,
},
});
@@ -70,7 +77,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return res.status(404).json({ message: "User not found" });
}
- if (req.method == "PATCH") {
+ const tOrganizer = await getTranslation(currentUser.locale ?? "en", "common");
+
+ if (req.method === "PATCH") {
const booking = await prisma.booking.findFirst({
where: {
id: bookingId,
@@ -88,6 +97,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
id: true,
uid: true,
payment: true,
+ destinationCalendar: true,
},
});
@@ -103,6 +113,20 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return res.status(400).json({ message: "booking already confirmed" });
}
+ const attendeesListPromises = booking.attendees.map(async (attendee) => {
+ return {
+ name: attendee.name,
+ email: attendee.email,
+ timeZone: attendee.timeZone,
+ language: {
+ translate: await getTranslation(attendee.locale ?? "en", "common"),
+ locale: attendee.locale ?? "en",
+ },
+ };
+ });
+
+ const attendeesList = await Promise.all(attendeesListPromises);
+
const evt: CalendarEvent = {
type: booking.title,
title: booking.title,
@@ -113,17 +137,39 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
email: currentUser.email,
name: currentUser.name || "Unnamed",
timeZone: currentUser.timeZone,
+ language: { translate: tOrganizer, locale: currentUser.locale ?? "en" },
},
- attendees: booking.attendees,
+ attendees: attendeesList,
location: booking.location ?? "",
uid: booking.uid,
- language: t,
+ destinationCalendar: booking?.destinationCalendar || currentUser.destinationCalendar,
};
if (reqBody.confirmed) {
- const eventManager = new EventManager(currentUser.credentials);
+ const eventManager = new EventManager(currentUser);
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,
@@ -146,10 +192,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
},
data: {
rejected: true,
+ status: BookingStatus.REJECTED,
},
});
- const attendeeMail = new EventRejectionMail(evt);
- await attendeeMail.sendEmail();
+
+ await sendDeclinedEmails(evt);
res.status(204).end();
}
diff --git a/pages/api/book/event.ts b/pages/api/book/event.ts
index 893b689d..d5766ef7 100644
--- a/pages/api/book/event.ts
+++ b/pages/api/book/event.ts
@@ -11,27 +11,28 @@ 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 {
+ sendScheduledEmails,
+ sendRescheduledEmails,
+ sendOrganizerRequestEmail,
+} from "@lib/emails/email-manager";
+import { ensureArray } from "@lib/ensureArray";
import { getErrorFromUnknown } from "@lib/errors";
import { getEventName } from "@lib/event";
import EventManager, { EventResult, PartialReference } from "@lib/events/EventManager";
+import { getBusyCalendarTimes } from "@lib/integrations/calendar/CalendarManager";
+import { CalendarEvent, AdditionInformation } from "@lib/integrations/calendar/interfaces/Calendar";
+import { BufferedBusyTime } from "@lib/integrations/calendar/interfaces/Office365Calendar";
import logger from "@lib/logger";
+import notEmpty from "@lib/notEmpty";
import prisma from "@lib/prisma";
import { BookingCreateBody } from "@lib/types/booking";
import { getBusyVideoTimes } from "@lib/videoClient";
import sendPayload from "@lib/webhooks/sendPayload";
-import getSubscriberUrls from "@lib/webhooks/subscriberUrls";
+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 +41,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.
@@ -125,11 +126,64 @@ function isOutOfBounds(
}
}
+const userSelect = Prisma.validator()({
+ select: {
+ id: true,
+ email: true,
+ name: true,
+ username: true,
+ timeZone: true,
+ credentials: true,
+ bufferTime: true,
+ destinationCalendar: true,
+ locale: true,
+ },
+});
+
+const getUserNameWithBookingCounts = async (eventTypeId: number, selectedUserNames: string[]) => {
+ const users = await prisma.user.findMany({
+ where: {
+ username: { in: selectedUserNames },
+ eventTypes: {
+ some: {
+ id: eventTypeId,
+ },
+ },
+ },
+ select: {
+ id: true,
+ username: true,
+ locale: true,
+ },
+ });
+
+ const userNamesWithBookingCounts = await Promise.all(
+ users.map(async (user) => ({
+ username: user.username,
+ bookingCount: await prisma.booking.count({
+ where: {
+ user: {
+ id: user.id,
+ },
+ startTime: {
+ gt: new Date(),
+ },
+ eventTypeId,
+ },
+ }),
+ }))
+ );
+
+ return userNamesWithBookingCounts;
+};
+
+type User = Prisma.UserGetPayload;
+
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const reqBody = req.body as BookingCreateBody;
const eventTypeId = reqBody.eventTypeId;
- const t = await getTranslation(reqBody.language ?? "en", "common");
-
+ const tAttendees = await getTranslation(reqBody.language ?? "en", "common");
+ const tGuests = await getTranslation("en", "common");
log.debug(`Booking eventType ${eventTypeId} started`);
const isTimeInPast = (time: string): boolean => {
@@ -146,28 +200,13 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return res.status(400).json(error);
}
- const userSelect = Prisma.validator()({
- id: true,
- email: true,
- name: true,
- username: true,
- timeZone: true,
- credentials: true,
- bufferTime: true,
- });
-
- const userData = Prisma.validator()({
- select: userSelect,
- });
-
const eventType = await prisma.eventType.findUnique({
+ rejectOnNotFound: true,
where: {
id: eventTypeId,
},
select: {
- users: {
- select: userSelect,
- },
+ users: userSelect,
team: {
select: {
id: true,
@@ -187,6 +226,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
userId: true,
price: true,
currency: true,
+ destinationCalendar: true,
},
});
@@ -200,84 +240,103 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
where: {
id: eventType.userId,
},
- select: userSelect,
+ ...userSelect,
});
if (!eventTypeUser) return res.status(404).json({ message: "eventTypeUser.notFound" });
users.push(eventTypeUser);
}
- if (eventType.schedulingType === SchedulingType.ROUND_ROBIN) {
- const selectedUsers = reqBody.users || [];
- const selectedUsersDataWithBookingsCount = await prisma.user.findMany({
- where: {
- username: { in: selectedUsers },
- bookings: {
- every: {
- startTime: {
- gt: new Date(),
- },
- },
- },
- },
- select: {
- username: true,
- _count: {
- select: { bookings: true },
- },
- },
- });
+ const organizer = await prisma.user.findUnique({
+ where: {
+ id: users[0].id,
+ },
+ select: {
+ locale: true,
+ },
+ });
- const bookingCounts = selectedUsersDataWithBookingsCount.map((userData) => ({
- username: userData.username,
- bookingCount: userData._count?.bookings || 0,
- }));
+ const tOrganizer = await getTranslation(organizer?.locale ?? "en", "common");
- if (!bookingCounts.length) users.slice(0, 1);
+ if (eventType.schedulingType === SchedulingType.ROUND_ROBIN) {
+ const bookingCounts = await getUserNameWithBookingCounts(
+ eventTypeId,
+ ensureArray(reqBody.user) || users.map((user) => user.username)
+ );
- const [firstMostAvailableUser] = bookingCounts.sort((a, b) => (a.bookingCount > b.bookingCount ? 1 : -1));
- const luckyUser = users.find((user) => user.username === firstMostAvailableUser?.username);
- users = luckyUser ? [luckyUser] : users;
+ users = getLuckyUsers(users, bookingCounts);
}
- const invitee = [{ email: reqBody.email, name: reqBody.name, timeZone: reqBody.timeZone }];
- const guests = reqBody.guests.map((guest) => {
+ const invitee = [
+ {
+ email: reqBody.email,
+ name: reqBody.name,
+ timeZone: reqBody.timeZone,
+ language: { translate: tAttendees, locale: reqBody.language ?? "en" },
+ },
+ ];
+ const guests = (reqBody.guests || []).map((guest) => {
const g = {
email: guest,
name: "",
timeZone: reqBody.timeZone,
+ language: { translate: tGuests, locale: "en" },
};
return g;
});
- const teamMembers =
+ const teamMemberPromises =
eventType.schedulingType === SchedulingType.COLLECTIVE
- ? users.slice(1).map((user) => ({
- email: user.email || "",
- name: user.name || "",
- timeZone: user.timeZone,
- }))
+ ? users.slice(1).map(async function (user) {
+ return {
+ email: user.email || "",
+ name: user.name || "",
+ timeZone: user.timeZone,
+ language: {
+ translate: await getTranslation(user.locale ?? "en", "common"),
+ locale: user.locale ?? "en",
+ },
+ };
+ })
: [];
+ const teamMembers = await Promise.all(teamMemberPromises);
+
const attendeesList = [...invitee, ...guests, ...teamMembers];
const seed = `${users[0].username}:${dayjs(req.body.start).utc().format()}:${new Date().getTime()}`;
const uid = translator.fromUUID(uuidv5(seed, uuidv5.URL));
+ const eventNameObject = {
+ attendeeName: reqBody.name || "Nameless",
+ eventType: eventType.title,
+ eventName: eventType.eventName,
+ host: users[0].name || "Nameless",
+ t: tOrganizer,
+ };
+
+ const description =
+ reqBody.notes +
+ reqBody.customInputs.reduce(
+ (str, input) => str + " " + input.label + ": " + input.value,
+ ""
+ );
+
const evt: CalendarEvent = {
type: eventType.title,
- title: getEventName(reqBody.name, eventType.title, eventType.eventName),
- description: reqBody.notes,
+ title: getEventName(eventNameObject), //this needs to be either forced in english, or fetched for each attendee and organizer separately
+ description,
startTime: reqBody.start,
endTime: reqBody.end,
organizer: {
name: users[0].name || "Nameless",
email: users[0].email || "Email-less",
timeZone: users[0].timeZone,
+ language: { translate: tOrganizer, locale: organizer?.locale ?? "en" },
},
attendees: attendeesList,
location: reqBody.location, // Will be processed by the EventManager later.
- language: t,
- uid,
+ /** For team events, we will need to handle each member destinationCalendar eventually */
+ destinationCalendar: eventType.destinationCalendar || users[0].destinationCalendar,
};
if (eventType.schedulingType === SchedulingType.COLLECTIVE) {
@@ -304,7 +363,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
startTime: dayjs(evt.startTime).toDate(),
endTime: dayjs(evt.endTime).toDate(),
description: evt.description,
- confirmed: !eventType?.requiresConfirmation || !!rescheduleUid,
+ confirmed: (!eventType.requiresConfirmation && !eventType.price) || !!rescheduleUid,
location: evt.location,
eventType: {
connect: {
@@ -313,7 +372,17 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
},
attendees: {
createMany: {
- data: evt.attendees,
+ data: evt.attendees.map((attendee) => {
+ //if attendee is team member, it should fetch their locale not booker's locale
+ //perhaps make email fetch request to see if his locale is stored, else
+ const retObj = {
+ name: attendee.name,
+ email: attendee.email,
+ timeZone: attendee.timeZone,
+ locale: attendee.language.locale,
+ };
+ return retObj;
+ }),
},
},
user: {
@@ -321,6 +390,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
id: users[0].id,
},
},
+ destinationCalendar: evt.destinationCalendar
+ ? {
+ connect: { id: evt.destinationCalendar.id },
+ }
+ : undefined,
},
});
}
@@ -343,9 +417,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
let results: EventResult[] = [];
let referencesToCreate: PartialReference[] = [];
- type User = Prisma.UserGetPayload;
let user: User | null = null;
+ /** Let's start cheking for availability */
for (const currentUser of users) {
if (!currentUser) {
console.error(`currentUser not found`);
@@ -368,8 +442,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
selectedCalendars
);
- const videoBusyTimes = (await getBusyVideoTimes(credentials)).filter((time) => time);
- calendarBusyTimes.push(...(videoBusyTimes as any[])); // FIXME add types
+ const videoBusyTimes = (await getBusyVideoTimes(credentials)).filter(notEmpty);
+ calendarBusyTimes.push(...videoBusyTimes);
console.log("calendarBusyTimes==>>>", calendarBusyTimes);
const bufferedBusyTimes: BufferedBusyTimes = calendarBusyTimes.map((a) => ({
@@ -427,15 +501,15 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
if (!user) throw Error("Can't continue, user not found.");
// After polling videoBusyTimes, credentials might have been changed due to refreshment, so query them again.
- const eventManager = new EventManager(await refreshCredentials(user.credentials));
+ const credentials = await refreshCredentials(user.credentials);
+ const eventManager = new EventManager({ ...user, credentials });
if (rescheduleUid) {
// Use EventManager to conditionally use all needed integrations.
- const eventManagerCalendarEvent = { ...evt, uid: rescheduleUid };
- const updateResults = await eventManager.update(eventManagerCalendarEvent);
+ 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 = {
@@ -444,15 +518,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 = {
@@ -461,11 +546,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, uid }).sendEmail();
+ await sendOrganizerRequestEmail(evt);
}
if (typeof eventType.price === "number" && eventType.price > 0) {
@@ -487,10 +582,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 subscriberUrls = await getSubscriberUrls(user.id, eventTrigger);
- const promises = subscriberUrls.map((url) =>
- sendPayload(eventTrigger, new Date().toISOString(), url, evt).catch((e) => {
- console.error(`Error executing webhook for event: ${eventTrigger}, URL: ${url}`, e);
+ const subscribers = await getSubscribers(user.id, eventTrigger);
+ console.log("evt:", {
+ ...evt,
+ metadata: reqBody.metadata,
+ });
+ const promises = subscribers.map((sub) =>
+ 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);
@@ -511,3 +619,14 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
// booking successful
return res.status(201).json(booking);
}
+
+export function getLuckyUsers(
+ users: User[],
+ bookingCounts: Prisma.PromiseReturnType
+) {
+ if (!bookingCounts.length) users.slice(0, 1);
+
+ const [firstMostAvailableUser] = bookingCounts.sort((a, b) => (a.bookingCount > b.bookingCount ? 1 : -1));
+ const luckyUser = users.find((user) => user.username === firstMostAvailableUser?.username);
+ return luckyUser ? [luckyUser] : users;
+}
diff --git a/pages/api/cancel.ts b/pages/api/cancel.ts
index 7c889d3f..8e86b2e4 100644
--- a/pages/api/cancel.ts
+++ b/pages/api/cancel.ts
@@ -1,17 +1,20 @@
import { BookingStatus } from "@prisma/client";
import async from "async";
+import dayjs from "dayjs";
import { NextApiRequest, NextApiResponse } from "next";
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 { getCalendar } from "@lib/integrations/calendar/CalendarManager";
+import { CalendarEvent } from "@lib/integrations/calendar/interfaces/Calendar";
import prisma from "@lib/prisma";
import { deleteMeeting } from "@lib/videoClient";
import sendPayload from "@lib/webhooks/sendPayload";
-import getSubscriberUrls from "@lib/webhooks/subscriberUrls";
+import getSubscribers from "@lib/webhooks/subscriptions";
import { getTranslation } from "@server/lib/i18n";
@@ -22,6 +25,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}
const uid = asStringOrNull(req.body.uid) || "";
+ const cancellationReason = asStringOrNull(req.body.reason) || "";
const session = await getSession({ req: req });
const bookingToDelete = await prisma.booking.findUnique({
@@ -38,6 +42,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
email: true,
timeZone: true,
name: true,
+ destinationCalendar: true,
},
},
attendees: true,
@@ -51,11 +56,17 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
payment: true,
paid: true,
title: true,
+ eventType: {
+ select: {
+ title: true,
+ },
+ },
description: true,
startTime: true,
endTime: true,
uid: true,
eventTypeId: true,
+ destinationCalendar: true,
},
});
@@ -79,39 +90,55 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
name: true,
email: true,
timeZone: true,
+ locale: true,
},
rejectOnNotFound: true,
});
- const t = await getTranslation(req.body.language ?? "en", "common");
+ const attendeesListPromises = bookingToDelete.attendees.map(async (attendee) => {
+ return {
+ name: attendee.name,
+ email: attendee.email,
+ timeZone: attendee.timeZone,
+ language: {
+ translate: await getTranslation(attendee.locale ?? "en", "common"),
+ locale: attendee.locale ?? "en",
+ },
+ };
+ });
+
+ const attendeesList = await Promise.all(attendeesListPromises);
+ const tOrganizer = await getTranslation(organizer.locale ?? "en", "common");
const evt: CalendarEvent = {
- type: bookingToDelete?.title,
title: bookingToDelete?.title,
+ type: bookingToDelete?.eventType?.title as string,
description: bookingToDelete?.description || "",
- startTime: bookingToDelete?.startTime.toString(),
- endTime: bookingToDelete?.endTime.toString(),
+ startTime: bookingToDelete?.startTime ? dayjs(bookingToDelete.startTime).format() : "",
+ endTime: bookingToDelete?.endTime ? dayjs(bookingToDelete.endTime).format() : "",
organizer: {
email: organizer.email,
name: organizer.name ?? "Nameless",
timeZone: organizer.timeZone,
+ language: { translate: tOrganizer, locale: organizer.locale ?? "en" },
},
- attendees: bookingToDelete?.attendees.map((attendee) => {
- const retObj = { name: attendee.name, email: attendee.email, timeZone: attendee.timeZone };
- return retObj;
- }),
+ attendees: attendeesList,
uid: bookingToDelete?.uid,
- language: t,
+ location: bookingToDelete?.location,
+ destinationCalendar: bookingToDelete?.destinationCalendar || bookingToDelete?.user.destinationCalendar,
+ cancellationReason: cancellationReason,
};
// Hook up the webhook logic here
const eventTrigger = "BOOKING_CANCELLED";
// Send Webhook call if hooked to BOOKING.CANCELLED
- const subscriberUrls = await getSubscriberUrls(bookingToDelete.userId, eventTrigger);
- const promises = subscriberUrls.map((url) =>
- sendPayload(eventTrigger, new Date().toISOString(), url, evt).catch((e) => {
- console.error(`Error executing webhook for event: ${eventTrigger}, URL: ${url}`, e);
- })
+ const subscribers = await getSubscribers(bookingToDelete.userId, eventTrigger);
+ 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);
+ }
+ )
);
await Promise.all(promises);
@@ -123,6 +150,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
},
data: {
status: BookingStatus.CANCELLED,
+ cancellationReason: cancellationReason,
},
});
@@ -134,16 +162,18 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const bookingRefUid = bookingToDelete.references.filter((ref) => ref.type === credential.type)[0]?.uid;
if (bookingRefUid) {
if (credential.type.endsWith("_calendar")) {
- return await deleteEvent(credential, bookingRefUid);
+ const calendar = getCalendar(credential);
+
+ return calendar?.deleteEvent(bookingRefUid, evt);
} else if (credential.type.endsWith("_video")) {
- return await deleteMeeting(credential, bookingRefUid);
+ return deleteMeeting(credential, bookingRefUid);
}
}
});
if (bookingToDelete && bookingToDelete.paid) {
const evt: CalendarEvent = {
- type: bookingToDelete.title,
+ type: bookingToDelete?.eventType?.title as string,
title: bookingToDelete.title,
description: bookingToDelete.description ?? "",
startTime: bookingToDelete.startTime.toISOString(),
@@ -152,11 +182,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
email: bookingToDelete.user?.email ?? "dev@calendso.com",
name: bookingToDelete.user?.name ?? "no user",
timeZone: bookingToDelete.user?.timeZone ?? "",
+ language: { translate: tOrganizer, locale: organizer.locale ?? "en" },
},
- attendees: bookingToDelete.attendees,
+ attendees: attendeesList,
location: bookingToDelete.location ?? "",
uid: bookingToDelete.uid ?? "",
- language: t,
+ destinationCalendar: bookingToDelete?.destinationCalendar || bookingToDelete?.user.destinationCalendar,
};
await refund(bookingToDelete, evt);
await prisma.booking.update({
@@ -187,7 +218,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();
}
diff --git a/pages/api/cron/bookingReminder.ts b/pages/api/cron/bookingReminder.ts
index 3ea5ad74..d4341d62 100644
--- a/pages/api/cron/bookingReminder.ts
+++ b/pages/api/cron/bookingReminder.ts
@@ -2,8 +2,8 @@ import { ReminderType } from "@prisma/client";
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 { CalendarEvent } from "@lib/integrations/calendar/interfaces/Calendar";
import prisma from "@lib/prisma";
import { getTranslation } from "@server/lib/i18n";
@@ -44,10 +44,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
username: true,
locale: true,
timeZone: true,
+ destinationCalendar: true,
},
},
id: true,
uid: true,
+ destinationCalendar: true,
},
});
@@ -71,7 +73,21 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
continue;
}
- const t = await getTranslation(user.locale ?? "en", "common");
+ const tOrganizer = await getTranslation(user.locale ?? "en", "common");
+
+ const attendeesListPromises = booking.attendees.map(async (attendee) => {
+ return {
+ name: attendee.name,
+ email: attendee.email,
+ timeZone: attendee.timeZone,
+ language: {
+ translate: await getTranslation(attendee.locale ?? "en", "common"),
+ locale: attendee.locale ?? "en",
+ },
+ };
+ });
+
+ const attendeesList = await Promise.all(attendeesListPromises);
const evt: CalendarEvent = {
type: booking.title,
@@ -84,13 +100,15 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
email: user.email,
name,
timeZone: user.timeZone,
+ language: { translate: tOrganizer, locale: user.locale ?? "en" },
},
- attendees: booking.attendees,
+ attendees: attendeesList,
uid: booking.uid,
- language: t,
+ destinationCalendar: booking.destinationCalendar || user.destinationCalendar,
};
- await new EventOrganizerRequestReminderMail(evt).sendEmail();
+ await sendOrganizerRequestReminderEmail(evt);
+
await prisma.reminderMail.create({
data: {
referenceId: booking.id,
diff --git a/pages/api/cron/downgradeUsers.ts b/pages/api/cron/downgradeUsers.ts
index 24ad89f1..fd60c8d2 100644
--- a/pages/api/cron/downgradeUsers.ts
+++ b/pages/api/cron/downgradeUsers.ts
@@ -1,10 +1,9 @@
import dayjs from "dayjs";
import type { NextApiRequest, NextApiResponse } from "next";
+import { TRIAL_LIMIT_DAYS } from "@lib/config/constants";
import prisma from "@lib/prisma";
-const TRIAL_LIMIT_DAYS = 14;
-
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const apiKey = req.headers.authorization || req.query.apiKey;
if (process.env.CRON_API_KEY !== apiKey) {
@@ -16,6 +15,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return;
}
+ /**
+ * TODO:
+ * We should add and extra check for non-paying customers in Stripe so we can
+ * downgrade them here.
+ */
+
await prisma.user.updateMany({
data: {
plan: "FREE",
diff --git a/pages/api/eventType.ts b/pages/api/eventType.ts
index 66f5ef9b..f4be6d1e 100644
--- a/pages/api/eventType.ts
+++ b/pages/api/eventType.ts
@@ -1,6 +1,6 @@
import { Prisma } from "@prisma/client";
import type { NextApiRequest, NextApiResponse } from "next";
-import { getSession } from "next-auth/client";
+import { getSession } from "next-auth/react";
import prisma from "@lib/prisma";
@@ -32,6 +32,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
});
const user = await prisma.user.findUnique({
+ rejectOnNotFound: true,
where: {
id: session.user.id,
},
diff --git a/pages/api/import/calendly.ts b/pages/api/import/calendly.ts
new file mode 100644
index 00000000..720c3e29
--- /dev/null
+++ b/pages/api/import/calendly.ts
@@ -0,0 +1,78 @@
+import { PrismaClient } from "@prisma/client";
+import type { NextApiRequest, NextApiResponse } from "next";
+
+import { getSession } from "@lib/auth";
+
+const prisma = new PrismaClient();
+
+export default async function handler(req: NextApiRequest, res: NextApiResponse) {
+ const session = await getSession({ req });
+ const authenticatedUser = await prisma.user.findFirst({
+ rejectOnNotFound: true,
+ where: {
+ id: session?.user.id,
+ },
+ select: {
+ id: true,
+ },
+ });
+ if (req.method == "POST") {
+ const userResult = await fetch("https://api.calendly.com/users/me", {
+ method: "GET",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: "Bearer " + req.body.token,
+ },
+ });
+
+ if (userResult.status == 200) {
+ const userData = await userResult.json();
+
+ await prisma.user.update({
+ where: {
+ id: authenticatedUser.id,
+ },
+ data: {
+ name: userData.resource.name,
+ },
+ });
+
+ const eventTypesResult = await fetch(
+ "https://api.calendly.com/event_types?user=" + userData.resource.uri,
+ {
+ method: "GET",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: "Bearer " + req.body.token,
+ },
+ }
+ );
+
+ const eventTypesData = await eventTypesResult.json();
+
+ eventTypesData.collection.forEach(async (eventType: any) => {
+ await prisma.eventType.create({
+ data: {
+ title: eventType.name,
+ slug: eventType.slug,
+ length: eventType.duration,
+ description: eventType.description_plain,
+ hidden: eventType.secret,
+ users: {
+ connect: {
+ id: authenticatedUser.id,
+ },
+ },
+ userId: authenticatedUser.id,
+ },
+ });
+ });
+
+ res.status(201).end();
+ } else {
+ res.status(500).end();
+ }
+ } else {
+ res.status(405).end();
+ }
+}
diff --git a/pages/api/import/savvycal.ts b/pages/api/import/savvycal.ts
new file mode 100644
index 00000000..4e5a8091
--- /dev/null
+++ b/pages/api/import/savvycal.ts
@@ -0,0 +1,78 @@
+import { PrismaClient } from "@prisma/client";
+import type { NextApiRequest, NextApiResponse } from "next";
+
+import { getSession } from "@lib/auth";
+
+const prisma = new PrismaClient();
+
+export default async function handler(req: NextApiRequest, res: NextApiResponse) {
+ const session = await getSession({ req });
+ const authenticatedUser = await prisma.user.findFirst({
+ rejectOnNotFound: true,
+ where: {
+ id: session?.user.id,
+ },
+ select: {
+ id: true,
+ },
+ });
+ if (req.method === "POST") {
+ const userResult = await fetch("https://api.savvycal.com/v1/me", {
+ method: "GET",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: "Bearer " + req.body.token,
+ },
+ });
+
+ if (userResult.status === 200) {
+ const userData = await userResult.json();
+
+ await prisma.user.update({
+ where: {
+ id: authenticatedUser.id,
+ },
+ data: {
+ name: userData.display_name,
+ timeZone: userData.time_zone,
+ weekStart: userData.first_day_of_week === 0 ? "Sunday" : "Monday",
+ avatar: userData.avatar_url,
+ },
+ });
+
+ const eventTypesResult = await fetch("https://api.savvycal.com/v1/links?limit=100", {
+ method: "GET",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: "Bearer " + req.body.token,
+ },
+ });
+
+ const eventTypesData = await eventTypesResult.json();
+
+ eventTypesData.entries.forEach(async (eventType: any) => {
+ await prisma.eventType.create({
+ data: {
+ title: eventType.name,
+ slug: eventType.slug,
+ length: eventType.durations[0],
+ description: eventType.description.replace(/<[^>]*>?/gm, ""),
+ hidden: eventType.state === "active" ? true : false,
+ users: {
+ connect: {
+ id: authenticatedUser.id,
+ },
+ },
+ userId: authenticatedUser.id,
+ },
+ });
+ });
+
+ res.status(201).end();
+ } else {
+ res.status(500).end();
+ }
+ } else {
+ res.status(405).end();
+ }
+}
diff --git a/pages/api/integrations/apple/add.ts b/pages/api/integrations/apple/add.ts
index cb0ff1bd..df767683 100644
--- a/pages/api/integrations/apple/add.ts
+++ b/pages/api/integrations/apple/add.ts
@@ -1,48 +1,47 @@
import type { NextApiRequest, NextApiResponse } from "next";
-import { getSession } from "next-auth/client";
+import { getSession } from "@lib/auth";
import { symmetricEncrypt } from "@lib/crypto";
-import { AppleCalendar } from "@lib/integrations/Apple/AppleCalendarAdapter";
+import { getCalendar } from "@lib/integrations/calendar/CalendarManager";
import logger from "@lib/logger";
-
-import prisma from "../../../../lib/prisma";
+import prisma from "@lib/prisma";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "POST") {
// Check that user is authenticated
- const session = await getSession({ req: req });
+ const session = await getSession({ req });
- if (!session) {
+ if (!session?.user?.id) {
res.status(401).json({ message: "You must be logged in to do this" });
return;
}
const { username, password } = req.body;
// Get user
- await prisma.user.findFirst({
+ const user = await prisma.user.findFirst({
+ rejectOnNotFound: true,
where: {
- email: session.user.email,
+ id: session?.user?.id,
},
select: {
id: true,
},
});
+ const data = {
+ type: "apple_calendar",
+ key: symmetricEncrypt(JSON.stringify({ username, password }), process.env.CALENDSO_ENCRYPTION_KEY!),
+ userId: user.id,
+ };
+
try {
- const dav = new AppleCalendar({
+ const dav = getCalendar({
id: 0,
- type: "apple_calendar",
- key: symmetricEncrypt(JSON.stringify({ username, password }), process.env.CALENDSO_ENCRYPTION_KEY),
- userId: session.user.id,
+ ...data,
});
-
- await dav.listCalendars();
+ await dav?.listCalendars();
await prisma.credential.create({
- data: {
- type: "apple_calendar",
- key: symmetricEncrypt(JSON.stringify({ username, password }), process.env.CALENDSO_ENCRYPTION_KEY),
- userId: session.user.id,
- },
+ data,
});
} catch (reason) {
logger.error("Could not add this caldav account", reason);
diff --git a/pages/api/integrations/caldav/add.ts b/pages/api/integrations/caldav/add.ts
index 7ba4318a..5cc88645 100644
--- a/pages/api/integrations/caldav/add.ts
+++ b/pages/api/integrations/caldav/add.ts
@@ -2,53 +2,49 @@ import type { NextApiRequest, NextApiResponse } from "next";
import { getSession } from "@lib/auth";
import { symmetricEncrypt } from "@lib/crypto";
-import { CalDavCalendar } from "@lib/integrations/CalDav/CalDavCalendarAdapter";
+import { getCalendar } from "@lib/integrations/calendar/CalendarManager";
import logger from "@lib/logger";
-
-import prisma from "../../../../lib/prisma";
+import prisma from "@lib/prisma";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "POST") {
// Check that user is authenticated
- const session = await getSession({ req: req });
+ const session = await getSession({ req });
- if (!session) {
+ if (!session?.user?.id) {
res.status(401).json({ message: "You must be logged in to do this" });
return;
}
const { username, password, url } = req.body;
// Get user
- await prisma.user.findFirst({
+ const user = await prisma.user.findFirst({
+ rejectOnNotFound: true,
where: {
- email: session.user.email,
+ id: session?.user?.id,
},
select: {
id: true,
},
});
+ const data = {
+ type: "caldav_calendar",
+ key: symmetricEncrypt(
+ JSON.stringify({ username, password, url }),
+ process.env.CALENDSO_ENCRYPTION_KEY!
+ ),
+ userId: user.id,
+ };
+
try {
- const dav = new CalDavCalendar({
+ const dav = getCalendar({
id: 0,
- type: "caldav_calendar",
- key: symmetricEncrypt(
- JSON.stringify({ username, password, url }),
- process.env.CALENDSO_ENCRYPTION_KEY
- ),
- userId: session.user.id,
+ ...data,
});
-
- await dav.listCalendars();
+ await dav?.listCalendars();
await prisma.credential.create({
- data: {
- type: "caldav_calendar",
- key: symmetricEncrypt(
- JSON.stringify({ username, password, url }),
- process.env.CALENDSO_ENCRYPTION_KEY
- ),
- userId: session.user.id,
- },
+ data,
});
} catch (reason) {
logger.error("Could not add this caldav account", reason);
diff --git a/pages/api/integrations/googlecalendar/add.ts b/pages/api/integrations/googlecalendar/add.ts
index f7a57688..15fdf782 100644
--- a/pages/api/integrations/googlecalendar/add.ts
+++ b/pages/api/integrations/googlecalendar/add.ts
@@ -2,6 +2,9 @@ import { google } from "googleapis";
import type { NextApiRequest, NextApiResponse } from "next";
import { getSession } from "@lib/auth";
+import { BASE_URL } from "@lib/config/constants";
+
+import { encodeOAuthState } from "../utils";
const credentials = process.env.GOOGLE_API_CREDENTIALS!;
const scopes = [
@@ -21,7 +24,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
// Get token from Google Calendar API
const { client_secret, client_id } = JSON.parse(credentials).web;
- const redirect_uri = process.env.BASE_URL + "/api/integrations/googlecalendar/callback";
+ const redirect_uri = BASE_URL + "/api/integrations/googlecalendar/callback";
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uri);
const authUrl = oAuth2Client.generateAuthUrl({
@@ -32,6 +35,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
// setting the prompt to 'consent' will force this consent
// every time, forcing a refresh_token to be returned.
prompt: "consent",
+ state: encodeOAuthState(req),
});
res.status(200).json({ url: authUrl });
diff --git a/pages/api/integrations/googlecalendar/callback.ts b/pages/api/integrations/googlecalendar/callback.ts
index 6b6974cb..5499db83 100644
--- a/pages/api/integrations/googlecalendar/callback.ts
+++ b/pages/api/integrations/googlecalendar/callback.ts
@@ -2,8 +2,11 @@ import { google } from "googleapis";
import type { NextApiRequest, NextApiResponse } from "next";
import { getSession } from "@lib/auth";
+import { BASE_URL } from "@lib/config/constants";
import prisma from "@lib/prisma";
+import { decodeOAuthState } from "../utils";
+
const credentials = process.env.GOOGLE_API_CREDENTIALS;
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
@@ -16,7 +19,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
res.status(401).json({ message: "You must be logged in to do this" });
return;
}
- if (typeof code !== "string") {
+ if (code && typeof code !== "string") {
res.status(400).json({ message: "`code` must be a string" });
return;
}
@@ -26,10 +29,18 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}
const { client_secret, client_id } = JSON.parse(credentials).web;
- const redirect_uri = process.env.BASE_URL + "/api/integrations/googlecalendar/callback";
+ const redirect_uri = BASE_URL + "/api/integrations/googlecalendar/callback";
+
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uri);
- const token = await oAuth2Client.getToken(code);
- const key = token.res?.data;
+
+ let key = "";
+
+ if (code) {
+ const token = await oAuth2Client.getToken(code);
+
+ key = token.res?.data;
+ }
+
await prisma.credential.create({
data: {
type: "google_calendar",
@@ -37,6 +48,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
userId: session.user.id,
},
});
-
- res.redirect("/integrations");
+ const state = decodeOAuthState(req);
+ res.redirect(state?.returnTo ?? "/integrations");
}
diff --git a/pages/api/integrations/office365calendar/add.ts b/pages/api/integrations/office365calendar/add.ts
index a3ae606f..7ab61569 100644
--- a/pages/api/integrations/office365calendar/add.ts
+++ b/pages/api/integrations/office365calendar/add.ts
@@ -1,43 +1,33 @@
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";
+import { encodeOAuthState } from "../utils";
const scopes = ["User.Read", "Calendars.Read", "Calendars.ReadWrite", "offline_access"];
-function generateAuthUrl() {
- return (
- "https://login.microsoftonline.com/common/oauth2/v2.0/authorize?response_type=code&scope=" +
- scopes.join(" ") +
- "&client_id=" +
- process.env.MS_GRAPH_CLIENT_ID +
- "&redirect_uri=" +
- process.env.BASE_URL +
- "/api/integrations/office365calendar/callback"
- );
-}
-
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "GET") {
// Check that user is authenticated
- const session = await getSession({ req: req });
+ const session = await getSession({ req });
- if (!session) {
+ if (!session?.user) {
res.status(401).json({ message: "You must be logged in to do this" });
return;
}
- // Get user
- await prisma.user.findFirst({
- where: {
- email: session.user.email,
- },
- select: {
- id: true,
- },
- });
-
- res.status(200).json({ url: generateAuthUrl() });
+ const state = encodeOAuthState(req);
+ const params = {
+ response_type: "code",
+ scope: scopes.join(" "),
+ client_id: process.env.MS_GRAPH_CLIENT_ID,
+ redirect_uri: BASE_URL + "/api/integrations/office365calendar/callback",
+ state,
+ };
+ const query = stringify(params);
+ const url = `https://login.microsoftonline.com/common/oauth2/v2.0/authorize?${query}`;
+ res.status(200).json({ url });
}
}
diff --git a/pages/api/integrations/office365calendar/callback.ts b/pages/api/integrations/office365calendar/callback.ts
index ecc4c8a4..35814fba 100644
--- a/pages/api/integrations/office365calendar/callback.ts
+++ b/pages/api/integrations/office365calendar/callback.ts
@@ -1,8 +1,10 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { getSession } from "@lib/auth";
+import { BASE_URL } from "@lib/config/constants";
import prisma from "../../../../lib/prisma";
+import { decodeOAuthState } from "../utils";
const scopes = ["offline_access", "Calendars.Read", "Calendars.ReadWrite"];
@@ -11,23 +13,27 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
// Check that user is authenticated
const session = await getSession({ req: req });
- if (!session) {
+ if (!session?.user?.id) {
res.status(401).json({ message: "You must be logged in to do this" });
return;
}
+ if (typeof code !== "string") {
+ res.status(400).json({ message: "No code returned" });
+ return;
+ }
- const toUrlEncoded = (payload) =>
+ const toUrlEncoded = (payload: Record) =>
Object.keys(payload)
.map((key) => key + "=" + encodeURIComponent(payload[key]))
.join("&");
const body = toUrlEncoded({
- client_id: process.env.MS_GRAPH_CLIENT_ID,
+ client_id: process.env.MS_GRAPH_CLIENT_ID!,
grant_type: "authorization_code",
code,
scope: scopes.join(" "),
- redirect_uri: process.env.BASE_URL + "/api/integrations/office365calendar/callback",
- client_secret: process.env.MS_GRAPH_CLIENT_SECRET,
+ redirect_uri: BASE_URL + "/api/integrations/office365calendar/callback",
+ client_secret: process.env.MS_GRAPH_CLIENT_SECRET!,
});
const response = await fetch("https://login.microsoftonline.com/common/oauth2/v2.0/token", {
@@ -62,5 +68,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
},
});
- return res.redirect("/integrations");
+ const state = decodeOAuthState(req);
+ return res.redirect(state?.returnTo ?? "/integrations");
}
diff --git a/pages/api/integrations/types.d.ts b/pages/api/integrations/types.d.ts
new file mode 100644
index 00000000..bcdc5dde
--- /dev/null
+++ b/pages/api/integrations/types.d.ts
@@ -0,0 +1,3 @@
+export type IntegrationOAuthCallbackState = {
+ returnTo: string;
+};
diff --git a/pages/api/integrations/utils.ts b/pages/api/integrations/utils.ts
new file mode 100644
index 00000000..8353ac43
--- /dev/null
+++ b/pages/api/integrations/utils.ts
@@ -0,0 +1,21 @@
+import { NextApiRequest } from "next";
+
+import { IntegrationOAuthCallbackState } from "./types";
+
+export function encodeOAuthState(req: NextApiRequest) {
+ if (typeof req.query.state !== "string") {
+ return undefined;
+ }
+ const state: IntegrationOAuthCallbackState = JSON.parse(req.query.state);
+
+ return JSON.stringify(state);
+}
+
+export function decodeOAuthState(req: NextApiRequest) {
+ if (typeof req.query.state !== "string") {
+ return undefined;
+ }
+ const state: IntegrationOAuthCallbackState = JSON.parse(req.query.state);
+
+ return state;
+}
diff --git a/pages/api/integrations/zoomvideo/add.ts b/pages/api/integrations/zoomvideo/add.ts
index c271ce0a..013069c2 100644
--- a/pages/api/integrations/zoomvideo/add.ts
+++ b/pages/api/integrations/zoomvideo/add.ts
@@ -1,38 +1,40 @@
import type { NextApiRequest, NextApiResponse } from "next";
+import { stringify } from "querystring";
import { getSession } from "@lib/auth";
-
-import prisma from "../../../../lib/prisma";
+import { BASE_URL } from "@lib/config/constants";
+import prisma from "@lib/prisma";
const client_id = process.env.ZOOM_CLIENT_ID;
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "GET") {
// Check that user is authenticated
- const session = await getSession({ req: req });
+ const session = await getSession({ req });
- if (!session) {
+ if (!session?.user?.id) {
res.status(401).json({ message: "You must be logged in to do this" });
return;
}
// Get user
await prisma.user.findFirst({
+ rejectOnNotFound: true,
where: {
- email: session.user.email,
+ id: session?.user?.id,
},
select: {
id: true,
},
});
- const redirectUri = encodeURI(process.env.BASE_URL + "/api/integrations/zoomvideo/callback");
- const authUrl =
- "https://zoom.us/oauth/authorize?response_type=code&client_id=" +
- client_id +
- "&redirect_uri=" +
- redirectUri;
-
- res.status(200).json({ url: authUrl });
+ const params = {
+ response_type: "code",
+ client_id,
+ redirect_uri: BASE_URL + "/api/integrations/zoomvideo/callback",
+ };
+ const query = stringify(params);
+ const url = `https://zoom.us/oauth/authorize?${query}`;
+ res.status(200).json({ url });
}
}
diff --git a/pages/api/integrations/zoomvideo/callback.ts b/pages/api/integrations/zoomvideo/callback.ts
index 13a16145..2d6e7479 100644
--- a/pages/api/integrations/zoomvideo/callback.ts
+++ b/pages/api/integrations/zoomvideo/callback.ts
@@ -1,6 +1,7 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { getSession } from "@lib/auth";
+import { BASE_URL } from "@lib/config/constants";
import prisma from "../../../../lib/prisma";
@@ -11,14 +12,14 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const { code } = req.query;
// Check that user is authenticated
- const session = await getSession({ req: req });
+ const session = await getSession({ req });
- if (!session) {
+ if (!session?.user?.id) {
res.status(401).json({ message: "You must be logged in to do this" });
return;
}
- const redirectUri = encodeURI(process.env.BASE_URL + "/api/integrations/zoomvideo/callback");
+ const redirectUri = encodeURI(BASE_URL + "/api/integrations/zoomvideo/callback");
const authHeader = "Basic " + Buffer.from(client_id + ":" + client_secret).toString("base64");
const result = await fetch(
"https://zoom.us/oauth/token?grant_type=authorization_code&code=" + code + "&redirect_uri=" + redirectUri,
@@ -29,14 +30,25 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
},
}
);
- const json = await result.json();
- await prisma.credential.create({
+ const responseBody = await result.json();
+
+ responseBody.expiry_date = Math.round(Date.now() + responseBody.expires_in * 1000);
+ delete responseBody.expires_in;
+
+ await prisma.user.update({
+ where: {
+ id: session.user.id,
+ },
data: {
- type: "zoom_video",
- key: json,
- userId: session.user.id,
+ credentials: {
+ create: {
+ type: "zoom_video",
+ key: responseBody,
+ },
+ },
},
});
+
res.redirect("/integrations");
}
diff --git a/pages/api/me.ts b/pages/api/me.ts
index 6cec62b5..bf33f248 100644
--- a/pages/api/me.ts
+++ b/pages/api/me.ts
@@ -4,14 +4,18 @@ import { getSession } from "@lib/auth";
import prisma from "@lib/prisma";
import { defaultAvatarSrc } from "@lib/profile";
+/**
+ * @deprecated Use TRCP's viewer.me query
+ */
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
- const session = await getSession({ req: req });
+ const session = await getSession({ req });
if (!session) {
res.status(401).json({ message: "Not authenticated" });
return;
}
- const user: User = await prisma.user.findUnique({
+ const user = await prisma.user.findUnique({
+ rejectOnNotFound: true,
where: {
id: session.user.id,
},
diff --git a/pages/api/schedule/index.ts b/pages/api/schedule/index.ts
index 22b10581..830ff036 100644
--- a/pages/api/schedule/index.ts
+++ b/pages/api/schedule/index.ts
@@ -1,32 +1,50 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { getSession } from "@lib/auth";
+import { getAvailabilityFromSchedule } from "@lib/availability";
import prisma from "@lib/prisma";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
- const session = await getSession({ req: req });
-
- if (!session) {
+ const session = await getSession({ req });
+ const userId = session?.user?.id;
+ if (!userId) {
res.status(401).json({ message: "Not authenticated" });
return;
}
+ if (!req.body.schedule || req.body.schedule.length !== 7) {
+ return res.status(400).json({ message: "Bad Request." });
+ }
+
+ const availability = getAvailabilityFromSchedule(req.body.schedule);
+
if (req.method === "POST") {
try {
- const createdSchedule = await prisma.schedule.create({
+ await prisma.user.update({
+ where: {
+ id: userId,
+ },
data: {
- freeBusyTimes: req.body.data.freeBusyTimes,
- user: {
- connect: {
- id: session.user.id,
+ availability: {
+ /* We delete user availabilty */
+ deleteMany: {
+ userId: {
+ equals: userId,
+ },
+ },
+ /* So we can replace it */
+ createMany: {
+ data: availability.map((schedule) => ({
+ days: schedule.days,
+ startTime: schedule.startTime,
+ endTime: schedule.endTime,
+ })),
},
},
},
});
-
return res.status(200).json({
message: "created",
- data: createdSchedule,
});
} catch (error) {
console.error(error);
diff --git a/pages/api/teams.ts b/pages/api/teams.ts
index bca5901f..c06814c0 100644
--- a/pages/api/teams.ts
+++ b/pages/api/teams.ts
@@ -1,14 +1,13 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { getSession } from "@lib/auth";
+import prisma from "@lib/prisma";
import slugify from "@lib/slugify";
-import prisma from "../../lib/prisma";
-
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const session = await getSession({ req: req });
- if (!session) {
+ if (!session?.user?.id) {
res.status(401).json({ message: "Not authenticated" });
return;
}
@@ -23,7 +22,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
});
if (nameCollisions > 0) {
- return res.status(409).json({ errorCode: "TeamNameCollision", message: "Team name already take." });
+ return res.status(409).json({ errorCode: "TeamNameCollision", message: "Team name already taken." });
}
const createTeam = await prisma.team.create({
diff --git a/pages/api/teams/[team]/index.ts b/pages/api/teams/[team]/index.ts
index ee53b5f6..881540b2 100644
--- a/pages/api/teams/[team]/index.ts
+++ b/pages/api/teams/[team]/index.ts
@@ -2,24 +2,35 @@ import type { NextApiRequest, NextApiResponse } from "next";
import { getSession } from "@lib/auth";
import prisma from "@lib/prisma";
+import { getTeamWithMembers } from "@lib/queries/teams";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const session = await getSession({ req: req });
if (!session) {
return res.status(401).json({ message: "Not authenticated" });
}
+ if (!session.user?.id) {
+ console.log("Received session token without a user id.");
+ return res.status(500).json({ message: "Something went wrong." });
+ }
+ if (!req.query.team) {
+ console.log("Missing team query param.");
+ return res.status(500).json({ message: "Something went wrong." });
+ }
+
+ const teamId = parseInt(req.query.team as string);
+ // GET /api/teams/{team}
+ if (req.method === "GET") {
+ const team = await getTeamWithMembers(teamId);
+ return res.status(200).json({ team });
+ }
// DELETE /api/teams/{team}
if (req.method === "DELETE") {
- if (!session.user?.id) {
- console.log("Received session token without a user id.");
- return res.status(500).json({ message: "Something went wrong." });
- }
-
const membership = await prisma.membership.findFirst({
where: {
userId: session.user.id,
- teamId: parseInt(req.query.team as string),
+ teamId,
},
});
@@ -30,12 +41,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
await prisma.membership.delete({
where: {
- userId_teamId: { userId: session.user.id, teamId: parseInt(req.query.team) },
+ userId_teamId: { userId: session.user.id, teamId },
},
});
await prisma.team.delete({
where: {
- id: parseInt(req.query.team),
+ id: teamId,
},
});
return res.status(204).send(null);
diff --git a/pages/api/teams/[team]/invite.ts b/pages/api/teams/[team]/invite.ts
index f1f529de..f37a5f79 100644
--- a/pages/api/teams/[team]/invite.ts
+++ b/pages/api/teams/[team]/invite.ts
@@ -1,9 +1,13 @@
+import { MembershipRole } from "@prisma/client";
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 slugify from "@lib/slugify";
import { getTranslation } from "@server/lib/i18n";
@@ -14,7 +18,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return res.status(400).json({ message: "Bad request" });
}
- const session = await getSession({ req: req });
+ const session = await getSession({ req });
if (!session) {
return res.status(401).json({ message: "Not authenticated" });
}
@@ -29,56 +33,67 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return res.status(404).json({ message: "Invalid team" });
}
+ const reqBody = req.body as {
+ usernameOrEmail: string;
+ role: MembershipRole;
+ sendEmailInvitation: boolean;
+ };
+ const { role, sendEmailInvitation } = reqBody;
+ // liberal email match
+ const isEmail = (str: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(str);
+ const usernameOrEmail = isEmail(reqBody.usernameOrEmail)
+ ? reqBody.usernameOrEmail.toLowerCase()
+ : slugify(reqBody.usernameOrEmail);
+
const invitee = await prisma.user.findFirst({
where: {
- OR: [{ username: req.body.usernameOrEmail }, { email: req.body.usernameOrEmail }],
+ OR: [{ username: usernameOrEmail }, { email: usernameOrEmail }],
},
});
if (!invitee) {
- // liberal email match
- const isEmail = (str: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(str);
-
- if (!isEmail(req.body.usernameOrEmail)) {
+ const email = isEmail(usernameOrEmail) ? usernameOrEmail : undefined;
+ if (!email) {
return res.status(400).json({
- message: `Invite failed because there is no corresponding user for ${req.body.usernameOrEmail}`,
+ message: `Invite failed because there is no corresponding user for ${usernameOrEmail}`,
});
}
- // valid email given, create User
- await prisma.user
- .create({
- data: {
- email: req.body.usernameOrEmail,
- },
- })
- .then((invitee) =>
- prisma.membership.create({
- data: {
- teamId: parseInt(req.query.team as string),
- userId: invitee.id,
- role: req.body.role,
+ await prisma.user.create({
+ data: {
+ email,
+ teams: {
+ create: {
+ team: {
+ connect: {
+ id: parseInt(req.query.team as string),
+ },
+ },
+ role,
},
- })
- );
+ },
+ },
+ });
const token: string = randomBytes(32).toString("hex");
await prisma.verificationRequest.create({
data: {
- identifier: req.body.usernameOrEmail,
+ identifier: usernameOrEmail,
token,
expires: new Date(new Date().setHours(168)), // +1 week
},
});
if (session?.user?.name && team?.name) {
- createInvitationEmail({
+ const teamInviteEvent: TeamInvite = {
language: t,
- toEmail: req.body.usernameOrEmail,
from: session.user.name,
+ to: 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({});
@@ -90,7 +105,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
data: {
teamId: parseInt(req.query.team as string),
userId: invitee.id,
- role: req.body.role,
+ role,
},
});
} catch (err: any) {
@@ -105,13 +120,16 @@ 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({
+ if (sendEmailInvitation && session?.user?.name && team?.name) {
+ const teamInviteEvent: TeamInvite = {
language: t,
- toEmail: invitee.email,
from: session.user.name,
+ to: usernameOrEmail,
teamName: team.name,
- });
+ joinLink: BASE_URL + "/settings/teams",
+ };
+
+ await sendTeamInviteEmail(teamInviteEvent);
}
res.status(201).json({});
diff --git a/pages/api/teams/[team]/membership.ts b/pages/api/teams/[team]/membership.ts
index 39a2f054..0f3f5864 100644
--- a/pages/api/teams/[team]/membership.ts
+++ b/pages/api/teams/[team]/membership.ts
@@ -1,8 +1,7 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { getSession } from "@lib/auth";
-
-import prisma from "../../../../lib/prisma";
+import prisma from "@lib/prisma";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const session = await getSession({ req });
@@ -14,7 +13,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const isTeamOwner = !!(await prisma.membership.findFirst({
where: {
- userId: session.user.id,
+ userId: session.user?.id,
teamId: parseInt(req.query.team as string),
role: "OWNER",
},
@@ -54,7 +53,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const membership = memberships.find((membership) => member.id === membership.userId);
return {
...member,
- role: membership.accepted ? membership.role : "INVITEE",
+ role: membership?.accepted ? membership?.role : "INVITEE",
};
});
@@ -65,7 +64,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
if (req.method === "DELETE") {
await prisma.membership.delete({
where: {
- userId_teamId: { userId: req.body.userId, teamId: parseInt(req.query.team) },
+ userId_teamId: { userId: req.body.userId, teamId: parseInt(req.query.team as string) },
},
});
return res.status(204).send(null);
diff --git a/pages/api/teams/[team]/profile.ts b/pages/api/teams/[team]/profile.ts
index fa36b4c2..ac1420ef 100644
--- a/pages/api/teams/[team]/profile.ts
+++ b/pages/api/teams/[team]/profile.ts
@@ -1,11 +1,12 @@
import type { NextApiRequest, NextApiResponse } from "next";
-import { getSession } from "next-auth/client";
+import { getSession } from "@lib/auth";
import prisma from "@lib/prisma";
+// @deprecated - USE TRPC
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
- const session = await getSession({ req: req });
- if (!session) {
+ const session = await getSession({ req });
+ if (!session?.user?.id) {
return res.status(401).json({ message: "Not authenticated" });
}
diff --git a/pages/api/upgrade.ts b/pages/api/upgrade.ts
new file mode 100644
index 00000000..bcfe02a3
--- /dev/null
+++ b/pages/api/upgrade.ts
@@ -0,0 +1,52 @@
+import { Prisma } from "@prisma/client";
+import type { NextApiRequest, NextApiResponse } from "next";
+
+import { getSession } from "@lib/auth";
+import { WEBSITE_URL } from "@lib/config/constants";
+import { HttpError as HttpCode } from "@lib/core/http/error";
+import prisma from "@lib/prisma";
+
+export default async function handler(req: NextApiRequest, res: NextApiResponse) {
+ const session = await getSession({ req });
+ if (!session?.user?.id) {
+ return res.status(401).json({ message: "Not authenticated" });
+ }
+
+ if (!["GET", "POST"].includes(req.method!)) {
+ throw new HttpCode({ statusCode: 405, message: "Method Not Allowed" });
+ }
+
+ const user = await prisma.user.findUnique({
+ rejectOnNotFound: true,
+ where: {
+ id: session.user.id,
+ },
+ select: {
+ email: true,
+ metadata: true,
+ },
+ });
+
+ try {
+ const response = await fetch(`${WEBSITE_URL}/api/upgrade`, {
+ method: "POST",
+ credentials: "include",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ stripeCustomerId: (user.metadata as Prisma.JsonObject)?.stripeCustomerId,
+ email: user.email,
+ fromApp: true,
+ }),
+ });
+ const data = await response.json();
+
+ if (!data.url) throw new HttpCode({ statusCode: 401, message: data.message });
+
+ res.redirect(303, data.url);
+ } catch (error) {
+ console.error(`error`, error);
+ res.redirect(303, req.headers.origin || "/");
+ }
+}
diff --git a/pages/api/user/[id].ts b/pages/api/user/[id].ts
index 2e92d641..252f9279 100644
--- a/pages/api/user/[id].ts
+++ b/pages/api/user/[id].ts
@@ -5,18 +5,19 @@ import { getSession } from "@lib/auth";
import prisma from "@lib/prisma";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
- const session = await getSession({ req: req });
+ const session = await getSession({ req });
- if (!session) {
+ if (!session?.user.id) {
return res.status(401).json({ message: "Not authenticated" });
}
const userIdQuery = req.query?.id ?? null;
- const userId = Array.isArray(userIdQuery) ? parseInt(userIdQuery.pop()) : parseInt(userIdQuery);
+ const userId = Array.isArray(userIdQuery) ? parseInt(userIdQuery.pop() || "") : parseInt(userIdQuery);
const authenticatedUser = await prisma.user.findFirst({
+ rejectOnNotFound: true,
where: {
- email: session.user.email,
+ id: session.user.id,
},
select: {
id: true,
@@ -31,10 +32,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return res.status(405).json({ message: "Method Not Allowed" });
}
- if (req.method === "DELETE") {
- return res.status(405).json({ message: "Method Not Allowed" });
- }
-
if (req.method === "PATCH") {
const updatedUser = await prisma.user.update({
where: {
diff --git a/pages/api/user/avatar.ts b/pages/api/user/avatar.ts
new file mode 100644
index 00000000..1c248413
--- /dev/null
+++ b/pages/api/user/avatar.ts
@@ -0,0 +1,47 @@
+import crypto from "crypto";
+import type { NextApiRequest, NextApiResponse } from "next";
+
+import prisma from "@lib/prisma";
+import { defaultAvatarSrc } from "@lib/profile";
+
+export default async function handler(req: NextApiRequest, res: NextApiResponse) {
+ // const username = req.url?.substring(1, req.url.lastIndexOf("/"));
+ const username = req.query.username as string;
+ const user = await prisma.user.findUnique({
+ where: {
+ username: username,
+ },
+ select: {
+ avatar: true,
+ email: true,
+ },
+ });
+
+ const emailMd5 = crypto
+ .createHash("md5")
+ .update(user?.email as string)
+ .digest("hex");
+ const img = user?.avatar;
+ if (!img) {
+ res.writeHead(302, {
+ Location: defaultAvatarSrc({ md5: emailMd5 }),
+ });
+ res.end();
+ } else if (!img.includes("data:image")) {
+ res.writeHead(302, {
+ Location: img,
+ });
+ res.end();
+ } else {
+ const decoded = img
+ .toString()
+ .replace("data:image/png;base64,", "")
+ .replace("data:image/jpeg;base64,", "");
+ const imageResp = Buffer.from(decoded, "base64");
+ res.writeHead(200, {
+ "Content-Type": "image/png",
+ "Content-Length": imageResp.length,
+ });
+ res.end(imageResp);
+ }
+}
diff --git a/pages/api/user/me.ts b/pages/api/user/me.ts
new file mode 100644
index 00000000..376f7d2c
--- /dev/null
+++ b/pages/api/user/me.ts
@@ -0,0 +1,42 @@
+import type { NextApiRequest, NextApiResponse } from "next";
+
+import { deleteStripeCustomer } from "@ee/lib/stripe/server";
+
+import { getSession } from "@lib/auth";
+import prisma from "@lib/prisma";
+
+export default async function handler(req: NextApiRequest, res: NextApiResponse) {
+ const session = await getSession({ req });
+
+ if (!session?.user.id) {
+ return res.status(401).json({ message: "Not authenticated" });
+ }
+
+ if (req.method !== "DELETE") {
+ return res.status(405).json({ message: "Method Not Allowed" });
+ }
+
+ if (req.method === "DELETE") {
+ // Get user
+ const user = await prisma.user.findUnique({
+ rejectOnNotFound: true,
+ where: {
+ id: session.user?.id,
+ },
+ select: {
+ email: true,
+ metadata: true,
+ },
+ });
+ // Delete from stripe
+ await deleteStripeCustomer(user).catch(console.warn);
+ // Delete from Cal
+ await prisma.user.delete({
+ where: {
+ id: session?.user.id,
+ },
+ });
+
+ return res.status(204).end();
+ }
+}
diff --git a/pages/api/user/membership.ts b/pages/api/user/membership.ts
index b8990a1c..f523f117 100644
--- a/pages/api/user/membership.ts
+++ b/pages/api/user/membership.ts
@@ -1,12 +1,11 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { getSession } from "@lib/auth";
-
-import prisma from "../../../lib/prisma";
+import prisma from "@lib/prisma";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const session = await getSession({ req: req });
- if (!session) {
+ if (!session || !session.user?.id) {
return res.status(401).json({ message: "Not authenticated" });
}
diff --git a/pages/auth/error.tsx b/pages/auth/error.tsx
index f9a05dd9..0571b3e5 100644
--- a/pages/auth/error.tsx
+++ b/pages/auth/error.tsx
@@ -1,4 +1,5 @@
import { XIcon } from "@heroicons/react/outline";
+import { GetServerSidePropsContext } from "next";
import Link from "next/link";
import { useRouter } from "next/router";
@@ -6,6 +7,8 @@ import { useLocale } from "@lib/hooks/useLocale";
import { HeadSeo } from "@components/seo/head-seo";
+import { ssrInit } from "@server/lib/ssr";
+
export default function Error() {
const { t } = useLocale();
const router = useRouter();
@@ -48,3 +51,13 @@ export default function Error() {
);
}
+
+export async function getServerSideProps(context: GetServerSidePropsContext) {
+ const ssr = await ssrInit(context);
+
+ return {
+ props: {
+ trpcState: ssr.dehydrate(),
+ },
+ };
+}
diff --git a/pages/auth/forgot-password/[id].tsx b/pages/auth/forgot-password/[id].tsx
index bc065850..9cf850cc 100644
--- a/pages/auth/forgot-password/[id].tsx
+++ b/pages/auth/forgot-password/[id].tsx
@@ -2,7 +2,7 @@ import { ResetPasswordRequest } from "@prisma/client";
import dayjs from "dayjs";
import debounce from "lodash/debounce";
import { GetServerSidePropsContext } from "next";
-import { getCsrfToken } from "next-auth/client";
+import { getCsrfToken } from "next-auth/react";
import Link from "next/link";
import React, { useMemo } from "react";
@@ -20,15 +20,12 @@ type Props = {
export default function Page({ resetPasswordRequest, csrfToken }: Props) {
const { t } = useLocale();
const [loading, setLoading] = React.useState(false);
- const [error, setError] = React.useState(null);
+ const [error, setError] = React.useState<{ message: string } | null>(null);
const [success, setSuccess] = React.useState(false);
const [password, setPassword] = React.useState("");
- const handleChange = (e) => {
- setPassword(e.target.value);
- };
- const submitChangePassword = async ({ password, requestId }) => {
+ const submitChangePassword = async ({ password, requestId }: { password: string; requestId: string }) => {
try {
const res = await fetch("/api/auth/reset-password", {
method: "POST",
@@ -56,30 +53,12 @@ export default function Page({ resetPasswordRequest, csrfToken }: Props) {
const debouncedChangePassword = debounce(submitChangePassword, 250);
- const handleSubmit = async (e) => {
- e.preventDefault();
-
- if (!password) {
- return;
- }
-
- if (loading) {
- return;
- }
-
- setLoading(true);
- setError(null);
- setSuccess(false);
-
- await debouncedChangePassword({ password, requestId: resetPasswordRequest.id });
- };
-
const Success = () => {
return (
<>
-
+
{t("success")}
@@ -87,7 +66,7 @@ export default function Page({ resetPasswordRequest, csrfToken }: Props) {
+ className="flex justify-center w-full px-4 py-2 text-sm font-medium text-blue-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black">
{t("login")}
@@ -101,14 +80,14 @@ export default function Page({ resetPasswordRequest, csrfToken }: Props) {
<>
-
{t("whoops")}
- {t("request_is_expired")}
+ {t("whoops")}
+ {t("request_is_expired")}
{t("request_is_expired_instructions")}
+ className="flex justify-center w-full px-4 py-2 text-sm font-medium text-blue-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black">
{t("try_again")}
@@ -123,21 +102,40 @@ export default function Page({ resetPasswordRequest, csrfToken }: Props) {
}, [resetPasswordRequest]);
return (
-
+
-
+
{isRequestExpired &&
}
{!isRequestExpired && !success && (
<>
-
+
{t("reset_password")}
{t("enter_new_password")}
{error &&
{error.message}
}
-
+ {
+ e.preventDefault();
+
+ if (!password) {
+ return;
+ }
+
+ if (loading) {
+ return;
+ }
+
+ setLoading(true);
+ setError(null);
+ setSuccess(false);
+
+ await debouncedChangePassword({ password, requestId: resetPasswordRequest.id });
+ }}
+ action="#">
@@ -165,7 +165,7 @@ export default function Page({ resetPasswordRequest, csrfToken }: Props) {
}`}>
{loading && (
@@ -200,12 +200,13 @@ export default function Page({ resetPasswordRequest, csrfToken }: Props) {
}
export async function getServerSideProps(context: GetServerSidePropsContext) {
- const id = context.params.id;
+ const id = context.params?.id as string;
try {
const resetPasswordRequest = await prisma.resetPasswordRequest.findUnique({
+ rejectOnNotFound: true,
where: {
- id: id,
+ id,
},
select: {
id: true,
diff --git a/pages/auth/forgot-password/index.tsx b/pages/auth/forgot-password/index.tsx
index a17d7bc8..6eadf2e6 100644
--- a/pages/auth/forgot-password/index.tsx
+++ b/pages/auth/forgot-password/index.tsx
@@ -1,13 +1,15 @@
import debounce from "lodash/debounce";
import { GetServerSidePropsContext } from "next";
-import { getCsrfToken } from "next-auth/client";
+import { getCsrfToken } from "next-auth/react";
import Link from "next/link";
import React, { SyntheticEvent } from "react";
import { getSession } from "@lib/auth";
import { useLocale } from "@lib/hooks/useLocale";
-import { HeadSeo } from "@components/seo/head-seo";
+import { EmailField } from "@components/form/fields";
+import AuthContainer from "@components/ui/AuthContainer";
+import Button from "@components/ui/Button";
export default function ForgotPassword({ csrfToken }: { csrfToken: string }) {
const { t, i18n } = useLocale();
@@ -34,6 +36,8 @@ export default function ForgotPassword({ csrfToken }: { csrfToken: string }) {
const json = await res.json();
if (!res.ok) {
setError(json);
+ } else if ("resetLink" in json) {
+ window.location = json.resetLink;
} else {
setSuccess(true);
}
@@ -69,93 +73,56 @@ export default function ForgotPassword({ csrfToken }: { csrfToken: string }) {
const Success = () => {
return (
-
{t("done")}
-
{t("check_email_reset_password")}
- {error &&
{error.message}
}
+
{t("check_email_reset_password")}
+ {error &&
{error.message}
}
);
};
return (
-
-
-
-
- {success &&
}
- {!success && (
- <>
-
-
- {t("forgot_password")}
-
-
{t("reset_instructions")}
- {error &&
{error.message}
}
-
-
-
-
-
- {t("email_address")}
-
-
-
-
-
-
-
-
- {loading && (
-
-
-
-
- )}
- {t("request_password_reset")}
-
-
-
-
-
- {t("login")}
-
-
-
-
- >
- )}
-
-
-
+
+ {t("already_have_an_account")}{" "}
+
+ {t("login_instead")}
+
+ >
+ }>
+ {success && }
+ {!success && (
+ <>
+
+
{t("reset_instructions")}
+ {error &&
{error.message}
}
+
+
+
+
+
+
+ {t("request_password_reset")}
+
+
+
+ >
+ )}
+
);
}
diff --git a/pages/auth/login.tsx b/pages/auth/login.tsx
index 3d77ca84..14d78579 100644
--- a/pages/auth/login.tsx
+++ b/pages/auth/login.tsx
@@ -1,16 +1,32 @@
-import { getCsrfToken, signIn } from "next-auth/client";
+import { GetServerSidePropsContext } from "next";
+import { getCsrfToken, signIn } from "next-auth/react";
import Link from "next/link";
import { useRouter } from "next/router";
import { useState } from "react";
import { ErrorCode, getSession } from "@lib/auth";
+import { WEBSITE_URL } from "@lib/config/constants";
import { useLocale } from "@lib/hooks/useLocale";
+import { isSAMLLoginEnabled, hostedCal, samlTenantID, samlProductID } from "@lib/saml";
+import { trpc } from "@lib/trpc";
+import { inferSSRProps } from "@lib/types/inferSSRProps";
import AddToHomescreen from "@components/AddToHomescreen";
-import Loader from "@components/Loader";
-import { HeadSeo } from "@components/seo/head-seo";
-
-export default function Login({ csrfToken }) {
+import { EmailField, PasswordField, TextField } from "@components/form/fields";
+import AuthContainer from "@components/ui/AuthContainer";
+import Button from "@components/ui/Button";
+
+import { IS_GOOGLE_LOGIN_ENABLED } from "@server/lib/constants";
+import { ssrInit } from "@server/lib/ssr";
+
+export default function Login({
+ csrfToken,
+ isGoogleLoginEnabled,
+ isSAMLLoginEnabled,
+ hostedCal,
+ samlTenantID,
+ samlProductID,
+}: inferSSRProps) {
const { t } = useLocale();
const router = useRouter();
const [email, setEmail] = useState("");
@@ -25,6 +41,7 @@ export default function Login({ csrfToken }) {
[ErrorCode.UserNotFound]: t("no_account_exists"),
[ErrorCode.IncorrectTwoFactorCode]: `${t("incorrect_2fa_code")} ${t("please_try_again")}`,
[ErrorCode.InternalServerError]: `${t("something_went_wrong")} ${t("please_try_again_and_contact_us")}`,
+ [ErrorCode.ThirdPartyIdentityProviderEnabled]: t("account_created_with_identity_provider"),
};
const callbackUrl = typeof router.query?.callbackUrl === "string" ? router.query.callbackUrl : "/";
@@ -40,7 +57,7 @@ export default function Login({ csrfToken }) {
setErrorMessage(null);
try {
- const response = await signIn("credentials", {
+ const response = await signIn<"credentials">("credentials", {
redirect: false,
email,
password,
@@ -70,132 +87,159 @@ export default function Login({ csrfToken }) {
}
}
+ const mutation = trpc.useMutation("viewer.samlTenantProduct", {
+ onSuccess: (data) => {
+ signIn("saml", {}, { tenant: data.tenant, product: data.product });
+ },
+ onError: (err) => {
+ setErrorMessage(err.message);
+ },
+ });
+
return (
-
-
-
- {isSubmitting && (
-
-
-
- )}
-
-
-
-
- {t("sign_in_account")}
-
-
-
-
-
-
-
-
-
- {t("email_address")}
-
-
- setEmail(e.currentTarget.value)}
- className="appearance-none block w-full px-3 py-2 border border-neutral-300 rounded-sm shadow-sm placeholder-gray-400 focus:outline-none focus:ring-neutral-900 focus:border-neutral-900 sm:text-sm"
- />
-
+ <>
+
+ {t("dont_have_an_account")} {/* replace this with your account creation flow */}
+
+ {t("create_an_account")}
+
+ >
+ }>
+
+
+
+
+ {t("email_address")}
+
+
+ setEmail(e.currentTarget.value)}
+ />
-
-
-
-
-
- {t("password")}
-
-
-
-
-
- setPassword(e.currentTarget.value)}
- className="appearance-none block w-full px-3 py-2 border border-neutral-300 rounded-sm shadow-sm placeholder-gray-400 focus:outline-none focus:ring-neutral-900 focus:border-neutral-900 sm:text-sm"
- />
-
+
+
+
+
-
- {secondFactorRequired && (
-
-
- {t("2fa_code")}
-
-
- setCode(e.currentTarget.value)}
- className="appearance-none block w-full px-3 py-2 border border-neutral-300 rounded-sm shadow-sm placeholder-gray-400 focus:outline-none focus:ring-neutral-900 focus:border-neutral-900 sm:text-sm"
- />
-
-
- )}
-
-
-
- {t("sign_in")}
-
-
-
- {errorMessage &&
{errorMessage}
}
-
-
-
-
+ setPassword(e.currentTarget.value)}
+ />
+
+
+ {secondFactorRequired && (
+ setCode(e.currentTarget.value)}
+ />
+ )}
+
+
+
+ {t("sign_in")}
+
+
+
+ {errorMessage && {errorMessage}
}
+
+ {isGoogleLoginEnabled && (
+
+ await signIn("google")}>
+ {" "}
+ {t("signin_with_google")}
+
+
+ )}
+ {isSAMLLoginEnabled && (
+
+ {
+ event.preventDefault();
+
+ if (!hostedCal) {
+ await signIn("saml", {}, { tenant: samlTenantID, product: samlProductID });
+ } else {
+ if (email.length === 0) {
+ setErrorMessage(t("saml_email_required"));
+ return;
+ }
+
+ // hosted solution, fetch tenant and product from the backend
+ mutation.mutate({
+ email,
+ });
+ }
+ }}>
+ {t("signin_with_saml")}
+
+
+ )}
+
-
+ >
);
}
-Login.getInitialProps = async (context) => {
- const { req, res } = context;
+export async function getServerSideProps(context: GetServerSidePropsContext) {
+ const { req } = context;
const session = await getSession({ req });
+ const ssr = await ssrInit(context);
if (session) {
- res.writeHead(302, { Location: "/" });
- res.end();
- return;
+ return {
+ redirect: {
+ destination: "/",
+ permanent: false,
+ },
+ };
}
return {
- csrfToken: await getCsrfToken(context),
+ props: {
+ csrfToken: await getCsrfToken(context),
+ trpcState: ssr.dehydrate(),
+ isGoogleLoginEnabled: IS_GOOGLE_LOGIN_ENABLED,
+ isSAMLLoginEnabled,
+ hostedCal,
+ samlTenantID,
+ samlProductID,
+ },
};
-};
+}
diff --git a/pages/auth/logout.tsx b/pages/auth/logout.tsx
index 85663996..e8d2bcc8 100644
--- a/pages/auth/logout.tsx
+++ b/pages/auth/logout.tsx
@@ -1,47 +1,57 @@
import { CheckIcon } from "@heroicons/react/outline";
+import { GetServerSidePropsContext } from "next";
import Link from "next/link";
+import { useRouter } from "next/router";
+import { useEffect } from "react";
import { useLocale } from "@lib/hooks/useLocale";
+import { inferSSRProps } from "@lib/types/inferSSRProps";
-import { HeadSeo } from "@components/seo/head-seo";
+import AuthContainer from "@components/ui/AuthContainer";
+import Button from "@components/ui/Button";
-export default function Logout() {
+import { ssrInit } from "@server/lib/ssr";
+
+type Props = inferSSRProps
;
+
+export default function Logout(props: Props) {
+ const router = useRouter();
+ useEffect(() => {
+ if (props.query?.survey === "true") {
+ router.push("https://cal.com/cancellation");
+ }
+ }, []);
const { t } = useLocale();
return (
-
-
-
-
-
-
-
-
-
-
-
-
-
- {t("youve_been_logged_out")}
-
-
-
{t("hope_to_see_you_soon")}
-
-
-
-
+
+
{t("go_back_login")}
+
+
);
}
+
+export async function getServerSideProps(context: GetServerSidePropsContext) {
+ const ssr = await ssrInit(context);
+
+ return {
+ props: {
+ trpcState: ssr.dehydrate(),
+ query: context.query,
+ },
+ };
+}
diff --git a/pages/auth/signup.tsx b/pages/auth/signup.tsx
index ab40ead5..7dc1b3f1 100644
--- a/pages/auth/signup.tsx
+++ b/pages/auth/signup.tsx
@@ -1,43 +1,54 @@
-import { signIn } from "next-auth/client";
+import { GetServerSidePropsContext } from "next";
+import { signIn } from "next-auth/react";
import { useRouter } from "next/router";
-import { useState } from "react";
+import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
+import { asStringOrNull } from "@lib/asStringOrNull";
import { useLocale } from "@lib/hooks/useLocale";
import prisma from "@lib/prisma";
+import { isSAMLLoginEnabled } from "@lib/saml";
+import { inferSSRProps } from "@lib/types/inferSSRProps";
+import { EmailField, PasswordField, TextField } from "@components/form/fields";
import { HeadSeo } from "@components/seo/head-seo";
-import { UsernameInput } from "@components/ui/UsernameInput";
-import ErrorAlert from "@components/ui/alerts/Error";
+import { Alert } from "@components/ui/Alert";
+import Button from "@components/ui/Button";
-export default function Signup(props) {
+import { IS_GOOGLE_LOGIN_ENABLED } from "@server/lib/constants";
+import { ssrInit } from "@server/lib/ssr";
+
+type Props = inferSSRProps
;
+
+type FormValues = {
+ username: string;
+ email: string;
+ password: string;
+ passwordcheck: string;
+ apiError: string;
+};
+
+export default function Signup({ email }: Props) {
const { t } = useLocale();
const router = useRouter();
+ const methods = useForm();
+ const {
+ register,
+ formState: { errors, isSubmitting },
+ } = methods;
- const [hasErrors, setHasErrors] = useState(false);
- const [errorMessage, setErrorMessage] = useState("");
+ methods.setValue("email", email);
- const handleErrors = async (resp) => {
+ const handleErrors = async (resp: Response) => {
if (!resp.ok) {
const err = await resp.json();
throw new Error(err.message);
}
};
- const signUp = (e) => {
- e.preventDefault();
-
- if (e.target.password.value !== e.target.passwordcheck.value) {
- throw new Error("Password mismatch");
- }
-
- const email: string = e.target.email.value;
- const password: string = e.target.password.value;
-
- fetch("/api/auth/signup", {
+ const signUp: SubmitHandler = async (data) => {
+ await fetch("/api/auth/signup", {
body: JSON.stringify({
- username: e.target.username.value,
- password,
- email,
+ ...data,
}),
headers: {
"Content-Type": "application/json",
@@ -45,104 +56,97 @@ export default function Signup(props) {
method: "POST",
})
.then(handleErrors)
- .then(() => signIn("Cal.com", { callbackUrl: (router.query.callbackUrl || "") as string }))
+ .then(async () => await signIn("Cal.com", { callbackUrl: (router.query.callbackUrl || "") as string }))
.catch((err) => {
- setHasErrors(true);
- setErrorMessage(err.message);
+ methods.setError("apiError", { message: err.message });
});
};
return (
-
+
{t("create_your_account")}
-
-
- {hasErrors && }
-
-
-
-
-
-
- {t("email")}
-
-
-
-
-
- {t("password")}
-
-
+ {/* TODO: Refactor as soon as /availability is live */}
+
+
+ {errors.apiError && }
+
+
+ {process.env.NEXT_PUBLIC_APP_URL}/
+
+ }
+ labelProps={{ className: "block text-sm font-medium text-gray-700" }}
+ className="flex-grow block w-full min-w-0 lowercase border-gray-300 rounded-none rounded-r-sm focus:ring-black focus:border-black sm:text-sm"
+ {...register("username")}
required
- placeholder="•••••••••••••"
- className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-black focus:border-black sm:text-sm"
/>
-
-
-
- {t("confirm_password")}
-
-
+
+
+ value === methods.watch("password") || (t("error_password_mismatch") as string),
+ })}
+ className="block w-full px-3 py-2 mt-1 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-black focus:border-black sm:text-sm"
+ />
+
+
+
+ {t("create_account")}
+
+
+ signIn("Cal.com", { callbackUrl: (router.query.callbackUrl || "") as string })
+ }>
+ {t("login_instead")}
+
-
-
-
+
+
);
}
-export async function getServerSideProps(ctx) {
- if (!ctx.query.token) {
+export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
+ const ssr = await ssrInit(ctx);
+ const token = asStringOrNull(ctx.query.token);
+ if (!token) {
return {
notFound: true,
};
}
const verificationRequest = await prisma.verificationRequest.findUnique({
where: {
- token: ctx.query.token,
+ token,
},
});
@@ -170,9 +174,19 @@ export async function getServerSideProps(ctx) {
if (existingUser) {
return {
- redirect: { permanent: false, destination: "/auth/login?callbackUrl=" + ctx.query.callbackUrl },
+ redirect: {
+ permanent: false,
+ destination: "/auth/login?callbackUrl=" + ctx.query.callbackUrl,
+ },
};
}
- return { props: { email: verificationRequest.identifier } };
-}
+ return {
+ props: {
+ isGoogleLoginEnabled: IS_GOOGLE_LOGIN_ENABLED,
+ isSAMLLoginEnabled,
+ email: verificationRequest.identifier,
+ trpcState: ssr.dehydrate(),
+ },
+ };
+};
diff --git a/pages/auth/sso/[provider].tsx b/pages/auth/sso/[provider].tsx
new file mode 100644
index 00000000..e64405cf
--- /dev/null
+++ b/pages/auth/sso/[provider].tsx
@@ -0,0 +1,84 @@
+import { GetServerSidePropsContext } from "next";
+import { signIn } from "next-auth/react";
+import { useRouter } from "next/router";
+
+import { asStringOrNull } from "@lib/asStringOrNull";
+import prisma from "@lib/prisma";
+import { isSAMLLoginEnabled, hostedCal, samlTenantID, samlProductID, samlTenantProduct } from "@lib/saml";
+import { inferSSRProps } from "@lib/types/inferSSRProps";
+
+export type SSOProviderPageProps = inferSSRProps
;
+
+export default function Type(props: SSOProviderPageProps) {
+ const router = useRouter();
+
+ if (props.provider === "saml") {
+ const email = typeof router.query?.email === "string" ? router.query?.email : null;
+
+ if (!email) {
+ router.push("/auth/error?error=" + "Email not provided");
+ return null;
+ }
+
+ if (!props.isSAMLLoginEnabled) {
+ router.push("/auth/error?error=" + "SAML login not enabled");
+ return null;
+ }
+
+ signIn("saml", {}, { tenant: props.tenant, product: props.product });
+ } else {
+ signIn(props.provider);
+ }
+
+ return null;
+}
+
+export const getServerSideProps = async (context: GetServerSidePropsContext) => {
+ // get query params and typecast them to string
+ // (would be even better to assert them instead of typecasting)
+ const providerParam = asStringOrNull(context.query.provider);
+ const emailParam = asStringOrNull(context.query.email);
+
+ if (!providerParam) {
+ throw new Error(`File is not named sso/[provider]`);
+ }
+
+ let error: string | null = null;
+
+ let tenant = samlTenantID;
+ let product = samlProductID;
+
+ if (providerParam === "saml") {
+ if (!emailParam) {
+ error = "Email not provided";
+ } else {
+ try {
+ const ret = await samlTenantProduct(prisma, emailParam);
+ tenant = ret.tenant;
+ product = ret.product;
+ } catch (e: any) {
+ error = e.message;
+ }
+ }
+ }
+
+ if (error) {
+ return {
+ redirect: {
+ destination: "/auth/error?error=" + error,
+ permanent: false,
+ },
+ };
+ }
+
+ return {
+ props: {
+ provider: providerParam,
+ isSAMLLoginEnabled,
+ hostedCal,
+ tenant,
+ product,
+ error,
+ },
+ };
+};
diff --git a/pages/availability/index.tsx b/pages/availability/index.tsx
index f73d24c8..de8cdf01 100644
--- a/pages/availability/index.tsx
+++ b/pages/availability/index.tsx
@@ -1,245 +1,89 @@
-import { ClockIcon } from "@heroicons/react/outline";
-import Link from "next/link";
-import { useRouter } from "next/router";
-import { useEffect } from "react";
import { useForm } from "react-hook-form";
+import { QueryCell } from "@lib/QueryCell";
+import { DEFAULT_SCHEDULE } from "@lib/availability";
import { useLocale } from "@lib/hooks/useLocale";
-import { useToggleQuery } from "@lib/hooks/useToggleQuery";
import showToast from "@lib/notification";
-import { trpc } from "@lib/trpc";
+import { inferQueryOutput, trpc } from "@lib/trpc";
+import { Schedule as ScheduleType } from "@lib/types/schedule";
-import { Dialog, DialogContent } from "@components/Dialog";
-import Loader from "@components/Loader";
import Shell from "@components/Shell";
-import { Alert } from "@components/ui/Alert";
+import { Form } from "@components/form/fields";
import Button from "@components/ui/Button";
+import Schedule from "@components/ui/form/Schedule";
-function convertMinsToHrsMins(mins: number) {
- const h = Math.floor(mins / 60);
- const m = mins % 60;
- const hours = h < 10 ? "0" + h : h;
- const minutes = m < 10 ? "0" + m : m;
- return `${hours}:${minutes}`;
-}
-export default function Availability() {
+type FormValues = {
+ schedule: ScheduleType;
+};
+
+export function AvailabilityForm(props: inferQueryOutput<"viewer.availability">) {
const { t } = useLocale();
- const queryMe = trpc.useQuery(["viewer.me"]);
- const formModal = useToggleQuery("edit");
- const formMethods = useForm<{
- startHours: string;
- startMins: string;
- endHours: string;
- endMins: string;
- bufferHours: string;
- bufferMins: string;
- }>({});
- const router = useRouter();
+ const createSchedule = async ({ schedule }: FormValues) => {
+ const res = await fetch(`/api/schedule`, {
+ method: "POST",
+ body: JSON.stringify({ schedule, timeZone: props.timeZone }),
+ headers: {
+ "Content-Type": "application/json",
+ },
+ });
- useEffect(() => {
- /**
- * This hook populates the form with new values as soon as the user is loaded or changes
- */
- const user = queryMe.data;
- if (formMethods.formState.isDirty || !user) {
- return;
+ if (!res.ok) {
+ throw new Error((await res.json()).message);
}
- formMethods.reset({
- startHours: convertMinsToHrsMins(user.startTime).split(":")[0],
- startMins: convertMinsToHrsMins(user.startTime).split(":")[1],
- endHours: convertMinsToHrsMins(user.endTime).split(":")[0],
- endMins: convertMinsToHrsMins(user.endTime).split(":")[1],
- bufferHours: convertMinsToHrsMins(user.bufferTime).split(":")[0],
- bufferMins: convertMinsToHrsMins(user.bufferTime).split(":")[1],
- });
- }, [formMethods, queryMe.data]);
+ const responseData = await res.json();
+ showToast(t("availability_updated_successfully"), "success");
+ return responseData.data;
+ };
- if (queryMe.status === "loading") {
- return ;
- }
- if (queryMe.status !== "success") {
- return ;
- }
- const user = queryMe.data;
+ const form = useForm({
+ defaultValues: {
+ schedule: props.schedule || DEFAULT_SCHEDULE,
+ },
+ });
return (
-
-
-
-
-
-
{t("change_start_end")}
-
-
- {t("current_start_date")} {convertMinsToHrsMins(user.startTime)} {t("and_end_at")}{" "}
- {convertMinsToHrsMins(user.endTime)}.
-
-
-
- {t("change_available_times")}
-
-
+
+
{
+ await createSchedule(values);
+ }}
+ className="col-span-3 space-y-2 lg:col-span-2">
+
+
{t("change_start_end")}
+
+
+
+ {t("save")}
+
+
+
+
+
+ {t("something_doesnt_look_right")}
+
+
+
{t("troubleshoot_availability")}
-
-
-
-
- {t("something_doesnt_look_right")}
-
-
-
{t("troubleshoot_availability")}
-
-
-
+
+
+ {t("launch_troubleshooter")}
+
+
+
+ );
+}
-
{
- router.push(isOpen ? formModal.hrefOn : formModal.hrefOff);
- }}>
-
-
-
-
-
-
-
- {t("change_your_available_times")}
-
-
-
{t("change_start_end_buffer")}
-
-
-
- {
- const startMins = parseInt(values.startHours) * 60 + parseInt(values.startMins);
- const endMins = parseInt(values.endHours) * 60 + parseInt(values.endMins);
- const bufferMins = parseInt(values.bufferHours) * 60 + parseInt(values.bufferMins);
-
- // TODO: Add validation
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
- const response = await fetch("/api/availability/day", {
- method: "PATCH",
- body: JSON.stringify({ start: startMins, end: endMins, buffer: bufferMins }),
- headers: {
- "Content-Type": "application/json",
- },
- });
- if (!response.ok) {
- showToast(t("something_went_wrong"), "error");
- return;
- }
- await queryMe.refetch();
- router.push(formModal.hrefOff);
-
- showToast(t("start_end_changed_successfully"), "success");
- })}>
-
-
- {t("start_time")}
-
-
-
- {t("hours")}
-
-
-
-
:
-
-
- {t("minutes")}
-
-
-
-
-
-
{t("end_time")}
-
-
- {t("hours")}
-
-
-
-
:
-
-
- {t("minutes")}
-
-
-
-
-
-
{t("buffer")}
-
-
- {t("hours")}
-
-
-
-
:
-
-
- {t("minutes")}
-
-
-
-
-
-
- {t("cancel")}
-
-
- {t("update")}
-
-
-
-
-
+export default function Availability() {
+ const { t } = useLocale();
+ const query = trpc.useQuery(["viewer.availability"]);
+ return (
+
);
diff --git a/pages/availability/troubleshoot.tsx b/pages/availability/troubleshoot.tsx
index d6b46a00..4063ed5e 100644
--- a/pages/availability/troubleshoot.tsx
+++ b/pages/availability/troubleshoot.tsx
@@ -51,12 +51,12 @@ const AvailabilityView = ({ user }: { user: User }) => {
}, [selectedDate]);
return (
-
+
{t("overview_of_day")}{" "}
{
setSelectedDate(dayjs(e.target.value));
@@ -64,8 +64,8 @@ const AvailabilityView = ({ user }: { user: User }) => {
/>
{t("hover_over_bold_times_tip")}
-
-
+
+
{t("your_day_starts_at")} {convertMinsToHrsMins(user.startTime)}
@@ -73,8 +73,8 @@ const AvailabilityView = ({ user }: { user: User }) => {
) : availability.length > 0 ? (
availability.map((slot) => (
-
-
+
+
{t("calendar_shows_busy_between")}{" "}
{dayjs(slot.start).format("HH:mm")}
@@ -89,13 +89,13 @@ const AvailabilityView = ({ user }: { user: User }) => {
))
) : (
-
-
{t("calendar_no_busy_slots")}
+
+
{t("calendar_no_busy_slots")}
)}
-
-
+
+
{t("your_day_ends_at")} {convertMinsToHrsMins(user.endTime)}
diff --git a/pages/bookings/[status].tsx b/pages/bookings/[status].tsx
index fd237910..ece1227d 100644
--- a/pages/bookings/[status].tsx
+++ b/pages/bookings/[status].tsx
@@ -80,9 +80,9 @@ export default function Bookings() {
{query.status === "success" && isEmpty && (
diff --git a/pages/bookings/index.tsx b/pages/bookings/index.tsx
index f63871c1..3dcf15eb 100644
--- a/pages/bookings/index.tsx
+++ b/pages/bookings/index.tsx
@@ -1,10 +1,12 @@
+import { NextPageContext } from "next";
+
import { getSession } from "@lib/auth";
function RedirectPage() {
return null;
}
-export async function getServerSideProps(context) {
+export async function getServerSideProps(context: NextPageContext) {
const session = await getSession(context);
if (!session?.user?.id) {
return { redirect: { permanent: false, destination: "/auth/login" } };
diff --git a/pages/call/[uid].tsx b/pages/call/[uid].tsx
index 02cd58d5..7d44700a 100644
--- a/pages/call/[uid].tsx
+++ b/pages/call/[uid].tsx
@@ -1,15 +1,20 @@
import DailyIframe from "@daily-co/daily-js";
-import { getSession } from "next-auth/client";
+import { NextPageContext } from "next";
+import { getSession } from "next-auth/react";
import Head from "next/head";
import Link from "next/link";
import { useRouter } from "next/router";
import React, { useEffect } from "react";
+import prisma from "@lib/prisma";
+import { inferSSRProps } from "@lib/types/inferSSRProps";
+
import { HeadSeo } from "@components/seo/head-seo";
-import prisma from "../../lib/prisma";
+export type JoinCallPageProps = inferSSRProps
;
-export default function JoinCall(props, session) {
+export default function JoinCall(props: JoinCallPageProps) {
+ const session = props.session;
const router = useRouter();
//if no booking redirectis to the 404 page
@@ -23,8 +28,8 @@ export default function JoinCall(props, session) {
console.log(enterDate);
//find out if the meeting is upcoming or in the past
- const isPast = new Date(props.booking.endTime) <= exitDate;
- const isUpcoming = new Date(props.booking.startTime) >= enterDate;
+ const isPast = new Date(props.booking?.endTime || "") <= exitDate;
+ const isUpcoming = new Date(props.booking?.startTime || "") >= enterDate;
const meetingUnavailable = isUpcoming == true || isPast == true;
useEffect(() => {
@@ -33,16 +38,16 @@ export default function JoinCall(props, session) {
}
if (isUpcoming) {
- router.push(`/call/meeting-not-started/${props.booking.uid}`);
+ router.push(`/call/meeting-not-started/${props.booking?.uid}`);
}
if (isPast) {
- router.push(`/call/meeting-ended/${props.booking.uid}`);
+ router.push(`/call/meeting-ended/${props.booking?.uid}`);
}
});
useEffect(() => {
- if (!meetingUnavailable && !emptyBooking && session.userid !== props.booking.user.id) {
+ if (!meetingUnavailable && !emptyBooking && session?.userid !== props.booking.user?.id) {
const callFrame = DailyIframe.createFrame({
theme: {
colors: {
@@ -51,7 +56,7 @@ export default function JoinCall(props, session) {
background: "#111111",
backgroundAccent: "#111111",
baseText: "#FFF",
- border: "#000000",
+ border: "#292929",
mainAreaBg: "#111111",
mainAreaBgAccent: "#111111",
mainAreaText: "#FFF",
@@ -66,11 +71,11 @@ export default function JoinCall(props, session) {
},
});
callFrame.join({
- url: props.booking.dailyRef.dailyurl,
+ url: props.booking.dailyRef?.dailyurl,
showLeaveButton: true,
});
}
- if (!meetingUnavailable && !emptyBooking && session.userid === props.booking.user.id) {
+ if (!meetingUnavailable && !emptyBooking && session?.userid === props.booking.user?.id) {
const callFrame = DailyIframe.createFrame({
theme: {
colors: {
@@ -79,7 +84,7 @@ export default function JoinCall(props, session) {
background: "#111111",
backgroundAccent: "#111111",
baseText: "#FFF",
- border: "#000000",
+ border: "#292929",
mainAreaBg: "#111111",
mainAreaBgAccent: "#111111",
mainAreaText: "#FFF",
@@ -94,9 +99,9 @@ export default function JoinCall(props, session) {
},
});
callFrame.join({
- url: props.booking.dailyRef.dailyurl,
+ url: props.booking.dailyRef?.dailyurl,
showLeaveButton: true,
- token: props.booking.dailyRef.dailytoken,
+ token: props.booking.dailyRef?.dailytoken,
});
}
}, []);
@@ -128,10 +133,10 @@ export default function JoinCall(props, session) {
);
}
-export async function getServerSideProps(context) {
+export async function getServerSideProps(context: NextPageContext) {
const booking = await prisma.booking.findUnique({
where: {
- uid: context.query.uid,
+ uid: context.query.uid as string,
},
select: {
uid: true,
@@ -142,6 +147,7 @@ export async function getServerSideProps(context) {
endTime: true,
user: {
select: {
+ id: true,
credentials: true,
},
},
diff --git a/pages/call/meeting-ended/[uid].tsx b/pages/call/meeting-ended/[uid].tsx
index d7c35c95..b9a570ab 100644
--- a/pages/call/meeting-ended/[uid].tsx
+++ b/pages/call/meeting-ended/[uid].tsx
@@ -1,17 +1,19 @@
import { CalendarIcon, XIcon } from "@heroicons/react/outline";
import { ArrowRightIcon } from "@heroicons/react/solid";
import dayjs from "dayjs";
-import { getSession } from "next-auth/client";
+import { NextPageContext } from "next";
+import { getSession } from "next-auth/react";
import { useRouter } from "next/router";
import { useState } from "react";
import { useEffect } from "react";
import prisma from "@lib/prisma";
+import { inferSSRProps } from "@lib/types/inferSSRProps";
import { HeadSeo } from "@components/seo/head-seo";
import Button from "@components/ui/Button";
-export default function MeetingUnavailable(props) {
+export default function MeetingUnavailable(props: inferSSRProps) {
const router = useRouter();
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@@ -79,10 +81,10 @@ export default function MeetingUnavailable(props) {
return null;
}
-export async function getServerSideProps(context) {
+export async function getServerSideProps(context: NextPageContext) {
const booking = await prisma.booking.findUnique({
where: {
- uid: context.query.uid,
+ uid: context.query.uid as string,
},
select: {
uid: true,
diff --git a/pages/call/meeting-not-started/[uid].tsx b/pages/call/meeting-not-started/[uid].tsx
index c2d1aceb..f3807660 100644
--- a/pages/call/meeting-not-started/[uid].tsx
+++ b/pages/call/meeting-not-started/[uid].tsx
@@ -1,17 +1,19 @@
import { CalendarIcon, XIcon } from "@heroicons/react/outline";
import { ArrowRightIcon } from "@heroicons/react/solid";
import dayjs from "dayjs";
-import { getSession } from "next-auth/client";
+import { NextPageContext } from "next";
+import { getSession } from "next-auth/react";
import { useRouter } from "next/router";
import { useState } from "react";
import { useEffect } from "react";
import prisma from "@lib/prisma";
+import { inferSSRProps } from "@lib/types/inferSSRProps";
import { HeadSeo } from "@components/seo/head-seo";
import Button from "@components/ui/Button";
-export default function MeetingUnavailable(props) {
+export default function MeetingNotStarted(props: inferSSRProps) {
const router = useRouter();
//if no booking redirectis to the 404 page
@@ -83,10 +85,10 @@ export default function MeetingUnavailable(props) {
return null;
}
-export async function getServerSideProps(context) {
+export async function getServerSideProps(context: NextPageContext) {
const booking = await prisma.booking.findUnique({
where: {
- uid: context.query.uid,
+ uid: context.query.uid as string,
},
select: {
uid: true,
diff --git a/pages/cancel/[uid].tsx b/pages/cancel/[uid].tsx
index 870bec90..0d2d89f3 100644
--- a/pages/cancel/[uid].tsx
+++ b/pages/cancel/[uid].tsx
@@ -1,69 +1,41 @@
import { CalendarIcon, XIcon } from "@heroicons/react/solid";
import dayjs from "dayjs";
-import utc from "dayjs/plugin/utc";
-import { getSession } from "next-auth/client";
+import { GetServerSidePropsContext } from "next";
import { useRouter } from "next/router";
import { useState } from "react";
+import { asStringOrUndefined } from "@lib/asStringOrNull";
+import { getSession } from "@lib/auth";
import { useLocale } from "@lib/hooks/useLocale";
import prisma from "@lib/prisma";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
+import { inferSSRProps } from "@lib/types/inferSSRProps";
+import CustomBranding from "@components/CustomBranding";
+import { TextField } from "@components/form/fields";
import { HeadSeo } from "@components/seo/head-seo";
import { Button } from "@components/ui/Button";
-dayjs.extend(utc);
+import { ssrInit } from "@server/lib/ssr";
-export default function Type(props) {
+export default function Type(props: inferSSRProps) {
const { t } = useLocale();
// Get router variables
const router = useRouter();
const { uid } = router.query;
-
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
- const [is24h, setIs24h] = useState(false);
+ const [is24h] = useState(false);
const [loading, setLoading] = useState(false);
- const [error, setError] = useState(props.booking ? null : t("booking_already_cancelled"));
+ const [error, setError] = useState(props.booking ? null : t("booking_already_cancelled"));
+ const [cancellationReason, setCancellationReason] = useState