opt in booking

This commit is contained in:
Malte Delfs 2021-07-17 14:30:29 +02:00
parent 87d9c5e289
commit a2bf242c9e
11 changed files with 705 additions and 258 deletions

View file

@ -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 `<svg
xmlns="http://www.w3.org/2000/svg"
style="height: 60px; width: 60px; color: #31c48d"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>`;
}
/**
* Returns the email text as HTML representation.
*
@ -67,22 +92,9 @@ export default class EventOrganizerMail extends EventMail {
margin-top: 40px;
"
>
<svg
xmlns="http://www.w3.org/2000/svg"
style="height: 60px; width: 60px; color: #31c48d"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<h1 style="font-weight: 500; color: #161e2e;">A new event has been scheduled.</h1>
<p style="color: #4b5563; margin-bottom: 30px;">You and any other attendees have been emailed with this information.</p>
${this.getImage()}
<h1 style="font-weight: 500; color: #161e2e;">${this.getBodyHeader()}</h1>
<p style="color: #4b5563; margin-bottom: 30px;">${this.getBodyText()}</p>
<hr />
<table style="border-spacing: 20px; color: #161e2e; margin-bottom: 10px;">
<colgroup>
@ -165,8 +177,6 @@ export default class EventOrganizerMail extends EventMail {
* @protected
*/
protected getNodeMailerPayload(): Record<string, unknown> {
const organizerStart: Dayjs = <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>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);
}

View file

@ -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 `<a href="${process.env.BASE_URL}/bookings">Confirm or reject the booking</a>`;
}
protected getImage(): string {
return `<svg
xmlns="http://www.w3.org/2000/svg"
style="height: 60px; width: 60px; color: #01579b"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>`;
}
protected getSubject(): string {
const organizerStart: Dayjs = <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}`;
}
}

View file

@ -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 (
`
<body style="background: #f4f5f7; font-family: Helvetica, sans-serif">
<div
style="
margin: 0 auto;
max-width: 450px;
background: white;
border-radius: 0.75rem;
border: 1px solid #e5e7eb;
padding: 2rem 2rem 2rem 2rem;
text-align: center;
margin-top: 40px;
"
>
<svg
xmlns="http://www.w3.org/2000/svg"
style="height: 60px; width: 60px; color: #ba2525"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<h1 style="font-weight: 500; color: #161e2e;">Your meeting request has been rejected</h1>
<p style="color: #4b5563; margin-bottom: 30px;">You and any other attendees have been emailed with this information.</p>
<hr />
` +
`
</div>
<div style="text-align: center; margin-top: 20px; color: #ccc; font-size: 12px;">
<img style="opacity: 0.25; width: 120px;" src="https://app.calendso.com/calendso-logo-word.svg" alt="Calendso Logo"></div>
</body>
`
);
}
/**
* Returns the payload object for the nodemailer.
*
* @protected
*/
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: `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>dayjs(this.calEvent.startTime).tz(this.calEvent.attendees[0].timeZone);
}
}

View file

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

View file

@ -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<void> {
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,19 +495,13 @@ 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);
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: "Booking failed" });
res.status(500).json({ message: legacyMailError.message });
return;
}
}
try {
await prisma.booking.create({
@ -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) {

107
pages/api/book/confirm.ts Normal file
View file

@ -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" });
}
}
}

View file

