diff --git a/.gitignore b/.gitignore index 17568606..126df069 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 8f052f87..d45d0f7d 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/components/ActiveLink.tsx b/components/ActiveLink.tsx index f4da7d65..d7ae61c3 100644 --- a/components/ActiveLink.tsx +++ b/components/ActiveLink.tsx @@ -1,5 +1,4 @@ import { useRouter } from 'next/router' -import PropTypes from 'prop-types' import Link from 'next/link' import React, { Children } from 'react' diff --git a/components/Settings.tsx b/components/Settings.tsx index ff23e35b..bfbfd19c 100644 --- a/components/Settings.tsx +++ b/components/Settings.tsx @@ -1,5 +1,5 @@ import ActiveLink from '../components/ActiveLink'; -import { UserCircleIcon, KeyIcon, CodeIcon, UserGroupIcon } from '@heroicons/react/outline'; +import { UserCircleIcon, KeyIcon, CodeIcon, UserGroupIcon, CreditCardIcon } from '@heroicons/react/outline'; export default function SettingsShell(props) { return ( @@ -37,6 +37,11 @@ export default function SettingsShell(props) { Teams + {/* Change/remove me, if you're self-hosting */} + + Billing + + {/* diff --git a/lib/calendarClient.ts b/lib/calendarClient.ts index c11348f4..fa358fbf 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,57 @@ 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([]); + } + + 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) + 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 +201,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 +338,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 +370,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 +413,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/[user]/[type].tsx b/pages/[user]/[type].tsx index 36cc1c79..9b825cfe 100644 --- a/pages/[user]/[type].tsx +++ b/pages/[user]/[type].tsx @@ -55,9 +55,9 @@ export default function Type(props) { setIs24h(!!localStorage.getItem('timeOption.is24hClock')); } - useEffect(() => { - telemetry.withJitsu((jitsu) => jitsu.track(telemetryEventTypes.pageView, collectPageParameters())) - }); + useEffect(() => { + telemetry.withJitsu((jitsu) => jitsu.track(telemetryEventTypes.pageView, collectPageParameters())) + }, []); // Handle date change and timezone change useEffect(() => { diff --git a/pages/api/availability/[user].ts b/pages/api/availability/[user].ts index d3dfd856..40b4e256 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 00000000..43bc929d --- /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 d732eee1..7e132e3b 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. + + + + + Select calendars + + + + + {showSelectCalendarModal && + + + {/* */} + + + {/* */} + + + + + + + + Select calendars + + + + If no entry is selected, all calendars will be checked + + + + + + + {selectableCalendars.map( (calendar) => ( + + + + + { calendar.name } + + + + Select calendar + + + + ))} + + + + + Close + + + + + + } ); @@ -225,4 +376,4 @@ export async function getServerSideProps(context) { return { props: {integrations}, } -} \ No newline at end of file +} diff --git a/pages/settings/billing.tsx b/pages/settings/billing.tsx new file mode 100644 index 00000000..57acc64f --- /dev/null +++ b/pages/settings/billing.tsx @@ -0,0 +1,66 @@ +import Head from 'next/head'; +import Shell from '../../components/Shell'; +import SettingsShell from '../../components/Settings'; +import prisma from '../../lib/prisma'; +import {getSession, useSession} from 'next-auth/client'; + +export default function Billing(props) { + const [ session, loading ] = useSession(); + + if (loading) { + return Loading...; + } + + return ( + + + Billing | Calendso + + + + + + Change your Subscription + + + Cancel, update credit card or change plan + + + + + + + + + ); +} + +export async function getServerSideProps(context) { + const session = await getSession(context); + if (!session) { + return { redirect: { permanent: false, destination: '/auth/login' } }; + } + + const user = await prisma.user.findFirst({ + where: { + email: session.user.email, + }, + select: { + id: true, + username: true, + name: true, + email: true, + bio: true, + avatar: true, + timeZone: true, + weekStart: true, + } + }); + + return { + props: {user}, // will be passed to the page component as props + } +} \ No newline at end of file diff --git a/prisma/migrations/20210615140247_added_selected_calendar/migration.sql b/prisma/migrations/20210615140247_added_selected_calendar/migration.sql new file mode 100644 index 00000000..cdb27778 --- /dev/null +++ b/prisma/migrations/20210615140247_added_selected_calendar/migration.sql @@ -0,0 +1,11 @@ +-- CreateTable +CREATE TABLE "SelectedCalendar" ( + "userId" INTEGER NOT NULL, + "integration" TEXT NOT NULL, + "externalId" TEXT NOT NULL, + + PRIMARY KEY ("userId","integration","externalId") +); + +-- AddForeignKey +ALTER TABLE "SelectedCalendar" ADD FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5f3ee65d..5d374407 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -50,6 +50,7 @@ model User { credentials Credential[] teams Membership[] bookings Booking[] + selectedCalendars SelectedCalendar[] @@map(name: "users") } @@ -121,3 +122,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...
+ Select which calendars are checked for availability to prevent double bookings. +
+ If no entry is selected, all calendars will be checked +
+ Cancel, update credit card or change plan +