chore: booking verification token and booking rejection logic from email (#16324)

Co-authored-by: Omar López <zomars@me.com>
This commit is contained in:
Anik Dhabal Babu
2024-09-21 02:25:27 +00:00
committed by GitHub
co-authored by Omar López
parent 76a2773688
commit 1a60afad7a
19 changed files with 362 additions and 8 deletions
@@ -0,0 +1,97 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod";
import { defaultResponder } from "@calcom/lib/server";
import prisma from "@calcom/prisma";
import { UserPermissionRole } from "@calcom/prisma/enums";
import { TRPCError } from "@calcom/trpc/server";
import { createContext } from "@calcom/trpc/server/createContext";
import { bookingsRouter } from "@calcom/trpc/server/routers/viewer/bookings/_router";
import { createCallerFactory } from "@calcom/trpc/server/trpc";
import type { UserProfile } from "@calcom/types/UserProfile";
enum DirectAction {
ACCEPT = "accept",
REJECT = "reject",
}
const querySchema = z.object({
action: z.nativeEnum(DirectAction),
token: z.string(),
bookingUid: z.string(),
userId: z.string(),
});
async function handler(req: NextApiRequest, res: NextApiResponse<Response>) {
const { action, token, bookingUid, userId } = querySchema.parse(req.query);
// Rejections runs on a POST request, confirming on a GET request.
const { reason } = z.object({ reason: z.string().optional() }).parse(req.body || {});
const booking = await prisma.booking.findUnique({
where: { oneTimePassword: token },
});
if (!booking) {
res.redirect(`/booking/${bookingUid}?error=${encodeURIComponent("Error confirming booking")}`);
return;
}
const user = await prisma.user.findUniqueOrThrow({
where: { id: Number(userId) },
});
/** We shape the session as required by tRPC router */
async function sessionGetter() {
return {
user: {
id: Number(userId),
username: "" /* Not used in this context */,
role: UserPermissionRole.USER,
/* Not used in this context */
profile: {
id: null,
organizationId: null,
organization: null,
username: "",
upId: "",
} satisfies UserProfile,
},
upId: "",
hasValidLicense: true,
expires: "" /* Not used in this context */,
};
}
try {
/** @see https://trpc.io/docs/server-side-calls */
const createCaller = createCallerFactory(bookingsRouter);
const ctx = await createContext({ req, res }, sessionGetter);
const caller = createCaller({
...ctx,
req,
res,
user: { ...user, locale: user?.locale ?? "en" },
});
await caller.confirm({
bookingId: booking.id,
recurringEventId: booking.recurringEventId || undefined,
confirmed: action === DirectAction.ACCEPT,
/** Ignored reason input unless we're rejecting */
reason: action === DirectAction.REJECT ? reason : undefined,
});
} catch (e) {
let message = "Error confirming booking";
if (e instanceof TRPCError) message = (e as TRPCError).message;
res.redirect(`/booking/${booking.uid}?error=${encodeURIComponent(message)}`);
return;
}
await prisma.booking.update({
where: { id: booking.id },
data: { oneTimePassword: null },
});
res.redirect(`/booking/${booking.uid}`);
}
export default defaultResponder(handler);
@@ -0,0 +1,90 @@
export const BookingConfirmationForm = (props: { action: string; children: React.ReactNode }) => {
return (
<form action={props.action} method="POST" target="_blank">
{props.children}
<p
style={{
display: "inline-block",
background: "#FFFFFF",
border: "",
color: "#ffffff",
fontFamily: "Roboto, Helvetica, sans-serif",
fontSize: "0.875rem",
fontWeight: 500,
lineHeight: "1rem",
margin: 0,
textDecoration: "none",
textTransform: "none",
padding: "0.625rem 0",
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
msoPaddingAlt: "0px",
borderRadius: "6px",
boxSizing: "border-box",
height: "2.25rem",
width: "100%",
}}>
<label
style={{
color: "#3e3e3e",
fontFamily: "Roboto, Helvetica, sans-serif",
fontSize: "0.875rem",
fontWeight: 500,
lineHeight: "1rem",
textAlign: "left",
whiteSpace: "pre-wrap",
display: "block",
}}>
Reason for rejection &nbsp;
<small>(Optional)</small>
</label>
<textarea
name="reason"
placeholder="Why are you rejecting?"
style={{
appearance: "none",
backgroundColor: "rgb(255, 255, 255)",
borderBottomColor: "rgb(209, 213, 219)",
borderBottomLeftRadius: "6px",
borderBottomRightRadius: "6px",
borderBottomStyle: "solid",
borderBottomWidth: "1px",
borderLeftColor: "rgb(209, 213, 219)",
borderLeftStyle: "solid",
borderLeftWidth: "1px",
borderRightColor: "rgb(209, 213, 219)",
borderRightStyle: "solid",
borderRightWidth: "1px",
borderTopColor: "rgb(209, 213, 219)",
borderTopLeftRadius: "6px",
borderTopRightRadius: "6px",
borderTopStyle: "solid",
borderTopWidth: "1px",
boxSizing: "border-box",
color: "rgb(56, 66, 82)",
display: "block",
fontSize: "14px",
lineHeight: "20px",
marginBottom: "16px",
marginLeft: "0px",
marginRight: "0px",
marginTop: "8px",
paddingBottom: "8px",
paddingLeft: "12px",
paddingRight: "12px",
paddingTop: "8px",
resize: "vertical",
scrollbarColor: "auto",
scrollbarWidth: "auto",
tabSize: 4,
textAlign: "start",
visibility: "visible",
width: "100%",
maxWidth: 550,
}}
rows={3}
/>
</p>
</form>
);
};
@@ -2,7 +2,7 @@ import { CallToActionIcon } from "./CallToActionIcon";
export const CallToAction = (props: {
label: string;
href: string;
href?: string;
secondary?: boolean;
startIconName?: string;
endIconName?: string;
@@ -24,6 +24,9 @@ export const CallToAction = (props: {
return `${paddingTop} ${paddingRight} ${paddingBottom} ${paddingLeft}`;
};
const El = href ? "a" : "button";
const restProps = href ? { href, target: "_blank" } : { type: "submit" };
return (
<p
style={{
@@ -46,7 +49,8 @@ export const CallToAction = (props: {
boxSizing: "border-box",
height: "2.25rem",
}}>
<a
{/* @ts-expect-error shared props between href and button */}
<El
style={{
color: secondary ? "#292929" : "#FFFFFF",
textDecoration: "none",
@@ -54,9 +58,16 @@ export const CallToAction = (props: {
alignItems: "center",
justifyContent: "center",
margin: "auto",
appearance: "none",
background: "transparent",
border: "none",
padding: 0,
fontSize: "inherit",
fontWeight: 500,
lineHeight: "1rem",
cursor: "pointer",
}}
href={href}
target="_blank"
{...restProps}
rel="noreferrer">
{startIconName && (
<CallToActionIcon
@@ -69,7 +80,7 @@ export const CallToAction = (props: {
)}
{label}
{endIconName && <CallToActionIcon iconName={endIconName} />}
</a>
</El>
</p>
);
};
@@ -1,5 +1,7 @@
import BaseTable from "./BaseTable";
export const CallToActionTable = (props: { children: React.ReactNode }) => (
<table>
<BaseTable border="0" style={{ verticalAlign: "top" }} width="100%">
<tbody>
<tr>
<td
@@ -18,5 +20,5 @@ export const CallToActionTable = (props: { children: React.ReactNode }) => (
</td>
</tr>
</tbody>
</table>
</BaseTable>
);
+1
View File
@@ -1,4 +1,5 @@
export { BaseEmailHtml } from "./BaseEmailHtml";
export { BookingConfirmationForm } from "./BookingConfirmationForm";
export { V2BaseEmailHtml } from "./V2BaseEmailHtml";
export { CallToAction } from "./CallToAction";
export { CallToActionTable } from "./CallToActionTable";
@@ -0,0 +1,43 @@
import { WEBAPP_URL } from "@calcom/lib/constants";
import { CallToAction, Separator, CallToActionTable, BookingConfirmationForm } from "../components";
import { OrganizerScheduledEmail } from "./OrganizerScheduledEmail";
export const OrganizerRequestEmailV2 = (props: React.ComponentProps<typeof OrganizerScheduledEmail>) => {
const { uid } = props.calEvent;
const userId = props.calEvent.organizer.id;
const token = props.calEvent.oneTimePassword;
//TODO: We should switch to using org domain if available
const actionHref = `${WEBAPP_URL}/api/verify-booking-token/?token=${token}&userId=${userId}&bookingUid=${uid}`;
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={
<BookingConfirmationForm action={`${actionHref}&action=reject`}>
<CallToActionTable>
<CallToAction
label={props.calEvent.organizer.language.translate("confirm")}
href={`${actionHref}&action=accept`}
startIconName="confirmIcon"
/>
<Separator />
<CallToAction
label={props.calEvent.organizer.language.translate("reject")}
// href={`${actionHref}&action=reject`}
startIconName="rejectIcon"
secondary
/>
</CallToActionTable>
</BookingConfirmationForm>
}
{...props}
/>
);
};
+1
View File
@@ -15,6 +15,7 @@ export { OrganizerCancelledEmail } from "./OrganizerCancelledEmail";
export { OrganizerLocationChangeEmail } from "./OrganizerLocationChangeEmail";
export { OrganizerPaymentRefundFailedEmail } from "./OrganizerPaymentRefundFailedEmail";
export { OrganizerRequestEmail } from "./OrganizerRequestEmail";
export { OrganizerRequestEmailV2 } from "./OrganizerRequestEmailV2";
export { OrganizerRequestReminderEmail } from "./OrganizerRequestReminderEmail";
export { OrganizerRequestedToRescheduleEmail } from "./OrganizerRequestedToRescheduleEmail";
export { OrganizerRescheduledEmail } from "./OrganizerRescheduledEmail";
@@ -1,18 +1,35 @@
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { EMAIL_FROM_NAME } from "@calcom/lib/constants";
import { renderEmail } from "../";
import OrganizerScheduledEmail from "./organizer-scheduled-email";
/**
* TODO: Remove once fully migrated to V2
*/
async function getOrganizerRequestTemplate(args: { teamId?: number; userId?: number }) {
const featuresRepository = new FeaturesRepository();
const hasNewTemplate = await featuresRepository.checkIfTeamOrUserHasFeature(
args,
"organizer-request-email-v2"
);
return hasNewTemplate ? ("OrganizerRequestEmailV2" as const) : ("OrganizerRequestEmail" as const);
}
export default class OrganizerRequestEmail extends OrganizerScheduledEmail {
protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
const toAddresses = [this.teamMember?.email || this.calEvent.organizer.email];
const template = await getOrganizerRequestTemplate({
userId: this.calEvent.organizer.id,
teamId: this.calEvent.team?.id,
});
return {
from: `${EMAIL_FROM_NAME} <${this.getMailerOptions().from}>`,
to: toAddresses.join(","),
replyTo: [this.calEvent.organizer.email, ...this.calEvent.attendees.map(({ email }) => email)],
subject: `${this.t("awaiting_approval")}: ${this.calEvent.title}`,
html: await renderEmail("OrganizerRequestEmail", {
html: await renderEmail(template, {
calEvent: this.calEvent,
attendee: this.calEvent.organizer,
}),
@@ -906,6 +906,7 @@ async function handler(
platformRescheduleUrl,
platformCancelUrl,
platformBookingUrl,
oneTimePassword: isConfirmedByDefault ? null : undefined,
};
if (req.body.thirdPartyRecurringEventId) {
@@ -1110,6 +1111,7 @@ async function handler(
})
);
evt.uid = booking?.uid ?? null;
evt.oneTimePassword = booking?.oneTimePassword ?? null;
if (booking && booking.id && eventType.seatsPerTimeSlot) {
const currentAttendee = booking.attendees.find(
@@ -214,6 +214,7 @@ function buildNewBookingData(params: {
description: evt.seatsPerTimeSlot ? null : evt.additionalNotes,
customInputs: isPrismaObjOrUndefined(evt.customInputs),
status: isConfirmedByDefault ? BookingStatus.ACCEPTED : BookingStatus.PENDING,
oneTimePassword: evt.oneTimePassword,
location: evt.location,
eventType: eventTypeRel,
smsReminderNumber,
+1
View File
@@ -14,4 +14,5 @@ export type AppFlags = {
"google-workspace-directory": boolean;
"disable-signup": boolean;
attributes: boolean;
"organizer-request-email-v2": boolean;
};
@@ -0,0 +1,5 @@
export interface IFeaturesRepository {
checkIfUserHasFeature(userId: number, slug: string): Promise<boolean>;
checkIfTeamHasFeature(teamId: number, slug: string): Promise<boolean>;
checkIfTeamOrUserHasFeature(args: { teamId?: number; userId?: number }, slug: string): Promise<boolean>;
}
@@ -0,0 +1,59 @@
import { captureException } from "@sentry/nextjs";
import db from "@calcom/prisma";
import type { IFeaturesRepository } from "./features.repository.interface";
export class FeaturesRepository implements IFeaturesRepository {
async checkIfTeamHasFeature(teamId: number, slug: string) {
try {
const teamFeature = await db.teamFeatures.findUnique({
where: {
teamId_featureId: { teamId, featureId: slug },
},
});
return !!teamFeature;
} catch (err) {
captureException(err);
throw err;
}
}
async checkIfUserHasFeature(userId: number, slug: string) {
try {
const userFeature = await db.userFeatures.findUnique({
where: {
userId_featureId: { userId, featureId: slug },
},
});
return !!userFeature;
} catch (err) {
captureException(err);
throw err;
}
}
async checkIfTeamOrUserHasFeature(
args: {
teamId?: number;
userId?: number;
},
slug: string
) {
const { teamId, userId } = args;
try {
if (teamId) {
const teamHasFeature = await this.checkIfTeamHasFeature(teamId, slug);
if (teamHasFeature) return true;
}
if (userId) {
const userHasFeature = await this.checkIfUserHasFeature(userId, slug);
if (userHasFeature) return true;
}
return false;
} catch (err) {
captureException(err);
throw err;
}
}
}
+1
View File
@@ -73,6 +73,7 @@ export const buildBooking = (
noShowHost: null,
ratingFeedback: null,
attendees: [],
oneTimePassword: null,
...booking,
};
};
@@ -0,0 +1,11 @@
/*
Warnings:
- A unique constraint covering the columns `[oneTimePassword]` on the table `Booking` will be added. If there are existing duplicate values, this will fail.
*/
-- AlterTable
ALTER TABLE "Booking" ADD COLUMN "oneTimePassword" TEXT;
-- CreateIndex
CREATE UNIQUE INDEX "Booking_oneTimePassword_key" ON "Booking"("oneTimePassword");
@@ -0,0 +1,9 @@
INSERT INTO
"Feature" (slug, enabled, description, "type")
VALUES
(
'organizer-request-email-v2',
false,
'Enable confirmation email with built-in rejection form',
'OPERATIONAL'
) ON CONFLICT (slug) DO NOTHING;
+1
View File
@@ -612,6 +612,7 @@ model Booking {
ratingFeedback String?
noShowHost Boolean? @default(false)
scheduledTriggers WebhookScheduledTriggers[]
oneTimePassword String? @unique @default(uuid())
/// @zod.email()
cancelledBy String?
/// @zod.email()
+1
View File
@@ -13,6 +13,7 @@ export const router = tRPCContext.router;
export const mergeRouters = tRPCContext.mergeRouters;
export const middleware = tRPCContext.middleware;
export const procedure = tRPCContext.procedure;
export const createCallerFactory = tRPCContext.createCallerFactory;
export type TrpcSessionUser = UserFromSession;
+1
View File
@@ -210,6 +210,7 @@ export interface CalendarEvent {
platformRescheduleUrl?: string | null;
platformCancelUrl?: string | null;
platformBookingUrl?: string | null;
oneTimePassword?: string | null;
}
export interface EntryPoint {