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 timezone from "dayjs/plugin/timezone";
import getSlots from "@lib/slots";
dayjs.extend(utc);
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 getSlots from "../../lib/slots";
import dayjs, { Dayjs } from "dayjs";
import isBetween from "dayjs/plugin/isBetween";
import utc from "dayjs/plugin/utc";
dayjs.extend(isBetween);
dayjs.extend(utc);

View file

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

View file

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

View file

@ -7,6 +7,7 @@ import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import timezone from "dayjs/plugin/timezone";
import { Availability } from "@prisma/client";
dayjs.extend(utc);
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 EventAttendeeMail from "./emails/EventAttendeeMail";
import { v5 as uuidv5 } from "uuid";
import short from "short-uuid";
import EventOrganizerRescheduledMail from "./emails/EventOrganizerRescheduledMail";
import EventAttendeeRescheduledMail from "./emails/EventAttendeeRescheduledMail";
const translator = short();
import prisma from "./prisma";
import { Credential } from "@prisma/client";
import CalEventParser from "./CalEventParser";
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { google } = require("googleapis");
import prisma from "./prisma";
const googleAuth = (credential) => {
const { client_secret, client_id, redirect_uris } = JSON.parse(process.env.GOOGLE_API_CREDENTIALS).web;
@ -111,7 +109,7 @@ interface Person {
timeZone: string;
}
interface CalendarEvent {
export interface CalendarEvent {
type: string;
title: string;
startTime: string;
@ -123,25 +121,25 @@ interface CalendarEvent {
conferenceData?: ConferenceData;
}
interface ConferenceData {
createRequest: any;
export interface ConferenceData {
createRequest: unknown;
}
interface IntegrationCalendar {
export interface IntegrationCalendar {
integration: string;
primary: boolean;
externalId: string;
name: string;
}
interface CalendarApiAdapter {
createEvent(event: CalendarEvent): Promise<any>;
export interface CalendarApiAdapter {
createEvent(event: CalendarEvent): Promise<unknown>;
updateEvent(uid: string, event: CalendarEvent);
deleteEvent(uid: string);
getAvailability(dateFrom, dateTo, selectedCalendars: IntegrationCalendar[]): Promise<any>;
getAvailability(dateFrom, dateTo, selectedCalendars: IntegrationCalendar[]): Promise<unknown>;
listCalendars(): Promise<IntegrationCalendar[]>;
}
@ -373,6 +371,7 @@ const GoogleCalendar = (credential): CalendarApiAdapter => {
auth: myGoogleAuth,
calendarId: "primary",
resource: payload,
conferenceDataVersion: 1,
},
function (err, event) {
if (err) {
@ -506,13 +505,29 @@ const listCalendars = (withCredentials) =>
results.reduce((acc, calendars) => acc.concat(calendars), [])
);
const createEvent = async (credential, calEvent: CalendarEvent): Promise<any> => {
const uid: string = translator.fromUUID(uuidv5(JSON.stringify(calEvent), uuidv5.URL));
const createEvent = async (credential: Credential, calEvent: CalendarEvent): Promise<unknown> => {
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 {
await organizerMail.sendEmail();
} 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 newUid: string = translator.fromUUID(uuidv5(JSON.stringify(calEvent), uuidv5.URL));
const updateEvent = async (
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
? await calendars([credential])[0].updateEvent(uidToUpdate, calEvent)
? await calendars([credential])[0].updateEvent(uidToUpdate, richEvent)
: null;
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) {
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 utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
import localizedFormat from 'dayjs/plugin/localizedFormat';
import utc from "dayjs/plugin/utc";
import timezone from "dayjs/plugin/timezone";
import localizedFormat from "dayjs/plugin/localizedFormat";
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(localizedFormat);
@ -15,20 +16,60 @@ export default class EventAttendeeMail extends EventMail {
* @protected
*/
protected getHtmlRepresentation(): string {
return `
return (
`
<div>
Hi ${this.calEvent.attendees[0].name},<br />
<br />
Your ${this.calEvent.type} with ${this.calEvent.organizer.name} at ${this.getInviteeStart().format('h:mma')}
(${this.calEvent.attendees[0].timeZone}) on ${this.getInviteeStart().format('dddd, LL')} is scheduled.<br />
<br />` + this.getAdditionalBody() + (
this.calEvent.location ? `<strong>Location:</strong> ${this.calEvent.location}<br /><br />` : ''
) +
Your ${this.calEvent.type} with ${this.calEvent.organizer.name} at ${this.getInviteeStart().format(
"h:mma"
)}
(${this.calEvent.attendees[0].timeZone}) on ${this.getInviteeStart().format(
"dddd, LL"
)} is scheduled.<br />
<br />` +
this.getAdditionalBody() +
"<br />" +
`<strong>Additional notes:</strong><br />
${this.calEvent.description}<br />
` + this.getAdditionalFooter() + `
` +
this.getAdditionalFooter() +
`
</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 getNodeMailerPayload(): Object {
protected getNodeMailerPayload(): Record<string, unknown> {
return {
to: `${this.calEvent.attendees[0].name} <${this.calEvent.attendees[0].email}>`,
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
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(),
text: this.getPlainTextRepresentation(),
};
@ -59,4 +102,4 @@ export default class EventAttendeeMail extends EventMail {
protected getInviteeStart(): Dayjs {
return <Dayjs>dayjs(this.calEvent.startTime).tz(this.calEvent.attendees[0].timeZone);
}
}
}

View file

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

View file

@ -1,10 +1,31 @@
import {CalendarEvent} from "../calendarClient";
import {serverConfig} from "../serverConfig";
import nodemailer from 'nodemailer';
import CalEventParser from "../CalEventParser";
import { stripHtml } from "./helpers";
import { CalendarEvent, ConferenceData } from "../calendarClient";
import { serverConfig } from "../serverConfig";
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 {
calEvent: CalendarEvent;
parser: CalEventParser;
uid: string;
additionInformation?: AdditionInformation;
/**
* An EventMail always consists of a CalendarEvent
@ -14,9 +35,11 @@ export default abstract class EventMail {
* @param calEvent
* @param uid
*/
constructor(calEvent: CalendarEvent, uid: string) {
constructor(calEvent: CalendarEvent, uid: string, additionInformation: AdditionInformation = null) {
this.calEvent = calEvent;
this.uid = uid;
this.parser = new CalEventParser(calEvent);
this.additionInformation = additionInformation;
}
/**
@ -33,41 +56,30 @@ export default abstract class EventMail {
* @protected
*/
protected getPlainTextRepresentation(): string {
return this.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, '');
return stripHtml(this.getHtmlRepresentation());
}
/**
* Returns the payload object for the nodemailer.
* @protected
*/
protected abstract getNodeMailerPayload(): Object;
protected abstract getNodeMailerPayload(): Record<string, unknown>;
/**
* Sends the email to the event attendant and returns a Promise.
*/
public sendEmail(): Promise<any> {
new Promise((resolve, reject) => nodemailer.createTransport(this.getMailerOptions().transport).sendMail(
this.getNodeMailerPayload(),
(error, info) => {
if (error) {
this.printNodeMailerError(error);
reject(new Error(error));
} else {
resolve(info);
}
})
new Promise((resolve, reject) =>
nodemailer
.createTransport(this.getMailerOptions().transport)
.sendMail(this.getNodeMailerPayload(), (error, info) => {
if (error) {
this.printNodeMailerError(error);
reject(new Error(error));
} else {
resolve(info);
}
})
).catch((e) => console.error("sendEmail", e));
return new Promise((resolve) => resolve("send mail async"));
}
@ -95,6 +107,8 @@ export default abstract class EventMail {
return "";
}
protected abstract getLocation(): string;
/**
* Prints out the desired information when an error
* occured while sending the mail.
@ -109,7 +123,7 @@ export default abstract class EventMail {
* @protected
*/
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 getCancelLink(): string {
return process.env.BASE_URL + '/cancel/' + this.uid;
return this.parser.getCancelLink();
}
/**
* Defines a footer that will be appended to the email.
* @protected
*/
protected getAdditionalFooter(): string {
return `
<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>
`;
return this.parser.getChangeEventFooterHtml();
}
}

View file

@ -1,11 +1,13 @@
import {createEvent} from "ics";
import dayjs, {Dayjs} from "dayjs";
import { createEvent } from "ics";
import dayjs, { Dayjs } from "dayjs";
import EventMail from "./EventMail";
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
import toArray from 'dayjs/plugin/toArray';
import localizedFormat from 'dayjs/plugin/localizedFormat';
import utc from "dayjs/plugin/utc";
import timezone from "dayjs/plugin/timezone";
import toArray from "dayjs/plugin/toArray";
import localizedFormat from "dayjs/plugin/localizedFormat";
import { stripHtml } from "./helpers";
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(toArray);
@ -18,14 +20,24 @@ export default class EventOrganizerMail extends EventMail {
*/
protected getiCalEventAsString(): string {
const icsEvent = createEvent({
start: dayjs(this.calEvent.startTime).utc().toArray().slice(0, 6).map((v, i) => i === 1 ? v + 1 : v),
startInputType: 'utc',
productId: 'calendso/ics',
start: dayjs(this.calEvent.startTime)
.utc()
.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}`,
description: this.calEvent.description + this.stripHtml(this.getAdditionalBody()) + this.stripHtml(this.getAdditionalFooter()),
duration: { minutes: dayjs(this.calEvent.endTime).diff(dayjs(this.calEvent.startTime), 'minute') },
description:
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 },
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",
});
if (icsEvent.error) {
@ -40,7 +52,8 @@ export default class EventOrganizerMail extends EventMail {
* @protected
*/
protected getHtmlRepresentation(): string {
return `
return (
`
<div>
Hi ${this.calEvent.organizer.name},<br />
<br />
@ -51,40 +64,71 @@ export default class EventOrganizerMail extends EventMail {
<br />
<strong>Invitee Email:</strong><br />
<a href="mailto:${this.calEvent.attendees[0].email}">${this.calEvent.attendees[0].email}</a><br />
<br />` + this.getAdditionalBody() +
(
this.calEvent.location ? `
<strong>Location:</strong><br />
${this.calEvent.location}<br />
<br />
` : ''
) +
<br />` +
this.getAdditionalBody() +
"<br />" +
`<strong>Invitee Time Zone:</strong><br />
${this.calEvent.attendees[0].timeZone}<br />
<br />
<strong>Additional notes:</strong><br />
${this.calEvent.description}
` + this.getAdditionalFooter() + `
${this.calEvent.attendees[0].timeZone}<br />
<br />
<strong>Additional notes:</strong><br />
${this.calEvent.description}
` +
this.getAdditionalFooter() +
`
</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.
*
* @protected
*/
protected getNodeMailerPayload(): Object {
protected getNodeMailerPayload(): Record<string, unknown> {
const organizerStart: Dayjs = <Dayjs>dayjs(this.calEvent.startTime).tz(this.calEvent.organizer.timeZone);
return {
icalEvent: {
filename: 'event.ics',
filename: "event.ics",
content: this.getiCalEventAsString(),
},
from: `Calendso <${this.getMailerOptions().from}>`,
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(),
text: this.getPlainTextRepresentation(),
};
@ -93,4 +137,4 @@ export default class EventOrganizerMail extends EventMail {
protected printNodeMailerError(error: string): void {
console.error("SEND_NEW_EVENT_NOTIFICATION_ERROR", this.calEvent.organizer.email, error);
}
}
}

View file

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

View file

@ -1,7 +1,7 @@
import {CalendarEvent} from "../calendarClient";
import { CalendarEvent } from "../calendarClient";
import EventOrganizerMail from "./EventOrganizerMail";
import {VideoCallData} from "../videoClient";
import {getFormattedMeetingId, getIntegrationName} from "./helpers";
import { VideoCallData } from "../videoClient";
import { getFormattedMeetingId, getIntegrationName } from "./helpers";
export default class VideoEventOrganizerMail extends EventOrganizerMail {
videoCallData: VideoCallData;
@ -18,11 +18,12 @@ export default class VideoEventOrganizerMail extends EventOrganizerMail {
* @protected
*/
protected getAdditionalBody(): string {
// This odd indentation is necessary because otherwise the leading tabs will be applied into the event description.
return `
<strong>Video call provider:</strong> ${getIntegrationName(this.videoCallData)}<br />
<strong>Meeting ID:</strong> ${getFormattedMeetingId(this.videoCallData)}<br />
<strong>Meeting Password:</strong> ${this.videoCallData.password}<br />
<strong>Meeting URL:</strong> <a href="${this.videoCallData.url}">${this.videoCallData.url}</a><br />
<strong>Video call provider:</strong> ${getIntegrationName(this.videoCallData)}<br />
<strong>Meeting ID:</strong> ${getFormattedMeetingId(this.videoCallData)}<br />
<strong>Meeting Password:</strong> ${this.videoCallData.password}<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 {
//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);
}
function extractZoom(videoCallData: VideoCallData): string {
const strId = videoCallData.id.toString();
const part1 = strId.slice(0, 3);
const part2 = strId.slice(3, 7);
const part3 = strId.slice(7, 11);
return part1 + " " + part2 + " " + part3;
}
export function getFormattedMeetingId(videoCallData: VideoCallData): string {
switch(videoCallData.type) {
case 'zoom_video':
const strId = videoCallData.id.toString();
const part1 = strId.slice(0, 3);
const part2 = strId.slice(3, 7);
const part3 = strId.slice(7, 11);
return part1 + " " + part2 + " " + part3;
switch (videoCallData.type) {
case "zoom_video":
return extractZoom(videoCallData);
default:
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;
const globalAny:any = global;
const globalAny: any = global;
if (process.env.NODE_ENV === 'production') {
if (process.env.NODE_ENV === "production") {
prisma = new PrismaClient();
} else {
if (!globalAny.prisma) {
@ -12,4 +12,27 @@ if (process.env.NODE_ENV === 'production') {
prisma = globalAny.prisma;
}
export default 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;

View file

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

View file

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

View file

@ -1,7 +1,7 @@
import { useEffect, useState } from "react";
import { GetServerSideProps } from "next";
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 { useRouter } from "next/router";
import { Dayjs } from "dayjs";

View file

@ -1,14 +1,15 @@
import '../styles/globals.css';
import {createTelemetryClient, TelemetryProvider} from '../lib/telemetry';
import { Provider } from 'next-auth/client';
import "../styles/globals.css";
import { createTelemetryClient, TelemetryProvider } from "../lib/telemetry";
import { Provider } from "next-auth/client";
import type { AppProps } from "next/app";
function MyApp({ Component, pageProps }) {
function MyApp({ Component, pageProps }: AppProps) {
return (
<TelemetryProvider value={createTelemetryClient()}>
<Provider session={pageProps.session}>
<Component {...pageProps} />
</Provider>
</TelemetryProvider>
<TelemetryProvider value={createTelemetryClient()}>
<Provider session={pageProps.session}>
<Component {...pageProps} />
</Provider>
</TelemetryProvider>
);
}

View file

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

View file

@ -1,18 +1,16 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import prisma from '../../lib/prisma';
import {getSession} from "next-auth/client";
import type { NextApiRequest, NextApiResponse } from "next";
import prisma from "../../lib/prisma";
import { getSession } from "next-auth/client";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const session = await getSession({req: req});
const session = await getSession({ req: req });
if (!session) {
res.status(401).json({message: "Not authenticated"});
res.status(401).json({ message: "Not authenticated" });
return;
}
if (req.method === "POST") {
// TODO: Prevent creating a team with identical names?
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: {
teamId: createTeam.id,
userId: session.user.id,
role: 'OWNER',
role: "OWNER",
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 { 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 { PlusIcon } from "@heroicons/react/solid";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
dayjs.extend(utc);
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";
dayjs.extend(utc);
dayjs.extend(timezone);
type Props = {

View file

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

View file

@ -1,31 +1,41 @@
import Head from 'next/head';
import prisma from '../../lib/prisma';
import Modal from '../../components/Modal';
import Shell from '../../components/Shell';
import SettingsShell from '../../components/Settings';
import { useEffect, useState } from 'react';
import { useSession, getSession } from 'next-auth/client';
import {
UsersIcon,
} from "@heroicons/react/outline";
import { GetServerSideProps } from "next";
import Head from "next/head";
import Shell from "../../components/Shell";
import SettingsShell from "../../components/Settings";
import { useEffect, useState } from "react";
import type { Session } from "next-auth";
import { getSession, useSession } from "next-auth/client";
import { UsersIcon } from "@heroicons/react/outline";
import TeamList from "../../components/team/TeamList";
import TeamListItem from "../../components/team/TeamListItem";
export default function Teams(props) {
const [session, loading] = useSession();
export default function Teams() {
const [, loading] = useSession();
const [teams, setTeams] = useState([]);
const [invites, setInvites] = useState([]);
const [showCreateTeamModal, setShowCreateTeamModal] = useState(false);
const loadTeams = () => fetch('/api/user/membership').then((res: any) => res.json()).then(
(data) => {
setTeams(data.membership.filter((m) => m.role !== "INVITEE"));
setInvites(data.membership.filter((m) => m.role === "INVITEE"));
const handleErrors = async (resp) => {
if (!resp.ok) {
const err = await resp.json();
throw new Error(err.message);
}
);
return resp.json();
};
useEffect(() => { loadTeams(); }, []);
const loadData = () => {
fetch("/api/user/membership")
.then(handleErrors)
.then((data) => {
setTeams(data.membership.filter((m) => m.role !== "INVITEE"));
setInvites(data.membership.filter((m) => m.role === "INVITEE"));
})
.catch(console.log);
};
useEffect(() => {
loadData();
}, []);
if (loading) {
return <p className="text-gray-400">Loading...</p>;
@ -33,17 +43,18 @@ export default function Teams(props) {
const createTeam = (e) => {
e.preventDefault();
return fetch('/api/teams', {
method: 'POST',
body: JSON.stringify({ name: e.target.elements['name'].value }),
return fetch("/api/teams", {
method: "POST",
body: JSON.stringify({ name: e.target.elements["name"].value }),
headers: {
'Content-Type': 'application/json'
}
"Content-Type": "application/json",
},
}).then(() => {
loadTeams();
loadData();
setShowCreateTeamModal(false);
});
}
};
return (
<Shell heading="Teams">
@ -60,10 +71,12 @@ export default function Teams(props) {
<p className="mt-1 text-sm text-gray-500 mb-4">
View, edit and create teams to organise relationships between users
</p>
{!(invites.length || teams.length) &&
{!(invites.length || teams.length) && (
<div className="bg-gray-50 sm:rounded-lg">
<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">
<p>Create your first team and invite other users to work together with you.</p>
</div>
@ -71,31 +84,35 @@ export default function Teams(props) {
<button
type="button"
onClick={() => setShowCreateTeamModal(true)}
className="btn btn-primary"
>
className="btn btn-primary">
Create new team
</button>
</div>
</div>
</div>
}
)}
</div>
{!!(invites.length || teams.length) && <div>
<button className="btn-sm btn-primary" onClick={() => setShowCreateTeamModal(true)}>Create new team</button>
</div>}
{!!(invites.length || teams.length) && (
<div>
<button className="btn-sm btn-primary" onClick={() => setShowCreateTeamModal(true)}>
Create new team
</button>
</div>
)}
</div>
<div>
{!!teams.length &&
<TeamList teams={teams} onChange={loadTeams}>
</TeamList>
}
{!!teams.length && <TeamList teams={teams} onChange={loadData}></TeamList>}
{!!invites.length && <div>
<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">
{invites.map((team) => <TeamListItem onChange={loadTeams} key={team.id} team={team}></TeamListItem>)}
</ul>
</div>}
{!!invites.length && (
<div>
<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">
{invites.map((team) => (
<TeamListItem onChange={loadData} key={team.id} team={team}></TeamListItem>
))}
</ul>
</div>
)}
</div>
{/*{teamsLoaded && <div className="flex justify-between">
<div>
@ -111,12 +128,20 @@ export default function Teams(props) {
</div>}*/}
</div>
</div>
{showCreateTeamModal &&
<div className="fixed z-10 inset-0 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
{showCreateTeamModal && (
<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="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="sm:flex sm:items-start mb-4">
@ -124,33 +149,57 @@ export default function Teams(props) {
<UsersIcon className="h-6 w-6 text-blue-600" />
</div>
<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>
<p className="text-sm text-gray-400">
Create a new team to collaborate with users.
</p>
<p className="text-sm text-gray-400">Create a new team to collaborate with users.</p>
</div>
</div>
</div>
<form onSubmit={createTeam}>
<div className="mb-4">
<label htmlFor="name" className="block text-sm font-medium text-gray-700">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" />
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
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 className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<button type="submit" className="btn btn-primary">
Create team
</button>
<button onClick={() => setShowCreateTeamModal(false)} type="button" className="btn btn-white mr-2">
</button>
<button
onClick={() => setShowCreateTeamModal(false)}
type="button"
className="btn btn-white mr-2">
Cancel
</button>
</button>
</div>
</form>
</div>
</div>
</div>
}
)}
</SettingsShell>
</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 {it, expect} from '@jest/globals';
import MockDate from 'mockdate';
import dayjs, {Dayjs} from 'dayjs';
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
import getSlots from "@lib/slots";
import { expect, it } from "@jest/globals";
import MockDate from "mockdate";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import timezone from "dayjs/plugin/timezone";
dayjs.extend(utc);
dayjs.extend(timezone);
@ -53,4 +54,4 @@ it('can cut off dates that due to invitee timezone differences fall on the previ
],
organizerTimeZone: 'Europe/London'
})).toHaveLength(0);
});
});