Merge pull request #253 from Nico-J/feature/cancel-reschedule-links
This commit is contained in:
		
						commit
						4dacf64a18
					
				
					 22 changed files with 4890 additions and 118 deletions
				
			
		|  | @ -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 | ||||
|  |  | |||
|  | @ -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) => { | ||||
|         .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' })) | ||||
|                     .then(handleErrorsJson) | ||||
|                     .then(responseBody => { | ||||
|                         return responseBody.value[0].scheduleItems.map((evt) => ({ | ||||
|                             start: evt.start.dateTime + 'Z', | ||||
|                             end: evt.end.dateTime + 'Z' | ||||
|                         })) | ||||
|                     }) | ||||
|             ).catch( (err) => { | ||||
|             ).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,8 +179,8 @@ 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 => { | ||||
|  | @ -173,7 +206,7 @@ const GoogleCalendar = (credential): CalendarApiAdapter => { | |||
|                 }); | ||||
| 
 | ||||
|         }), | ||||
|         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,9 +318,9 @@ 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> => { | ||||
|  | @ -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}; | ||||
|  |  | |||
|  | @ -11,8 +11,8 @@ dayjs.extend(localizedFormat); | |||
| dayjs.extend(utc); | ||||
| dayjs.extend(timezone); | ||||
| 
 | ||||
