Merge branch 'main' into feature/scheduling

This commit is contained in:
Alex van Andel 2021-06-22 15:19:28 +00:00
commit 1dce84fa8f
14 changed files with 625 additions and 363 deletions

View file

@ -17,7 +17,8 @@
"plugins": ["@typescript-eslint", "prettier", "react", "react-hooks"], "plugins": ["@typescript-eslint", "prettier", "react", "react-hooks"],
"rules": { "rules": {
"prettier/prettier": ["error"], "prettier/prettier": ["error"],
"@typescript-eslint/no-unused-vars": "error" "@typescript-eslint/no-unused-vars": "error",
"react/react-in-jsx-scope": "off"
}, },
"env": { "env": {
"browser": true, "browser": true,

View file

@ -13,16 +13,14 @@ const AvailableTimes = (props) => {
const { user, rescheduleUid } = router.query; const { user, rescheduleUid } = router.query;
const [loaded, setLoaded] = useState(false); const [loaded, setLoaded] = useState(false);
const times = useMemo(() => const times = getSlots({
getSlots({
calendarTimeZone: props.user.timeZone, calendarTimeZone: props.user.timeZone,
selectedTimeZone: timeZone(), selectedTimeZone: timeZone(),
eventLength: props.eventType.length, eventLength: props.eventType.length,
selectedDate: props.date, selectedDate: props.date,
dayStartTime: props.user.startTime, dayStartTime: props.user.startTime,
dayEndTime: props.user.endTime, dayEndTime: props.user.endTime,
}) });
, [])
const handleAvailableSlots = (busyTimes: []) => { const handleAvailableSlots = (busyTimes: []) => {
// Check for conflicts // Check for conflicts

View file

@ -1,16 +1,18 @@
import EventOrganizerMail from "./emails/EventOrganizerMail"; import EventOrganizerMail from "./emails/EventOrganizerMail";
import EventAttendeeMail from "./emails/EventAttendeeMail"; import EventAttendeeMail from "./emails/EventAttendeeMail";
import {v5 as uuidv5} from 'uuid'; import { v5 as uuidv5 } from "uuid";
import short from 'short-uuid'; import short from "short-uuid";
import EventOrganizerRescheduledMail from "./emails/EventOrganizerRescheduledMail"; import EventOrganizerRescheduledMail from "./emails/EventOrganizerRescheduledMail";
import EventAttendeeRescheduledMail from "./emails/EventAttendeeRescheduledMail"; import EventAttendeeRescheduledMail from "./emails/EventAttendeeRescheduledMail";
const translator = short(); const translator = short();
const {google} = require('googleapis'); const { google } = require("googleapis");
const googleAuth = () => { const googleAuth = () => {
const {client_secret, client_id, redirect_uris} = JSON.parse(process.env.GOOGLE_API_CREDENTIALS).web; 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]); return new google.auth.OAuth2(client_id, client_secret, redirect_uris[0]);
}; };
@ -31,36 +33,41 @@ function handleErrorsRaw(response) {
} }
const o365Auth = (credential) => { const o365Auth = (credential) => {
const isExpired = (expiryDate) => expiryDate < +new Date();
const isExpired = (expiryDate) => expiryDate < +(new Date()); const refreshAccessToken = (refreshToken) =>
fetch("https://login.microsoftonline.com/common/oauth2/v2.0/token", {
const refreshAccessToken = (refreshToken) => fetch('https://login.microsoftonline.com/common/oauth2/v2.0/token', { method: "POST",
method: 'POST', headers: { "Content-Type": "application/x-www-form-urlencoded" },
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({ body: new URLSearchParams({
'scope': 'User.Read Calendars.Read Calendars.ReadWrite', scope: "User.Read Calendars.Read Calendars.ReadWrite",
'client_id': process.env.MS_GRAPH_CLIENT_ID, client_id: process.env.MS_GRAPH_CLIENT_ID,
'refresh_token': refreshToken, refresh_token: refreshToken,
'grant_type': 'refresh_token', grant_type: "refresh_token",
'client_secret': process.env.MS_GRAPH_CLIENT_SECRET, client_secret: process.env.MS_GRAPH_CLIENT_SECRET,
}) }),
}) })
.then(handleErrorsJson) .then(handleErrorsJson)
.then((responseBody) => { .then((responseBody) => {
credential.key.access_token = responseBody.access_token; credential.key.access_token = responseBody.access_token;
credential.key.expiry_date = Math.round((+(new Date()) / 1000) + responseBody.expires_in); credential.key.expiry_date = Math.round(
+new Date() / 1000 + responseBody.expires_in
);
return credential.key.access_token; return credential.key.access_token;
}) });
return { return {
getToken: () => !isExpired(credential.key.expiry_date) ? Promise.resolve(credential.key.access_token) : refreshAccessToken(credential.key.refresh_token) getToken: () =>
!isExpired(credential.key.expiry_date)
? Promise.resolve(credential.key.access_token)
: refreshAccessToken(credential.key.refresh_token),
}; };
}; };
interface Person { interface Person {
name?: string, name?: string;
email: string, email: string;
timeZone: string timeZone: string;
} }
interface CalendarEvent { interface CalendarEvent {
@ -72,7 +79,12 @@ interface CalendarEvent {
location?: string; location?: string;
organizer: Person; organizer: Person;
attendees: Person[]; attendees: Person[];
}; conferenceData?: ConferenceData;
}
interface ConferenceData {
createRequest: any;
}
interface IntegrationCalendar { interface IntegrationCalendar {
integration: string; integration: string;
@ -88,26 +100,28 @@ interface CalendarApiAdapter {
deleteEvent(uid: String); deleteEvent(uid: String);
getAvailability(dateFrom, dateTo, selectedCalendars: IntegrationCalendar[]): Promise<any>; getAvailability(
dateFrom,
dateTo,
selectedCalendars: IntegrationCalendar[]
): Promise<any>;
listCalendars(): Promise<IntegrationCalendar[]>; listCalendars(): Promise<IntegrationCalendar[]>;
} }
const MicrosoftOffice365Calendar = (credential): CalendarApiAdapter => { const MicrosoftOffice365Calendar = (credential): CalendarApiAdapter => {
const auth = o365Auth(credential); const auth = o365Auth(credential);
const translateEvent = (event: CalendarEvent) => { const translateEvent = (event: CalendarEvent) => {
let optional = {}; let optional = {};
if (event.location) { if (event.location) {
optional.location = {displayName: event.location}; optional.location = { displayName: event.location };
} }
return { return {
subject: event.title, subject: event.title,
body: { body: {
contentType: 'HTML', contentType: "HTML",
content: event.description, content: event.description,
}, },
start: { start: {
@ -118,99 +132,138 @@ const MicrosoftOffice365Calendar = (credential): CalendarApiAdapter => {
dateTime: event.endTime, dateTime: event.endTime,
timeZone: event.organizer.timeZone, timeZone: event.organizer.timeZone,
}, },
attendees: event.attendees.map(attendee => ({ attendees: event.attendees.map((attendee) => ({
emailAddress: { emailAddress: {
address: attendee.email, address: attendee.email,
name: attendee.name name: attendee.name,
}, },
type: "required" type: "required",
})), })),
...optional ...optional,
} };
}; };
const integrationType = "office365_calendar"; const integrationType = "office365_calendar";
function listCalendars(): Promise<IntegrationCalendar[]> { function listCalendars(): Promise<IntegrationCalendar[]> {
return auth.getToken().then(accessToken => fetch('https://graph.microsoft.com/v1.0/me/calendars', { return auth.getToken().then((accessToken) =>
method: 'get', fetch("https://graph.microsoft.com/v1.0/me/calendars", {
method: "get",
headers: { headers: {
'Authorization': 'Bearer ' + accessToken, Authorization: "Bearer " + accessToken,
'Content-Type': 'application/json' "Content-Type": "application/json",
}, },
}).then(handleErrorsJson) })
.then(responseBody => { .then(handleErrorsJson)
return responseBody.value.map(cal => { .then((responseBody) => {
return responseBody.value.map((cal) => {
const calendar: IntegrationCalendar = { const calendar: IntegrationCalendar = {
externalId: cal.id, integration: integrationType, name: cal.name, primary: cal.isDefaultCalendar externalId: cal.id,
} integration: integrationType,
name: cal.name,
primary: cal.isDefaultCalendar,
};
return calendar; return calendar;
}); });
}) })
) );
} }
return { return {
getAvailability: (dateFrom, dateTo, selectedCalendars) => { getAvailability: (dateFrom, dateTo, selectedCalendars) => {
const filter = "?$filter=start/dateTime ge '" + dateFrom + "' and end/dateTime le '" + dateTo + "'" const filter =
return auth.getToken().then( "?$filter=start/dateTime ge '" +
(accessToken) => { dateFrom +
const selectedCalendarIds = selectedCalendars.filter(e => e.integration === integrationType).map(e => e.externalId); "' and end/dateTime le '" +
if (selectedCalendarIds.length == 0 && selectedCalendars.length > 0){ 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 // Only calendars of other integrations selected
return Promise.resolve([]); return Promise.resolve([]);
} }
return (selectedCalendarIds.length == 0 return (
? listCalendars().then(cals => cals.map(e => e.externalId)) selectedCalendarIds.length == 0
: Promise.resolve(selectedCalendarIds).then(x => x)).then((ids: string[]) => { ? listCalendars().then((cals) => cals.map((e) => e.externalId))
const urls = ids.map(calendarId => 'https://graph.microsoft.com/v1.0/me/calendars/' + calendarId + '/events' + filter) : Promise.resolve(selectedCalendarIds).then((x) => x)
return Promise.all(urls.map(url => fetch(url, { ).then((ids: string[]) => {
method: 'get', 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: { headers: {
'Authorization': 'Bearer ' + accessToken, Authorization: "Bearer " + accessToken,
'Prefer': 'outlook.timezone="Etc/GMT"' Prefer: 'outlook.timezone="Etc/GMT"',
} },
}) })
.then(handleErrorsJson) .then(handleErrorsJson)
.then(responseBody => responseBody.value.map((evt) => ({ .then((responseBody) =>
start: evt.start.dateTime + 'Z', responseBody.value.map((evt) => ({
end: evt.end.dateTime + 'Z' start: evt.start.dateTime + "Z",
end: evt.end.dateTime + "Z",
})) }))
))).then(results => results.reduce((acc, events) => acc.concat(events), [])) )
)
).then((results) =>
results.reduce((acc, events) => acc.concat(events), [])
);
});
}) })
} .catch((err) => {
).catch((err) => {
console.log(err); console.log(err);
}); });
}, },
createEvent: (event: CalendarEvent) => auth.getToken().then(accessToken => fetch('https://graph.microsoft.com/v1.0/me/calendar/events', { createEvent: (event: CalendarEvent) =>
method: 'POST', auth.getToken().then((accessToken) =>
fetch("https://graph.microsoft.com/v1.0/me/calendar/events", {
method: "POST",
headers: { headers: {
'Authorization': 'Bearer ' + accessToken, Authorization: "Bearer " + accessToken,
'Content-Type': 'application/json', "Content-Type": "application/json",
}, },
body: JSON.stringify(translateEvent(event)) body: JSON.stringify(translateEvent(event)),
}).then(handleErrorsJson).then((responseBody) => ({ })
.then(handleErrorsJson)
.then((responseBody) => ({
...responseBody, ...responseBody,
disableConfirmationEmail: true, disableConfirmationEmail: true,
}))), }))
deleteEvent: (uid: String) => auth.getToken().then(accessToken => fetch('https://graph.microsoft.com/v1.0/me/calendar/events/' + uid, { ),
method: 'DELETE', deleteEvent: (uid: String) =>
auth.getToken().then((accessToken) =>
fetch("https://graph.microsoft.com/v1.0/me/calendar/events/" + uid, {
method: "DELETE",
headers: { headers: {
'Authorization': 'Bearer ' + accessToken Authorization: "Bearer " + accessToken,
}
}).then(handleErrorsRaw)),
updateEvent: (uid: String, event: CalendarEvent) => auth.getToken().then(accessToken => fetch('https://graph.microsoft.com/v1.0/me/calendar/events/' + uid, {
method: 'PATCH',
headers: {
'Authorization': 'Bearer ' + accessToken,
'Content-Type': 'application/json'
}, },
body: JSON.stringify(translateEvent(event)) }).then(handleErrorsRaw)
}).then(handleErrorsRaw)), ),
listCalendars updateEvent: (uid: String, event: CalendarEvent) =>
} auth.getToken().then((accessToken) =>
fetch("https://graph.microsoft.com/v1.0/me/calendar/events/" + uid, {
method: "PATCH",
headers: {
Authorization: "Bearer " + accessToken,
"Content-Type": "application/json",
},
body: JSON.stringify(translateEvent(event)),
}).then(handleErrorsRaw)
),
listCalendars,
};
}; };
const GoogleCalendar = (credential): CalendarApiAdapter => { const GoogleCalendar = (credential): CalendarApiAdapter => {
@ -219,23 +272,30 @@ const GoogleCalendar = (credential): CalendarApiAdapter => {
const integrationType = "google_calendar"; const integrationType = "google_calendar";
return { return {
getAvailability: (dateFrom, dateTo, selectedCalendars) => new Promise((resolve, reject) => { getAvailability: (dateFrom, dateTo, selectedCalendars) =>
const calendar = google.calendar({version: 'v3', auth: myGoogleAuth}); new Promise((resolve, reject) => {
const calendar = google.calendar({ version: "v3", auth: myGoogleAuth });
calendar.calendarList calendar.calendarList
.list() .list()
.then(cals => { .then((cals) => {
const filteredItems = cals.data.items.filter(i => selectedCalendars.findIndex(e => e.externalId === i.id) > -1) const filteredItems = cals.data.items.filter(
if (filteredItems.length == 0 && selectedCalendars.length > 0){ (i) =>
selectedCalendars.findIndex((e) => e.externalId === i.id) > -1
);
if (filteredItems.length == 0 && selectedCalendars.length > 0) {
// Only calendars of other integrations selected // Only calendars of other integrations selected
resolve([]); resolve([]);
} }
calendar.freebusy.query({ calendar.freebusy.query(
{
requestBody: { requestBody: {
timeMin: dateFrom, timeMin: dateFrom,
timeMax: dateTo, timeMax: dateTo,
items: filteredItems.length > 0 ? filteredItems : cals.data.items items:
} filteredItems.length > 0 ? filteredItems : cals.data.items,
}, (err, apires) => { },
},
(err, apires) => {
if (err) { if (err) {
reject(err); reject(err);
} }
@ -244,15 +304,16 @@ const GoogleCalendar = (credential): CalendarApiAdapter => {
Object.values(apires.data.calendars).flatMap( Object.values(apires.data.calendars).flatMap(
(item) => item["busy"] (item) => item["busy"]
) )
) );
}); }
);
}) })
.catch((err) => { .catch((err) => {
reject(err); reject(err);
}); });
}), }),
createEvent: (event: CalendarEvent) => new Promise((resolve, reject) => { createEvent: (event: CalendarEvent) =>
new Promise((resolve, reject) => {
const payload = { const payload = {
summary: event.title, summary: event.title,
description: event.description, description: event.description,
@ -267,30 +328,39 @@ const GoogleCalendar = (credential): CalendarApiAdapter => {
attendees: event.attendees, attendees: event.attendees,
reminders: { reminders: {
useDefault: false, useDefault: false,
overrides: [ overrides: [{ method: "email", minutes: 60 }],
{'method': 'email', 'minutes': 60}
],
}, },
}; };
if (event.location) { if (event.location) {
payload['location'] = event.location; payload["location"] = event.location;
} }
const calendar = google.calendar({version: 'v3', auth: myGoogleAuth}); if (event.conferenceData) {
calendar.events.insert({ payload["conferenceData"] = event.conferenceData;
}
const calendar = google.calendar({ version: "v3", auth: myGoogleAuth });
calendar.events.insert(
{
auth: myGoogleAuth, auth: myGoogleAuth,
calendarId: 'primary', calendarId: "primary",
resource: payload, resource: payload,
}, function (err, event) { conferenceDataVersion: 1,
},
function (err, event) {
if (err) { if (err) {
console.log('There was an error contacting the Calendar service: ' + err); console.log(
"There was an error contacting the Calendar service: " + err
);
return reject(err); return reject(err);
} }
return resolve(event.data); return resolve(event.data);
}); }
);
}), }),
updateEvent: (uid: String, event: CalendarEvent) => new Promise((resolve, reject) => { updateEvent: (uid: String, event: CalendarEvent) =>
new Promise((resolve, reject) => {
const payload = { const payload = {
summary: event.title, summary: event.title,
description: event.description, description: event.description,
@ -305,97 +375,127 @@ const GoogleCalendar = (credential): CalendarApiAdapter => {
attendees: event.attendees, attendees: event.attendees,
reminders: { reminders: {
useDefault: false, useDefault: false,
overrides: [ overrides: [{ method: "email", minutes: 60 }],
{'method': 'email', 'minutes': 60}
],
}, },
}; };
if (event.location) { if (event.location) {
payload['location'] = event.location; payload["location"] = event.location;
} }
const calendar = google.calendar({version: 'v3', auth: myGoogleAuth}); const calendar = google.calendar({ version: "v3", auth: myGoogleAuth });
calendar.events.update({ calendar.events.update(
{
auth: myGoogleAuth, auth: myGoogleAuth,
calendarId: 'primary', calendarId: "primary",
eventId: uid, eventId: uid,
sendNotifications: true, sendNotifications: true,
sendUpdates: 'all', sendUpdates: "all",
resource: payload resource: payload,
}, function (err, event) { },
function (err, event) {
if (err) { if (err) {
console.log('There was an error contacting the Calendar service: ' + err); console.log(
"There was an error contacting the Calendar service: " + err
);
return reject(err); return reject(err);
} }
return resolve(event.data); return resolve(event.data);
}); }
);
}), }),
deleteEvent: (uid: String) => new Promise( (resolve, reject) => { deleteEvent: (uid: String) =>
const calendar = google.calendar({version: 'v3', auth: myGoogleAuth}); new Promise((resolve, reject) => {
calendar.events.delete({ const calendar = google.calendar({ version: "v3", auth: myGoogleAuth });
calendar.events.delete(
{
auth: myGoogleAuth, auth: myGoogleAuth,
calendarId: 'primary', calendarId: "primary",
eventId: uid, eventId: uid,
sendNotifications: true, sendNotifications: true,
sendUpdates: 'all', sendUpdates: "all",
}, function (err, event) { },
function (err, event) {
if (err) { if (err) {
console.log('There was an error contacting the Calendar service: ' + err); console.log(
"There was an error contacting the Calendar service: " + err
);
return reject(err); return reject(err);
} }
return resolve(event.data); return resolve(event.data);
}); }
);
}), }),
listCalendars: () => new Promise((resolve, reject) => { listCalendars: () =>
const calendar = google.calendar({version: 'v3', auth: myGoogleAuth}); new Promise((resolve, reject) => {
const calendar = google.calendar({ version: "v3", auth: myGoogleAuth });
calendar.calendarList calendar.calendarList
.list() .list()
.then(cals => { .then((cals) => {
resolve(cals.data.items.map(cal => { resolve(
cals.data.items.map((cal) => {
const calendar: IntegrationCalendar = { const calendar: IntegrationCalendar = {
externalId: cal.id, integration: integrationType, name: cal.summary, primary: cal.primary externalId: cal.id,
} integration: integrationType,
name: cal.summary,
primary: cal.primary,
};
return calendar; return calendar;
})) })
);
}) })
.catch((err) => { .catch((err) => {
reject(err); reject(err);
}); });
}) }),
}; };
}; };
// factory // factory
const calendars = (withCredentials): CalendarApiAdapter[] => withCredentials.map((cred) => { const calendars = (withCredentials): CalendarApiAdapter[] =>
withCredentials
.map((cred) => {
switch (cred.type) { switch (cred.type) {
case 'google_calendar': case "google_calendar":
return GoogleCalendar(cred); return GoogleCalendar(cred);
case 'office365_calendar': case "office365_calendar":
return MicrosoftOffice365Calendar(cred); return MicrosoftOffice365Calendar(cred);
default: default:
return; // unknown credential, could be legacy? In any case, ignore return; // unknown credential, could be legacy? In any case, ignore
} }
}).filter(Boolean); })
.filter(Boolean);
const getBusyCalendarTimes = (withCredentials, dateFrom, dateTo, selectedCalendars) => Promise.all( const getBusyCalendarTimes = (
calendars(withCredentials).map(c => c.getAvailability(dateFrom, dateTo, selectedCalendars)) withCredentials,
).then( dateFrom,
(results) => { dateTo,
return results.reduce((acc, availability) => acc.concat(availability), []) selectedCalendars
} ) =>
); Promise.all(
calendars(withCredentials).map((c) =>
c.getAvailability(dateFrom, dateTo, selectedCalendars)
)
).then((results) => {
return results.reduce((acc, availability) => acc.concat(availability), []);
});
const listCalendars = (withCredentials) => Promise.all( const listCalendars = (withCredentials) =>
calendars(withCredentials).map(c => c.listCalendars()) Promise.all(calendars(withCredentials).map((c) => c.listCalendars())).then(
).then(
(results) => results.reduce((acc, calendars) => acc.concat(calendars), []) (results) => results.reduce((acc, calendars) => acc.concat(calendars), [])
); );
const createEvent = async (credential, calEvent: CalendarEvent): Promise<any> => { const createEvent = async (
const uid: string = translator.fromUUID(uuidv5(JSON.stringify(calEvent), uuidv5.URL)); credential,
calEvent: CalendarEvent
): Promise<any> => {
const uid: string = translator.fromUUID(
uuidv5(JSON.stringify(calEvent), uuidv5.URL)
);
const creationResult = credential ? await calendars([credential])[0].createEvent(calEvent) : null; const creationResult = credential
? await calendars([credential])[0].createEvent(calEvent)
: null;
const organizerMail = new EventOrganizerMail(calEvent, uid); const organizerMail = new EventOrganizerMail(calEvent, uid);
const attendeeMail = new EventAttendeeMail(calEvent, uid); const attendeeMail = new EventAttendeeMail(calEvent, uid);
@ -407,14 +507,22 @@ const createEvent = async (credential, calEvent: CalendarEvent): Promise<any> =>
return { return {
uid, uid,
createdEvent: creationResult createdEvent: creationResult,
}; };
}; };
const updateEvent = async (credential, uidToUpdate: String, calEvent: CalendarEvent): Promise<any> => { const updateEvent = async (
const newUid: string = translator.fromUUID(uuidv5(JSON.stringify(calEvent), uuidv5.URL)); credential,
uidToUpdate: String,
calEvent: CalendarEvent
): Promise<any> => {
const newUid: string = translator.fromUUID(
uuidv5(JSON.stringify(calEvent), uuidv5.URL)
);
const updateResult = credential ? await calendars([credential])[0].updateEvent(uidToUpdate, calEvent) : null; const updateResult = credential
? await calendars([credential])[0].updateEvent(uidToUpdate, calEvent)
: null;
const organizerMail = new EventOrganizerRescheduledMail(calEvent, newUid); const organizerMail = new EventOrganizerRescheduledMail(calEvent, newUid);
const attendeeMail = new EventAttendeeRescheduledMail(calEvent, newUid); const attendeeMail = new EventAttendeeRescheduledMail(calEvent, newUid);
@ -426,7 +534,7 @@ const updateEvent = async (credential, uidToUpdate: String, calEvent: CalendarEv
return { return {
uid: newUid, uid: newUid,
updatedEvent: updateResult updatedEvent: updateResult,
}; };
}; };
@ -438,4 +546,12 @@ const deleteEvent = (credential, uid: String): Promise<any> => {
return Promise.resolve({}); return Promise.resolve({});
}; };
export {getBusyCalendarTimes, createEvent, updateEvent, deleteEvent, CalendarEvent, listCalendars, IntegrationCalendar}; export {
getBusyCalendarTimes,
createEvent,
updateEvent,
deleteEvent,
CalendarEvent,
listCalendars,
IntegrationCalendar,
};

View file

@ -1,5 +1,10 @@
// handles logic related to user clock display using 24h display / timeZone options. // handles logic related to user clock display using 24h display / timeZone options.
import dayjs, {Dayjs} from 'dayjs'; import dayjs, {Dayjs} from 'dayjs';
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
dayjs.extend(utc)
dayjs.extend(timezone)
interface TimeOptions { is24hClock: boolean, inviteeTimeZone: string }; interface TimeOptions { is24hClock: boolean, inviteeTimeZone: string };

View file

@ -1,6 +1,13 @@
import dayjs, {Dayjs} from "dayjs"; import dayjs, {Dayjs} from "dayjs";
import EventMail from "./EventMail"; import EventMail from "./EventMail";
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
import localizedFormat from 'dayjs/plugin/localizedFormat';
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(localizedFormat);
export default class EventAttendeeMail extends EventMail { export default class EventAttendeeMail extends EventMail {
/** /**
* Returns the email text as HTML representation. * Returns the email text as HTML representation.

View file

@ -2,6 +2,15 @@ import {createEvent} from "ics";
import dayjs, {Dayjs} from "dayjs"; import dayjs, {Dayjs} from "dayjs";
import EventMail from "./EventMail"; import EventMail from "./EventMail";
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
import toArray from 'dayjs/plugin/toArray';
import localizedFormat from 'dayjs/plugin/localizedFormat';
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(toArray);
dayjs.extend(localizedFormat);
export default class EventOrganizerMail extends EventMail { export default class EventOrganizerMail extends EventMail {
/** /**
* Returns the instance's event as an iCal event in string representation. * Returns the instance's event as an iCal event in string representation.

View file

@ -2,5 +2,6 @@
export enum LocationType { export enum LocationType {
InPerson = 'inPerson', InPerson = 'inPerson',
Phone = 'phone', Phone = 'phone',
GoogleMeet = 'integrations:google:meet'
} }

View file

@ -22,6 +22,7 @@
"dayjs": "^1.10.4", "dayjs": "^1.10.4",
"googleapis": "^67.1.1", "googleapis": "^67.1.1",
"ics": "^2.27.0", "ics": "^2.27.0",
"lodash.merge": "^4.6.2",
"next": "^10.2.0", "next": "^10.2.0",
"next-auth": "^3.13.2", "next-auth": "^3.13.2",
"next-transpile-modules": "^7.0.0", "next-transpile-modules": "^7.0.0",

View file

@ -22,12 +22,15 @@ import {useRouter} from "next/router";
export default function Type(props) { export default function Type(props) {
// Get router variables
const router = useRouter(); const router = useRouter();
const { rescheduleUid } = router.query; const { rescheduleUid } = router.query;
// Initialise state
const [selectedDate, setSelectedDate] = useState<Dayjs>(); const [selectedDate, setSelectedDate] = useState<Dayjs>();
const [selectedMonth, setSelectedMonth] = useState(dayjs().month());
const [isTimeOptionsOpen, setIsTimeOptionsOpen] = useState(false); const [isTimeOptionsOpen, setIsTimeOptionsOpen] = useState(false);
const [timeFormat, setTimeFormat] = useState('hh:mm'); const [timeFormat, setTimeFormat] = useState('h:mma');
const telemetry = useTelemetry(); const telemetry = useTelemetry();
useEffect(() => { useEffect(() => {

View file

@ -45,9 +45,10 @@ export default function Book(props) {
const locationLabels = { const locationLabels = {
[LocationType.InPerson]: 'In-person meeting', [LocationType.InPerson]: 'In-person meeting',
[LocationType.Phone]: 'Phone call', [LocationType.Phone]: 'Phone call',
[LocationType.GoogleMeet]: 'Google Meet',
}; };
const bookingHandler = event => { const bookingHandler = (event) => {
event.preventDefault(); event.preventDefault();
let notes = ""; let notes = "";
@ -81,7 +82,19 @@ export default function Book(props) {
}; };
if (selectedLocation) { if (selectedLocation) {
payload['location'] = selectedLocation === LocationType.Phone ? event.target.phone.value : locationInfo(selectedLocation).address; switch (selectedLocation) {
case LocationType.Phone:
payload['location'] = event.target.phone.value
break
case LocationType.InPerson:
payload['location'] = locationInfo(selectedLocation).address
break
case LocationType.GoogleMeet:
payload['location'] = LocationType.GoogleMeet
break
}
} }
telemetry.withJitsu(jitsu => jitsu.track(telemetryEventTypes.bookingConfirmed, collectPageParameters())); telemetry.withJitsu(jitsu => jitsu.track(telemetryEventTypes.bookingConfirmed, collectPageParameters()));
@ -98,8 +111,13 @@ export default function Book(props) {
let successUrl = `/success?date=${date}&type=${props.eventType.id}&user=${props.user.username}&reschedule=${!!rescheduleUid}&name=${payload.name}`; let successUrl = `/success?date=${date}&type=${props.eventType.id}&user=${props.user.username}&reschedule=${!!rescheduleUid}&name=${payload.name}`;
if (payload['location']) { if (payload['location']) {
if (payload['location'].includes('integration')) {
successUrl += "&location=" + encodeURIComponent("Web conferencing details to follow.");
}
else {
successUrl += "&location=" + encodeURIComponent(payload['location']); successUrl += "&location=" + encodeURIComponent(payload['location']);
} }
}
router.push(successUrl); router.push(successUrl);
} }

View file

@ -7,9 +7,30 @@ import short from 'short-uuid';
import {createMeeting, updateMeeting} from "../../../lib/videoClient"; import {createMeeting, updateMeeting} from "../../../lib/videoClient";
import EventAttendeeMail from "../../../lib/emails/EventAttendeeMail"; import EventAttendeeMail from "../../../lib/emails/EventAttendeeMail";
import {getEventName} from "../../../lib/event"; import {getEventName} from "../../../lib/event";
import { LocationType } from '../../../lib/location';
import merge from "lodash.merge"
const translator = short(); const translator = short();
interface p {
location: string
}
const getLocationRequestFromIntegration = ({location}: p) => {
if (location === LocationType.GoogleMeet.valueOf()) {
const requestId = uuidv5(location, uuidv5.URL)
return {
conferenceData: {
createRequest: {
requestId: requestId
}
}
}
}
return null
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) { export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const {user} = req.query; const {user} = req.query;
@ -43,19 +64,38 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
} }
}); });
const evt: CalendarEvent = { let rawLocation = req.body.location
let evt: CalendarEvent = {
type: selectedEventType.title, type: selectedEventType.title,
title: getEventName(req.body.name, selectedEventType.title, selectedEventType.eventName), title: getEventName(req.body.name, selectedEventType.title, selectedEventType.eventName),
description: req.body.notes, description: req.body.notes,
startTime: req.body.start, startTime: req.body.start,
endTime: req.body.end, endTime: req.body.end,
location: req.body.location,
organizer: {email: currentUser.email, name: currentUser.name, timeZone: currentUser.timeZone}, organizer: {email: currentUser.email, name: currentUser.name, timeZone: currentUser.timeZone},
attendees: [ attendees: [
{email: req.body.email, name: req.body.name, timeZone: req.body.timeZone} {email: req.body.email, name: req.body.name, timeZone: req.body.timeZone}
] ]
}; };
// If phone or inPerson use raw location
// set evt.location to req.body.location
if (!rawLocation.includes('integration')) {
evt.location = rawLocation
}
// If location is set to an integration location
// Build proper transforms for evt object
// Extend evt object with those transformations
if (rawLocation.includes('integration')) {
let maybeLocationRequestObject = getLocationRequestFromIntegration({
location: rawLocation
})
evt = merge(evt, maybeLocationRequestObject)
}
const eventType = await prisma.eventType.findFirst({ const eventType = await prisma.eventType.findFirst({
where: { where: {
userId: currentUser.id, userId: currentUser.id,

View file

@ -1,8 +1,8 @@
import Head from 'next/head'; import Head from 'next/head';
import Link from 'next/link'; import Link from 'next/link';
import {useRouter} from 'next/router'; import { useRouter } from 'next/router';
import {useRef, useState} from 'react'; import { useRef, useState, useEffect } from 'react';
import Select, {OptionBase} from 'react-select'; import Select, { OptionBase } from 'react-select';
import prisma from '../../../lib/prisma'; import prisma from '../../../lib/prisma';
import {LocationType} from '../../../lib/location'; import {LocationType} from '../../../lib/location';
import Shell from '../../../components/Shell'; import Shell from '../../../components/Shell';
@ -42,6 +42,7 @@ export default function EventType(props) {
const [ locations, setLocations ] = useState(props.eventType.locations || []); const [ locations, setLocations ] = useState(props.eventType.locations || []);
const [ schedule, setSchedule ] = useState(undefined); const [ schedule, setSchedule ] = useState(undefined);
const [customInputs, setCustomInputs] = useState<EventTypeCustomInput[]>(props.eventType.customInputs.sort((a, b) => a.id - b.id) || []); const [customInputs, setCustomInputs] = useState<EventTypeCustomInput[]>(props.eventType.customInputs.sort((a, b) => a.id - b.id) || []);
const locationOptions = props.locationOptions
const titleRef = useRef<HTMLInputElement>(); const titleRef = useRef<HTMLInputElement>();
const slugRef = useRef<HTMLInputElement>(); const slugRef = useRef<HTMLInputElement>();
@ -115,12 +116,6 @@ export default function EventType(props) {
router.push('/availability'); router.push('/availability');
} }
// TODO: Tie into translations instead of abstracting to locations.ts
const locationOptions: OptionBase[] = [
{ value: LocationType.InPerson, label: 'In-person meeting' },
{ value: LocationType.Phone, label: 'Phone call', },
];
const openLocationModal = (type: LocationType) => { const openLocationModal = (type: LocationType) => {
setSelectedLocation(locationOptions.find( (option) => option.value === type)); setSelectedLocation(locationOptions.find( (option) => option.value === type));
setShowLocationModal(true); setShowLocationModal(true);
@ -158,6 +153,10 @@ export default function EventType(props) {
return ( return (
<p className="text-sm">Calendso will ask your invitee to enter a phone number before scheduling.</p> <p className="text-sm">Calendso will ask your invitee to enter a phone number before scheduling.</p>
) )
case LocationType.GoogleMeet:
return (
<p className="text-sm">Calendso will provide a Google Meet location.</p>
)
} }
return null; return null;
}; };
@ -197,7 +196,6 @@ export default function EventType(props) {
setCustomInputs(customInputs.concat(customInput)); setCustomInputs(customInputs.concat(customInput));
console.log(customInput)
setShowAddCustomModal(false); setShowAddCustomModal(false);
}; };
@ -268,6 +266,12 @@ export default function EventType(props) {
<span className="ml-2 text-sm">Phone call</span> <span className="ml-2 text-sm">Phone call</span>
</div> </div>
)} )}
{location.type === LocationType.GoogleMeet && (
<div className="flex-grow flex">
<svg className="h-6 w-6" stroke="currentColor" fill="currentColor" stroke-width="0" role="img" viewBox="0 0 24 24" height="1em" width="1em" xmlns="http://www.w3.org/2000/svg"><title></title><path d="M12 0C6.28 0 1.636 4.641 1.636 10.364c0 5.421 4.945 9.817 10.364 9.817V24c6.295-3.194 10.364-8.333 10.364-13.636C22.364 4.64 17.72 0 12 0zM7.5 6.272h6.817a1.363 1.363 0 0 1 1.365 1.365v1.704l2.728-2.727v7.501l-2.726-2.726v1.703a1.362 1.362 0 0 1-1.365 1.365H7.5c-.35 0-.698-.133-.965-.4a1.358 1.358 0 0 1-.4-.965V7.637A1.362 1.362 0 0 1 7.5 6.272Z"></path></svg>
<span className="ml-2 text-sm">Google Meet</span>
</div>
)}
<div className="flex"> <div className="flex">
<button type="button" onClick={() => openLocationModal(location.type)} className="mr-2 text-sm text-blue-600">Edit</button> <button type="button" onClick={() => openLocationModal(location.type)} className="mr-2 text-sm text-blue-600">Edit</button>
<button onClick={() => removeLocation(location)}> <button onClick={() => removeLocation(location)}>
@ -501,6 +505,17 @@ export default function EventType(props) {
); );
} }
const validJson = (jsonString: string) => {
try {
const o = JSON.parse(jsonString);
if (o && typeof o === "object") {
return o;
}
}
catch (e) {}
return false;
}
export async function getServerSideProps(context) { export async function getServerSideProps(context) {
const session = await getSession(context); const session = await getSession(context);
if (!session) { if (!session) {
@ -538,6 +553,67 @@ export async function getServerSideProps(context) {
} }
}); });
const credentials = await prisma.credential.findMany({
where: {
userId: user.id,
},
select: {
id: true,
type: true,
key: true
}
});
const integrations = [ {
installed: !!(process.env.GOOGLE_API_CREDENTIALS && validJson(process.env.GOOGLE_API_CREDENTIALS)),
enabled: credentials.find( (integration) => integration.type === "google_calendar" ) != null,
type: "google_calendar",
title: "Google Calendar",
imageSrc: "integrations/google-calendar.png",
description: "For personal and business accounts",
}, {
installed: !!(process.env.MS_GRAPH_CLIENT_ID && process.env.MS_GRAPH_CLIENT_SECRET),
type: "office365_calendar",
enabled: credentials.find( (integration) => integration.type === "office365_calendar" ) != null,
title: "Office 365 / Outlook.com Calendar",
imageSrc: "integrations/office-365.png",
description: "For personal and business accounts",
} ];
let locationOptions: OptionBase[] = [
{ value: LocationType.InPerson, label: 'In-person meeting' },
{ value: LocationType.Phone, label: 'Phone call', },
];
const hasGoogleCalendarIntegration = integrations.find((i) => i.type === "google_calendar" && i.installed === true && i.enabled)
if (hasGoogleCalendarIntegration) {
locationOptions.push( { value: LocationType.GoogleMeet, label: 'Google Meet' })
}
const hasOfficeIntegration = integrations.find((i) => i.type === "office365_calendar" && i.installed === true && i.enabled)
if (hasOfficeIntegration) {
// TODO: Add default meeting option of the office integration.
// Assuming it's Microsoft Teams.
}
const eventType = await prisma.eventType.findUnique({
where: {
id: parseInt(context.query.type),
},
select: {
id: true,
title: true,
slug: true,
description: true,
length: true,
hidden: true,
locations: true,
eventName: true,
customInputs: true,
availability: true,
}
});
if (!eventType) { if (!eventType) {
return { return {
notFound: true, notFound: true,
@ -558,7 +634,8 @@ export async function getServerSideProps(context) {
props: { props: {
user, user,
eventType, eventType,
schedules schedules,
locationOptions,
}, },
} }
} }

View file

@ -134,38 +134,6 @@ export default function Type(props) {
} }
export async function getServerSideProps(context) { export async function getServerSideProps(context) {
const user = await prisma.user.findFirst({
where: {
username: context.query.user,
},
select: {
id: true,
username: true,
name: true,
}
});
if (!user) {
return {
notFound: true,
}
}
const eventType = await prisma.eventType.findFirst({
where: {
userId: user.id,
slug: {
equals: context.query.type,
},
},
select: {
id: true,
title: true,
description: true,
length: true
}
});
const booking = await prisma.booking.findFirst({ const booking = await prisma.booking.findFirst({
where: { where: {
uid: context.query.uid, uid: context.query.uid,
@ -176,7 +144,15 @@ export async function getServerSideProps(context) {
description: true, description: true,
startTime: true, startTime: true,
endTime: true, endTime: true,
attendees: true attendees: true,
eventType: true,
user: {
select: {
id: true,
username: true,
name: true,
}
}
} }
}); });
@ -188,8 +164,8 @@ export async function getServerSideProps(context) {
return { return {
props: { props: {
user, user: booking.user,
eventType, eventType: booking.eventType,
booking: bookingObj booking: bookingObj
}, },
} }

View file

@ -124,6 +124,11 @@ export default function Home(props) {
</div> </div>
</li> </li>
))} ))}
{props.eventTypes.length == 0 &&
<div className="text-center text-gray-400 py-12">
<p>You haven't created any event types.</p>
</div>
}
</ul> </ul>
</div> </div>
<div className="mt-8 bg-white shadow overflow-hidden rounded-md p-6 mb-8 md:mb-0"> <div className="mt-8 bg-white shadow overflow-hidden rounded-md p-6 mb-8 md:mb-0">
@ -254,6 +259,11 @@ export default function Home(props) {
</div> </div>
</li> </li>
))} ))}
{props.eventTypes.length == 0 &&
<div className="text-center text-gray-400 py-2">
<p>You haven't created any event types.</p>
</div>
}
</ul> </ul>
</div> </div>
</div> </div>