Skip to content

Commit

Permalink
chore: booking verification token and booking rejection logic from em…
Browse files Browse the repository at this point in the history
…ail (#16324)

Co-authored-by: Omar López <zomars@me.com>
  • Loading branch information
anikdhabal and zomars authored Sep 21, 2024
1 parent 76a2773 commit 1a60afa
Show file tree
Hide file tree
Showing 19 changed files with 362 additions and 8 deletions.
97 changes: 97 additions & 0 deletions apps/web/pages/api/verify-booking-token.ts
Original file line number Diff line number Diff line change
@@ -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);
90 changes: 90 additions & 0 deletions packages/emails/src/components/BookingConfirmationForm.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
};
21 changes: 16 additions & 5 deletions packages/emails/src/components/CallToAction.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { CallToActionIcon } from "./CallToActionIcon";

export const CallToAction = (props: {
label: string;
href: string;
href?: string;
secondary?: boolean;
startIconName?: string;
endIconName?: string;
Expand All @@ -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={{
Expand All @@ -46,17 +49,25 @@ 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",
display: "flex",
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
Expand All @@ -69,7 +80,7 @@ export const CallToAction = (props: {
)}
{label}
{endIconName && <CallToActionIcon iconName={endIconName} />}
</a>
</El>
</p>
);
};
6 changes: 4 additions & 2 deletions packages/emails/src/components/CallToActionTable.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -18,5 +20,5 @@ export const CallToActionTable = (props: { children: React.ReactNode }) => (
</td>
</tr>
</tbody>
</table>
</BaseTable>
);
1 change: 1 addition & 0 deletions packages/emails/src/components/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export { BaseEmailHtml } from "./BaseEmailHtml";
export { BookingConfirmationForm } from "./BookingConfirmationForm";
export { V2BaseEmailHtml } from "./V2BaseEmailHtml";
export { CallToAction } from "./CallToAction";
export { CallToActionTable } from "./CallToActionTable";
Expand Down
43 changes: 43 additions & 0 deletions packages/emails/src/templates/OrganizerRequestEmailV2.tsx
Original file line number Diff line number Diff line change
@@ -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 change: 1 addition & 0 deletions packages/emails/src/templates/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
19 changes: 18 additions & 1 deletion packages/emails/templates/organizer-request-email.ts
Original file line number Diff line number Diff line change
@@ -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,
}),
Expand Down
2 changes: 2 additions & 0 deletions packages/features/bookings/lib/handleNewBooking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -906,6 +906,7 @@ async function handler(
platformRescheduleUrl,
platformCancelUrl,
platformBookingUrl,
oneTimePassword: isConfirmedByDefault ? null : undefined,
};

if (req.body.thirdPartyRecurringEventId) {
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/features/flags/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,5 @@ export type AppFlags = {
"google-workspace-directory": boolean;
"disable-signup": boolean;
attributes: boolean;
"organizer-request-email-v2": boolean;
};
5 changes: 5 additions & 0 deletions packages/features/flags/features.repository.interface.ts
Original file line number Diff line number Diff line change
@@ -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>;
}
Loading

0 comments on commit 1a60afa

Please sign in to comment.