Merge pull request #253 from Nico-J/feature/cancel-reschedule-links

pull/255/head
Bailey Pumfleet 2021-06-10 07:53:38 +01:00 committed by GitHub
commit 4dacf64a18
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 4890 additions and 118 deletions

View File

@ -1,6 +1,6 @@
DATABASE_URL='postgresql://<user>:<pass>@<db-host>:<db-port>/<db-name>'
GOOGLE_API_CREDENTIALS='secret'
NEXTAUTH_URL='http://localhost:3000'
BASE_URL='http://localhost:3000'
# Remove this var if you don't want Calendso to collect anonymous usage
NEXT_PUBLIC_TELEMETRY_KEY=js.2pvs2bbpqq1zxna97wcml.oi2jzirnbj1ev4tc57c5r

View File

@ -1,4 +1,3 @@
const {google} = require('googleapis');
import createNewEventEmail from "./emails/new-event";
@ -7,14 +6,21 @@ const googleAuth = () => {
return new google.auth.OAuth2(client_id, client_secret, redirect_uris[0]);
};
function handleErrors(response) {
function handleErrorsJson(response) {
if (!response.ok) {
response.json().then( console.log );
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 o365Auth = (credential) => {
@ -22,7 +28,7 @@ const o365Auth = (credential) => {
const refreshAccessToken = (refreshToken) => fetch('https://login.microsoftonline.com/common/oauth2/v2.0/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
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,
@ -31,19 +37,24 @@ const o365Auth = (credential) => {
'client_secret': process.env.MS_GRAPH_CLIENT_SECRET,
})
})
.then(handleErrors)
.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)
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 }
interface Person {
name?: string,
email: string,
timeZone: string
}
interface CalendarEvent {
type: string;
title: string;
@ -57,6 +68,11 @@ interface CalendarEvent {
interface CalendarApiAdapter {
createEvent(event: CalendarEvent): Promise<any>;
updateEvent(uid: String, event: CalendarEvent);
deleteEvent(uid: String);
getAvailability(dateFrom, dateTo): Promise<any>;
}
@ -68,7 +84,7 @@ const MicrosoftOffice365Calendar = (credential): CalendarApiAdapter => {
let optional = {};
if (event.location) {
optional.location = { displayName: event.location };
optional.location = {displayName: event.location};
}
return {
@ -99,7 +115,7 @@ const MicrosoftOffice365Calendar = (credential): CalendarApiAdapter => {
return {
getAvailability: (dateFrom, dateTo) => {
const payload = {
schedules: [ credential.key.email ],
schedules: [credential.key.email],
startTime: {
dateTime: dateFrom,
timeZone: 'UTC',
@ -120,25 +136,42 @@ const MicrosoftOffice365Calendar = (credential): CalendarApiAdapter => {
},
body: JSON.stringify(payload)
})
.then(handleErrors)
.then( responseBody => {
return responseBody.value[0].scheduleItems.map( (evt) => ({ start: evt.start.dateTime + 'Z', end: evt.end.dateTime + 'Z' }))
})
).catch( (err) => {
.then(handleErrorsJson)
.then(responseBody => {
return responseBody.value[0].scheduleItems.map((evt) => ({
start: evt.start.dateTime + 'Z',
end: evt.end.dateTime + 'Z'
}))
})
).catch((err) => {
console.log(err);
});
},
createEvent: (event: CalendarEvent) => auth.getToken().then( accessToken => fetch('https://graph.microsoft.com/v1.0/me/calendar/events', {
createEvent: (event: CalendarEvent) => auth.getToken().then(accessToken => fetch('https://graph.microsoft.com/v1.0/me/calendar/events', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + accessToken,
'Content-Type': 'application/json',
},
body: JSON.stringify(translateEvent(event))
}).then(handleErrors).then( (responseBody) => ({
}).then(handleErrorsJson).then((responseBody) => ({
...responseBody,
disableConfirmationEmail: true,
})))
}))),
deleteEvent: (uid: String) => auth.getToken().then(accessToken => fetch('https://graph.microsoft.com/v1.0/me/calendar/events/' + uid, {
method: 'DELETE',
headers: {
'Authorization': 'Bearer ' + accessToken
}
}).then(handleErrorsRaw)),
updateEvent: (uid: String, event: CalendarEvent) => auth.getToken().then(accessToken => fetch('https://graph.microsoft.com/v1.0/me/calendar/events/' + uid, {
method: 'PATCH',
headers: {
'Authorization': 'Bearer ' + accessToken,
'Content-Type': 'application/json'
},
body: JSON.stringify(translateEvent(event))
}).then(handleErrorsRaw)),
}
};
@ -146,34 +179,34 @@ const GoogleCalendar = (credential): CalendarApiAdapter => {
const myGoogleAuth = googleAuth();
myGoogleAuth.setCredentials(credential.key);
return {
getAvailability: (dateFrom, dateTo) => new Promise( (resolve, reject) => {
const calendar = google.calendar({ version: 'v3', auth: myGoogleAuth });
getAvailability: (dateFrom, dateTo) => new Promise((resolve, reject) => {
const calendar = google.calendar({version: 'v3', auth: myGoogleAuth});
calendar.calendarList
.list()
.then(cals => {
calendar.freebusy.query({
requestBody: {
timeMin: dateFrom,
timeMax: dateTo,
items: cals.data.items
}
}, (err, apires) => {
if (err) {
reject(err);
}
resolve(
Object.values(apires.data.calendars).flatMap(
(item) => item["busy"]
)
)
.list()
.then(cals => {
calendar.freebusy.query({
requestBody: {
timeMin: dateFrom,
timeMax: dateTo,
items: cals.data.items
}
}, (err, apires) => {
if (err) {
reject(err);
}
resolve(
Object.values(apires.data.calendars).flatMap(
(item) => item["busy"]
)
)
});
})
.catch((err) => {
reject(err);
});
})
.catch((err) => {
reject(err);
});
}),
createEvent: (event: CalendarEvent) => new Promise( (resolve, reject) => {
createEvent: (event: CalendarEvent) => new Promise((resolve, reject) => {
const payload = {
summary: event.title,
description: event.description,
@ -198,12 +231,69 @@ const GoogleCalendar = (credential): CalendarApiAdapter => {
payload['location'] = event.location;
}
const calendar = google.calendar({version: 'v3', auth: myGoogleAuth });
const calendar = google.calendar({version: 'v3', auth: myGoogleAuth});
calendar.events.insert({
auth: myGoogleAuth,
calendarId: 'primary',
resource: payload,
}, function(err, event) {
}, 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;
}
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);
}
return resolve(event.data);
});
}),
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);
@ -215,10 +305,12 @@ 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);
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
}
@ -226,15 +318,15 @@ const calendars = (withCredentials): CalendarApiAdapter[] => withCredentials.map
const getBusyTimes = (withCredentials, dateFrom, dateTo) => Promise.all(
calendars(withCredentials).map( c => c.getAvailability(dateFrom, dateTo) )
calendars(withCredentials).map(c => c.getAvailability(dateFrom, dateTo))
).then(
(results) => results.reduce( (acc, availability) => acc.concat(availability), [])
(results) => results.reduce((acc, availability) => acc.concat(availability), [])
);
const createEvent = (credential, calEvent: CalendarEvent): Promise<any> => {
createNewEventEmail(
calEvent,
calEvent,
);
if (credential) {
@ -244,4 +336,20 @@ const createEvent = (credential, calEvent: CalendarEvent): Promise<any> => {
return Promise.resolve({});
};
export { getBusyTimes, createEvent, CalendarEvent };
const updateEvent = (credential, uid: String, calEvent: CalendarEvent): Promise<any> => {
if (credential) {
return calendars([credential])[0].updateEvent(uid, calEvent);
}
return Promise.resolve({});
};
const deleteEvent = (credential, uid: String): Promise<any> => {
if (credential) {
return calendars([credential])[0].deleteEvent(uid);
}
return Promise.resolve({});
};
export {getBusyTimes, createEvent, updateEvent, deleteEvent, CalendarEvent};

View File

@ -11,8 +11,8 @@ dayjs.extend(localizedFormat);
dayjs.extend(utc);
dayjs.extend(timezone);
export default function createConfirmBookedEmail(calEvent: CalendarEvent, options: any = {}) {
return sendEmail(calEvent, {
export default function createConfirmBookedEmail(calEvent: CalendarEvent, uid: String, options: any = {}) {
return sendEmail(calEvent, uid, {
provider: {
transport: serverConfig.transport,
from: serverConfig.from,
@ -21,7 +21,7 @@ export default function createConfirmBookedEmail(calEvent: CalendarEvent, option
});
}
const sendEmail = (calEvent: CalendarEvent, {
const sendEmail = (calEvent: CalendarEvent, uid: String, {
provider,
}) => new Promise( (resolve, reject) => {
@ -33,8 +33,8 @@ const sendEmail = (calEvent: CalendarEvent, {
to: `${calEvent.attendees[0].name} <${calEvent.attendees[0].email}>`,
from: `${calEvent.organizer.name} <${from}>`,
subject: `Confirmed: ${calEvent.type} with ${calEvent.organizer.name} on ${inviteeStart.format('dddd, LL')}`,
html: html(calEvent),
text: text(calEvent),
html: html(calEvent, uid),
text: text(calEvent, uid),
},
(error, info) => {
if (error) {
@ -46,7 +46,10 @@ const sendEmail = (calEvent: CalendarEvent, {
)
});
const html = (calEvent: CalendarEvent) => {
const html = (calEvent: CalendarEvent, uid: String) => {
const cancelLink = process.env.BASE_URL + '/cancel/' + uid;
const rescheduleLink = process.env.BASE_URL + '/reschedule/' + uid;
const inviteeStart: Dayjs = <Dayjs>dayjs(calEvent.startTime).tz(calEvent.attendees[0].timeZone);
return `
<div>
@ -58,9 +61,13 @@ const html = (calEvent: CalendarEvent) => {
calEvent.location ? `<strong>Location:</strong> ${calEvent.location}<br /><br />` : ''
) +
`Additional notes:<br />
${calEvent.description}
${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) => html(evt).replace('<br />', "\n").replace(/<[^>]+>/g, '');
const text = (evt: CalendarEvent, uid: String) => html(evt, uid).replace('<br />', "\n").replace(/<[^>]+>/g, '');

View File

@ -9,7 +9,8 @@ export const telemetryEventTypes = {
pageView: 'page_view',
dateSelected: 'date_selected',
timeSelected: 'time_selected',
bookingConfirmed: 'booking_confirmed'
bookingConfirmed: 'booking_confirmed',
bookingCancelled: 'booking_cancelled'
}
/**

View File

@ -9,6 +9,9 @@ if (process.env.NEXTAUTH_URL) {
if ( ! process.env.EMAIL_FROM ) {
console.warn('\x1b[33mwarn', '\x1b[0m', 'EMAIL_FROM environment variable is not set, this may indicate mailing is currently disabled. Please refer to the .env.example file.');
}
if (process.env.BASE_URL) {
process.env.NEXTAUTH_URL = process.env.BASE_URL + '/api/auth';
}
const validJson = (jsonString) => {
try {

3966
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -14,6 +14,7 @@
"@jitsu/sdk-js": "^2.0.1",
"@prisma/client": "^2.23.0",
"@tailwindcss/forms": "^0.2.1",
"async": "^3.2.0",
"bcryptjs": "^2.4.3",
"dayjs": "^1.10.4",
"googleapis": "^67.1.1",
@ -26,7 +27,9 @@
"react-dom": "17.0.1",
"react-phone-number-input": "^3.1.21",
"react-select": "^4.3.0",
"react-timezone-select": "^1.0.2"
"react-timezone-select": "^1.0.2",
"short-uuid": "^4.2.0",
"uuid": "^8.3.2"
},
"devDependencies": {
"@types/node": "^14.14.33",

View File

@ -82,7 +82,7 @@ export default function Type(props) {
// Get router variables
const router = useRouter();
const { user } = router.query;
const { user, rescheduleUid } = router.query;
// Handle month changes
const incrementMonth = () => {
@ -180,7 +180,7 @@ export default function Type(props) {
// Display available times
const availableTimes = times.map((time) =>
<div key={dayjs(time).utc().format()}>
<Link href={`/${props.user.username}/book?date=${dayjs(time).utc().format()}&type=${props.eventType.id}`}>
<Link href={`/${props.user.username}/book?date=${dayjs(time).utc().format()}&type=${props.eventType.id}` + (rescheduleUid ? "&rescheduleUid=" + rescheduleUid : "")}>
<a key={dayjs(time).format("hh:mma")} className="block font-medium mb-4 text-blue-600 border border-blue-600 rounded hover:text-white hover:bg-blue-600 py-4">{dayjs(time).tz(selectedTimeZone).format(is24h ? "HH:mm" : "hh:mma")}</a>
</Link>
</div>
@ -190,7 +190,7 @@ export default function Type(props) {
<div>
<Head>
<title>
{props.eventType.title} | {props.user.name || props.user.username} |
{rescheduleUid && "Reschedule"} {props.eventType.title} | {props.user.name || props.user.username} |
Calendso
</title>
<link rel="icon" href="/favicon.ico" />
@ -413,7 +413,7 @@ export async function getServerSideProps(context) {
return {
props: {
user,
eventType
eventType,
},
}
}

View File

@ -1,16 +1,16 @@
import Head from 'next/head';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { ClockIcon, CalendarIcon, LocationMarkerIcon } from '@heroicons/react/solid';
import {useRouter} from 'next/router';
import {CalendarIcon, ClockIcon, LocationMarkerIcon} from '@heroicons/react/solid';
import prisma from '../../lib/prisma';
import {collectPageParameters, telemetryEventTypes, useTelemetry} from "../../lib/telemetry";
import { useEffect, useState } from "react";
import {useEffect, useState} from "react";
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
import 'react-phone-number-input/style.css';
import PhoneInput from 'react-phone-number-input';
import { LocationType } from '../../lib/location';
import {LocationType} from '../../lib/location';
import Avatar from '../../components/Avatar';
import Button from '../../components/ui/Button';
@ -19,7 +19,7 @@ dayjs.extend(timezone);
export default function Book(props) {
const router = useRouter();
const { date, user } = router.query;
const { date, user, rescheduleUid } = router.query;
const [ is24h, setIs24h ] = useState(false);
const [ preferredTimeZone, setPreferredTimeZone ] = useState('');
@ -57,6 +57,7 @@ export default function Book(props) {
notes: event.target.notes.value,
timeZone: preferredTimeZone,
eventName: props.eventType.title,
rescheduleUid: rescheduleUid
};
if (selectedLocation) {
@ -75,7 +76,7 @@ export default function Book(props) {
}
);
let successUrl = `/success?date=${date}&type=${props.eventType.id}&user=${props.user.username}`;
let successUrl = `/success?date=${date}&type=${props.eventType.id}&user=${props.user.username}&reschedule=1`;
if (payload['location']) {
successUrl += "&location=" + encodeURIComponent(payload['location']);
}
@ -86,7 +87,7 @@ export default function Book(props) {
return (
<div>
<Head>
<title>Confirm your {props.eventType.title} with {props.user.name || props.user.username} | Calendso</title>
<title>{rescheduleUid ? 'Reschedule' : 'Confirm'} your {props.eventType.title} with {props.user.name || props.user.username} | Calendso</title>
<link rel="icon" href="/favicon.ico" />
</Head>
@ -116,13 +117,13 @@ export default function Book(props) {
<div className="mb-4">
<label htmlFor="name" className="block text-sm font-medium text-gray-700">Your name</label>
<div className="mt-1">
<input type="text" name="name" id="name" required className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md" placeholder="John Doe" />
<input type="text" name="name" id="name" required className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md" placeholder="John Doe" defaultValue={props.booking ? props.booking.attendees[0].name : ''} />
</div>
</div>
<div className="mb-4">
<label htmlFor="email" className="block text-sm font-medium text-gray-700">Email address</label>
<div className="mt-1">
<input type="email" name="email" id="email" required className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md" placeholder="you@example.com" />
<input type="email" name="email" id="email" required className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md" placeholder="you@example.com" defaultValue={props.booking ? props.booking.attendees[0].email : ''} />
</div>
</div>
{locations.length > 1 && (
@ -144,11 +145,11 @@ export default function Book(props) {
</div>)}
<div className="mb-4">
<label htmlFor="notes" className="block text-sm font-medium text-gray-700 mb-1">Additional notes</label>
<textarea name="notes" id="notes" rows={3} className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md" placeholder="Please share anything that will help prepare for our meeting."></textarea>
<textarea name="notes" id="notes" rows={3} className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md" placeholder="Please share anything that will help prepare for our meeting." defaultValue={props.booking ? props.booking.description : ''}></textarea>
</div>
<div className="flex items-start">
<Button type="submit" className="btn btn-primary">Confirm</Button>
<Link href={"/" + props.user.username + "/" + props.eventType.slug}>
<Button type="submit" className="btn btn-primary">{rescheduleUid ? 'Reschedule' : 'Confirm'}</Button>
<Link href={"/" + props.user.username + "/" + props.eventType.slug + (rescheduleUid ? "?rescheduleUid=" + rescheduleUid : "")}>
<a className="ml-2 btn btn-white">Cancel</a>
</Link>
</div>
@ -190,10 +191,30 @@ export async function getServerSideProps(context) {
}
});
let booking = null;
if(context.query.rescheduleUid) {
booking = await prisma.booking.findFirst({
where: {
uid: context.query.rescheduleUid
},
select: {
description: true,
attendees: {
select: {
email: true,
name: true
}
}
}
});
}
return {
props: {
user,
eventType
eventType,
booking
},
}
}

View File

@ -1,43 +1,152 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import type {NextApiRequest, NextApiResponse} from 'next';
import prisma from '../../../lib/prisma';
import { createEvent, CalendarEvent } from '../../../lib/calendarClient';
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';
const translator = short();
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { user } = req.query;
const {user} = req.query;
const currentUser = await prisma.user.findFirst({
where: {
username: user,
},
select: {
credentials: true,
timeZone: true,
email: true,
name: true,
const currentUser = await prisma.user.findFirst({
where: {
username: user,
},
select: {
id: true,
credentials: true,
timeZone: true,
email: true,
name: true,
}
});
const rescheduleUid = req.body.rescheduleUid;
const evt: CalendarEvent = {
type: req.body.eventName,
title: req.body.eventName + ' with ' + req.body.name,
description: req.body.notes,
startTime: req.body.start,
endTime: req.body.end,
location: req.body.location,
organizer: {email: currentUser.email, name: currentUser.name, timeZone: currentUser.timeZone},
attendees: [
{email: req.body.email, name: req.body.name, timeZone: req.body.timeZone}
]
};
const hashUID = translator.fromUUID(uuidv5(JSON.stringify(evt), uuidv5.URL));
const eventType = await prisma.eventType.findFirst({
where: {
userId: currentUser.id,
title: evt.type
},
select: {
id: true
}
});
let results = undefined;
let referencesToCreate = undefined;
if (rescheduleUid) {
// Reschedule event
const booking = await prisma.booking.findFirst({
where: {
uid: rescheduleUid
},
select: {
id: true,
references: {
select: {
id: true,
type: true,
uid: true
}
}
}
});
const evt: CalendarEvent = {
type: req.body.eventName,
title: req.body.eventName + ' with ' + req.body.name,
description: req.body.notes,
startTime: req.body.start,
endTime: req.body.end,
location: req.body.location,
organizer: { email: currentUser.email, name: currentUser.name, timeZone: currentUser.timeZone },
attendees: [
{ email: req.body.email, name: req.body.name, timeZone: req.body.timeZone }
]
};
// Use all integrations
results = await async.mapLimit(currentUser.credentials, 5, async (credential) => {
const bookingRefUid = booking.references.filter((ref) => ref.type === credential.type)[0].uid;
return await updateEvent(credential, bookingRefUid, evt)
});
const result = await createEvent(currentUser.credentials[0], evt);
// Clone elements
referencesToCreate = [...booking.references];
if (!result.disableConfirmationEmail) {
createConfirmBookedEmail(
evt
);
// Now we can delete the old booking and its references.
let bookingReferenceDeletes = prisma.bookingReference.deleteMany({
where: {
bookingId: booking.id
}
});
let attendeeDeletes = prisma.attendee.deleteMany({
where: {
bookingId: booking.id
}
});
let bookingDeletes = prisma.booking.delete({
where: {
uid: rescheduleUid
}
});
await Promise.all([
bookingReferenceDeletes,
attendeeDeletes,
bookingDeletes
]);
} else {
// Schedule event
results = await async.mapLimit(currentUser.credentials, 5, async (credential) => {
const response = await createEvent(credential, evt);
return {
type: credential.type,
response
};
});
referencesToCreate = results.map((result => {
return {
type: result.type,
uid: result.response.id
};
}));
}
await prisma.booking.create({
data: {
uid: hashUID,
userId: currentUser.id,
references: {
create: referencesToCreate
},
eventTypeId: eventType.id,
title: evt.title,
description: evt.description,
startTime: evt.startTime,
endTime: evt.endTime,
attendees: {
create: evt.attendees
}
}
});
res.status(200).json(result);
// If one of the integrations allows email confirmations, send it.
if (!results.every((result) => result.disableConfirmationEmail)) {
await createConfirmBookedEmail(
evt, hashUID
);
}
res.status(200).json(results);
}

63
pages/api/cancel.ts Normal file
View File

@ -0,0 +1,63 @@
import prisma from '../../lib/prisma';
import {deleteEvent} from "../../lib/calendarClient";
import async from 'async';
export default async function handler(req, res) {
if (req.method == "POST") {
const uid = req.body.uid;
const bookingToDelete = await prisma.booking.findFirst({
where: {
uid: uid,
},
select: {
id: true,
user: {
select: {
credentials: true
}
},
attendees: true,
references: {
select: {
uid: true,
type: true
}
}
}
});
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);
});
const attendeeDeletes = prisma.attendee.deleteMany({
where: {
bookingId: bookingToDelete.id
}
});
const bookingReferenceDeletes = prisma.bookingReference.deleteMany({
where: {
bookingId: bookingToDelete.id
}
});
const bookingDeletes = prisma.booking.delete({
where: {
id: bookingToDelete.id,
},
});
await Promise.all([
apiDeletes,
attendeeDeletes,
bookingReferenceDeletes,
bookingDeletes
]);
//TODO Perhaps send emails to user and client to tell about the cancellation
res.status(200).json({message: 'Booking successfully deleted.'});
} else {
res.status(405).json({message: 'This endpoint only accepts POST requests.'});
}
}

View File

@ -2,7 +2,7 @@ import type { NextApiRequest, NextApiResponse } from 'next';
import { getSession } from 'next-auth/client';
import prisma from '../../../../lib/prisma';
const scopes = ['User.Read', 'Calendars.Read', 'Calendars.ReadWrite'];
const scopes = ['User.Read', 'Calendars.Read', 'Calendars.ReadWrite', 'offline_access'];
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === 'GET') {

View File

@ -28,7 +28,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const whoami = await fetch('https://graph.microsoft.com/v1.0/me', { headers: { 'Authorization': 'Bearer ' + responseBody.access_token } });
const graphUser = await whoami.json();
responseBody.email = graphUser.mail;
// In some cases, graphUser.mail is null. Then graphUser.userPrincipalName most likely contains the email address.
responseBody.email = graphUser.mail ?? graphUser.userPrincipalName;
responseBody.expiry_date = Math.round((+(new Date()) / 1000) + responseBody.expires_in); // set expiry date in seconds
delete responseBody.expires_in;

196
pages/cancel/[uid].tsx Normal file
View File

@ -0,0 +1,196 @@
import {useState} from 'react';
import Head from 'next/head';
import prisma from '../../lib/prisma';
import {useRouter} from 'next/router';
import dayjs from 'dayjs';
import {CalendarIcon, ClockIcon, XIcon} 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 {collectPageParameters, telemetryEventTypes, useTelemetry} from "../../lib/telemetry";
dayjs.extend(isSameOrBefore);
dayjs.extend(isBetween);
dayjs.extend(utc);
dayjs.extend(timezone);
function classNames(...classes) {
return classes.filter(Boolean).join(' ')
}
export default function Type(props) {
// Get router variables
const router = useRouter();
const { uid } = router.query;
const [is24h, setIs24h] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const telemetry = useTelemetry();
const cancellationHandler = async (event) => {
setLoading(true);
let payload = {
uid: uid
};
telemetry.withJitsu(jitsu => jitsu.track(telemetryEventTypes.bookingCancelled, collectPageParameters()));
const res = await fetch(
'/api/cancel',
{
body: JSON.stringify(payload),
headers: {
'Content-Type': 'application/json'
},
method: 'POST'
}
);
if(res.status >= 200 && res.status < 300) {
router.push('/cancel/success?user=' + props.user.username + '&title=' + props.eventType.title);
} else {
setLoading(false);
setError("An error with status code " + res.status + " occurred. Please try again later.");
}
}
return (
<div>
<Head>
<title>
Cancel {props.booking.title} | {props.user.name || props.user.username} |
Calendso
</title>
<link rel="icon" href="/favicon.ico"/>
</Head>
<main className="max-w-3xl mx-auto my-24">
<div className="fixed z-10 inset-0 overflow-y-auto">
<div
className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div className="fixed inset-0 my-4 sm:my-0 transition-opacity" aria-hidden="true">
<span className="hidden sm:inline-block sm:align-middle sm:h-screen"
aria-hidden="true">&#8203;</span>
<div
className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-sm sm:w-full sm:p-6"
role="dialog" aria-modal="true" aria-labelledby="modal-headline">
{error && <div>
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100">
<XIcon className="h-6 w-6 text-red-600" />
</div>
<div className="mt-3 text-center sm:mt-5">
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title">
{error}
</h3>
</div>
</div>}
{!error && <div>
<div
className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100">
<XIcon className="h-6 w-6 text-red-600"/>
</div>
<div className="mt-3 text-center sm:mt-5">
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-headline">
Really cancel your booking?
</h3>
<div className="mt-2">
<p className="text-sm text-gray-500">
Instead, you could also reschedule it.
</p>
</div>
<div className="mt-4 border-t border-b py-4">
<h2 className="text-lg font-medium text-gray-600 mb-2">{props.booking.title}</h2>
<p className="text-gray-500 mb-1">
<ClockIcon className="inline-block w-4 h-4 mr-1 -mt-1"/>
{props.eventType.length} minutes
</p>
<p className="text-gray-500">
<CalendarIcon className="inline-block w-4 h-4 mr-1 -mt-1"/>
{dayjs.utc(props.booking.startTime).format((is24h ? 'H:mm' : 'h:mma') + ", dddd DD MMMM YYYY")}
</p>
</div>
</div>
</div>}
<div className="mt-5 sm:mt-6 text-center">
<div className="mt-5">
<button onClick={cancellationHandler} disabled={loading} type="button"
className="inline-flex items-center justify-center px-4 py-2 border border-transparent font-medium rounded-md text-red-700 bg-red-100 hover:bg-red-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:text-sm mx-2 btn-white">
Cancel
</button>
<button onClick={() => router.push('/reschedule/' + uid)} disabled={loading} type="button"
className="inline-flex items-center justify-center px-4 py-2 border border-transparent font-medium rounded-md text-gray-700 bg-gray-100 hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 sm:text-sm mx-2 btn-white">
Reschedule
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
</div>
);
}
export async function getServerSideProps(context) {
const user = await prisma.user.findFirst({
where: {
username: context.query.user,
},
select: {
id: true,
username: true,
name: true,
}
});
if (!user) {
return {
notFound: true,
}
}
const eventType = await prisma.eventType.findFirst({
where: {
userId: user.id,
slug: {
equals: context.query.type,
},
},
select: {
id: true,
title: true,
description: true,
length: true
}
});
const booking = await prisma.booking.findFirst({
where: {
uid: context.query.uid,
},
select: {
id: true,
title: true,
description: true,
startTime: true,
endTime: true,
attendees: true
}
});
// Workaround since Next.js has problems serializing date objects (see https://github.com/vercel/next.js/issues/11993)
const bookingObj = Object.assign({}, booking, {
startTime: booking.startTime.toString(),
endTime: booking.endTime.toString()
});
return {
props: {
user,
eventType,
booking: bookingObj
},
}
}

98
pages/cancel/success.tsx Normal file
View File

@ -0,0 +1,98 @@
import {useState} from 'react';
import Head from 'next/head';
import prisma from '../../lib/prisma';
import {useRouter} from 'next/router';
import dayjs from 'dayjs';
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 {CheckIcon} from "@heroicons/react/outline";
dayjs.extend(isSameOrBefore);
dayjs.extend(isBetween);
dayjs.extend(utc);
dayjs.extend(timezone);
function classNames(...classes) {
return classes.filter(Boolean).join(' ')
}
export default function Type(props) {
// Get router variables
const router = useRouter();
const [is24h, setIs24h] = useState(false);
return (
<div>
<Head>
<title>
Cancelled {props.title} | {props.user.name || props.user.username} |
Calendso
</title>
<link rel="icon" href="/favicon.ico"/>
</Head>
<main className="max-w-3xl mx-auto my-24">
<div className="fixed z-10 inset-0 overflow-y-auto">
<div
className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div className="fixed inset-0 my-4 sm:my-0 transition-opacity" aria-hidden="true">
<span className="hidden sm:inline-block sm:align-middle sm:h-screen"
aria-hidden="true">&#8203;</span>
<div
className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-sm sm:w-full sm:p-6"
role="dialog" aria-modal="true" aria-labelledby="modal-headline">
<div>
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100">
<CheckIcon className="h-6 w-6 text-green-600" />
</div>
<div className="mt-3 text-center sm:mt-5">
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-headline">
Cancellation successful
</h3>
<div className="mt-2">
<p className="text-sm text-gray-500">
Feel free to pick another event anytime.
</p>
</div>
</div>
</div>
<div className="mt-5 sm:mt-6 text-center">
<div className="mt-5">
<button onClick={() => router.push('/' + props.user.username)} type="button"
className="inline-flex items-center justify-center px-4 py-2 border border-transparent font-medium rounded-md text-gray-700 bg-gray-100 hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 sm:text-sm mx-2 btn-white">
Pick another
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
</div>
);
}
export async function getServerSideProps(context) {
const user = await prisma.user.findFirst({
where: {
username: context.query.user,
},
select: {
username: true,
name: true,
bio: true,
avatar: true,
eventTypes: true
}
});
return {
props: {
user,
title: context.query.title
},
}
}

View File

@ -0,0 +1,31 @@
import prisma from '../../lib/prisma';
export default function Type(props) {
// Just redirect to the schedule page to reschedule it.
return null;
}
export async function getServerSideProps(context) {
const booking = await prisma.booking.findFirst({
where: {
uid: context.query.uid,
},
select: {
id: true,
user: {select: {username: true}},
eventType: {select: {slug: true}},
title: true,
description: true,
startTime: true,
endTime: true,
attendees: true
}
});
return {
redirect: {
destination: '/' + booking.user.username + '/' + booking.eventType.slug + '?rescheduleUid=' + context.query.uid,
permanent: false,
},
}
}

View File

@ -0,0 +1,50 @@
-- CreateTable
CREATE TABLE "EventType" (
"id" SERIAL NOT NULL,
"title" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"description" TEXT,
"locations" JSONB,
"length" INTEGER NOT NULL,
"hidden" BOOLEAN NOT NULL DEFAULT false,
"userId" INTEGER,
PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Credential" (
"id" SERIAL NOT NULL,
"type" TEXT NOT NULL,
"key" JSONB NOT NULL,
"userId" INTEGER,
PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "users" (
"id" SERIAL NOT NULL,
"username" TEXT,
"name" TEXT,
"email" TEXT,
"password" TEXT,
"bio" TEXT,
"avatar" TEXT,
"timeZone" TEXT NOT NULL DEFAULT E'Europe/London',
"weekStart" TEXT DEFAULT E'Sunday',
"startTime" INTEGER NOT NULL DEFAULT 0,
"endTime" INTEGER NOT NULL DEFAULT 1440,
"created" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "users.email_unique" ON "users"("email");
-- AddForeignKey
ALTER TABLE "EventType" ADD FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Credential" ADD FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -0,0 +1,48 @@
-- CreateTable
CREATE TABLE "BookingReference" (
"id" SERIAL NOT NULL,
"type" TEXT NOT NULL,
"uid" TEXT NOT NULL,
"bookingId" INTEGER,
PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Attendee" (
"id" SERIAL NOT NULL,
"email" TEXT NOT NULL,
"name" TEXT NOT NULL,
"timeZone" TEXT NOT NULL,
"bookingId" INTEGER,
PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Booking" (
"id" SERIAL NOT NULL,
"uid" TEXT NOT NULL,
"userId" INTEGER,
"eventTypeId" INTEGER,
"title" TEXT NOT NULL,
"description" TEXT,
"startTime" TIMESTAMP(3) NOT NULL,
"endTime" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3),
PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "Attendee" ADD FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Booking" ADD FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Booking" ADD FOREIGN KEY ("eventTypeId") REFERENCES "EventType"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BookingReference" ADD FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -0,0 +1,8 @@
/*
Warnings:
- A unique constraint covering the columns `[uid]` on the table `Booking` will be added. If there are existing duplicate values, this will fail.
*/
-- CreateIndex
CREATE UNIQUE INDEX "Booking.uid_unique" ON "Booking"("uid");

View File

@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"

View File

@ -20,6 +20,7 @@ model EventType {
hidden Boolean @default(false)
user User? @relation(fields: [userId], references: [id])
userId Int?
bookings Booking[]
}
model Credential {
@ -46,7 +47,7 @@ model User {
eventTypes EventType[]
credentials Credential[]
teams Membership[]
bookings Booking[]
@@map(name: "users")
}
@ -70,4 +71,41 @@ model Membership {
user User @relation(fields: [userId], references: [id])
@@id([userId,teamId])
}
model BookingReference {
id Int @default(autoincrement()) @id
type String
uid String
booking Booking? @relation(fields: [bookingId], references: [id])
bookingId Int?
}
model Attendee {
id Int @default(autoincrement()) @id
email String
name String
timeZone String
booking Booking? @relation(fields: [bookingId], references: [id])
bookingId Int?
}
model Booking {
id Int @default(autoincrement()) @id
uid String @unique
user User? @relation(fields: [userId], references: [id])
userId Int?
references BookingReference[]
eventType EventType? @relation(fields: [eventTypeId], references: [id])
eventTypeId Int?
title String
description String?
startTime DateTime
endTime DateTime
attendees Attendee[]
createdAt DateTime @default(now())
updatedAt DateTime?
}

View File

@ -391,6 +391,11 @@ ansi-styles@^4.0.0, ansi-styles@^4.1.0:
dependencies:
color-convert "^2.0.1"
any-base@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/any-base/-/any-base-1.1.0.tgz#ae101a62bc08a597b4c9ab5b7089d456630549fe"
integrity sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg==
any-promise@^1.0.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/any-promise/-/any-promise-1.3.0.tgz#abc6afeedcea52e809cdc0376aed3ce39635d17f"
@ -457,6 +462,11 @@ ast-types@0.13.2:
resolved "https://registry.yarnpkg.com/ast-types/-/ast-types-0.13.2.tgz#df39b677a911a83f3a049644fb74fdded23cea48"
integrity sha512-uWMHxJxtfj/1oZClOxDEV1sQ1HCDkA4MG8Gr69KKeBjEVH0R84WlejZ0y2DcwyBlpAEMltmVYkVgqfLFb2oyiA==
async@^3.2.0:
version "3.2.0"
resolved "https://registry.yarnpkg.com/async/-/async-3.2.0.tgz#b3a2685c5ebb641d3de02d161002c60fc9f85720"
integrity sha512-TR2mEZFVOj2pLStYxLht7TyfuRzaydfpxr3k9RpHIzMgw7A64dzsdqCxH1WJyQdoe8T10nDXd9wnEigmiuHIZw==
at-least-node@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/at-least-node/-/at-least-node-1.0.0.tgz#602cd4b46e844ad4effc92a8011a3c46e0238dc2"
@ -2844,6 +2854,14 @@ shell-quote@1.7.2:
resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.7.2.tgz#67a7d02c76c9da24f99d20808fcaded0e0e04be2"
integrity sha512-mRz/m/JVscCrkMyPqHc/bczi3OQHkLTqXHEFu0zDhK/qfv3UcOA4SVmRCLmos4bhjr9ekVQubj/R7waKapmiQg==
short-uuid@^4.2.0:
version "4.2.0"
resolved "https://registry.yarnpkg.com/short-uuid/-/short-uuid-4.2.0.tgz#3706d9e7287ac589dc5ffe324d3e34817a07540b"
integrity sha512-r3cxuPPZSuF0QkKsK9bBR7u+7cwuCRzWzgjPh07F5N2iIUNgblnMHepBY16xgj5t1lG9iOP9k/TEafY1qhRzaw==
dependencies:
any-base "^1.1.0"
uuid "^8.3.2"
side-channel@^1.0.4:
version "1.0.4"
resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.4.tgz#efce5c8fdc104ee751b25c58d4290011fa5ea2cf"
@ -3275,7 +3293,7 @@ uuid@^3.3.3:
resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee"
integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==
uuid@^8.0.0:
uuid@^8.0.0, uuid@^8.3.2:
version "8.3.2"
resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2"
integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==