Merge branch 'main' of https://github.com/calendso/calendso into main

This commit is contained in:
Bailey Pumfleet 2021-07-08 10:23:48 +01:00
commit db7c467d73
27 changed files with 746 additions and 327 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;
@ -111,7 +109,7 @@ interface Person {
timeZone: string; timeZone: string;
} }
interface CalendarEvent { export interface CalendarEvent {
type: string; type: string;
title: string; title: string;
startTime: string; startTime: string;
@ -123,25 +121,25 @@ interface CalendarEvent {
conferenceData?: ConferenceData; conferenceData?: ConferenceData;
} }
interface ConferenceData { export interface ConferenceData {
createRequest: any; createRequest: unknown;
} }
interface IntegrationCalendar { export interface IntegrationCalendar {
integration: string; integration: string;
primary: boolean; primary: boolean;
externalId: string; externalId: string;
name: string; name: string;
} }
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[]>;
} }
@ -373,6 +371,7 @@ const GoogleCalendar = (credential): CalendarApiAdapter => {
auth: myGoogleAuth, auth: myGoogleAuth,
calendarId: "primary", calendarId: "primary",
resource: payload, resource: payload,
conferenceDataVersion: 1,
}, },
function (err, event) { function (err, event) {
if (err) { if (err) {
@ -506,13 +505,29 @@ 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 maybeEntryPoints = creationResult?.entryPoints;
const maybeConferenceData = creationResult?.conferenceData;
const organizerMail = new EventOrganizerMail(calEvent, uid, {
hangoutLink: maybeHangoutLink,
conferenceData: maybeConferenceData,
entryPoints: maybeEntryPoints,
});
const attendeeMail = new EventAttendeeMail(calEvent, uid, {
hangoutLink: maybeHangoutLink,
conferenceData: maybeConferenceData,
entryPoints: maybeEntryPoints,
});
const organizerMail = new EventOrganizerMail(calEvent, uid);
const attendeeMail = new EventAttendeeMail(calEvent, uid);
try { try {
await organizerMail.sendEmail(); await organizerMail.sendEmail();
} catch (e) { } catch (e) {
@ -533,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);
@ -562,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);
} }

View file

@ -1,9 +1,10 @@
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 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);
@ -15,19 +16,59 @@ export default class EventAttendeeMail extends EventMail {
* @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} at ${this.getInviteeStart().format('h:mma')} Your ${this.calEvent.type} with ${this.calEvent.organizer.name} at ${this.getInviteeStart().format(
(${this.calEvent.attendees[0].timeZone}) on ${this.getInviteeStart().format('dddd, LL')} is scheduled.<br /> "h:mma"
<br />` + this.getAdditionalBody() + ( )}
this.calEvent.location ? `<strong>Location:</strong> ${this.calEvent.location}<br /><br />` : '' (${this.calEvent.attendees[0].timeZone}) on ${this.getInviteeStart().format(
) + "dddd, LL"
)} is scheduled.<br />
<br />` +
this.getAdditionalBody() +
"<br />" +
`<strong>Additional notes:</strong><br /> `<strong>Additional notes:</strong><br />
${this.calEvent.description}<br /> ${this.calEvent.description}<br />
` + this.getAdditionalFooter() + ` ` +
this.getAdditionalFooter() +
`
</div> </div>
`
);
}
/**
* Adds the video call information to the mail body.
*
* @protected
*/
protected getLocation(): string {
if (this.additionInformation?.hangoutLink) {
return `<strong>Location:</strong> <a href="${this.additionInformation?.hangoutLink}">${this.additionInformation?.hangoutLink}</a><br />`;
}
if (this.additionInformation?.entryPoints && this.additionInformation?.entryPoints.length > 0) {
const locations = this.additionInformation?.entryPoints
.map((entryPoint) => {
return `
Join by ${entryPoint.entryPointType}: <br />
<a href="${entryPoint.uri}">${entryPoint.label}</a> <br />
`;
})
.join("<br />");
return `<strong>Locations:</strong><br /> ${locations}`;
}
return this.calEvent.location ? `<strong>Location:</strong> ${this.calEvent.location}<br /><br />` : "";
}
protected getAdditionalBody(): string {
return `
${this.getLocation()}
`; `;
} }
@ -36,12 +77,14 @@ export default class EventAttendeeMail extends EventMail {
* *
* @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: `Confirmed: ${this.calEvent.type} with ${this.calEvent.organizer.name} on ${this.getInviteeStart().format('dddd, LL')}`, subject: `Confirmed: ${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

@ -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,10 +1,31 @@
import {CalendarEvent} from "../calendarClient"; import CalEventParser from "../CalEventParser";
import { stripHtml } from "./helpers";
import { CalendarEvent, ConferenceData } from "../calendarClient";
import { serverConfig } from "../serverConfig"; import { serverConfig } from "../serverConfig";
import nodemailer from 'nodemailer'; import nodemailer from "nodemailer";
interface EntryPoint {
entryPointType?: string;
uri?: string;
label?: string;
pin?: string;
accessCode?: string;
meetingCode?: string;
passcode?: string;
password?: string;
}
interface AdditionInformation {
conferenceData?: ConferenceData;
entryPoints?: EntryPoint[];
hangoutLink?: string;
}
export default abstract class EventMail { export default abstract class EventMail {
calEvent: CalendarEvent; calEvent: CalendarEvent;
parser: CalEventParser;
uid: string; uid: string;
additionInformation?: AdditionInformation;
/** /**
* An EventMail always consists of a CalendarEvent * An EventMail always consists of a CalendarEvent
@ -14,9 +35,11 @@ export default abstract class EventMail {
* @param calEvent * @param calEvent
* @param uid * @param uid
*/ */
constructor(calEvent: CalendarEvent, uid: string) { 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;
} }
/** /**
@ -33,34 +56,23 @@ 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(): Object; 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.
*/ */
public sendEmail(): Promise<any> { public sendEmail(): Promise<any> {
new Promise((resolve, reject) => nodemailer.createTransport(this.getMailerOptions().transport).sendMail( new Promise((resolve, reject) =>
this.getNodeMailerPayload(), nodemailer
(error, info) => { .createTransport(this.getMailerOptions().transport)
.sendMail(this.getNodeMailerPayload(), (error, info) => {
if (error) { if (error) {
this.printNodeMailerError(error); this.printNodeMailerError(error);
reject(new Error(error)); reject(new Error(error));
@ -95,6 +107,8 @@ export default abstract class EventMail {
return ""; return "";
} }
protected abstract getLocation(): string;
/** /**
* Prints out the desired information when an error * Prints out the desired information when an error
* occured while sending the mail. * occured while sending the mail.
@ -109,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();
} }
/** /**
@ -118,21 +132,14 @@ 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();
} }
/** /**
* Defines a footer that will be appended to the email. * Defines a footer that will be appended to the email.
* @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

@ -2,10 +2,12 @@ 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 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);
@ -18,14 +20,24 @@ export default class EventOrganizerMail extends EventMail {
*/ */
protected getiCalEventAsString(): string { protected getiCalEventAsString(): string {
const icsEvent = createEvent({ const icsEvent = createEvent({
start: dayjs(this.calEvent.startTime).utc().toArray().slice(0, 6).map((v, i) => i === 1 ? v + 1 : v), start: dayjs(this.calEvent.startTime)
startInputType: 'utc', .utc()
productId: 'calendso/ics', .toArray()
.slice(0, 6)
.map((v, i) => (i === 1 ? v + 1 : v)),
startInputType: "utc",
productId: "calendso/ics",
title: `${this.calEvent.type} with ${this.calEvent.attendees[0].name}`, title: `${this.calEvent.type} with ${this.calEvent.attendees[0].name}`,
description: this.calEvent.description + this.stripHtml(this.getAdditionalBody()) + this.stripHtml(this.getAdditionalFooter()), description:
duration: { minutes: dayjs(this.calEvent.endTime).diff(dayjs(this.calEvent.startTime), 'minute') }, this.calEvent.description +
stripHtml(this.getAdditionalBody()) +
stripHtml(this.getAdditionalFooter()),
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) => ({ name: attendee.name, email: attendee.email }) ), attendees: this.calEvent.attendees.map((attendee: unknown) => ({
name: attendee.name,
email: attendee.email,
})),
status: "CONFIRMED", status: "CONFIRMED",
}); });
if (icsEvent.error) { if (icsEvent.error) {
@ -40,7 +52,8 @@ export default class EventOrganizerMail extends EventMail {
* @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 />
@ -51,40 +64,71 @@ export default class EventOrganizerMail extends EventMail {
<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 ? ` "<br />" +
<strong>Location:</strong><br />
${this.calEvent.location}<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>
`; `
);
} }
/**
* Adds the video call information to the mail body.
*
* @protected
*/
protected getLocation(): string {
if (this.additionInformation?.hangoutLink) {
return `<strong>Location:</strong> <a href="${this.additionInformation?.hangoutLink}">${this.additionInformation?.hangoutLink}</a><br />`;
}
if (this.additionInformation?.entryPoints && this.additionInformation?.entryPoints.length > 0) {
const locations = this.additionInformation?.entryPoints
.map((entryPoint) => {
return `
Join by ${entryPoint.entryPointType}: <br />
<a href="${entryPoint.uri}">${entryPoint.label}</a> <br />
`;
})
.join("<br />");
return `<strong>Locations:</strong><br /> ${locations}`;
}
return this.calEvent.location ? `<strong>Location:</strong> ${this.calEvent.location}<br /><br />` : "";
}
protected getAdditionalBody(): string {
return `
${this.getLocation()}
`;
}
/** /**
* Returns the payload object for the nodemailer. * Returns the payload object for the nodemailer.
* *
* @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: `New event: ${this.calEvent.attendees[0].name} - ${organizerStart.format('LT dddd, LL')} - ${this.calEvent.type}`, subject: `New 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

@ -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

@ -18,6 +18,7 @@ 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 />

View file

@ -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,9 +1,9 @@
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from "@prisma/client";
let prisma: PrismaClient; let prisma: PrismaClient;
const globalAny: any = global; const globalAny: any = global;
if (process.env.NODE_ENV === 'production') { if (process.env.NODE_ENV === "production") {
prisma = new PrismaClient(); prisma = new PrismaClient();
} else { } else {
if (!globalAny.prisma) { if (!globalAny.prisma) {
@ -12,4 +12,27 @@ if (process.env.NODE_ENV === 'production') {
prisma = globalAny.prisma; prisma = globalAny.prisma;
} }
const pluck = (select: Record<string, boolean>, attr: string) => {
const parts = attr.split(".");
const alwaysAttr = parts[0];
const pluckedValue =
parts.length > 1
? {
select: pluck(select[alwaysAttr] ? select[alwaysAttr].select : {}, parts.slice(1).join(".")),
}
: true;
return {
...select,
[alwaysAttr]: pluckedValue,
};
};
const whereAndSelect = (modelQuery, criteria: Record<string, unknown>, pluckedAttributes: string[]) =>
modelQuery({
where: criteria,
select: pluckedAttributes.reduce(pluck, {}),
});
export { whereAndSelect };
export default prisma; export default prisma;

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

@ -4,7 +4,7 @@
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
"test": "node --experimental-vm-modules node_modules/.bin/jest", "test": "node node_modules/.bin/jest",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"postinstall": "prisma generate", "postinstall": "prisma generate",

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,8 +1,9 @@
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";
function MyApp({ Component, pageProps }) { function MyApp({ Component, pageProps }: AppProps) {
return ( return (
<TelemetryProvider value={createTelemetryClient()}> <TelemetryProvider value={createTelemetryClient()}>
<Provider session={pageProps.session}> <Provider session={pageProps.session}>

View file

@ -1,6 +1,6 @@
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 });
@ -11,20 +11,19 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
} }
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,10 +1,10 @@
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import prisma from "../../../lib/prisma"; import prisma from "../../../lib/prisma";
import { CalendarEvent, createEvent, updateEvent } from "../../../lib/calendarClient"; import { CalendarEvent, createEvent, getBusyCalendarTimes, updateEvent } from "../../../lib/calendarClient";
import async from "async"; import async from "async";
import { v5 as uuidv5 } from "uuid"; import { v5 as uuidv5 } from "uuid";
import short from "short-uuid"; import short from "short-uuid";
import { createMeeting, updateMeeting } from "../../../lib/videoClient"; import { createMeeting, getBusyVideoTimes, 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 { LocationType } from "../../../lib/location";
@ -13,37 +13,39 @@ import dayjs from "dayjs";
const translator = short(); const translator = short();
// Commented out because unused and thus throwing an error in linter. function isAvailable(busyTimes, time, length) {
// const isAvailable = (busyTimes, time, length) => { // Check for conflicts
// // Check for conflicts let t = true;
// let t = true;
// busyTimes.forEach((busyTime) => { if (Array.isArray(busyTimes) && busyTimes.length > 0) {
// const startTime = dayjs(busyTime.start); busyTimes.forEach((busyTime) => {
// const endTime = dayjs(busyTime.end); const startTime = dayjs(busyTime.start);
// const endTime = dayjs(busyTime.end);
// // Check if start times are the same
// if (dayjs(time).format("HH:mm") == startTime.format("HH:mm")) { // Check if start times are the same
// t = false; if (dayjs(time).format("HH:mm") == startTime.format("HH:mm")) {
// } t = false;
// }
// // Check if time is between start and end times
// if (dayjs(time).isBetween(startTime, endTime)) { // Check if time is between start and end times
// t = false; if (dayjs(time).isBetween(startTime, endTime)) {
// } t = false;
// }
// // Check if slot end time is between start and end time
// if (dayjs(time).add(length, "minutes").isBetween(startTime, endTime)) { // Check if slot end time is between start and end time
// t = false; if (dayjs(time).add(length, "minutes").isBetween(startTime, endTime)) {
// } t = false;
// }
// // Check if startTime is between slot
// if (startTime.isBetween(dayjs(time), dayjs(time).add(length, "minutes"))) { // Check if startTime is between slot
// t = false; if (startTime.isBetween(dayjs(time), dayjs(time).add(length, "minutes"))) {
// } t = false;
// }); }
// });
// return t; }
// };
return t;
}
interface GetLocationRequestFromIntegrationRequest { interface GetLocationRequestFromIntegrationRequest {
location: string; location: string;
@ -91,46 +93,43 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}, },
}); });
// Commented out because unused and thus throwing an error in linter. const selectedCalendars = await prisma.selectedCalendar.findMany({
// const selectedCalendars = await prisma.selectedCalendar.findMany({ where: {
// where: { userId: currentUser.id,
// userId: currentUser.id, },
// }, });
// });
// Split credentials up into calendar credentials and video credentials // Split credentials up into calendar credentials and video credentials
let calendarCredentials = currentUser.credentials.filter((cred) => cred.type.endsWith("_calendar")); let calendarCredentials = currentUser.credentials.filter((cred) => cred.type.endsWith("_calendar"));
let videoCredentials = currentUser.credentials.filter((cred) => cred.type.endsWith("_video")); let videoCredentials = currentUser.credentials.filter((cred) => cred.type.endsWith("_video"));
// Commented out because unused and thus throwing an error in linter. const hasCalendarIntegrations =
// const hasCalendarIntegrations = currentUser.credentials.filter((cred) => cred.type.endsWith("_calendar")).length > 0;
// currentUser.credentials.filter((cred) => cred.type.endsWith("_calendar")).length > 0; const hasVideoIntegrations =
// const hasVideoIntegrations = currentUser.credentials.filter((cred) => cred.type.endsWith("_video")).length > 0;
// currentUser.credentials.filter((cred) => cred.type.endsWith("_video")).length > 0;
// Commented out because unused and thus throwing an error in linter. const calendarAvailability = await getBusyCalendarTimes(
// const calendarAvailability = await getBusyCalendarTimes( currentUser.credentials,
// currentUser.credentials, dayjs(req.body.start).startOf("day").utc().format(),
// dayjs(req.body.start).startOf("day").utc().format(), dayjs(req.body.end).endOf("day").utc().format(),
// dayjs(req.body.end).endOf("day").utc().format(), selectedCalendars
// selectedCalendars );
// ); const videoAvailability = await getBusyVideoTimes(
// const videoAvailability = await getBusyVideoTimes( currentUser.credentials,
// currentUser.credentials, dayjs(req.body.start).startOf("day").utc().format(),
// dayjs(req.body.start).startOf("day").utc().format(), dayjs(req.body.end).endOf("day").utc().format()
// dayjs(req.body.end).endOf("day").utc().format() );
// ); let commonAvailability = [];
// let commonAvailability = [];
// Commented out because unused and thus throwing an error in linter. if (hasCalendarIntegrations && hasVideoIntegrations) {
// if (hasCalendarIntegrations && hasVideoIntegrations) { commonAvailability = calendarAvailability.filter((availability) =>
// commonAvailability = calendarAvailability.filter((availability) => videoAvailability.includes(availability)
// videoAvailability.includes(availability) );
// ); } else if (hasVideoIntegrations) {
// } else if (hasVideoIntegrations) { commonAvailability = videoAvailability;
// commonAvailability = videoAvailability; } else if (hasCalendarIntegrations) {
// } else if (hasCalendarIntegrations) { commonAvailability = calendarAvailability;
// commonAvailability = calendarAvailability; }
// }
// Now, get the newly stored credentials (new refresh token for example). // Now, get the newly stored credentials (new refresh token for example).
currentUser = await prisma.user.findFirst({ currentUser = await prisma.user.findFirst({
@ -201,8 +200,15 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}, },
}); });
// TODO isAvailable was throwing an error let isAvailableToBeBooked = true;
const isAvailableToBeBooked = true; //isAvailable(commonAvailability, req.body.start, selectedEventType.length);
try {
isAvailableToBeBooked = isAvailable(commonAvailability, req.body.start, selectedEventType.length);
} catch {
console.debug({
message: "Unable set isAvailableToBeBooked. Using true. ",
});
}
if (!isAvailableToBeBooked) { if (!isAvailableToBeBooked) {
return res.status(400).json({ message: `${currentUser.name} is unavailable at this time.` }); return res.status(400).json({ message: `${currentUser.name} is unavailable at this time.` });

View file

@ -1,9 +1,8 @@
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) {
@ -12,7 +11,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
} }
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({
@ -21,17 +19,17 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}, },
}); });
const createMembership = await prisma.membership.create({ await prisma.membership.create({
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).setHeader('Location', process.env.BASE_URL + '/api/teams/1').send(null); return res.status(201).json({ message: "Team created" });
} }
res.status(404).send(null); 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,31 +1,41 @@
import Head from 'next/head'; import { GetServerSideProps } from "next";
import prisma from '../../lib/prisma'; import Head from "next/head";
import Modal from '../../components/Modal'; 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 { 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(props) { export default function Teams() {
const [, loading] = useSession();
const [session, 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);
const loadTeams = () => fetch('/api/user/membership').then((res: any) => res.json()).then( const handleErrors = async (resp) => {
(data) => { if (!resp.ok) {
const err = await resp.json();
throw new Error(err.message);
}
return resp.json();
};
const loadData = () => {
fetch("/api/user/membership")
.then(handleErrors)
.then((data) => {
setTeams(data.membership.filter((m) => m.role !== "INVITEE")); setTeams(data.membership.filter((m) => m.role !== "INVITEE"));
setInvites(data.membership.filter((m) => m.role === "INVITEE")); setInvites(data.membership.filter((m) => m.role === "INVITEE"));
} })
); .catch(console.log);
};
useEffect(() => { loadTeams(); }, []); useEffect(() => {
loadData();
}, []);
if (loading) { if (loading) {
return <p className="text-gray-400">Loading...</p>; return <p className="text-gray-400">Loading...</p>;
@ -33,17 +43,18 @@ export default function Teams(props) {
const createTeam = (e) => { const createTeam = (e) => {
e.preventDefault(); e.preventDefault();
return fetch('/api/teams', {
method: 'POST', return fetch("/api/teams", {
body: JSON.stringify({ name: e.target.elements['name'].value }), method: "POST",
body: JSON.stringify({ name: e.target.elements["name"].value }),
headers: { headers: {
'Content-Type': 'application/json' "Content-Type": "application/json",
} },
}).then(() => { }).then(() => {
loadTeams(); loadData();
setShowCreateTeamModal(false); setShowCreateTeamModal(false);
}); });
} };
return ( return (
<Shell heading="Teams"> <Shell heading="Teams">
@ -60,10 +71,12 @@ export default function Teams(props) {
<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>
@ -71,31 +84,35 @@ export default function Teams(props) {
<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={loadTeams}>
</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={loadTeams} 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>
@ -111,12 +128,20 @@ export default function Teams(props) {
</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">
@ -124,24 +149,36 @@ export default function Teams(props) {
<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>
@ -149,8 +186,20 @@ export default function Teams(props) {
</div> </div>
</div> </div>
</div> </div>
} )}
</SettingsShell> </SettingsShell>
</Shell> </Shell>
); );
} }
// Export the `session` prop to use sessions with Server Side Rendering
export const getServerSideProps: GetServerSideProps<{ session: Session | null }> = async (context) => {
const session = await getSession(context);
if (!session) {
return { redirect: { permanent: false, destination: "/auth/login" } };
}
return {
props: { session },
};
};

108
test/lib/prisma.test.ts Normal file
View file

@ -0,0 +1,108 @@
import { expect, it } from "@jest/globals";
import { whereAndSelect } from "@lib/prisma";
it("can decorate using whereAndSelect", async () => {
whereAndSelect(
(queryObj) => {
expect(queryObj).toStrictEqual({ where: { id: 1 }, select: { example: true } });
},
{ id: 1 },
[
"example",
]
);
});
it("can do nested selects using . seperator", async () => {
whereAndSelect(
(queryObj) => {
expect(queryObj).toStrictEqual({
where: {
uid: 1,
},
select: {
description: true,
attendees: {
select: {
email: true,
name: true,
},
},
},
});
},
{ uid: 1 },
[
"description",
"attendees.email",
"attendees.name",
]
);
});
it("can handle nesting deeply", async () => {
whereAndSelect(
(queryObj) => {
expect(queryObj).toStrictEqual({
where: {
uid: 1,
},
select: {
description: true,
attendees: {
select: {
email: {
select: {
nested: true,
}
},
name: true,
},
},
},
});
},
{ uid: 1 },
[
"description",
"attendees.email.nested",
"attendees.name",
]
);
});
it("can handle nesting multiple", async () => {
whereAndSelect(
(queryObj) => {
expect(queryObj).toStrictEqual({
where: {
uid: 1,
},
select: {
description: true,
attendees: {
select: {
email: true,
name: true,
},
},
bookings: {
select: {
id: true,
name: true,
}
}
}
});
},
{ uid: 1 },
[
"description",
"attendees.email",
"attendees.name",
"bookings.id",
"bookings.name",
]
);
});

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);