Skip to content

Commit

Permalink
Adds Office 365 / Outlook.com Calendar Integration
Browse files Browse the repository at this point in the history
* Added MS_GRAPH_CLIENT_* credentials to .env.example.
* Refactored the google integration into an abstraction layer for creating events and getting the user schedule from either Google or Office 365.
* FIX: when re-authorizing the Google Integration the refresh_token would no longer be set and the google integration would stop working.
* Updated Office 365 integration image
  • Loading branch information
emrysal authored Apr 21, 2021
1 parent ce398bd commit 8010abf
Show file tree
Hide file tree
Showing 12 changed files with 365 additions and 110 deletions.
10 changes: 7 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
DATABASE_URL='postgresql://<user>:<pass>@<db-host>:<db-port>'
GOOGLE_API_CREDENTIALS='secret'
NEXTAUTH_URL='http://localhost:3000'
DATABASE_URL='postgresql://<user>:<pass>@<db-host>:<db-port>'
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=
203 changes: 203 additions & 0 deletions lib/calendarClient.ts
Original file line number Diff line number Diff line change
@@ -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 };
12 changes: 6 additions & 6 deletions lib/integrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}
}
return "Unknown";
}
18 changes: 9 additions & 9 deletions pages/[user]/[type].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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])

Expand All @@ -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]);

Expand Down Expand Up @@ -145,7 +145,7 @@ export default function Type(props) {
<GlobeIcon className="inline-block w-4 h-4 mr-1 -mt-1"/>
{dayjs.tz.guess()} <ChevronDownIcon className="inline-block w-4 h-4 mb-1" />
</button>
{ isTimeOptionsOpen &&
{ isTimeOptionsOpen &&
<div className="bg-white rounded shadow p-4 absolute w-72">
<Switch.Group as="div" className="flex items-center">
<Switch.Label as="span" className="mr-3">
Expand Down Expand Up @@ -240,4 +240,4 @@ export async function getServerSideProps(context) {
eventType
},
}
}
}
34 changes: 3 additions & 31 deletions pages/api/availability/[user].ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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);
}
Loading

0 comments on commit 8010abf

Please sign in to comment.