Booking confirm endpoint refactoring (#2949)

* Adds new default handler and responder

* Moved confirm endpoint

* Fixes availability for unconfirmed bookings

* Cleanup

* Update _patch.ts

* Prevent too much diffs

* Adds missing BookingStatus

* Migrates confirmed & rejected to status

* Adds requiresConfirmation icon to listing

* Adds booking status migration

* Adds migrations to remove confirmed/rejected

* Undo refactor

* Sets the organizer as "accepted" in gCal

* Update getBusyTimes.ts

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
This commit is contained in:
Omar López 2022-06-06 10:54:47 -06:00 committed by GitHub
parent 3b321e5d3c
commit 12d66cb9df
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 393 additions and 336 deletions

View File

@ -7,6 +7,7 @@
"bradlc.vscode-tailwindcss", // hinting / autocompletion for tailwind "bradlc.vscode-tailwindcss", // hinting / autocompletion for tailwind
"ban.spellright", // Spell check for docs "ban.spellright", // Spell check for docs
"stripe.vscode-stripe", // stripe VSCode extension "stripe.vscode-stripe", // stripe VSCode extension
"Prisma.prisma" // syntax|format|completion for prisma "Prisma.prisma", // syntax|format|completion for prisma
"rebornix.project-snippets" // Share useful snippets between collaborators
] ]
} }

View File

