diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 447e1ed7db..cae98d6257 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -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 ] } diff --git a/apps/web/components/booking/BookingListItem.tsx b/apps/web/components/booking/BookingListItem.tsx index 575371d14d..2b237f9cf1 100644 --- a/apps/web/components/booking/BookingListItem.tsx +++ b/apps/web/components/booking/BookingListItem.tsx @@ -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) { - +
- {!booking.confirmed && !booking.rejected && ( - {t("unconfirmed")} - )} + {isPending && {t("unconfirmed")}} {!!booking?.eventType?.price && !booking.paid && ( Pending payment )} @@ -351,9 +350,7 @@ function BookingListItem(booking: BookingItemProps) { {!!booking?.eventType?.price && !booking.paid && ( Pending payment )} - {!booking.confirmed && !booking.rejected && ( - {t("unconfirmed")} - )} + {isPending && {t("unconfirmed")}}
{booking.description && (
{isUpcoming && !isCancelled ? ( <> - {!booking.confirmed && !booking.rejected && user!.id === booking.user!.id && ( - - )} - {booking.confirmed && !booking.rejected && } - {!booking.confirmed && booking.rejected && ( -
{t("rejected")}
- )} + {isPending && user?.id === booking.user?.id && } + {isConfirmed && } + {isRejected &&
{t("rejected")}
} ) : null} {isCancelled && booking.rescheduled && ( diff --git a/apps/web/components/eventtype/EventTypeDescription.tsx b/apps/web/components/eventtype/EventTypeDescription.tsx index 8f0e7aab66..612be28614 100644 --- a/apps/web/components/eventtype/EventTypeDescription.tsx +++ b/apps/web/components/eventtype/EventTypeDescription.tsx @@ -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()({ - select: { - id: true, - length: true, - price: true, - currency: true, - schedulingType: true, - recurringEvent: true, - description: true, - }, + select: baseEventTypeSelect, }); type EventType = Prisma.EventTypeGetPayload; @@ -83,6 +82,12 @@ export const EventTypeDescription = ({ eventType, className }: EventTypeDescript )} + {eventType.requiresConfirmation && ( +
  • +
  • + )}
    diff --git a/apps/web/ee/pages/api/integrations/stripepayment/webhook.ts b/apps/web/ee/pages/api/integrations/stripepayment/webhook.ts index e0dc07017f..ff23521921 100644 --- a/apps/web/ee/pages/api/integrations/stripepayment/webhook.ts +++ b/apps/web/ee/pages/api/integrations/stripepayment/webhook.ts @@ -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 }; diff --git a/apps/web/lib/getBusyTimes.ts b/apps/web/lib/getBusyTimes.ts index b6e81d07ce..8b14e6582c 100644 --- a/apps/web/lib/getBusyTimes.ts +++ b/apps/web/lib/getBusyTimes.ts @@ -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, diff --git a/apps/web/lib/queries/teams/index.ts b/apps/web/lib/queries/teams/index.ts index b25869d550..65bdab5ad8 100644 --- a/apps/web/lib/queries/teams/index.ts +++ b/apps/web/lib/queries/teams/index.ts @@ -1,5 +1,7 @@ import { Prisma, UserPlan } from "@prisma/client"; +import { baseEventTypeSelect } from "@calcom/prisma"; + import prisma from "@lib/prisma"; type AsyncReturnType Promise> = T extends (...args: any) => Promise @@ -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, }, }, }); diff --git a/apps/web/lib/types/booking.ts b/apps/web/lib/types/booking.ts index 6f879767c3..81eb7fa3fb 100644 --- a/apps/web/lib/types/booking.ts +++ b/apps/web/lib/types/booking.ts @@ -1,10 +1,5 @@ import { Attendee, Booking } from "@prisma/client"; -export type BookingConfirmBody = { - confirmed: boolean; - id: number; -}; - export type BookingCreateBody = { email: string; end: string; diff --git a/apps/web/modules/common/api/defaultHandler.ts b/apps/web/modules/common/api/defaultHandler.ts new file mode 100644 index 0000000000..bd2b2c4a92 --- /dev/null +++ b/apps/web/modules/common/api/defaultHandler.ts @@ -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; diff --git a/apps/web/modules/common/api/defaultResponder.ts b/apps/web/modules/common/api/defaultResponder.ts new file mode 100644 index 0000000000..f230928d41 --- /dev/null +++ b/apps/web/modules/common/api/defaultResponder.ts @@ -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 = (req: NextApiRequest, res: NextApiResponse) => Promise; + +/** Allows us to get type inference from API handler responses */ +function defaultResponder(f: Handle) { + 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; diff --git a/apps/web/modules/common/api/index.ts b/apps/web/modules/common/api/index.ts new file mode 100644 index 0000000000..db67b1334c --- /dev/null +++ b/apps/web/modules/common/api/index.ts @@ -0,0 +1,2 @@ +export { default as defaultHandler } from "./defaultHandler"; +export { default as defaultResponder } from "./defaultResponder"; diff --git a/apps/web/modules/common/index.ts b/apps/web/modules/common/index.ts new file mode 100644 index 0000000000..d158c57640 --- /dev/null +++ b/apps/web/modules/common/index.ts @@ -0,0 +1 @@ +export * from "./api"; diff --git a/apps/web/pages/[user].tsx b/apps/web/pages/[user].tsx index 6b0f40e9fb..3572bbc1f5 100644 --- a/apps/web/pages/[user].tsx +++ b/apps/web/pages/[user].tsx @@ -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, }); diff --git a/apps/web/pages/api/book/confirm.ts b/apps/web/pages/api/book/confirm.ts index cb7b5b5c49..06c5edf34a 100644 --- a/apps/web/pages/api/book/confirm.ts +++ b/apps/web/pages/api/book/confirm.ts @@ -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, booking: Pick @@ -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 shouldn’t 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 shouldn’t 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>; + +export default defaultHandler({ + // To prevent too much git diff until moved to another file + PATCH: Promise.resolve({ default: defaultResponder(patchHandler) }), +}); diff --git a/apps/web/pages/api/book/event.ts b/apps/web/pages/api/book/event.ts index 93ae0cc319..9e5c461073 100644 --- a/apps/web/pages/api/book/event.ts +++ b/apps/web/pages/api/book/event.ts @@ -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: { diff --git a/apps/web/pages/api/cancel.ts b/apps/web/pages/api/cancel.ts index 302eb99ceb..c37cf73a23 100644 --- a/apps/web/pages/api/cancel.ts +++ b/apps/web/pages/api/cancel.ts @@ -200,7 +200,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) id: bookingToDelete.id, }, data: { - rejected: true, + status: BookingStatus.REJECTED, }, }); diff --git a/apps/web/pages/api/cron/bookingReminder.ts b/apps/web/pages/api/cron/bookingReminder.ts index cec5ac1e7e..9887b5d262 100644 --- a/apps/web/pages/api/cron/bookingReminder.ts +++ b/apps/web/pages/api/cron/bookingReminder.ts @@ -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(), }, diff --git a/apps/web/pages/api/integrations.ts b/apps/web/pages/api/integrations.ts index 0aa8e4090a..aa924b0913 100644 --- a/apps/web/pages/api/integrations.ts +++ b/apps/web/pages/api/integrations.ts @@ -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, diff --git a/apps/web/playwright/fixtures/bookings.ts b/apps/web/playwright/fixtures/bookings.ts index 86d69bdd17..72045a7b64 100644 --- a/apps/web/playwright/fixtures/bookings.ts +++ b/apps/web/playwright/fixtures/bookings.ts @@ -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 = {} + { rescheduled = false, paid = false, status = "ACCEPTED" }: Partial = {} ) => { 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, diff --git a/apps/web/server/routers/viewer.tsx b/apps/web/server/routers/viewer.tsx index 01b96d610c..50ca54b1a6 100644 --- a/apps/web/server/routers/viewer.tsx +++ b/apps/web/server/routers/viewer.tsx @@ -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()({ - 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: { diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 22b05a3665..7d0598edff 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -3,6 +3,7 @@ "compilerOptions": { "baseUrl": ".", "paths": { + "~/*": ["modules/*"], "@components/*": ["components/*"], "@lib/*": ["lib/*"], "@server/*": ["server/*"], diff --git a/packages/app-store/googlecalendar/lib/CalendarService.ts b/packages/app-store/googlecalendar/lib/CalendarService.ts index 3edd911471..5df04987b9 100644 --- a/packages/app-store/googlecalendar/lib/CalendarService.ts +++ b/packages/app-store/googlecalendar/lib/CalendarService.ts @@ -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, }, diff --git a/packages/prisma/migrations/20220604144700_fixes_booking_status/migration.sql b/packages/prisma/migrations/20220604144700_fixes_booking_status/migration.sql new file mode 100644 index 0000000000..022876e945 --- /dev/null +++ b/packages/prisma/migrations/20220604144700_fixes_booking_status/migration.sql @@ -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; diff --git a/packages/prisma/migrations/20220604210102_removes_booking_confirmed_rejected/migration.sql b/packages/prisma/migrations/20220604210102_removes_booking_confirmed_rejected/migration.sql new file mode 100644 index 0000000000..54fa28fab2 --- /dev/null +++ b/packages/prisma/migrations/20220604210102_removes_booking_confirmed_rejected/migration.sql @@ -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"; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index ff6b059480..d082e4e938 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -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[] diff --git a/packages/prisma/seed.ts b/packages/prisma/seed.ts index 1f8003bf13..c4b0f08c5e 100644 --- a/packages/prisma/seed.ts +++ b/packages/prisma/seed.ts @@ -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, }, ], }, diff --git a/packages/prisma/selects/event-types.ts b/packages/prisma/selects/event-types.ts new file mode 100644 index 0000000000..59e4d3199d --- /dev/null +++ b/packages/prisma/selects/event-types.ts @@ -0,0 +1,15 @@ +import { Prisma } from "@prisma/client"; + +export const baseEventTypeSelect = Prisma.validator()({ + id: true, + title: true, + description: true, + length: true, + schedulingType: true, + recurringEvent: true, + slug: true, + hidden: true, + price: true, + currency: true, + requiresConfirmation: true, +}); diff --git a/packages/prisma/selects/index.ts b/packages/prisma/selects/index.ts index b032fb844e..b59bed2e77 100644 --- a/packages/prisma/selects/index.ts +++ b/packages/prisma/selects/index.ts @@ -1 +1,2 @@ export * from "./booking"; +export * from "./event-types";