diff --git a/components/Shell.tsx b/components/Shell.tsx index c13468bc..59cb8cf3 100644 --- a/components/Shell.tsx +++ b/components/Shell.tsx @@ -1,152 +1,273 @@ -import Link from 'next/link'; -import {useEffect, useState} from "react"; -import {useRouter} from "next/router"; -import {signOut, useSession} from 'next-auth/client'; -import {MenuIcon, XIcon} from '@heroicons/react/outline'; -import {collectPageParameters, telemetryEventTypes, useTelemetry} from "../lib/telemetry"; +import Link from "next/link"; +import { useEffect, useState } from "react"; +import { useRouter } from "next/router"; +import { signOut, useSession } from "next-auth/client"; +import { MenuIcon, XIcon } from "@heroicons/react/outline"; +import { collectPageParameters, telemetryEventTypes, useTelemetry } from "../lib/telemetry"; export default function Shell(props) { - const router = useRouter(); - const [ session, loading ] = useSession(); - const [ profileDropdownExpanded, setProfileDropdownExpanded ] = useState(false); - const [ mobileMenuExpanded, setMobileMenuExpanded ] = useState(false); - let telemetry = useTelemetry(); + const router = useRouter(); + const [session, loading] = useSession(); + const [profileDropdownExpanded, setProfileDropdownExpanded] = useState(false); + const [mobileMenuExpanded, setMobileMenuExpanded] = useState(false); + const telemetry = useTelemetry(); - useEffect(() => { - telemetry.withJitsu((jitsu) => { - return jitsu.track(telemetryEventTypes.pageView, collectPageParameters(router.pathname)) - }); - }, [telemetry]) + useEffect(() => { + telemetry.withJitsu((jitsu) => { + return jitsu.track(telemetryEventTypes.pageView, collectPageParameters(router.pathname)); + }); + }, [telemetry]); - const toggleProfileDropdown = () => { - setProfileDropdownExpanded(!profileDropdownExpanded); - } + const toggleProfileDropdown = () => { + setProfileDropdownExpanded(!profileDropdownExpanded); + }; - const toggleMobileMenu = () => { - setMobileMenuExpanded(!mobileMenuExpanded); - } + const toggleMobileMenu = () => { + setMobileMenuExpanded(!mobileMenuExpanded); + }; - const logoutHandler = () => { - signOut({ redirect: false }).then( () => router.push('/auth/logout') ); - } + const logoutHandler = () => { + signOut({ redirect: false }).then(() => router.push("/auth/logout")); + }; - if ( ! loading && ! session ) { - router.replace('/auth/login'); - } + if (!loading && !session) { + router.replace("/auth/login"); + } - return session && ( -
-
-
+
+ + + {mobileMenuExpanded && ( +
+
+ + + Dashboard + + + + + Availability + + + + + Integrations + + +
+
+
+
+ +
+
+
+ {session.user.name || session.user.username} +
+
{session.user.email}
+
+
+
+ + + Your Profile + + + + + Settings + + + +
+
+
+ )} + +
+
+

{props.heading}

+
+
+ + +
+
{props.children}
+
+ + ) : null; +} diff --git a/components/booking/DatePicker.tsx b/components/booking/DatePicker.tsx index b58b6c93..ac1a0420 100644 --- a/components/booking/DatePicker.tsx +++ b/components/booking/DatePicker.tsx @@ -11,7 +11,7 @@ const DatePicker = ({ weekStart, onDatePicked, workingHours, disableToday }) => useEffect(() => { if (selectedDate) onDatePicked(selectedDate); - }, [selectedDate, onDatePicked]); + }, [selectedDate]); // Handle month changes const incrementMonth = () => { diff --git a/components/ui/Button.tsx b/components/ui/Button.tsx index 647d633c..054bbd66 100644 --- a/components/ui/Button.tsx +++ b/components/ui/Button.tsx @@ -1,11 +1,10 @@ import { useState } from 'react'; export default function Button(props) { - const [loading, setLoading] = useState(false); return( - ); -} \ No newline at end of file +} diff --git a/components/ui/UsernameInput.tsx b/components/ui/UsernameInput.tsx index 8b1110d0..bc611937 100644 --- a/components/ui/UsernameInput.tsx +++ b/components/ui/UsernameInput.tsx @@ -1,6 +1,6 @@ import React from "react"; -export const UsernameInput = React.forwardRef( (props, ref) => ( +const UsernameInput = React.forwardRef((props, ref) => ( // todo, check if username is already taken here?
-)); \ No newline at end of file +)); + +UsernameInput.displayName = "UsernameInput"; + +export { UsernameInput }; diff --git a/lib/calendarClient.ts b/lib/calendarClient.ts index 791235c5..4d8d7421 100644 --- a/lib/calendarClient.ts +++ b/lib/calendarClient.ts @@ -7,18 +7,51 @@ import EventAttendeeRescheduledMail from "./emails/EventAttendeeRescheduledMail" const translator = short(); +// eslint-disable-next-line @typescript-eslint/no-var-requires const { google } = require("googleapis"); +import prisma from "./prisma"; -const googleAuth = () => { - const { client_secret, client_id, redirect_uris } = JSON.parse( - process.env.GOOGLE_API_CREDENTIALS - ).web; - return new google.auth.OAuth2(client_id, client_secret, redirect_uris[0]); +const googleAuth = (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]); + myGoogleAuth.setCredentials(credential.key); + + const isExpired = () => myGoogleAuth.isTokenExpiring(); + + const refreshAccessToken = () => + myGoogleAuth + .refreshToken(credential.key.refresh_token) + .then((res) => { + const token = res.res.data; + credential.key.access_token = token.access_token; + credential.key.expiry_date = token.expiry_date; + return prisma.credential + .update({ + where: { + id: credential.id, + }, + data: { + key: credential.key, + }, + }) + .then(() => { + myGoogleAuth.setCredentials(credential.key); + return myGoogleAuth; + }); + }) + .catch((err) => { + console.error("Error refreshing google token", err); + return myGoogleAuth; + }); + + return { + getToken: () => (!isExpired() ? Promise.resolve(myGoogleAuth) : refreshAccessToken()), + }; }; function handleErrorsJson(response) { if (!response.ok) { - response.json().then(console.log); + response.json().then((e) => console.error("O365 Error", e)); throw Error(response.statusText); } return response.json(); @@ -26,17 +59,17 @@ function handleErrorsJson(response) { function handleErrorsRaw(response) { if (!response.ok) { - response.text().then(console.log); + response.text().then((e) => console.error("O365 Error", e)); throw Error(response.statusText); } return response.text(); } const o365Auth = (credential) => { - const isExpired = (expiryDate) => expiryDate < +new Date(); + const isExpired = (expiryDate) => expiryDate < Math.round(+new Date() / 1000); - const refreshAccessToken = (refreshToken) => - fetch("https://login.microsoftonline.com/common/oauth2/v2.0/token", { + const refreshAccessToken = (refreshToken) => { + return fetch("https://login.microsoftonline.com/common/oauth2/v2.0/token", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ @@ -50,11 +83,19 @@ const o365Auth = (credential) => { .then(handleErrorsJson) .then((responseBody) => { credential.key.access_token = responseBody.access_token; - credential.key.expiry_date = Math.round( - +new Date() / 1000 + responseBody.expires_in - ); - return credential.key.access_token; + credential.key.expiry_date = Math.round(+new Date() / 1000 + responseBody.expires_in); + return prisma.credential + .update({ + where: { + id: credential.id, + }, + data: { + key: credential.key, + }, + }) + .then(() => credential.key.access_token); }); + }; return { getToken: () => @@ -96,15 +137,11 @@ interface IntegrationCalendar { interface CalendarApiAdapter { createEvent(event: CalendarEvent): Promise; - updateEvent(uid: String, event: CalendarEvent); + updateEvent(uid: string, event: CalendarEvent); - deleteEvent(uid: String); + deleteEvent(uid: string); - getAvailability( - dateFrom, - dateTo, - selectedCalendars: IntegrationCalendar[] - ): Promise; + getAvailability(dateFrom, dateTo, selectedCalendars: IntegrationCalendar[]): Promise; listCalendars(): Promise; } @@ -113,7 +150,7 @@ const MicrosoftOffice365Calendar = (credential): CalendarApiAdapter => { const auth = o365Auth(credential); const translateEvent = (event: CalendarEvent) => { - let optional = {}; + const optional = {}; if (event.location) { optional.location = { displayName: event.location }; } @@ -171,12 +208,7 @@ const MicrosoftOffice365Calendar = (credential): CalendarApiAdapter => { return { getAvailability: (dateFrom, dateTo, selectedCalendars) => { - const filter = - "?$filter=start/dateTime ge '" + - dateFrom + - "' and end/dateTime le '" + - dateTo + - "'"; + const filter = "?$filter=start/dateTime ge '" + dateFrom + "' and end/dateTime le '" + dateTo + "'"; return auth .getToken() .then((accessToken) => { @@ -195,10 +227,7 @@ const MicrosoftOffice365Calendar = (credential): CalendarApiAdapter => { ).then((ids: string[]) => { const urls = ids.map( (calendarId) => - "https://graph.microsoft.com/v1.0/me/calendars/" + - calendarId + - "/events" + - filter + "https://graph.microsoft.com/v1.0/me/calendars/" + calendarId + "/events" + filter ); return Promise.all( urls.map((url) => @@ -217,9 +246,7 @@ const MicrosoftOffice365Calendar = (credential): CalendarApiAdapter => { })) ) ) - ).then((results) => - results.reduce((acc, events) => acc.concat(events), []) - ); + ).then((results) => results.reduce((acc, events) => acc.concat(events), [])); }); }) .catch((err) => { @@ -242,7 +269,7 @@ const MicrosoftOffice365Calendar = (credential): CalendarApiAdapter => { disableConfirmationEmail: true, })) ), - deleteEvent: (uid: String) => + deleteEvent: (uid: string) => auth.getToken().then((accessToken) => fetch("https://graph.microsoft.com/v1.0/me/calendar/events/" + uid, { method: "DELETE", @@ -251,7 +278,7 @@ const MicrosoftOffice365Calendar = (credential): CalendarApiAdapter => { }, }).then(handleErrorsRaw) ), - updateEvent: (uid: String, event: CalendarEvent) => + updateEvent: (uid: string, event: CalendarEvent) => auth.getToken().then((accessToken) => fetch("https://graph.microsoft.com/v1.0/me/calendar/events/" + uid, { method: "PATCH", @@ -267,187 +294,188 @@ const MicrosoftOffice365Calendar = (credential): CalendarApiAdapter => { }; const GoogleCalendar = (credential): CalendarApiAdapter => { - const myGoogleAuth = googleAuth(); - myGoogleAuth.setCredentials(credential.key); + const auth = googleAuth(credential); const integrationType = "google_calendar"; return { getAvailability: (dateFrom, dateTo, selectedCalendars) => - new Promise((resolve, reject) => { - const calendar = google.calendar({ version: "v3", auth: myGoogleAuth }); - calendar.calendarList - .list() - .then((cals) => { - const filteredItems = cals.data.items.filter( - (i) => - selectedCalendars.findIndex((e) => e.externalId === i.id) > -1 - ); - if (filteredItems.length == 0 && selectedCalendars.length > 0) { - // Only calendars of other integrations selected - resolve([]); - } - calendar.freebusy.query( - { - requestBody: { - timeMin: dateFrom, - timeMax: dateTo, - items: - filteredItems.length > 0 ? filteredItems : cals.data.items, + 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)) + : 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); + (err, apires) => { + if (err) { + reject(err); + } + resolve(Object.values(apires.data.calendars).flatMap((item) => item["busy"])); } - - resolve( - Object.values(apires.data.calendars).flatMap( - (item) => item["busy"] - ) - ); - } - ); - }) - .catch((err) => { - reject(err); - }); - }), + ); + }) + .catch((err) => { + console.error("There was an error contacting google calendar service: ", err); + reject(err); + }); + }) + ), createEvent: (event: CalendarEvent) => - new Promise((resolve, reject) => { - const payload = { - 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: 60 }], - }, - }; + new Promise((resolve, reject) => + auth.getToken().then((myGoogleAuth) => { + const payload = { + 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: 60 }], + }, + }; - if (event.location) { - payload["location"] = event.location; - } - - if (event.conferenceData) { - payload["conferenceData"] = event.conferenceData; - } - - const calendar = google.calendar({ version: "v3", auth: myGoogleAuth }); - calendar.events.insert( - { - auth: myGoogleAuth, - calendarId: "primary", - resource: payload, - conferenceDataVersion: 1, - }, - function (err, event) { - if (err) { - console.log( - "There was an error contacting the Calendar service: " + err - ); - return reject(err); - } - return resolve(event.data); + if (event.location) { + payload["location"] = event.location; } - ); - }), - updateEvent: (uid: String, event: CalendarEvent) => - new Promise((resolve, reject) => { - const payload = { - 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: 60 }], - }, - }; - if (event.location) { - payload["location"] = event.location; - } + if (event.conferenceData) { + payload["conferenceData"] = event.conferenceData; + } - const calendar = google.calendar({ version: "v3", auth: myGoogleAuth }); - calendar.events.update( - { - auth: myGoogleAuth, - calendarId: "primary", - eventId: uid, - sendNotifications: true, - sendUpdates: "all", - resource: payload, - }, - function (err, event) { - if (err) { - console.log( - "There was an error contacting the Calendar service: " + err - ); - return reject(err); + const calendar = google.calendar({ version: "v3", auth: myGoogleAuth }); + calendar.events.insert( + { + auth: myGoogleAuth, + calendarId: "primary", + resource: payload, + }, + function (err, event) { + if (err) { + console.error("There was an error contacting google calendar service: ", err); + return reject(err); + } + return resolve(event.data); } - return resolve(event.data); + ); + }) + ), + updateEvent: (uid: string, event: CalendarEvent) => + new Promise((resolve, reject) => + auth.getToken().then((myGoogleAuth) => { + const payload = { + 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: 60 }], + }, + }; + + if (event.location) { + payload["location"] = event.location; } - ); - }), - deleteEvent: (uid: String) => - new Promise((resolve, reject) => { - 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.log( - "There was an error contacting the Calendar service: " + err - ); - return reject(err); + + const calendar = google.calendar({ version: "v3", auth: myGoogleAuth }); + calendar.events.update( + { + auth: myGoogleAuth, + calendarId: "primary", + eventId: uid, + sendNotifications: true, + sendUpdates: "all", + resource: payload, + }, + function (err, event) { + if (err) { + console.error("There was an error contacting google calendar service: ", err); + return reject(err); + } + return resolve(event.data); } - 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) => { - 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, - integration: integrationType, - name: cal.summary, - primary: cal.primary, - }; - return calendar; - }) - ); - }) - .catch((err) => { - reject(err); - }); - }), + 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, + integration: integrationType, + name: cal.summary, + primary: cal.primary, + }; + return calendar; + }) + ); + }) + .catch((err) => { + console.error("There was an error contacting google calendar service: ", err); + reject(err); + }); + }) + ), }; }; @@ -466,43 +494,37 @@ const calendars = (withCredentials): CalendarApiAdapter[] => }) .filter(Boolean); -const getBusyCalendarTimes = ( - withCredentials, - dateFrom, - dateTo, - selectedCalendars -) => +const getBusyCalendarTimes = (withCredentials, dateFrom, dateTo, selectedCalendars) => Promise.all( - calendars(withCredentials).map((c) => - c.getAvailability(dateFrom, dateTo, selectedCalendars) - ) + calendars(withCredentials).map((c) => c.getAvailability(dateFrom, dateTo, selectedCalendars)) ).then((results) => { return results.reduce((acc, availability) => acc.concat(availability), []); }); const listCalendars = (withCredentials) => - Promise.all(calendars(withCredentials).map((c) => c.listCalendars())).then( - (results) => results.reduce((acc, calendars) => acc.concat(calendars), []) + Promise.all(calendars(withCredentials).map((c) => c.listCalendars())).then((results) => + results.reduce((acc, calendars) => acc.concat(calendars), []) ); -const createEvent = async ( - credential, - calEvent: CalendarEvent -): Promise => { - const uid: string = translator.fromUUID( - uuidv5(JSON.stringify(calEvent), uuidv5.URL) - ); +const createEvent = async (credential, calEvent: CalendarEvent): Promise => { + const uid: string = translator.fromUUID(uuidv5(JSON.stringify(calEvent), uuidv5.URL)); - const creationResult = credential - ? await calendars([credential])[0].createEvent(calEvent) - : null; + const creationResult = credential ? await calendars([credential])[0].createEvent(calEvent) : null; const organizerMail = new EventOrganizerMail(calEvent, uid); const attendeeMail = new EventAttendeeMail(calEvent, uid); - await organizerMail.sendEmail(); + try { + await organizerMail.sendEmail(); + } catch (e) { + console.error("organizerMail.sendEmail failed", e); + } if (!creationResult || !creationResult.disableConfirmationEmail) { - await attendeeMail.sendEmail(); + try { + await attendeeMail.sendEmail(); + } catch (e) { + console.error("attendeeMail.sendEmail failed", e); + } } return { @@ -511,14 +533,8 @@ const createEvent = async ( }; }; -const updateEvent = async ( - credential, - uidToUpdate: String, - calEvent: CalendarEvent -): Promise => { - const newUid: string = translator.fromUUID( - uuidv5(JSON.stringify(calEvent), uuidv5.URL) - ); +const updateEvent = async (credential, uidToUpdate: string, calEvent: CalendarEvent): Promise => { + const newUid: string = translator.fromUUID(uuidv5(JSON.stringify(calEvent), uuidv5.URL)); const updateResult = credential ? await calendars([credential])[0].updateEvent(uidToUpdate, calEvent) @@ -526,10 +542,18 @@ const updateEvent = async ( const organizerMail = new EventOrganizerRescheduledMail(calEvent, newUid); const attendeeMail = new EventAttendeeRescheduledMail(calEvent, newUid); - await organizerMail.sendEmail(); + try { + await organizerMail.sendEmail(); + } catch (e) { + console.error("organizerMail.sendEmail failed", e); + } if (!updateResult || !updateResult.disableConfirmationEmail) { - await attendeeMail.sendEmail(); + try { + await attendeeMail.sendEmail(); + } catch (e) { + console.error("attendeeMail.sendEmail failed", e); + } } return { @@ -538,7 +562,7 @@ const updateEvent = async ( }; }; -const deleteEvent = (credential, uid: String): Promise => { +const deleteEvent = (credential, uid: string): Promise => { if (credential) { return calendars([credential])[0].deleteEvent(uid); } diff --git a/lib/emails/EventMail.ts b/lib/emails/EventMail.ts index 2d4d8489..de1c0507 100644 --- a/lib/emails/EventMail.ts +++ b/lib/emails/EventMail.ts @@ -58,7 +58,7 @@ export default abstract class EventMail { * Sends the email to the event attendant and returns a Promise. */ public sendEmail(): Promise { - return new Promise((resolve, reject) => nodemailer.createTransport(this.getMailerOptions().transport).sendMail( + new Promise((resolve, reject) => nodemailer.createTransport(this.getMailerOptions().transport).sendMail( this.getNodeMailerPayload(), (error, info) => { if (error) { @@ -67,7 +67,9 @@ export default abstract class EventMail { } else { resolve(info); } - })); + }) + ).catch((e) => console.error("sendEmail", e)); + return new Promise((resolve) => resolve("send mail async")); } /** @@ -127,9 +129,10 @@ export default abstract class EventMail { protected getAdditionalFooter(): string { return `
- Need to change this event?
+
+ Need to change this event?
Cancel: ${this.getCancelLink()}
Reschedule: ${this.getRescheduleLink()} `; } -} \ No newline at end of file +} diff --git a/lib/emails/buildMessageTemplate.ts b/lib/emails/buildMessageTemplate.ts new file mode 100644 index 00000000..2d3f0696 --- /dev/null +++ b/lib/emails/buildMessageTemplate.ts @@ -0,0 +1,19 @@ +import Handlebars from "handlebars"; + +export const buildMessageTemplate = ({ + messageTemplate, + subjectTemplate, + vars, +}): { subject: string; message: string } => { + 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/sendMail.ts b/lib/emails/sendMail.ts new file mode 100644 index 00000000..917a7308 --- /dev/null +++ b/lib/emails/sendMail.ts @@ -0,0 +1,30 @@ +import { serverConfig } from "../serverConfig"; +import nodemailer, { SentMessageInfo } from "nodemailer"; + +const sendEmail = ({ to, subject, text, html = null }): 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: `Calendso ${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/forgot-password/messaging/forgot-password.ts b/lib/forgot-password/messaging/forgot-password.ts new file mode 100644 index 00000000..fde5350e --- /dev/null +++ b/lib/forgot-password/messaging/forgot-password.ts @@ -0,0 +1,20 @@ +import buildMessageTemplate from "../../emails/buildMessageTemplate"; + +export const forgotPasswordSubjectTemplate = "Forgot your password? - Calendso"; + +export const forgotPasswordMessageTemplate = `Hey there, + +Use the link below to reset your password. +{{link}} + +p.s. It expires in 6 hours. + +- Calendso`; + +export const buildForgotPasswordMessage = (vars) => { + return buildMessageTemplate({ + subjectTemplate: forgotPasswordSubjectTemplate, + messageTemplate: forgotPasswordMessageTemplate, + vars, + }); +}; diff --git a/lib/location.ts b/lib/location.ts index b27f4977..3bd8f71f 100644 --- a/lib/location.ts +++ b/lib/location.ts @@ -1,7 +1,6 @@ - export enum LocationType { - InPerson = 'inPerson', - Phone = 'phone', - GoogleMeet = 'integrations:google:meet' + InPerson = "inPerson", + Phone = "phone", + GoogleMeet = "integrations:google:meet", + Zoom = "integrations:zoom", } - diff --git a/lib/videoClient.ts b/lib/videoClient.ts index b359e83a..0e171ac6 100644 --- a/lib/videoClient.ts +++ b/lib/videoClient.ts @@ -193,10 +193,18 @@ const createMeeting = async (credential, calEvent: CalendarEvent): Promise const organizerMail = new VideoEventOrganizerMail(calEvent, uid, videoCallData); const attendeeMail = new VideoEventAttendeeMail(calEvent, uid, videoCallData); - await organizerMail.sendEmail(); + try { + await organizerMail.sendEmail(); + } catch (e) { + console.error("organizerMail.sendEmail failed", e) + } if (!creationResult || !creationResult.disableConfirmationEmail) { - await attendeeMail.sendEmail(); + try { + await attendeeMail.sendEmail(); + } catch (e) { + console.error("attendeeMail.sendEmail failed", e) + } } return { @@ -216,10 +224,18 @@ const updateMeeting = async (credential, uidToUpdate: String, calEvent: Calendar const organizerMail = new EventOrganizerRescheduledMail(calEvent, newUid); const attendeeMail = new EventAttendeeRescheduledMail(calEvent, newUid); - await organizerMail.sendEmail(); + try { + await organizerMail.sendEmail(); + } catch (e) { + console.error("organizerMail.sendEmail failed", e) + } if (!updateResult || !updateResult.disableConfirmationEmail) { - await attendeeMail.sendEmail(); + try { + await attendeeMail.sendEmail(); + } catch (e) { + console.error("attendeeMail.sendEmail failed", e) + } } return { diff --git a/package.json b/package.json index a81a930b..b0ac1af3 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,9 @@ "bcryptjs": "^2.4.3", "dayjs": "^1.10.4", "googleapis": "^67.1.1", + "handlebars": "^4.7.7", "ics": "^2.27.0", + "lodash.debounce": "^4.0.8", "lodash.merge": "^4.6.2", "next": "^10.2.0", "next-auth": "^3.13.2", @@ -39,6 +41,7 @@ "devDependencies": { "@types/jest": "^26.0.23", "@types/node": "^14.14.33", + "@types/nodemailer": "^6.4.2", "@types/react": "^17.0.3", "@typescript-eslint/eslint-plugin": "^4.27.0", "@typescript-eslint/parser": "^4.27.0", diff --git a/pages/[user].tsx b/pages/[user].tsx index 62499c4f..63bb08ad 100644 --- a/pages/[user].tsx +++ b/pages/[user].tsx @@ -1,92 +1,94 @@ -import Head from 'next/head'; -import Link from 'next/link'; -import prisma from '../lib/prisma'; -import Avatar from '../components/Avatar'; +import { GetServerSideProps } from "next"; +import Head from "next/head"; +import Link from "next/link"; +import prisma from "../lib/prisma"; +import Avatar from "../components/Avatar"; -export default function User(props) { - const eventTypes = props.eventTypes.map(type => -
  • - - -
    -

    {type.title}

    -

    {type.description}

    -
    - -
  • - ); - return ( -
    - - {props.user.name || props.user.username} | Calendso - - +export default function User(props): User { + const eventTypes = props.eventTypes.map((type) => ( +
  • + + +
    +

    {type.title}

    +

    {type.description}

    +
    + +
  • + )); + return ( +
    + + {props.user.name || props.user.username} | Calendso + + -
    -
    - -

    {props.user.name || props.user.username}

    -

    {props.user.bio}

    -
    -
    -
      - {eventTypes} -
    - {eventTypes.length == 0 && -
    -

    Uh oh!

    -

    This user hasn't set up any event types yet.

    -
    - } -
    -
    +
    +
    + +

    + {props.user.name || props.user.username} +

    +

    {props.user.bio}

    - ) +
    +
      {eventTypes}
    + {eventTypes.length == 0 && ( +
    +

    Uh oh!

    +

    This user hasn't set up any event types yet.

    +
    + )} +
    +
    +
    + ); } -export async function getServerSideProps(context) { - const user = await prisma.user.findFirst({ - where: { - username: context.query.user, - }, - select: { - id: true, - username: true, - email:true, - name: true, - bio: true, - avatar: true, - eventTypes: true - } - }); - - if (!user) { - return { - notFound: true, - } - } - - const eventTypes = await prisma.eventType.findMany({ - where: { - userId: user.id, - hidden: false - } - }); +export const getServerSideProps: GetServerSideProps = async (context) => { + const user = await prisma.user.findFirst({ + where: { + username: context.query.user.toLowerCase(), + }, + select: { + id: true, + username: true, + email: true, + name: true, + bio: true, + avatar: true, + eventTypes: true, + }, + }); + if (!user) { return { - props: { - user, - eventTypes - }, - } -} + notFound: true, + }; + } + + const eventTypes = await prisma.eventType.findMany({ + where: { + userId: user.id, + hidden: false, + }, + }); + + return { + props: { + user, + eventTypes, + }, + }; +}; // Auxiliary methods - -export function getRandomColorCode() { - let color = '#'; - for (let idx = 0; idx < 6; idx++) { - color += Math.floor(Math.random() * 10); - } - return color; -} \ No newline at end of file +export function getRandomColorCode(): string { + let color = "#"; + for (let idx = 0; idx < 6; idx++) { + color += Math.floor(Math.random() * 10); + } + return color; +} diff --git a/pages/[user]/[type].tsx b/pages/[user]/[type].tsx index 66f3640e..973c8d78 100644 --- a/pages/[user]/[type].tsx +++ b/pages/[user]/[type].tsx @@ -1,14 +1,10 @@ import { useEffect, useState, useMemo } from "react"; +import { GetServerSideProps } from "next"; import Head from "next/head"; -import prisma from "../../lib/prisma"; -import dayjs, { Dayjs } from "dayjs"; import { ClockIcon, GlobeIcon, ChevronDownIcon } from "@heroicons/react/solid"; -import isSameOrBefore from "dayjs/plugin/isSameOrBefore"; -import utc from "dayjs/plugin/utc"; -import timezone from "dayjs/plugin/timezone"; -dayjs.extend(isSameOrBefore); -dayjs.extend(utc); -dayjs.extend(timezone); +import prisma from "../../lib/prisma"; +import { useRouter } from "next/router"; +import dayjs, { Dayjs } from "dayjs"; import { collectPageParameters, telemetryEventTypes, useTelemetry } from "../../lib/telemetry"; import AvailableTimes from "../../components/booking/AvailableTimes"; @@ -17,10 +13,10 @@ import Avatar from "../../components/Avatar"; import { timeZone } from "../../lib/clock"; import DatePicker from "../../components/booking/DatePicker"; import PoweredByCalendso from "../../components/ui/PoweredByCalendso"; -import { useRouter } from "next/router"; import getSlots from "@lib/slots"; -export default function Type(props) { +export default function Type(props): Type { + // Get router variables const router = useRouter(); const { rescheduleUid } = router.query; @@ -47,10 +43,10 @@ export default function Type(props) { const changeDate = (date: Dayjs) => { telemetry.withJitsu((jitsu) => jitsu.track(telemetryEventTypes.dateSelected, collectPageParameters())); - setSelectedDate(date); + setSelectedDate(date.tz(timeZone())); }; - const handleSelectTimeZone = (selectedTimeZone: string) => { + const handleSelectTimeZone = (selectedTimeZone: string): void => { if (selectedDate) { setSelectedDate(selectedDate.tz(selectedTimeZone)); } @@ -67,7 +63,21 @@ export default function Type(props) { {rescheduleUid && "Reschedule"} {props.eventType.title} | {props.user.name || props.user.username} | Calendso - + + + + + + + + " + props.eventType.description).replace(/'/g, "%27") + ".png?md=1&images=https%3A%2F%2Fcalendso.com%2Fcalendso-logo-white.svg&images=" + encodeURIComponent(props.user.avatar)} /> + + + + + + " + props.eventType.description).replace(/'/g, "%27") + ".png?md=1&images=https%3A%2F%2Fcalendso.com%2Fcalendso-logo-white.svg&images=" + encodeURIComponent(props.user.avatar)} /> +
    -
    - - - - - - ); + +
    + + +
    + + +
    +
    + +
    + + + + + + ); } -export async function getServerSideProps(context) { - const session = await getSession(context); - if (!session) { - return { redirect: { permanent: false, destination: '/auth/login' } }; - } +export const getServerSideProps: GetServerSideProps = async (context) => { + const session = await getSession(context); + if (!session) { + return { redirect: { permanent: false, destination: "/auth/login" } }; + } - const user = await prisma.user.findFirst({ - where: { - email: session.user.email, - }, - select: { - id: true, - username: true, - name: true, - email: true, - bio: true, - avatar: true, - timeZone: true, - weekStart: true, - } - }); + const user = await prisma.user.findFirst({ + where: { + email: session.user.email, + }, + select: { + id: true, + username: true, + name: true, + email: true, + bio: true, + avatar: true, + timeZone: true, + weekStart: true, + }, + }); - return { - props: {user}, // will be passed to the page component as props - } -} \ No newline at end of file + return { + props: { user }, // will be passed to the page component as props + }; +}; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 0ff77712..5fb93fdc 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -159,3 +159,11 @@ model EventTypeCustomInput { required Boolean } +model ResetPasswordRequest { + id String @id @default(cuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + email String + expires DateTime +} + diff --git a/yarn.lock b/yarn.lock index 0311f59f..ffcaf0d8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -902,6 +902,13 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-14.14.44.tgz#df7503e6002847b834371c004b372529f3f85215" integrity sha512-+gaugz6Oce6ZInfI/tK4Pq5wIIkJMEJUu92RB3Eu93mtj4wjjjz9EB5mLp5s1pSsLXdC/CPut/xF20ZzAQJbTA== +"@types/nodemailer@^6.4.2": + version "6.4.2" + resolved "https://registry.yarnpkg.com/@types/nodemailer/-/nodemailer-6.4.2.tgz#d8ee254c969e6ad83fb9a0a0df3a817406a3fa3b" + integrity sha512-yhsqg5Xbr8aWdwjFS3QjkniW5/tLpWXtOYQcJdo9qE3DolBxsKzgRCQrteaMY0hos8MklJNSEsMqDpZynGzMNg== + dependencies: + "@types/node" "*" + "@types/parse-json@^4.0.0": version "4.0.0" resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0" @@ -2874,6 +2881,18 @@ gtoken@^5.0.4: google-p12-pem "^3.0.3" jws "^4.0.0" +handlebars@^4.7.7: + version "4.7.7" + resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.7.7.tgz#9ce33416aad02dbd6c8fafa8240d5d98004945a1" + integrity sha512-aAcXm5OAfE/8IXkcZvCepKU3VzW1/39Fb5ZuqMtgI/hT8X2YgoMvBY5dLhq/cpOvw7Lk1nK/UF71aLG/ZnVYRA== + dependencies: + minimist "^1.2.5" + neo-async "^2.6.0" + source-map "^0.6.1" + wordwrap "^1.0.0" + optionalDependencies: + uglify-js "^3.1.4" + has-ansi@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/has-ansi/-/has-ansi-2.0.0.tgz#34f5049ce1ecdf2b0649af3ef24e45ed35416d91" @@ -4075,6 +4094,11 @@ lodash.clonedeep@^4.5.0: resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef" integrity sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8= +lodash.debounce@^4.0.8: + version "4.0.8" + resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af" + integrity sha1-gteb/zCmfEAF/9XiUVMArZyk168= + lodash.includes@^4.3.0: version "4.3.0" resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f" @@ -4331,6 +4355,11 @@ natural-compare@^1.4.0: resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" integrity sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc= +neo-async@^2.6.0: + version "2.6.2" + resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f" + integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw== + next-auth@^3.13.2: version "3.19.8" resolved "https://registry.yarnpkg.com/next-auth/-/next-auth-3.19.8.tgz#32331f33dd73b46ec5c774735a9db78f9dbba3c7" @@ -6032,6 +6061,11 @@ typescript@^4.2.3: resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.2.4.tgz#8610b59747de028fda898a8aef0e103f156d0961" integrity sha512-V+evlYHZnQkaz8TRBuxTA92yZBPotr5H+WhQ7bD3hZUndx5tGOa1fuCgeSjxAzM1RiN5IzvadIXTVefuuwZCRg== +uglify-js@^3.1.4: + version "3.13.9" + resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.13.9.tgz#4d8d21dcd497f29cfd8e9378b9df123ad025999b" + integrity sha512-wZbyTQ1w6Y7fHdt8sJnHfSIuWeDgk6B5rCb4E/AM6QNNPbOMIZph21PW5dRB3h7Df0GszN+t7RuUH6sWK5bF0g== + unbox-primitive@^1.0.0, unbox-primitive@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.0.1.tgz#085e215625ec3162574dc8859abee78a59b14471" @@ -6254,6 +6288,11 @@ word-wrap@^1.2.3, word-wrap@~1.2.3: resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c" integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ== +wordwrap@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" + integrity sha1-J1hIEIkUVqQXHI0CJkQa3pDLyus= + wrap-ansi@^6.2.0: version "6.2.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz#e9393ba07102e6c91a3b221478f0257cd2856e53"