Refactor/email confirm links (#6151)

* Booking confirmation link improvements

* Cleanup

* Update webhooks payload

* Removing unneeded dependency

* Feedback

* Matches enum style to rest of codebase

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
Co-authored-by: Leo Giovanetti <hello@leog.me>
This commit is contained in:
Omar López 2022-12-21 17:15:51 -07:00 committed by GitHub
parent dcc226976c
commit 23450b61e0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 485 additions and 924 deletions

View File

@ -227,6 +227,11 @@ const nextConfig = {
destination: "/404",
permanent: false,
},
{
source: "/booking/direct/:action/:email/:bookingUid/:oldToken",
destination: "/api/link?action=:action&email=:email&bookingUid=:bookingUid&oldToken=:oldToken",
permanent: true,
},
];
if (process.env.NEXT_PUBLIC_WEBAPP_URL === "https://app.cal.com") {

View File

@ -64,7 +64,6 @@
"@vercel/og": "^0.0.21",
"accept-language-parser": "^1.5.0",
"async": "^3.2.4",
"base64url": "^3.0.1",
"bcryptjs": "^2.4.3",
"classnames": "^2.3.1",
"dotenv-cli": "^6.0.0",

View File

@ -0,0 +1,63 @@
import { UserPermissionRole } from "@prisma/client";
import type { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod";
import { symmetricDecrypt } from "@calcom/lib/crypto";
import { defaultResponder } from "@calcom/lib/server";
import prisma from "@calcom/prisma";
import { createContext } from "@calcom/trpc/server/createContext";
import { viewerRouter } from "@calcom/trpc/server/routers/viewer";
enum DirectAction {
ACCEPT = "accept",
REJECT = "reject",
}
const querySchema = z.object({
action: z.nativeEnum(DirectAction),
token: z.string(),
reason: z.string().optional(),
});
const decryptedSchema = z.object({
bookingUid: z.string(),
userId: z.number().int(),
});
async function handler(req: NextApiRequest, res: NextApiResponse<Response>) {
const { action, token, reason } = querySchema.parse(req.query);
const { bookingUid, userId } = decryptedSchema.parse(
JSON.parse(symmetricDecrypt(token, process.env.CALENDSO_ENCRYPTION_KEY || ""))
);
/** We shape the session as required by tRPC router */
async function sessionGetter() {
return {
user: {
id: userId,
username: "" /* Not used in this context */,
role: UserPermissionRole.USER,
},
hasValidLicense: true,
expires: "" /* Not used in this context */,
};
}
const booking = await prisma.booking.findUniqueOrThrow({
where: { uid: bookingUid },
});
/** @see https://trpc.io/docs/server-side-calls */
const ctx = await createContext({ req, res }, sessionGetter);
const caller = viewerRouter.createCaller(ctx);
await caller.bookings.confirm({
bookingId: booking.id,
recurringEventId: booking.recurringEventId || undefined,
confirmed: action === DirectAction.ACCEPT,
reason,
});
return res.redirect(`/booking/${bookingUid}`);
}
export default defaultResponder(handler);

View File

@ -1,450 +0,0 @@
import { BookingStatus } from "@prisma/client";
import base64url from "base64url";
import { createHmac } from "crypto";
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 sha1 = createHmac("sha1", CALENDSO_ENCRYPTION_KEY).update(signedData).digest();
const sig = base64url(sha1);
if (data[3] !== sig) {
ctx.addIssue({
message: pageErrors.signature_mismatch,
code: "custom",
});
}
}),
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?.user?.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,
},
};
}

View File

@ -57,6 +57,7 @@ test("add webhook & test that creating an event triggers a webhook call", async
attendee.timeZone = dynamic;
attendee.language = dynamic;
}
body.payload.organizer.id = dynamic;
body.payload.organizer.email = dynamic;
body.payload.organizer.timeZone = dynamic;
body.payload.organizer.language = dynamic;

View File

@ -1 +1 @@
{"triggerEvent":"BOOKING_CREATED","createdAt":"[redacted/dynamic]","payload":{"type":"30 min","title":"30 min between Nameless and Test Testson","description":"","additionalNotes":"","customInputs":{},"startTime":"[redacted/dynamic]","endTime":"[redacted/dynamic]","organizer":{"name":"Nameless","email":"[redacted/dynamic]","timeZone":"[redacted/dynamic]","language":"[redacted/dynamic]"},"attendees":[{"email":"test@example.com","name":"Test Testson","timeZone":"[redacted/dynamic]","language":"[redacted/dynamic]"}],"location":"[redacted/dynamic]","destinationCalendar":null,"hideCalendarNotes":false,"requiresConfirmation":"[redacted/dynamic]","eventTypeId":"[redacted/dynamic]","seatsShowAttendees":false,"uid":"[redacted/dynamic]","videoCallData":"[redacted/dynamic]","appsStatus":"[redacted/dynamic]","eventTitle":"30 min","eventDescription":null,"price":0,"currency":"usd","length":30,"bookingId":"[redacted/dynamic]","metadata":{"videoCallUrl":"[redacted/dynamic]"},"status":"ACCEPTED","additionalInformation":"[redacted/dynamic]"}}
{"triggerEvent":"BOOKING_CREATED","createdAt":"[redacted/dynamic]","payload":{"type":"30 min","title":"30 min between Nameless and Test Testson","description":"","additionalNotes":"","customInputs":{},"startTime":"[redacted/dynamic]","endTime":"[redacted/dynamic]","organizer":{"id":"[redacted/dynamic]","name":"Nameless","email":"[redacted/dynamic]","timeZone":"[redacted/dynamic]","language":"[redacted/dynamic]"},"attendees":[{"email":"test@example.com","name":"Test Testson","timeZone":"[redacted/dynamic]","language":"[redacted/dynamic]"}],"location":"[redacted/dynamic]","destinationCalendar":null,"hideCalendarNotes":false,"requiresConfirmation":"[redacted/dynamic]","eventTypeId":"[redacted/dynamic]","seatsShowAttendees":false,"uid":"[redacted/dynamic]","videoCallData":"[redacted/dynamic]","appsStatus":"[redacted/dynamic]","eventTitle":"30 min","eventDescription":null,"price":0,"currency":"usd","length":30,"bookingId":"[redacted/dynamic]","metadata":{"videoCallUrl":"[redacted/dynamic]"},"status":"ACCEPTED","additionalInformation":"[redacted/dynamic]"}}

