Merge branch 'main' into feature/invite-external-users

pull/251/head
Bailey Pumfleet 2021-06-11 22:02:07 +01:00 committed by GitHub
commit 65c7960b76
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 5123 additions and 305 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

@ -121,6 +121,40 @@ You will also need Google API credentials. You can get this from the [Google API
9. Fill out the fields (remembering to encrypt your password with [BCrypt](https://bcrypt-generator.com/)) and click `Save 1 Record` to create your first user.
10. Open a browser to [http://localhost:3000](http://localhost:3000) and login with your just created, first user.
### Upgrading from earlier versions
1. Pull the current version:
```
git pull
```
2. Apply database migrations by running <b>one of</b> the following commands:
In a development environment, run:
```
npx prisma migrate dev
```
(this can clear your development database in some cases)
In a production environment, run:
```
npx prisma migrate deploy
```
3. Check the `.env.example` and compare it to your current `.env` file. In case there are any fields not present
in your current `.env`, add them there.
For the current version, especially check if the variable `BASE_URL` is present and properly set in your environment, for example:
```
BASE_URL='https://yourdomain.com'
```
4. Start the server. In a development environment, just do:
```
yarn dev
```
For a production build, run for example:
```
yarn build
yarn start
```
5. Enjoy the new version.
<!-- ROADMAP -->
## Roadmap

View File

@ -1,8 +1,8 @@
import Link from 'next/link';
import {useContext, useEffect, useState} from "react";
import { useRouter } from "next/router";
import { signOut, useSession } from 'next-auth/client';
import { MenuIcon, XIcon } from '@heroicons/react/outline';
import {useEffect, useState} from "react";
import {useRouter} from "next/router";
import {signOut, useSession} from 'next-auth/client';
import {MenuIcon, XIcon} from '@heroicons/react/outline';
import {collectPageParameters, telemetryEventTypes, useTelemetry} from "../lib/telemetry";
export default function Shell(props) {
@ -133,7 +133,7 @@ export default function Shell(props) {
</div>
}
</nav>
<header className="py-10">
<header className={props.noPaddingBottom ? "pt-10" : "py-10"}>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<h1 className="text-3xl font-bold text-white">
{props.heading}

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) => {
@ -32,9 +32,10 @@ const sendEmail = (calEvent: CalendarEvent, {
{
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),
text: text(calEvent),
html: html(calEvent, uid),
text: text(calEvent, uid),
},
(error, info) => {
if (error) {
@ -46,7 +47,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 +62,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 or no integrations are added, send it.
if (currentUser.credentials.length === 0 || !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;

View File

@ -15,7 +15,7 @@ export default function Login({ csrfToken }) {
</div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="bg-white py-8 px-4 shadow rounded-lg sm:px-10">
<div className="bg-white py-8 px-4 mx-2 shadow rounded-lg sm:px-10">
<form className="space-y-6" method="post" action="/api/auth/callback/credentials">
<input name='csrfToken' type='hidden' defaultValue={csrfToken} hidden/>
<div>

View File

@ -7,7 +7,12 @@ import prisma from '../../../lib/prisma';
import { LocationType } from '../../../lib/location';
import Shell from '../../../components/Shell';
import { useSession, getSession } from 'next-auth/client';
import { LocationMarkerIcon, PlusCircleIcon, XIcon, PhoneIcon } from '@heroicons/react/outline';
import {
LocationMarkerIcon,
PlusCircleIcon,
XIcon,
PhoneIcon,
} from '@heroicons/react/outline';
export default function EventType(props) {
const router = useRouter();
@ -129,191 +134,191 @@ export default function EventType(props) {
};
return (
<div>
<Head>
<title>{props.eventType.title} | Event Type | Calendso</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<Shell heading={'Event Type - ' + props.eventType.title}>
<div className="grid grid-cols-3 gap-4">
<div className="col-span-2">
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<form onSubmit={updateEventTypeHandler}>
<div className="mb-4">
<label htmlFor="title" className="block text-sm font-medium text-gray-700">Title</label>
<div className="mt-1">
<input ref={titleRef} type="text" name="title" id="title" required className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md" placeholder="Quick Chat" defaultValue={props.eventType.title} />
</div>
</div>
<div className="mb-4">
<label htmlFor="slug" className="block text-sm font-medium text-gray-700">URL</label>
<div className="mt-1">
<div className="flex rounded-md shadow-sm">
<div>
<Head>
<title>{props.eventType.title} | Event Type | Calendso</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<Shell heading={'Event Type - ' + props.eventType.title}>
<div className="grid grid-cols-3 gap-4">
<div className="col-span-3 sm:col-span-2">
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<form onSubmit={updateEventTypeHandler}>
<div className="mb-4">
<label htmlFor="title" className="block text-sm font-medium text-gray-700">Title</label>
<div className="mt-1">
<input ref={titleRef} type="text" name="title" id="title" required className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md" placeholder="Quick Chat" defaultValue={props.eventType.title} />
</div>
</div>
<div className="mb-4">
<label htmlFor="slug" className="block text-sm font-medium text-gray-700">URL</label>
<div className="mt-1">
<div className="flex rounded-md shadow-sm">
<span className="inline-flex items-center px-3 rounded-l-md border border-r-0 border-gray-300 bg-gray-50 text-gray-500 sm:text-sm">
{location.hostname}/{props.user.username}/
</span>
<input
ref={slugRef}
type="text"
name="slug"
id="slug"
required
className="flex-1 block w-full focus:ring-blue-500 focus:border-blue-500 min-w-0 rounded-none rounded-r-md sm:text-sm border-gray-300"
defaultValue={props.eventType.slug}
/>
</div>
</div>
</div>
<div className="mb-4">
<label htmlFor="location" className="block text-sm font-medium text-gray-700">Location</label>
{locations.length === 0 && <div className="mt-1 mb-2">
<div className="flex rounded-md shadow-sm">
<Select
name="location"
id="location"
options={locationOptions}
isSearchable="false"
className="flex-1 block w-full focus:ring-blue-500 focus:border-blue-500 min-w-0 rounded-none rounded-r-md sm:text-sm border-gray-300"
onChange={(e) => openLocationModal(e.value)}
/>
</div>
</div>}
{locations.length > 0 && <ul className="w-96 mt-1">
{locations.map( (location) => (
<li key={location.type} className="bg-blue-50 mb-2 p-2 border">
<div className="flex justify-between">
{location.type === LocationType.InPerson && (
<div className="flex-grow flex">
<LocationMarkerIcon className="h-6 w-6" />
<span className="ml-2 text-sm">{location.address}</span>
</div>
)}
{location.type === LocationType.Phone && (
<div className="flex-grow flex">
<PhoneIcon className="h-6 w-6" />
<span className="ml-2 text-sm">Phone call</span>
</div>
)}
<div className="flex">
<button type="button" onClick={() => openLocationModal(location.type)} className="mr-2 text-sm text-blue-600">Edit</button>
<button onClick={() => removeLocation(location)}>
<XIcon className="h-6 w-6 border-l-2 pl-1 hover:text-red-500 " />
</button>
</div>
</div>
</li>
))}
{locations.length > 0 && locations.length !== locationOptions.length && <li>
<button type="button" className="sm:flex sm:items-start text-sm text-blue-600" onClick={() => setShowLocationModal(true)}>
<PlusCircleIcon className="h-6 w-6" />
<span className="ml-1">Add another location option</span>
</button>
</li>}
</ul>}
</div>
<div className="mb-4">
<label htmlFor="description" className="block text-sm font-medium text-gray-700">Description</label>
<div className="mt-1">
<textarea ref={descriptionRef} name="description" id="description" className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md" placeholder="A quick video meeting." defaultValue={props.eventType.description}></textarea>
</div>
</div>
<div className="mb-4">
<label htmlFor="length" className="block text-sm font-medium text-gray-700">Length</label>
<div className="mt-1 relative rounded-md shadow-sm">
<input ref={lengthRef} type="number" name="length" id="length" required className="focus:ring-blue-500 focus:border-blue-500 block w-full pr-20 sm:text-sm border-gray-300 rounded-md" placeholder="15" defaultValue={props.eventType.length} />
<div className="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 text-sm">
minutes
</div>
</div>
</div>
<div className="my-8">
<div className="relative flex items-start">
<div className="flex items-center h-5">
<input
ref={isHiddenRef}
id="ishidden"
name="ishidden"
type="checkbox"
className="focus:ring-blue-500 h-4 w-4 text-blue-600 border-gray-300 rounded"
defaultChecked={props.eventType.hidden}
/>
</div>
<div className="ml-3 text-sm">
<label htmlFor="ishidden" className="font-medium text-gray-700">
Hide this event type
</label>
<p className="text-gray-500">Hide the event type from your page, so it can only be booked through it's URL.</p>
</div>
</div>
</div>
<button type="submit" className="btn btn-primary">Update</button>
<Link href="/availability"><a className="ml-2 btn btn-white">Cancel</a></Link>
</form>
</div>
<input
ref={slugRef}
type="text"
name="slug"
id="slug"
required
className="flex-1 block w-full focus:ring-blue-500 focus:border-blue-500 min-w-0 rounded-none rounded-r-md sm:text-sm border-gray-300"
defaultValue={props.eventType.slug}
/>
</div>
</div>
</div>
<div>
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<h3 className="text-lg leading-6 font-medium text-gray-900">
Delete this event type
</h3>
<div className="mt-2 max-w-xl text-sm text-gray-500">
<p>
Once you delete this event type, it will be permanently removed.
</p>
</div>
<div className="mt-5">
<button onClick={deleteEventTypeHandler} 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">
Delete event type
</button>
</div>
</div>
<div className="mb-4">
<label htmlFor="location" className="block text-sm font-medium text-gray-700">Location</label>
{locations.length === 0 && <div className="mt-1 mb-2">
<div className="flex rounded-md shadow-sm">
<Select
name="location"
id="location"
options={locationOptions}
isSearchable="false"
className="flex-1 block w-full focus:ring-blue-500 focus:border-blue-500 min-w-0 rounded-none rounded-r-md sm:text-sm border-gray-300"
onChange={(e) => openLocationModal(e.value)}
/>
</div>
</div>}
{locations.length > 0 && <ul className="w-96 mt-1">
{locations.map( (location) => (
<li key={location.type} className="bg-blue-50 mb-2 p-2 border">
<div className="flex justify-between">
{location.type === LocationType.InPerson && (
<div className="flex-grow flex">
<LocationMarkerIcon className="h-6 w-6" />
<span className="ml-2 text-sm">{location.address}</span>
</div>
)}
{location.type === LocationType.Phone && (
<div className="flex-grow flex">
<PhoneIcon className="h-6 w-6" />
<span className="ml-2 text-sm">Phone call</span>
</div>
)}
<div className="flex">
<button type="button" onClick={() => openLocationModal(location.type)} className="mr-2 text-sm text-blue-600">Edit</button>
<button onClick={() => removeLocation(location)}>
<XIcon className="h-6 w-6 border-l-2 pl-1 hover:text-red-500 " />
</button>
</div>
</div>
</li>
))}
{locations.length > 0 && locations.length !== locationOptions.length && <li>
<button type="button" className="sm:flex sm:items-start text-sm text-blue-600" onClick={() => setShowLocationModal(true)}>
<PlusCircleIcon className="h-6 w-6" />
<span className="ml-1">Add another location option</span>
</button>
</li>}
</ul>}
</div>
<div className="mb-4">
<label htmlFor="description" className="block text-sm font-medium text-gray-700">Description</label>
<div className="mt-1">
<textarea ref={descriptionRef} name="description" id="description" className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md" placeholder="A quick video meeting." defaultValue={props.eventType.description}></textarea>
</div>
</div>
<div className="mb-4">
<label htmlFor="length" className="block text-sm font-medium text-gray-700">Length</label>
<div className="mt-1 relative rounded-md shadow-sm">
<input ref={lengthRef} type="number" name="length" id="length" required className="focus:ring-blue-500 focus:border-blue-500 block w-full pr-20 sm:text-sm border-gray-300 rounded-md" placeholder="15" defaultValue={props.eventType.length} />
<div className="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 text-sm">
minutes
</div>
</div>
</div>
<div className="my-8">
<div className="relative flex items-start">
<div className="flex items-center h-5">
<input
ref={isHiddenRef}
id="ishidden"
name="ishidden"
type="checkbox"
className="focus:ring-blue-500 h-4 w-4 text-blue-600 border-gray-300 rounded"
defaultChecked={props.eventType.hidden}
/>
</div>
<div className="ml-3 text-sm">
<label htmlFor="ishidden" className="font-medium text-gray-700">
Hide this event type
</label>
<p className="text-gray-500">Hide the event type from your page, so it can only be booked through it's URL.</p>
</div>
</div>
</div>
<button type="submit" className="btn btn-primary">Update</button>
<Link href="/availability"><a className="ml-2 btn btn-white">Cancel</a></Link>
</form>
</div>
{showLocationModal &&
<div className="fixed z-10 inset-0 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" aria-hidden="true"></div>
</div>
</div>
<div className="col-span-3 sm:col-span-1">
<div className="bg-white shadow sm:rounded-lg">
<div className="px-4 py-5 sm:p-6">
<h3 className="text-lg mb-2 leading-6 font-medium text-gray-900">
Delete this event type
</h3>
<div className="mb-4 max-w-xl text-sm text-gray-500">
<p>
Once you delete this event type, it will be permanently removed.
</p>
</div>
<div>
<button onClick={deleteEventTypeHandler} 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">
Delete event type
</button>
</div>
</div>
</div>
</div>
</div>
{showLocationModal &&
<div className="fixed z-10 inset-0 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" aria-hidden="true"></div>
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span>
<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 shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
<div className="sm:flex sm:items-start mb-4">
<div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-blue-100 sm:mx-0 sm:h-10 sm:w-10">
<LocationMarkerIcon className="h-6 w-6 text-blue-600" />
</div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title">Edit location</h3>
</div>
</div>
<form onSubmit={updateLocations}>
<Select
name="location"
defaultValue={selectedLocation}
options={locationOptions}
isSearchable="false"
className="mb-2 flex-1 block w-full focus:ring-blue-500 focus:border-blue-500 min-w-0 rounded-none rounded-r-md sm:text-sm border-gray-300"
onChange={setSelectedLocation}
/>
<LocationOptions />
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<button type="submit" className="btn btn-primary">
Update
</button>
<button onClick={closeLocationModal} type="button" className="btn btn-white mr-2">
Cancel
</button>
</div>
</form>
</div>
</div>
</div>
}
</Shell>
</div>
<div className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
<div className="sm:flex sm:items-start mb-4">
<div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-blue-100 sm:mx-0 sm:h-10 sm:w-10">
<LocationMarkerIcon className="h-6 w-6 text-blue-600" />
</div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title">Edit location</h3>
</div>
</div>
<form onSubmit={updateLocations}>
<Select
name="location"
defaultValue={selectedLocation}
options={locationOptions}
isSearchable="false"
className="mb-2 flex-1 block w-full focus:ring-blue-500 focus:border-blue-500 min-w-0 rounded-none rounded-r-md sm:text-sm border-gray-300"
onChange={setSelectedLocation}
/>
<LocationOptions />
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<button type="submit" className="btn btn-primary">
Update
</button>
<button onClick={closeLocationModal} type="button" className="btn btn-white mr-2">
Cancel
</button>
</div>
</form>
</div>
</div>
</div>
}
</Shell>
</div>
);
}

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

@ -2,10 +2,10 @@ import Head from 'next/head';
import Link from 'next/link';
import prisma from '../../lib/prisma';
import Shell from '../../components/Shell';
import { useState } from 'react';
import { useSession, getSession } from 'next-auth/client';
import { CheckCircleIcon, XCircleIcon, ChevronRightIcon, PlusIcon } from '@heroicons/react/solid';
import { InformationCircleIcon } from '@heroicons/react/outline';
import {useState} from 'react';
import {getSession, useSession} from 'next-auth/client';
import {CheckCircleIcon, ChevronRightIcon, PlusIcon, XCircleIcon} from '@heroicons/react/solid';
import {InformationCircleIcon} from '@heroicons/react/outline';
export default function Home({ integrations }) {
const [session, loading] = useSession();
@ -32,7 +32,13 @@ export default function Home({ integrations }) {
<link rel="icon" href="/favicon.ico" />
</Head>
<Shell heading="Integrations">
<Shell heading="Integrations" noPaddingBottom>
<div className="text-right py-2">
<button onClick={toggleAddModal} type="button"
className="px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
Add new integration
</button>
</div>
<div className="bg-white shadow overflow-hidden rounded-lg">
{integrations.filter( (ig) => ig.credential ).length !== 0 ? <ul className="divide-y divide-gray-200">
{integrations.filter(ig => ig.credential).map( (ig) => (<li>

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 {
@ -47,7 +48,7 @@ model User {
eventTypes EventType[]
credentials Credential[]
teams Membership[]
bookings Booking[]
@@map(name: "users")
}
@ -81,4 +82,41 @@ model VerificationRequest {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([identifier, token])
}
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==