diff --git a/.env.example b/.env.example index 9c571f6b..269c048b 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,7 @@ -DATABASE_URL='postgresql://:@:' -GOOGLE_API_CREDENTIALS='secret' -NEXTAUTH_URL='http://localhost:3000' \ No newline at end of file +DATABASE_URL='postgresql://:@:' +GOOGLE_API_CREDENTIALS='secret' +NEXTAUTH_URL='http://localhost:3000' + +# Used for the Office 365 / Outlook.com Calendar integration +MS_GRAPH_CLIENT_ID= +MS_GRAPH_CLIENT_SECRET= \ No newline at end of file diff --git a/lib/calendarClient.ts b/lib/calendarClient.ts new file mode 100644 index 00000000..bfea4bf6 --- /dev/null +++ b/lib/calendarClient.ts @@ -0,0 +1,203 @@ + +const {google} = require('googleapis'); +const credentials = process.env.GOOGLE_API_CREDENTIALS; + +const googleAuth = () => { + const {client_secret, client_id, redirect_uris} = JSON.parse(process.env.GOOGLE_API_CREDENTIALS).web; + return new google.auth.OAuth2(client_id, client_secret, redirect_uris[0]); +}; + +function handleErrors(response) { + if (!response.ok) { + response.json().then( console.log ); + throw Error(response.statusText); + } + return response.json(); +} + + +const o365Auth = (credential) => { + + const isExpired = (expiryDate) => expiryDate < +(new Date()); + + const refreshAccessToken = (refreshToken) => fetch('https://login.microsoftonline.com/common/oauth2/v2.0/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + 'scope': 'Calendars.Read Calendars.ReadWrite', + 'client_id': process.env.MS_GRAPH_CLIENT_ID, + 'refresh_token': refreshToken, + 'grant_type': 'refresh_token', + 'client_secret': process.env.MS_GRAPH_CLIENT_SECRET, + }) + }) + .then(handleErrors) + .then( (responseBody) => { + credential.key.access_token = responseBody.access_token; + credential.key.expiry_date = Math.round((+(new Date()) / 1000) + responseBody.expires_in); + return credential.key.access_token; + }) + + return { + getToken: () => ! isExpired(credential.key.expiry_date) ? Promise.resolve(credential.key.access_token) : refreshAccessToken(credential.key.refresh_token) + }; +}; + +interface CalendarEvent { + title: string; + startTime: string; + timeZone: string; + endTime: string; + description?: string; + organizer: { name?: string, email: string }; + attendees: { name?: string, email: string }[]; +}; + +const MicrosoftOffice365Calendar = (credential) => { + + const auth = o365Auth(credential); + + const translateEvent = (event: CalendarEvent) => ({ + subject: event.title, + body: { + contentType: 'HTML', + content: event.description, + }, + start: { + dateTime: event.startTime, + timeZone: event.timeZone, + }, + end: { + dateTime: event.endTime, + timeZone: event.timeZone, + }, + attendees: event.attendees.map(attendee => ({ + emailAddress: { + address: attendee.email, + name: attendee.name + }, + type: "required" + })) + }); + + return { + getAvailability: (dateFrom, dateTo) => { + const payload = { + schedules: [ credential.key.email ], + startTime: { + dateTime: dateFrom, + timeZone: 'UTC', + }, + endTime: { + dateTime: dateTo, + timeZone: 'UTC', + }, + availabilityViewInterval: 60 + }; + + return auth.getToken().then( + (accessToken) => fetch('https://graph.microsoft.com/v1.0/me/calendar/getSchedule', { + method: 'post', + headers: { + 'Authorization': 'Bearer ' + accessToken, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(payload) + }) + .then(handleErrors) + .then( responseBody => { + return responseBody.value[0].scheduleItems.map( (evt) => ({ start: evt.start.dateTime, end: evt.end.dateTime })) + }) + ).catch( (err) => { + console.log(err); + }); + }, + 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)) + })) + } +}; + +const GoogleCalendar = (credential) => { + const myGoogleAuth = googleAuth(); + myGoogleAuth.setCredentials(credential.key); + return { + getAvailability: (dateFrom, dateTo) => new Promise( (resolve, reject) => { + const calendar = google.calendar({ version: 'v3', auth: myGoogleAuth }); + calendar.freebusy.query({ + requestBody: { + timeMin: dateFrom, + timeMax: dateTo, + items: [ { + "id": "primary" + } ] + } + }, (err, apires) => { + if (err) { + reject(err); + } + resolve(apires.data.calendars.primary.busy) + }); + }), + createEvent: (event: CalendarEvent) => new Promise( (resolve, reject) => { + const payload = { + summary: event.title, + description: event.description, + start: { + dateTime: event.startTime, + timeZone: event.timeZone, + }, + end: { + dateTime: event.endTime, + timeZone: event.timeZone, + }, + attendees: event.attendees, + reminders: { + useDefault: false, + overrides: [ + {'method': 'email', 'minutes': 60} + ], + }, + }; + + const calendar = google.calendar({version: 'v3', auth: myGoogleAuth }); + calendar.events.insert({ + auth: myGoogleAuth, + calendarId: 'primary', + resource: payload, + }, function(err, event) { + if (err) { + console.log('There was an error contacting the Calendar service: ' + err); + return reject(err); + } + return resolve(event.data); + }); + }) + }; +}; + +// factory +const calendars = (withCredentials): [] => 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 + } +}).filter(Boolean); + + +const getBusyTimes = (withCredentials, dateFrom, dateTo) => Promise.all( + calendars(withCredentials).map( c => c.getAvailability(dateFrom, dateTo) ) +).then( + (results) => results.reduce( (acc, availability) => acc.concat(availability) ) +); + +const createEvent = (credential, evt: CalendarEvent) => calendars([ credential ])[0].createEvent(evt); + +export { getBusyTimes, createEvent, CalendarEvent }; \ No newline at end of file diff --git a/lib/integrations.ts b/lib/integrations.ts index 940ea153..44d9c612 100644 --- a/lib/integrations.ts +++ b/lib/integrations.ts @@ -2,16 +2,16 @@ export function getIntegrationName(name: String) { switch(name) { case "google_calendar": return "Google Calendar"; + case "office365_calendar": + return "Office 365 Calendar"; default: return "Unknown"; } } export function getIntegrationType(name: String) { - switch(name) { - case "google_calendar": - return "Calendar"; - default: - return "Unknown"; + if (name.endsWith('_calendar')) { + return 'Calendar'; } -} \ No newline at end of file + return "Unknown"; +} diff --git a/pages/[user]/[type].tsx b/pages/[user]/[type].tsx index 24f64f58..1b9392e7 100644 --- a/pages/[user]/[type].tsx +++ b/pages/[user]/[type].tsx @@ -48,15 +48,15 @@ export default function Type(props) { // Need to define the bounds of the 24-hour window const lowerBound = useMemo(() => { if(!selectedDate) { - return + return } - + return selectedDate.startOf('day') }, [selectedDate]) - + const upperBound = useMemo(() => { - if(!selectedDate) return - + if(!selectedDate) return + return selectedDate.endOf('day') }, [selectedDate]) @@ -81,8 +81,8 @@ export default function Type(props) { setLoading(true); const res = await fetch(`/api/availability/${user}?dateFrom=${lowerBound.utc().format()}&dateTo=${upperBound.utc().format()}`); - const data = await res.json(); - setBusy(data.primary.busy); + const busyTimes = await res.json(); + if (busyTimes.length > 0) setBusy(busyTimes); setLoading(false); }, [selectedDate]); @@ -145,7 +145,7 @@ export default function Type(props) { {dayjs.tz.guess()} - { isTimeOptionsOpen && + { isTimeOptionsOpen &&
@@ -240,4 +240,4 @@ export async function getServerSideProps(context) { eventType }, } -} +} \ No newline at end of file diff --git a/pages/api/availability/[user].ts b/pages/api/availability/[user].ts index f291b595..d3dfd856 100644 --- a/pages/api/availability/[user].ts +++ b/pages/api/availability/[user].ts @@ -1,8 +1,6 @@ import type { NextApiRequest, NextApiResponse } from 'next'; import prisma from '../../../lib/prisma'; -const {google} = require('googleapis'); - -const credentials = process.env.GOOGLE_API_CREDENTIALS; +import { getBusyTimes } from '../../../lib/calendarClient'; export default async function handler(req: NextApiRequest, res: NextApiResponse) { const { user } = req.query @@ -17,32 +15,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) } }); - let availability = []; - - authorise(getAvailability) - - // Set up Google API credentials - function authorise(callback) { - const {client_secret, client_id, redirect_uris} = JSON.parse(credentials).web; - const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uris[0]); - oAuth2Client.setCredentials(currentUser.credentials[0].key); - callback(oAuth2Client) - } - - function getAvailability(auth) { - const calendar = google.calendar({version: 'v3', auth}); - calendar.freebusy.query({ - requestBody: { - timeMin: req.query.dateFrom, - timeMax: req.query.dateTo, - items: [{ - "id": "primary" - }] - } - }, (err, apires) => { - if (err) return console.log('The API returned an error: ' + err); - availability = apires.data.calendars; - res.status(200).json(availability); - }); - } + const availability = await getBusyTimes(currentUser.credentials, req.query.dateFrom, req.query.dateTo); + res.status(200).json(availability); } diff --git a/pages/api/book/[user].ts b/pages/api/book/[user].ts index eadfb61a..dd00c245 100644 --- a/pages/api/book/[user].ts +++ b/pages/api/book/[user].ts @@ -1,8 +1,6 @@ import type { NextApiRequest, NextApiResponse } from 'next'; import prisma from '../../../lib/prisma'; -const {google} = require('googleapis'); - -const credentials = process.env.GOOGLE_API_CREDENTIALS; +import { createEvent, CalendarEvent } from '../../../lib/calendarClient'; export default async function handler(req: NextApiRequest, res: NextApiResponse) { const { user } = req.query; @@ -17,50 +15,18 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) } }); - authorise(bookEvent); + const evt: CalendarEvent = { + title: 'Meeting with ' + req.body.name, + description: req.body.notes, + startTime: req.body.start, + endTime: req.body.end, + timeZone: currentUser.timeZone, + attendees: [ + { email: req.body.email, name: req.body.name } + ] + }; - // Set up Google API credentials - function authorise(callback) { - const {client_secret, client_id, redirect_uris} = JSON.parse(credentials).web; - const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uris[0]); - oAuth2Client.setCredentials(currentUser.credentials[0].key); - callback(oAuth2Client); - } - - function bookEvent(auth) { - var event = { - 'summary': 'Meeting with ' + req.body.name, - 'description': req.body.notes, - 'start': { - 'dateTime': req.body.start, - 'timeZone': currentUser.timeZone, - }, - 'end': { - 'dateTime': req.body.end, - 'timeZone': currentUser.timeZone, - }, - 'attendees': [ - {'email': req.body.email}, - ], - 'reminders': { - 'useDefault': false, - 'overrides': [ - {'method': 'email', 'minutes': 60} - ], - }, - }; - - const calendar = google.calendar({version: 'v3', auth}); - calendar.events.insert({ - auth: auth, - calendarId: 'primary', - resource: event, - }, function(err, event) { - if (err) { - console.log('There was an error contacting the Calendar service: ' + err); - return; - } - res.status(200).json({message: 'Event created'}); - }); - } + // TODO: for now, first integration created; primary = obvious todo; ability to change primary. + const result = await createEvent(currentUser.credentials[0], evt); + res.status(200).json(result); } diff --git a/pages/api/integrations/googlecalendar/add.ts b/pages/api/integrations/googlecalendar/add.ts index 0e7be5a7..ccca79ac 100644 --- a/pages/api/integrations/googlecalendar/add.ts +++ b/pages/api/integrations/googlecalendar/add.ts @@ -30,8 +30,13 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) const authUrl = oAuth2Client.generateAuthUrl({ access_type: 'offline', scope: scopes, + // A refresh token is only returned the first time the user + // consents to providing access. For illustration purposes, + // setting the prompt to 'consent' will force this consent + // every time, forcing a refresh_token to be returned. + prompt: 'consent', }); res.status(200).json({url: authUrl}); } -} \ No newline at end of file +} diff --git a/pages/api/integrations/office365calendar/add.ts b/pages/api/integrations/office365calendar/add.ts new file mode 100644 index 00000000..e5c05cb9 --- /dev/null +++ b/pages/api/integrations/office365calendar/add.ts @@ -0,0 +1,35 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; +import { getSession } from 'next-auth/client'; +import prisma from '../../../../lib/prisma'; + +const scopes = ['Calendars.Read', 'Calendars.ReadWrite']; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method === 'GET') { + // Check that user is authenticated + const session = await getSession({req: req}); + + if (!session) { res.status(401).json({message: 'You must be logged in to do this'}); return; } + + // TODO: Add user ID to user session object + const user = await prisma.user.findFirst({ + where: { + email: session.user.email, + }, + select: { + id: true + } + }); + + const hostname = 'x-forwarded-host' in req.headers ? 'https://' + req.headers['x-forwarded-host'] : 'host' in req.headers ? (req.secure ? 'https://' : 'http://') + req.headers['host'] : ''; + if ( ! hostname || ! req.headers.referer.startsWith(hostname)) { + throw new Error('Unable to determine external url, check server settings'); + } + + function generateAuthUrl() { + return 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize?response_type=code&scope=' + scopes.join(' ') + '&client_id=' + process.env.MS_GRAPH_CLIENT_ID + '&redirect_uri=' + hostname + '/api/integrations/office365calendar/callback'; + } + + res.status(200).json({url: generateAuthUrl() }); + } +} diff --git a/pages/api/integrations/office365calendar/callback.ts b/pages/api/integrations/office365calendar/callback.ts new file mode 100644 index 00000000..989e9b7b --- /dev/null +++ b/pages/api/integrations/office365calendar/callback.ts @@ -0,0 +1,54 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; +import { getSession } from 'next-auth/client'; +import prisma from '../../../../lib/prisma'; +const scopes = ['offline_access', 'Calendars.Read', 'Calendars.ReadWrite']; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const { code } = req.query; + + // Check that user is authenticated + const session = await getSession({req: req}); + if (!session) { res.status(401).json({message: 'You must be logged in to do this'}); return; } + + // TODO: Add user ID to user session object + const user = await prisma.user.findFirst({ + where: { + email: session.user.email, + }, + select: { + id: true + } + }); + + const toUrlEncoded = payload => Object.keys(payload).map( (key) => key + '=' + encodeURIComponent(payload[ key ]) ).join('&'); + const hostname = 'x-forwarded-host' in req.headers ? 'https://' + req.headers['x-forwarded-host'] : 'host' in req.headers ? (req.secure ? 'https://' : 'http://') + req.headers['host'] : ''; + + const body = toUrlEncoded({ client_id: process.env.MS_GRAPH_CLIENT_ID, grant_type: 'authorization_code', code, scope: scopes.join(' '), redirect_uri: hostname + '/api/integrations/office365calendar/callback', client_secret: process.env.MS_GRAPH_CLIENT_SECRET }); + + const response = await fetch('https://login.microsoftonline.com/common/oauth2/v2.0/token', { method: 'POST', headers: { + 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8', + }, body }); + + const responseBody = await response.json(); + + if (!response.ok) { + return res.redirect('/integrations?error=' + JSON.stringify(responseBody)); + } + + 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; + responseBody.expiry_date = Math.round((+(new Date()) / 1000) + responseBody.expires_in); // set expiry date in seconds + delete responseBody.expires_in; + + const credential = await prisma.credential.create({ + data: { + type: 'office365_calendar', + key: responseBody, + userId: user.id + } + }); + + return res.redirect('/integrations'); +} diff --git a/pages/index.tsx b/pages/index.tsx index 458f44d2..572fad7b 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -56,16 +56,18 @@ export default function Home(props) {
    - {props.credentials.map((integration) => + {props.credentials.map((integration) =>
  • {integration.type == 'google_calendar' && Google Calendar} + {integration.type == 'office365_calendar' && Office 365 / Outlook.com Calendar}
    + {integration.type == 'office365_calendar' &&

    Office 365 / Outlook.com Calendar

    } {integration.type == 'google_calendar' &&

    Google Calendar

    } - {integration.type == 'google_calendar' &&

    Calendar Integration

    } +

    Calendar Integration

  • )} - {props.credentials.length == 0 && + {props.credentials.length == 0 &&

    You haven't added any integrations.

    @@ -93,7 +95,7 @@ export async function getServerSideProps(context) { id: true } }); - + credentials = await prisma.credential.findMany({ where: { userId: user.id, diff --git a/pages/integrations/index.tsx b/pages/integrations/index.tsx index 9f8b8d49..edb01a29 100644 --- a/pages/integrations/index.tsx +++ b/pages/integrations/index.tsx @@ -23,8 +23,8 @@ export default function Home(props) { setShowAddModal(!showAddModal); } - function googleCalendarHandler() { - fetch('/api/integrations/googlecalendar/add') + function integrationHandler(type) { + fetch('/api/integrations/' + type + '/add') .then((response) => response.json()) .then((data) => window.location.href = data.url); } @@ -47,18 +47,20 @@ export default function Home(props) {
    {integration.type == 'google_calendar' && Google Calendar} + {integration.type == 'office365_calendar' && Office 365 / Outlook.com Calendar}
    {integration.type == 'google_calendar' &&

    Google Calendar

    } -

    - {integration.type == 'google_calendar' && Calendar Integration} + {integration.type == 'office365_calendar' &&

    Office365 / Outlook.com Calendar

    } +

    + {integration.type.endsWith('_calendar') && Calendar Integration}

    {integration.key && -

    +

    Connected

    @@ -105,7 +107,7 @@ export default function Home(props) {
    }
    - {showAddModal && + {showAddModal &&
    {/*