Merge branch 'main' into fix/after-meeting-ends-migration
This commit is contained in:
commit
c114fa8837
|
@ -277,179 +277,6 @@ paths:
|
|||
type: string
|
||||
integration:
|
||||
type: string
|
||||
/api/availability/day:
|
||||
patch:
|
||||
description: Updates the start and end times for a user's availability.
|
||||
summary: Updates the user's start and end times
|
||||
tags:
|
||||
- Availability
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
startMins:
|
||||
type: string
|
||||
endMins:
|
||||
type: string
|
||||
bufferMins:
|
||||
type: string
|
||||
description: ""
|
||||
/api/availability/eventtype:
|
||||
post:
|
||||
description: Adds a new event type for the user.
|
||||
summary: Adds a new event type
|
||||
tags:
|
||||
- Availability
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
title:
|
||||
type: string
|
||||
slug:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
length:
|
||||
type: string
|
||||
hidden:
|
||||
type: boolean
|
||||
requiresConfirmation:
|
||||
type: boolean
|
||||
locations:
|
||||
type: array
|
||||
items: {}
|
||||
eventName:
|
||||
type: string
|
||||
customInputs:
|
||||
type: array
|
||||
items: {}
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
eventType:
|
||||
type: object
|
||||
properties:
|
||||
title:
|
||||
type: string
|
||||
slug:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
length:
|
||||
type: string
|
||||
hidden:
|
||||
type: boolean
|
||||
requiresConfirmation:
|
||||
type: boolean
|
||||
locations:
|
||||
type: array
|
||||
items: {}
|
||||
eventName:
|
||||
type: string
|
||||
customInputs:
|
||||
type: array
|
||||
items: {}
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
patch:
|
||||
description: Updates an event type for the user.
|
||||
summary: Updates an event type
|
||||
tags:
|
||||
- Availability
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
title:
|
||||
type: string
|
||||
slug:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
length:
|
||||
type: string
|
||||
hidden:
|
||||
type: boolean
|
||||
requiresConfirmation:
|
||||
type: boolean
|
||||
locations:
|
||||
type: array
|
||||
items: {}
|
||||
eventName:
|
||||
type: string
|
||||
customInputs:
|
||||
type: array
|
||||
items: {}
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
eventType:
|
||||
type: object
|
||||
properties:
|
||||
title:
|
||||
type: string
|
||||
slug:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
length:
|
||||
type: string
|
||||
hidden:
|
||||
type: boolean
|
||||
requiresConfirmation:
|
||||
type: boolean
|
||||
locations:
|
||||
type: array
|
||||
items: {}
|
||||
eventName:
|
||||
type: string
|
||||
customInputs:
|
||||
type: array
|
||||
items: {}
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
delete:
|
||||
description: Deletes an event type for the user.
|
||||
summary: Deletes an event type
|
||||
tags:
|
||||
- Availability
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties: {}
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
"/api/book/event":
|
||||
post:
|
||||
description: Creates a booking in the user's calendar.
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import { BookingStatus } from "@prisma/client";
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
import { useMutation } from "react-query";
|
||||
|
||||
import { EventLocationType, getEventLocationType } from "@calcom/app-store/locations";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
|
@ -16,7 +15,6 @@ import { Icon } from "@calcom/ui/Icon";
|
|||
import { Tooltip } from "@calcom/ui/Tooltip";
|
||||
import { TextArea } from "@calcom/ui/form/fields";
|
||||
|
||||
import { HttpError } from "@lib/core/http/error";
|
||||
import useMeQuery from "@lib/hooks/useMeQuery";
|
||||
import { extractRecurringDates } from "@lib/parseDate";
|
||||
|
||||
|
@ -42,39 +40,29 @@ function BookingListItem(booking: BookingItemProps) {
|
|||
const router = useRouter();
|
||||
const [rejectionReason, setRejectionReason] = useState<string>("");
|
||||
const [rejectionDialogIsOpen, setRejectionDialogIsOpen] = useState(false);
|
||||
const mutation = useMutation(
|
||||
async (confirm: boolean) => {
|
||||
let body = {
|
||||
id: booking.id,
|
||||
confirmed: confirm,
|
||||
language: i18n.language,
|
||||
reason: rejectionReason,
|
||||
};
|
||||
/**
|
||||
* Only pass down the recurring event id when we need to confirm the entire series, which happens in
|
||||
* the "Recurring" tab, to support confirming discretionally in the "Recurring" tab.
|
||||
*/
|
||||
if (booking.listingStatus === "recurring" && booking.recurringEventId !== null) {
|
||||
body = Object.assign({}, body, { recurringEventId: booking.recurringEventId });
|
||||
}
|
||||
const res = await fetch("/api/book/confirm", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(body),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new HttpError({ statusCode: res.status });
|
||||
}
|
||||
const mutation = trpc.useMutation(["viewer.bookings.confirm"], {
|
||||
onSuccess: () => {
|
||||
setRejectionDialogIsOpen(false);
|
||||
utils.invalidateQueries("viewer.bookings");
|
||||
},
|
||||
{
|
||||
async onSettled() {
|
||||
await utils.invalidateQueries(["viewer.bookings"]);
|
||||
},
|
||||
});
|
||||
|
||||
const bookingConfirm = async (confirm: boolean) => {
|
||||
let body = {
|
||||
bookingId: booking.id,
|
||||
confirmed: confirm,
|
||||
reason: rejectionReason,
|
||||
};
|
||||
/**
|
||||
* Only pass down the recurring event id when we need to confirm the entire series, which happens in
|
||||
* the "Recurring" tab, to support confirming discretionally in the "Recurring" tab.
|
||||
*/
|
||||
if (booking.listingStatus === "recurring" && booking.recurringEventId !== null) {
|
||||
body = Object.assign({}, body, { recurringEventId: booking.recurringEventId });
|
||||
}
|
||||
);
|
||||
mutation.mutate(body);
|
||||
};
|
||||
|
||||
const isUpcoming = new Date(booking.endTime) >= new Date();
|
||||
const isCancelled = booking.status === BookingStatus.CANCELLED;
|
||||
const isConfirmed = booking.status === BookingStatus.ACCEPTED;
|
||||
|
@ -101,7 +89,7 @@ function BookingListItem(booking: BookingItemProps) {
|
|||
? t("confirm_all")
|
||||
: t("confirm"),
|
||||
onClick: () => {
|
||||
mutation.mutate(true);
|
||||
bookingConfirm(true);
|
||||
},
|
||||
icon: Icon.FiCheck,
|
||||
disabled: mutation.isLoading,
|
||||
|
@ -269,7 +257,7 @@ function BookingListItem(booking: BookingItemProps) {
|
|||
<Button
|
||||
disabled={mutation.isLoading}
|
||||
onClick={() => {
|
||||
mutation.mutate(false);
|
||||
bookingConfirm(false);
|
||||
}}>
|
||||
{t("rejection_confirmation")}
|
||||
</Button>
|
||||
|
|
|
@ -8,6 +8,7 @@ export type GetSubscriberOptions = {
|
|||
triggerEvent: WebhookTriggerEvents;
|
||||
};
|
||||
|
||||
/** @deprecated use `packages/lib/webhooks/subscriptions.tsx` */
|
||||
const getWebhooks = async (options: GetSubscriberOptions) => {
|
||||
const { userId, eventTypeId } = options;
|
||||
const allWebhooks = await prisma.webhook.findMany({
|
||||
|
|
|
@ -1,33 +0,0 @@
|
|||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { getSession } from "@lib/auth";
|
||||
|
||||
import prisma from "../../../lib/prisma";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const session = await getSession({ req: req });
|
||||
|
||||
if (!session) {
|
||||
res.status(401).json({ message: "Not authenticated" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method == "PATCH") {
|
||||
const startMins = req.body.start;
|
||||
const endMins = req.body.end;
|
||||
const bufferMins = req.body.buffer;
|
||||
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: session.user.id,
|
||||
},
|
||||
data: {
|
||||
startTime: startMins,
|
||||
endTime: endMins,
|
||||
bufferTime: bufferMins,
|
||||
},
|
||||
});
|
||||
|
||||
res.status(200).json({ message: "Start and end times updated successfully" });
|
||||
}
|
||||
}
|
|
@ -1,32 +0,0 @@
|
|||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { createContext } from "@calcom/trpc/server/createContext";
|
||||
import { viewerRouter } from "@calcom/trpc/server/routers/viewer";
|
||||
|
||||
import { getSession } from "@lib/auth";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const session = await getSession({ req });
|
||||
/** So we can reuse tRCP queries */
|
||||
const trpcCtx = await createContext({ req, res });
|
||||
|
||||
if (!session?.user?.id) {
|
||||
res.status(401).json({ message: "Not authenticated" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST") {
|
||||
const eventType = await viewerRouter.createCaller(trpcCtx).mutation("eventTypes.create", req.body);
|
||||
res.status(201).json({ eventType });
|
||||
}
|
||||
|
||||
if (req.method === "PATCH") {
|
||||
const eventType = await viewerRouter.createCaller(trpcCtx).mutation("eventTypes.update", req.body);
|
||||
res.status(201).json({ eventType });
|
||||
}
|
||||
|
||||
if (req.method === "DELETE") {
|
||||
await viewerRouter.createCaller(trpcCtx).mutation("eventTypes.delete", { id: req.body.id });
|
||||
res.status(200).json({ id: req.body.id, message: "Event Type deleted" });
|
||||
}
|
||||
}
|
|
@ -1,425 +0,0 @@
|
|||
import {
|
||||
Booking,
|
||||
BookingStatus,
|
||||
Prisma,
|
||||
SchedulingType,
|
||||
User,
|
||||
WebhookTriggerEvents,
|
||||
Workflow,
|
||||
WorkflowsOnEventTypes,
|
||||
WorkflowStep,
|
||||
} from "@prisma/client";
|
||||
import type { NextApiRequest } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import { refund } from "@calcom/app-store/stripepayment/lib/server";
|
||||
import { scheduleTrigger } from "@calcom/app-store/zapier/lib/nodeScheduler";
|
||||
import EventManager from "@calcom/core/EventManager";
|
||||
import { sendDeclinedEmails, sendScheduledEmails } from "@calcom/emails";
|
||||
import { scheduleWorkflowReminders } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler";
|
||||
import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { AdditionalInformation, CalendarEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import { getSession } from "@lib/auth";
|
||||
import { HttpError } from "@lib/core/http/error";
|
||||
import getSubscribers from "@lib/webhooks/subscriptions";
|
||||
|
||||
import { getTranslation } from "@server/lib/i18n";
|
||||
|
||||
const authorized = async (
|
||||
currentUser: Pick<User, "id">,
|
||||
booking: Pick<Booking, "eventTypeId" | "userId">
|
||||
) => {
|
||||
// if the organizer
|
||||
if (booking.userId === currentUser.id) {
|
||||
return true;
|
||||
}
|
||||
const eventType = await prisma.eventType.findUnique({
|
||||
where: {
|
||||
id: booking.eventTypeId || undefined,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
schedulingType: true,
|
||||
users: true,
|
||||
},
|
||||
});
|
||||
if (
|
||||
eventType?.schedulingType === SchedulingType.COLLECTIVE &&
|
||||
eventType.users.find((user) => user.id === currentUser.id)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const log = logger.getChildLogger({ prefix: ["[api] book:user"] });
|
||||
|
||||
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) {
|
||||
throw new HttpError({ statusCode: 401, message: "Not authenticated" });
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
credentials: {
|
||||
orderBy: { id: "desc" as Prisma.SortOrder },
|
||||
},
|
||||
timeZone: true,
|
||||
email: true,
|
||||
name: true,
|
||||
username: true,
|
||||
destinationCalendar: true,
|
||||
locale: true,
|
||||
},
|
||||
});
|
||||
|
||||
const tOrganizer = await getTranslation(currentUser.locale ?? "en", "common");
|
||||
|
||||
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: {
|
||||
id: true,
|
||||
recurringEvent: true,
|
||||
requiresConfirmation: true,
|
||||
workflows: {
|
||||
include: {
|
||||
workflow: {
|
||||
include: {
|
||||
steps: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
location: true,
|
||||
userId: true,
|
||||
id: true,
|
||||
uid: true,
|
||||
payment: true,
|
||||
destinationCalendar: true,
|
||||
paid: true,
|
||||
recurringEventId: true,
|
||||
status: true,
|
||||
smsReminderNumber: true,
|
||||
scheduledJobs: true,
|
||||
},
|
||||
});
|
||||
|
||||
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,
|
||||
},
|
||||
data: {
|
||||
status: BookingStatus.ACCEPTED,
|
||||
},
|
||||
});
|
||||
|
||||
req.statusCode = 204;
|
||||
return { message: "Booking confirmed" };
|
||||
}
|
||||
|
||||
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" },
|
||||
},
|
||||
attendees: attendeesList,
|
||||
location: booking.location ?? "",
|
||||
uid: booking.uid,
|
||||
destinationCalendar: booking?.destinationCalendar || currentUser.destinationCalendar,
|
||||
requiresConfirmation: booking?.eventType?.requiresConfirmation ?? false,
|
||||
eventTypeId: booking.eventType?.id,
|
||||
};
|
||||
|
||||
const recurringEvent = parseRecurringEvent(booking.eventType?.recurringEvent);
|
||||
if (recurringEventId && recurringEvent) {
|
||||
const groupedRecurringBookings = await prisma.booking.groupBy({
|
||||
where: {
|
||||
recurringEventId: booking.recurringEventId,
|
||||
},
|
||||
by: [Prisma.BookingScalarFieldEnum.recurringEventId],
|
||||
_count: true,
|
||||
});
|
||||
// Overriding the recurring event configuration count to be the actual number of events booked for
|
||||
// the recurring event (equal or less than recurring event configuration count)
|
||||
recurringEvent.count = groupedRecurringBookings[0]._count;
|
||||
// count changed, parsing again to get the new value in
|
||||
evt.recurringEvent = parseRecurringEvent(recurringEvent);
|
||||
}
|
||||
|
||||
if (confirmed) {
|
||||
const eventManager = new EventManager(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: AdditionalInformation = {};
|
||||
|
||||
if (results.length) {
|
||||
// TODO: Handle created event metadata more elegantly
|
||||
metadata.hangoutLink = results[0].createdEvent?.hangoutLink;
|
||||
metadata.conferenceData = results[0].createdEvent?.conferenceData;
|
||||
metadata.entryPoints = results[0].createdEvent?.entryPoints;
|
||||
}
|
||||
try {
|
||||
await sendScheduledEmails({ ...evt, additionalInformation: metadata });
|
||||
} catch (error) {
|
||||
log.error(error);
|
||||
}
|
||||
}
|
||||
let updatedBookings: {
|
||||
scheduledJobs: string[];
|
||||
id: number;
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
uid: string;
|
||||
smsReminderNumber: string | null;
|
||||
eventType: {
|
||||
workflows: (WorkflowsOnEventTypes & {
|
||||
workflow: Workflow & {
|
||||
steps: WorkflowStep[];
|
||||
};
|
||||
})[];
|
||||
} | null;
|
||||
}[] = [];
|
||||
|
||||
if (recurringEventId) {
|
||||
// The booking to confirm is a recurring event and comes from /booking/recurring, proceeding to mark all related
|
||||
// bookings as confirmed. Prisma updateMany does not support relations, so doing this in two steps for now.
|
||||
const unconfirmedRecurringBookings = await prisma.booking.findMany({
|
||||
where: {
|
||||
recurringEventId,
|
||||
status: BookingStatus.PENDING,
|
||||
},
|
||||
});
|
||||
|
||||
const updateBookingsPromise = unconfirmedRecurringBookings.map((recurringBooking) => {
|
||||
return prisma.booking.update({
|
||||
where: {
|
||||
id: recurringBooking.id,
|
||||
},
|
||||
data: {
|
||||
status: BookingStatus.ACCEPTED,
|
||||
references: {
|
||||
create: scheduleResult.referencesToCreate,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
eventType: {
|
||||
select: {
|
||||
workflows: {
|
||||
include: {
|
||||
workflow: {
|
||||
include: {
|
||||
steps: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
uid: true,
|
||||
startTime: true,
|
||||
endTime: true,
|
||||
smsReminderNumber: true,
|
||||
id: true,
|
||||
scheduledJobs: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
const updatedBookingsResult = await Promise.all(updateBookingsPromise);
|
||||
updatedBookings = updatedBookings.concat(updatedBookingsResult);
|
||||
} else {
|
||||
// @NOTE: be careful with this as if any error occurs before this booking doesn't get confirmed
|
||||
// Should perform update on booking (confirm) -> then trigger the rest handlers
|
||||
const updatedBooking = await prisma.booking.update({
|
||||
where: {
|
||||
id: bookingId,
|
||||
},
|
||||
data: {
|
||||
status: BookingStatus.ACCEPTED,
|
||||
references: {
|
||||
create: scheduleResult.referencesToCreate,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
eventType: {
|
||||
select: {
|
||||
workflows: {
|
||||
include: {
|
||||
workflow: {
|
||||
include: {
|
||||
steps: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
uid: true,
|
||||
startTime: true,
|
||||
endTime: true,
|
||||
smsReminderNumber: true,
|
||||
id: true,
|
||||
scheduledJobs: true,
|
||||
},
|
||||
});
|
||||
updatedBookings.push(updatedBooking);
|
||||
}
|
||||
|
||||
//Workflows - set reminders for confirmed events
|
||||
for (const updatedBooking of updatedBookings) {
|
||||
if (updatedBooking.eventType?.workflows) {
|
||||
const evtOfBooking = evt;
|
||||
evtOfBooking.startTime = updatedBooking.startTime.toISOString();
|
||||
evtOfBooking.endTime = updatedBooking.endTime.toISOString();
|
||||
evtOfBooking.uid = updatedBooking.uid;
|
||||
|
||||
await scheduleWorkflowReminders(
|
||||
updatedBooking.eventType.workflows,
|
||||
updatedBooking.smsReminderNumber,
|
||||
evtOfBooking,
|
||||
false
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// schedule job for zapier trigger 'when meeting ends'
|
||||
const subscriberOptionsMeetingEnded = {
|
||||
userId: booking.userId || 0,
|
||||
eventTypeId: booking.eventTypeId || 0,
|
||||
triggerEvent: WebhookTriggerEvents.MEETING_ENDED,
|
||||
};
|
||||
|
||||
const subscribersMeetingEnded = await getSubscribers(subscriberOptionsMeetingEnded);
|
||||
|
||||
subscribersMeetingEnded.forEach((subscriber) => {
|
||||
updatedBookings.forEach((booking) => {
|
||||
scheduleTrigger(booking, subscriber.subscriberUrl, subscriber);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
evt.rejectionReason = rejectionReason;
|
||||
if (recurringEventId) {
|
||||
// The booking to reject is a recurring event and comes from /booking/upcoming, proceeding to mark all related
|
||||
// bookings as rejected.
|
||||
await prisma.booking.updateMany({
|
||||
where: {
|
||||
recurringEventId,
|
||||
status: BookingStatus.PENDING,
|
||||
},
|
||||
data: {
|
||||
status: BookingStatus.REJECTED,
|
||||
rejectionReason,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await refund(booking, evt); // No payment integration for recurring events for v1
|
||||
await prisma.booking.update({
|
||||
where: {
|
||||
id: bookingId,
|
||||
},
|
||||
data: {
|
||||
status: BookingStatus.REJECTED,
|
||||
rejectionReason,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await sendDeclinedEmails(evt);
|
||||
}
|
||||
|
||||
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) }),
|
||||
});
|
|
@ -26,9 +26,9 @@ import { getDefaultEvent, getGroupName, getUsernameList } from "@calcom/lib/defa
|
|||
import { getErrorFromUnknown } from "@calcom/lib/errors";
|
||||
import isOutOfBounds, { BookingDateInPastError } from "@calcom/lib/isOutOfBounds";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { getLuckyUser } from "@calcom/lib/server";
|
||||
import { defaultResponder } from "@calcom/lib/server";
|
||||
import { defaultResponder, getLuckyUser } from "@calcom/lib/server";
|
||||
import { updateWebUser as syncServicesUpdateWebUser } from "@calcom/lib/sync/SyncServiceManager";
|
||||
import getSubscribers from "@calcom/lib/webhooks/subscriptions";
|
||||
import prisma, { userSelect } from "@calcom/prisma";
|
||||
import { extendedBookingCreateBody } from "@calcom/prisma/zod-utils";
|
||||
import type { BufferedBusyTime } from "@calcom/types/BufferedBusyTime";
|
||||
|
@ -38,7 +38,6 @@ import type { EventResult, PartialReference } from "@calcom/types/EventManager";
|
|||
import { getSession } from "@lib/auth";
|
||||
import { HttpError } from "@lib/core/http/error";
|
||||
import sendPayload from "@lib/webhooks/sendPayload";
|
||||
import getSubscribers from "@lib/webhooks/subscriptions";
|
||||
|
||||
import { getTranslation } from "@server/lib/i18n";
|
||||
|
||||
|
|
|
@ -1,117 +0,0 @@
|
|||
import { Prisma } from "@prisma/client";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { getSession } from "next-auth/react";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const session = await getSession({ req: req });
|
||||
|
||||
if (!session?.user?.id) {
|
||||
res.status(401).json({ message: "Not authenticated" });
|
||||
return;
|
||||
}
|
||||
|
||||
const eventTypeSelect = Prisma.validator<Prisma.EventTypeSelect>()({
|
||||
id: true,
|
||||
title: true,
|
||||
description: true,
|
||||
length: true,
|
||||
schedulingType: true,
|
||||
slug: true,
|
||||
hidden: true,
|
||||
price: true,
|
||||
currency: true,
|
||||
metadata: true,
|
||||
users: {
|
||||
select: {
|
||||
id: true,
|
||||
avatar: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
rejectOnNotFound: true,
|
||||
where: {
|
||||
id: session.user.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
name: true,
|
||||
startTime: true,
|
||||
endTime: true,
|
||||
bufferTime: true,
|
||||
avatar: true,
|
||||
completedOnboarding: true,
|
||||
createdDate: true,
|
||||
plan: true,
|
||||
teams: {
|
||||
where: {
|
||||
accepted: true,
|
||||
},
|
||||
select: {
|
||||
role: true,
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
logo: true,
|
||||
members: {
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
},
|
||||
eventTypes: {
|
||||
select: eventTypeSelect,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
eventTypes: {
|
||||
where: {
|
||||
team: null,
|
||||
},
|
||||
select: eventTypeSelect,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// backwards compatibility, TMP:
|
||||
const typesRaw = await prisma.eventType.findMany({
|
||||
where: {
|
||||
userId: session.user.id,
|
||||
},
|
||||
select: eventTypeSelect,
|
||||
});
|
||||
|
||||
type EventTypeGroup = {
|
||||
teamId?: number | null;
|
||||
profile?: {
|
||||
slug: typeof user["username"];
|
||||
name: typeof user["name"];
|
||||
image: typeof user["avatar"];
|
||||
};
|
||||
metadata: {
|
||||
membershipCount: number;
|
||||
readOnly: boolean;
|
||||
};
|
||||
eventTypes: (typeof user.eventTypes[number] & { $disabled?: boolean })[];
|
||||
};
|
||||
|
||||
const eventTypesHashMap = user.eventTypes.concat(typesRaw).reduce((hashMap, newItem) => {
|
||||
const oldItem = hashMap[newItem.id] || {};
|
||||
hashMap[newItem.id] = { ...oldItem, ...newItem };
|
||||
return hashMap;
|
||||
}, {} as Record<number, EventTypeGroup["eventTypes"][number]>);
|
||||
const mergedEventTypes = Object.values(eventTypesHashMap).map((et, index) => ({
|
||||
...et,
|
||||
$disabled: user?.plan === "FREE" && index > 0,
|
||||
}));
|
||||
|
||||
return res.status(200).json({ eventTypes: mergedEventTypes });
|
||||
}
|
|
@ -89,6 +89,13 @@ export const bookingCreateBodySchema = z.object({
|
|||
|
||||
export type BookingCreateBody = z.input<typeof bookingCreateBodySchema>;
|
||||
|
||||
export const bookingConfirmPatchBodySchema = z.object({
|
||||
bookingId: z.number(),
|
||||
confirmed: z.boolean(),
|
||||
recurringEventId: z.string().optional(),
|
||||
reason: z.string().optional(),
|
||||
});
|
||||
|
||||
export const extendedBookingCreateBody = bookingCreateBodySchema.merge(
|
||||
z.object({
|
||||
noEmail: z.boolean().optional(),
|
||||
|
|
|
@ -1,13 +1,26 @@
|
|||
import { SchedulingType } from "@prisma/client";
|
||||
import {
|
||||
BookingStatus,
|
||||
Prisma,
|
||||
SchedulingType,
|
||||
WebhookTriggerEvents,
|
||||
Workflow,
|
||||
WorkflowsOnEventTypes,
|
||||
WorkflowStep,
|
||||
} from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
|
||||
import { DailyLocationType } from "@calcom/app-store/locations";
|
||||
import { refund } from "@calcom/app-store/stripepayment/lib/server";
|
||||
import { scheduleTrigger } from "@calcom/app-store/zapier/lib/nodeScheduler";
|
||||
import EventManager from "@calcom/core/EventManager";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { sendLocationChangeEmails } from "@calcom/emails";
|
||||
import { parseRecurringEvent } from "@calcom/lib";
|
||||
import { sendDeclinedEmails, sendLocationChangeEmails, sendScheduledEmails } from "@calcom/emails";
|
||||
import { scheduleWorkflowReminders } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler";
|
||||
import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||
import { getTranslation } from "@calcom/lib/server";
|
||||
import getSubscribers from "@calcom/lib/webhooks/subscriptions";
|
||||
import { bookingConfirmPatchBodySchema } from "@calcom/prisma/zod-utils";
|
||||
import type { AdditionalInformation, CalendarEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
@ -19,6 +32,8 @@ const commonBookingSchema = z.object({
|
|||
bookingId: z.number(),
|
||||
});
|
||||
|
||||
const log = logger.getChildLogger({ prefix: ["[api] book:user"] });
|
||||
|
||||
export const bookingsRouter = createProtectedRouter()
|
||||
.middleware(async ({ ctx, rawInput, next }) => {
|
||||
// Endpoints that just read the logged in user's data - like 'list' don't necessary have any input
|
||||
|
@ -169,4 +184,344 @@ export const bookingsRouter = createProtectedRouter()
|
|||
}
|
||||
return { message: "Location updated" };
|
||||
},
|
||||
})
|
||||
.mutation("confirm", {
|
||||
input: bookingConfirmPatchBodySchema,
|
||||
async resolve({ ctx, input }) {
|
||||
const { user, prisma } = ctx;
|
||||
const { bookingId, recurringEventId, reason: rejectionReason, confirmed } = input;
|
||||
|
||||
const tOrganizer = await getTranslation(user.locale ?? "en", "common");
|
||||
|
||||
const booking = await prisma.booking.findFirst({
|
||||
where: {
|
||||
id: bookingId,
|
||||
},
|
||||
rejectOnNotFound() {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Booking not found" });
|
||||
},
|
||||
// should trpc handle this error ?
|
||||
select: {
|
||||
title: true,
|
||||
description: true,
|
||||
customInputs: true,
|
||||
startTime: true,
|
||||
endTime: true,
|
||||
attendees: true,
|
||||
eventTypeId: true,
|
||||
eventType: {
|
||||
select: {
|
||||
id: true,
|
||||
recurringEvent: true,
|
||||
requiresConfirmation: true,
|
||||
workflows: {
|
||||
include: {
|
||||
workflow: {
|
||||
include: {
|
||||
steps: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
location: true,
|
||||
userId: true,
|
||||
id: true,
|
||||
uid: true,
|
||||
payment: true,
|
||||
destinationCalendar: true,
|
||||
paid: true,
|
||||
recurringEventId: true,
|
||||
status: true,
|
||||
smsReminderNumber: true,
|
||||
scheduledJobs: true,
|
||||
},
|
||||
});
|
||||
const authorized = async () => {
|
||||
// if the organizer
|
||||
if (booking.userId === user.id) {
|
||||
return true;
|
||||
}
|
||||
const eventType = await prisma.eventType.findUnique({
|
||||
where: {
|
||||
id: booking.eventTypeId || undefined,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
schedulingType: true,
|
||||
users: true,
|
||||
},
|
||||
});
|
||||
if (
|
||||
eventType?.schedulingType === SchedulingType.COLLECTIVE &&
|
||||
eventType.users.find((user) => user.id === user.id)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
if (!(await authorized())) throw new TRPCError({ code: "UNAUTHORIZED", message: "UNAUTHORIZED" });
|
||||
|
||||
const isConfirmed = booking.status === BookingStatus.ACCEPTED;
|
||||
if (isConfirmed) throw new TRPCError({ code: "BAD_REQUEST", message: "Booking already confirmed" });
|
||||
|
||||
/** When a booking that requires payment its being confirmed but doesn't have any payment,
|
||||
* we shouldn’t save it on DestinationCalendars
|
||||
*/
|
||||
if (booking.payment.length > 0 && !booking.paid) {
|
||||
await prisma.booking.update({
|
||||
where: {
|
||||
id: bookingId,
|
||||
},
|
||||
data: {
|
||||
status: BookingStatus.ACCEPTED,
|
||||
},
|
||||
});
|
||||
|
||||
return { message: "Booking confirmed" };
|
||||
}
|
||||
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: user.email,
|
||||
name: user.name || "Unnamed",
|
||||
timeZone: user.timeZone,
|
||||
language: { translate: tOrganizer, locale: user.locale ?? "en" },
|
||||
},
|
||||
attendees: attendeesList,
|
||||
location: booking.location ?? "",
|
||||
uid: booking.uid,
|
||||
destinationCalendar: booking?.destinationCalendar || user.destinationCalendar,
|
||||
requiresConfirmation: booking?.eventType?.requiresConfirmation ?? false,
|
||||
eventTypeId: booking.eventType?.id,
|
||||
};
|
||||
|
||||
const recurringEvent = parseRecurringEvent(booking.eventType?.recurringEvent);
|
||||
if (recurringEventId && recurringEvent) {
|
||||
const groupedRecurringBookings = await prisma.booking.groupBy({
|
||||
where: {
|
||||
recurringEventId: booking.recurringEventId,
|
||||
},
|
||||
by: [Prisma.BookingScalarFieldEnum.recurringEventId],
|
||||
_count: true,
|
||||
});
|
||||
// Overriding the recurring event configuration count to be the actual number of events booked for
|
||||
// the recurring event (equal or less than recurring event configuration count)
|
||||
recurringEvent.count = groupedRecurringBookings[0]._count;
|
||||
// count changed, parsing again to get the new value in
|
||||
evt.recurringEvent = parseRecurringEvent(recurringEvent);
|
||||
}
|
||||
|
||||
if (confirmed) {
|
||||
const eventManager = new EventManager(user);
|
||||
const scheduleResult = await eventManager.create(evt);
|
||||
|
||||
const results = scheduleResult.results;
|
||||
|
||||
if (results.length > 0 && results.every((res) => !res.success)) {
|
||||
const error = {
|
||||
errorCode: "BookingCreatingMeetingFailed",
|
||||
message: "Booking failed",
|
||||
};
|
||||
|
||||
log.error(`Booking ${user.username} failed`, error, results);
|
||||
} else {
|
||||
const metadata: AdditionalInformation = {};
|
||||
|
||||
if (results.length) {
|
||||
// TODO: Handle created event metadata more elegantly
|
||||
metadata.hangoutLink = results[0].createdEvent?.hangoutLink;
|
||||
metadata.conferenceData = results[0].createdEvent?.conferenceData;
|
||||
metadata.entryPoints = results[0].createdEvent?.entryPoints;
|
||||
}
|
||||
try {
|
||||
await sendScheduledEmails({ ...evt, additionalInformation: metadata });
|
||||
} catch (error) {
|
||||
log.error(error);
|
||||
}
|
||||
}
|
||||
let updatedBookings: {
|
||||
scheduledJobs: string[];
|
||||
id: number;
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
uid: string;
|
||||
smsReminderNumber: string | null;
|
||||
eventType: {
|
||||
workflows: (WorkflowsOnEventTypes & {
|
||||
workflow: Workflow & {
|
||||
steps: WorkflowStep[];
|
||||
};
|
||||
})[];
|
||||
} | null;
|
||||
}[] = [];
|
||||
|
||||
if (recurringEventId) {
|
||||
// The booking to confirm is a recurring event and comes from /booking/recurring, proceeding to mark all related
|
||||
// bookings as confirmed. Prisma updateMany does not support relations, so doing this in two steps for now.
|
||||
const unconfirmedRecurringBookings = await prisma.booking.findMany({
|
||||
where: {
|
||||
recurringEventId,
|
||||
status: BookingStatus.PENDING,
|
||||
},
|
||||
});
|
||||
|
||||
const updateBookingsPromise = unconfirmedRecurringBookings.map((recurringBooking) => {
|
||||
return prisma.booking.update({
|
||||
where: {
|
||||
id: recurringBooking.id,
|
||||
},
|
||||
data: {
|
||||
status: BookingStatus.ACCEPTED,
|
||||
references: {
|
||||
create: scheduleResult.referencesToCreate,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
eventType: {
|
||||
select: {
|
||||
workflows: {
|
||||
include: {
|
||||
workflow: {
|
||||
include: {
|
||||
steps: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
uid: true,
|
||||
startTime: true,
|
||||
endTime: true,
|
||||
smsReminderNumber: true,
|
||||
id: true,
|
||||
scheduledJobs: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
const updatedBookingsResult = await Promise.all(updateBookingsPromise);
|
||||
updatedBookings = updatedBookings.concat(updatedBookingsResult);
|
||||
} else {
|
||||
// @NOTE: be careful with this as if any error occurs before this booking doesn't get confirmed
|
||||
// Should perform update on booking (confirm) -> then trigger the rest handlers
|
||||
const updatedBooking = await prisma.booking.update({
|
||||
where: {
|
||||
id: bookingId,
|
||||
},
|
||||
data: {
|
||||
status: BookingStatus.ACCEPTED,
|
||||
references: {
|
||||
create: scheduleResult.referencesToCreate,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
eventType: {
|
||||
select: {
|
||||
workflows: {
|
||||
include: {
|
||||
workflow: {
|
||||
include: {
|
||||
steps: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
uid: true,
|
||||
startTime: true,
|
||||
endTime: true,
|
||||
smsReminderNumber: true,
|
||||
id: true,
|
||||
scheduledJobs: true,
|
||||
},
|
||||
});
|
||||
updatedBookings.push(updatedBooking);
|
||||
}
|
||||
|
||||
//Workflows - set reminders for confirmed events
|
||||
for (const updatedBooking of updatedBookings) {
|
||||
if (updatedBooking.eventType?.workflows) {
|
||||
const evtOfBooking = evt;
|
||||
evtOfBooking.startTime = updatedBooking.startTime.toISOString();
|
||||
evtOfBooking.endTime = updatedBooking.endTime.toISOString();
|
||||
evtOfBooking.uid = updatedBooking.uid;
|
||||
|
||||
await scheduleWorkflowReminders(
|
||||
updatedBooking.eventType.workflows,
|
||||
updatedBooking.smsReminderNumber,
|
||||
evtOfBooking,
|
||||
false
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// schedule job for zapier trigger 'when meeting ends'
|
||||
const subscriberOptionsMeetingEnded = {
|
||||
userId: booking.userId || 0,
|
||||
eventTypeId: booking.eventTypeId || 0,
|
||||
triggerEvent: WebhookTriggerEvents.MEETING_ENDED,
|
||||
};
|
||||
|
||||
const subscribersMeetingEnded = await getSubscribers(subscriberOptionsMeetingEnded);
|
||||
|
||||
subscribersMeetingEnded.forEach((subscriber) => {
|
||||
updatedBookings.forEach((booking) => {
|
||||
scheduleTrigger(booking, subscriber.subscriberUrl, subscriber);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
evt.rejectionReason = rejectionReason;
|
||||
if (recurringEventId) {
|
||||
// The booking to reject is a recurring event and comes from /booking/upcoming, proceeding to mark all related
|
||||
// bookings as rejected.
|
||||
await prisma.booking.updateMany({
|
||||
where: {
|
||||
recurringEventId,
|
||||
status: BookingStatus.PENDING,
|
||||
},
|
||||
data: {
|
||||
status: BookingStatus.REJECTED,
|
||||
rejectionReason,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await refund(booking, evt); // No payment integration for recurring events for v1
|
||||
await prisma.booking.update({
|
||||
where: {
|
||||
id: bookingId,
|
||||
},
|
||||
data: {
|
||||
status: BookingStatus.REJECTED,
|
||||
rejectionReason,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await sendDeclinedEmails(evt);
|
||||
}
|
||||
return { message: "Booking " + confirmed ? "confirmed" : "rejected" };
|
||||
},
|
||||
});
|
||||
|
|
Loading…
Reference in New Issue
Block a user