diff --git a/components/booking/DatePicker.tsx b/components/booking/DatePicker.tsx index 1bea6caf65..cfcc6a79cc 100644 --- a/components/booking/DatePicker.tsx +++ b/components/booking/DatePicker.tsx @@ -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); diff --git a/components/booking/Slots.tsx b/components/booking/Slots.tsx index 8f92aaeb14..1b4a8bfd21 100644 --- a/components/booking/Slots.tsx +++ b/components/booking/Slots.tsx @@ -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); diff --git a/components/booking/TimeOptions.tsx b/components/booking/TimeOptions.tsx index 580e174e4b..a785789f1f 100644 --- a/components/booking/TimeOptions.tsx +++ b/components/booking/TimeOptions.tsx @@ -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(" "); diff --git a/components/ui/PoweredByCalendso.tsx b/components/ui/PoweredByCalendso.tsx index 2e890fa836..a438189ded 100644 --- a/components/ui/PoweredByCalendso.tsx +++ b/components/ui/PoweredByCalendso.tsx @@ -3,10 +3,7 @@ import Link from "next/link"; const PoweredByCalendso = () => (
- + powered by{" "} (
); -export default PoweredByCalendso; \ No newline at end of file +export default PoweredByCalendso; diff --git a/components/ui/Scheduler.tsx b/components/ui/Scheduler.tsx index 045c726d20..edec1319bf 100644 --- a/components/ui/Scheduler.tsx +++ b/components/ui/Scheduler.tsx @@ -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); diff --git a/lib/CalEventParser.ts b/lib/CalEventParser.ts new file mode 100644 index 0000000000..a38494f2aa --- /dev/null +++ b/lib/CalEventParser.ts @@ -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 `
+Need to change this event?
+Cancel:
${this.getCancelLink()}
+Reschedule: ${this.getRescheduleLink()} + `; + } + + /** + * 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 ( + ` +Event Type:
${this.calEvent.type}
+Invitee Email:
${this.calEvent.attendees[0].email}
+` + + (this.calEvent.location + ? `Location:
${this.calEvent.location}
+` + : "") + + `Invitee Time Zone:
${this.calEvent.attendees[0].timeZone}
+Additional notes:
${this.calEvent.description}
` + + 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; + } +} diff --git a/lib/calendarClient.ts b/lib/calendarClient.ts index 4d8d7421b6..3891feab71 100644 --- a/lib/calendarClient.ts +++ b/lib/calendarClient.ts @@ -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; +export interface CalendarApiAdapter { + createEvent(event: CalendarEvent): Promise; updateEvent(uid: string, event: CalendarEvent); deleteEvent(uid: string); - getAvailability(dateFrom, dateTo, selectedCalendars: IntegrationCalendar[]): Promise; + getAvailability(dateFrom, dateTo, selectedCalendars: IntegrationCalendar[]): Promise; listCalendars(): Promise; } @@ -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 => { - const uid: string = translator.fromUUID(uuidv5(JSON.stringify(calEvent), uuidv5.URL)); +const createEvent = async (credential: Credential, calEvent: CalendarEvent): Promise => { + 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 => }; }; -const updateEvent = async (credential, uidToUpdate: string, calEvent: CalendarEvent): Promise => { - const newUid: string = translator.fromUUID(uuidv5(JSON.stringify(calEvent), uuidv5.URL)); +const updateEvent = async ( + credential: Credential, + uidToUpdate: string, + calEvent: CalendarEvent +): Promise => { + 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 => { +const deleteEvent = (credential: Credential, uid: string): Promise => { if (credential) { return calendars([credential])[0].deleteEvent(uid); } diff --git a/lib/emails/EventAttendeeMail.ts b/lib/emails/EventAttendeeMail.ts index 55f231f939..3fd73f17ae 100644 --- a/lib/emails/EventAttendeeMail.ts +++ b/lib/emails/EventAttendeeMail.ts @@ -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 ( + `
Hi ${this.calEvent.attendees[0].name},

- 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.
-
` + this.getAdditionalBody() + ( - this.calEvent.location ? `Location: ${this.calEvent.location}

` : '' - ) + + 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.
+
` + + this.getAdditionalBody() + + "
" + `Additional notes:
${this.calEvent.description}
- ` + this.getAdditionalFooter() + ` + ` + + this.getAdditionalFooter() + + `
- `; + ` + ); + } + + /** + * Adds the video call information to the mail body. + * + * @protected + */ + protected getLocation(): string { + if (this.additionInformation?.hangoutLink) { + return `Location: ${this.additionInformation?.hangoutLink}
`; + } + + if (this.additionInformation?.entryPoints && this.additionInformation?.entryPoints.length > 0) { + const locations = this.additionInformation?.entryPoints + .map((entryPoint) => { + return ` + Join by ${entryPoint.entryPointType}:
+ ${entryPoint.label}
+ `; + }) + .join("
"); + + return `Locations:
${locations}`; + } + + return this.calEvent.location ? `Location: ${this.calEvent.location}

` : ""; + } + + protected getAdditionalBody(): string { + return ` + ${this.getLocation()} + `; } /** @@ -36,12 +77,14 @@ export default class EventAttendeeMail extends EventMail { * * @protected */ - protected getNodeMailerPayload(): Object { + protected getNodeMailerPayload(): Record { 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(this.calEvent.startTime).tz(this.calEvent.attendees[0].timeZone); } -} \ No newline at end of file +} diff --git a/lib/emails/EventAttendeeRescheduledMail.ts b/lib/emails/EventAttendeeRescheduledMail.ts index 760aa040f8..1286c7ab7f 100644 --- a/lib/emails/EventAttendeeRescheduledMail.ts +++ b/lib/emails/EventAttendeeRescheduledMail.ts @@ -7,15 +7,21 @@ export default class EventAttendeeRescheduledMail extends EventAttendeeMail { * @protected */ protected getHtmlRepresentation(): string { - return ` + return ( + `
Hi ${this.calEvent.attendees[0].name},

- 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')}.
- ` + 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")}.
+ ` + + this.getAdditionalFooter() + + `
- `; + ` + ); } /** @@ -23,12 +29,14 @@ export default class EventAttendeeRescheduledMail extends EventAttendeeMail { * * @protected */ - protected getNodeMailerPayload(): Object { + protected getNodeMailerPayload(): Record { 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); } -} \ No newline at end of file +} diff --git a/lib/emails/EventMail.ts b/lib/emails/EventMail.ts index de1c050732..90cee0b5e9 100644 --- a/lib/emails/EventMail.ts +++ b/lib/emails/EventMail.ts @@ -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('
', "\n") - .replace(/<[^>]+>/g, ''); + return stripHtml(this.getHtmlRepresentation()); } /** * Returns the payload object for the nodemailer. * @protected */ - protected abstract getNodeMailerPayload(): Object; + protected abstract getNodeMailerPayload(): Record; /** * Sends the email to the event attendant and returns a Promise. */ public sendEmail(): Promise { - 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 ` -
-
- Need to change this event?
- Cancel: ${this.getCancelLink()}
- Reschedule: ${this.getRescheduleLink()} - `; + return this.parser.getChangeEventFooterHtml(); } } diff --git a/lib/emails/EventOrganizerMail.ts b/lib/emails/EventOrganizerMail.ts index 6d3060b6ca..48b5f078f9 100644 --- a/lib/emails/EventOrganizerMail.ts +++ b/lib/emails/EventOrganizerMail.ts @@ -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 ( + `
Hi ${this.calEvent.organizer.name},

@@ -51,40 +64,71 @@ export default class EventOrganizerMail extends EventMail {
Invitee Email:
${this.calEvent.attendees[0].email}
-
` + this.getAdditionalBody() + - ( - this.calEvent.location ? ` - Location:
- ${this.calEvent.location}
-
- ` : '' - ) + +
` + + this.getAdditionalBody() + + "
" + `Invitee Time Zone:
- ${this.calEvent.attendees[0].timeZone}
-
- Additional notes:
- ${this.calEvent.description} - ` + this.getAdditionalFooter() + ` + ${this.calEvent.attendees[0].timeZone}
+
+ Additional notes:
+ ${this.calEvent.description} + ` + + this.getAdditionalFooter() + + `
- `; + ` + ); } + /** + * Adds the video call information to the mail body. + * + * @protected + */ + protected getLocation(): string { + if (this.additionInformation?.hangoutLink) { + return `Location: ${this.additionInformation?.hangoutLink}
`; + } + + if (this.additionInformation?.entryPoints && this.additionInformation?.entryPoints.length > 0) { + const locations = this.additionInformation?.entryPoints + .map((entryPoint) => { + return ` + Join by ${entryPoint.entryPointType}:
+ ${entryPoint.label}
+ `; + }) + .join("
"); + + return `Locations:
${locations}`; + } + + return this.calEvent.location ? `Location: ${this.calEvent.location}

` : ""; + } + + protected getAdditionalBody(): string { + return ` + ${this.getLocation()} + `; + } /** * Returns the payload object for the nodemailer. * * @protected */ - protected getNodeMailerPayload(): Object { + protected getNodeMailerPayload(): Record { const organizerStart: 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); } -} \ No newline at end of file +} diff --git a/lib/emails/EventOrganizerRescheduledMail.ts b/lib/emails/EventOrganizerRescheduledMail.ts index 7e67ac4466..af9cc864de 100644 --- a/lib/emails/EventOrganizerRescheduledMail.ts +++ b/lib/emails/EventOrganizerRescheduledMail.ts @@ -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 ( + `
Hi ${this.calEvent.organizer.name},

@@ -19,22 +20,26 @@ export default class EventOrganizerRescheduledMail extends EventOrganizerMail {
Invitee Email:
${this.calEvent.attendees[0].email}
-
` + this.getAdditionalBody() + - ( - this.calEvent.location ? ` +
` + + this.getAdditionalBody() + + (this.calEvent.location + ? ` Location:
${this.calEvent.location}

- ` : '' - ) + + ` + : "") + `Invitee Time Zone:
${this.calEvent.attendees[0].timeZone}

Additional notes:
${this.calEvent.description} - ` + this.getAdditionalFooter() + ` + ` + + this.getAdditionalFooter() + + `
- `; + ` + ); } /** @@ -42,17 +47,19 @@ export default class EventOrganizerRescheduledMail extends EventOrganizerMail { * * @protected */ - protected getNodeMailerPayload(): Object { + protected getNodeMailerPayload(): Record { const organizerStart: 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); } -} \ No newline at end of file +} diff --git a/lib/emails/VideoEventOrganizerMail.ts b/lib/emails/VideoEventOrganizerMail.ts index 60d85237cf..1f5b2384ab 100644 --- a/lib/emails/VideoEventOrganizerMail.ts +++ b/lib/emails/VideoEventOrganizerMail.ts @@ -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 ` - Video call provider: ${getIntegrationName(this.videoCallData)}
- Meeting ID: ${getFormattedMeetingId(this.videoCallData)}
- Meeting Password: ${this.videoCallData.password}
- Meeting URL: ${this.videoCallData.url}
+Video call provider: ${getIntegrationName(this.videoCallData)}
+Meeting ID: ${getFormattedMeetingId(this.videoCallData)}
+Meeting Password: ${this.videoCallData.password}
+Meeting URL: ${this.videoCallData.url}
`; } -} \ No newline at end of file +} diff --git a/lib/emails/helpers.ts b/lib/emails/helpers.ts index ed5a10c479..e5218a0a06 100644 --- a/lib/emails/helpers.ts +++ b/lib/emails/helpers.ts @@ -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(); } -} \ No newline at end of file +} + +export function stripHtml(html: string): string { + return html.replace("
", "\n").replace(/<[^>]+>/g, ""); +} diff --git a/lib/prisma.ts b/lib/prisma.ts index 4d3f847070..5d75ada2f4 100644 --- a/lib/prisma.ts +++ b/lib/prisma.ts @@ -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; \ No newline at end of file +const pluck = (select: Record, 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, pluckedAttributes: string[]) => + modelQuery({ + where: criteria, + select: pluckedAttributes.reduce(pluck, {}), + }); + +export { whereAndSelect }; + +export default prisma; diff --git a/lib/slots.ts b/lib/slots.ts index 5beeb9f8af..3313fbce2f 100644 --- a/lib/slots.ts +++ b/lib/slots.ts @@ -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); diff --git a/package.json b/package.json index b0ac1af372..4b8836be4b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pages/[user]/[type].tsx b/pages/[user]/[type].tsx index 7f81af1446..47c40dc5e6 100644 --- a/pages/[user]/[type].tsx +++ b/pages/[user]/[type].tsx @@ -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"; diff --git a/pages/_app.tsx b/pages/_app.tsx index e2964ddee3..5473bd4188 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -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 ( - - - - - + + + + + ); } diff --git a/pages/api/availability/week.ts b/pages/api/availability/week.ts index d52c55d7b7..f0cd408e40 100644 --- a/pages/api/availability/week.ts +++ b/pages/api/availability/week.ts @@ -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" }); } -} \ No newline at end of file +} diff --git a/pages/api/book/[user].ts b/pages/api/book/[user].ts index 2ea6611ba2..32ae584d44 100644 --- a/pages/api/book/[user].ts +++ b/pages/api/book/[user].ts @@ -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.` }); diff --git a/pages/api/teams.ts b/pages/api/teams.ts index 1ab69adbc1..73a48bd80c 100644 --- a/pages/api/teams.ts +++ b/pages/api/teams.ts @@ -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" }); } diff --git a/pages/availability/event/[type].tsx b/pages/availability/event/[type].tsx index 5e99468f5b..6302ede032 100644 --- a/pages/availability/event/[type].tsx +++ b/pages/availability/event/[type].tsx @@ -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 = { diff --git a/pages/bookings/index.tsx b/pages/bookings/index.tsx index eb62b9d455..adb75ae743 100644 --- a/pages/bookings/index.tsx +++ b/pages/bookings/index.tsx @@ -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

Loading...

; diff --git a/pages/settings/teams.tsx b/pages/settings/teams.tsx index e4d1dc5f0b..c129939f3e 100644 --- a/pages/settings/teams.tsx +++ b/pages/settings/teams.tsx @@ -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

Loading...

; @@ -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 ( @@ -60,10 +71,12 @@ export default function Teams(props) {

View, edit and create teams to organise relationships between users

- {!(invites.length || teams.length) && + {!(invites.length || teams.length) && (
-

Create a team to get started

+

+ Create a team to get started +

Create your first team and invite other users to work together with you.

@@ -71,31 +84,35 @@ export default function Teams(props) {
- } + )} - {!!(invites.length || teams.length) &&
- -
} + {!!(invites.length || teams.length) && ( +
+ +
+ )}
- {!!teams.length && - - - } + {!!teams.length && } - {!!invites.length &&
-

Open Invitations

-
    - {invites.map((team) => )} -
-
} + {!!invites.length && ( +
+

Open Invitations

+
    + {invites.map((team) => ( + + ))} +
+
+ )}
{/*{teamsLoaded &&
@@ -111,12 +128,20 @@ export default function Teams(props) {
}*/}
- {showCreateTeamModal && -
+ {showCreateTeamModal && ( +
- + - +
@@ -124,33 +149,57 @@ export default function Teams(props) {
- +
-

- Create a new team to collaborate with users. -

+

Create a new team to collaborate with users.

- - + +
- + +
- } + )}
); -} \ No newline at end of file +} + +// 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 }, + }; +}; diff --git a/test/lib/prisma.test.ts b/test/lib/prisma.test.ts new file mode 100644 index 0000000000..464d6d1aa3 --- /dev/null +++ b/test/lib/prisma.test.ts @@ -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", + ] + ); +}); diff --git a/test/lib/slots.test.ts b/test/lib/slots.test.ts index 9412b8587f..580619a486 100644 --- a/test/lib/slots.test.ts +++ b/test/lib/slots.test.ts @@ -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); -}); \ No newline at end of file +});