| export default function createConfirmBookedEmail(calEvent: CalendarEvent, options: any = {}) { | ||||
|   return sendEmail(calEvent, { | ||||
| export default function createConfirmBookedEmail(calEvent: CalendarEvent, uid: String, options: any = {}) { | ||||
|   return sendEmail(calEvent, uid, { | ||||
|     provider: { | ||||
|       transport: serverConfig.transport, | ||||
|       from: serverConfig.from, | ||||
|  | @ -21,7 +21,7 @@ export default function createConfirmBookedEmail(calEvent: CalendarEvent, option | |||
|   }); | ||||
| } | ||||
| 
 | ||||
| const sendEmail = (calEvent: CalendarEvent, { | ||||
| const sendEmail = (calEvent: CalendarEvent, uid: String, { | ||||
|   provider, | ||||
| }) => new Promise( (resolve, reject) => { | ||||
| 
 | ||||
|  | @ -33,8 +33,8 @@ const sendEmail = (calEvent: CalendarEvent, { | |||
|       to: `${calEvent.attendees[0].name} <${calEvent.attendees[0].email}>`, | ||||
|       from: `${calEvent.organizer.name} <${from}>`, | ||||
|       subject: `Confirmed: ${calEvent.type} with ${calEvent.organizer.name} on ${inviteeStart.format('dddd, LL')}`, | ||||
|       html: html(calEvent), | ||||
|       text: text(calEvent), | ||||
|       html: html(calEvent, uid), | ||||
|       text: text(calEvent, uid), | ||||
|     }, | ||||
|     (error, info) => { | ||||
|       if (error) { | ||||
|  | @ -46,7 +46,10 @@ const sendEmail = (calEvent: CalendarEvent, { | |||
|   ) | ||||
| }); | ||||
| 
 | ||||
| const html = (calEvent: CalendarEvent) => { | ||||
| const html = (calEvent: CalendarEvent, uid: String) => { | ||||
|   const cancelLink = process.env.BASE_URL + '/cancel/' + uid; | ||||
|   const rescheduleLink = process.env.BASE_URL + '/reschedule/' + uid; | ||||
| 
 | ||||
|   const inviteeStart: Dayjs = <Dayjs>dayjs(calEvent.startTime).tz(calEvent.attendees[0].timeZone); | ||||
|   return ` | ||||
|     <div> | ||||
|  | @ -58,9 +61,13 @@ const html = (calEvent: CalendarEvent) => { | |||
|         calEvent.location ? `<strong>Location:</strong> ${calEvent.location}<br /><br />` : '' | ||||
|       ) + | ||||
|       `Additional notes:<br />
 | ||||
|       ${calEvent.description} | ||||
|       ${calEvent.description}<br /> | ||||
|       <br /> | ||||
|       Need to change this event?<br /> | ||||
|       Cancel: <a href="${cancelLink}">${cancelLink}</a><br /> | ||||
|       Reschedule: <a href="${rescheduleLink}">${rescheduleLink}</a> | ||||
|     </div> | ||||
|   `;
 | ||||
| }; | ||||
| 
 | ||||
| const text = (evt: CalendarEvent) => html(evt).replace('<br />', "\n").replace(/<[^>]+>/g, ''); | ||||
| const text = (evt: CalendarEvent, uid: String) => html(evt, uid).replace('<br />', "\n").replace(/<[^>]+>/g, ''); | ||||
|  | @ -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' | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  |  | |||
|  | @ -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
									
								
							
							
						
						
									
										3966
									
								
								package-lock.json
									
									
									
										generated
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load diff
											
										
									
								
							|  | @ -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", | ||||
|  |  | |||
|  | @ -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, | ||||
|         }, | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -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 | ||||
|         }, | ||||
|     } | ||||
| } | ||||
|  | @ -1,16 +1,22 @@ | |||
| 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: { | ||||
|       id: true, | ||||
|       credentials: true, | ||||
|       timeZone: true, | ||||
|       email: true, | ||||
|  | @ -18,6 +24,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) | |||
|     } | ||||
|   }); | ||||
| 
 | ||||
|   const rescheduleUid = req.body.rescheduleUid; | ||||
| 
 | ||||
|   const evt: CalendarEvent = { | ||||
|     type: req.body.eventName, | ||||
|     title: req.body.eventName + ' with ' + req.body.name, | ||||
|  | @ -25,19 +33,120 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) | |||
|     startTime: req.body.start, | ||||
|     endTime: req.body.end, | ||||
|     location: req.body.location, | ||||
|         organizer: { email: currentUser.email, name: currentUser.name, timeZone: currentUser.timeZone }, | ||||
|     organizer: {email: currentUser.email, name: currentUser.name, timeZone: currentUser.timeZone}, | ||||
|     attendees: [ | ||||
|             { email: req.body.email, name: req.body.name, timeZone: req.body.timeZone } | ||||
|       {email: req.body.email, name: req.body.name, timeZone: req.body.timeZone} | ||||
|     ] | ||||
|   }; | ||||
| 
 | ||||
|     const result = await createEvent(currentUser.credentials[0], evt); | ||||
|   const hashUID = translator.fromUUID(uuidv5(JSON.stringify(evt), uuidv5.URL)); | ||||
| 
 | ||||
|     if (!result.disableConfirmationEmail) { | ||||
|         createConfirmBookedEmail( | ||||
|           evt | ||||
|   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 | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|     }); | ||||
| 
 | ||||
|     // 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) | ||||
|     }); | ||||
| 
 | ||||
|     // Clone elements
 | ||||
|     referencesToCreate = [...booking.references]; | ||||
| 
 | ||||
|     // 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 | ||||
|       } | ||||
|     } | ||||
|   }); | ||||
| 
 | ||||
|   // If one of the integrations allows email confirmations, send it.
 | ||||
|   if (!results.every((result) => result.disableConfirmationEmail)) { | ||||
|     await createConfirmBookedEmail( | ||||
|       evt, hashUID | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|     res.status(200).json(result); | ||||
|   res.status(200).json(results); | ||||
| } | ||||
|  |  | |||
							
								
								
									
										63
									
								
								pages/api/cancel.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										63
									
								
								pages/api/cancel.ts
									
									
									
									
									
										Normal 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.'}); | ||||
|   } | ||||
| } | ||||
|  | @ -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') { | ||||
|  |  | |||
|  | @ -28,7 +28,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) | |||
|     const whoami = await fetch('https://graph.microsoft.com/v1.0/me', { headers: { 'Authorization': 'Bearer ' + responseBody.access_token } }); | ||||
|     const graphUser = await whoami.json(); | ||||
| 
 | ||||
|     responseBody.email = graphUser.mail; | ||||
|     // In some cases, graphUser.mail is null. Then graphUser.userPrincipalName most likely contains the email address.
 | ||||
|     responseBody.email = graphUser.mail ?? graphUser.userPrincipalName; | ||||
|     responseBody.expiry_date = Math.round((+(new Date()) / 1000) + responseBody.expires_in); // set expiry date in seconds
 | ||||
|     delete responseBody.expires_in; | ||||
| 
 | ||||
|  |  | |||
							
								
								
									
										196
									
								
								pages/cancel/[uid].tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										196
									
								
								pages/cancel/[uid].tsx
									
									
									
									
									
										Normal 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">​</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
									
								
							
							
						
						
									
										98
									
								
								pages/cancel/success.tsx
									
									
									
									
									
										Normal 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">​</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 | ||||
|         }, | ||||
|     } | ||||
| } | ||||
							
								
								
									
										31
									
								
								pages/reschedule/[uid].tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								pages/reschedule/[uid].tsx
									
									
									
									
									
										Normal 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, | ||||
|         }, | ||||
|     } | ||||
| } | ||||
							
								
								
									
										50
									
								
								prisma/migrations/20210605225044_init/migration.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										50
									
								
								prisma/migrations/20210605225044_init/migration.sql
									
									
									
									
									
										Normal 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; | ||||
|  | @ -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; | ||||
|  | @ -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"); | ||||
							
								
								
									
										3
									
								
								prisma/migrations/migration_lock.toml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								prisma/migrations/migration_lock.toml
									
									
									
									
									
										Normal 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" | ||||
|  | @ -20,6 +20,7 @@ model EventType { | |||
|   hidden        Boolean @default(false) | ||||
|   user          User?   @relation(fields: [userId], references: [id]) | ||||
|   userId        Int? | ||||
|   bookings      Booking[] | ||||
| } | ||||
| 
 | ||||
| model Credential { | ||||
|  | @ -46,7 +47,7 @@ model User { | |||
|   eventTypes    EventType[] | ||||
|   credentials   Credential[] | ||||
|   teams         Membership[] | ||||
| 
 | ||||
|   bookings      Booking[] | ||||
|   @@map(name: "users") | ||||
| } | ||||
| 
 | ||||
|  | @ -71,3 +72,40 @@ model Membership { | |||
| 
 | ||||
|   @@id([userId,teamId]) | ||||
| } | ||||
| 
 | ||||
| model BookingReference { | ||||
|   id            Int         @default(autoincrement()) @id | ||||
|   type          String | ||||
|   uid           String | ||||
|   booking       Booking?    @relation(fields: [bookingId], references: [id]) | ||||
|   bookingId     Int? | ||||
| } | ||||
| 
 | ||||
| model Attendee { | ||||
|   id            Int         @default(autoincrement()) @id | ||||
|   email         String | ||||
|   name          String | ||||
|   timeZone      String | ||||
|   booking       Booking?    @relation(fields: [bookingId], references: [id]) | ||||
|   bookingId     Int? | ||||
| } | ||||
| 
 | ||||
| model Booking { | ||||
|   id            Int         @default(autoincrement()) @id | ||||
|   uid           String      @unique | ||||
|   user          User?       @relation(fields: [userId], references: [id]) | ||||
|   userId        Int? | ||||
|   references    BookingReference[] | ||||
|   eventType     EventType?  @relation(fields: [eventTypeId], references: [id]) | ||||
|   eventTypeId   Int? | ||||
| 
 | ||||
|   title         String | ||||
|   description   String? | ||||
|   startTime     DateTime | ||||
|   endTime       DateTime | ||||
| 
 | ||||
|   attendees     Attendee[] | ||||
| 
 | ||||
|   createdAt     DateTime    @default(now()) | ||||
|   updatedAt     DateTime? | ||||
| } | ||||
							
								
								
									
										20
									
								
								yarn.lock
									
									
									
									
									
								
							
							
						
						
									
										20
									
								
								yarn.lock
									
									
									
									
									
								
							|  | @ -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== | ||||
|  |  | |||
		Loading…
	
		Reference in a new issue
	
	 Bailey Pumfleet
						Bailey Pumfleet