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
"ban.spellright", // Spell check for docs
"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 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[] = [
{
@ -205,7 +208,7 @@ function BookingListItem(booking: BookingItemProps) {
if (location.includes("integration")) {
if (booking.status === BookingStatus.CANCELLED || booking.status === BookingStatus.REJECTED) {
location = t("web_conference");
} else if (booking.confirmed) {
} else if (isConfirmed) {
location = linkValueToString(booking.location, t);
} else {
location = t("web_conferencing_details_to_follow");
@ -227,7 +230,7 @@ function BookingListItem(booking: BookingItemProps) {
eventName: booking.eventType.eventName || "",
bookingId: booking.id,
recur: booking.recurringEventId,
reschedule: booking.confirmed,
reschedule: isConfirmed,
listingStatus: booking.listingStatus,
status: booking.status,
},
@ -322,14 +325,10 @@ function BookingListItem(booking: BookingItemProps) {
</div>
</div>
</td>
<td
className={"flex-1 ltr:pl-4 rtl:pr-4" + (booking.rejected ? " line-through" : "")}
onClick={onClick}>
<td className={"flex-1 ltr:pl-4 rtl:pr-4" + (isRejected ? " line-through" : "")} onClick={onClick}>
<div className="cursor-pointer py-4">
<div className="sm:hidden">
{!booking.confirmed && !booking.rejected && (
<Tag className="mb-2 ltr:mr-2 rtl:ml-2">{t("unconfirmed")}</Tag>
)}
{isPending && <Tag className="mb-2 ltr:mr-2 rtl:ml-2">{t("unconfirmed")}</Tag>}
{!!booking?.eventType?.price && !booking.paid && (
<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 && (
<Tag className="hidden ltr:ml-2 rtl:mr-2 sm:inline-flex">Pending payment</Tag>
)}
{!booking.confirmed && !booking.rejected && (
<Tag className="hidden ltr:ml-2 rtl:mr-2 sm:inline-flex">{t("unconfirmed")}</Tag>
)}
{isPending && <Tag className="hidden ltr:ml-2 rtl:mr-2 sm:inline-flex">{t("unconfirmed")}</Tag>}
</div>
{booking.description && (
<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">
{isUpcoming && !isCancelled ? (
<>
{!booking.confirmed && !booking.rejected && user!.id === booking.user!.id && (
<TableActions actions={pendingActions} />
)}
{booking.confirmed && !booking.rejected && <TableActions actions={bookedActions} />}
{!booking.confirmed && booking.rejected && (
<div className="text-sm text-gray-500">{t("rejected")}</div>
)}
{isPending && user?.id === booking.user?.id && <TableActions actions={pendingActions} />}
{isConfirmed && <TableActions actions={bookedActions} />}
{isRejected && <div className="text-sm text-gray-500">{t("rejected")}</div>}
</>
) : null}
{isCancelled && booking.rescheduled && (

View File

@ -1,24 +1,23 @@
import { ClockIcon, CreditCardIcon, RefreshIcon, UserIcon, UsersIcon } from "@heroicons/react/solid";
import { SchedulingType } from "@prisma/client";
import { Prisma } from "@prisma/client";
import React, { useMemo } from "react";
import {
ClipboardCheckIcon,
ClockIcon,
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 { useLocale } from "@calcom/lib/hooks/useLocale";
import { baseEventTypeSelect } from "@calcom/prisma/selects";
import { RecurringEvent } from "@calcom/types/Calendar";
import classNames from "@lib/classNames";
const eventTypeData = Prisma.validator<Prisma.EventTypeArgs>()({
select: {
id: true,
length: true,
price: true,
currency: true,
schedulingType: true,
recurringEvent: true,
description: true,
},
select: baseEventTypeSelect,
});
type EventType = Prisma.EventTypeGetPayload<typeof eventTypeData>;
@ -83,6 +82,12 @@ export const EventTypeDescription = ({ eventType, className }: EventTypeDescript
</IntlProvider>
</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>
</div>
</>

View File

@ -1,4 +1,4 @@
import { Prisma } from "@prisma/client";
import { BookingStatus, Prisma } from "@prisma/client";
import { buffer } from "micro";
import type { NextApiRequest, NextApiResponse } from "next";
import Stripe from "stripe";
@ -44,13 +44,13 @@ async function handlePaymentSuccess(event: Stripe.Event) {
},
select: {
...bookingMinimalSelect,
confirmed: true,
location: true,
eventTypeId: true,
userId: true,
uid: true,
paid: true,
destinationCalendar: true,
status: true,
user: {
select: {
id: true,
@ -125,10 +125,11 @@ async function handlePaymentSuccess(event: Stripe.Event) {
const bookingData: Prisma.BookingUpdateInput = {
paid: true,
confirmed: true,
status: BookingStatus.ACCEPTED,
};
if (booking.confirmed) {
const isConfirmed = booking.status === BookingStatus.ACCEPTED;
if (isConfirmed) {
const eventManager = new EventManager(user);
const scheduleResult = await eventManager.create(evt);
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 { getBusyVideoTimes } from "@calcom/core/videoClient";
@ -19,24 +19,13 @@ async function getBusyTimes(params: {
const busyTimes: EventBusyDate[] = await prisma.booking
.findMany({
where: {
AND: [
{
userId,
eventTypeId,
startTime: { gte: new Date(startTime) },
endTime: { lte: new Date(endTime) },
},
{
OR: [
{
status: "ACCEPTED",
},
{
status: "PENDING",
},
],
},
],
userId,
eventTypeId,
startTime: { gte: new Date(startTime) },
endTime: { lte: new Date(endTime) },
status: {
in: [BookingStatus.ACCEPTED],
},
},
select: {
startTime: true,

View File

@ -1,5 +1,7 @@
import { Prisma, UserPlan } from "@prisma/client";
import { baseEventTypeSelect } from "@calcom/prisma";
import prisma from "@lib/prisma";
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,
},
select: {
id: true,
title: true,
description: true,
length: true,
slug: true,
schedulingType: true,
recurringEvent: true,
price: true,
currency: true,
users: {
select: userSelect,
},
...baseEventTypeSelect,
},
},
});

View File

@ -1,10 +1,5 @@
import { Attendee, Booking } from "@prisma/client";
export type BookingConfirmBody = {
confirmed: boolean;
id: number;
};
export type BookingCreateBody = {
email: 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,
} from "@calcom/lib/defaultEvents";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { baseEventTypeSelect } from "@calcom/prisma/selects";
import { useExposePlanGlobally } from "@lib/hooks/useExposePlanGlobally";
import useTheme from "@lib/hooks/useTheme";
@ -274,17 +275,8 @@ const getEventTypesWithHiddenFromDB = async (userId: number, plan: UserPlan) =>
},
],
select: {
id: true,
slug: true,
title: true,
length: true,
description: true,
hidden: true,
schedulingType: true,
recurringEvent: true,
price: true,
currency: true,
metadata: true,
...baseEventTypeSelect,
},
take: plan === UserPlan.FREE ? 1 : undefined,
});

View File

@ -1,20 +1,22 @@
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 { isPrismaObjOrUndefined } from "@calcom/lib";
import logger from "@calcom/lib/logger";
import prisma from "@calcom/prisma";
import type { AdditionInformation, CalendarEvent, RecurringEvent } from "@calcom/types/Calendar";
import { refund } from "@ee/lib/stripe/server";
import { asStringOrNull } from "@lib/asStringOrNull";
import { getSession } from "@lib/auth";
import { HttpError } from "@lib/core/http/error";
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 { defaultHandler, defaultResponder } from "~/common";
const authorized = async (
currentUser: Pick<User, "id">,
booking: Pick<Booking, "eventTypeId" | "userId">
@ -43,20 +45,30 @@ const authorized = async (
const log = logger.getChildLogger({ prefix: ["[api] book:user"] });
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const session = await getSession({ req: req });
const bookingConfirmPatchBodySchema = z.object({
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) {
return res.status(401).json({ message: "Not authenticated" });
throw new HttpError({ statusCode: 401, message: "Not authenticated" });
}
const reqBody = req.body as BookingConfirmBody;
const bookingId = reqBody.id;
if (!bookingId) {
return res.status(400).json({ message: "bookingId missing" });
}
const {
id: bookingId,
recurringEventId,
reason: rejectionReason,
confirmed,
} = bookingConfirmPatchBodySchema.parse(req.body);
const currentUser = await prisma.user.findFirst({
rejectOnNotFound() {
throw new HttpError({ statusCode: 404, message: "User not found" });
},
where: {
id: session.user.id,
},
@ -74,230 +86,228 @@ 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");
if (req.method === "PATCH") {
const booking = await prisma.booking.findFirst({
const booking = await prisma.booking.findFirst({
where: {
id: bookingId,
},
rejectOnNotFound() {
throw new HttpError({ statusCode: 404, message: "Booking not found" });
},
select: {
title: true,
description: true,
customInputs: true,
startTime: true,
endTime: true,
attendees: true,
eventTypeId: true,
eventType: {
select: {
recurringEvent: true,
},
},
location: true,
userId: true,
id: true,
uid: true,
payment: true,
destinationCalendar: true,
paid: true,
recurringEventId: true,
status: true,
},
});
if (!(await authorized(currentUser, booking))) {
throw new HttpError({ statusCode: 401, message: "UNAUTHORIZED" });
}
const isConfirmed = booking.status === BookingStatus.ACCEPTED;
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,
* we shouldnt save it on DestinationCalendars
*/
if (booking.payment.length > 0 && !booking.paid) {
await prisma.booking.update({
where: {
id: bookingId,
},
select: {
title: true,
description: true,
customInputs: true,
startTime: true,
endTime: true,
confirmed: true,
attendees: true,
eventTypeId: true,
eventType: {
select: {
recurringEvent: true,
},
},
location: true,
userId: true,
id: true,
uid: true,
payment: true,
destinationCalendar: true,
paid: true,
recurringEventId: true,
data: {
status: BookingStatus.ACCEPTED,
},
});
if (!booking) {
return res.status(404).json({ message: "booking not found" });
}
req.statusCode = 204;
return { message: "Booking confirmed" };
}
if (!(await authorized(currentUser, booking))) {
return res.status(401).end();
}
if (booking.confirmed) {
return res.status(400).json({ message: "booking already confirmed" });
}
/** When a booking that requires payment its being confirmed but doesn't have any payment,
* we shouldnt save it on DestinationCalendars
*/
if (booking.payment.length > 0 && !booking.paid) {
await prisma.booking.update({
where: {
id: bookingId,
},
data: {
confirmed: true,
},
});
return res.status(204).end();
}
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.title,
title: booking.title,
description: booking.description,
customInputs: isPrismaObjOrUndefined(booking.customInputs),
startTime: booking.startTime.toISOString(),
endTime: booking.endTime.toISOString(),
organizer: {
email: currentUser.email,
name: currentUser.name || "Unnamed",
timeZone: currentUser.timeZone,
language: { translate: tOrganizer, locale: currentUser.locale ?? "en" },
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",
},
attendees: attendeesList,
location: booking.location ?? "",
uid: booking.uid,
destinationCalendar: booking?.destinationCalendar || currentUser.destinationCalendar,
};
});
const recurringEvent = booking.eventType?.recurringEvent as RecurringEvent;
const attendeesList = await Promise.all(attendeesListPromises);
if (req.body.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;
const evt: CalendarEvent = {
type: booking.title,
title: booking.title,
description: booking.description,
customInputs: isPrismaObjOrUndefined(booking.customInputs),
startTime: booking.startTime.toISOString(),
endTime: booking.endTime.toISOString(),
organizer: {
email: currentUser.email,
name: currentUser.name || "Unnamed",
timeZone: currentUser.timeZone,
language: { translate: tOrganizer, locale: currentUser.locale ?? "en" },
},
attendees: attendeesList,
location: booking.location ?? "",
uid: booking.uid,
destinationCalendar: booking?.destinationCalendar || currentUser.destinationCalendar,
};
const recurringEvent = booking.eventType?.recurringEvent as 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;
}
if (confirmed) {
const eventManager = new EventManager(currentUser);
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 ${currentUser.username} failed`, error, results);
} else {
const metadata: AdditionInformation = {};
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, additionInformation: metadata },
recurringEventId ? recurringEvent : {} // Send email with recurring event info only on recurring event context
);
} catch (error) {
log.error(error);
}
}
if (reqBody.confirmed) {
const eventManager = new EventManager(currentUser);
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 ${currentUser.username} failed`, error, results);
} else {
const metadata: AdditionInformation = {};
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, additionInformation: metadata },
req.body.recurringEventId ? recurringEvent : {} // Send email with recurring event info only on recurring event context
);
} catch (error) {
log.error(error);
}
}
if (req.body.recurringEventId) {
// 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.
const unconfirmedRecurringBookings = await prisma.booking.findMany({
where: {
recurringEventId: req.body.recurringEventId,
confirmed: false,
},
});
unconfirmedRecurringBookings.map(async (recurringBooking) => {
await prisma.booking.update({
where: {
id: recurringBooking.id,
},
data: {
confirmed: true,
references: {
create: scheduleResult.referencesToCreate,
},
},
});
});
} 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
if (recurringEventId) {
// 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.
const unconfirmedRecurringBookings = await prisma.booking.findMany({
where: {
recurringEventId,
status: BookingStatus.PENDING,
},
});
unconfirmedRecurringBookings.map(async (recurringBooking) => {
await prisma.booking.update({
where: {
id: bookingId,
id: recurringBooking.id,
},
data: {
confirmed: true,
status: BookingStatus.ACCEPTED,
references: {
create: scheduleResult.referencesToCreate,
},
},
});
}
res.status(204).end();
});
} else {
const rejectionReason = asStringOrNull(req.body.reason) || "";
evt.rejectionReason = rejectionReason;
if (req.body.recurringEventId) {
// 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.
const unconfirmedRecurringBookings = await prisma.booking.findMany({
where: {
recurringEventId: req.body.recurringEventId,
confirmed: false,
// @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
await prisma.booking.update({
where: {
id: bookingId,
},
data: {
status: BookingStatus.ACCEPTED,
references: {
create: scheduleResult.referencesToCreate,
},
});
unconfirmedRecurringBookings.map(async (recurringBooking) => {
await prisma.booking.update({
where: {
id: recurringBooking.id,
},
data: {
rejected: true,
status: BookingStatus.REJECTED,
rejectionReason: rejectionReason,
},
});
});
} else {
await refund(booking, evt); // No payment integration for recurring events for v1
},
});
}
} 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. 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,
},
});
unconfirmedRecurringBookings.map(async (recurringBooking) => {
await prisma.booking.update({
where: {
id: bookingId,
id: recurringBooking.id,
},
data: {
rejected: true,
status: BookingStatus.REJECTED,
rejectionReason: rejectionReason,
rejectionReason,
},
});
}
await sendDeclinedEmails(evt, req.body.recurringEventId ? recurringEvent : {}); // Send email with recurring event info only on recurring event context
res.status(204).end();
});
} 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, recurringEventId ? recurringEvent : {}); // Send email with recurring event info only on recurring event context
}
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 dynamicGroupSlugRef = !eventTypeId ? (reqBody.user as string).toLowerCase() : null;
const isConfirmedByDefault = (!eventType.requiresConfirmation && !eventType.price) || !!rescheduleUid;
const newBookingData: Prisma.BookingCreateInput = {
uid,
title: evt.title,
@ -485,7 +486,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
endTime: dayjs(evt.endTime).toDate(),
description: evt.additionalNotes,
customInputs: isPrismaObjOrUndefined(evt.customInputs),
confirmed: (!eventType.requiresConfirmation && !eventType.price) || !!rescheduleUid,
status: isConfirmedByDefault ? BookingStatus.ACCEPTED : BookingStatus.PENDING,
location: evt.location,
eventType: eventTypeRel,
attendees: {

View File

@ -200,7 +200,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
id: bookingToDelete.id,
},
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 type { NextApiRequest, NextApiResponse } from "next";
@ -26,8 +26,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
for (const interval of reminderIntervalMinutes) {
const bookings = await prisma.booking.findMany({
where: {
confirmed: false,
rejected: false,
status: BookingStatus.PENDING,
createdAt: {
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 { getSession } from "@lib/auth";
@ -105,7 +105,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
},
},
data: {
status: "CANCELLED",
status: BookingStatus.CANCELLED,
rejectionReason: "Payment provider got removed",
},
});
@ -113,8 +113,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const bookingReferences = await prisma.booking
.findMany({
where: {
confirmed: true,
rejected: false,
status: BookingStatus.ACCEPTED,
},
select: {
id: true,

View File

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

View File

@ -7,7 +7,7 @@ import { z } from "zod";
import getApps, { getLocationOptions } from "@calcom/app-store/utils";
import { getCalendarCredentials, getConnectedCalendars } from "@calcom/core/CalendarManager";
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 { checkRegularUsername } from "@lib/core/checkRegularUsername";
@ -131,16 +131,6 @@ const loggedInViewerRouter = createProtectedRouter()
async resolve({ ctx }) {
const { prisma } = ctx;
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,
successRedirectUrl: true,
hashedLink: true,
@ -151,6 +141,7 @@ const loggedInViewerRouter = createProtectedRouter()
name: true,
},
},
...baseEventTypeSelect,
});
const user = await prisma.user.findUnique({
@ -328,7 +319,7 @@ const loggedInViewerRouter = createProtectedRouter()
// handled separately for each occurrence
OR: [
{
AND: [{ NOT: { recurringEventId: { equals: null } } }, { confirmed: false }],
AND: [{ NOT: { recurringEventId: { equals: null } } }, { status: BookingStatus.PENDING }],
},
{
AND: [
@ -398,8 +389,6 @@ const loggedInViewerRouter = createProtectedRouter()
select: {
...bookingMinimalSelect,
uid: true,
confirmed: true,
rejected: true,
recurringEventId: true,
location: true,
eventType: {

View File

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

View File

@ -14,7 +14,6 @@ import type {
IntegrationCalendar,
NewCalendarEventType,
} from "@calcom/types/Calendar";
import type { PartialReference } from "@calcom/types/EventManager";
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
@ -103,11 +102,8 @@ export default class GoogleCalendarService implements Calendar {
timeZone: calEventRaw.organizer.timeZone,
},
attendees: [
{ ...calEventRaw.organizer, organizer: true },
...calEventRaw.attendees.map((attendee) => ({
...attendee,
responseStatus: "accepted",
})),
{ ...calEventRaw.organizer, organizer: true, responseStatus: "accepted" },
...calEventRaw.attendees.map((attendee) => ({ ...attendee, responseStatus: "accepted" })),
],
reminders: {
useDefault: true,
@ -185,7 +181,7 @@ export default class GoogleCalendarService implements Calendar {
dateTime: event.endTime,
timeZone: event.organizer.timeZone,
},
attendees: event.attendees,
attendees: [{ ...event.organizer, organizer: true, responseStatus: "accepted" }, ...event.attendees],
reminders: {
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?
createdAt DateTime @default(now())
updatedAt DateTime?
confirmed Boolean @default(true)
rejected Boolean @default(false)
status BookingStatus @default(ACCEPTED)
paid Boolean @default(false)
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 { uuid } from "short-uuid";
@ -110,7 +110,7 @@ async function createUserAndEventType(opts: {
id,
},
},
confirmed: bookingInput.confirmed,
status: bookingInput.status,
},
});
console.log(
@ -238,7 +238,7 @@ async function main() {
title: "30min",
startTime: dayjs().add(2, "day").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"),
startTime: dayjs().add(1, "day").toDate(),
endTime: dayjs().add(1, "day").add(30, "minutes").toDate(),
confirmed: false,
status: BookingStatus.PENDING,
},
{
uid: uuid(),
@ -297,7 +297,7 @@ async function main() {
recurringEventId: Buffer.from("yoga-class").toString("base64"),
startTime: dayjs().add(1, "day").add(1, "week").toDate(),
endTime: dayjs().add(1, "day").add(1, "week").add(30, "minutes").toDate(),
confirmed: false,
status: BookingStatus.PENDING,
},
{
uid: uuid(),
@ -305,7 +305,7 @@ async function main() {
recurringEventId: Buffer.from("yoga-class").toString("base64"),
startTime: dayjs().add(1, "day").add(2, "week").toDate(),
endTime: dayjs().add(1, "day").add(2, "week").add(30, "minutes").toDate(),
confirmed: false,
status: BookingStatus.PENDING,
},
{
uid: uuid(),
@ -313,7 +313,7 @@ async function main() {
recurringEventId: Buffer.from("yoga-class").toString("base64"),
startTime: dayjs().add(1, "day").add(3, "week").toDate(),
endTime: dayjs().add(1, "day").add(3, "week").add(30, "minutes").toDate(),
confirmed: false,
status: BookingStatus.PENDING,
},
{
uid: uuid(),
@ -321,7 +321,7 @@ async function main() {
recurringEventId: Buffer.from("yoga-class").toString("base64"),
startTime: dayjs().add(1, "day").add(4, "week").toDate(),
endTime: dayjs().add(1, "day").add(4, "week").add(30, "minutes").toDate(),
confirmed: false,
status: BookingStatus.PENDING,
},
{
uid: uuid(),
@ -329,7 +329,7 @@ async function main() {
recurringEventId: Buffer.from("yoga-class").toString("base64"),
startTime: dayjs().add(1, "day").add(5, "week").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"),
startTime: dayjs().add(2, "day").toDate(),
endTime: dayjs().add(2, "day").add(60, "minutes").toDate(),
confirmed: false,
status: BookingStatus.PENDING,
},
{
uid: uuid(),
@ -354,7 +354,7 @@ async function main() {
recurringEventId: Buffer.from("tennis-class").toString("base64"),
startTime: dayjs().add(2, "day").add(2, "week").toDate(),
endTime: dayjs().add(2, "day").add(2, "week").add(60, "minutes").toDate(),
confirmed: false,
status: BookingStatus.PENDING,
},
{
uid: uuid(),
@ -362,7 +362,7 @@ async function main() {
recurringEventId: Buffer.from("tennis-class").toString("base64"),
startTime: dayjs().add(2, "day").add(4, "week").toDate(),
endTime: dayjs().add(2, "day").add(4, "week").add(60, "minutes").toDate(),
confirmed: false,
status: BookingStatus.PENDING,
},
{
uid: uuid(),
@ -370,7 +370,7 @@ async function main() {
recurringEventId: Buffer.from("tennis-class").toString("base64"),
startTime: dayjs().add(2, "day").add(8, "week").toDate(),
endTime: dayjs().add(2, "day").add(8, "week").add(60, "minutes").toDate(),
confirmed: false,
status: BookingStatus.PENDING,
},
{
uid: uuid(),
@ -378,7 +378,7 @@ async function main() {
recurringEventId: Buffer.from("tennis-class").toString("base64"),
startTime: dayjs().add(2, "day").add(10, "week").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 "./event-types";