diff --git a/.gitignore b/.gitignore index 1756860634..126df0694f 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,6 @@ yarn-error.log* # vercel .vercel + +# Webstorm +.idea diff --git a/calendso.yaml b/calendso.yaml index 8f052f877f..d45d0f7d2b 100644 --- a/calendso.yaml +++ b/calendso.yaml @@ -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. @@ -144,4 +155,4 @@ paths: description: Updates a user's profile. summary: Updates a user's profile tags: - - User \ No newline at end of file + - User diff --git a/lib/calendarClient.ts b/lib/calendarClient.ts index c11348f483..526e117737 100644 --- a/lib/calendarClient.ts +++ b/lib/calendarClient.ts @@ -66,6 +66,13 @@ interface CalendarEvent { attendees: Person[]; }; +interface IntegrationCalendar { + integration: string; + primary: boolean; + externalId: string; + name: string; +} + interface CalendarApiAdapter { createEvent(event: CalendarEvent): Promise; @@ -73,7 +80,9 @@ interface CalendarApiAdapter { deleteEvent(uid: String); - getAvailability(dateFrom, dateTo): Promise; + getAvailability(dateFrom, dateTo, selectedCalendars: IntegrationCalendar[]): Promise; + + listCalendars(): Promise; } 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"; + function listCalendars(): Promise { + return auth.getToken().then(accessToken => fetch('https://graph.microsoft.com/v1.0/me/calendars', { + method: 'get', + headers: { + 'Authorization': 'Bearer ' + accessToken, + 'Content-Type': 'application/json' + }, + }).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) => 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(handleErrorsJson) - .then(responseBody => { - return responseBody.value[0].scheduleItems.map((evt) => ({ - start: evt.start.dateTime + 'Z', - end: evt.end.dateTime + 'Z' - })) + (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 => 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 => { @@ -352,4 +415,4 @@ const deleteEvent = (credential, uid: String): Promise => { return Promise.resolve({}); }; -export {getBusyTimes, createEvent, updateEvent, deleteEvent, CalendarEvent}; +export {getBusyTimes, createEvent, updateEvent, deleteEvent, CalendarEvent, listCalendars, IntegrationCalendar}; diff --git a/pages/api/availability/[user].ts b/pages/api/availability/[user].ts index d3dfd8565d..40b4e256f3 100644 --- a/pages/api/availability/[user].ts +++ b/pages/api/availability/[user].ts @@ -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); } diff --git a/pages/api/availability/calendar.ts b/pages/api/availability/calendar.ts new file mode 100644 index 0000000000..43bc929d70 --- /dev/null +++ b/pages/api/availability/calendar.ts @@ -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); + } +} diff --git a/pages/integrations/index.tsx b/pages/integrations/index.tsx index d732eee1cb..4363f4a888 100644 --- a/pages/integrations/index.tsx +++ b/pages/integrations/index.tsx @@ -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

Loading...

; - } + 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

Loading...

; + } + return (
@@ -39,7 +92,7 @@ export default function Home({ integrations }) { Add new integration
-
+
{integrations.filter( (ig) => ig.credential ).length !== 0 ?
    {integrations.filter(ig => ig.credential).map( (ig) => (
  • @@ -165,6 +218,104 @@ export default function Home({ integrations }) {
} +
+
+

+ Select calendars +

+
+

+ Select which calendars are checked for availability to prevent double bookings. +

+
+
+ +
+
+
+ {showSelectCalendarModal && +
+
+ {/* */} + + + {/* */} +
+
+
+ +
+
+ +
+

+ If no entry is selected, all calendars will be checked +

+
+
+
+
+
    + {selectableCalendars.map( (calendar) => (
  • +
    + {calendar.integration} +
    +
    +

    { calendar.name }

    +
    +
    + + Select calendar + +
    +
  • ))} +
+
+
+ +
+
+
+
+ } ); @@ -225,4 +376,4 @@ export async function getServerSideProps(context) { return { props: {integrations}, } -} \ No newline at end of file +} diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 88a8187dda..60067ab11f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -49,6 +49,7 @@ model User { credentials Credential[] teams Membership[] bookings Booking[] + selectedCalendars SelectedCalendar[] @@map(name: "users") } @@ -119,4 +120,12 @@ model Booking { createdAt DateTime @default(now()) updatedAt DateTime? -} \ No newline at end of file +} + +model SelectedCalendar { + user User @relation(fields: [userId], references: [id]) + userId Int + integration String + externalId String + @@id([userId,integration,externalId]) +}