Merge pull request #330 from Nico-J/feature/extended-event-info

Feature/extended event info
This commit is contained in:
Bailey Pumfleet 2021-07-07 13:32:15 +01:00 committed by GitHub
commit e1720e0161
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 341 additions and 196 deletions

View file

@ -4,6 +4,7 @@ import dayjs, { Dayjs } from "dayjs";
import utc from "dayjs/plugin/utc"; import utc from "dayjs/plugin/utc";
import timezone from "dayjs/plugin/timezone"; import timezone from "dayjs/plugin/timezone";
import getSlots from "@lib/slots"; import getSlots from "@lib/slots";
dayjs.extend(utc); dayjs.extend(utc);
dayjs.extend(timezone); dayjs.extend(timezone);

View file

@ -1,9 +1,10 @@
import { useState, useEffect } from "react"; import { useEffect, useState } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import getSlots from "../../lib/slots"; import getSlots from "../../lib/slots";
import dayjs, { Dayjs } from "dayjs"; import dayjs, { Dayjs } from "dayjs";
import isBetween from "dayjs/plugin/isBetween"; import isBetween from "dayjs/plugin/isBetween";
import utc from "dayjs/plugin/utc"; import utc from "dayjs/plugin/utc";
dayjs.extend(isBetween); dayjs.extend(isBetween);
dayjs.extend(utc); dayjs.extend(utc);

View file

