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:
parent
dcc226976c
commit
23450b61e0
|
@ -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") {
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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);
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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]"}}
|
|
@ -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>
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 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);
|
||||
}
|
||||
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 };
|
||||
}
|
|
@ -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 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 };
|
||||
}),
|
||||
});
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in New Issue
Block a user