@ -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<HTMLTextAreaElement>();
const lengthRef = useRef<HTMLInputElement>();
const isHiddenRef = useRef<HTMLInputElement>();
const requiresConfirmationRef = useRef<HTMLInputElement>();
const eventNameRef = useRef<HTMLInputElement>();
const periodDaysRef = useRef<HTMLInputElement>();
const periodDaysTypeRef = useRef<HTMLSelectElement>();
@ -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({
</div>
</div>
</div>
<div className="my-8">
<div className="relative flex items-start">
<div className="flex items-center h-5">
<input
ref={requiresConfirmationRef}
id="requiresConfirmation"
name="requiresConfirmation"
type="checkbox"
className="focus:ring-blue-500 h-4 w-4 text-blue-600 border-gray-300 rounded"
defaultChecked={eventType.requiresConfirmation}
/>
</div>
<div className="ml-3 text-sm">
<label htmlFor="requiresConfirmation" className="font-medium text-gray-700">
Booking requires manual confirmation
</label>
<p className="text-gray-500">
The booking needs to be confirmed, before it is pushed to the integrations and a
confirmation mail is sent.
</p>
</div>
</div>
</div>
<fieldset className="my-8">
<Text variant="largetitle">When can people book this event?</Text>
@ -991,6 +1018,7 @@ export const getServerSideProps: GetServerSideProps<Props> = async ({ req, query
periodStartDate: true,
periodEndDate: true,
periodCountCalendarDays: true,
requiresConfirmation: true,
},
});

View file

@ -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 <p className="text-gray-400">Loading...</p>;
}
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 (
<div>
<Head>
@ -45,13 +60,29 @@ export default function Bookings({ bookings }) {
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{bookings.map((booking) => (
{bookings
.filter((booking) => !booking.confirmed && !booking.rejected)
.concat(bookings.filter((booking) => booking.confirmed || booking.rejected))
.map((booking) => (
<tr key={booking.id}>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">{booking.attendees[0].name}</div>
<td
className={
"px-6 py-4 whitespace-nowrap" + (booking.rejected ? " line-through" : "")
}>
{!booking.confirmed && !booking.rejected && (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-600 text-gray-100">
Unconfirmed
</span>
)}
<div className="text-sm font-medium text-gray-900">
{booking.attendees[0].name}
</div>
<div className="text-sm text-gray-500">{booking.attendees[0].email}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<td
className={
"px-6 py-4 whitespace-nowrap" + (booking.rejected ? " line-through" : "")
}>
<div className="text-sm text-gray-900">{booking.title}</div>
<div className="text-sm text-gray-500">{booking.description}</div>
</td>
@ -61,6 +92,22 @@ export default function Bookings({ bookings }) {
</div>
</td> */}
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
{!booking.confirmed && !booking.rejected && (
<>
<a
onClick={() => confirmBookingHandler(booking, true)}
className="cursor-pointer text-blue-600 hover:text-blue-900">
Confirm
</a>
<a
onClick={() => confirmBookingHandler(booking, false)}
className="cursor-pointer ml-4 text-blue-600 hover:text-blue-900">
Reject
</a>
</>
)}
{booking.confirmed && !booking.rejected && (
<>
<a
href={window.location.href + "/../reschedule/" + booking.uid}
className="text-blue-600 hover:text-blue-900">
@ -71,6 +118,11 @@ export default function Bookings({ bookings }) {
className="ml-4 text-blue-600 hover:text-blue-900">
Cancel
</a>
</>
)}
{!booking.confirmed && booking.rejected && (
<div className="text-sm text-gray-500">Rejected</div>
)}
</td>
</tr>
))}
@ -110,6 +162,9 @@ export async function getServerSideProps(context) {
title: true,
description: true,
attendees: true,
confirmed: true,
rejected: true,
id: true,
},
orderBy: {
startTime: "desc",

View file

@ -62,7 +62,10 @@ export default function Success(props) {
isReady && (
<div>
<Head>
<title>Booking Confirmed | {eventName} | Calendso</title>
<title>
Booking {props.eventType.requiresConfirmation ? "Submitted" : "Confirmed"} | {eventName} |
Calendso
</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<main className="max-w-3xl mx-auto my-24">
@ -79,17 +82,26 @@ export default function Success(props) {
aria-labelledby="modal-headline">
<div>
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100">
{!props.eventType.requiresConfirmation && (
<CheckIcon className="h-6 w-6 text-green-600" />
)}
{props.eventType.requiresConfirmation && (
<ClockIcon className="h-6 w-6 text-green-600" />
)}
</div>
<div className="mt-3 text-center sm:mt-5">
<h3
className="text-lg leading-6 font-medium dark:text-white text-gray-900"
id="modal-headline">
Booking confirmed
Booking {props.eventType.requiresConfirmation ? "Submitted" : "Confirmed"}
</h3>
<div className="mt-2">
<p className="text-sm text-gray-500 dark:text-gray-300">
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}.`}
</p>
</div>
<div className="mt-4 border-t border-b dark:border-gray-900 py-4">
@ -113,8 +125,11 @@ export default function Success(props) {
</div>
</div>
</div>
{!props.eventType.requiresConfirmation && (
<div className="mt-5 sm:mt-0 pt-2 text-center">
<span className="font-medium text-gray-500 dark:text-gray-50">Add to your calendar</span>
<span className="font-medium text-gray-500 dark:text-gray-50">
Add to your calendar
</span>
<div className="flex mt-2">
<Link
href={
@ -200,6 +215,7 @@ export default function Success(props) {
</Link>
</div>
</div>
)}
{!props.user.hideBranding && (
<div className="mt-4 pt-4 border-t dark:border-gray-900 text-gray-400 text-center text-xs dark:text-white">
<a href="https://checkout.calendso.com">Create your own booking link with Calendso</a>
@ -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 {

View file

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

View file

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