@ -1,7 +1,7 @@
import { Switch } from "@headlessui/react"; import { Switch } from "@headlessui/react";
import TimezoneSelect from "react-timezone-select"; import TimezoneSelect from "react-timezone-select";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { timeZone, is24h } from "../../lib/clock"; import { is24h, timeZone } from "../../lib/clock";
function classNames(...classes) { function classNames(...classes) {
return classes.filter(Boolean).join(" "); return classes.filter(Boolean).join(" ");

View file

@ -3,10 +3,7 @@ import Link from "next/link";
const PoweredByCalendso = () => ( const PoweredByCalendso = () => (
<div className="text-xs text-center sm:text-right pt-1"> <div className="text-xs text-center sm:text-right pt-1">
<Link href="https://calendso.com"> <Link href="https://calendso.com">
<a <a style={{ color: "#104D86" }} className="opacity-50 hover:opacity-100">
style={{ color: "#104D86" }}
className="opacity-50 hover:opacity-100"
>
powered by{" "} powered by{" "}
<img <img
style={{ top: -2 }} style={{ top: -2 }}

View file

@ -7,6 +7,7 @@ import dayjs from "dayjs";
import utc from "dayjs/plugin/utc"; import utc from "dayjs/plugin/utc";
import timezone from "dayjs/plugin/timezone"; import timezone from "dayjs/plugin/timezone";
import { Availability } from "@prisma/client"; import { Availability } from "@prisma/client";
dayjs.extend(utc); dayjs.extend(utc);
dayjs.extend(timezone); dayjs.extend(timezone);

93
lib/CalEventParser.ts Normal file
View file

@ -0,0 +1,93 @@
import { CalendarEvent } from "./calendarClient";
import { v5 as uuidv5 } from "uuid";
import short from "short-uuid";
import { stripHtml } from "./emails/helpers";
const translator = short();
export default class CalEventParser {
calEvent: CalendarEvent;
constructor(calEvent: CalendarEvent) {
this.calEvent = calEvent;
}
/**
* Returns a link to reschedule the given booking.
*/
public getRescheduleLink(): string {
return process.env.BASE_URL + "/reschedule/" + this.getUid();
}
/**
* Returns a link to cancel the given booking.
*/
public getCancelLink(): string {
return process.env.BASE_URL + "/cancel/" + this.getUid();
}
/**
* Returns a unique identifier for the given calendar event.
*/
public getUid(): string {
return translator.fromUUID(uuidv5(JSON.stringify(this.calEvent), uuidv5.URL));
}
/**
* Returns a footer section with links to change the event (as HTML).
*/
public getChangeEventFooterHtml(): string {
return `<br />
<strong>Need to change this event?</strong><br />
Cancel: <a href="${this.getCancelLink()}">${this.getCancelLink()}</a><br />
Reschedule: <a href="${this.getRescheduleLink()}">${this.getRescheduleLink()}</a>
`;
}
/**
* Returns a footer section with links to change the event (as plain text).
*/
public getChangeEventFooter(): string {
return stripHtml(this.getChangeEventFooterHtml());
}
/**
* Returns an extended description with all important information (as HTML).
*
* @protected
*/
public getRichDescriptionHtml(): string {
// This odd indentation is necessary because otherwise the leading tabs will be applied into the event description.
return (
`
<strong>Event Type:</strong><br />${this.calEvent.type}<br />
<strong>Invitee Email:</strong><br /><a href="mailto:${this.calEvent.attendees[0].email}">${this.calEvent.attendees[0].email}</a><br />
` +
(this.calEvent.location
? `<strong>Location:</strong><br />${this.calEvent.location}<br />
`
: "") +
`<strong>Invitee Time Zone:</strong><br />${this.calEvent.attendees[0].timeZone}<br />
<strong>Additional notes:</strong><br />${this.calEvent.description}<br />` +
this.getChangeEventFooterHtml()
);
}
/**
* Returns an extended description with all important information (as plain text).
*
* @protected
*/
public getRichDescription(): string {
return stripHtml(this.getRichDescriptionHtml());
}
/**
* Returns a calendar event with rich description.
*/
public asRichEvent(): CalendarEvent {
const eventCopy: CalendarEvent = { ...this.calEvent };
eventCopy.description = this.getRichDescriptionHtml();
return eventCopy;
}
}

View file

@ -1,15 +1,13 @@
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 short from "short-uuid";
import EventOrganizerRescheduledMail from "./emails/EventOrganizerRescheduledMail"; import EventOrganizerRescheduledMail from "./emails/EventOrganizerRescheduledMail";
import EventAttendeeRescheduledMail from "./emails/EventAttendeeRescheduledMail"; import EventAttendeeRescheduledMail from "./emails/EventAttendeeRescheduledMail";
import prisma from "./prisma";
const translator = short(); import { Credential } from "@prisma/client";
import CalEventParser from "./CalEventParser";
// eslint-disable-next-line @typescript-eslint/no-var-requires // eslint-disable-next-line @typescript-eslint/no-var-requires
const { google } = require("googleapis"); const { google } = require("googleapis");
import prisma from "./prisma";
const googleAuth = (credential) => { const googleAuth = (credential) => {
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;
@ -124,7 +122,7 @@ export interface CalendarEvent {
} }
export interface ConferenceData { export interface ConferenceData {
createRequest: any; createRequest: unknown;
} }
export interface IntegrationCalendar { export interface IntegrationCalendar {
@ -135,13 +133,13 @@ export interface IntegrationCalendar {
} }
export interface CalendarApiAdapter { export interface CalendarApiAdapter {
createEvent(event: CalendarEvent): Promise<any>; createEvent(event: CalendarEvent): Promise<unknown>;
updateEvent(uid: string, event: CalendarEvent); updateEvent(uid: string, event: CalendarEvent);
deleteEvent(uid: string); deleteEvent(uid: string);
getAvailability(dateFrom, dateTo, selectedCalendars: IntegrationCalendar[]): Promise<any>; getAvailability(dateFrom, dateTo, selectedCalendars: IntegrationCalendar[]): Promise<unknown>;
listCalendars(): Promise<IntegrationCalendar[]>; listCalendars(): Promise<IntegrationCalendar[]>;
} }
@ -507,10 +505,12 @@ const listCalendars = (withCredentials) =>
results.reduce((acc, calendars) => acc.concat(calendars), []) results.reduce((acc, calendars) => acc.concat(calendars), [])
); );
const createEvent = async (credential, calEvent: CalendarEvent): Promise<any> => { const createEvent = async (credential: Credential, calEvent: CalendarEvent): Promise<unknown> => {
const uid: string = translator.fromUUID(uuidv5(JSON.stringify(calEvent), uuidv5.URL)); const parser: CalEventParser = new CalEventParser(calEvent);
const uid: string = parser.getUid();
const richEvent: CalendarEvent = parser.asRichEvent();
const creationResult = credential ? await calendars([credential])[0].createEvent(calEvent) : null; const creationResult = credential ? await calendars([credential])[0].createEvent(richEvent) : null;
const maybeHangoutLink = creationResult?.hangoutLink; const maybeHangoutLink = creationResult?.hangoutLink;
const maybeEntryPoints = creationResult?.entryPoints; const maybeEntryPoints = creationResult?.entryPoints;
@ -548,11 +548,17 @@ const createEvent = async (credential, calEvent: CalendarEvent): Promise<any> =>
}; };
}; };
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: Credential,
uidToUpdate: string,
calEvent: CalendarEvent
): Promise<unknown> => {
const parser: CalEventParser = new CalEventParser(calEvent);
const newUid: string = parser.getUid();
const richEvent: CalendarEvent = parser.asRichEvent();
const updateResult = credential const updateResult = credential
? await calendars([credential])[0].updateEvent(uidToUpdate, calEvent) ? await calendars([credential])[0].updateEvent(uidToUpdate, richEvent)
: null; : null;
const organizerMail = new EventOrganizerRescheduledMail(calEvent, newUid); const organizerMail = new EventOrganizerRescheduledMail(calEvent, newUid);
@ -577,7 +583,7 @@ const updateEvent = async (credential, uidToUpdate: string, calEvent: CalendarEv
}; };
}; };
const deleteEvent = (credential, uid: string): Promise<any> => { const deleteEvent = (credential: Credential, uid: string): Promise<unknown> => {
if (credential) { if (credential) {
return calendars([credential])[0].deleteEvent(uid); return calendars([credential])[0].deleteEvent(uid);
} }
@ -585,4 +591,12 @@ const deleteEvent = (credential, uid: string): Promise<any> => {
return Promise.resolve({}); return Promise.resolve({});
}; };
export { getBusyCalendarTimes, createEvent, updateEvent, deleteEvent, listCalendars }; export {
getBusyCalendarTimes,
createEvent,
updateEvent,
deleteEvent,
CalendarEvent,
listCalendars,
IntegrationCalendar,
};

View file

@ -4,6 +4,7 @@ import EventMail from "./EventMail";
import utc from "dayjs/plugin/utc"; import utc from "dayjs/plugin/utc";
import timezone from "dayjs/plugin/timezone"; import timezone from "dayjs/plugin/timezone";
import localizedFormat from "dayjs/plugin/localizedFormat"; import localizedFormat from "dayjs/plugin/localizedFormat";
dayjs.extend(utc); dayjs.extend(utc);
dayjs.extend(timezone); dayjs.extend(timezone);
dayjs.extend(localizedFormat); dayjs.extend(localizedFormat);
@ -76,7 +77,7 @@ export default class EventAttendeeMail extends EventMail {
* *
* @protected * @protected
*/ */
protected getNodeMailerPayload() { protected getNodeMailerPayload(): Record<string, unknown> {
return { return {
to: `${this.calEvent.attendees[0].name} <${this.calEvent.attendees[0].email}>`, to: `${this.calEvent.attendees[0].name} <${this.calEvent.attendees[0].email}>`,
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`, from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,

View file

@ -7,15 +7,21 @@ export default class EventAttendeeRescheduledMail extends EventAttendeeMail {
* @protected * @protected
*/ */
protected getHtmlRepresentation(): string { protected getHtmlRepresentation(): string {
return ` return (
`
<div> <div>
Hi ${this.calEvent.attendees[0].name},<br /> Hi ${this.calEvent.attendees[0].name},<br />
<br /> <br />
Your ${this.calEvent.type} with ${this.calEvent.organizer.name} has been rescheduled to ${this.getInviteeStart().format('h:mma')} Your ${this.calEvent.type} with ${
(${this.calEvent.attendees[0].timeZone}) on ${this.getInviteeStart().format('dddd, LL')}.<br /> this.calEvent.organizer.name
` + this.getAdditionalFooter() + ` } has been rescheduled to ${this.getInviteeStart().format("h:mma")}
(${this.calEvent.attendees[0].timeZone}) on ${this.getInviteeStart().format("dddd, LL")}.<br />
` +
this.getAdditionalFooter() +
`
</div> </div>
`; `
);
} }
/** /**
@ -23,12 +29,14 @@ export default class EventAttendeeRescheduledMail extends EventAttendeeMail {
* *
* @protected * @protected
*/ */
protected getNodeMailerPayload(): Object { protected getNodeMailerPayload(): Record<string, unknown> {
return { return {
to: `${this.calEvent.attendees[0].name} <${this.calEvent.attendees[0].email}>`, to: `${this.calEvent.attendees[0].name} <${this.calEvent.attendees[0].email}>`,
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`, from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
replyTo: this.calEvent.organizer.email, replyTo: this.calEvent.organizer.email,
subject: `Rescheduled: ${this.calEvent.type} with ${this.calEvent.organizer.name} on ${this.getInviteeStart().format('dddd, LL')}`, subject: `Rescheduled: ${this.calEvent.type} with ${
this.calEvent.organizer.name
} on ${this.getInviteeStart().format("dddd, LL")}`,
html: this.getHtmlRepresentation(), html: this.getHtmlRepresentation(),
text: this.getPlainTextRepresentation(), text: this.getPlainTextRepresentation(),
}; };

View file

@ -1,3 +1,5 @@
import CalEventParser from "../CalEventParser";
import { stripHtml } from "./helpers";
import { CalendarEvent, ConferenceData } from "../calendarClient"; import { CalendarEvent, ConferenceData } from "../calendarClient";
import { serverConfig } from "../serverConfig"; import { serverConfig } from "../serverConfig";
import nodemailer from "nodemailer"; import nodemailer from "nodemailer";
@ -21,6 +23,7 @@ interface AdditionInformation {
export default abstract class EventMail { export default abstract class EventMail {
calEvent: CalendarEvent; calEvent: CalendarEvent;
parser: CalEventParser;
uid: string; uid: string;
additionInformation?: AdditionInformation; additionInformation?: AdditionInformation;
@ -35,6 +38,7 @@ export default abstract class EventMail {
constructor(calEvent: CalendarEvent, uid: string, additionInformation: AdditionInformation = null) { constructor(calEvent: CalendarEvent, uid: string, additionInformation: AdditionInformation = null) {
this.calEvent = calEvent; this.calEvent = calEvent;
this.uid = uid; this.uid = uid;
this.parser = new CalEventParser(calEvent);
this.additionInformation = additionInformation; this.additionInformation = additionInformation;
} }
@ -52,24 +56,14 @@ export default abstract class EventMail {
* @protected * @protected
*/ */
protected getPlainTextRepresentation(): string { protected getPlainTextRepresentation(): string {
return this.stripHtml(this.getHtmlRepresentation()); return stripHtml(this.getHtmlRepresentation());
}
/**
* Strips off all HTML tags and leaves plain text.
*
* @param html
* @protected
*/
protected stripHtml(html: string): string {
return html.replace("<br />", "\n").replace(/<[^>]+>/g, "");
} }
/** /**
* Returns the payload object for the nodemailer. * Returns the payload object for the nodemailer.
* @protected * @protected
*/ */
protected abstract getNodeMailerPayload(); protected abstract getNodeMailerPayload(): Record<string, unknown>;
/** /**
* Sends the email to the event attendant and returns a Promise. * Sends the email to the event attendant and returns a Promise.
@ -129,7 +123,7 @@ export default abstract class EventMail {
* @protected * @protected
*/ */
protected getRescheduleLink(): string { protected getRescheduleLink(): string {
return process.env.BASE_URL + "/reschedule/" + this.uid; return this.parser.getRescheduleLink();
} }
/** /**
@ -138,7 +132,7 @@ export default abstract class EventMail {
* @protected * @protected
*/ */
protected getCancelLink(): string { protected getCancelLink(): string {
return process.env.BASE_URL + "/cancel/" + this.uid; return this.parser.getCancelLink();
} }
/** /**
@ -146,12 +140,6 @@ export default abstract class EventMail {
* @protected * @protected
*/ */
protected getAdditionalFooter(): string { protected getAdditionalFooter(): string {
return ` return this.parser.getChangeEventFooterHtml();
<br/>
<br/>
<strong>Need to change this event?</strong><br />
Cancel: <a href="${this.getCancelLink()}">${this.getCancelLink()}</a><br />
Reschedule: <a href="${this.getRescheduleLink()}">${this.getRescheduleLink()}</a>
`;
} }
} }

View file

@ -6,6 +6,8 @@ import utc from "dayjs/plugin/utc";
import timezone from "dayjs/plugin/timezone"; import timezone from "dayjs/plugin/timezone";
import toArray from "dayjs/plugin/toArray"; import toArray from "dayjs/plugin/toArray";
import localizedFormat from "dayjs/plugin/localizedFormat"; import localizedFormat from "dayjs/plugin/localizedFormat";
import { stripHtml } from "./helpers";
dayjs.extend(utc); dayjs.extend(utc);
dayjs.extend(timezone); dayjs.extend(timezone);
dayjs.extend(toArray); dayjs.extend(toArray);
@ -28,11 +30,11 @@ export default class EventOrganizerMail extends EventMail {
title: `${this.calEvent.type} with ${this.calEvent.attendees[0].name}`, title: `${this.calEvent.type} with ${this.calEvent.attendees[0].name}`,
description: description:
this.calEvent.description + this.calEvent.description +
this.stripHtml(this.getAdditionalBody()) + stripHtml(this.getAdditionalBody()) +
this.stripHtml(this.getAdditionalFooter()), stripHtml(this.getAdditionalFooter()),
duration: { minutes: dayjs(this.calEvent.endTime).diff(dayjs(this.calEvent.startTime), "minute") }, duration: { minutes: dayjs(this.calEvent.endTime).diff(dayjs(this.calEvent.startTime), "minute") },
organizer: { name: this.calEvent.organizer.name, email: this.calEvent.organizer.email }, organizer: { name: this.calEvent.organizer.name, email: this.calEvent.organizer.email },
attendees: this.calEvent.attendees.map((attendee: any) => ({ attendees: this.calEvent.attendees.map((attendee: unknown) => ({
name: attendee.name, name: attendee.name,
email: attendee.email, email: attendee.email,
})), })),
@ -114,7 +116,7 @@ export default class EventOrganizerMail extends EventMail {
* *
* @protected * @protected
*/ */
protected getNodeMailerPayload() { protected getNodeMailerPayload(): Record<string, unknown> {
const organizerStart: Dayjs = <Dayjs>dayjs(this.calEvent.startTime).tz(this.calEvent.organizer.timeZone); const organizerStart: Dayjs = <Dayjs>dayjs(this.calEvent.startTime).tz(this.calEvent.organizer.timeZone);
return { return {

View file

@ -1,4 +1,4 @@
import dayjs, {Dayjs} from "dayjs"; import dayjs, { Dayjs } from "dayjs";
import EventOrganizerMail from "./EventOrganizerMail"; import EventOrganizerMail from "./EventOrganizerMail";
export default class EventOrganizerRescheduledMail extends EventOrganizerMail { export default class EventOrganizerRescheduledMail extends EventOrganizerMail {
@ -8,7 +8,8 @@ export default class EventOrganizerRescheduledMail extends EventOrganizerMail {
* @protected * @protected
*/ */
protected getHtmlRepresentation(): string { protected getHtmlRepresentation(): string {
return ` return (
`
<div> <div>
Hi ${this.calEvent.organizer.name},<br /> Hi ${this.calEvent.organizer.name},<br />
<br /> <br />
@ -19,22 +20,26 @@ export default class EventOrganizerRescheduledMail extends EventOrganizerMail {
<br /> <br />
<strong>Invitee Email:</strong><br /> <strong>Invitee Email:</strong><br />
<a href="mailto:${this.calEvent.attendees[0].email}">${this.calEvent.attendees[0].email}</a><br /> <a href="mailto:${this.calEvent.attendees[0].email}">${this.calEvent.attendees[0].email}</a><br />
<br />` + this.getAdditionalBody() + <br />` +
( this.getAdditionalBody() +
this.calEvent.location ? ` (this.calEvent.location
? `
<strong>Location:</strong><br /> <strong>Location:</strong><br />
${this.calEvent.location}<br /> ${this.calEvent.location}<br />
<br /> <br />
` : '' `
) + : "") +
`<strong>Invitee Time Zone:</strong><br /> `<strong>Invitee Time Zone:</strong><br />
${this.calEvent.attendees[0].timeZone}<br /> ${this.calEvent.attendees[0].timeZone}<br />
<br /> <br />
<strong>Additional notes:</strong><br /> <strong>Additional notes:</strong><br />
${this.calEvent.description} ${this.calEvent.description}
` + this.getAdditionalFooter() + ` ` +
this.getAdditionalFooter() +
`
</div> </div>
`; `
);
} }
/** /**
@ -42,17 +47,19 @@ export default class EventOrganizerRescheduledMail extends EventOrganizerMail {
* *
* @protected * @protected
*/ */
protected getNodeMailerPayload(): Object { protected getNodeMailerPayload(): Record<string, unknown> {
const organizerStart: Dayjs = <Dayjs>dayjs(this.calEvent.startTime).tz(this.calEvent.organizer.timeZone); const organizerStart: Dayjs = <Dayjs>dayjs(this.calEvent.startTime).tz(this.calEvent.organizer.timeZone);
return { return {
icalEvent: { icalEvent: {
filename: 'event.ics', filename: "event.ics",
content: this.getiCalEventAsString(), content: this.getiCalEventAsString(),
}, },
from: `Calendso <${this.getMailerOptions().from}>`, from: `Calendso <${this.getMailerOptions().from}>`,
to: this.calEvent.organizer.email, to: this.calEvent.organizer.email,
subject: `Rescheduled event: ${this.calEvent.attendees[0].name} - ${organizerStart.format('LT dddd, LL')} - ${this.calEvent.type}`, subject: `Rescheduled event: ${this.calEvent.attendees[0].name} - ${organizerStart.format(
"LT dddd, LL"
)} - ${this.calEvent.type}`,
html: this.getHtmlRepresentation(), html: this.getHtmlRepresentation(),
text: this.getPlainTextRepresentation(), text: this.getPlainTextRepresentation(),
}; };

View file

@ -1,7 +1,7 @@
import {CalendarEvent} from "../calendarClient"; import { CalendarEvent } from "../calendarClient";
import EventOrganizerMail from "./EventOrganizerMail"; import EventOrganizerMail from "./EventOrganizerMail";
import {VideoCallData} from "../videoClient"; import { VideoCallData } from "../videoClient";
import {getFormattedMeetingId, getIntegrationName} from "./helpers"; import { getFormattedMeetingId, getIntegrationName } from "./helpers";
export default class VideoEventOrganizerMail extends EventOrganizerMail { export default class VideoEventOrganizerMail extends EventOrganizerMail {
videoCallData: VideoCallData; videoCallData: VideoCallData;
@ -18,11 +18,12 @@ export default class VideoEventOrganizerMail extends EventOrganizerMail {
* @protected * @protected
*/ */
protected getAdditionalBody(): string { protected getAdditionalBody(): string {
// This odd indentation is necessary because otherwise the leading tabs will be applied into the event description.
return ` return `
<strong>Video call provider:</strong> ${getIntegrationName(this.videoCallData)}<br /> <strong>Video call provider:</strong> ${getIntegrationName(this.videoCallData)}<br />
<strong>Meeting ID:</strong> ${getFormattedMeetingId(this.videoCallData)}<br /> <strong>Meeting ID:</strong> ${getFormattedMeetingId(this.videoCallData)}<br />
<strong>Meeting Password:</strong> ${this.videoCallData.password}<br /> <strong>Meeting Password:</strong> ${this.videoCallData.password}<br />
<strong>Meeting URL:</strong> <a href="${this.videoCallData.url}">${this.videoCallData.url}</a><br /> <strong>Meeting URL:</strong> <a href="${this.videoCallData.url}">${this.videoCallData.url}</a><br />
`; `;
} }
} }

View file

@ -1,4 +1,4 @@
import {VideoCallData} from "../videoClient"; import { VideoCallData } from "../videoClient";
export function getIntegrationName(videoCallData: VideoCallData): string { export function getIntegrationName(videoCallData: VideoCallData): string {
//TODO: When there are more complex integration type strings, we should consider using an extra field in the DB for that. //TODO: When there are more complex integration type strings, we should consider using an extra field in the DB for that.
@ -6,15 +6,24 @@ export function getIntegrationName(videoCallData: VideoCallData): string {
return nameProto.charAt(0).toUpperCase() + nameProto.slice(1); return nameProto.charAt(0).toUpperCase() + nameProto.slice(1);
} }
export function getFormattedMeetingId(videoCallData: VideoCallData): string { function extractZoom(videoCallData: VideoCallData): string {
switch(videoCallData.type) {
case 'zoom_video':
const strId = videoCallData.id.toString(); const strId = videoCallData.id.toString();
const part1 = strId.slice(0, 3); const part1 = strId.slice(0, 3);
const part2 = strId.slice(3, 7); const part2 = strId.slice(3, 7);
const part3 = strId.slice(7, 11); const part3 = strId.slice(7, 11);
return part1 + " " + part2 + " " + part3; return part1 + " " + part2 + " " + part3;
}
export function getFormattedMeetingId(videoCallData: VideoCallData): string {
switch (videoCallData.type) {
case "zoom_video":
return extractZoom(videoCallData);
default: default:
return videoCallData.id.toString(); return videoCallData.id.toString();
} }
} }
export function stripHtml(html: string): string {
return html.replace("<br />", "\n").replace(/<[^>]+>/g, "");
}

View file

@ -1,6 +1,7 @@
import dayjs, { Dayjs } from "dayjs"; import dayjs, { Dayjs } from "dayjs";
import utc from "dayjs/plugin/utc"; import utc from "dayjs/plugin/utc";
import timezone from "dayjs/plugin/timezone"; import timezone from "dayjs/plugin/timezone";
dayjs.extend(utc); dayjs.extend(utc);
dayjs.extend(timezone); dayjs.extend(timezone);

View file

@ -1,7 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { GetServerSideProps } from "next"; import { GetServerSideProps } from "next";
import Head from "next/head"; import Head from "next/head";
import { ClockIcon, GlobeIcon, ChevronDownIcon } from "@heroicons/react/solid"; import { ChevronDownIcon, ClockIcon, GlobeIcon } from "@heroicons/react/solid";
import prisma from "../../lib/prisma"; import prisma from "../../lib/prisma";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { Dayjs } from "dayjs"; import { Dayjs } from "dayjs";

View file

@ -1,6 +1,6 @@
import '../styles/globals.css'; import "../styles/globals.css";
import {createTelemetryClient, TelemetryProvider} from '../lib/telemetry'; import { createTelemetryClient, TelemetryProvider } from "../lib/telemetry";
import { Provider } from 'next-auth/client'; import { Provider } from "next-auth/client";
import type { AppProps } from "next/app"; import type { AppProps } from "next/app";
function MyApp({ Component, pageProps }: AppProps) { function MyApp({ Component, pageProps }: AppProps) {

View file

@ -1,30 +1,29 @@
import type { NextApiRequest, NextApiResponse } from 'next'; import type { NextApiRequest, NextApiResponse } from "next";
import { getSession } from 'next-auth/client'; import { getSession } from "next-auth/client";
import prisma from '../../../lib/prisma'; import prisma from "../../../lib/prisma";
export default async function handler(req: NextApiRequest, res: NextApiResponse) { export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const session = await getSession({req: req}); const session = await getSession({ req: req });
if (!session) { if (!session) {
res.status(401).json({message: "Not authenticated"}); res.status(401).json({ message: "Not authenticated" });
return; return;
} }
if (req.method == "PATCH") { if (req.method == "PATCH") {
const startMins = req.body.start; const startMins = req.body.start;
const endMins = req.body.end; const endMins = req.body.end;
const updateWeek = await prisma.schedule.update({ await prisma.schedule.update({
where: { where: {
id: session.user.id, id: session.user.id,
}, },
data: { data: {
startTime: startMins, startTime: startMins,
endTime: endMins endTime: endMins,
}, },
}); });
res.status(200).json({message: 'Start and end times updated successfully'}); res.status(200).json({ message: "Start and end times updated successfully" });
} }
} }

View file

@ -1,18 +1,16 @@
import type { NextApiRequest, NextApiResponse } from 'next'; import type { NextApiRequest, NextApiResponse } from "next";
import prisma from '../../lib/prisma'; import prisma from "../../lib/prisma";
import {getSession} from "next-auth/client"; import { getSession } from "next-auth/client";
export default async function handler(req: NextApiRequest, res: NextApiResponse) { export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const session = await getSession({ req: req });
const session = await getSession({req: req});
if (!session) { if (!session) {
res.status(401).json({message: "Not authenticated"}); res.status(401).json({ message: "Not authenticated" });
return; return;
} }
if (req.method === "POST") { if (req.method === "POST") {
// TODO: Prevent creating a team with identical names? // TODO: Prevent creating a team with identical names?
const createTeam = await prisma.team.create({ const createTeam = await prisma.team.create({
@ -25,13 +23,13 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
data: { data: {
teamId: createTeam.id, teamId: createTeam.id,
userId: session.user.id, userId: session.user.id,
role: 'OWNER', role: "OWNER",
accepted: true, accepted: true,
} },
}); });
return res.status(201).json({ message: 'Team created' }); return res.status(201).json({ message: "Team created" });
} }
res.status(404).json({ message: 'Team not found' }); res.status(404).json({ message: "Team not found" });
} }

View file

@ -10,16 +10,17 @@ import Shell from "@components/Shell";
import { getSession } from "next-auth/client"; import { getSession } from "next-auth/client";
import { Scheduler } from "@components/ui/Scheduler"; import { Scheduler } from "@components/ui/Scheduler";
import { LocationMarkerIcon, PlusCircleIcon, XIcon, PhoneIcon } from "@heroicons/react/outline"; import { LocationMarkerIcon, PhoneIcon, PlusCircleIcon, XIcon } from "@heroicons/react/outline";
import { EventTypeCustomInput, EventTypeCustomInputType } from "@lib/eventTypeInput"; import { EventTypeCustomInput, EventTypeCustomInputType } from "@lib/eventTypeInput";
import { PlusIcon } from "@heroicons/react/solid"; import { PlusIcon } from "@heroicons/react/solid";
import dayjs from "dayjs"; import dayjs from "dayjs";
import utc from "dayjs/plugin/utc"; import utc from "dayjs/plugin/utc";
dayjs.extend(utc);
import timezone from "dayjs/plugin/timezone"; import timezone from "dayjs/plugin/timezone";
import { EventType, User, Availability } from "@prisma/client"; import { Availability, EventType, User } from "@prisma/client";
import { validJson } from "@lib/jsonUtils"; import { validJson } from "@lib/jsonUtils";
dayjs.extend(utc);
dayjs.extend(timezone); dayjs.extend(timezone);
type Props = { type Props = {

View file

@ -2,10 +2,9 @@ import Head from "next/head";
import prisma from "../../lib/prisma"; import prisma from "../../lib/prisma";
import { getSession, useSession } from "next-auth/client"; import { getSession, useSession } from "next-auth/client";
import Shell from "../../components/Shell"; import Shell from "../../components/Shell";
import dayjs from "dayjs";
export default function Bookings({ bookings }) { export default function Bookings({ bookings }) {
const [session, loading] = useSession(); const [, loading] = useSession();
if (loading) { if (loading) {
return <p className="text-gray-400">Loading...</p>; return <p className="text-gray-400">Loading...</p>;

View file

@ -1,18 +1,16 @@
import { GetServerSideProps } from "next"; import { GetServerSideProps } from "next";
import Head from 'next/head'; import Head from "next/head";
import Shell from '../../components/Shell'; import Shell from "../../components/Shell";
import SettingsShell from '../../components/Settings'; import SettingsShell from "../../components/Settings";
import { useEffect, useState } from 'react'; import { useEffect, useState } from "react";
import type { Session } from "next-auth"; import type { Session } from "next-auth";
import { useSession, getSession } from 'next-auth/client'; import { getSession, useSession } from "next-auth/client";
import { import { UsersIcon } from "@heroicons/react/outline";
UsersIcon,
} from "@heroicons/react/outline";
import TeamList from "../../components/team/TeamList"; import TeamList from "../../components/team/TeamList";
import TeamListItem from "../../components/team/TeamListItem"; import TeamListItem from "../../components/team/TeamListItem";
export default function Teams() { export default function Teams() {
const [session, loading] = useSession(); const [, loading] = useSession();
const [teams, setTeams] = useState([]); const [teams, setTeams] = useState([]);
const [invites, setInvites] = useState([]); const [invites, setInvites] = useState([]);
const [showCreateTeamModal, setShowCreateTeamModal] = useState(false); const [showCreateTeamModal, setShowCreateTeamModal] = useState(false);
@ -23,7 +21,7 @@ export default function Teams() {
throw new Error(err.message); throw new Error(err.message);
} }
return resp.json(); return resp.json();
} };
const loadData = () => { const loadData = () => {
fetch("/api/user/membership") fetch("/api/user/membership")
@ -33,7 +31,7 @@ export default function Teams() {
setInvites(data.membership.filter((m) => m.role === "INVITEE")); setInvites(data.membership.filter((m) => m.role === "INVITEE"));
}) })
.catch(console.log); .catch(console.log);
} };
useEffect(() => { useEffect(() => {
loadData(); loadData();
@ -46,17 +44,17 @@ export default function Teams() {
const createTeam = (e) => { const createTeam = (e) => {
e.preventDefault(); e.preventDefault();
return fetch('/api/teams', { return fetch("/api/teams", {
method: 'POST', method: "POST",
body: JSON.stringify({ name: e.target.elements['name'].value }), body: JSON.stringify({ name: e.target.elements["name"].value }),
headers: { headers: {
'Content-Type': 'application/json' "Content-Type": "application/json",
} },
}).then(() => { }).then(() => {
loadData(); loadData();
setShowCreateTeamModal(false); setShowCreateTeamModal(false);
}); });
} };
return ( return (
<Shell heading="Teams"> <Shell heading="Teams">
@ -73,10 +71,12 @@ export default function Teams() {
<p className="mt-1 text-sm text-gray-500 mb-4"> <p className="mt-1 text-sm text-gray-500 mb-4">
View, edit and create teams to organise relationships between users View, edit and create teams to organise relationships between users
</p> </p>
{!(invites.length || teams.length) && {!(invites.length || teams.length) && (
<div className="bg-gray-50 sm:rounded-lg"> <div className="bg-gray-50 sm:rounded-lg">
<div className="px-4 py-5 sm:p-6"> <div className="px-4 py-5 sm:p-6">
<h3 className="text-lg leading-6 font-medium text-gray-900">Create a team to get started</h3> <h3 className="text-lg leading-6 font-medium text-gray-900">
Create a team to get started
</h3>
<div className="mt-2 max-w-xl text-sm text-gray-500"> <div className="mt-2 max-w-xl text-sm text-gray-500">
<p>Create your first team and invite other users to work together with you.</p> <p>Create your first team and invite other users to work together with you.</p>
</div> </div>
@ -84,31 +84,35 @@ export default function Teams() {
<button <button
type="button" type="button"
onClick={() => setShowCreateTeamModal(true)} onClick={() => setShowCreateTeamModal(true)}
className="btn btn-primary" className="btn btn-primary">
>
Create new team Create new team
</button> </button>
</div> </div>
</div> </div>
</div> </div>
} )}
</div> </div>
{!!(invites.length || teams.length) && <div> {!!(invites.length || teams.length) && (
<button className="btn-sm btn-primary" onClick={() => setShowCreateTeamModal(true)}>Create new team</button> <div>
</div>} <button className="btn-sm btn-primary" onClick={() => setShowCreateTeamModal(true)}>
Create new team
</button>
</div>
)}
</div> </div>
<div> <div>
{!!teams.length && {!!teams.length && <TeamList teams={teams} onChange={loadData}></TeamList>}
<TeamList teams={teams} onChange={loadData}>
</TeamList>
}
{!!invites.length && <div> {!!invites.length && (
<div>
<h2 className="text-lg leading-6 font-medium text-gray-900">Open Invitations</h2> <h2 className="text-lg leading-6 font-medium text-gray-900">Open Invitations</h2>
<ul className="border px-2 rounded mt-2 mb-2 divide-y divide-gray-200"> <ul className="border px-2 rounded mt-2 mb-2 divide-y divide-gray-200">
{invites.map((team) => <TeamListItem onChange={loadData} key={team.id} team={team}></TeamListItem>)} {invites.map((team) => (
<TeamListItem onChange={loadData} key={team.id} team={team}></TeamListItem>
))}
</ul> </ul>
</div>} </div>
)}
</div> </div>
{/*{teamsLoaded && <div className="flex justify-between"> {/*{teamsLoaded && <div className="flex justify-between">
<div> <div>
@ -124,12 +128,20 @@ export default function Teams() {
</div>}*/} </div>}*/}
</div> </div>
</div> </div>
{showCreateTeamModal && {showCreateTeamModal && (
<div className="fixed z-10 inset-0 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true"> <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"> <div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" aria-hidden="true"></div> <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">&#8203;</span> <span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
&#8203;
</span>
<div className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6"> <div className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left 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 mb-4"> <div className="sm:flex sm:items-start mb-4">
@ -137,24 +149,36 @@ export default function Teams() {
<UsersIcon className="h-6 w-6 text-blue-600" /> <UsersIcon className="h-6 w-6 text-blue-600" />
</div> </div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"> <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">Create a new team</h3> <h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title">
Create a new team
</h3>
<div> <div>
<p className="text-sm text-gray-400"> <p className="text-sm text-gray-400">Create a new team to collaborate with users.</p>
Create a new team to collaborate with users.
</p>
</div> </div>
</div> </div>
</div> </div>
<form onSubmit={createTeam}> <form onSubmit={createTeam}>
<div className="mb-4"> <div className="mb-4">
<label htmlFor="name" className="block text-sm font-medium text-gray-700">Name</label> <label htmlFor="name" className="block text-sm font-medium text-gray-700">
<input type="text" name="name" id="name" placeholder="Acme Inc." required className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm" /> Name
</label>
<input
type="text"
name="name"
id="name"
placeholder="Acme Inc."
required
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
/>
</div> </div>
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse"> <div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<button type="submit" className="btn btn-primary"> <button type="submit" className="btn btn-primary">
Create team Create team
</button> </button>
<button onClick={() => setShowCreateTeamModal(false)} type="button" className="btn btn-white mr-2"> <button
onClick={() => setShowCreateTeamModal(false)}
type="button"
className="btn btn-white mr-2">
Cancel Cancel
</button> </button>
</div> </div>
@ -162,7 +186,7 @@ export default function Teams() {
</div> </div>
</div> </div>
</div> </div>
} )}
</SettingsShell> </SettingsShell>
</Shell> </Shell>
); );
@ -172,10 +196,10 @@ export default function Teams() {
export const getServerSideProps: GetServerSideProps<{ session: Session | null }> = async (context) => { export const getServerSideProps: GetServerSideProps<{ session: Session | null }> = async (context) => {
const session = await getSession(context); const session = await getSession(context);
if (!session) { if (!session) {
return { redirect: { permanent: false, destination: '/auth/login' } }; return { redirect: { permanent: false, destination: "/auth/login" } };
} }
return { return {
props: { session } props: { session },
} };
} };

View file

@ -1,5 +1,4 @@
import { expect, it } from "@jest/globals";
import { it, expect } from '@jest/globals';
import { whereAndSelect } from "@lib/prisma"; import { whereAndSelect } from "@lib/prisma";
it("can decorate using whereAndSelect", async () => { it("can decorate using whereAndSelect", async () => {

View file

@ -1,9 +1,10 @@
import getSlots from '@lib/slots'; import getSlots from "@lib/slots";
import {it, expect} from '@jest/globals'; import { expect, it } from "@jest/globals";
import MockDate from 'mockdate'; import MockDate from "mockdate";
import dayjs, {Dayjs} from 'dayjs'; import dayjs from "dayjs";
import utc from 'dayjs/plugin/utc'; import utc from "dayjs/plugin/utc";
import timezone from 'dayjs/plugin/timezone'; import timezone from "dayjs/plugin/timezone";
dayjs.extend(utc); dayjs.extend(utc);
dayjs.extend(timezone); dayjs.extend(timezone);