View File

@ -1,15 +1,13 @@
import base64url from "base64url";
import { createHmac } from "crypto";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { symmetricEncrypt } from "@calcom/lib/crypto";
import { CallToAction, CallToActionTable, Separator } from "../components";
import { OrganizerScheduledEmail } from "./OrganizerScheduledEmail";
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 sha1 = createHmac("sha1", CALENDSO_ENCRYPTION_KEY).update(signedData).digest();
const signature = base64url(sha1);
const seedData = { bookingUid: props.calEvent.uid, userId: props.calEvent.organizer.id };
const token = symmetricEncrypt(JSON.stringify(seedData), process.env.CALENDSO_ENCRYPTION_KEY || "");
const actionHref = `${WEBAPP_URL}/api/link/?token=${token}`;
return (
<OrganizerScheduledEmail
title={
@ -24,17 +22,13 @@ export const OrganizerRequestEmail = (props: React.ComponentProps<typeof Organiz
<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)}/${signature}`}
href={`${actionHref}&action=accept`}
/>
<Separator />
<CallToAction
label={props.calEvent.organizer.language.translate("reject")}
href={`${actionHref}&action=reject`}
secondary
href={`${process.env.NEXT_PUBLIC_WEBAPP_URL}/booking/direct/reject/${encodeURIComponent(
props.attendee.email
)}/${encodeURIComponent(props.calEvent.uid as string)}/${signature}`}
/>
</CallToActionTable>
}

View File

@ -499,6 +499,7 @@ async function handler(req: NextApiRequest & { userId?: number | undefined }) {
startTime: dayjs(reqBody.start).utc().format(),
endTime: dayjs(reqBody.end).utc().format(),
organizer: {
id: organizerUser.id,
name: organizerUser.name || "Nameless",
email: organizerUser.email || "Email-less",
timeZone: organizerUser.timeZone,

View File

@ -1,446 +0,0 @@
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 shouldnt 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);
}
let videoCallUrl;
if (confirmed) {
const eventManager = new EventManager(user);
const scheduleResult = await eventManager.create(evt);
const results = scheduleResult.results;
videoCallUrl = evt.videoCallData && evt.videoCallData.url ? evt.videoCallData.url : null;
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;
videoCallUrl = metadata.hangoutLink || videoCallUrl;
}
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;
}[] = [];
const metadata = videoCallUrl ? { videoCallUrl } : undefined;
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,
metadata,
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,
metadata,
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 };
}

View File

@ -6,24 +6,34 @@ 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 { sendLocationChangeEmails, sendRequestRescheduleEmail } from "@calcom/emails";
import {
sendDeclinedEmails,
sendLocationChangeEmails,
sendRequestRescheduleEmail,
sendScheduledEmails,
} from "@calcom/emails";
import { scheduleWorkflowReminders } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler";
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
import sendPayload from "@calcom/features/webhooks/lib/sendPayload";
import sendPayload, { EventTypeInfo } 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";
@ -631,9 +641,398 @@ export const bookingsRouter = router({
}
return { message: "Location updated" };
}),
confirm: bookingsProcedure
.input(bookingConfirmPatchBodySchema)
.mutation(async ({ ctx: { user, prisma }, input }) => {
return processBookingConfirmation({ user, ...input }, prisma);
}),
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 shouldnt 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 };
}),
});

View File

@ -10237,11 +10237,6 @@ base64-url@^2.2.0:
resolved "https://registry.yarnpkg.com/base64-url/-/base64-url-2.3.3.tgz#645b71455c75109511f27d98450327e455f488ec"
integrity sha512-dLMhIsK7OplcDauDH/tZLvK7JmUZK3A7KiQpjNzsBrM6Etw7hzNI1tLEywqJk9NnwkgWuFKSlx/IUO7vF6Mo8Q==
base64url@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/base64url/-/base64url-3.0.1.tgz#6399d572e2bc3f90a9a8b22d5dbb0a32d33f788d"
integrity sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==
base@^0.11.1:
version "0.11.2"
resolved "https://registry.yarnpkg.com/base/-/base-0.11.2.tgz#7bde5ced145b6d551a90db87f83c558b4eb48a8f"