@ -87,6 +87,9 @@ function BookingListItem(booking: BookingItemProps) {
); );
const isUpcoming = new Date(booking.endTime) >= new Date(); const isUpcoming = new Date(booking.endTime) >= new Date();
const isCancelled = booking.status === BookingStatus.CANCELLED; const isCancelled = booking.status === BookingStatus.CANCELLED;
const isConfirmed = booking.status === BookingStatus.ACCEPTED;
const isRejected = booking.status === BookingStatus.REJECTED;
const isPending = booking.status === BookingStatus.PENDING;
const pendingActions: ActionType[] = [ const pendingActions: ActionType[] = [
{ {
@ -205,7 +208,7 @@ function BookingListItem(booking: BookingItemProps) {
if (location.includes("integration")) { if (location.includes("integration")) {
if (booking.status === BookingStatus.CANCELLED || booking.status === BookingStatus.REJECTED) { if (booking.status === BookingStatus.CANCELLED || booking.status === BookingStatus.REJECTED) {
location = t("web_conference"); location = t("web_conference");
} else if (booking.confirmed) { } else if (isConfirmed) {
location = linkValueToString(booking.location, t); location = linkValueToString(booking.location, t);
} else { } else {
location = t("web_conferencing_details_to_follow"); location = t("web_conferencing_details_to_follow");
@ -227,7 +230,7 @@ function BookingListItem(booking: BookingItemProps) {
eventName: booking.eventType.eventName || "", eventName: booking.eventType.eventName || "",
bookingId: booking.id, bookingId: booking.id,
recur: booking.recurringEventId, recur: booking.recurringEventId,
reschedule: booking.confirmed, reschedule: isConfirmed,
listingStatus: booking.listingStatus, listingStatus: booking.listingStatus,
status: booking.status, status: booking.status,
}, },
@ -322,14 +325,10 @@ function BookingListItem(booking: BookingItemProps) {
</div> </div>
</div> </div>
</td> </td>
<td <td className={"flex-1 ltr:pl-4 rtl:pr-4" + (isRejected ? " line-through" : "")} onClick={onClick}>
className={"flex-1 ltr:pl-4 rtl:pr-4" + (booking.rejected ? " line-through" : "")}
onClick={onClick}>
<div className="cursor-pointer py-4"> <div className="cursor-pointer py-4">
<div className="sm:hidden"> <div className="sm:hidden">
{!booking.confirmed && !booking.rejected && ( {isPending && <Tag className="mb-2 ltr:mr-2 rtl:ml-2">{t("unconfirmed")}</Tag>}
<Tag className="mb-2 ltr:mr-2 rtl:ml-2">{t("unconfirmed")}</Tag>
)}
{!!booking?.eventType?.price && !booking.paid && ( {!!booking?.eventType?.price && !booking.paid && (
<Tag className="mb-2 ltr:mr-2 rtl:ml-2">Pending payment</Tag> <Tag className="mb-2 ltr:mr-2 rtl:ml-2">Pending payment</Tag>
)} )}
@ -351,9 +350,7 @@ function BookingListItem(booking: BookingItemProps) {
{!!booking?.eventType?.price && !booking.paid && ( {!!booking?.eventType?.price && !booking.paid && (
<Tag className="hidden ltr:ml-2 rtl:mr-2 sm:inline-flex">Pending payment</Tag> <Tag className="hidden ltr:ml-2 rtl:mr-2 sm:inline-flex">Pending payment</Tag>
)} )}
{!booking.confirmed && !booking.rejected && ( {isPending && <Tag className="hidden ltr:ml-2 rtl:mr-2 sm:inline-flex">{t("unconfirmed")}</Tag>}
<Tag className="hidden ltr:ml-2 rtl:mr-2 sm:inline-flex">{t("unconfirmed")}</Tag>
)}
</div> </div>
{booking.description && ( {booking.description && (
<div <div
@ -382,13 +379,9 @@ function BookingListItem(booking: BookingItemProps) {
<td className="whitespace-nowrap py-4 text-right text-sm font-medium ltr:pr-4 rtl:pl-4"> <td className="whitespace-nowrap py-4 text-right text-sm font-medium ltr:pr-4 rtl:pl-4">
{isUpcoming && !isCancelled ? ( {isUpcoming && !isCancelled ? (
<> <>
{!booking.confirmed && !booking.rejected && user!.id === booking.user!.id && ( {isPending && user?.id === booking.user?.id && <TableActions actions={pendingActions} />}
<TableActions actions={pendingActions} /> {isConfirmed && <TableActions actions={bookedActions} />}
)} {isRejected && <div className="text-sm text-gray-500">{t("rejected")}</div>}
{booking.confirmed && !booking.rejected && <TableActions actions={bookedActions} />}
{!booking.confirmed && booking.rejected && (
<div className="text-sm text-gray-500">{t("rejected")}</div>
)}
</> </>
) : null} ) : null}
{isCancelled && booking.rescheduled && ( {isCancelled && booking.rescheduled && (

View File

@ -1,24 +1,23 @@
import { ClockIcon, CreditCardIcon, RefreshIcon, UserIcon, UsersIcon } from "@heroicons/react/solid"; import {
import { SchedulingType } from "@prisma/client"; ClipboardCheckIcon,
import { Prisma } from "@prisma/client"; ClockIcon,
import React, { useMemo } from "react"; CreditCardIcon,
RefreshIcon,
UserIcon,
UsersIcon,
} from "@heroicons/react/solid";
import { Prisma, SchedulingType } from "@prisma/client";
import { useMemo } from "react";
import { FormattedNumber, IntlProvider } from "react-intl"; import { FormattedNumber, IntlProvider } from "react-intl";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
import { baseEventTypeSelect } from "@calcom/prisma/selects";
import { RecurringEvent } from "@calcom/types/Calendar"; import { RecurringEvent } from "@calcom/types/Calendar";
import classNames from "@lib/classNames"; import classNames from "@lib/classNames";
const eventTypeData = Prisma.validator<Prisma.EventTypeArgs>()({ const eventTypeData = Prisma.validator<Prisma.EventTypeArgs>()({
select: { select: baseEventTypeSelect,
id: true,
length: true,
price: true,
currency: true,
schedulingType: true,
recurringEvent: true,
description: true,
},
}); });
type EventType = Prisma.EventTypeGetPayload<typeof eventTypeData>; type EventType = Prisma.EventTypeGetPayload<typeof eventTypeData>;
@ -83,6 +82,12 @@ export const EventTypeDescription = ({ eventType, className }: EventTypeDescript
</IntlProvider> </IntlProvider>
</li> </li>
)} )}
{eventType.requiresConfirmation && (
<li className="mr-4 flex items-center whitespace-nowrap">
<ClipboardCheckIcon className="mr-1.5 inline h-4 w-4 text-neutral-400" aria-hidden="true" />
Opt-in
</li>
)}
</ul> </ul>
</div> </div>
</> </>

View File

@ -1,4 +1,4 @@
import { Prisma } from "@prisma/client"; import { BookingStatus, Prisma } from "@prisma/client";
import { buffer } from "micro"; import { buffer } from "micro";
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import Stripe from "stripe"; import Stripe from "stripe";
@ -44,13 +44,13 @@ async function handlePaymentSuccess(event: Stripe.Event) {
}, },
select: { select: {
...bookingMinimalSelect, ...bookingMinimalSelect,
confirmed: true,
location: true, location: true,
eventTypeId: true, eventTypeId: true,
userId: true, userId: true,
uid: true, uid: true,
paid: true, paid: true,
destinationCalendar: true, destinationCalendar: true,
status: true,
user: { user: {
select: { select: {
id: true, id: true,
@ -125,10 +125,11 @@ async function handlePaymentSuccess(event: Stripe.Event) {
const bookingData: Prisma.BookingUpdateInput = { const bookingData: Prisma.BookingUpdateInput = {
paid: true, paid: true,
confirmed: true, status: BookingStatus.ACCEPTED,
}; };
if (booking.confirmed) { const isConfirmed = booking.status === BookingStatus.ACCEPTED;
if (isConfirmed) {
const eventManager = new EventManager(user); const eventManager = new EventManager(user);
const scheduleResult = await eventManager.create(evt); const scheduleResult = await eventManager.create(evt);
bookingData.references = { create: scheduleResult.referencesToCreate }; bookingData.references = { create: scheduleResult.referencesToCreate };

View File

@ -1,4 +1,4 @@
import { Credential, SelectedCalendar } from "@prisma/client"; import { BookingStatus, Credential, SelectedCalendar } from "@prisma/client";
import { getBusyCalendarTimes } from "@calcom/core/CalendarManager"; import { getBusyCalendarTimes } from "@calcom/core/CalendarManager";
import { getBusyVideoTimes } from "@calcom/core/videoClient"; import { getBusyVideoTimes } from "@calcom/core/videoClient";
@ -19,24 +19,13 @@ async function getBusyTimes(params: {
const busyTimes: EventBusyDate[] = await prisma.booking const busyTimes: EventBusyDate[] = await prisma.booking
.findMany({ .findMany({
where: { where: {
AND: [
{
userId, userId,
eventTypeId, eventTypeId,
startTime: { gte: new Date(startTime) }, startTime: { gte: new Date(startTime) },
endTime: { lte: new Date(endTime) }, endTime: { lte: new Date(endTime) },
status: {
in: [BookingStatus.ACCEPTED],
}, },
{
OR: [
{
status: "ACCEPTED",
},
{
status: "PENDING",
},
],
},
],
}, },
select: { select: {
startTime: true, startTime: true,

View File

@ -1,5 +1,7 @@
import { Prisma, UserPlan } from "@prisma/client"; import { Prisma, UserPlan } from "@prisma/client";
import { baseEventTypeSelect } from "@calcom/prisma";
import prisma from "@lib/prisma"; import prisma from "@lib/prisma";
type AsyncReturnType<T extends (...args: any) => Promise<any>> = T extends (...args: any) => Promise<infer R> type AsyncReturnType<T extends (...args: any) => Promise<any>> = T extends (...args: any) => Promise<infer R>
@ -37,18 +39,10 @@ export async function getTeamWithMembers(id?: number, slug?: string) {
hidden: false, hidden: false,
}, },
select: { select: {
id: true,
title: true,
description: true,
length: true,
slug: true,
schedulingType: true,
recurringEvent: true,
price: true,
currency: true,
users: { users: {
select: userSelect, select: userSelect,
}, },
...baseEventTypeSelect,
}, },
}, },
}); });

View File

@ -1,10 +1,5 @@
import { Attendee, Booking } from "@prisma/client"; import { Attendee, Booking } from "@prisma/client";
export type BookingConfirmBody = {
confirmed: boolean;
id: number;
};
export type BookingCreateBody = { export type BookingCreateBody = {
email: string; email: string;
end: string; end: string;

View File

@ -0,0 +1,22 @@
import type { NextApiHandler, NextApiRequest, NextApiResponse } from "next";
type Handlers = {
[method in "GET" | "POST" | "PATCH" | "PUT" | "DELETE"]?: Promise<{ default: NextApiHandler }>;
};
/** Allows us to split big API handlers by method and auto catch unsupported methods */
const defaultHandler = (handlers: Handlers) => async (req: NextApiRequest, res: NextApiResponse) => {
const handler = (await handlers[req.method as keyof typeof handlers])?.default;
if (!handler) return res.status(405).json({ message: "Method not allowed" });
try {
await handler(req, res);
return;
} catch (error) {
console.error(error);
return res.status(500).json({ message: "Something went wrong" });
}
};
export default defaultHandler;

View File

@ -0,0 +1,38 @@
import type { NextApiRequest, NextApiResponse } from "next";
import Stripe from "stripe";
import { ZodError } from "zod";
import { HttpError } from "@calcom/lib/http-error";
type Handle<T> = (req: NextApiRequest, res: NextApiResponse) => Promise<T>;
/** Allows us to get type inference from API handler responses */
function defaultResponder<T>(f: Handle<T>) {
return async (req: NextApiRequest, res: NextApiResponse) => {
try {
const result = await f(req, res);
res.json(result);
} catch (err) {
if (err instanceof HttpError) {
res.statusCode = err.statusCode;
res.json({ message: err.message });
} else if (err instanceof Stripe.errors.StripeInvalidRequestError) {
console.error("err", err);
res.statusCode = err.statusCode || 500;
res.json({ message: "Stripe error: " + err.message });
} else if (err instanceof ZodError && err.name === "ZodError") {
console.error("err", JSON.parse(err.message)[0].message);
res.statusCode = 400;
res.json({
message: "Validation errors: " + err.issues.map((i) => `—'${i.path}' ${i.message}`).join(". "),
});
} else {
console.error("err", err);
res.statusCode = 500;
res.json({ message: "Unknown error" });
}
}
};
}
export default defaultResponder;

View File

@ -0,0 +1,2 @@
export { default as defaultHandler } from "./defaultHandler";
export { default as defaultResponder } from "./defaultResponder";

View File

@ -0,0 +1 @@
export * from "./api";

View File

@ -23,6 +23,7 @@ import defaultEvents, {
getUsernameSlugLink, getUsernameSlugLink,
} from "@calcom/lib/defaultEvents"; } from "@calcom/lib/defaultEvents";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
import { baseEventTypeSelect } from "@calcom/prisma/selects";
import { useExposePlanGlobally } from "@lib/hooks/useExposePlanGlobally"; import { useExposePlanGlobally } from "@lib/hooks/useExposePlanGlobally";
import useTheme from "@lib/hooks/useTheme"; import useTheme from "@lib/hooks/useTheme";
@ -274,17 +275,8 @@ const getEventTypesWithHiddenFromDB = async (userId: number, plan: UserPlan) =>
}, },
], ],
select: { select: {
id: true,
slug: true,
title: true,
length: true,
description: true,
hidden: true,
schedulingType: true,
recurringEvent: true,
price: true,
currency: true,
metadata: true, metadata: true,
...baseEventTypeSelect,
}, },
take: plan === UserPlan.FREE ? 1 : undefined, take: plan === UserPlan.FREE ? 1 : undefined,
}); });

View File

@ -1,20 +1,22 @@
import { Booking, BookingStatus, Prisma, SchedulingType, User } from "@prisma/client"; import { Booking, BookingStatus, Prisma, SchedulingType, User } from "@prisma/client";
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest } from "next";
import { z } from "zod";
import EventManager from "@calcom/core/EventManager"; import EventManager from "@calcom/core/EventManager";
import { isPrismaObjOrUndefined } from "@calcom/lib"; import { isPrismaObjOrUndefined } from "@calcom/lib";
import logger from "@calcom/lib/logger"; import logger from "@calcom/lib/logger";
import prisma from "@calcom/prisma";
import type { AdditionInformation, CalendarEvent, RecurringEvent } from "@calcom/types/Calendar"; import type { AdditionInformation, CalendarEvent, RecurringEvent } from "@calcom/types/Calendar";
import { refund } from "@ee/lib/stripe/server"; import { refund } from "@ee/lib/stripe/server";
import { asStringOrNull } from "@lib/asStringOrNull";
import { getSession } from "@lib/auth"; import { getSession } from "@lib/auth";
import { HttpError } from "@lib/core/http/error";
import { sendDeclinedEmails, sendScheduledEmails } from "@lib/emails/email-manager"; import { sendDeclinedEmails, sendScheduledEmails } from "@lib/emails/email-manager";
import prisma from "@lib/prisma";
import { BookingConfirmBody } from "@lib/types/booking";
import { getTranslation } from "@server/lib/i18n"; import { getTranslation } from "@server/lib/i18n";
import { defaultHandler, defaultResponder } from "~/common";
const authorized = async ( const authorized = async (
currentUser: Pick<User, "id">, currentUser: Pick<User, "id">,
booking: Pick<Booking, "eventTypeId" | "userId"> booking: Pick<Booking, "eventTypeId" | "userId">
@ -43,20 +45,30 @@ const authorized = async (
const log = logger.getChildLogger({ prefix: ["[api] book:user"] }); const log = logger.getChildLogger({ prefix: ["[api] book:user"] });
export default async function handler(req: NextApiRequest, res: NextApiResponse) { const bookingConfirmPatchBodySchema = z.object({
const session = await getSession({ req: req }); confirmed: z.boolean(),
id: z.number(),
recurringEventId: z.string().optional(),
reason: z.string().optional(),
});
async function patchHandler(req: NextApiRequest) {
const session = await getSession({ req });
if (!session?.user?.id) { if (!session?.user?.id) {
return res.status(401).json({ message: "Not authenticated" }); throw new HttpError({ statusCode: 401, message: "Not authenticated" });
} }
const reqBody = req.body as BookingConfirmBody; const {
const bookingId = reqBody.id; id: bookingId,
recurringEventId,
if (!bookingId) { reason: rejectionReason,
return res.status(400).json({ message: "bookingId missing" }); confirmed,
} } = bookingConfirmPatchBodySchema.parse(req.body);
const currentUser = await prisma.user.findFirst({ const currentUser = await prisma.user.findFirst({
rejectOnNotFound() {
throw new HttpError({ statusCode: 404, message: "User not found" });
},
where: { where: {
id: session.user.id, id: session.user.id,
}, },
@ -74,24 +86,21 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}, },
}); });
if (!currentUser) {
return res.status(404).json({ message: "User not found" });
}
const tOrganizer = await getTranslation(currentUser.locale ?? "en", "common"); const tOrganizer = await getTranslation(currentUser.locale ?? "en", "common");
if (req.method === "PATCH") {
const booking = await prisma.booking.findFirst({ const booking = await prisma.booking.findFirst({
where: { where: {
id: bookingId, id: bookingId,
}, },
rejectOnNotFound() {
throw new HttpError({ statusCode: 404, message: "Booking not found" });
},
select: { select: {
title: true, title: true,
description: true, description: true,
customInputs: true, customInputs: true,
startTime: true, startTime: true,
endTime: true, endTime: true,
confirmed: true,
attendees: true, attendees: true,
eventTypeId: true, eventTypeId: true,
eventType: { eventType: {
@ -107,19 +116,17 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
destinationCalendar: true, destinationCalendar: true,
paid: true, paid: true,
recurringEventId: true, recurringEventId: true,
status: true,
}, },
}); });
if (!booking) {
return res.status(404).json({ message: "booking not found" });
}
if (!(await authorized(currentUser, booking))) { if (!(await authorized(currentUser, booking))) {
return res.status(401).end(); throw new HttpError({ statusCode: 401, message: "UNAUTHORIZED" });
} }
if (booking.confirmed) { const isConfirmed = booking.status === BookingStatus.ACCEPTED;
return res.status(400).json({ message: "booking already confirmed" }); if (isConfirmed) {
throw new HttpError({ statusCode: 400, message: "booking already confirmed" });
} }
/** When a booking that requires payment its being confirmed but doesn't have any payment, /** When a booking that requires payment its being confirmed but doesn't have any payment,
@ -131,11 +138,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
id: bookingId, id: bookingId,
}, },
data: { data: {
confirmed: true, status: BookingStatus.ACCEPTED,
}, },
}); });
return res.status(204).end(); req.statusCode = 204;
return { message: "Booking confirmed" };
} }
const attendeesListPromises = booking.attendees.map(async (attendee) => { const attendeesListPromises = booking.attendees.map(async (attendee) => {
@ -173,7 +181,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const recurringEvent = booking.eventType?.recurringEvent as RecurringEvent; const recurringEvent = booking.eventType?.recurringEvent as RecurringEvent;
if (req.body.recurringEventId && recurringEvent) { if (recurringEventId && recurringEvent) {
const groupedRecurringBookings = await prisma.booking.groupBy({ const groupedRecurringBookings = await prisma.booking.groupBy({
where: { where: {
recurringEventId: booking.recurringEventId, recurringEventId: booking.recurringEventId,
@ -186,7 +194,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
recurringEvent.count = groupedRecurringBookings[0]._count; recurringEvent.count = groupedRecurringBookings[0]._count;
} }
if (reqBody.confirmed) { if (confirmed) {
const eventManager = new EventManager(currentUser); const eventManager = new EventManager(currentUser);
const scheduleResult = await eventManager.create(evt); const scheduleResult = await eventManager.create(evt);
@ -211,20 +219,20 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
try { try {
await sendScheduledEmails( await sendScheduledEmails(
{ ...evt, additionInformation: metadata }, { ...evt, additionInformation: metadata },
req.body.recurringEventId ? recurringEvent : {} // Send email with recurring event info only on recurring event context recurringEventId ? recurringEvent : {} // Send email with recurring event info only on recurring event context
); );
} catch (error) { } catch (error) {
log.error(error); log.error(error);
} }
} }
if (req.body.recurringEventId) { if (recurringEventId) {
// The booking to confirm is a recurring event and comes from /booking/upcoming, proceeding to mark all related // The booking to confirm is a recurring event and comes from /booking/upcoming, proceeding to mark all related
// bookings as confirmed. Prisma updateMany does not support relations, so doing this in two steps for now. // bookings as confirmed. Prisma updateMany does not support relations, so doing this in two steps for now.
const unconfirmedRecurringBookings = await prisma.booking.findMany({ const unconfirmedRecurringBookings = await prisma.booking.findMany({
where: { where: {
recurringEventId: req.body.recurringEventId, recurringEventId,
confirmed: false, status: BookingStatus.PENDING,
}, },
}); });
unconfirmedRecurringBookings.map(async (recurringBooking) => { unconfirmedRecurringBookings.map(async (recurringBooking) => {
@ -233,7 +241,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
id: recurringBooking.id, id: recurringBooking.id,
}, },
data: { data: {
confirmed: true, status: BookingStatus.ACCEPTED,
references: { references: {
create: scheduleResult.referencesToCreate, create: scheduleResult.referencesToCreate,
}, },
@ -248,25 +256,22 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
id: bookingId, id: bookingId,
}, },
data: { data: {
confirmed: true, status: BookingStatus.ACCEPTED,
references: { references: {
create: scheduleResult.referencesToCreate, create: scheduleResult.referencesToCreate,
}, },
}, },
}); });
} }
res.status(204).end();
} else { } else {
const rejectionReason = asStringOrNull(req.body.reason) || "";
evt.rejectionReason = rejectionReason; evt.rejectionReason = rejectionReason;
if (req.body.recurringEventId) { if (recurringEventId) {
// The booking to reject is a recurring event and comes from /booking/upcoming, proceeding to mark all related // The booking to reject is a recurring event and comes from /booking/upcoming, proceeding to mark all related
// bookings as rejected. Prisma updateMany does not support relations, so doing this in two steps for now. // bookings as rejected. Prisma updateMany does not support relations, so doing this in two steps for now.
const unconfirmedRecurringBookings = await prisma.booking.findMany({ const unconfirmedRecurringBookings = await prisma.booking.findMany({
where: { where: {
recurringEventId: req.body.recurringEventId, recurringEventId,
confirmed: false, status: BookingStatus.PENDING,
}, },
}); });
unconfirmedRecurringBookings.map(async (recurringBooking) => { unconfirmedRecurringBookings.map(async (recurringBooking) => {
@ -275,9 +280,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
id: recurringBooking.id, id: recurringBooking.id,
}, },
data: { data: {
rejected: true,
status: BookingStatus.REJECTED, status: BookingStatus.REJECTED,
rejectionReason: rejectionReason, rejectionReason,
}, },
}); });
}); });
@ -288,16 +292,22 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
id: bookingId, id: bookingId,
}, },
data: { data: {
rejected: true,
status: BookingStatus.REJECTED, status: BookingStatus.REJECTED,
rejectionReason: rejectionReason, rejectionReason,
}, },
}); });
} }
await sendDeclinedEmails(evt, req.body.recurringEventId ? recurringEvent : {}); // Send email with recurring event info only on recurring event context await sendDeclinedEmails(evt, recurringEventId ? recurringEvent : {}); // Send email with recurring event info only on recurring event context
}
res.status(204).end(); req.statusCode = 204;
} return { message: "Booking " + confirmed ? "confirmed" : "rejected" };
}
} }
export type BookConfirmPatchResponse = Awaited<ReturnType<typeof patchHandler>>;
export default defaultHandler({
// To prevent too much git diff until moved to another file
PATCH: Promise.resolve({ default: defaultResponder(patchHandler) }),
});

View File

@ -478,6 +478,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const dynamicEventSlugRef = !eventTypeId ? eventTypeSlug : null; const dynamicEventSlugRef = !eventTypeId ? eventTypeSlug : null;
const dynamicGroupSlugRef = !eventTypeId ? (reqBody.user as string).toLowerCase() : null; const dynamicGroupSlugRef = !eventTypeId ? (reqBody.user as string).toLowerCase() : null;
const isConfirmedByDefault = (!eventType.requiresConfirmation && !eventType.price) || !!rescheduleUid;
const newBookingData: Prisma.BookingCreateInput = { const newBookingData: Prisma.BookingCreateInput = {
uid, uid,
title: evt.title, title: evt.title,
@ -485,7 +486,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
endTime: dayjs(evt.endTime).toDate(), endTime: dayjs(evt.endTime).toDate(),
description: evt.additionalNotes, description: evt.additionalNotes,
customInputs: isPrismaObjOrUndefined(evt.customInputs), customInputs: isPrismaObjOrUndefined(evt.customInputs),
confirmed: (!eventType.requiresConfirmation && !eventType.price) || !!rescheduleUid, status: isConfirmedByDefault ? BookingStatus.ACCEPTED : BookingStatus.PENDING,
location: evt.location, location: evt.location,
eventType: eventTypeRel, eventType: eventTypeRel,
attendees: { attendees: {

View File

@ -200,7 +200,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
id: bookingToDelete.id, id: bookingToDelete.id,
}, },
data: { data: {
rejected: true, status: BookingStatus.REJECTED,
}, },
}); });

View File

@ -1,4 +1,4 @@
import { ReminderType } from "@prisma/client"; import { BookingStatus, ReminderType } from "@prisma/client";
import dayjs from "dayjs"; import dayjs from "dayjs";
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
@ -26,8 +26,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
for (const interval of reminderIntervalMinutes) { for (const interval of reminderIntervalMinutes) {
const bookings = await prisma.booking.findMany({ const bookings = await prisma.booking.findMany({
where: { where: {
confirmed: false, status: BookingStatus.PENDING,
rejected: false,
createdAt: { createdAt: {
lte: dayjs().add(-interval, "minutes").toDate(), lte: dayjs().add(-interval, "minutes").toDate(),
}, },

View File

@ -1,4 +1,4 @@
import { Prisma } from "@prisma/client"; import { BookingStatus, Prisma } from "@prisma/client";
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import { getSession } from "@lib/auth"; import { getSession } from "@lib/auth";
@ -105,7 +105,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}, },
}, },
data: { data: {
status: "CANCELLED", status: BookingStatus.CANCELLED,
rejectionReason: "Payment provider got removed", rejectionReason: "Payment provider got removed",
}, },
}); });
@ -113,8 +113,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const bookingReferences = await prisma.booking const bookingReferences = await prisma.booking
.findMany({ .findMany({
where: { where: {
confirmed: true, status: BookingStatus.ACCEPTED,
rejected: false,
}, },
select: { select: {
id: true, id: true,

View File

@ -21,12 +21,7 @@ export const createBookingsFixture = (page: Page) => {
userId: number, userId: number,
username: string | null, username: string | null,
eventTypeId = -1, eventTypeId = -1,
{ { rescheduled = false, paid = false, status = "ACCEPTED" }: Partial<Prisma.BookingCreateInput> = {}
confirmed = true,
rescheduled = false,
paid = false,
status = "ACCEPTED",
}: Partial<Prisma.BookingCreateInput> = {}
) => { ) => {
const startDate = dayjs().add(1, "day").toDate(); const startDate = dayjs().add(1, "day").toDate();
const seed = `${username}:${dayjs(startDate).utc().format()}:${new Date().getTime()}`; const seed = `${username}:${dayjs(startDate).utc().format()}:${new Date().getTime()}`;
@ -54,7 +49,6 @@ export const createBookingsFixture = (page: Page) => {
id: eventTypeId, id: eventTypeId,
}, },
}, },
confirmed,
rescheduled, rescheduled,
paid, paid,
status, status,

View File

@ -7,7 +7,7 @@ import { z } from "zod";
import getApps, { getLocationOptions } from "@calcom/app-store/utils"; import getApps, { getLocationOptions } from "@calcom/app-store/utils";
import { getCalendarCredentials, getConnectedCalendars } from "@calcom/core/CalendarManager"; import { getCalendarCredentials, getConnectedCalendars } from "@calcom/core/CalendarManager";
import { checkPremiumUsername } from "@calcom/ee/lib/core/checkPremiumUsername"; import { checkPremiumUsername } from "@calcom/ee/lib/core/checkPremiumUsername";
import { bookingMinimalSelect } from "@calcom/prisma"; import { baseEventTypeSelect, bookingMinimalSelect } from "@calcom/prisma";
import { RecurringEvent } from "@calcom/types/Calendar"; import { RecurringEvent } from "@calcom/types/Calendar";
import { checkRegularUsername } from "@lib/core/checkRegularUsername"; import { checkRegularUsername } from "@lib/core/checkRegularUsername";
@ -131,16 +131,6 @@ const loggedInViewerRouter = createProtectedRouter()
async resolve({ ctx }) { async resolve({ ctx }) {
const { prisma } = ctx; const { prisma } = ctx;
const eventTypeSelect = Prisma.validator<Prisma.EventTypeSelect>()({ const eventTypeSelect = Prisma.validator<Prisma.EventTypeSelect>()({
id: true,
title: true,
description: true,
length: true,
schedulingType: true,
recurringEvent: true,
slug: true,
hidden: true,
price: true,
currency: true,
position: true, position: true,
successRedirectUrl: true, successRedirectUrl: true,
hashedLink: true, hashedLink: true,
@ -151,6 +141,7 @@ const loggedInViewerRouter = createProtectedRouter()
name: true, name: true,
}, },
}, },
...baseEventTypeSelect,
}); });
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
@ -328,7 +319,7 @@ const loggedInViewerRouter = createProtectedRouter()
// handled separately for each occurrence // handled separately for each occurrence
OR: [ OR: [
{ {
AND: [{ NOT: { recurringEventId: { equals: null } } }, { confirmed: false }], AND: [{ NOT: { recurringEventId: { equals: null } } }, { status: BookingStatus.PENDING }],
}, },
{ {
AND: [ AND: [
@ -398,8 +389,6 @@ const loggedInViewerRouter = createProtectedRouter()
select: { select: {
...bookingMinimalSelect, ...bookingMinimalSelect,
uid: true, uid: true,
confirmed: true,
rejected: true,
recurringEventId: true, recurringEventId: true,
location: true, location: true,
eventType: { eventType: {

View File

@ -3,6 +3,7 @@
"compilerOptions": { "compilerOptions": {
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"~/*": ["modules/*"],
"@components/*": ["components/*"], "@components/*": ["components/*"],
"@lib/*": ["lib/*"], "@lib/*": ["lib/*"],
"@server/*": ["server/*"], "@server/*": ["server/*"],

View File

@ -14,7 +14,6 @@ import type {
IntegrationCalendar, IntegrationCalendar,
NewCalendarEventType, NewCalendarEventType,
} from "@calcom/types/Calendar"; } from "@calcom/types/Calendar";
import type { PartialReference } from "@calcom/types/EventManager";
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug"; import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
@ -103,11 +102,8 @@ export default class GoogleCalendarService implements Calendar {
timeZone: calEventRaw.organizer.timeZone, timeZone: calEventRaw.organizer.timeZone,
}, },
attendees: [ attendees: [
{ ...calEventRaw.organizer, organizer: true }, { ...calEventRaw.organizer, organizer: true, responseStatus: "accepted" },
...calEventRaw.attendees.map((attendee) => ({ ...calEventRaw.attendees.map((attendee) => ({ ...attendee, responseStatus: "accepted" })),
...attendee,
responseStatus: "accepted",
})),
], ],
reminders: { reminders: {
useDefault: true, useDefault: true,
@ -185,7 +181,7 @@ export default class GoogleCalendarService implements Calendar {
dateTime: event.endTime, dateTime: event.endTime,
timeZone: event.organizer.timeZone, timeZone: event.organizer.timeZone,
}, },
attendees: event.attendees, attendees: [{ ...event.organizer, organizer: true, responseStatus: "accepted" }, ...event.attendees],
reminders: { reminders: {
useDefault: true, useDefault: true,
}, },

View File

@ -0,0 +1,11 @@
-- Set BookingStatus.PENDING
UPDATE "Booking" SET "status" = 'pending' WHERE "confirmed" = false AND "rejected" = false AND "rescheduled" IS NOT true;
-- Set BookingStatus.REJECTED
UPDATE "Booking" SET "status" = 'rejected' WHERE "confirmed" = false AND "rejected" = true AND "rescheduled" IS NOT true;
-- Set BookingStatus.CANCELLED
UPDATE "Booking" SET "status" = 'cancelled' WHERE "confirmed" = false AND "rejected" = false AND "rescheduled" IS true;
-- Set BookingStatus.ACCEPTED
UPDATE "Booking" SET "status" = 'accepted' WHERE "confirmed" = true AND "rejected" = false AND "rescheduled" IS NOT true;

View File

@ -0,0 +1,10 @@
/*
Warnings:
- You are about to drop the column `confirmed` on the `Booking` table. All the data in the column will be lost.
- You are about to drop the column `rejected` on the `Booking` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "Booking" DROP COLUMN "confirmed",
DROP COLUMN "rejected";

View File

@ -273,8 +273,6 @@ model Booking {
dailyRef DailyEventReference? dailyRef DailyEventReference?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime? updatedAt DateTime?
confirmed Boolean @default(true)
rejected Boolean @default(false)
status BookingStatus @default(ACCEPTED) status BookingStatus @default(ACCEPTED)
paid Boolean @default(false) paid Boolean @default(false)
payment Payment[] payment Payment[]

View File

@ -1,4 +1,4 @@
import { MembershipRole, Prisma, UserPlan } from "@prisma/client"; import { BookingStatus, MembershipRole, Prisma, UserPlan } from "@prisma/client";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { uuid } from "short-uuid"; import { uuid } from "short-uuid";
@ -110,7 +110,7 @@ async function createUserAndEventType(opts: {
id, id,
}, },
}, },
confirmed: bookingInput.confirmed, status: bookingInput.status,
}, },
}); });
console.log( console.log(
@ -238,7 +238,7 @@ async function main() {
title: "30min", title: "30min",
startTime: dayjs().add(2, "day").toDate(), startTime: dayjs().add(2, "day").toDate(),
endTime: dayjs().add(2, "day").add(30, "minutes").toDate(), endTime: dayjs().add(2, "day").add(30, "minutes").toDate(),
confirmed: false, status: BookingStatus.PENDING,
}, },
], ],
}, },
@ -289,7 +289,7 @@ async function main() {
recurringEventId: Buffer.from("yoga-class").toString("base64"), recurringEventId: Buffer.from("yoga-class").toString("base64"),
startTime: dayjs().add(1, "day").toDate(), startTime: dayjs().add(1, "day").toDate(),
endTime: dayjs().add(1, "day").add(30, "minutes").toDate(), endTime: dayjs().add(1, "day").add(30, "minutes").toDate(),
confirmed: false, status: BookingStatus.PENDING,
}, },
{ {
uid: uuid(), uid: uuid(),
@ -297,7 +297,7 @@ async function main() {
recurringEventId: Buffer.from("yoga-class").toString("base64"), recurringEventId: Buffer.from("yoga-class").toString("base64"),
startTime: dayjs().add(1, "day").add(1, "week").toDate(), startTime: dayjs().add(1, "day").add(1, "week").toDate(),
endTime: dayjs().add(1, "day").add(1, "week").add(30, "minutes").toDate(), endTime: dayjs().add(1, "day").add(1, "week").add(30, "minutes").toDate(),
confirmed: false, status: BookingStatus.PENDING,
}, },
{ {
uid: uuid(), uid: uuid(),
@ -305,7 +305,7 @@ async function main() {
recurringEventId: Buffer.from("yoga-class").toString("base64"), recurringEventId: Buffer.from("yoga-class").toString("base64"),
startTime: dayjs().add(1, "day").add(2, "week").toDate(), startTime: dayjs().add(1, "day").add(2, "week").toDate(),
endTime: dayjs().add(1, "day").add(2, "week").add(30, "minutes").toDate(), endTime: dayjs().add(1, "day").add(2, "week").add(30, "minutes").toDate(),
confirmed: false, status: BookingStatus.PENDING,
}, },
{ {
uid: uuid(), uid: uuid(),
@ -313,7 +313,7 @@ async function main() {
recurringEventId: Buffer.from("yoga-class").toString("base64"), recurringEventId: Buffer.from("yoga-class").toString("base64"),
startTime: dayjs().add(1, "day").add(3, "week").toDate(), startTime: dayjs().add(1, "day").add(3, "week").toDate(),
endTime: dayjs().add(1, "day").add(3, "week").add(30, "minutes").toDate(), endTime: dayjs().add(1, "day").add(3, "week").add(30, "minutes").toDate(),
confirmed: false, status: BookingStatus.PENDING,
}, },
{ {
uid: uuid(), uid: uuid(),
@ -321,7 +321,7 @@ async function main() {
recurringEventId: Buffer.from("yoga-class").toString("base64"), recurringEventId: Buffer.from("yoga-class").toString("base64"),
startTime: dayjs().add(1, "day").add(4, "week").toDate(), startTime: dayjs().add(1, "day").add(4, "week").toDate(),
endTime: dayjs().add(1, "day").add(4, "week").add(30, "minutes").toDate(), endTime: dayjs().add(1, "day").add(4, "week").add(30, "minutes").toDate(),
confirmed: false, status: BookingStatus.PENDING,
}, },
{ {
uid: uuid(), uid: uuid(),
@ -329,7 +329,7 @@ async function main() {
recurringEventId: Buffer.from("yoga-class").toString("base64"), recurringEventId: Buffer.from("yoga-class").toString("base64"),
startTime: dayjs().add(1, "day").add(5, "week").toDate(), startTime: dayjs().add(1, "day").add(5, "week").toDate(),
endTime: dayjs().add(1, "day").add(5, "week").add(30, "minutes").toDate(), endTime: dayjs().add(1, "day").add(5, "week").add(30, "minutes").toDate(),
confirmed: false, status: BookingStatus.PENDING,
}, },
], ],
}, },
@ -346,7 +346,7 @@ async function main() {
recurringEventId: Buffer.from("tennis-class").toString("base64"), recurringEventId: Buffer.from("tennis-class").toString("base64"),
startTime: dayjs().add(2, "day").toDate(), startTime: dayjs().add(2, "day").toDate(),
endTime: dayjs().add(2, "day").add(60, "minutes").toDate(), endTime: dayjs().add(2, "day").add(60, "minutes").toDate(),
confirmed: false, status: BookingStatus.PENDING,
}, },
{ {
uid: uuid(), uid: uuid(),
@ -354,7 +354,7 @@ async function main() {
recurringEventId: Buffer.from("tennis-class").toString("base64"), recurringEventId: Buffer.from("tennis-class").toString("base64"),
startTime: dayjs().add(2, "day").add(2, "week").toDate(), startTime: dayjs().add(2, "day").add(2, "week").toDate(),
endTime: dayjs().add(2, "day").add(2, "week").add(60, "minutes").toDate(), endTime: dayjs().add(2, "day").add(2, "week").add(60, "minutes").toDate(),
confirmed: false, status: BookingStatus.PENDING,
}, },
{ {
uid: uuid(), uid: uuid(),
@ -362,7 +362,7 @@ async function main() {
recurringEventId: Buffer.from("tennis-class").toString("base64"), recurringEventId: Buffer.from("tennis-class").toString("base64"),
startTime: dayjs().add(2, "day").add(4, "week").toDate(), startTime: dayjs().add(2, "day").add(4, "week").toDate(),
endTime: dayjs().add(2, "day").add(4, "week").add(60, "minutes").toDate(), endTime: dayjs().add(2, "day").add(4, "week").add(60, "minutes").toDate(),
confirmed: false, status: BookingStatus.PENDING,
}, },
{ {
uid: uuid(), uid: uuid(),
@ -370,7 +370,7 @@ async function main() {
recurringEventId: Buffer.from("tennis-class").toString("base64"), recurringEventId: Buffer.from("tennis-class").toString("base64"),
startTime: dayjs().add(2, "day").add(8, "week").toDate(), startTime: dayjs().add(2, "day").add(8, "week").toDate(),
endTime: dayjs().add(2, "day").add(8, "week").add(60, "minutes").toDate(), endTime: dayjs().add(2, "day").add(8, "week").add(60, "minutes").toDate(),
confirmed: false, status: BookingStatus.PENDING,
}, },
{ {
uid: uuid(), uid: uuid(),
@ -378,7 +378,7 @@ async function main() {
recurringEventId: Buffer.from("tennis-class").toString("base64"), recurringEventId: Buffer.from("tennis-class").toString("base64"),
startTime: dayjs().add(2, "day").add(10, "week").toDate(), startTime: dayjs().add(2, "day").add(10, "week").toDate(),
endTime: dayjs().add(2, "day").add(10, "week").add(60, "minutes").toDate(), endTime: dayjs().add(2, "day").add(10, "week").add(60, "minutes").toDate(),
confirmed: false, status: BookingStatus.PENDING,
}, },
], ],
}, },

View File

@ -0,0 +1,15 @@
import { Prisma } from "@prisma/client";
export const baseEventTypeSelect = Prisma.validator<Prisma.EventTypeSelect>()({
id: true,
title: true,
description: true,
length: true,
schedulingType: true,
recurringEvent: true,
slug: true,
hidden: true,
price: true,
currency: true,
requiresConfirmation: true,
});

View File

@ -1 +1,2 @@
export * from "./booking"; export * from "./booking";
export * from "./event-types";