Implemented calendar selection for availability checking.
Also upgraded outlook integration to be able to check all calendars instead of only the default one.pull/267/head
parent
a231ee6c0d
commit
d3b8431699
|
@ -35,3 +35,6 @@ yarn-error.log*
|
|||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# Webstorm
|
||||
.idea
|
||||
|
|
|
@ -110,6 +110,17 @@ paths:
|
|||
summary: Deletes an event type
|
||||
tags:
|
||||
- Availability
|
||||
/api/availability/calendars:
|
||||
post:
|
||||
description: Selects calendar for availability checking.
|
||||
summary: Adds selected calendar
|
||||
tags:
|
||||
- Availability
|
||||
delete:
|
||||
description: Removes a calendar from availability checking.
|
||||
summary: Deletes a selected calendar
|
||||
tags:
|
||||
- Availability
|
||||
/api/book/:user:
|
||||
post:
|
||||
description: Creates a booking in the user's calendar.
|
||||
|
|
|
@ -66,6 +66,13 @@ interface CalendarEvent {
|
|||
attendees: Person[];
|
||||
};
|
||||
|
||||
interface IntegrationCalendar {
|
||||
integration: string;
|
||||
primary: boolean;
|
||||
externalId: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface CalendarApiAdapter {
|
||||
createEvent(event: CalendarEvent): Promise<any>;
|
||||
|
||||
|
@ -73,7 +80,9 @@ interface CalendarApiAdapter {
|
|||
|
||||
deleteEvent(uid: String);
|
||||
|
||||
getAvailability(dateFrom, dateTo): Promise<any>;
|
||||
getAvailability(dateFrom, dateTo, selectedCalendars: IntegrationCalendar[]): Promise<any>;
|
||||
|
||||
listCalendars(): Promise<IntegrationCalendar[]>;
|
||||
}
|
||||
|
||||
const MicrosoftOffice365Calendar = (credential): CalendarApiAdapter => {
|
||||
|
@ -112,37 +121,59 @@ const MicrosoftOffice365Calendar = (credential): CalendarApiAdapter => {
|
|||
}
|
||||
};
|
||||
|
||||
return {
|
||||
getAvailability: (dateFrom, dateTo) => {
|
||||
const payload = {
|
||||
schedules: [credential.key.email],
|
||||
startTime: {
|
||||
dateTime: dateFrom,
|
||||
timeZone: 'UTC',
|
||||
},
|
||||
endTime: {
|
||||
dateTime: dateTo,
|
||||
timeZone: 'UTC',
|
||||
},
|
||||
availabilityViewInterval: 60
|
||||
};
|
||||
const integrationType = "office365_calendar";
|
||||
|
||||
return auth.getToken().then(
|
||||
(accessToken) => fetch('https://graph.microsoft.com/v1.0/me/calendar/getSchedule', {
|
||||
method: 'post',
|
||||
function listCalendars(): Promise<IntegrationCalendar[]> {
|
||||
return auth.getToken().then(accessToken => fetch('https://graph.microsoft.com/v1.0/me/calendars', {
|
||||
method: 'get',
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + accessToken,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
}).then(handleErrorsJson)
|
||||
.then(responseBody => {
|
||||
return responseBody.value.map(cal => {
|
||||
const calendar: IntegrationCalendar = {
|
||||
externalId: cal.id, integration: integrationType, name: cal.name, primary: cal.isDefaultCalendar
|
||||
}
|
||||
return calendar;
|
||||
});
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
getAvailability: (dateFrom, dateTo, selectedCalendars) => {
|
||||
const filter = "?$filter=start/dateTime ge '" + dateFrom + "' and end/dateTime le '" + dateTo + "'"
|
||||
return auth.getToken().then(
|
||||
(accessToken) => {
|
||||
const selectedCalendarIds = selectedCalendars.filter(e => e.integration === integrationType).map(e => e.externalId);
|
||||
if (selectedCalendarIds.length == 0 && selectedCalendars.length > 0){
|
||||
// Only calendars of other integrations selected
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
console.log("selectedCalendarIds.length: " + selectedCalendarIds.length)
|
||||
return (selectedCalendarIds.length == 0
|
||||
? listCalendars().then(cals => cals.map(e => e.externalId))
|
||||
: Promise.resolve(selectedCalendarIds).then(x => x)).then((ids: string[]) => {
|
||||
const urls = ids.map(calendarId => 'https://graph.microsoft.com/v1.0/me/calendars/' + calendarId + '/events' + filter)
|
||||
console.log("urls", urls)
|
||||
return Promise.all(urls.map(url => fetch(url, {
|
||||
method: 'get',
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + accessToken,
|
||||
'Prefer': 'outlook.timezone="Etc/GMT"'
|
||||
}
|
||||
})
|
||||
.then(handleErrorsJson)
|
||||
.then(responseBody => {
|
||||
return responseBody.value[0].scheduleItems.map((evt) => ({
|
||||
.then(responseBody => responseBody.value.map((evt) => ({
|
||||
start: evt.start.dateTime + 'Z',
|
||||
end: evt.end.dateTime + 'Z'
|
||||
}))
|
||||
))).then(results => results.reduce((acc, events) => acc.concat(events), []))
|
||||
})
|
||||
}
|
||||
).catch((err) => {
|
||||
console.log(err);
|
||||
});
|
||||
|
@ -172,28 +203,37 @@ const MicrosoftOffice365Calendar = (credential): CalendarApiAdapter => {
|
|||
},
|
||||
body: JSON.stringify(translateEvent(event))
|
||||
}).then(handleErrorsRaw)),
|
||||
listCalendars
|
||||
}
|
||||
};
|
||||
|
||||
const GoogleCalendar = (credential): CalendarApiAdapter => {
|
||||
const myGoogleAuth = googleAuth();
|
||||
myGoogleAuth.setCredentials(credential.key);
|
||||
const integrationType = "google_calendar";
|
||||
|
||||
return {
|
||||
getAvailability: (dateFrom, dateTo) => new Promise((resolve, reject) => {
|
||||
getAvailability: (dateFrom, dateTo, selectedCalendars) => new Promise((resolve, reject) => {
|
||||
const calendar = google.calendar({version: 'v3', auth: myGoogleAuth});
|
||||
calendar.calendarList
|
||||
.list()
|
||||
.then(cals => {
|
||||
const filteredItems = cals.data.items.filter(i => selectedCalendars.findIndex(e => e.externalId === i.id) > -1)
|
||||
if (filteredItems.length == 0 && selectedCalendars.length > 0){
|
||||
// Only calendars of other integrations selected
|
||||
resolve([]);
|
||||
}
|
||||
calendar.freebusy.query({
|
||||
requestBody: {
|
||||
timeMin: dateFrom,
|
||||
timeMax: dateTo,
|
||||
items: cals.data.items
|
||||
items: filteredItems.length > 0 ? filteredItems : cals.data.items
|
||||
}
|
||||
}, (err, apires) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
}
|
||||
|
||||
resolve(
|
||||
Object.values(apires.data.calendars).flatMap(
|
||||
(item) => item["busy"]
|
||||
|
@ -300,6 +340,22 @@ const GoogleCalendar = (credential): CalendarApiAdapter => {
|
|||
}
|
||||
return resolve(event.data);
|
||||
});
|
||||
}),
|
||||
listCalendars: () => new Promise((resolve, reject) => {
|
||||
const calendar = google.calendar({version: 'v3', auth: myGoogleAuth});
|
||||
calendar.calendarList
|
||||
.list()
|
||||
.then(cals => {
|
||||
resolve(cals.data.items.map(cal => {
|
||||
const calendar: IntegrationCalendar = {
|
||||
externalId: cal.id, integration: integrationType, name: cal.summary, primary: cal.primary
|
||||
}
|
||||
return calendar;
|
||||
}))
|
||||
})
|
||||
.catch((err) => {
|
||||
reject(err);
|
||||
});
|
||||
})
|
||||
};
|
||||
};
|
||||
|
@ -316,11 +372,18 @@ const calendars = (withCredentials): CalendarApiAdapter[] => withCredentials.map
|
|||
}
|
||||
}).filter(Boolean);
|
||||
|
||||
|
||||
const getBusyTimes = (withCredentials, dateFrom, dateTo) => Promise.all(
|
||||
calendars(withCredentials).map(c => c.getAvailability(dateFrom, dateTo))
|
||||
const getBusyTimes = (withCredentials, dateFrom, dateTo, selectedCalendars) => Promise.all(
|
||||
calendars(withCredentials).map(c => c.getAvailability(dateFrom, dateTo, selectedCalendars))
|
||||
).then(
|
||||
(results) => results.reduce((acc, availability) => acc.concat(availability), [])
|
||||
(results) => {
|
||||
return results.reduce((acc, availability) => acc.concat(availability), [])
|
||||
}
|
||||
);
|
||||
|
||||
const listCalendars = (withCredentials) => Promise.all(
|
||||
calendars(withCredentials).map(c => c.listCalendars())
|
||||
).then(
|
||||
(results) => results.reduce((acc, calendars) => acc.concat(calendars), [])
|
||||
);
|
||||
|
||||
const createEvent = (credential, calEvent: CalendarEvent): Promise<any> => {
|
||||
|
@ -352,4 +415,4 @@ const deleteEvent = (credential, uid: String): Promise<any> => {
|
|||
return Promise.resolve({});
|
||||
};
|
||||
|
||||
export {getBusyTimes, createEvent, updateEvent, deleteEvent, CalendarEvent};
|
||||
export {getBusyTimes, createEvent, updateEvent, deleteEvent, CalendarEvent, listCalendars, IntegrationCalendar};
|
||||
|
|
|
@ -15,6 +15,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
}
|
||||
});
|
||||
|
||||
const availability = await getBusyTimes(currentUser.credentials, req.query.dateFrom, req.query.dateTo);
|
||||
const selectedCalendars = (await prisma.selectedCalendar.findMany({
|
||||
where: {
|
||||
userId: currentUser.id
|
||||
}
|
||||
}));
|
||||
|
||||
const availability = await getBusyTimes(currentUser.credentials, req.query.dateFrom, req.query.dateTo, selectedCalendars);
|
||||
res.status(200).json(availability);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,69 @@
|
|||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { getSession } from 'next-auth/client';
|
||||
import prisma from '../../../lib/prisma';
|
||||
import {IntegrationCalendar, listCalendars} from "../../../lib/calendarClient";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const session = await getSession({req: req});
|
||||
|
||||
if (!session) {
|
||||
res.status(401).json({message: "Not authenticated"});
|
||||
return;
|
||||
}
|
||||
|
||||
const currentUser = await prisma.user.findFirst({
|
||||
where: {
|
||||
id: session.user.id,
|
||||
},
|
||||
select: {
|
||||
credentials: true,
|
||||
timeZone: true,
|
||||
id: true
|
||||
}
|
||||
});
|
||||
|
||||
if (req.method == "POST") {
|
||||
await prisma.selectedCalendar.create({
|
||||
data: {
|
||||
user: {
|
||||
connect: {
|
||||
id: currentUser.id
|
||||
}
|
||||
},
|
||||
integration: req.body.integration,
|
||||
externalId: req.body.externalId
|
||||
}
|
||||
});
|
||||
res.status(200).json({message: "Calendar Selection Saved"});
|
||||
|
||||
}
|
||||
|
||||
if (req.method == "DELETE") {
|
||||
await prisma.selectedCalendar.delete({
|
||||
where: {
|
||||
userId_integration_externalId: {
|
||||
userId: currentUser.id,
|
||||
externalId: req.body.externalId,
|
||||
integration: req.body.integration
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
res.status(200).json({message: "Calendar Selection Saved"});
|
||||
}
|
||||
|
||||
if (req.method == "GET") {
|
||||
const selectedCalendarIds = await prisma.selectedCalendar.findMany({
|
||||
where: {
|
||||
userId: currentUser.id
|
||||
},
|
||||
select: {
|
||||
externalId: true
|
||||
}
|
||||
});
|
||||
|
||||
const calendars: IntegrationCalendar[] = await listCalendars(currentUser.credentials);
|
||||
const selectableCalendars = calendars.map(cal => {return {selected: selectedCalendarIds.findIndex(s => s.externalId === cal.externalId) > -1, ...cal}});
|
||||
res.status(200).json(selectableCalendars);
|
||||
}
|
||||
}
|
|
@ -2,29 +2,82 @@ import Head from 'next/head';
|
|||
import Link from 'next/link';
|
||||
import prisma from '../../lib/prisma';
|
||||
import Shell from '../../components/Shell';
|
||||
import {useState} from 'react';
|
||||
import {useEffect, useState} from 'react';
|
||||
import {getSession, useSession} from 'next-auth/client';
|
||||
import {CheckCircleIcon, ChevronRightIcon, PlusIcon, XCircleIcon} from '@heroicons/react/solid';
|
||||
import {CalendarIcon, CheckCircleIcon, ChevronRightIcon, PlusIcon, XCircleIcon} from '@heroicons/react/solid';
|
||||
import {InformationCircleIcon} from '@heroicons/react/outline';
|
||||
import { Switch } from '@headlessui/react'
|
||||
|
||||
export default function Home({ integrations }) {
|
||||
const [session, loading] = useSession();
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
|
||||
if (loading) {
|
||||
return <p className="text-gray-400">Loading...</p>;
|
||||
}
|
||||
const [showSelectCalendarModal, setShowSelectCalendarModal] = useState(false);
|
||||
const [selectableCalendars, setSelectableCalendars] = useState([]);
|
||||
|
||||
function toggleAddModal() {
|
||||
setShowAddModal(!showAddModal);
|
||||
}
|
||||
|
||||
function toggleShowCalendarModal() {
|
||||
setShowSelectCalendarModal(!showSelectCalendarModal);
|
||||
}
|
||||
|
||||
function loadCalendars() {
|
||||
fetch('api/availability/calendar')
|
||||
.then((response) => response.json())
|
||||
.then(data => {
|
||||
setSelectableCalendars(data)
|
||||
});
|
||||
}
|
||||
|
||||
function integrationHandler(type) {
|
||||
fetch('/api/integrations/' + type.replace('_', '') + '/add')
|
||||
.then((response) => response.json())
|
||||
.then((data) => window.location.href = data.url);
|
||||
}
|
||||
|
||||
function calendarSelectionHandler(calendar) {
|
||||
return (selected) => {
|
||||
let cals = [...selectableCalendars];
|
||||
let i = cals.findIndex(c => c.externalId === calendar.externalId);
|
||||
cals[i].selected = selected;
|
||||
setSelectableCalendars(cals);
|
||||
if (selected) {
|
||||
fetch('api/availability/calendar', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(cals[i])
|
||||
}).then((response) => response.json());
|
||||
} else {
|
||||
fetch('api/availability/calendar', {
|
||||
method: 'DELETE', headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}, body: JSON.stringify(cals[i])
|
||||
}).then((response) => response.json());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getCalendarIntegrationImage(integrationType: string){
|
||||
switch (integrationType) {
|
||||
case "google_calendar": return "integrations/google-calendar.png";
|
||||
case "office365_calendar": return "integrations/office-365.png";
|
||||
default: return "";
|
||||
}
|
||||
}
|
||||
|
||||
function classNames(...classes) {
|
||||
return classes.filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
useEffect(loadCalendars, [integrations]);
|
||||
|
||||
if (loading) {
|
||||
return <p className="text-gray-400">Loading...</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Head>
|
||||
|
@ -39,7 +92,7 @@ export default function Home({ integrations }) {
|
|||
Add new integration
|
||||
</button>
|
||||
</div>
|
||||
<div className="bg-white shadow overflow-hidden rounded-lg">
|
||||
<div className="bg-white shadow overflow-hidden rounded-lg mb-8">
|
||||
{integrations.filter( (ig) => ig.credential ).length !== 0 ? <ul className="divide-y divide-gray-200">
|
||||
{integrations.filter(ig => ig.credential).map( (ig) => (<li>
|
||||
<Link href={"/integrations/" + ig.credential.id}>
|
||||
|
@ -165,6 +218,104 @@ export default function Home({ integrations }) {
|
|||
</div>
|
||||
</div>
|
||||
}
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900">
|
||||
Select calendars
|
||||
</h3>
|
||||
<div className="mt-2 max-w-xl text-sm text-gray-500">
|
||||
<p>
|
||||
Select which calendars are checked for availability to prevent double bookings.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-5">
|
||||
<button type="button" onClick={toggleShowCalendarModal} className="btn btn-primary">
|
||||
Select calendars
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{showSelectCalendarModal &&
|
||||
<div className="fixed z-10 inset-0 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
|
||||
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||
{/* <!--
|
||||
Background overlay, show/hide based on modal state.
|
||||
|
||||
Entering: "ease-out duration-300"
|
||||
From: "opacity-0"
|
||||
To: "opacity-100"
|
||||
Leaving: "ease-in duration-200"
|
||||
From: "opacity-100"
|
||||
To: "opacity-0"
|
||||
--> */}
|
||||
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" aria-hidden="true"></div>
|
||||
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">​</span>
|
||||
{/* <!--
|
||||
Modal panel, show/hide based on modal state.
|
||||
|
||||
Entering: "ease-out duration-300"
|
||||
From: "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
To: "opacity-100 translate-y-0 sm:scale-100"
|
||||
Leaving: "ease-in duration-200"
|
||||
From: "opacity-100 translate-y-0 sm:scale-100"
|
||||
To: "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
--> */}
|
||||
<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-lg sm:w-full sm:p-6">
|
||||
<div className="sm:flex sm:items-start">
|
||||
<div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-blue-100 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<CalendarIcon className="h-6 w-6 text-blue-600" />
|
||||
</div>
|
||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title">
|
||||
Select calendars
|
||||
</h3>
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">
|
||||
If no entry is selected, all calendars will be checked
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="my-4">
|
||||
<ul className="divide-y divide-gray-200">
|
||||
{selectableCalendars.map( (calendar) => (<li className="flex py-4">
|
||||
<div className="w-1/12 mr-4 pt-2">
|
||||
<img className="h-8 w-8 mr-2" src={getCalendarIntegrationImage(calendar.integration)} alt={calendar.integration} />
|
||||
</div>
|
||||
<div className="w-10/12">
|
||||
<h2 className="text-gray-800 font-medium">{ calendar.name }</h2>
|
||||
</div>
|
||||
<div className="w-2/12 text-right pt-2">
|
||||
<Switch
|
||||
checked={calendar.selected}
|
||||
onChange={calendarSelectionHandler(calendar)}
|
||||
className={classNames(
|
||||
calendar.selected ? 'bg-indigo-600' : 'bg-gray-200',
|
||||
'relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500'
|
||||
)}
|
||||
>
|
||||
<span className="sr-only">Select calendar</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={classNames(
|
||||
calendar.selected ? 'translate-x-5' : 'translate-x-0',
|
||||
'pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200'
|
||||
)}
|
||||
/>
|
||||
</Switch>
|
||||
</div>
|
||||
</li>))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
||||
<button onClick={toggleShowCalendarModal} type="button" className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:w-auto sm:text-sm">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</Shell>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -49,6 +49,7 @@ model User {
|
|||
credentials Credential[]
|
||||
teams Membership[]
|
||||
bookings Booking[]
|
||||
selectedCalendars SelectedCalendar[]
|
||||
@@map(name: "users")
|
||||
}
|
||||
|
||||
|
@ -120,3 +121,11 @@ model Booking {
|
|||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime?
|
||||
}
|
||||
|
||||
model SelectedCalendar {
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
userId Int
|
||||
integration String
|
||||
externalId String
|
||||
@@id([userId,integration,externalId])
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue