Direct links to accept/reject a booking (#5939)

* Impl

* Added reason

* Secondary reject CTA

* Update packages/emails/src/components/Info.tsx

* Adjusting recurring info

* Not showing reason for rejecting if none given

* Feedback

* Error handling + style fixes

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
This commit is contained in:
Leo Giovanetti
2022-12-13 22:09:28 +01:00
committed by GitHub
co-authored by Peer Richelsen
parent 2e6c3a6068
commit 61e6f76bc6
16 changed files with 1018 additions and 478 deletions
+29 -13
View File
@@ -1,32 +1,48 @@
import Head from "next/head";
import { useRouter } from "next/router";
import { APP_NAME, WEBSITE_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button } from "@calcom/ui";
import { Button, Icon, showToast } from "@calcom/ui";
export default function Error500() {
const { t } = useLocale();
const router = useRouter();
return (
<div className="flex h-screen">
<div className="flex h-screen bg-gray-100">
<Head>
<title>Something unexpected occurred | {APP_NAME}</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<div className="m-auto text-center">
<h1 className="font-cal text-[250px] text-gray-900">
5
{
// eslint-disable-next-line @next/next/no-img-element
<img src="/error.svg" className="-mt-10 inline w-60" alt="0" />
}
0
</h1>
<h2 className="mb-2 -mt-16 text-3xl text-gray-600">It&apos;s not you, it&apos;s us.</h2>
<p className="mb-4 max-w-2xl text-gray-500">
<div className="rtl: m-auto rounded-md bg-white p-10 text-right ltr:text-left">
<h1 className="font-cal text-6xl text-black">500</h1>
<h2 className="mt-6 text-2xl font-medium text-black">It&apos;s not you, it&apos;s us.</h2>
<p className="mt-4 mb-6 max-w-2xl text-sm text-gray-600">
Something went wrong on our end. Get in touch with our support team, and we’ll get it fixed right
away for you.
</p>
{router.query.error && (
<div className="mb-8 flex flex-col">
<p className="mb-4 max-w-2xl text-sm text-gray-600">
Please provide the following text when contacting support to better help you:
</p>
<pre className="w-full max-w-2xl whitespace-normal break-words rounded-md bg-gray-200 p-4 text-gray-900">
{router.query.error}
<br />
<Button
color="secondary"
className="mt-2 border-0 font-sans font-normal hover:bg-gray-300"
StartIcon={Icon.FiCopy}
onClick={() => {
navigator.clipboard.writeText(router.query.error as string);
showToast("Link copied!", "success");
}}>
{t("copy")}
</Button>
</pre>
</div>
)}
<Button href={`${WEBSITE_URL}/support`}>{t("contact_support")}</Button>
<Button color="secondary" href="javascript:history.back()" className="ml-2">
Go back
+4 -13
View File
@@ -13,10 +13,6 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
title: "30min between Pro Example and pro@example.com",
description: null,
additionalNotes: "asdasdas",
customInputs: {
"Custom input 01": "sadasdasdsadasd",
"Custom input 02": "asdasdasd",
},
startTime: "2022-06-03T09:00:00-06:00",
endTime: "2022-06-03T09:30:00-06:00",
organizer: {
@@ -36,10 +32,8 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
location: "Zoom video",
destinationCalendar: null,
hideCalendarNotes: false,
uid: "bwPWLpjYrx4rZf6MCZdKgE",
uid: "xxyPr4cg2xx4XoS2KeMEQy",
metadata: {},
cancellationReason: "It got late",
paymentInfo: { id: "pi_12312", link: "https://cal.com", reason: "no reason" },
recurringEvent: null,
};
@@ -48,12 +42,9 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
res.setHeader("Content-Type", "text/html");
res.setHeader("Cache-Control", "no-cache, no-store, private, must-revalidate");
res.write(
renderEmail("TeamInviteEmail", {
language: t,
from: "From",
to: "To",
teamName: "Team name",
joinLink: "Join link",
renderEmail("OrganizerRequestEmail", {
attendee: evt.attendees[0],
calEvent: evt,
})
);
res.end();
+450
View File
@@ -0,0 +1,450 @@
import { BookingStatus } from "@prisma/client";
import { createHmac } from "crypto";
import { instance } from "gaxios";
import { GetServerSidePropsContext } from "next";
import { useRouter } from "next/router";
import { useState } from "react";
import z from "zod";
import { getEventLocationValue, getSuccessPageLocationMessage } from "@calcom/app-store/locations";
import dayjs from "@calcom/dayjs";
import { getRecurringWhen } from "@calcom/emails/src/components/WhenInfo";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { parseRecurringEvent } from "@calcom/lib/isRecurringEvent";
import { processBookingConfirmation } from "@calcom/lib/server/queries/bookings/confirm";
import prisma from "@calcom/prisma";
import { TRPCError } from "@calcom/trpc/server";
import { inferSSRProps } from "@calcom/types/inferSSRProps";
import { Button, Icon, TextArea } from "@calcom/ui";
import { HeadSeo } from "@components/seo/head-seo";
enum DirectAction {
"accept" = "accept",
"reject" = "reject",
}
const actionSchema = z.nativeEnum(DirectAction);
const refineParse = (result: z.SafeParseReturnType<any, any>, context: z.RefinementCtx) => {
if (result.success === false) {
result.error.issues.map((issue) => context.addIssue(issue));
}
};
const CALENDSO_ENCRYPTION_KEY = process.env.CALENDSO_ENCRYPTION_KEY || "";
const pageErrors = {
signature_mismatch: "Direct link signature doesn't match signed data",
booking_not_found: "Direct link booking not found",
user_not_found: "Direct link booking user not found",
};
const requestSchema = z.object({
link: z
.array(z.string())
.max(4)
.superRefine((data, ctx) => {
refineParse(actionSchema.safeParse(data[0]), ctx);
const signedData = `${data[1]}/${data[2]}`;
const sig = createHmac("sha1", CALENDSO_ENCRYPTION_KEY).update(signedData).digest("base64");
if (data[3] !== sig) {
ctx.addIssue({
message: pageErrors.signature_mismatch,
code: "custom",
});
console.log(signedData, data, data[3], "==", sig);
}
}),
reason: z.string().optional(),
});
function bookingContent(status: BookingStatus | undefined | null) {
switch (status) {
case BookingStatus.PENDING:
// Trying to reject booking without reason
return {
iconColor: "gray",
Icon: Icon.FiCalendar,
titleKey: "event_awaiting_approval",
subtitleKey: "someone_requested_an_event",
};
case BookingStatus.ACCEPTED:
// Booking was acepted successfully
return {
iconColor: "green",
Icon: Icon.FiCheck,
titleKey: "booking_confirmed",
subtitleKey: "emailed_you_and_any_other_attendees",
};
case BookingStatus.REJECTED:
// Booking was rejected successfully
return {
iconColor: "red",
Icon: Icon.FiX,
titleKey: "booking_rejection_success",
subtitleKey: "emailed_you_and_any_other_attendees",
};
default:
// Booking was already accepted or rejected
return {
iconColor: "yellow",
Icon: Icon.FiAlertTriangle,
titleKey: "booking_already_accepted_rejected",
};
}
}
export default function Directlink({ booking, reason, status }: inferSSRProps<typeof getServerSideProps>) {
const { t } = useLocale();
const router = useRouter();
const acceptPath = router.asPath.replace("reject", "accept");
const rejectPath = router.asPath.replace("accept", "reject");
const [cancellationReason, setCancellationReason] = useState("");
function getRecipientStart(format: string) {
return dayjs(booking.startTime).tz(booking?.user?.timeZone).format(format);
}
function getRecipientEnd(format: string) {
return dayjs(booking.endTime).tz(booking?.user?.timeZone).format(format);
}
const organizer = {
...booking.attendees[0],
language: {
translate: t,
locale: booking.attendees[0].locale ?? "en",
},
};
const location: ReturnType<typeof getEventLocationValue> = Array.isArray(booking.location)
? booking.location[0]
: // If there is no location set then we default to Cal Video
"integrations:daily";
const locationToDisplay = getSuccessPageLocationMessage(location, t);
const content = bookingContent(status);
const recurringInfo = getRecurringWhen({
recurringEvent: booking.eventType?.recurringEvent,
attendee: organizer,
});
return (
<>
<HeadSeo
title={t(content.titleKey)}
description=""
nextSeoProps={{
nofollow: true,
noindex: true,
}}
/>
<div className="dark:bg-darkgray-50 desktop-transparent min-h-screen bg-gray-100 px-4">
<main className="mx-auto max-w-3xl">
<div className="z-50 overflow-y-auto ">
<div className="flex items-end justify-center px-4 pt-4 pb-20 text-center sm:block sm:p-0">
<div className="inset-0 my-4 transition-opacity sm:my-0" aria-hidden="true">
<div
className="main dark:bg-darkgray-100 inline-block transform overflow-hidden rounded-lg border bg-white px-8 pt-5 pb-4 text-left align-bottom transition-all dark:border-neutral-700 sm:my-[68px] sm:w-full sm:max-w-xl sm:py-8 sm:align-middle"
role="dialog"
aria-modal="true"
aria-labelledby="modal-headline">
<div
className={`flex h-12 w-12 items-center justify-center rounded-full sm:mx-auto bg-${content.iconColor}-100`}>
<content.Icon className={`h-5 w-5 text-${content.iconColor}-600`} />
</div>
<div className="mt-6 mb-8 last:mb-0 sm:text-center">
<h3
className="text-2xl font-semibold leading-6 text-neutral-900 dark:text-white"
id="modal-headline">
{t(content.titleKey)}
</h3>
{content.subtitleKey && (
<div className="mt-3">
<p className="text-neutral-600 dark:text-gray-300">{t(content.subtitleKey)}</p>
</div>
)}
<div className="dark:border-darkgray-300 mt-8 grid grid-cols-3 border-t border-[#e1e1e1] pt-8 text-left text-[#313131] dark:text-gray-300">
<div className="col-span-3 font-medium sm:col-span-1">{t("what")}</div>
<div className="col-span-3 mb-6 last:mb-0 sm:col-span-2">{booking.title}</div>
<div className="col-span-3 font-medium sm:col-span-1">{t("when")}</div>
<div className="col-span-3 mb-6 last:mb-0 sm:col-span-2">
{recurringInfo !== "" && (
<>
{recurringInfo}
<br />
</>
)}
{booking.eventType.recurringEvent?.count ? `${t("starting")} ` : ""}
{t(getRecipientStart("dddd").toLowerCase())},{" "}
{t(getRecipientStart("MMMM").toLowerCase())} {getRecipientStart("D, YYYY")}
<br />
{getRecipientStart("h:mma")} - {getRecipientEnd("h:mma")}{" "}
<span style={{ color: "#888888" }}>({booking.attendees[0].timeZone})</span>
</div>
{(booking?.user || booking?.attendees) && (
<>
<div className="col-span-3 font-medium sm:col-span-1">{t("who")}</div>
<div className="col-span-3 last:mb-0 sm:col-span-2">
<>
{booking?.user && (
<div className="mb-3">
<p>{booking.user.name}</p>
<p className="text-[#888888]">{booking.user.email}</p>
</div>
)}
{booking?.attendees.map((attendee) => (
<div key={attendee.name} className="mb-3 last:mb-0">
{attendee.name && <p>{attendee.name}</p>}
<p className="text-[#888888]">{attendee.email}</p>
</div>
))}
</>
</div>
</>
)}
{locationToDisplay && (
<>
<div className="col-span-3 mt-6 font-medium sm:col-span-1">{t("where")}</div>
<div className="col-span-3 mt-6 sm:col-span-2">
{locationToDisplay.startsWith("http") ? (
<a title="Meeting Link" href={locationToDisplay}>
{locationToDisplay}
</a>
) : (
locationToDisplay
)}
</div>
</>
)}
{booking?.description && (
<>
<div className="col-span-3 mt-9 font-medium sm:col-span-1">
{t("additional_notes")}
</div>
<div className="col-span-3 mb-2 mt-9 sm:col-span-2">
<p>{booking.description}</p>
</div>
</>
)}
{status === BookingStatus.REJECTED && reason && (
<>
<div className="col-span-3 mt-9 font-medium sm:col-span-1">
{t("rejection_reason")}
</div>
<div className="col-span-3 mb-2 mt-9 sm:col-span-2">
<p>{reason}</p>
</div>
</>
)}
</div>
{status === BookingStatus.PENDING && reason === undefined && (
<>
<hr className="mt-6" />
<div className="mt-5 text-left sm:mt-6">
<label className="font-medium text-[#313131] dark:text-white">
{`${t("rejection_reason")} (${t("optional").toLowerCase()})`}
</label>
<TextArea
value={cancellationReason}
onChange={(e) => setCancellationReason(e.target.value)}
className="mt-2 mb-4 w-full dark:border-gray-900 dark:bg-gray-700 dark:text-white "
rows={3}
/>
<div className="flex flex-col-reverse rtl:space-x-reverse">
<div className="ml-auto flex w-full justify-end space-x-4">
<Button
color="secondary"
className="hidden text-center sm:block"
href={acceptPath}>
{t("booking_accept_intent")}
</Button>
<Button
className="hidden sm:block"
onClick={async () => {
router.push(
`${rejectPath}?reason=${encodeURIComponent(cancellationReason)}`
);
}}>
{t("rejection_confirmation")}
</Button>
<Button
color="secondary"
className="block text-center sm:hidden"
href={acceptPath}>
{t("accept")}
</Button>
<Button
className="block sm:hidden"
onClick={async () => {
router.push(
`${rejectPath}?reason=${encodeURIComponent(cancellationReason)}`
);
}}>
{t("reject")}
</Button>
</div>
</div>
</div>
</>
)}
</div>
</div>
</div>
</div>
</div>
</main>
</div>
</>
);
}
export async function getServerSideProps(context: GetServerSidePropsContext) {
const parsedQuery = requestSchema.safeParse(context.query);
// Parsing error, showing error 500 with message
if (parsedQuery.success === false) {
return {
redirect: {
destination: `/500?error=${parsedQuery.error.errors[0].message.concat(
" accessing " + context.resolvedUrl
)}`,
permanent: false,
},
};
}
const {
link: [action, email, bookingUid],
reason,
} = parsedQuery.data;
const isAccept = action === DirectAction.accept;
const bookingRaw = await prisma?.booking.findFirst({
where: {
uid: bookingUid,
user: {
email,
},
},
select: {
location: true,
description: true,
id: true,
recurringEventId: true,
status: true,
title: true,
startTime: true,
endTime: true,
eventType: {
select: {
recurringEvent: true,
},
},
attendees: {
select: {
locale: true,
name: true,
email: true,
timeZone: true,
},
},
user: {
select: {
id: true,
email: true,
name: true,
timeZone: true,
locale: true,
destinationCalendar: true,
credentials: true,
username: true,
},
},
},
});
// Booking not found, showing error 500 with message
if (!bookingRaw) {
return {
redirect: {
destination: `/500?error=${pageErrors.booking_not_found.concat(" accessing " + context.resolvedUrl)}`,
permanent: false,
},
};
}
const booking = {
...bookingRaw,
startTime: bookingRaw.startTime.toString(),
endTime: bookingRaw.endTime.toString(),
eventType: {
...bookingRaw.eventType,
recurringEvent: parseRecurringEvent(bookingRaw?.eventType?.recurringEvent),
},
attendees: bookingRaw?.attendees.map((att) => ({
...att,
language: {
locale: att.locale ?? "en",
},
})),
};
// Booking user not found, showing error 500 with message
if (booking.user === null) {
return {
redirect: {
destination: `/500?error=${pageErrors.user_not_found.concat(" accessing " + context.resolvedUrl)}`,
permanent: false,
},
};
}
// Booking already accepted or rejected
if (booking.status !== BookingStatus.PENDING) {
return {
props: {
booking,
status: null,
},
};
}
// Trying to reject booking without reason
if (!isAccept && reason === undefined) {
return {
props: {
booking,
status: BookingStatus.PENDING,
},
};
}
// Booking good to be accepted or rejected, proceeding to mark it
let result: { status: BookingStatus | undefined } = { status: undefined };
try {
result = await processBookingConfirmation(
{
bookingId: booking.id,
user: booking.user,
recurringEventId: booking.recurringEventId,
confirmed: action === DirectAction.accept,
rejectionReason: reason,
},
prisma
);
} catch (e) {
if (e instanceof TRPCError) {
return {
redirect: {
destination: `/500?error=${e.message.concat(" accessing " + context.resolvedUrl)}`,
permanent: false,
},
};
}
}
return {
props: {
booking,
status: result.status,
reason: context.query.reason ?? null,
},
};
}
+5
View File
@@ -0,0 +1,5 @@
<svg stroke="#888888" fill="none" stroke-width="2.5" viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" height="1em" width="1em" xmlns="http://www.w3.org/2000/svg">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path>
<polyline points="15 3 21 3 21 9"></polyline>
<line x1="10" y1="14" x2="21" y2="3"></line>
</svg>

After

Width:  |  Height:  |  Size: 360 B

@@ -303,6 +303,7 @@
"cancelling_all_recurring": "These are all remaining instances in the recurring event.",
"error_with_status_code_occured": "An error with status code {{status}} occurred.",
"booking_already_cancelled": "This booking was already cancelled",
"booking_already_accepted_rejected": "This booking was already accepted or rejected",
"go_back_home": "Go back home",
"or_go_back_home": "Or go back home",
"no_availability": "Unavailable",
@@ -1327,6 +1328,8 @@
"booking_confirmation_success": "Booking confirmation succeeded",
"booking_rejection_success": "Booking rejection succeeded",
"booking_confirmation_fail": "Booking confirmation failed",
"booking_tentative": "This booking is tentative",
"booking_accept_intent": "Oops, I want to accept",
"we_wont_show_again": "We won't show this again",
"couldnt_update_timezone": "We couldn't update the timezone",
"updated_timezone_to": "Updated timezone to {{formattedCurrentTz}}",
@@ -1,30 +1,33 @@
import { LinkIcon } from "./LinkIcon";
export const CallToAction = (props: { label: string; href: string }) => (
export const CallToAction = (props: { label: string; href: string; secondary?: boolean }) => (
<p
style={{
display: "inline-block",
background: "#292929",
background: props.secondary ? "#FFFFFF" : "#292929",
border: props.secondary ? "1px solid #d1d5db" : "",
color: "#ffffff",
fontFamily: "Roboto, Helvetica, sans-serif",
fontSize: "16px",
fontSize: "14px",
fontWeight: 500,
lineHeight: "120%",
lineHeight: "20px",
margin: 0,
textDecoration: "none",
textTransform: "none",
padding: "10px 25px",
padding: "10px 16px",
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
msoPaddingAlt: "0px",
borderRadius: "3px",
borderRadius: "6px",
boxSizing: "border-box",
}}>
<a
style={{ color: "#FFFFFF", textDecoration: "none" }}
style={{ color: props.secondary ? "#292929" : "#FFFFFF", textDecoration: "none" }}
href={props.href}
target="_blank"
rel="noreferrer">
{props.label} <LinkIcon />
{props.label}
<LinkIcon secondary />
</a>
</p>
);
@@ -4,7 +4,6 @@ export const CallToActionTable = (props: { children: React.ReactNode }) => (
<tr>
<td
align="center"
bgcolor="#292929"
role="presentation"
style={{
border: "none",
@@ -13,7 +12,6 @@ export const CallToActionTable = (props: { children: React.ReactNode }) => (
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
msoPaddingAlt: "10px 25px",
background: "#292929",
}}
valign="middle">
{props.children}
+4 -2
View File
@@ -1,9 +1,11 @@
import { BASE_URL, IS_PRODUCTION } from "@calcom/lib/constants";
export const LinkIcon = () => (
export const LinkIcon = ({ secondary }: { secondary?: boolean }) => (
<img
src={IS_PRODUCTION ? BASE_URL + "/emails/linkIcon.png" : "https://app.cal.com/emails/linkIcon.png"}
width="12px"
srcSet={IS_PRODUCTION ? BASE_URL + "/emails/linkIcon.svg" : "https://app.cal.com/emails/linkIcon.svg"}
width="16px"
style={{ marginBottom: "-3px", marginLeft: "8px", ...(secondary && { filter: "brightness(80%)" }) }}
alt=""
/>
);
@@ -0,0 +1,3 @@
export const Separator = () => (
<p style={{ width: "16px", height: "16px", display: "inline-block" }}>&nbsp;</p>
);
+19 -8
View File
@@ -3,21 +3,27 @@ import { RRule } from "rrule";
import dayjs from "@calcom/dayjs";
import { getEveryFreqFor } from "@calcom/lib/recurringStrings";
import type { CalendarEvent } from "@calcom/types/Calendar";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
import type { RecurringEvent } from "@calcom/types/Calendar";
import { Info } from "./Info";
function getRecurringWhen({ calEvent }: { calEvent: CalendarEvent }) {
if (calEvent.recurringEvent) {
const t = calEvent.attendees[0].language.translate;
const rruleOptions = new RRule(calEvent.recurringEvent).options;
const recurringEvent: RecurringEvent = {
export function getRecurringWhen({
recurringEvent,
attendee,
}: {
recurringEvent?: RecurringEvent | null;
attendee: Pick<Person, "language">;
}) {
if (recurringEvent) {
const t = attendee.language.translate;
const rruleOptions = new RRule(recurringEvent).options;
const recurringEventConfig: RecurringEvent = {
freq: rruleOptions.freq,
count: rruleOptions.count || 1,
interval: rruleOptions.interval,
};
return ` - ${getEveryFreqFor({ t, recurringEvent })}`;
return `${getEveryFreqFor({ t, recurringEvent: recurringEventConfig })}`;
}
return "";
}
@@ -33,10 +39,15 @@ export function WhenInfo(props: { calEvent: CalendarEvent; timeZone: string; t:
return dayjs(props.calEvent.endTime).tz(timeZone).format(format);
}
const recurringInfo = getRecurringWhen({
recurringEvent: props.calEvent.recurringEvent,
attendee: props.calEvent.attendees[0],
});
return (
<div>
<Info
label={`${t("when")} ${getRecurringWhen(props)}`}
label={`${t("when")} ${recurringInfo !== "" ? ` - ${recurringInfo}` : ""}`}
lineThrough={
!!props.calEvent.cancellationReason && !props.calEvent.cancellationReason.includes("$RCH$")
}
+1
View File
@@ -11,3 +11,4 @@ export { default as RawHtml } from "./RawHtml";
export { WhenInfo } from "./WhenInfo";
export { WhoInfo } from "./WhoInfo";
export { AppsStatus } from "./AppsStatus";
export { Separator } from "./Separator";
@@ -1,27 +1,42 @@
import { CallToAction, CallToActionTable } from "../components";
import { createHmac } from "crypto";
import { CallToAction, CallToActionTable, Separator } from "../components";
import { OrganizerScheduledEmail } from "./OrganizerScheduledEmail";
export const OrganizerRequestEmail = (props: React.ComponentProps<typeof OrganizerScheduledEmail>) => (
<OrganizerScheduledEmail
title={
props.title || props.calEvent.recurringEvent?.count
? "event_awaiting_approval_recurring"
: "event_awaiting_approval"
}
subtitle={<>{props.calEvent.organizer.language.translate("someone_requested_an_event")}</>}
headerType="calendarCircle"
subject="event_awaiting_approval_subject"
callToAction={
<CallToActionTable>
<CallToAction
label={props.calEvent.organizer.language.translate("confirm_or_reject_request")}
href={
process.env.NEXT_PUBLIC_WEBAPP_URL +
(props.calEvent.recurringEvent?.count ? "/bookings/recurring" : "/bookings/upcoming")
}
/>
</CallToActionTable>
}
{...props}
/>
);
const CALENDSO_ENCRYPTION_KEY = process.env.CALENDSO_ENCRYPTION_KEY || "";
export const OrganizerRequestEmail = (props: React.ComponentProps<typeof OrganizerScheduledEmail>) => {
const signedData = `${props.attendee.email}/${props.calEvent.uid}`;
const signature = createHmac("sha1", CALENDSO_ENCRYPTION_KEY).update(signedData).digest("base64");
return (
<OrganizerScheduledEmail
title={
props.title || props.calEvent.recurringEvent?.count
? "event_awaiting_approval_recurring"
: "event_awaiting_approval"
}
subtitle={<>{props.calEvent.organizer.language.translate("someone_requested_an_event")}</>}
headerType="calendarCircle"
subject="event_awaiting_approval_subject"
callToAction={
<CallToActionTable>
<CallToAction
label={props.calEvent.organizer.language.translate("accept")}
href={`${process.env.NEXT_PUBLIC_WEBAPP_URL}/booking/direct/accept/${encodeURIComponent(
props.attendee.email
)}/${encodeURIComponent(props.calEvent.uid as string)}/${encodeURIComponent(signature)}`}
/>
<Separator />
<CallToAction
label={props.calEvent.organizer.language.translate("reject")}
secondary
href={`${process.env.NEXT_PUBLIC_WEBAPP_URL}/booking/direct/reject/${encodeURIComponent(
props.attendee.email
)}/${encodeURIComponent(props.calEvent.uid as string)}/${encodeURIComponent(signature)}`}
/>
</CallToActionTable>
}
{...props}
/>
);
};
@@ -0,0 +1,441 @@
import {
Prisma,
BookingStatus,
DestinationCalendar,
PrismaClient,
SchedulingType,
Credential,
WorkflowsOnEventTypes,
WorkflowStep,
Workflow,
WebhookTriggerEvents,
} from "@prisma/client";
import { refund } from "@calcom/app-store/stripepayment/lib/server";
import { scheduleTrigger } from "@calcom/app-store/zapier/lib/nodeScheduler";
import EventManager from "@calcom/core/EventManager";
import { sendDeclinedEmails, sendScheduledEmails } from "@calcom/emails";
import { scheduleWorkflowReminders } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler";
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
import sendPayload, { EventTypeInfo } from "@calcom/features/webhooks/lib/sendPayload";
import { TRPCError } from "@calcom/trpc/server";
import { AdditionalInformation, CalendarEvent } from "@calcom/types/Calendar";
import { isPrismaObjOrUndefined } from "../../../isPrismaObj";
import { parseRecurringEvent } from "../../../isRecurringEvent";
import logger from "../../../logger";
import { getTranslation } from "../../i18n";
type ProcessBookingConfirmationProps = {
user: {
id: number;
email: string;
name: string | null;
timeZone: string;
locale: string | null;
destinationCalendar: DestinationCalendar | null;
credentials: Credential[];
username: string | null;
};
bookingId: number;
recurringEventId?: string | null;
confirmed: boolean;
rejectionReason?: string;
};
export async function processBookingConfirmation(
{ user, bookingId, recurringEventId, confirmed, rejectionReason }: ProcessBookingConfirmationProps,
prisma: PrismaClient
) {
const log = logger.getChildLogger({ prefix: ["[lib] queries:bookings:confirm"] });
const tOrganizer = await getTranslation(user.locale ?? "en", "common");
const booking = await prisma.booking.findFirst({
where: {
id: bookingId,
},
rejectOnNotFound() {
throw new TRPCError({ code: "NOT_FOUND", message: "Booking not found" });
},
// should trpc handle this error ?
select: {
title: true,
description: true,
customInputs: true,
startTime: true,
endTime: true,
attendees: true,
eventTypeId: true,
eventType: {
select: {
id: true,
recurringEvent: true,
title: true,
requiresConfirmation: true,
currency: true,
length: true,
description: true,
price: true,
workflows: {
include: {
workflow: {
include: {
steps: true,
},
},
},
},
},
},
location: true,
userId: true,
id: true,
uid: true,
payment: true,
destinationCalendar: true,
paid: true,
recurringEventId: true,
status: true,
smsReminderNumber: true,
scheduledJobs: true,
},
});
const authorized = async () => {
// if the organizer
if (booking.userId === user.id) {
return true;
}
const eventType = await prisma.eventType.findUnique({
where: {
id: booking.eventTypeId || undefined,
},
select: {
id: true,
schedulingType: true,
users: true,
},
});
if (
eventType?.schedulingType === SchedulingType.COLLECTIVE &&
eventType.users.find((user) => user.id === user.id)
) {
return true;
}
return false;
};
if (!(await authorized())) throw new TRPCError({ code: "UNAUTHORIZED", message: "UNAUTHORIZED" });
const isConfirmed = booking.status === BookingStatus.ACCEPTED;
if (isConfirmed) throw new TRPCError({ code: "BAD_REQUEST", message: "Booking already confirmed" });
/** When a booking that requires payment its being confirmed but doesn't have any payment,
* we shouldn’t save it on DestinationCalendars
*/
if (booking.payment.length > 0 && !booking.paid) {
await prisma.booking.update({
where: {
id: bookingId,
},
data: {
status: BookingStatus.ACCEPTED,
},
});
return { message: "Booking confirmed", status: BookingStatus.ACCEPTED };
}
const attendeesListPromises = booking.attendees.map(async (attendee) => {
return {
name: attendee.name,
email: attendee.email,
timeZone: attendee.timeZone,
language: {
translate: await getTranslation(attendee.locale ?? "en", "common"),
locale: attendee.locale ?? "en",
},
};
});
const attendeesList = await Promise.all(attendeesListPromises);
const evt: CalendarEvent = {
type: booking.eventType?.title || booking.title,
title: booking.title,
description: booking.description,
customInputs: isPrismaObjOrUndefined(booking.customInputs),
startTime: booking.startTime.toISOString(),
endTime: booking.endTime.toISOString(),
organizer: {
email: user.email,
name: user.name || "Unnamed",
timeZone: user.timeZone,
language: { translate: tOrganizer, locale: user.locale ?? "en" },
},
attendees: attendeesList,
location: booking.location ?? "",
uid: booking.uid,
destinationCalendar: booking?.destinationCalendar || user.destinationCalendar,
requiresConfirmation: booking?.eventType?.requiresConfirmation ?? false,
eventTypeId: booking.eventType?.id,
};
const recurringEvent = parseRecurringEvent(booking.eventType?.recurringEvent);
if (recurringEventId && recurringEvent) {
const groupedRecurringBookings = await prisma.booking.groupBy({
where: {
recurringEventId: booking.recurringEventId,
},
by: [Prisma.BookingScalarFieldEnum.recurringEventId],
_count: true,
});
// Overriding the recurring event configuration count to be the actual number of events booked for
// the recurring event (equal or less than recurring event configuration count)
recurringEvent.count = groupedRecurringBookings[0]._count;
// count changed, parsing again to get the new value in
evt.recurringEvent = parseRecurringEvent(recurringEvent);
}
if (confirmed) {
const eventManager = new EventManager(user);
const scheduleResult = await eventManager.create(evt);
const results = scheduleResult.results;
if (results.length > 0 && results.every((res) => !res.success)) {
const error = {
errorCode: "BookingCreatingMeetingFailed",
message: "Booking failed",
};
log.error(`Booking ${user.username} failed`, error, results);
} else {
const metadata: AdditionalInformation = {};
if (results.length) {
// TODO: Handle created event metadata more elegantly
metadata.hangoutLink = results[0].createdEvent?.hangoutLink;
metadata.conferenceData = results[0].createdEvent?.conferenceData;
metadata.entryPoints = results[0].createdEvent?.entryPoints;
}
try {
await sendScheduledEmails({ ...evt, additionalInformation: metadata });
} catch (error) {
log.error(error);
}
}
let updatedBookings: {
scheduledJobs: string[];
id: number;
startTime: Date;
endTime: Date;
uid: string;
smsReminderNumber: string | null;
eventType: {
workflows: (WorkflowsOnEventTypes & {
workflow: Workflow & {
steps: WorkflowStep[];
};
})[];
} | null;
}[] = [];
if (recurringEventId) {
// The booking to confirm is a recurring event and comes from /booking/recurring, proceeding to mark all related
// bookings as confirmed. Prisma updateMany does not support relations, so doing this in two steps for now.
const unconfirmedRecurringBookings = await prisma.booking.findMany({
where: {
recurringEventId,
status: BookingStatus.PENDING,
},
});
const updateBookingsPromise = unconfirmedRecurringBookings.map((recurringBooking) => {
return prisma.booking.update({
where: {
id: recurringBooking.id,
},
data: {
status: BookingStatus.ACCEPTED,
references: {
create: scheduleResult.referencesToCreate,
},
},
select: {
eventType: {
select: {
workflows: {
include: {
workflow: {
include: {
steps: true,
},
},
},
},
},
},
uid: true,
startTime: true,
endTime: true,
smsReminderNumber: true,
id: true,
scheduledJobs: true,
},
});
});
const updatedBookingsResult = await Promise.all(updateBookingsPromise);
updatedBookings = updatedBookings.concat(updatedBookingsResult);
} else {
// @NOTE: be careful with this as if any error occurs before this booking doesn't get confirmed
// Should perform update on booking (confirm) -> then trigger the rest handlers
const updatedBooking = await prisma.booking.update({
where: {
id: bookingId,
},
data: {
status: BookingStatus.ACCEPTED,
references: {
create: scheduleResult.referencesToCreate,
},
},
select: {
eventType: {
select: {
workflows: {
include: {
workflow: {
include: {
steps: true,
},
},
},
},
},
},
uid: true,
startTime: true,
endTime: true,
smsReminderNumber: true,
id: true,
scheduledJobs: true,
},
});
updatedBookings.push(updatedBooking);
}
//Workflows - set reminders for confirmed events
try {
for (let index = 0; index < updatedBookings.length; index++) {
if (updatedBookings[index].eventType?.workflows) {
const evtOfBooking = evt;
evtOfBooking.startTime = updatedBookings[index].startTime.toISOString();
evtOfBooking.endTime = updatedBookings[index].endTime.toISOString();
evtOfBooking.uid = updatedBookings[index].uid;
const isFirstBooking = index === 0;
await scheduleWorkflowReminders(
updatedBookings[index]?.eventType?.workflows || [],
updatedBookings[index].smsReminderNumber,
evtOfBooking,
false,
false,
isFirstBooking
);
}
}
} catch (error) {
// Silently fail
console.error(error);
}
try {
// schedule job for zapier trigger 'when meeting ends'
const subscriberOptionsMeetingEnded = {
userId: booking.userId || 0,
eventTypeId: booking.eventTypeId || 0,
triggerEvent: WebhookTriggerEvents.MEETING_ENDED,
};
const subscriberOptionsBookingCreated = {
userId: booking.userId || 0,
eventTypeId: booking.eventTypeId || 0,
triggerEvent: WebhookTriggerEvents.BOOKING_CREATED,
};
const subscribersBookingCreated = await getWebhooks(subscriberOptionsBookingCreated);
const subscribersMeetingEnded = await getWebhooks(subscriberOptionsMeetingEnded);
subscribersMeetingEnded.forEach(
(subscriber: { subscriberUrl: string; id: string; appId: string | null }) => {
updatedBookings.forEach((booking) => {
scheduleTrigger(booking, subscriber.subscriberUrl, subscriber);
});
}
);
const eventTypeInfo: EventTypeInfo = {
eventTitle: booking.eventType?.title,
eventDescription: booking.eventType?.description,
requiresConfirmation: booking.eventType?.requiresConfirmation || null,
price: booking.eventType?.price,
currency: booking.eventType?.currency,
length: booking.eventType?.length,
};
const promises = subscribersBookingCreated.map((sub) =>
sendPayload(sub.secret, WebhookTriggerEvents.BOOKING_CREATED, new Date().toISOString(), sub, {
...evt,
...eventTypeInfo,
bookingId,
eventTypeId: booking.eventType?.id,
status: "ACCEPTED",
smsReminderNumber: booking.smsReminderNumber || undefined,
}).catch((e) => {
console.error(
`Error executing webhook for event: ${WebhookTriggerEvents.BOOKING_CREATED}, URL: ${sub.subscriberUrl}`,
e
);
})
);
await Promise.all(promises);
} catch (error) {
// Silently fail
console.error(error);
}
} else {
evt.rejectionReason = rejectionReason;
if (recurringEventId) {
// The booking to reject is a recurring event and comes from /booking/upcoming, proceeding to mark all related
// bookings as rejected.
await prisma.booking.updateMany({
where: {
recurringEventId,
status: BookingStatus.PENDING,
},
data: {
status: BookingStatus.REJECTED,
rejectionReason,
},
});
} else {
await refund(booking, evt); // No payment integration for recurring events for v1
await prisma.booking.update({
where: {
id: bookingId,
},
data: {
status: BookingStatus.REJECTED,
rejectionReason,
},
});
}
await sendDeclinedEmails(evt);
}
const message = "Booking " + confirmed ? "confirmed" : "rejected";
const status = confirmed ? BookingStatus.ACCEPTED : BookingStatus.REJECTED;
return { message, status };
}
@@ -6,34 +6,24 @@ import {
SchedulingType,
User,
WebhookTriggerEvents,
Workflow,
WorkflowsOnEventTypes,
WorkflowStep,
} from "@prisma/client";
import type { TFunction } from "next-i18next";
import { z } from "zod";
import { getCalendar } from "@calcom/app-store/_utils/getCalendar";
import { DailyLocationType } from "@calcom/app-store/locations";
import { refund } from "@calcom/app-store/stripepayment/lib/server";
import { scheduleTrigger } from "@calcom/app-store/zapier/lib/nodeScheduler";
import EventManager from "@calcom/core/EventManager";
import { CalendarEventBuilder } from "@calcom/core/builders/CalendarEvent/builder";
import { CalendarEventDirector } from "@calcom/core/builders/CalendarEvent/director";
import { deleteMeeting } from "@calcom/core/videoClient";
import dayjs from "@calcom/dayjs";
import {
sendDeclinedEmails,
sendLocationChangeEmails,
sendRequestRescheduleEmail,
sendScheduledEmails,
} from "@calcom/emails";
import { scheduleWorkflowReminders } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler";
import { sendLocationChangeEmails, sendRequestRescheduleEmail } from "@calcom/emails";
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
import sendPayload, { EventTypeInfo } from "@calcom/features/webhooks/lib/sendPayload";
import sendPayload from "@calcom/features/webhooks/lib/sendPayload";
import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib";
import logger from "@calcom/lib/logger";
import { getTranslation } from "@calcom/lib/server";
import { processBookingConfirmation } from "@calcom/lib/server/queries/bookings/confirm";
import { bookingMinimalSelect } from "@calcom/prisma";
import { bookingConfirmPatchBodySchema } from "@calcom/prisma/zod-utils";
import type { AdditionalInformation, CalendarEvent, Person } from "@calcom/types/Calendar";
@@ -641,398 +631,9 @@ export const bookingsRouter = router({
}
return { message: "Location updated" };
}),
confirm: bookingsProcedure.input(bookingConfirmPatchBodySchema).mutation(async ({ ctx, input }) => {
const { user, prisma } = ctx;
const { bookingId, recurringEventId, reason: rejectionReason, confirmed } = input;
const tOrganizer = await getTranslation(user.locale ?? "en", "common");
const booking = await prisma.booking.findFirst({
where: {
id: bookingId,
},
rejectOnNotFound() {
throw new TRPCError({ code: "NOT_FOUND", message: "Booking not found" });
},
// should trpc handle this error ?
select: {
title: true,
description: true,
customInputs: true,
startTime: true,
endTime: true,
attendees: true,
eventTypeId: true,
eventType: {
select: {
id: true,
recurringEvent: true,
title: true,
requiresConfirmation: true,
currency: true,
length: true,
description: true,
price: true,
workflows: {
include: {
workflow: {
include: {
steps: true,
},
},
},
},
},
},
location: true,
userId: true,
id: true,
uid: true,
payment: true,
destinationCalendar: true,
paid: true,
recurringEventId: true,
status: true,
smsReminderNumber: true,
scheduledJobs: true,
},
});
const authorized = async () => {
// if the organizer
if (booking.userId === user.id) {
return true;
}
const eventType = await prisma.eventType.findUnique({
where: {
id: booking.eventTypeId || undefined,
},
select: {
id: true,
schedulingType: true,
users: true,
},
});
if (
eventType?.schedulingType === SchedulingType.COLLECTIVE &&
eventType.users.find((user) => user.id === user.id)
) {
return true;
}
return false;
};
if (!(await authorized())) throw new TRPCError({ code: "UNAUTHORIZED", message: "UNAUTHORIZED" });
const isConfirmed = booking.status === BookingStatus.ACCEPTED;
if (isConfirmed) throw new TRPCError({ code: "BAD_REQUEST", message: "Booking already confirmed" });
/** When a booking that requires payment its being confirmed but doesn't have any payment,
* we shouldn’t save it on DestinationCalendars
*/
if (booking.payment.length > 0 && !booking.paid) {
await prisma.booking.update({
where: {
id: bookingId,
},
data: {
status: BookingStatus.ACCEPTED,
},
});
return { message: "Booking confirmed", status: BookingStatus.ACCEPTED };
}
const attendeesListPromises = booking.attendees.map(async (attendee) => {
return {
name: attendee.name,
email: attendee.email,
timeZone: attendee.timeZone,
language: {
translate: await getTranslation(attendee.locale ?? "en", "common"),
locale: attendee.locale ?? "en",
},
};
});
const attendeesList = await Promise.all(attendeesListPromises);
const evt: CalendarEvent = {
type: booking.eventType?.title || booking.title,
title: booking.title,
description: booking.description,
customInputs: isPrismaObjOrUndefined(booking.customInputs),
startTime: booking.startTime.toISOString(),
endTime: booking.endTime.toISOString(),
organizer: {
email: user.email,
name: user.name || "Unnamed",
timeZone: user.timeZone,
language: { translate: tOrganizer, locale: user.locale ?? "en" },
},
attendees: attendeesList,
location: booking.location ?? "",
uid: booking.uid,
destinationCalendar: booking?.destinationCalendar || user.destinationCalendar,
requiresConfirmation: booking?.eventType?.requiresConfirmation ?? false,
eventTypeId: booking.eventType?.id,
};
const recurringEvent = parseRecurringEvent(booking.eventType?.recurringEvent);
if (recurringEventId && recurringEvent) {
const groupedRecurringBookings = await prisma.booking.groupBy({
where: {
recurringEventId: booking.recurringEventId,
},
by: [Prisma.BookingScalarFieldEnum.recurringEventId],
_count: true,
});
// Overriding the recurring event configuration count to be the actual number of events booked for
// the recurring event (equal or less than recurring event configuration count)
recurringEvent.count = groupedRecurringBookings[0]._count;
// count changed, parsing again to get the new value in
evt.recurringEvent = parseRecurringEvent(recurringEvent);
}
if (confirmed) {
const eventManager = new EventManager(user);
const scheduleResult = await eventManager.create(evt);
const results = scheduleResult.results;
if (results.length > 0 && results.every((res) => !res.success)) {
const error = {
errorCode: "BookingCreatingMeetingFailed",
message: "Booking failed",
};
log.error(`Booking ${user.username} failed`, error, results);
} else {
const metadata: AdditionalInformation = {};
if (results.length) {
// TODO: Handle created event metadata more elegantly
metadata.hangoutLink = results[0].createdEvent?.hangoutLink;
metadata.conferenceData = results[0].createdEvent?.conferenceData;
metadata.entryPoints = results[0].createdEvent?.entryPoints;
}
try {
await sendScheduledEmails({ ...evt, additionalInformation: metadata });
} catch (error) {
log.error(error);
}
}
let updatedBookings: {
scheduledJobs: string[];
id: number;
startTime: Date;
endTime: Date;
uid: string;
smsReminderNumber: string | null;
eventType: {
workflows: (WorkflowsOnEventTypes & {
workflow: Workflow & {
steps: WorkflowStep[];
};
})[];
} | null;
}[] = [];
if (recurringEventId) {
// The booking to confirm is a recurring event and comes from /booking/recurring, proceeding to mark all related
// bookings as confirmed. Prisma updateMany does not support relations, so doing this in two steps for now.
const unconfirmedRecurringBookings = await prisma.booking.findMany({
where: {
recurringEventId,
status: BookingStatus.PENDING,
},
});
const updateBookingsPromise = unconfirmedRecurringBookings.map((recurringBooking) => {
return prisma.booking.update({
where: {
id: recurringBooking.id,
},
data: {
status: BookingStatus.ACCEPTED,
references: {
create: scheduleResult.referencesToCreate,
},
},
select: {
eventType: {
select: {
workflows: {
include: {
workflow: {
include: {
steps: true,
},
},
},
},
},
},
uid: true,
startTime: true,
endTime: true,
smsReminderNumber: true,
id: true,
scheduledJobs: true,
},
});
});
const updatedBookingsResult = await Promise.all(updateBookingsPromise);
updatedBookings = updatedBookings.concat(updatedBookingsResult);
} else {
// @NOTE: be careful with this as if any error occurs before this booking doesn't get confirmed
// Should perform update on booking (confirm) -> then trigger the rest handlers
const updatedBooking = await prisma.booking.update({
where: {
id: bookingId,
},
data: {
status: BookingStatus.ACCEPTED,
references: {
create: scheduleResult.referencesToCreate,
},
},
select: {
eventType: {
select: {
workflows: {
include: {
workflow: {
include: {
steps: true,
},
},
},
},
},
},
uid: true,
startTime: true,
endTime: true,
smsReminderNumber: true,
id: true,
scheduledJobs: true,
},
});
updatedBookings.push(updatedBooking);
}
//Workflows - set reminders for confirmed events
try {
for (let index = 0; index < updatedBookings.length; index++) {
if (updatedBookings[index].eventType?.workflows) {
const evtOfBooking = evt;
evtOfBooking.startTime = updatedBookings[index].startTime.toISOString();
evtOfBooking.endTime = updatedBookings[index].endTime.toISOString();
evtOfBooking.uid = updatedBookings[index].uid;
const isFirstBooking = index === 0;
await scheduleWorkflowReminders(
updatedBookings[index]?.eventType?.workflows || [],
updatedBookings[index].smsReminderNumber,
evtOfBooking,
false,
false,
isFirstBooking
);
}
}
} catch (error) {
// Silently fail
console.error(error);
}
try {
// schedule job for zapier trigger 'when meeting ends'
const subscriberOptionsMeetingEnded = {
userId: booking.userId || 0,
eventTypeId: booking.eventTypeId || 0,
triggerEvent: WebhookTriggerEvents.MEETING_ENDED,
};
const subscriberOptionsBookingCreated = {
userId: booking.userId || 0,
eventTypeId: booking.eventTypeId || 0,
triggerEvent: WebhookTriggerEvents.BOOKING_CREATED,
};
const subscribersBookingCreated = await getWebhooks(subscriberOptionsBookingCreated);
const subscribersMeetingEnded = await getWebhooks(subscriberOptionsMeetingEnded);
subscribersMeetingEnded.forEach((subscriber) => {
updatedBookings.forEach((booking) => {
scheduleTrigger(booking, subscriber.subscriberUrl, subscriber);
});
});
const eventTypeInfo: EventTypeInfo = {
eventTitle: booking.eventType?.title,
eventDescription: booking.eventType?.description,
requiresConfirmation: booking.eventType?.requiresConfirmation || null,
price: booking.eventType?.price,
currency: booking.eventType?.currency,
length: booking.eventType?.length,
};
const promises = subscribersBookingCreated.map((sub) =>
sendPayload(sub.secret, WebhookTriggerEvents.BOOKING_CREATED, new Date().toISOString(), sub, {
...evt,
...eventTypeInfo,
bookingId,
eventTypeId: booking.eventType?.id,
status: "ACCEPTED",
smsReminderNumber: booking.smsReminderNumber || undefined,
}).catch((e) => {
console.error(
`Error executing webhook for event: ${WebhookTriggerEvents.BOOKING_CREATED}, URL: ${sub.subscriberUrl}`,
e
);
})
);
await Promise.all(promises);
} catch (error) {
// Silently fail
console.error(error);
}
} else {
evt.rejectionReason = rejectionReason;
if (recurringEventId) {
// The booking to reject is a recurring event and comes from /booking/upcoming, proceeding to mark all related
// bookings as rejected.
await prisma.booking.updateMany({
where: {
recurringEventId,
status: BookingStatus.PENDING,
},
data: {
status: BookingStatus.REJECTED,
rejectionReason,
},
});
} else {
await refund(booking, evt); // No payment integration for recurring events for v1
await prisma.booking.update({
where: {
id: bookingId,
},
data: {
status: BookingStatus.REJECTED,
rejectionReason,
},
});
}
await sendDeclinedEmails(evt);
}
const message = "Booking " + confirmed ? "confirmed" : "rejected";
const status = confirmed ? BookingStatus.ACCEPTED : BookingStatus.REJECTED;
return { message, status };
}),
confirm: bookingsProcedure
.input(bookingConfirmPatchBodySchema)
.mutation(async ({ ctx: { user, prisma }, input }) => {
return processBookingConfirmation({ user, ...input }, prisma);
}),
});