From a2bf242c9eb2c058513c3043cafe50b4ac5df7b5 Mon Sep 17 00:00:00 2001 From: Malte Delfs Date: Sat, 17 Jul 2021 14:30:29 +0200 Subject: [PATCH 1/6] opt in booking --- lib/emails/EventOrganizerMail.ts | 57 ++-- lib/emails/EventOrganizerRequestMail.ts | 50 +++ lib/emails/EventRejectionMail.ts | 91 +++++ pages/api/availability/eventtype.ts | 1 + pages/api/book/[user].ts | 317 +++++++++++------- pages/api/book/confirm.ts | 107 ++++++ pages/availability/event/[type].tsx | 28 ++ pages/bookings/index.tsx | 105 ++++-- pages/success.tsx | 198 ++++++----- .../migration.sql | 6 + prisma/schema.prisma | 3 + 11 files changed, 705 insertions(+), 258 deletions(-) create mode 100644 lib/emails/EventOrganizerRequestMail.ts create mode 100644 lib/emails/EventRejectionMail.ts create mode 100644 pages/api/book/confirm.ts create mode 100644 prisma/migrations/20210717120159_booking_confirmation/migration.sql diff --git a/lib/emails/EventOrganizerMail.ts b/lib/emails/EventOrganizerMail.ts index 1680ad9e..184a062d 100644 --- a/lib/emails/EventOrganizerMail.ts +++ b/lib/emails/EventOrganizerMail.ts @@ -46,6 +46,31 @@ export default class EventOrganizerMail extends EventMail { return icsEvent.value; } + protected getBodyHeader(): string { + return "A new event has been scheduled."; + } + + protected getBodyText(): string { + return "You and any other attendees have been emailed with this information."; + } + + protected getImage(): string { + return ` + + `; + } + /** * Returns the email text as HTML representation. * @@ -67,22 +92,9 @@ export default class EventOrganizerMail extends EventMail { margin-top: 40px; " > - - - -

A new event has been scheduled.

-

You and any other attendees have been emailed with this information.

+ ${this.getImage()} +

${this.getBodyHeader()}

+

${this.getBodyText()}


@@ -165,8 +177,6 @@ export default class EventOrganizerMail extends EventMail { * @protected */ protected getNodeMailerPayload(): Record { - const organizerStart: Dayjs = dayjs(this.calEvent.startTime).tz(this.calEvent.organizer.timeZone); - return { icalEvent: { filename: "event.ics", @@ -174,14 +184,19 @@ export default class EventOrganizerMail extends EventMail { }, 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: this.getSubject(), html: this.getHtmlRepresentation(), text: this.getPlainTextRepresentation(), }; } + protected getSubject(): string { + const organizerStart: Dayjs = dayjs(this.calEvent.startTime).tz(this.calEvent.organizer.timeZone); + return `New event: ${this.calEvent.attendees[0].name} - ${organizerStart.format("LT dddd, LL")} - ${ + this.calEvent.type + }`; + } + protected printNodeMailerError(error: string): void { console.error("SEND_NEW_EVENT_NOTIFICATION_ERROR", this.calEvent.organizer.email, error); } diff --git a/lib/emails/EventOrganizerRequestMail.ts b/lib/emails/EventOrganizerRequestMail.ts new file mode 100644 index 00000000..dbd2f4d4 --- /dev/null +++ b/lib/emails/EventOrganizerRequestMail.ts @@ -0,0 +1,50 @@ +import dayjs, { Dayjs } from "dayjs"; + +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 EventOrganizerMail from "@lib/emails/EventOrganizerMail"; + +dayjs.extend(utc); +dayjs.extend(timezone); +dayjs.extend(toArray); +dayjs.extend(localizedFormat); + +export default class EventOrganizerRequestMail extends EventOrganizerMail { + protected getBodyHeader(): string { + return "A new event is waiting for your approval."; + } + + protected getBodyText(): string { + return "Check your bookings page to confirm or reject the booking."; + } + + protected getAdditionalBody(): string { + return `Confirm or reject the booking`; + } + + protected getImage(): string { + return ` + + `; + } + + protected getSubject(): string { + const organizerStart: Dayjs = dayjs(this.calEvent.startTime).tz(this.calEvent.organizer.timeZone); + return `New event request: ${this.calEvent.attendees[0].name} - ${organizerStart.format( + "LT dddd, LL" + )} - ${this.calEvent.type}`; + } +} diff --git a/lib/emails/EventRejectionMail.ts b/lib/emails/EventRejectionMail.ts new file mode 100644 index 00000000..f52c7eb6 --- /dev/null +++ b/lib/emails/EventRejectionMail.ts @@ -0,0 +1,91 @@ +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"; + +dayjs.extend(utc); +dayjs.extend(timezone); +dayjs.extend(localizedFormat); + +export default class EventRejectionMail extends EventMail { + /** + * Returns the email text as HTML representation. + * + * @protected + */ + protected getHtmlRepresentation(): string { + return ( + ` + +
+ + + +

Your meeting request has been rejected

+

You and any other attendees have been emailed with this information.

+
+ ` + + ` +
+
+ Calendso Logo
+ + ` + ); + } + + /** + * Returns the payload object for the nodemailer. + * + * @protected + */ + 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: `Rejected: ${this.calEvent.type} with ${ + this.calEvent.organizer.name + } on ${this.getInviteeStart().format("dddd, LL")}`, + html: this.getHtmlRepresentation(), + text: this.getPlainTextRepresentation(), + }; + } + + protected printNodeMailerError(error: string): void { + console.error("SEND_BOOKING_CONFIRMATION_ERROR", this.calEvent.attendees[0].email, error); + } + + /** + * Returns the inviteeStart value used at multiple points. + * + * @private + */ + protected getInviteeStart(): Dayjs { + return dayjs(this.calEvent.startTime).tz(this.calEvent.attendees[0].timeZone); + } +} diff --git a/pages/api/availability/eventtype.ts b/pages/api/availability/eventtype.ts index 10d3a2f6..fbeffc53 100644 --- a/pages/api/availability/eventtype.ts +++ b/pages/api/availability/eventtype.ts @@ -16,6 +16,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) description: req.body.description, length: parseInt(req.body.length), hidden: req.body.hidden, + requiresConfirmation: req.body.requiresConfirmation, locations: req.body.locations, eventName: req.body.eventName, customInputs: !req.body.customInputs diff --git a/pages/api/book/[user].ts b/pages/api/book/[user].ts index 6aa4bb5c..c224466e 100644 --- a/pages/api/book/[user].ts +++ b/pages/api/book/[user].ts @@ -15,6 +15,8 @@ import logger from "../../../lib/logger"; import utc from "dayjs/plugin/utc"; import timezone from "dayjs/plugin/timezone"; import dayjsBusinessDays from "dayjs-business-days"; +import { Exception } from "handlebars"; +import EventOrganizerRequestMail from "@lib/emails/EventOrganizerRequestMail"; dayjs.extend(dayjsBusinessDays); dayjs.extend(utc); @@ -103,6 +105,164 @@ const getLocationRequestFromIntegration = ({ location }: GetLocationRequestFromI return null; }; +async function rescheduleEvent( + rescheduleUid: string | string[], + results: unknown[], + calendarCredentials: unknown[], + evt: CalendarEvent, + videoCredentials: unknown[], + referencesToCreate: { type: string; uid: string }[] +): Promise<{ + referencesToCreate: { type: string; uid: string }[]; + results: unknown[]; + error: { errorCode: string; message: string } | null; +}> { + // Reschedule event + const booking = await prisma.booking.findFirst({ + where: { + uid: rescheduleUid, + }, + select: { + id: true, + references: { + select: { + id: true, + type: true, + uid: true, + }, + }, + }, + }); + + // Use all integrations + results = results.concat( + await async.mapLimit(calendarCredentials, 5, async (credential) => { + const bookingRefUid = booking.references.filter((ref) => ref.type === credential.type)[0].uid; + return updateEvent(credential, bookingRefUid, evt) + .then((response) => ({ type: credential.type, success: true, response })) + .catch((e) => { + log.error("updateEvent failed", e, evt); + return { type: credential.type, success: false }; + }); + }) + ); + + results = results.concat( + await async.mapLimit(videoCredentials, 5, async (credential) => { + const bookingRefUid = booking.references.filter((ref) => ref.type === credential.type)[0].uid; + return updateMeeting(credential, bookingRefUid, evt) + .then((response) => ({ type: credential.type, success: true, response })) + .catch((e) => { + log.error("updateMeeting failed", e, evt); + return { type: credential.type, success: false }; + }); + }) + ); + + if (results.length > 0 && results.every((res) => !res.success)) { + const error = { + errorCode: "BookingReschedulingMeetingFailed", + message: "Booking Rescheduling failed", + }; + + return { referencesToCreate: [], results: [], error: error }; + } + + // Clone elements + referencesToCreate = [...booking.references]; + + // Now we can delete the old booking and its references. + const bookingReferenceDeletes = prisma.bookingReference.deleteMany({ + where: { + bookingId: booking.id, + }, + }); + const attendeeDeletes = prisma.attendee.deleteMany({ + where: { + bookingId: booking.id, + }, + }); + const bookingDeletes = prisma.booking.delete({ + where: { + uid: rescheduleUid, + }, + }); + + await Promise.all([bookingReferenceDeletes, attendeeDeletes, bookingDeletes]); + return { error: undefined, results, referencesToCreate }; +} + +export async function scheduleEvent( + results: unknown[], + calendarCredentials: unknown[], + evt: CalendarEvent, + videoCredentials: unknown[], + referencesToCreate: { type: string; uid: string }[] +): Promise<{ + referencesToCreate: { type: string; uid: string }[]; + results: unknown[]; + error: { errorCode: string; message: string } | null; +}> { + // Schedule event + results = results.concat( + await async.mapLimit(calendarCredentials, 5, async (credential) => { + return createEvent(credential, evt) + .then((response) => ({ type: credential.type, success: true, response })) + .catch((e) => { + log.error("createEvent failed", e, evt); + return { type: credential.type, success: false }; + }); + }) + ); + + results = results.concat( + await async.mapLimit(videoCredentials, 5, async (credential) => { + return createMeeting(credential, evt) + .then((response) => ({ type: credential.type, success: true, response })) + .catch((e) => { + log.error("createMeeting failed", e, evt); + return { type: credential.type, success: false }; + }); + }) + ); + + if (results.length > 0 && results.every((res) => !res.success)) { + const error = { + errorCode: "BookingCreatingMeetingFailed", + message: "Booking failed", + }; + + return { referencesToCreate: [], results: [], error: error }; + } + + referencesToCreate = results.map((result) => { + return { + type: result.type, + uid: result.response.createdEvent.id.toString(), + }; + }); + return { error: undefined, results, referencesToCreate }; +} + +export async function handleLegacyConfirmationMail( + results: unknown[], + selectedEventType: { requiresConfirmation: boolean }, + evt: CalendarEvent, + hashUID +): Promise<{ error: Exception; message: string | null }> { + if (results.length === 0 && !selectedEventType.requiresConfirmation) { + // Legacy as well, as soon as we have a separate email integration class. Just used + // to send an email even if there is no integration at all. + try { + const mail = new EventAttendeeMail(evt, hashUID); + await mail.sendEmail(); + } catch (e) { + return { error: e, message: "Booking failed" }; + } + } + return null; +} + export default async function handler(req: NextApiRequest, res: NextApiResponse): Promise { const { user } = req.query; log.debug(`Booking ${user} started`); @@ -205,6 +365,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) periodStartDate: true, periodEndDate: true, periodCountCalendarDays: true, + requiresConfirmation: true, }, }); @@ -298,119 +459,34 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) let referencesToCreate = []; if (rescheduleUid) { - // Reschedule event - const booking = await prisma.booking.findFirst({ - where: { - uid: rescheduleUid, - }, - select: { - id: true, - references: { - select: { - id: true, - type: true, - uid: true, - }, - }, - }, - }); - - // Use all integrations - results = results.concat( - await async.mapLimit(calendarCredentials, 5, async (credential) => { - const bookingRefUid = booking.references.filter((ref) => ref.type === credential.type)[0].uid; - return updateEvent(credential, bookingRefUid, evt) - .then((response) => ({ type: credential.type, success: true, response })) - .catch((e) => { - log.error("updateEvent failed", e, evt); - return { type: credential.type, success: false }; - }); - }) + const __ret = await rescheduleEvent( + rescheduleUid, + results, + calendarCredentials, + evt, + videoCredentials, + referencesToCreate ); - - results = results.concat( - await async.mapLimit(videoCredentials, 5, async (credential) => { - const bookingRefUid = booking.references.filter((ref) => ref.type === credential.type)[0].uid; - return updateMeeting(credential, bookingRefUid, evt) - .then((response) => ({ type: credential.type, success: true, response })) - .catch((e) => { - log.error("updateMeeting failed", e, evt); - return { type: credential.type, success: false }; - }); - }) - ); - - if (results.length > 0 && results.every((res) => !res.success)) { - const error = { - errorCode: "BookingReschedulingMeetingFailed", - message: "Booking Rescheduling failed", - }; - - log.error(`Booking ${user} failed`, error, results); - return res.status(500).json(error); + if (__ret.error) { + log.error(`Booking ${user} failed`, __ret.error, results); + return res.status(500).json(__ret.error); } - - // Clone elements - referencesToCreate = [...booking.references]; - - // Now we can delete the old booking and its references. - const bookingReferenceDeletes = prisma.bookingReference.deleteMany({ - where: { - bookingId: booking.id, - }, - }); - const attendeeDeletes = prisma.attendee.deleteMany({ - where: { - bookingId: booking.id, - }, - }); - const bookingDeletes = prisma.booking.delete({ - where: { - uid: rescheduleUid, - }, - }); - - await Promise.all([bookingReferenceDeletes, attendeeDeletes, bookingDeletes]); - } else { - // Schedule event - results = results.concat( - await async.mapLimit(calendarCredentials, 5, async (credential) => { - return createEvent(credential, evt) - .then((response) => ({ type: credential.type, success: true, response })) - .catch((e) => { - log.error("createEvent failed", e, evt); - return { type: credential.type, success: false }; - }); - }) + results = __ret.results; + referencesToCreate = __ret.referencesToCreate; + } else if (!selectedEventType.requiresConfirmation) { + const __ret = await scheduleEvent( + results, + calendarCredentials, + evt, + videoCredentials, + referencesToCreate ); - - results = results.concat( - await async.mapLimit(videoCredentials, 5, async (credential) => { - return createMeeting(credential, evt) - .then((response) => ({ type: credential.type, success: true, response })) - .catch((e) => { - log.error("createMeeting failed", e, evt); - return { type: credential.type, success: false }; - }); - }) - ); - - if (results.length > 0 && results.every((res) => !res.success)) { - const error = { - errorCode: "BookingCreatingMeetingFailed", - message: "Booking failed", - }; - - log.error(`Booking ${user} failed`, error, results); - return res.status(500).json(error); + if (__ret.error) { + log.error(`Booking ${user} failed`, __ret.error, results); + return res.status(500).json(__ret.error); } - - referencesToCreate = results.map((result) => { - return { - type: result.type, - uid: result.response.createdEvent.id.toString(), - }; - }); + results = __ret.results; + referencesToCreate = __ret.referencesToCreate; } const hashUID = @@ -419,18 +495,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) : translator.fromUUID(uuidv5(JSON.stringify(evt), uuidv5.URL)); // TODO Should just be set to the true case as soon as we have a "bare email" integration class. // UID generation should happen in the integration itself, not here. - if (results.length === 0) { - // Legacy as well, as soon as we have a separate email integration class. Just used - // to send an email even if there is no integration at all. - try { - const mail = new EventAttendeeMail(evt, hashUID); - await mail.sendEmail(); - } catch (e) { - log.error("Sending legacy event mail failed", e); - log.error(`Booking ${user} failed`); - res.status(500).json({ message: "Booking failed" }); - return; - } + const legacyMailError = await handleLegacyConfirmationMail(results, selectedEventType, evt, hashUID); + if (legacyMailError) { + log.error("Sending legacy event mail failed", legacyMailError.error); + log.error(`Booking ${user} failed`); + res.status(500).json({ message: legacyMailError.message }); + return; } try { @@ -449,6 +519,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) attendees: { create: evt.attendees, }, + confirmed: !selectedEventType.requiresConfirmation, }, }); } catch (e) { @@ -457,6 +528,10 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) return; } + if (selectedEventType.requiresConfirmation) { + await new EventOrganizerRequestMail(evt, hashUID).sendEmail(); + } + log.debug(`Booking ${user} completed`); return res.status(204).json({ message: "Booking completed" }); } catch (reason) { diff --git a/pages/api/book/confirm.ts b/pages/api/book/confirm.ts new file mode 100644 index 00000000..de1df144 --- /dev/null +++ b/pages/api/book/confirm.ts @@ -0,0 +1,107 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { getSession } from "next-auth/client"; +import prisma from "../../../lib/prisma"; +import { handleLegacyConfirmationMail, scheduleEvent } from "./[user]"; +import { CalendarEvent } from "@lib/calendarClient"; +import EventRejectionMail from "@lib/emails/EventRejectionMail"; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const session = await getSession({ req: req }); + if (!session) { + return res.status(401).json({ message: "Not authenticated" }); + } + + const bookingId = req.body.id; + if (!bookingId) { + return res.status(400).json({ message: "bookingId missing" }); + } + + const currentUser = await prisma.user.findFirst({ + where: { + id: session.user.id, + }, + select: { + id: true, + credentials: true, + timeZone: true, + email: true, + name: true, + }, + }); + + if (req.method == "PATCH") { + const booking = await prisma.booking.findFirst({ + where: { + id: bookingId, + }, + select: { + title: true, + description: true, + startTime: true, + endTime: true, + confirmed: true, + attendees: true, + userId: true, + id: true, + uid: true, + }, + }); + + if (!booking || booking.userId != currentUser.id) { + return res.status(404).json({ message: "booking not found" }); + } + if (booking.confirmed) { + return res.status(400).json({ message: "booking already confirmed" }); + } + + const calendarCredentials = currentUser.credentials.filter((cred) => cred.type.endsWith("_calendar")); + const videoCredentials = currentUser.credentials.filter((cred) => cred.type.endsWith("_video")); + + const evt: CalendarEvent = { + type: booking.title, + title: booking.title, + description: booking.description, + startTime: booking.startTime.toISOString(), + endTime: booking.endTime.toISOString(), + organizer: { email: currentUser.email, name: currentUser.name, timeZone: currentUser.timeZone }, + attendees: booking.attendees, + }; + + if (req.body.confirmed) { + const scheduleResult = await scheduleEvent([], calendarCredentials, evt, videoCredentials, []); + + await handleLegacyConfirmationMail( + scheduleResult.results, + { requiresConfirmation: false }, + evt, + booking.uid + ); + + await prisma.booking.update({ + where: { + id: bookingId, + }, + data: { + confirmed: true, + references: { + create: scheduleResult.referencesToCreate, + }, + }, + }); + + res.status(204).json({ message: "ok" }); + } else { + await prisma.booking.update({ + where: { + id: bookingId, + }, + data: { + rejected: true, + }, + }); + const attendeeMail = new EventRejectionMail(evt, booking.uid); + await attendeeMail.sendEmail(); + res.status(204).json({ message: "ok" }); + } + } +} diff --git a/pages/availability/event/[type].tsx b/pages/availability/event/[type].tsx index 87e57ac2..ae15a1a2 100644 --- a/pages/availability/event/[type].tsx +++ b/pages/availability/event/[type].tsx @@ -66,6 +66,7 @@ type EventTypeInput = { periodStartDate?: Date | string; periodEndDate?: Date | string; periodCountCalendarDays?: boolean; + enteredRequiresConfirmation: boolean; }; const PERIOD_TYPES = [ @@ -172,6 +173,7 @@ export default function EventTypePage({ const descriptionRef = useRef(); const lengthRef = useRef(); const isHiddenRef = useRef(); + const requiresConfirmationRef = useRef(); const eventNameRef = useRef(); const periodDaysRef = useRef(); const periodDaysTypeRef = useRef(); @@ -188,6 +190,7 @@ export default function EventTypePage({ const enteredDescription: string = descriptionRef.current.value; const enteredLength: number = parseInt(lengthRef.current.value); const enteredIsHidden: boolean = isHiddenRef.current.checked; + const enteredRequiresConfirmation: boolean = requiresConfirmationRef.current.checked; const enteredEventName: string = eventNameRef.current.value; const type = periodType.type; @@ -223,6 +226,7 @@ export default function EventTypePage({ periodStartDate: enteredPeriodStartDate, periodEndDate: enteredPeriodEndDate, periodCountCalendarDays: enteredPeriodDaysType, + requiresConfirmation: enteredRequiresConfirmation, }; if (enteredAvailability) { @@ -641,6 +645,29 @@ export default function EventTypePage({ +
+
+
+ +
+
+ +

+ The booking needs to be confirmed, before it is pushed to the integrations and a + confirmation mail is sent. +

+
+
+
When can people book this event? @@ -991,6 +1018,7 @@ export const getServerSideProps: GetServerSideProps = async ({ req, query periodStartDate: true, periodEndDate: true, periodCountCalendarDays: true, + requiresConfirmation: true, }, }); diff --git a/pages/bookings/index.tsx b/pages/bookings/index.tsx index adb75ae7..422eaede 100644 --- a/pages/bookings/index.tsx +++ b/pages/bookings/index.tsx @@ -2,14 +2,29 @@ import Head from "next/head"; import prisma from "../../lib/prisma"; import { getSession, useSession } from "next-auth/client"; import Shell from "../../components/Shell"; +import { useRouter } from "next/router"; export default function Bookings({ bookings }) { const [, loading] = useSession(); + const router = useRouter(); if (loading) { return

Loading...

; } + async function confirmBookingHandler(booking, confirm: boolean) { + const res = await fetch("/api/book/confirm", { + method: "PATCH", + body: JSON.stringify({ id: booking.id, confirmed: confirm }), + headers: { + "Content-Type": "application/json", + }, + }); + if (res.ok) { + await router.replace(router.asPath); + } + } + return (
@@ -45,35 +60,72 @@ export default function Bookings({ bookings }) {
- {bookings.map((booking) => ( - - - - {/* + + + {/* */} - - - ))} + + + ))}
-
{booking.attendees[0].name}
-
{booking.attendees[0].email}
-
-
{booking.title}
-
{booking.description}
-
+ {bookings + .filter((booking) => !booking.confirmed && !booking.rejected) + .concat(bookings.filter((booking) => booking.confirmed || booking.rejected)) + .map((booking) => ( +
+ {!booking.confirmed && !booking.rejected && ( + + Unconfirmed + + )} +
+ {booking.attendees[0].name} +
+
{booking.attendees[0].email}
+
+
{booking.title}
+
{booking.description}
+
{dayjs(booking.startTime).format("D MMMM YYYY HH:mm")}
- - Reschedule - - - Cancel - -
+ {!booking.confirmed && !booking.rejected && ( + <> + confirmBookingHandler(booking, true)} + className="cursor-pointer text-blue-600 hover:text-blue-900"> + Confirm + + confirmBookingHandler(booking, false)} + className="cursor-pointer ml-4 text-blue-600 hover:text-blue-900"> + Reject + + + )} + {booking.confirmed && !booking.rejected && ( + <> + + Reschedule + + + Cancel + + + )} + {!booking.confirmed && booking.rejected && ( +
Rejected
+ )} +
@@ -110,6 +162,9 @@ export async function getServerSideProps(context) { title: true, description: true, attendees: true, + confirmed: true, + rejected: true, + id: true, }, orderBy: { startTime: "desc", diff --git a/pages/success.tsx b/pages/success.tsx index d36ea8d9..0f1bff05 100644 --- a/pages/success.tsx +++ b/pages/success.tsx @@ -62,7 +62,10 @@ export default function Success(props) { isReady && (
- Booking Confirmed | {eventName} | Calendso + + Booking {props.eventType.requiresConfirmation ? "Submitted" : "Confirmed"} | {eventName} | + Calendso +
@@ -79,17 +82,26 @@ export default function Success(props) { aria-labelledby="modal-headline">
- + {!props.eventType.requiresConfirmation && ( + + )} + {props.eventType.requiresConfirmation && ( + + )}

- You are scheduled in with {props.user.name || props.user.username}. + {props.eventType.requiresConfirmation + ? `${ + props.user.name || props.user.username + } still needs to confirm or reject the booking.` + : `You are scheduled in with ${props.user.name || props.user.username}.`}

@@ -113,93 +125,97 @@ export default function Success(props) {
-
- Add to your calendar -
- - - - Google - - - - - - - - Microsoft Outlook - - - - - - - - Microsoft Office - - - - - - - - Other - - - - + {!props.eventType.requiresConfirmation && ( + -
+ )} {!props.user.hideBranding && (
Create your own booking link with Calendso @@ -237,7 +253,7 @@ export async function getServerSideProps(context) { { id: parseInt(context.query.type), }, - ["id", "title", "description", "length", "eventName"] + ["id", "title", "description", "length", "eventName", "requiresConfirmation"] ); return { diff --git a/prisma/migrations/20210717120159_booking_confirmation/migration.sql b/prisma/migrations/20210717120159_booking_confirmation/migration.sql new file mode 100644 index 00000000..8c96e1ad --- /dev/null +++ b/prisma/migrations/20210717120159_booking_confirmation/migration.sql @@ -0,0 +1,6 @@ +-- AlterTable +ALTER TABLE "Booking" ADD COLUMN "confirmed" BOOLEAN NOT NULL DEFAULT true, +ADD COLUMN "rejected" BOOLEAN NOT NULL DEFAULT false; + +-- AlterTable +ALTER TABLE "EventType" ADD COLUMN "requiresConfirmation" BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7ea3cafe..5cc676d1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -30,6 +30,7 @@ model EventType { periodEndDate DateTime? periodDays Int? periodCountCalendarDays Boolean? + requiresConfirmation Boolean @default(false) } model Credential { @@ -134,6 +135,8 @@ model Booking { createdAt DateTime @default(now()) updatedAt DateTime? + confirmed Boolean @default(true) + rejected Boolean @default(false) } model Availability { From f5516ed4277787c429f7677d4773f5788eee488f Mon Sep 17 00:00:00 2001 From: Malte Delfs Date: Sun, 18 Jul 2021 21:12:35 +0200 Subject: [PATCH 2/6] added reminder emails for opt-in bookings --- .env.example | 2 + .../EventOrganizerRequestReminderMail.ts | 25 +++++++ pages/api/cron/bookingReminder.ts | 74 +++++++++++++++++++ .../migration.sql | 13 ++++ prisma/schema.prisma | 12 +++ 5 files changed, 126 insertions(+) create mode 100644 lib/emails/EventOrganizerRequestReminderMail.ts create mode 100644 pages/api/cron/bookingReminder.ts create mode 100644 prisma/migrations/20210718184017_reminder_mails/migration.sql diff --git a/.env.example b/.env.example index 99636771..a0006420 100644 --- a/.env.example +++ b/.env.example @@ -32,3 +32,5 @@ EMAIL_SERVER_PORT=587 EMAIL_SERVER_USER='' # Keep in mind that if you have 2FA enabled, you will need to provision an App Password. EMAIL_SERVER_PASSWORD='' +# ApiKey for cronjobs +CRON_API_KEY='0cc0e6c35519bba620c9360cfe3e68d0' diff --git a/lib/emails/EventOrganizerRequestReminderMail.ts b/lib/emails/EventOrganizerRequestReminderMail.ts new file mode 100644 index 00000000..1f935943 --- /dev/null +++ b/lib/emails/EventOrganizerRequestReminderMail.ts @@ -0,0 +1,25 @@ +import dayjs, { Dayjs } from "dayjs"; + +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 EventOrganizerRequestMail from "@lib/emails/EventOrganizerRequestMail"; + +dayjs.extend(utc); +dayjs.extend(timezone); +dayjs.extend(toArray); +dayjs.extend(localizedFormat); + +export default class EventOrganizerRequestReminderMail extends EventOrganizerRequestMail { + protected getBodyHeader(): string { + return "An event is still waiting for your approval."; + } + + protected getSubject(): string { + const organizerStart: Dayjs = dayjs(this.calEvent.startTime).tz(this.calEvent.organizer.timeZone); + return `Event request is still waiting: ${this.calEvent.attendees[0].name} - ${organizerStart.format( + "LT dddd, LL" + )} - ${this.calEvent.type}`; + } +} diff --git a/pages/api/cron/bookingReminder.ts b/pages/api/cron/bookingReminder.ts new file mode 100644 index 00000000..1bf9da47 --- /dev/null +++ b/pages/api/cron/bookingReminder.ts @@ -0,0 +1,74 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import prisma from "@lib/prisma"; +import dayjs from "dayjs"; +import { ReminderType } from "@prisma/client"; +import EventOrganizerRequestReminderMail from "@lib/emails/EventOrganizerRequestReminderMail"; +import { CalendarEvent } from "@lib/calendarClient"; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const apiKey = req.query.apiKey; + if (process.env.CRON_API_KEY != apiKey) { + return res.status(401).json({ message: "Not authenticated" }); + } + + if (req.method == "POST") { + const reminderIntervalMinutes = [48 * 60, 24 * 60, 3 * 60]; + let notificationsSent = 0; + for (const interval of reminderIntervalMinutes) { + const bookings = await prisma.booking.findMany({ + where: { + confirmed: false, + rejected: false, + createdAt: { + lte: dayjs().add(-interval, "minutes").toDate(), + }, + }, + select: { + title: true, + description: true, + startTime: true, + endTime: true, + attendees: true, + user: true, + id: true, + uid: true, + }, + }); + + const reminders = await prisma.reminderMail.findMany({ + where: { + reminderType: ReminderType.PENDING_BOOKING_CONFIRMATION, + referenceId: { + in: bookings.map((b) => b.id), + }, + elapsedMinutes: { + gte: interval, + }, + }, + }); + + for (const booking of bookings.filter((b) => !reminders.some((r) => r.referenceId == b.id))) { + const evt: CalendarEvent = { + type: booking.title, + title: booking.title, + description: booking.description, + startTime: booking.startTime.toISOString(), + endTime: booking.endTime.toISOString(), + organizer: { email: booking.user.email, name: booking.user.name, timeZone: booking.user.timeZone }, + attendees: booking.attendees, + }; + + await new EventOrganizerRequestReminderMail(evt, booking.uid).sendEmail(); + await prisma.reminderMail.create({ + data: { + referenceId: booking.id, + reminderType: ReminderType.PENDING_BOOKING_CONFIRMATION, + elapsedMinutes: interval, + }, + }); + notificationsSent++; + } + } + res.status(200).json({ notificationsSent }); + } +} diff --git a/prisma/migrations/20210718184017_reminder_mails/migration.sql b/prisma/migrations/20210718184017_reminder_mails/migration.sql new file mode 100644 index 00000000..7386d848 --- /dev/null +++ b/prisma/migrations/20210718184017_reminder_mails/migration.sql @@ -0,0 +1,13 @@ +-- CreateEnum +CREATE TYPE "ReminderType" AS ENUM ('PENDING_BOOKING_CONFIRMATION'); + +-- CreateTable +CREATE TABLE "ReminderMail" ( + "id" SERIAL NOT NULL, + "referenceId" INTEGER NOT NULL, + "reminderType" "ReminderType" NOT NULL, + "elapsedMinutes" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + PRIMARY KEY ("id") +); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5cc676d1..83aed724 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -176,3 +176,15 @@ model ResetPasswordRequest { email String expires DateTime } + +enum ReminderType { + PENDING_BOOKING_CONFIRMATION +} + +model ReminderMail { + id Int @id @default(autoincrement()) + referenceId Int + reminderType ReminderType + elapsedMinutes Int + createdAt DateTime @default(now()) +} From 0c975cdcbcfc557f043ad49b63d091487e2b01c4 Mon Sep 17 00:00:00 2001 From: Malte Delfs Date: Sun, 18 Jul 2021 21:22:39 +0200 Subject: [PATCH 3/6] fixed codacy issues --- pages/api/book/[user].ts | 2 +- pages/api/book/confirm.ts | 2 +- pages/api/cron/bookingReminder.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pages/api/book/[user].ts b/pages/api/book/[user].ts index c224466e..2a5c2273 100644 --- a/pages/api/book/[user].ts +++ b/pages/api/book/[user].ts @@ -248,7 +248,7 @@ export async function handleLegacyConfirmationMail( results: unknown[], selectedEventType: { requiresConfirmation: boolean }, evt: CalendarEvent, - hashUID + hashUID: string ): Promise<{ error: Exception; message: string | null }> { if (results.length === 0 && !selectedEventType.requiresConfirmation) { // Legacy as well, as soon as we have a separate email integration class. Just used diff --git a/pages/api/book/confirm.ts b/pages/api/book/confirm.ts index de1df144..e685db10 100644 --- a/pages/api/book/confirm.ts +++ b/pages/api/book/confirm.ts @@ -5,7 +5,7 @@ import { handleLegacyConfirmationMail, scheduleEvent } from "./[user]"; import { CalendarEvent } from "@lib/calendarClient"; import EventRejectionMail from "@lib/emails/EventRejectionMail"; -export default async function handler(req: NextApiRequest, res: NextApiResponse) { +export default async function handler(req: NextApiRequest, res: NextApiResponse): Promise { const session = await getSession({ req: req }); if (!session) { return res.status(401).json({ message: "Not authenticated" }); diff --git a/pages/api/cron/bookingReminder.ts b/pages/api/cron/bookingReminder.ts index 1bf9da47..a31bde97 100644 --- a/pages/api/cron/bookingReminder.ts +++ b/pages/api/cron/bookingReminder.ts @@ -5,7 +5,7 @@ import { ReminderType } from "@prisma/client"; import EventOrganizerRequestReminderMail from "@lib/emails/EventOrganizerRequestReminderMail"; import { CalendarEvent } from "@lib/calendarClient"; -export default async function handler(req: NextApiRequest, res: NextApiResponse) { +export default async function handler(req: NextApiRequest, res: NextApiResponse): Promise { const apiKey = req.query.apiKey; if (process.env.CRON_API_KEY != apiKey) { return res.status(401).json({ message: "Not authenticated" }); From e6790281b6822b0b58a5305a483184e672a26d24 Mon Sep 17 00:00:00 2001 From: Bailey Pumfleet Date: Thu, 22 Jul 2021 12:04:15 +0100 Subject: [PATCH 4/6] Fix embed URLs to use BASE_URL --- pages/settings/embed.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/pages/settings/embed.tsx b/pages/settings/embed.tsx index 01dd7eae..99ec1c89 100644 --- a/pages/settings/embed.tsx +++ b/pages/settings/embed.tsx @@ -42,7 +42,7 @@ export default function Embed(props) { id="iframe" className="h-32 shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md" placeholder="Loading..." - defaultValue={''} + defaultValue={''} readOnly />
@@ -56,7 +56,7 @@ export default function Embed(props) { id="fullscreen" className="h-32 shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md" placeholder="Loading..." - defaultValue={'Schedule a meeting'} + defaultValue={'Schedule a meeting'} readOnly />
@@ -97,7 +97,9 @@ export async function getServerSideProps(context) { } }); + const BASE_URL = process.env.BASE_URL; + return { - props: {user}, // will be passed to the page component as props + props: {user, BASE_URL}, // will be passed to the page component as props } -} \ No newline at end of file +} From c5d0636da0062a31a564f6ee84d734ea9ae90f5a Mon Sep 17 00:00:00 2001 From: Peer Richelsen Date: Thu, 22 Jul 2021 21:56:29 +0200 Subject: [PATCH 5/6] added target _blank to powered by calendso banner, so iframes dont load the page inside the embed --- components/ui/PoweredByCalendso.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/components/ui/PoweredByCalendso.tsx b/components/ui/PoweredByCalendso.tsx index a06e258a..93286efb 100644 --- a/components/ui/PoweredByCalendso.tsx +++ b/components/ui/PoweredByCalendso.tsx @@ -2,8 +2,9 @@ import Link from "next/link"; const PoweredByCalendso = () => (
- - + + powered by{" "} Date: Thu, 22 Jul 2021 21:59:54 +0200 Subject: [PATCH 6/6] fixed UTM for powered by calendso link --- components/ui/PoweredByCalendso.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/components/ui/PoweredByCalendso.tsx b/components/ui/PoweredByCalendso.tsx index 93286efb..6f2596c8 100644 --- a/components/ui/PoweredByCalendso.tsx +++ b/components/ui/PoweredByCalendso.tsx @@ -2,8 +2,7 @@ import Link from "next/link"; const PoweredByCalendso = () => (