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:
co-authored by
Omar López
parent
76a2773688
commit
1a60afad7a
@@ -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
|
||||
<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,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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
+9
@@ -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;
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Vendored
+1
@@ -210,6 +210,7 @@ export interface CalendarEvent {
|
||||
platformRescheduleUrl?: string | null;
|
||||
platformCancelUrl?: string | null;
|
||||
platformBookingUrl?: string | null;
|
||||
oneTimePassword?: string | null;
|
||||
}
|
||||
|
||||
export interface EntryPoint {
|
||||
|
||||
Reference in New Issue
Block a user