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>' DATABASE_URL='postgresql://<user>:<pass>@<db-host>:<db-port>/<db-name>'
GOOGLE_API_CREDENTIALS='secret' 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 # Remove this var if you don't want Calendso to collect anonymous usage
NEXT_PUBLIC_TELEMETRY_KEY=js.2pvs2bbpqq1zxna97wcml.oi2jzirnbj1ev4tc57c5r 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. 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. 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 -->
## Roadmap ## Roadmap

View File

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

View File

@ -1,4 +1,3 @@
const {google} = require('googleapis'); const {google} = require('googleapis');
import createNewEventEmail from "./emails/new-event"; import createNewEventEmail from "./emails/new-event";
@ -7,14 +6,21 @@ const googleAuth = () => {
return new google.auth.OAuth2(client_id, client_secret, redirect_uris[0]); return new google.auth.OAuth2(client_id, client_secret, redirect_uris[0]);
}; };
function handleErrors(response) { function handleErrorsJson(response) {
if (!response.ok) { if (!response.ok) {
response.json().then( console.log ); response.json().then(console.log);
throw Error(response.statusText); throw Error(response.statusText);
} }
return response.json(); return response.json();
} }
function handleErrorsRaw(response) {
if (!response.ok) {
response.text().then(console.log);
throw Error(response.statusText);
}
return response.text();
}
const o365Auth = (credential) => { const o365Auth = (credential) => {
@ -22,7 +28,7 @@ const o365Auth = (credential) => {
const refreshAccessToken = (refreshToken) => fetch('https://login.microsoftonline.com/common/oauth2/v2.0/token', { const refreshAccessToken = (refreshToken) => fetch('https://login.microsoftonline.com/common/oauth2/v2.0/token', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({ body: new URLSearchParams({
'scope': 'User.Read Calendars.Read Calendars.ReadWrite', 'scope': 'User.Read Calendars.Read Calendars.ReadWrite',
'client_id': process.env.MS_GRAPH_CLIENT_ID, 'client_id': process.env.MS_GRAPH_CLIENT_ID,
@ -31,19 +37,24 @@ const o365Auth = (credential) => {
'client_secret': process.env.MS_GRAPH_CLIENT_SECRET, 'client_secret': process.env.MS_GRAPH_CLIENT_SECRET,
}) })
}) })
.then(handleErrors) .then(handleErrorsJson)
.then( (responseBody) => { .then((responseBody) => {
credential.key.access_token = responseBody.access_token; credential.key.access_token = responseBody.access_token;
credential.key.expiry_date = Math.round((+(new Date()) / 1000) + responseBody.expires_in); credential.key.expiry_date = Math.round((+(new Date()) / 1000) + responseBody.expires_in);
return credential.key.access_token; return credential.key.access_token;
}) })
return { 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 { interface CalendarEvent {
type: string; type: string;
title: string; title: string;
@ -57,6 +68,11 @@ interface CalendarEvent {
interface CalendarApiAdapter { interface CalendarApiAdapter {
createEvent(event: CalendarEvent): Promise<any>; createEvent(event: CalendarEvent): Promise<any>;
updateEvent(uid: String, event: CalendarEvent);
deleteEvent(uid: String);
getAvailability(dateFrom, dateTo): Promise<any>; getAvailability(dateFrom, dateTo): Promise<any>;
} }
@ -68,7 +84,7 @@ const MicrosoftOffice365Calendar = (credential): CalendarApiAdapter => {
let optional = {}; let optional = {};
if (event.location) { if (event.location) {
optional.location = { displayName: event.location }; optional.location = {displayName: event.location};
} }
return { return {
@ -99,7 +115,7 @@ const MicrosoftOffice365Calendar = (credential): CalendarApiAdapter => {
return { return {
getAvailability: (dateFrom, dateTo) => { getAvailability: (dateFrom, dateTo) => {
const payload = { const payload = {
schedules: [ credential.key.email ], schedules: [credential.key.email],
startTime: { startTime: {
dateTime: dateFrom, dateTime: dateFrom,
timeZone: 'UTC', timeZone: 'UTC',
@ -120,25 +136,42 @@ const MicrosoftOffice365Calendar = (credential): CalendarApiAdapter => {
}, },
body: JSON.stringify(payload) body: JSON.stringify(payload)
}) })
.then(handleErrors) .then(handleErrorsJson)
.then( responseBody => { .then(responseBody => {
return responseBody.value[0].scheduleItems.map( (evt) => ({ start: evt.start.dateTime + 'Z', end: evt.end.dateTime + 'Z' })) return responseBody.value[0].scheduleItems.map((evt) => ({
}) start: evt.start.dateTime + 'Z',
).catch( (err) => { end: evt.end.dateTime + 'Z'
}))
})
).catch((err) => {
console.log(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', method: 'POST',
headers: { headers: {
'Authorization': 'Bearer ' + accessToken, 'Authorization': 'Bearer ' + accessToken,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify(translateEvent(event)) body: JSON.stringify(translateEvent(event))
}).then(handleErrors).then( (responseBody) => ({ }).then(handleErrorsJson).then((responseBody) => ({
...responseBody, ...responseBody,
disableConfirmationEmail: true, 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(); const myGoogleAuth = googleAuth();
myGoogleAuth.setCredentials(credential.key); myGoogleAuth.setCredentials(credential.key);
return { return {
getAvailability: (dateFrom, dateTo) => new Promise( (resolve, reject) => { getAvailability: (dateFrom, dateTo) => new Promise((resolve, reject) => {
const calendar = google.calendar({ version: 'v3', auth: myGoogleAuth }); const calendar = google.calendar({version: 'v3', auth: myGoogleAuth});
calendar.calendarList calendar.calendarList
.list() .list()
.then(cals => { .then(cals => {
calendar.freebusy.query({ calendar.freebusy.query({
requestBody: { requestBody: {
timeMin: dateFrom, timeMin: dateFrom,
timeMax: dateTo, timeMax: dateTo,
items: cals.data.items items: cals.data.items
} }
}, (err, apires) => { }, (err, apires) => {
if (err) { if (err) {
reject(err); reject(err);
} }
resolve( resolve(
Object.values(apires.data.calendars).flatMap( Object.values(apires.data.calendars).flatMap(
(item) => item["busy"] (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 = { const payload = {
summary: event.title, summary: event.title,
description: event.description, description: event.description,
@ -198,12 +231,69 @@ const GoogleCalendar = (credential): CalendarApiAdapter => {
payload['location'] = event.location; payload['location'] = event.location;
} }
const calendar = google.calendar({version: 'v3', auth: myGoogleAuth }); const calendar = google.calendar({version: 'v3', auth: myGoogleAuth});
calendar.events.insert({ calendar.events.insert({
auth: myGoogleAuth, auth: myGoogleAuth,
calendarId: 'primary', calendarId: 'primary',
resource: payload, 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) { if (err) {
console.log('There was an error contacting the Calendar service: ' + err); console.log('There was an error contacting the Calendar service: ' + err);
return reject(err); return reject(err);
@ -215,10 +305,12 @@ const GoogleCalendar = (credential): CalendarApiAdapter => {
}; };
// factory // factory
const calendars = (withCredentials): CalendarApiAdapter[] => withCredentials.map( (cred) => { const calendars = (withCredentials): CalendarApiAdapter[] => withCredentials.map((cred) => {
switch(cred.type) { switch (cred.type) {
case 'google_calendar': return GoogleCalendar(cred); case 'google_calendar':
case 'office365_calendar': return MicrosoftOffice365Calendar(cred); return GoogleCalendar(cred);
case 'office365_calendar':
return MicrosoftOffice365Calendar(cred);
default: default:
return; // unknown credential, could be legacy? In any case, ignore 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( const getBusyTimes = (withCredentials, dateFrom, dateTo) => Promise.all(
calendars(withCredentials).map( c => c.getAvailability(dateFrom, dateTo) ) calendars(withCredentials).map(c => c.getAvailability(dateFrom, dateTo))
).then( ).then(
(results) => results.reduce( (acc, availability) => acc.concat(availability), []) (results) => results.reduce((acc, availability) => acc.concat(availability), [])
); );
const createEvent = (credential, calEvent: CalendarEvent): Promise<any> => { const createEvent = (credential, calEvent: CalendarEvent): Promise<any> => {
createNewEventEmail( createNewEventEmail(
calEvent, calEvent,
); );
if (credential) { if (credential) {
@ -244,4 +336,20 @@ const createEvent = (credential, calEvent: CalendarEvent): Promise<any> => {
return Promise.resolve({}); 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(utc);
dayjs.extend(timezone); dayjs.extend(timezone);
export default function createConfirmBookedEmail(calEvent: CalendarEvent, options: any = {}) { export default function createConfirmBookedEmail(calEvent: CalendarEvent, uid: String, options: any = {}) {
return sendEmail(calEvent, { return sendEmail(calEvent, uid, {
provider: { provider: {
transport: serverConfig.transport, transport: serverConfig.transport,
from: serverConfig.from, 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, provider,
}) => new Promise( (resolve, reject) => { }) => new Promise( (resolve, reject) => {
@ -32,9 +32,10 @@ const sendEmail = (calEvent: CalendarEvent, {
{ {
to: `${calEvent.attendees[0].name} <${calEvent.attendees[0].email}>`, to: `${calEvent.attendees[0].name} <${calEvent.attendees[0].email}>`,
from: `${calEvent.organizer.name} <${from}>`, from: `${calEvent.organizer.name} <${from}>`,
replyTo: calEvent.organizer.email,
subject: `Confirmed: ${calEvent.type} with ${calEvent.organizer.name} on ${inviteeStart.format('dddd, LL')}`, subject: `Confirmed: ${calEvent.type} with ${calEvent.organizer.name} on ${inviteeStart.format('dddd, LL')}`,
html: html(calEvent), html: html(calEvent, uid),
text: text(calEvent), text: text(calEvent, uid),
}, },
(error, info) => { (error, info) => {
if (error) { 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); const inviteeStart: Dayjs = <Dayjs>dayjs(calEvent.startTime).tz(calEvent.attendees[0].timeZone);
return ` return `
<div> <div>
@ -58,9 +62,13 @@ const html = (calEvent: CalendarEvent) => {
calEvent.location ? `<strong>Location:</strong> ${calEvent.location}<br /><br />` : '' calEvent.location ? `<strong>Location:</strong> ${calEvent.location}<br /><br />` : ''
) + ) +
`Additional notes:<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> </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', pageView: 'page_view',
dateSelected: 'date_selected', dateSelected: 'date_selected',
timeSelected: 'time_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 ) { 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.'); 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) => { const validJson = (jsonString) => {
try { 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", "@jitsu/sdk-js": "^2.0.1",
"@prisma/client": "^2.23.0", "@prisma/client": "^2.23.0",
"@tailwindcss/forms": "^0.2.1", "@tailwindcss/forms": "^0.2.1",
"async": "^3.2.0",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"dayjs": "^1.10.4", "dayjs": "^1.10.4",
"googleapis": "^67.1.1", "googleapis": "^67.1.1",
@ -26,7 +27,9 @@
"react-dom": "17.0.1", "react-dom": "17.0.1",
"react-phone-number-input": "^3.1.21", "react-phone-number-input": "^3.1.21",
"react-select": "^4.3.0", "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": { "devDependencies": {
"@types/node": "^14.14.33", "@types/node": "^14.14.33",

View File

@ -82,7 +82,7 @@ export default function Type(props) {
// Get router variables // Get router variables
const router = useRouter(); const router = useRouter();
const { user } = router.query; const { user, rescheduleUid } = router.query;
// Handle month changes // Handle month changes
const incrementMonth = () => { const incrementMonth = () => {
@ -180,7 +180,7 @@ export default function Type(props) {
// Display available times // Display available times
const availableTimes = times.map((time) => const availableTimes = times.map((time) =>
<div key={dayjs(time).utc().format()}> <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> <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> </Link>
</div> </div>
@ -190,7 +190,7 @@ export default function Type(props) {
<div> <div>
<Head> <Head>
<title> <title>
{props.eventType.title} | {props.user.name || props.user.username} | {rescheduleUid && "Reschedule"} {props.eventType.title} | {props.user.name || props.user.username} |
Calendso Calendso
</title> </title>
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
@ -413,7 +413,7 @@ export async function getServerSideProps(context) {
return { return {
props: { props: {
user, user,
eventType eventType,
}, },
} }
} }

View File

@ -1,16 +1,16 @@
import Head from 'next/head'; import Head from 'next/head';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter } from 'next/router'; import {useRouter} from 'next/router';
import { ClockIcon, CalendarIcon, LocationMarkerIcon } from '@heroicons/react/solid'; import {CalendarIcon, ClockIcon, LocationMarkerIcon} from '@heroicons/react/solid';
import prisma from '../../lib/prisma'; import prisma from '../../lib/prisma';
import {collectPageParameters, telemetryEventTypes, useTelemetry} from "../../lib/telemetry"; import {collectPageParameters, telemetryEventTypes, useTelemetry} from "../../lib/telemetry";
import { useEffect, useState } from "react"; import {useEffect, useState} from "react";
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc'; import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone'; import timezone from 'dayjs/plugin/timezone';
import 'react-phone-number-input/style.css'; import 'react-phone-number-input/style.css';
import PhoneInput from 'react-phone-number-input'; import PhoneInput from 'react-phone-number-input';
import { LocationType } from '../../lib/location'; import {LocationType} from '../../lib/location';
import Avatar from '../../components/Avatar'; import Avatar from '../../components/Avatar';
import Button from '../../components/ui/Button'; import Button from '../../components/ui/Button';
@ -19,7 +19,7 @@ dayjs.extend(timezone);
export default function Book(props) { export default function Book(props) {
const router = useRouter(); const router = useRouter();
const { date, user } = router.query; const { date, user, rescheduleUid } = router.query;
const [ is24h, setIs24h ] = useState(false); const [ is24h, setIs24h ] = useState(false);
const [ preferredTimeZone, setPreferredTimeZone ] = useState(''); const [ preferredTimeZone, setPreferredTimeZone ] = useState('');
@ -57,6 +57,7 @@ export default function Book(props) {
notes: event.target.notes.value, notes: event.target.notes.value,
timeZone: preferredTimeZone, timeZone: preferredTimeZone,
eventName: props.eventType.title, eventName: props.eventType.title,
rescheduleUid: rescheduleUid
}; };
if (selectedLocation) { 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']) { if (payload['location']) {
successUrl += "&location=" + encodeURIComponent(payload['location']); successUrl += "&location=" + encodeURIComponent(payload['location']);
} }
@ -86,7 +87,7 @@ export default function Book(props) {
return ( return (
<div> <div>
<Head> <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" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
@ -116,13 +117,13 @@ export default function Book(props) {
<div className="mb-4"> <div className="mb-4">
<label htmlFor="name" className="block text-sm font-medium text-gray-700">Your name</label> <label htmlFor="name" className="block text-sm font-medium text-gray-700">Your name</label>
<div className="mt-1"> <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> </div>
<div className="mb-4"> <div className="mb-4">
<label htmlFor="email" className="block text-sm font-medium text-gray-700">Email address</label> <label htmlFor="email" className="block text-sm font-medium text-gray-700">Email address</label>
<div className="mt-1"> <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>
</div> </div>
{locations.length > 1 && ( {locations.length > 1 && (
@ -144,11 +145,11 @@ export default function Book(props) {
</div>)} </div>)}
<div className="mb-4"> <div className="mb-4">
<label htmlFor="notes" className="block text-sm font-medium text-gray-700 mb-1">Additional notes</label> <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>
<div className="flex items-start"> <div className="flex items-start">
<Button type="submit" className="btn btn-primary">Confirm</Button> <Button type="submit" className="btn btn-primary">{rescheduleUid ? 'Reschedule' : 'Confirm'}</Button>
<Link href={"/" + props.user.username + "/" + props.eventType.slug}> <Link href={"/" + props.user.username + "/" + props.eventType.slug + (rescheduleUid ? "?rescheduleUid=" + rescheduleUid : "")}>
<a className="ml-2 btn btn-white">Cancel</a> <a className="ml-2 btn btn-white">Cancel</a>
</Link> </Link>
</div> </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 { return {
props: { props: {
user, 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 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 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) { export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { user } = req.query; const {user} = req.query;
const currentUser = await prisma.user.findFirst({ const currentUser = await prisma.user.findFirst({
where: { where: {
username: user, username: user,
}, },
select: { select: {
credentials: true, id: true,
timeZone: true, credentials: true,
email: true, timeZone: true,
name: 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 = { // Use all integrations
type: req.body.eventName, results = await async.mapLimit(currentUser.credentials, 5, async (credential) => {
title: req.body.eventName + ' with ' + req.body.name, const bookingRefUid = booking.references.filter((ref) => ref.type === credential.type)[0].uid;
description: req.body.notes, return await updateEvent(credential, bookingRefUid, evt)
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 result = await createEvent(currentUser.credentials[0], evt); // Clone elements
referencesToCreate = [...booking.references];
if (!result.disableConfirmationEmail) { // Now we can delete the old booking and its references.
createConfirmBookedEmail( let bookingReferenceDeletes = prisma.bookingReference.deleteMany({
evt 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 { getSession } from 'next-auth/client';
import prisma from '../../../../lib/prisma'; 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) { export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === 'GET') { 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 whoami = await fetch('https://graph.microsoft.com/v1.0/me', { headers: { 'Authorization': 'Bearer ' + responseBody.access_token } });
const graphUser = await whoami.json(); 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 responseBody.expiry_date = Math.round((+(new Date()) / 1000) + responseBody.expires_in); // set expiry date in seconds
delete responseBody.expires_in; delete responseBody.expires_in;

View File

@ -15,7 +15,7 @@ export default function Login({ csrfToken }) {
</div> </div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md"> <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"> <form className="space-y-6" method="post" action="/api/auth/callback/credentials">
<input name='csrfToken' type='hidden' defaultValue={csrfToken} hidden/> <input name='csrfToken' type='hidden' defaultValue={csrfToken} hidden/>
<div> <div>

View File

@ -7,7 +7,12 @@ import prisma from '../../../lib/prisma';
import { LocationType } from '../../../lib/location'; import { LocationType } from '../../../lib/location';
import Shell from '../../../components/Shell'; import Shell from '../../../components/Shell';
import { useSession, getSession } from 'next-auth/client'; 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) { export default function EventType(props) {
const router = useRouter(); const router = useRouter();
@ -129,191 +134,191 @@ export default function EventType(props) {
}; };
return ( return (
<div> <div>
<Head> <Head>
<title>{props.eventType.title} | Event Type | Calendso</title> <title>{props.eventType.title} | Event Type | Calendso</title>
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<Shell heading={'Event Type - ' + props.eventType.title}> <Shell heading={'Event Type - ' + props.eventType.title}>
<div className="grid grid-cols-3 gap-4"> <div className="grid grid-cols-3 gap-4">
<div className="col-span-2"> <div className="col-span-3 sm:col-span-2">
<div className="bg-white overflow-hidden shadow rounded-lg"> <div className="bg-white overflow-hidden shadow rounded-lg">
<div className="px-4 py-5 sm:p-6"> <div className="px-4 py-5 sm:p-6">
<form onSubmit={updateEventTypeHandler}> <form onSubmit={updateEventTypeHandler}>
<div className="mb-4"> <div className="mb-4">
<label htmlFor="title" className="block text-sm font-medium text-gray-700">Title</label> <label htmlFor="title" className="block text-sm font-medium text-gray-700">Title</label>
<div className="mt-1"> <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} /> <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> </div>
<div className="mb-4"> <div className="mb-4">
<label htmlFor="slug" className="block text-sm font-medium text-gray-700">URL</label> <label htmlFor="slug" className="block text-sm font-medium text-gray-700">URL</label>
<div className="mt-1"> <div className="mt-1">
<div className="flex rounded-md shadow-sm"> <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"> <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}/ {location.hostname}/{props.user.username}/
</span> </span>
<input <input
ref={slugRef} ref={slugRef}
type="text" type="text"
name="slug" name="slug"
id="slug" id="slug"
required 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" 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} 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>
</div> </div>
</div>
</div> </div>
<div> <div className="mb-4">
<div className="bg-white shadow rounded-lg"> <label htmlFor="location" className="block text-sm font-medium text-gray-700">Location</label>
<div className="px-4 py-5 sm:p-6"> {locations.length === 0 && <div className="mt-1 mb-2">
<h3 className="text-lg leading-6 font-medium text-gray-900"> <div className="flex rounded-md shadow-sm">
Delete this event type <Select
</h3> name="location"
<div className="mt-2 max-w-xl text-sm text-gray-500"> id="location"
<p> options={locationOptions}
Once you delete this event type, it will be permanently removed. isSearchable="false"
</p> 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"
</div> onChange={(e) => openLocationModal(e.value)}
<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> </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>
<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> </div>
{showLocationModal && </div>
<div className="fixed z-10 inset-0 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true"> </div>
<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="col-span-3 sm:col-span-1">
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" aria-hidden="true"></div> <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="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="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"> <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" /> <LocationMarkerIcon className="h-6 w-6 text-blue-600" />
</div> </div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"> <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> <h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title">Edit location</h3>
</div> </div>
</div> </div>
<form onSubmit={updateLocations}> <form onSubmit={updateLocations}>
<Select <Select
name="location" name="location"
defaultValue={selectedLocation} defaultValue={selectedLocation}
options={locationOptions} options={locationOptions}
isSearchable="false" 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" 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} onChange={setSelectedLocation}
/> />
<LocationOptions /> <LocationOptions />
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse"> <div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<button type="submit" className="btn btn-primary"> <button type="submit" className="btn btn-primary">
Update Update
</button> </button>
<button onClick={closeLocationModal} type="button" className="btn btn-white mr-2"> <button onClick={closeLocationModal} type="button" className="btn btn-white mr-2">
Cancel Cancel
</button> </button>
</div> </div>
</form> </form>
</div> </div>
</div> </div>
</div> </div>
} }
</Shell> </Shell>
</div> </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 Link from 'next/link';
import prisma from '../../lib/prisma'; import prisma from '../../lib/prisma';
import Shell from '../../components/Shell'; import Shell from '../../components/Shell';
import { useState } from 'react'; import {useState} from 'react';
import { useSession, getSession } from 'next-auth/client'; import {getSession, useSession} from 'next-auth/client';
import { CheckCircleIcon, XCircleIcon, ChevronRightIcon, PlusIcon } from '@heroicons/react/solid'; import {CheckCircleIcon, ChevronRightIcon, PlusIcon, XCircleIcon} from '@heroicons/react/solid';
import { InformationCircleIcon } from '@heroicons/react/outline'; import {InformationCircleIcon} from '@heroicons/react/outline';
export default function Home({ integrations }) { export default function Home({ integrations }) {
const [session, loading] = useSession(); const [session, loading] = useSession();
@ -32,7 +32,13 @@ export default function Home({ integrations }) {
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </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"> <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 ).length !== 0 ? <ul className="divide-y divide-gray-200">
{integrations.filter(ig => ig.credential).map( (ig) => (<li> {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) hidden Boolean @default(false)
user User? @relation(fields: [userId], references: [id]) user User? @relation(fields: [userId], references: [id])
userId Int? userId Int?
bookings Booking[]
} }
model Credential { model Credential {
@ -47,7 +48,7 @@ model User {
eventTypes EventType[] eventTypes EventType[]
credentials Credential[] credentials Credential[]
teams Membership[] teams Membership[]
bookings Booking[]
@@map(name: "users") @@map(name: "users")
} }
@ -81,4 +82,41 @@ model VerificationRequest {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@unique([identifier, token]) @@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: dependencies:
color-convert "^2.0.1" 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: any-promise@^1.0.0:
version "1.3.0" version "1.3.0"
resolved "https://registry.yarnpkg.com/any-promise/-/any-promise-1.3.0.tgz#abc6afeedcea52e809cdc0376aed3ce39635d17f" 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" resolved "https://registry.yarnpkg.com/ast-types/-/ast-types-0.13.2.tgz#df39b677a911a83f3a049644fb74fdded23cea48"
integrity sha512-uWMHxJxtfj/1oZClOxDEV1sQ1HCDkA4MG8Gr69KKeBjEVH0R84WlejZ0y2DcwyBlpAEMltmVYkVgqfLFb2oyiA== 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: at-least-node@^1.0.0:
version "1.0.0" version "1.0.0"
resolved "https://registry.yarnpkg.com/at-least-node/-/at-least-node-1.0.0.tgz#602cd4b46e844ad4effc92a8011a3c46e0238dc2" 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" resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.7.2.tgz#67a7d02c76c9da24f99d20808fcaded0e0e04be2"
integrity sha512-mRz/m/JVscCrkMyPqHc/bczi3OQHkLTqXHEFu0zDhK/qfv3UcOA4SVmRCLmos4bhjr9ekVQubj/R7waKapmiQg== 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: side-channel@^1.0.4:
version "1.0.4" version "1.0.4"
resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.4.tgz#efce5c8fdc104ee751b25c58d4290011fa5ea2cf" 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" resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee"
integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A== integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==
uuid@^8.0.0: uuid@^8.0.0, uuid@^8.3.2:
version "8.3.2" version "8.3.2"
resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2"
integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==