Merge branch 'main' into feature/custom-fields-on-the-booking-page

pull/290/head
Bailey Pumfleet 2021-06-21 11:35:59 +01:00 committed by GitHub
commit e9b75b90ba
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 1104 additions and 408 deletions

View File

@ -9,6 +9,10 @@ NEXT_PUBLIC_TELEMETRY_KEY=js.2pvs2bbpqq1zxna97wcml.oi2jzirnbj1ev4tc57c5r
MS_GRAPH_CLIENT_ID=
MS_GRAPH_CLIENT_SECRET=
# Used for the Zoom integration
ZOOM_CLIENT_ID=
ZOOM_CLIENT_SECRET=
# E-mail settings
# Calendso uses nodemailer (@see https://nodemailer.com/about/) to provide email sending. As such we are trying to

View File

@ -196,6 +196,30 @@ Contributions are what make the open source community such an amazing place to b
5. Use **Application (client) ID** as the **MS_GRAPH_CLIENT_ID** attribute value in .env
6. Click **Certificates & secrets** create a new client secret and use the value as the **MS_GRAPH_CLIENT_SECRET** attriubte
## Obtaining Zoom Client ID and Secret
1. Open [Zoom Marketplace](https://marketplace.zoom.us/) and sign in with your Zoom account.
2. On the upper right, click "Develop" => "Build App".
3. On "OAuth", select "Create".
4. Name your App.
5. Choose "Account-level app" as the app type.
6. De-select the option to publish the app on the Zoom App Marketplace.
7. Click "Create".
8. Now copy the Client ID and Client Secret to your .env file into the `ZOOM_CLIENT_ID` and `ZOOM_CLIENT_SECRET` fields.
4. Set the Redirect URL for OAuth `<CALENDSO URL>/api/integrations/zoomvideo/callback` replacing CALENDSO URL with the URI at which your application runs.
5. Also add the redirect URL given above as a whitelist URL and enable "Subdomain check". Make sure, it says "saved" below the form.
7. You don't need to provide basic information about your app. Instead click at "Scopes" and then at "+ Add Scopes". Search for and check the following scopes:
1. account:master
2. account:read:admin
3. account:write:admin
4. meeting:master
5. meeting:read:admin
6. meeting:write:admin
7. user:master
8. user:read:admin
9. user:write:admin
8. Click "Done".
9. You're good to go. Now you can easily add your Zoom integration in the Calendso settings.
<!-- LICENSE -->
## License

View File

@ -1,6 +1,6 @@
import { useRouter } from 'next/router'
import {useRouter} from 'next/router'
import Link from 'next/link'
import React, { Children } from 'react'
import React, {Children} from 'react'
const ActiveLink = ({ children, activeClassName, ...props }) => {
const { asPath } = useRouter()

View File

@ -16,7 +16,7 @@ export default function Avatar({ user, className = '', fallback }: {
return (
<img
onError={() => setGravatarAvailable(false)}
src={`https://www.gravatar.com/avatar/${md5(user.email)}?d=404&s=160`}
src={`https://www.gravatar.com/avatar/${md5(user.email)}?s=160&d=identicon&r=PG`}
alt="Avatar"
className={className}
/>

View File

@ -1,5 +1,5 @@
import ActiveLink from '../components/ActiveLink';
import { UserCircleIcon, KeyIcon, CodeIcon, UserGroupIcon, CreditCardIcon } from '@heroicons/react/outline';
import {CodeIcon, CreditCardIcon, KeyIcon, UserCircleIcon, UserGroupIcon} from '@heroicons/react/outline';
export default function SettingsShell(props) {
return (

View File

@ -1,69 +1,77 @@
import EventOrganizerMail from "./emails/EventOrganizerMail";
import EventAttendeeMail from "./emails/EventAttendeeMail";
import {v5 as uuidv5} from 'uuid';
import short from 'short-uuid';
import EventOrganizerRescheduledMail from "./emails/EventOrganizerRescheduledMail";
import EventAttendeeRescheduledMail from "./emails/EventAttendeeRescheduledMail";
const translator = short();
const {google} = require('googleapis');
import createNewEventEmail from "./emails/new-event";
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 {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]);
};
function handleErrorsJson(response) {
if (!response.ok) {
response.json().then(console.log);
throw Error(response.statusText);
}
return response.json();
if (!response.ok) {
response.json().then(console.log);
throw Error(response.statusText);
}
return response.json();
}
function handleErrorsRaw(response) {
if (!response.ok) {
response.text().then(console.log);
throw Error(response.statusText);
}
return response.text();
if (!response.ok) {
response.text().then(console.log);
throw Error(response.statusText);
}
return response.text();
}
const o365Auth = (credential) => {
const isExpired = (expiryDate) => expiryDate < +(new Date());
const isExpired = (expiryDate) => expiryDate < +(new Date());
const refreshAccessToken = (refreshToken) => fetch('https://login.microsoftonline.com/common/oauth2/v2.0/token', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({
'scope': 'User.Read Calendars.Read Calendars.ReadWrite',
'client_id': process.env.MS_GRAPH_CLIENT_ID,
'refresh_token': refreshToken,
'grant_type': 'refresh_token',
'client_secret': process.env.MS_GRAPH_CLIENT_SECRET,
})
const refreshAccessToken = (refreshToken) => fetch('https://login.microsoftonline.com/common/oauth2/v2.0/token', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({
'scope': 'User.Read Calendars.Read Calendars.ReadWrite',
'client_id': process.env.MS_GRAPH_CLIENT_ID,
'refresh_token': refreshToken,
'grant_type': 'refresh_token',
'client_secret': process.env.MS_GRAPH_CLIENT_SECRET,
})
})
.then(handleErrorsJson)
.then((responseBody) => {
credential.key.access_token = responseBody.access_token;
credential.key.expiry_date = Math.round((+(new Date()) / 1000) + responseBody.expires_in);
return credential.key.access_token;
})
.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;
})
return {
getToken: () => !isExpired(credential.key.expiry_date) ? Promise.resolve(credential.key.access_token) : refreshAccessToken(credential.key.refresh_token)
};
return {
getToken: () => !isExpired(credential.key.expiry_date) ? Promise.resolve(credential.key.access_token) : refreshAccessToken(credential.key.refresh_token)
};
};
interface Person {
name?: string,
email: string,
timeZone: string
name?: string,
email: string,
timeZone: string
}
interface CalendarEvent {
type: string;
title: string;
startTime: string;
endTime: string;
description?: string;
location?: string;
organizer: Person;
attendees: Person[];
type: string;
title: string;
startTime: string;
endTime: string;
description?: string;
location?: string;
organizer: Person;
attendees: Person[];
};
interface IntegrationCalendar {
@ -74,11 +82,11 @@ interface IntegrationCalendar {
}
interface CalendarApiAdapter {
createEvent(event: CalendarEvent): Promise<any>;
createEvent(event: CalendarEvent): Promise<any>;
updateEvent(uid: String, event: CalendarEvent);
updateEvent(uid: String, event: CalendarEvent);
deleteEvent(uid: String);
deleteEvent(uid: String);
getAvailability(dateFrom, dateTo, selectedCalendars: IntegrationCalendar[]): Promise<any>;
@ -87,39 +95,39 @@ interface CalendarApiAdapter {
const MicrosoftOffice365Calendar = (credential): CalendarApiAdapter => {
const auth = o365Auth(credential);
const auth = o365Auth(credential);
const translateEvent = (event: CalendarEvent) => {
const translateEvent = (event: CalendarEvent) => {
let optional = {};
if (event.location) {
optional.location = {displayName: event.location};
}
let optional = {};
if (event.location) {
optional.location = {displayName: event.location};
}
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"
})),
...optional
}
};
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"
})),
...optional
}
};
const integrationType = "office365_calendar";
@ -243,69 +251,69 @@ const GoogleCalendar = (credential): CalendarApiAdapter => {
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}
],
},
};
}),
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}
],
},
};
if (event.location) {
payload['location'] = event.location;
}
if (event.location) {
payload['location'] = event.location;
}
const calendar = google.calendar({version: 'v3', auth: myGoogleAuth});
calendar.events.insert({
auth: myGoogleAuth,
calendarId: 'primary',
resource: payload,
}, function (err, event) {
if (err) {
console.log('There was an error contacting the Calendar service: ' + err);
return reject(err);
}
return resolve(event.data);
});
}),
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}
],
},
};
const calendar = google.calendar({version: 'v3', auth: myGoogleAuth});
calendar.events.insert({
auth: myGoogleAuth,
calendarId: 'primary',
resource: payload,
}, function (err, event) {
if (err) {
console.log('There was an error contacting the Calendar service: ' + err);
return reject(err);
}
return resolve(event.data);
});
}),
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.location) {
payload['location'] = event.location;
}
const calendar = google.calendar({version: 'v3', auth: myGoogleAuth});
calendar.events.update({
@ -360,17 +368,17 @@ const GoogleCalendar = (credential): CalendarApiAdapter => {
// factory
const calendars = (withCredentials): CalendarApiAdapter[] => withCredentials.map((cred) => {
switch (cred.type) {
case 'google_calendar':
return GoogleCalendar(cred);
case 'office365_calendar':
return MicrosoftOffice365Calendar(cred);
default:
return; // unknown credential, could be legacy? In any case, ignore
}
switch (cred.type) {
case 'google_calendar':
return GoogleCalendar(cred);
case 'office365_calendar':
return MicrosoftOffice365Calendar(cred);
default:
return; // unknown credential, could be legacy? In any case, ignore
}
}).filter(Boolean);
const getBusyTimes = (withCredentials, dateFrom, dateTo, selectedCalendars) => Promise.all(
const getBusyCalendarTimes = (withCredentials, dateFrom, dateTo, selectedCalendars) => Promise.all(
calendars(withCredentials).map(c => c.getAvailability(dateFrom, dateTo, selectedCalendars))
).then(
(results) => {
@ -384,33 +392,50 @@ const listCalendars = (withCredentials) => Promise.all(
(results) => results.reduce((acc, calendars) => acc.concat(calendars), [])
);
const createEvent = (credential, calEvent: CalendarEvent): Promise<any> => {
const createEvent = async (credential, calEvent: CalendarEvent): Promise<any> => {
const uid: string = translator.fromUUID(uuidv5(JSON.stringify(calEvent), uuidv5.URL));
createNewEventEmail(
calEvent,
);
const creationResult = credential ? await calendars([credential])[0].createEvent(calEvent) : null;
if (credential) {
return calendars([credential])[0].createEvent(calEvent);
}
const organizerMail = new EventOrganizerMail(calEvent, uid);
const attendeeMail = new EventAttendeeMail(calEvent, uid);
await organizerMail.sendEmail();
return Promise.resolve({});
if (!creationResult || !creationResult.disableConfirmationEmail) {
await attendeeMail.sendEmail();
}
return {
uid,
createdEvent: creationResult
};
};
const updateEvent = (credential, uid: String, calEvent: CalendarEvent): Promise<any> => {
if (credential) {
return calendars([credential])[0].updateEvent(uid, calEvent);
}
const updateEvent = async (credential, uidToUpdate: String, calEvent: CalendarEvent): Promise<any> => {
const newUid: string = translator.fromUUID(uuidv5(JSON.stringify(calEvent), uuidv5.URL));
return Promise.resolve({});
const updateResult = credential ? await calendars([credential])[0].updateEvent(uidToUpdate, calEvent) : null;
const organizerMail = new EventOrganizerRescheduledMail(calEvent, newUid);
const attendeeMail = new EventAttendeeRescheduledMail(calEvent, newUid);
await organizerMail.sendEmail();
if (!updateResult || !updateResult.disableConfirmationEmail) {
await attendeeMail.sendEmail();
}
return {
uid: newUid,
updatedEvent: updateResult
};
};
const deleteEvent = (credential, uid: String): Promise<any> => {
if (credential) {
return calendars([credential])[0].deleteEvent(uid);
}
if (credential) {
return calendars([credential])[0].deleteEvent(uid);
}
return Promise.resolve({});
return Promise.resolve({});
};
export {getBusyTimes, createEvent, updateEvent, deleteEvent, CalendarEvent, listCalendars, IntegrationCalendar};
export {getBusyCalendarTimes, createEvent, updateEvent, deleteEvent, CalendarEvent, listCalendars, IntegrationCalendar};

View File

@ -0,0 +1,55 @@
import dayjs, {Dayjs} from "dayjs";
import EventMail from "./EventMail";
export default class EventAttendeeMail extends EventMail {
/**
* Returns the email text as HTML representation.
*
* @protected
*/
protected getHtmlRepresentation(): string {
return `
<div>
Hi ${this.calEvent.attendees[0].name},<br />
<br />
Your ${this.calEvent.type} with ${this.calEvent.organizer.name} at ${this.getInviteeStart().format('h:mma')}
(${this.calEvent.attendees[0].timeZone}) on ${this.getInviteeStart().format('dddd, LL')} is scheduled.<br />
<br />` + this.getAdditionalBody() + (
this.calEvent.location ? `<strong>Location:</strong> ${this.calEvent.location}<br /><br />` : ''
) +
`<strong>Additional notes:</strong><br />
${this.calEvent.description}<br />
` + this.getAdditionalFooter() + `
</div>
`;
}
/**
* Returns the payload object for the nodemailer.
*
* @protected
*/
protected getNodeMailerPayload(): Object {
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: `Confirmed: ${this.calEvent.type} with ${this.calEvent.organizer.name} on ${this.getInviteeStart().format('dddd, LL')}`,
html: this.getHtmlRepresentation(),
text: this.getPlainTextRepresentation(),
};
}
protected printNodeMailerError(error: string): 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>dayjs(this.calEvent.startTime).tz(this.calEvent.attendees[0].timeZone);
}
}

View File

@ -0,0 +1,40 @@
import EventAttendeeMail from "./EventAttendeeMail";
export default class EventAttendeeRescheduledMail extends EventAttendeeMail {
/**
* Returns the email text as HTML representation.
*
* @protected
*/
protected getHtmlRepresentation(): string {
return `
<div>
Hi ${this.calEvent.attendees[0].name},<br />
<br />
Your ${this.calEvent.type} with ${this.calEvent.organizer.name} has been rescheduled to ${this.getInviteeStart().format('h:mma')}
(${this.calEvent.attendees[0].timeZone}) on ${this.getInviteeStart().format('dddd, LL')}.<br />
` + this.getAdditionalFooter() + `
</div>
`;
}
/**
* Returns the payload object for the nodemailer.
*
* @protected
*/
protected getNodeMailerPayload(): Object {
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: `Rescheduled: ${this.calEvent.type} with ${this.calEvent.organizer.name} on ${this.getInviteeStart().format('dddd, LL')}`,
html: this.getHtmlRepresentation(),
text: this.getPlainTextRepresentation(),
};
}
protected printNodeMailerError(error: string): void {
console.error("SEND_RESCHEDULE_CONFIRMATION_ERROR", this.calEvent.attendees[0].email, error);
}
}

135
lib/emails/EventMail.ts Normal file
View File

@ -0,0 +1,135 @@
import {CalendarEvent} from "../calendarClient";
import {serverConfig} from "../serverConfig";
import nodemailer from 'nodemailer';
export default abstract class EventMail {
calEvent: CalendarEvent;
uid: string;
/**
* An EventMail always consists of a CalendarEvent
* that stores the very basic data of the event (like date, title etc).
* It also needs the UID of the stored booking in our database.
*
* @param calEvent
* @param uid
*/
constructor(calEvent: CalendarEvent, uid: string) {
this.calEvent = calEvent;
this.uid = uid;
}
/**
* 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 this.stripHtml(this.getHtmlRepresentation());
}
/**
* Strips off all HTML tags and leaves plain text.
*
* @param html
* @protected
*/
protected stripHtml(html: string): string {
return html
.replace('<br />', "\n")
.replace(/<[^>]+>/g, '');
}
/**
* Returns the payload object for the nodemailer.
* @protected
*/
protected abstract getNodeMailerPayload(): Object;
/**
* Sends the email to the event attendant and returns a Promise.
*/
public sendEmail(): Promise<any> {
return new Promise((resolve, reject) => nodemailer.createTransport(this.getMailerOptions().transport).sendMail(
this.getNodeMailerPayload(),
(error, info) => {
if (error) {
this.printNodeMailerError(error);
reject(new Error(error));
} else {
resolve(info);
}
}));
}
/**
* Gathers the required provider information from the config.
*
* @protected
*/
protected getMailerOptions(): any {
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 "";
}
/**
* Prints out the desired information when an error
* occured while sending the mail.
* @param error
* @protected
*/
protected abstract printNodeMailerError(error: string): void;
/**
* Returns a link to reschedule the given booking.
*
* @protected
*/
protected getRescheduleLink(): string {
return process.env.BASE_URL + '/reschedule/' + this.uid;
}
/**
* Returns a link to cancel the given booking.
*
* @protected
*/
protected getCancelLink(): string {
return process.env.BASE_URL + '/cancel/' + this.uid;
}
/**
* Defines a footer that will be appended to the email.
* @protected
*/
protected getAdditionalFooter(): string {
return `
<br/>
Need to change this event?<br />
Cancel: <a href="${this.getCancelLink()}">${this.getCancelLink()}</a><br />
Reschedule: <a href="${this.getRescheduleLink()}">${this.getRescheduleLink()}</a>
`;
}
}

View File

@ -0,0 +1,87 @@
import {createEvent} from "ics";
import dayjs, {Dayjs} from "dayjs";
import EventMail from "./EventMail";
export default class EventOrganizerMail extends EventMail {
/**
* Returns the instance's event as an iCal event in string representation.
* @protected
*/
protected getiCalEventAsString(): string {
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.type} with ${this.calEvent.attendees[0].name}`,
description: this.calEvent.description + this.stripHtml(this.getAdditionalBody()) + this.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: any) => ({ name: attendee.name, email: attendee.email }) ),
status: "CONFIRMED",
});
if (icsEvent.error) {
throw icsEvent.error;
}
return icsEvent.value;
}
/**
* Returns the email text as HTML representation.
*
* @protected
*/
protected getHtmlRepresentation(): string {
return `
<div>
Hi ${this.calEvent.organizer.name},<br />
<br />
A new event has been scheduled.<br />
<br />
<strong>Event Type:</strong><br />
${this.calEvent.type}<br />
<br />
<strong>Invitee Email:</strong><br />
<a href="mailto:${this.calEvent.attendees[0].email}">${this.calEvent.attendees[0].email}</a><br />
<br />` + this.getAdditionalBody() +
(
this.calEvent.location ? `
<strong>Location:</strong><br />
${this.calEvent.location}<br />
<br />
` : ''
) +
`<strong>Invitee Time Zone:</strong><br />
${this.calEvent.attendees[0].timeZone}<br />
<br />
<strong>Additional notes:</strong><br />
${this.calEvent.description}
` + this.getAdditionalFooter() + `
</div>
`;
}
/**
* Returns the payload object for the nodemailer.
*
* @protected
*/
protected getNodeMailerPayload(): Object {
const organizerStart: Dayjs = <Dayjs>dayjs(this.calEvent.startTime).tz(this.calEvent.organizer.timeZone);
return {
icalEvent: {
filename: 'event.ics',
content: this.getiCalEventAsString(),
},
from: `Calendso <${this.getMailerOptions().from}>`,
to: this.calEvent.organizer.email,
subject: `New event: ${this.calEvent.attendees[0].name} - ${organizerStart.format('LT dddd, LL')} - ${this.calEvent.type}`,
html: this.getHtmlRepresentation(),
text: this.getPlainTextRepresentation(),
};
}
protected printNodeMailerError(error: string): void {
console.error("SEND_NEW_EVENT_NOTIFICATION_ERROR", this.calEvent.organizer.email, error);
}
}

View File

@ -0,0 +1,64 @@
import dayjs, {Dayjs} from "dayjs";
import EventOrganizerMail from "./EventOrganizerMail";
export default class EventOrganizerRescheduledMail extends EventOrganizerMail {
/**
* Returns the email text as HTML representation.
*
* @protected
*/
protected getHtmlRepresentation(): string {
return `
<div>
Hi ${this.calEvent.organizer.name},<br />
<br />
Your event has been rescheduled.<br />
<br />
<strong>Event Type:</strong><br />
${this.calEvent.type}<br />
<br />
<strong>Invitee Email:</strong><br />
<a href="mailto:${this.calEvent.attendees[0].email}">${this.calEvent.attendees[0].email}</a><br />
<br />` + this.getAdditionalBody() +
(
this.calEvent.location ? `
<strong>Location:</strong><br />
${this.calEvent.location}<br />
<br />
` : ''
) +
`<strong>Invitee Time Zone:</strong><br />
${this.calEvent.attendees[0].timeZone}<br />
<br />
<strong>Additional notes:</strong><br />
${this.calEvent.description}
` + this.getAdditionalFooter() + `
</div>
`;
}
/**
* Returns the payload object for the nodemailer.
*
* @protected
*/
protected getNodeMailerPayload(): Object {
const organizerStart: Dayjs = <Dayjs>dayjs(this.calEvent.startTime).tz(this.calEvent.organizer.timeZone);
return {
icalEvent: {
filename: 'event.ics',
content: this.getiCalEventAsString(),
},
from: `Calendso <${this.getMailerOptions().from}>`,
to: this.calEvent.organizer.email,
subject: `Rescheduled event: ${this.calEvent.attendees[0].name} - ${organizerStart.format('LT dddd, LL')} - ${this.calEvent.type}`,
html: this.getHtmlRepresentation(),
text: this.getPlainTextRepresentation(),
};
}
protected printNodeMailerError(error: string): void {
console.error("SEND_RESCHEDULE_EVENT_NOTIFICATION_ERROR", this.calEvent.organizer.email, error);
}
}

View File

@ -0,0 +1,27 @@
import {CalendarEvent} from "../calendarClient";
import EventAttendeeMail from "./EventAttendeeMail";
import {getFormattedMeetingId, getIntegrationName} from "./helpers";
import {VideoCallData} from "../videoClient";
export default class VideoEventAttendeeMail extends EventAttendeeMail {
videoCallData: VideoCallData;
constructor(calEvent: CalendarEvent, uid: string, videoCallData: VideoCallData) {
super(calEvent, uid);
this.videoCallData = videoCallData;
}
/**
* Adds the video call information to the mail body.
*
* @protected
*/
protected getAdditionalBody(): string {
return `
<strong>Video call provider:</strong> ${getIntegrationName(this.videoCallData)}<br />
<strong>Meeting ID:</strong> ${getFormattedMeetingId(this.videoCallData)}<br />
<strong>Meeting Password:</strong> ${this.videoCallData.password}<br />
<strong>Meeting URL:</strong> <a href="${this.videoCallData.url}">${this.videoCallData.url}</a><br />
`;
}
}

View File

@ -0,0 +1,28 @@
import {CalendarEvent} from "../calendarClient";
import EventOrganizerMail from "./EventOrganizerMail";
import {VideoCallData} from "../videoClient";
import {getFormattedMeetingId, getIntegrationName} from "./helpers";
export default class VideoEventOrganizerMail extends EventOrganizerMail {
videoCallData: VideoCallData;
constructor(calEvent: CalendarEvent, uid: string, videoCallData: VideoCallData) {
super(calEvent, uid);
this.videoCallData = videoCallData;
}
/**
* Adds the video call information to the mail body
* and calendar event description.
*
* @protected
*/
protected getAdditionalBody(): string {
return `
<strong>Video call provider:</strong> ${getIntegrationName(this.videoCallData)}<br />
<strong>Meeting ID:</strong> ${getFormattedMeetingId(this.videoCallData)}<br />
<strong>Meeting Password:</strong> ${this.videoCallData.password}<br />
<strong>Meeting URL:</strong> <a href="${this.videoCallData.url}">${this.videoCallData.url}</a><br />
`;
}
}

View File

@ -1,70 +0,0 @@
import nodemailer from 'nodemailer';
import {serverConfig} from "../serverConfig";
import {CalendarEvent} from "../calendarClient";
import dayjs, {Dayjs} from "dayjs";
import localizedFormat from "dayjs/plugin/localizedFormat";
import utc from "dayjs/plugin/utc";
import timezone from "dayjs/plugin/timezone";
dayjs.extend(localizedFormat);
dayjs.extend(utc);
dayjs.extend(timezone);
export default function createConfirmBookedEmail(calEvent: CalendarEvent, cancelLink: string, rescheduleLink: string, options: any = {}) {
return sendEmail(calEvent, cancelLink, rescheduleLink, {
provider: {
transport: serverConfig.transport,
from: serverConfig.from,
},
...options
});
}
const sendEmail = (calEvent: CalendarEvent, cancelLink: string, rescheduleLink: string, {
provider,
}) => new Promise( (resolve, reject) => {
const { from, transport } = provider;
const inviteeStart: Dayjs = <Dayjs>dayjs(calEvent.startTime).tz(calEvent.attendees[0].timeZone);
nodemailer.createTransport(transport).sendMail(
{
to: `${calEvent.attendees[0].name} <${calEvent.attendees[0].email}>`,
from: `${calEvent.organizer.name} <${from}>`,
replyTo: calEvent.organizer.email,
subject: `Confirmed: ${calEvent.type} with ${calEvent.organizer.name} on ${inviteeStart.format('dddd, LL')}`,
html: html(calEvent, cancelLink, rescheduleLink),
text: text(calEvent, cancelLink, rescheduleLink),
},
(error, info) => {
if (error) {
console.error("SEND_BOOKING_CONFIRMATION_ERROR", calEvent.attendees[0].email, error);
return reject(new Error(error));
}
return resolve();
}
)
});
const html = (calEvent: CalendarEvent, cancelLink: string, rescheduleLink: string) => {
const inviteeStart: Dayjs = <Dayjs>dayjs(calEvent.startTime).tz(calEvent.attendees[0].timeZone);
return `
<div>
Hi ${calEvent.attendees[0].name},<br />
<br />
Your ${calEvent.type} with ${calEvent.organizer.name} at ${inviteeStart.format('h:mma')}
(${calEvent.attendees[0].timeZone}) on ${inviteeStart.format('dddd, LL')} is scheduled.<br />
<br />` + (
calEvent.location ? `<strong>Location:</strong> ${calEvent.location}<br /><br />` : ''
) +
`Additional notes:<br />
${calEvent.description}<br />
<br />
Need to change this event?<br />
Cancel: <a href="${cancelLink}">${cancelLink}</a><br />
Reschedule: <a href="${rescheduleLink}">${rescheduleLink}</a>
</div>
`;
};
const text = (evt: CalendarEvent, cancelLink: string, rescheduleLink: string) => html(evt, cancelLink, rescheduleLink).replace('<br />', "\n").replace(/<[^>]+>/g, '');

20
lib/emails/helpers.ts Normal file
View File

@ -0,0 +1,20 @@
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);
}
export function getFormattedMeetingId(videoCallData: VideoCallData): string {
switch(videoCallData.type) {
case 'zoom_video':
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;
default:
return videoCallData.id.toString();
}
}

View File

@ -1,99 +0,0 @@
import nodemailer from 'nodemailer';
import dayjs, { Dayjs } from "dayjs";
import localizedFormat from 'dayjs/plugin/localizedFormat';
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
import toArray from 'dayjs/plugin/toArray';
import { createEvent } from 'ics';
import { CalendarEvent } from '../calendarClient';
import { serverConfig } from '../serverConfig';
dayjs.extend(localizedFormat);
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(toArray);
export default function createNewEventEmail(calEvent: CalendarEvent, options: any = {}) {
return sendEmail(calEvent, {
provider: {
transport: serverConfig.transport,
from: serverConfig.from,
},
...options
});
}
const icalEventAsString = (calEvent: CalendarEvent): string => {
const icsEvent = createEvent({
start: dayjs(calEvent.startTime).utc().toArray().slice(0, 6).map((v, i) => i === 1 ? v + 1 : v),
startInputType: 'utc',
productId: 'calendso/ics',
title: `${calEvent.type} with ${calEvent.attendees[0].name}`,
description: calEvent.description,
duration: { minutes: dayjs(calEvent.endTime).diff(dayjs(calEvent.startTime), 'minute') },
organizer: { name: calEvent.organizer.name, email: calEvent.organizer.email },
attendees: calEvent.attendees.map( (attendee: any) => ({ name: attendee.name, email: attendee.email }) ),
status: "CONFIRMED",
});
if (icsEvent.error) {
throw icsEvent.error;
}
return icsEvent.value;
}
const sendEmail = (calEvent: CalendarEvent, {
provider,
}) => new Promise( (resolve, reject) => {
const { transport, from } = provider;
const organizerStart: Dayjs = <Dayjs>dayjs(calEvent.startTime).tz(calEvent.organizer.timeZone);
nodemailer.createTransport(transport).sendMail(
{
icalEvent: {
filename: 'event.ics',
content: icalEventAsString(calEvent),
},
from: `Calendso <${from}>`,
to: calEvent.organizer.email,
subject: `New event: ${calEvent.attendees[0].name} - ${organizerStart.format('LT dddd, LL')} - ${calEvent.type}`,
html: html(calEvent),
text: text(calEvent),
},
(error) => {
if (error) {
console.error("SEND_NEW_EVENT_NOTIFICATION_ERROR", calEvent.organizer.email, error);
return reject(new Error(error));
}
return resolve();
});
});
const html = (evt: CalendarEvent) => `
<div>
Hi ${evt.organizer.name},<br />
<br />
A new event has been scheduled.<br />
<br />
<strong>Event Type:</strong><br />
${evt.type}<br />
<br />
<strong>Invitee Email:</strong><br />
<a href="mailto:${evt.attendees[0].email}">${evt.attendees[0].email}</a><br />
<br />` +
(
evt.location ? `
<strong>Location:</strong><br />
${evt.location}<br />
<br />
` : ''
) +
`<strong>Invitee Time Zone:</strong><br />
${evt.attendees[0].timeZone}<br />
<br />
<strong>Additional notes:</strong><br />
${evt.description}
</div>
`;
// just strip all HTML and convert <br /> to \n
const text = (evt: CalendarEvent) => html(evt).replace('<br />', "\n").replace(/<[^>]+>/g, '');

View File

@ -4,6 +4,8 @@ export function getIntegrationName(name: String) {
return "Google Calendar";
case "office365_calendar":
return "Office 365 Calendar";
case "zoom_video":
return "Zoom";
default:
return "Unknown";
}

239
lib/videoClient.ts Normal file
View File

@ -0,0 +1,239 @@
import prisma from "./prisma";
import {CalendarEvent} from "./calendarClient";
import VideoEventOrganizerMail from "./emails/VideoEventOrganizerMail";
import VideoEventAttendeeMail from "./emails/VideoEventAttendeeMail";
import {v5 as uuidv5} from 'uuid';
import short from 'short-uuid';
import EventAttendeeRescheduledMail from "./emails/EventAttendeeRescheduledMail";
import EventOrganizerRescheduledMail from "./emails/EventOrganizerRescheduledMail";
const translator = short();
export interface VideoCallData {
type: string;
id: string;
password: string;
url: string;
}
function handleErrorsJson(response) {
if (!response.ok) {
response.json().then(console.log);
throw Error(response.statusText);
}
return response.json();
}
function handleErrorsRaw(response) {
if (!response.ok) {
response.text().then(console.log);
throw Error(response.statusText);
}
return response.text();
}
const zoomAuth = (credential) => {
const isExpired = (expiryDate) => expiryDate < +(new Date());
const authHeader = 'Basic ' + Buffer.from(process.env.ZOOM_CLIENT_ID + ':' + process.env.ZOOM_CLIENT_SECRET).toString('base64');
const refreshAccessToken = (refreshToken) => fetch('https://zoom.us/oauth/token', {
method: 'POST',
headers: {
'Authorization': authHeader,
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
'refresh_token': refreshToken,
'grant_type': 'refresh_token',
})
})
.then(handleErrorsJson)
.then(async (responseBody) => {
// Store new tokens in database.
await prisma.credential.update({
where: {
id: credential.id
},
data: {
key: responseBody
}
});
credential.key.access_token = responseBody.access_token;
credential.key.expires_in = Math.round((+(new Date()) / 1000) + responseBody.expires_in);
return credential.key.access_token;
})
return {
getToken: () => !isExpired(credential.key.expires_in) ? Promise.resolve(credential.key.access_token) : refreshAccessToken(credential.key.refresh_token)
};
};
interface VideoApiAdapter {
createMeeting(event: CalendarEvent): Promise<any>;
updateMeeting(uid: String, event: CalendarEvent);
deleteMeeting(uid: String);
getAvailability(dateFrom, dateTo): Promise<any>;
}
const ZoomVideo = (credential): VideoApiAdapter => {
const auth = zoomAuth(credential);
const translateEvent = (event: CalendarEvent) => {
// Documentation at: https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/meetingcreate
return {
topic: event.title,
type: 2, // Means that this is a scheduled meeting
start_time: event.startTime,
duration: ((new Date(event.endTime)).getTime() - (new Date(event.startTime)).getTime()) / 60000,
//schedule_for: "string", TODO: Used when scheduling the meeting for someone else (needed?)
timezone: event.attendees[0].timeZone,
//password: "string", TODO: Should we use a password? Maybe generate a random one?
agenda: event.description,
settings: {
host_video: true,
participant_video: true,
cn_meeting: false, // TODO: true if host meeting in China
in_meeting: false, // TODO: true if host meeting in India
join_before_host: true,
mute_upon_entry: false,
watermark: false,
use_pmi: false,
approval_type: 2,
audio: "both",
auto_recording: "none",
enforce_login: false,
registrants_email_notification: true
}
};
};
return {
getAvailability: (dateFrom, dateTo) => {
return auth.getToken().then(
// TODO Possibly implement pagination for cases when there are more than 300 meetings already scheduled.
(accessToken) => fetch('https://api.zoom.us/v2/users/me/meetings?type=scheduled&page_size=300', {
method: 'get',
headers: {
'Authorization': 'Bearer ' + accessToken
}
})
.then(handleErrorsJson)
.then(responseBody => {
return responseBody.meetings.map((meeting) => ({
start: meeting.start_time,
end: (new Date((new Date(meeting.start_time)).getTime() + meeting.duration * 60000)).toISOString()
}))
})
).catch((err) => {
console.log(err);
});
},
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 => 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)),
}
};
// factory
const videoIntegrations = (withCredentials): VideoApiAdapter[] => withCredentials.map((cred) => {
switch (cred.type) {
case 'zoom_video':
return ZoomVideo(cred);
default:
return; // unknown credential, could be legacy? In any case, ignore
}
}).filter(Boolean);
const getBusyVideoTimes = (withCredentials, dateFrom, dateTo) => Promise.all(
videoIntegrations(withCredentials).map(c => c.getAvailability(dateFrom, dateTo))
).then(
(results) => results.reduce((acc, availability) => acc.concat(availability), [])
);
const createMeeting = async (credential, calEvent: CalendarEvent): Promise<any> => {
const uid: string = translator.fromUUID(uuidv5(JSON.stringify(calEvent), uuidv5.URL));
if (!credential) {
throw new Error("Credentials must be set! Video platforms are optional, so this method shouldn't even be called when no video credentials are set.");
}
const creationResult = await videoIntegrations([credential])[0].createMeeting(calEvent);
const videoCallData: VideoCallData = {
type: credential.type,
id: creationResult.id,
password: creationResult.password,
url: creationResult.join_url,
};
const organizerMail = new VideoEventOrganizerMail(calEvent, uid, videoCallData);
const attendeeMail = new VideoEventAttendeeMail(calEvent, uid, videoCallData);
await organizerMail.sendEmail();
if (!creationResult || !creationResult.disableConfirmationEmail) {
await attendeeMail.sendEmail();
}
return {
uid,
createdEvent: creationResult
};
};
const updateMeeting = async (credential, uidToUpdate: String, calEvent: CalendarEvent): Promise<any> => {
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.");
}
const updateResult = credential ? await videoIntegrations([credential])[0].updateMeeting(uidToUpdate, calEvent) : null;
const organizerMail = new EventOrganizerRescheduledMail(calEvent, newUid);
const attendeeMail = new EventAttendeeRescheduledMail(calEvent, newUid);
await organizerMail.sendEmail();
if (!updateResult || !updateResult.disableConfirmationEmail) {
await attendeeMail.sendEmail();
}
return {
uid: newUid,
updatedEvent: updateResult
};
};
const deleteMeeting = (credential, uid: String): Promise<any> => {
if (credential) {
return videoIntegrations([credential])[0].deleteMeeting(uid);
}
return Promise.resolve({});
};
export {getBusyVideoTimes, createMeeting, updateMeeting, deleteMeeting};

View File

@ -1,25 +1,25 @@
import {useEffect, useState, useMemo} from 'react';
import {useEffect, useMemo, useState} from 'react';
import Head from 'next/head';
import Link from 'next/link';
import prisma from '../../lib/prisma';
import { useRouter } from 'next/router';
import dayjs, { Dayjs } from 'dayjs';
import { Switch } from '@headlessui/react';
import {useRouter} from 'next/router';
import dayjs, {Dayjs} from 'dayjs';
import {Switch} from '@headlessui/react';
import TimezoneSelect from 'react-timezone-select';
import { ClockIcon, GlobeIcon, ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/solid';
import {ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon, ClockIcon, GlobeIcon} from '@heroicons/react/solid';
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore';
import isBetween from 'dayjs/plugin/isBetween';
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
import Avatar from '../../components/Avatar';
import getSlots from '../../lib/slots';
import {collectPageParameters, telemetryEventTypes, useTelemetry} from "../../lib/telemetry";
dayjs.extend(isSameOrBefore);
dayjs.extend(isBetween);
dayjs.extend(utc);
dayjs.extend(timezone);
import getSlots from '../../lib/slots';
import {collectPageParameters, telemetryEventTypes, useTelemetry} from "../../lib/telemetry";
function classNames(...classes) {
return classes.filter(Boolean).join(' ')
}

View File

@ -96,7 +96,7 @@ export default function Book(props) {
}
);
let successUrl = `/success?date=${date}&type=${props.eventType.id}&user=${props.user.username}&reschedule=1&name=${payload.name}`;
let successUrl = `/success?date=${date}&type=${props.eventType.id}&user=${props.user.username}&reschedule=${!!rescheduleUid}&name=${payload.name}`;
if (payload['location']) {
successUrl += "&location=" + encodeURIComponent(payload['location']);
}

View File

@ -1,6 +1,7 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import type {NextApiRequest, NextApiResponse} from 'next';
import prisma from '../../../lib/prisma';
import { getBusyTimes } from '../../../lib/calendarClient';
import {getBusyCalendarTimes} from '../../../lib/calendarClient';
import {getBusyVideoTimes} from '../../../lib/videoClient';
import dayjs from "dayjs";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
@ -23,12 +24,26 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}
}));
let availability = await getBusyTimes(currentUser.credentials, req.query.dateFrom, req.query.dateTo, selectedCalendars);
const hasCalendarIntegrations = currentUser.credentials.filter((cred) => cred.type.endsWith('_calendar')).length > 0;
const hasVideoIntegrations = currentUser.credentials.filter((cred) => cred.type.endsWith('_video')).length > 0;
availability = availability.map(a => ({
const calendarAvailability = await getBusyCalendarTimes(currentUser.credentials, req.query.dateFrom, req.query.dateTo, selectedCalendars);
const videoAvailability = await getBusyVideoTimes(currentUser.credentials, req.query.dateFrom, req.query.dateTo);
let commonAvailability = [];
if(hasCalendarIntegrations && hasVideoIntegrations) {
commonAvailability = calendarAvailability.filter(availability => videoAvailability.includes(availability));
} else if(hasVideoIntegrations) {
commonAvailability = videoAvailability;
} else if(hasCalendarIntegrations) {
commonAvailability = calendarAvailability;
}
commonAvailability = commonAvailability.map(a => ({
start: dayjs(a.start).subtract(currentUser.bufferTime, 'minute').toString(),
end: dayjs(a.end).add(currentUser.bufferTime, 'minute').toString()
}));
res.status(200).json(availability);
res.status(200).json(commonAvailability);
}

View File

@ -1,5 +1,5 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { getSession } from 'next-auth/client';
import type {NextApiRequest, NextApiResponse} from 'next';
import {getSession} from 'next-auth/client';
import prisma from '../../../lib/prisma';
import {IntegrationCalendar, listCalendars} from "../../../lib/calendarClient";

View File

@ -1,5 +1,5 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { getSession } from 'next-auth/client';
import type {NextApiRequest, NextApiResponse} from 'next';
import {getSession} from 'next-auth/client';
import prisma from '../../../lib/prisma';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {

View File

@ -1,5 +1,5 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { getSession } from 'next-auth/client';
import type {NextApiRequest, NextApiResponse} from 'next';
import {getSession} from 'next-auth/client';
import prisma from '../../../lib/prisma';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {

View File

@ -1,10 +1,11 @@
import type {NextApiRequest, NextApiResponse} from 'next';
import prisma from '../../../lib/prisma';
import {CalendarEvent, createEvent, updateEvent} from '../../../lib/calendarClient';
import createConfirmBookedEmail from "../../../lib/emails/confirm-booked";
import async from 'async';
import {v5 as uuidv5} from 'uuid';
import short from 'short-uuid';
import {createMeeting, updateMeeting} from "../../../lib/videoClient";
import EventAttendeeMail from "../../../lib/emails/EventAttendeeMail";
import {getEventName} from "../../../lib/event";
const translator = short();
@ -25,6 +26,10 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}
});
// Split credentials up into calendar credentials and video credentials
const calendarCredentials = currentUser.credentials.filter(cred => cred.type.endsWith('_calendar'));
const videoCredentials = currentUser.credentials.filter(cred => cred.type.endsWith('_video'));
const rescheduleUid = req.body.rescheduleUid;
const selectedEventType = await prisma.eventType.findFirst({
@ -51,19 +56,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
]
};
const hashUID: string = translator.fromUUID(uuidv5(JSON.stringify(evt), uuidv5.URL));
const cancelLink: string = process.env.BASE_URL + '/cancel/' + hashUID;
const rescheduleLink:string = process.env.BASE_URL + '/reschedule/' + hashUID;
const appendLinksToEvents = (event: CalendarEvent) => {
const eventCopy = {...event};
eventCopy.description += "\n\n"
+ "Need to change this event?\n"
+ "Cancel: " + cancelLink + "\n"
+ "Reschedule:" + rescheduleLink;
return eventCopy;
}
const eventType = await prisma.eventType.findFirst({
where: {
userId: currentUser.id,
@ -74,8 +66,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}
});
let results = undefined;
let referencesToCreate = undefined;
let results = [];
let referencesToCreate = [];
if (rescheduleUid) {
// Reschedule event
@ -96,10 +88,24 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
});
// Use all integrations
results = await async.mapLimit(currentUser.credentials, 5, async (credential) => {
results = results.concat(await async.mapLimit(calendarCredentials, 5, async (credential) => {
const bookingRefUid = booking.references.filter((ref) => ref.type === credential.type)[0].uid;
return await updateEvent(credential, bookingRefUid, appendLinksToEvents(evt))
});
const response = await updateEvent(credential, bookingRefUid, evt);
return {
type: credential.type,
response
};
}));
results = results.concat(await async.mapLimit(videoCredentials, 5, async (credential) => {
const bookingRefUid = booking.references.filter((ref) => ref.type === credential.type)[0].uid;
const response = await updateMeeting(credential, bookingRefUid, evt);
return {
type: credential.type,
response
};
}));
// Clone elements
referencesToCreate = [...booking.references];
@ -128,22 +134,40 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
]);
} else {
// Schedule event
results = await async.mapLimit(currentUser.credentials, 5, async (credential) => {
const response = await createEvent(credential, appendLinksToEvents(evt));
results = results.concat(await async.mapLimit(calendarCredentials, 5, async (credential) => {
const response = await createEvent(credential, evt);
return {
type: credential.type,
response
};
});
}));
results = results.concat(await async.mapLimit(videoCredentials, 5, async (credential) => {
const response = await createMeeting(credential, evt);
return {
type: credential.type,
response
};
}));
referencesToCreate = results.map((result => {
return {
type: result.type,
uid: result.response.id
uid: result.response.createdEvent.id.toString()
};
}));
}
// TODO Should just be set to the true case as soon as we have a "bare email" integration class.
// UID generation should happen in the integration itself, not here.
const hashUID = results.length > 0 ? results[0].response.uid : translator.fromUUID(uuidv5(JSON.stringify(evt), uuidv5.URL));
if(results.length === 0) {
// Legacy as well, as soon as we have a separate email integration class. Just used
// to send an email even if there is no integration at all.
const mail = new EventAttendeeMail(evt, hashUID);
await mail.sendEmail();
}
await prisma.booking.create({
data: {
uid: hashUID,
@ -164,12 +188,5 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}
});
// If one of the integrations allows email confirmations or no integrations are added, send it.
if (currentUser.credentials.length === 0 || !results.every((result) => result.disableConfirmationEmail)) {
await createConfirmBookedEmail(
evt, cancelLink, rescheduleLink
);
}
res.status(200).json(results);
}

View File

@ -1,6 +1,7 @@
import prisma from '../../lib/prisma';
import {deleteEvent} from "../../lib/calendarClient";
import async from 'async';
import {deleteMeeting} from "../../lib/videoClient";
export default async function handler(req, res) {
if (req.method == "POST") {
@ -29,7 +30,11 @@ export default async function handler(req, res) {
const apiDeletes = async.mapLimit(bookingToDelete.user.credentials, 5, async (credential) => {
const bookingRefUid = bookingToDelete.references.filter((ref) => ref.type === credential.type)[0].uid;
return await deleteEvent(credential, bookingRefUid);
if(credential.type.endsWith("_calendar")) {
return await deleteEvent(credential, bookingRefUid);
} else if(credential.type.endsWith("_video")) {
return await deleteMeeting(credential, bookingRefUid);
}
});
const attendeeDeletes = prisma.attendee.deleteMany({
where: {

View File

@ -0,0 +1,29 @@
import type {NextApiRequest, NextApiResponse} from 'next';
import {getSession} from 'next-auth/client';
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});
if (!session) { res.status(401).json({message: 'You must be logged in to do this'}); return; }
// Get user
const user = await prisma.user.findFirst({
where: {
email: session.user.email,
},
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});
}
}

View File

@ -0,0 +1,39 @@
import type {NextApiRequest, NextApiResponse} from 'next';
import {getSession} from "next-auth/client";
import prisma from "../../../../lib/prisma";
const client_id = process.env.ZOOM_CLIENT_ID;
const client_secret = process.env.ZOOM_CLIENT_SECRET;
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { code } = req.query;
// Check that user is authenticated
const session = await getSession({req: req});
if (!session) { 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 authHeader = 'Basic ' + Buffer.from(client_id + ':' + client_secret).toString('base64');
return new Promise( async (resolve, reject) => {
const result = await fetch('https://zoom.us/oauth/token?grant_type=authorization_code&code=' + code + '&redirect_uri=' + redirectUri, {
method: 'POST',
headers: {
Authorization: authHeader
}
})
.then(res => res.json());
await prisma.credential.create({
data: {
type: 'zoom_video',
key: result,
userId: session.user.id
}
});
res.redirect('/integrations');
resolve();
});
}

View File

@ -1,10 +1,10 @@
import Head from 'next/head';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { useRef, useState } from 'react';
import Select, { OptionBase } from 'react-select';
import {useRouter} from 'next/router';
import {useRef, useState} from 'react';
import Select, {OptionBase} from 'react-select';
import prisma from '../../../lib/prisma';
import { LocationType } from '../../../lib/location';
import {LocationType} from '../../../lib/location';
import Shell from '../../../components/Shell';
import { useSession, getSession } from 'next-auth/client';
import {

View File

@ -3,11 +3,10 @@ import Link from 'next/link';
import prisma from '../../lib/prisma';
import Modal from '../../components/Modal';
import Shell from '../../components/Shell';
import { useRouter } from 'next/router';
import { useRef } from 'react';
import { useState } from 'react';
import { useSession, getSession } from 'next-auth/client';
import { PlusIcon, ClockIcon } from '@heroicons/react/outline';
import {useRouter} from 'next/router';
import {useRef, useState} from 'react';
import {getSession, useSession} from 'next-auth/client';
import {ClockIcon, PlusIcon} from '@heroicons/react/outline';
export default function Availability(props) {
const [ session, loading ] = useSession();

View File

@ -2,8 +2,8 @@ import Head from 'next/head';
import Link from 'next/link';
import prisma from '../lib/prisma';
import Shell from '../components/Shell';
import { signIn, useSession, getSession } from 'next-auth/client';
import { ClockIcon, CheckIcon, InformationCircleIcon } from '@heroicons/react/outline';
import {getSession, useSession} from 'next-auth/client';
import {CheckIcon, ClockIcon, InformationCircleIcon} from '@heroicons/react/outline';
import DonateBanner from '../components/DonateBanner';
function classNames(...classes) {
@ -206,10 +206,13 @@ export default function Home(props) {
<li className="pb-4 flex">
{integration.type == 'google_calendar' && <img className="h-10 w-10 mr-2" src="integrations/google-calendar.png" alt="Google Calendar" />}
{integration.type == 'office365_calendar' && <img className="h-10 w-10 mr-2" src="integrations/office-365.png" alt="Office 365 / Outlook.com Calendar" />}
{integration.type == 'zoom_video' && <img className="h-10 w-10 mr-2" src="integrations/zoom.png" alt="Zoom" />}
<div className="ml-3">
{integration.type == 'office365_calendar' && <p className="text-sm font-medium text-gray-900">Office 365 / Outlook.com Calendar</p>}
{integration.type == 'google_calendar' && <p className="text-sm font-medium text-gray-900">Google Calendar</p>}
<p className="text-sm text-gray-500">Calendar Integration</p>
{integration.type == 'zoom_video' && <p className="text-sm font-medium text-gray-900">Zoom</p>}
{integration.type.endsWith('_calendar') && <p className="text-sm text-gray-500">Calendar Integration</p>}
{integration.type.endsWith('_video') && <p className="text-sm text-gray-500">Video Conferencing</p>}
</div>
</li>
)}

View File

@ -6,7 +6,7 @@ import {useEffect, useState} from 'react';
import {getSession, useSession} from 'next-auth/client';
import {CalendarIcon, CheckCircleIcon, ChevronRightIcon, PlusIcon, XCircleIcon} from '@heroicons/react/solid';
import {InformationCircleIcon} from '@heroicons/react/outline';
import { Switch } from '@headlessui/react'
import {Switch} from '@headlessui/react'
export default function Home({ integrations }) {
const [session, loading] = useSession();
@ -107,6 +107,7 @@ export default function Home({ integrations }) {
<p className="text-sm font-medium text-blue-600 truncate">{ig.title}</p>
<p className="flex items-center text-sm text-gray-500">
{ig.type.endsWith('_calendar') && <span className="truncate">Calendar Integration</span>}
{ig.type.endsWith('_video') && <span className="truncate">Video Conferencing</span>}
</p>
</div>
<div className="hidden md:block">
@ -363,14 +364,21 @@ export async function getServerSideProps(context) {
type: "google_calendar",
title: "Google Calendar",
imageSrc: "integrations/google-calendar.png",
description: "For personal and business accounts",
description: "For personal and business calendars",
}, {
installed: !!(process.env.MS_GRAPH_CLIENT_ID && process.env.MS_GRAPH_CLIENT_SECRET),
type: "office365_calendar",
credential: credentials.find( (integration) => integration.type === "office365_calendar" ) || null,
title: "Office 365 / Outlook.com Calendar",
imageSrc: "integrations/office-365.png",
description: "For personal and business accounts",
description: "For personal and business calendars",
}, {
installed: !!(process.env.ZOOM_CLIENT_ID && process.env.ZOOM_CLIENT_SECRET),
type: "zoom_video",
credential: credentials.find( (integration) => integration.type === "zoom_video" ) || null,
title: "Zoom",
imageSrc: "integrations/zoom.png",
description: "Video Conferencing",
} ];
return {

View File

@ -2,14 +2,14 @@ import Head from 'next/head';
import Link from 'next/link';
import prisma from '../lib/prisma';
import {useEffect, useState} from "react";
import { useRouter } from 'next/router';
import { CheckIcon } from '@heroicons/react/outline';
import { ClockIcon, CalendarIcon, LocationMarkerIcon } from '@heroicons/react/solid';
import {useRouter} from 'next/router';
import {CheckIcon} from '@heroicons/react/outline';
import {CalendarIcon, ClockIcon, LocationMarkerIcon} from '@heroicons/react/solid';
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import toArray from 'dayjs/plugin/toArray';
import timezone from 'dayjs/plugin/timezone';
import { createEvent } from 'ics';
import {createEvent} from 'ics';
import {getEventName} from "../lib/event";
dayjs.extend(utc);