Abstracting booking cancellation (#5105)
* Abstracting booking cancellation * Tweaking to use id and not uid * Standardizing http errors * Moving schema to prisma zod-utils * Update apps/web/pages/api/cancel.ts Co-authored-by: Alex van Andel <me@alexvanandel.com> * Cancel only for delete and post verbs * Make cancellation work with uid, and id Co-authored-by: Alex van Andel <me@alexvanandel.com>pull/5141/head
parent
39a3a1affb
commit
5f78eceb89
|
@ -12,6 +12,7 @@ type Props = {
|
|||
booking: {
|
||||
title?: string;
|
||||
uid?: string;
|
||||
id?: number;
|
||||
};
|
||||
profile: {
|
||||
name: string | null;
|
||||
|
@ -79,7 +80,7 @@ export default function CancelBooking(props: Props) {
|
|||
setLoading(true);
|
||||
|
||||
const payload = {
|
||||
uid: booking?.uid,
|
||||
id: booking?.id,
|
||||
cancellationReason: cancellationReason,
|
||||
};
|
||||
|
||||
|
|
|
@ -1,396 +1,14 @@
|
|||
import {
|
||||
BookingStatus,
|
||||
Prisma,
|
||||
PrismaPromise,
|
||||
WebhookTriggerEvents,
|
||||
WorkflowMethods,
|
||||
WorkflowReminder,
|
||||
} from "@prisma/client";
|
||||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
import z from "zod";
|
||||
import type { NextApiRequest } from "next";
|
||||
|
||||
import { getCalendar } from "@calcom/app-store/_utils/getCalendar";
|
||||
import { FAKE_DAILY_CREDENTIAL } from "@calcom/app-store/dailyvideo/lib/VideoApiAdapter";
|
||||
import { DailyLocationType } from "@calcom/app-store/locations";
|
||||
import { refund } from "@calcom/app-store/stripepayment/lib/server";
|
||||
import { cancelScheduledJobs } from "@calcom/app-store/zapier/lib/nodeScheduler";
|
||||
import { deleteMeeting } from "@calcom/core/videoClient";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { sendCancelledEmails } from "@calcom/emails";
|
||||
import { deleteScheduledEmailReminder } from "@calcom/features/ee/workflows/lib/reminders/emailReminderManager";
|
||||
import { sendCancelledReminders } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler";
|
||||
import { deleteScheduledSMSReminder } from "@calcom/features/ee/workflows/lib/reminders/smsReminderManager";
|
||||
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
|
||||
import sendPayload, { EventTypeInfo } from "@calcom/features/webhooks/lib/sendPayload";
|
||||
import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib";
|
||||
import handleCancelBooking from "@calcom/features/bookings/lib/handleCancelBooking";
|
||||
import { getSession } from "@calcom/lib/auth";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
|
||||
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
|
||||
import type { CalendarEvent } from "@calcom/types/Calendar";
|
||||
import { defaultResponder, defaultHandler } from "@calcom/lib/server";
|
||||
|
||||
import { getTranslation } from "@server/lib/i18n";
|
||||
|
||||
const bodySchema = z.object({
|
||||
uid: z.string(),
|
||||
allRemainingBookings: z.boolean().optional(),
|
||||
cancellationReason: z.string().optional(),
|
||||
});
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const { uid, allRemainingBookings, cancellationReason } = bodySchema.parse(req.body);
|
||||
async function handler(req: NextApiRequest & { userId?: number }) {
|
||||
const session = await getSession({ req });
|
||||
|
||||
const bookingToDelete = await prisma.booking.findUnique({
|
||||
where: {
|
||||
uid,
|
||||
},
|
||||
select: {
|
||||
...bookingMinimalSelect,
|
||||
recurringEventId: true,
|
||||
userId: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
credentials: true,
|
||||
email: true,
|
||||
timeZone: true,
|
||||
name: true,
|
||||
destinationCalendar: true,
|
||||
},
|
||||
},
|
||||
location: true,
|
||||
references: {
|
||||
select: {
|
||||
uid: true,
|
||||
type: true,
|
||||
externalCalendarId: true,
|
||||
credentialId: true,
|
||||
},
|
||||
},
|
||||
payment: true,
|
||||
paid: true,
|
||||
eventType: {
|
||||
select: {
|
||||
recurringEvent: true,
|
||||
title: true,
|
||||
description: true,
|
||||
requiresConfirmation: true,
|
||||
price: true,
|
||||
currency: true,
|
||||
length: true,
|
||||
workflows: {
|
||||
include: {
|
||||
workflow: {
|
||||
include: {
|
||||
steps: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
uid: true,
|
||||
eventTypeId: true,
|
||||
destinationCalendar: true,
|
||||
smsReminderNumber: true,
|
||||
workflowReminders: true,
|
||||
scheduledJobs: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!bookingToDelete || !bookingToDelete.user) {
|
||||
throw new HttpError({ statusCode: 404, message: "Booking not found" });
|
||||
}
|
||||
|
||||
if ((!session || session.user?.id !== bookingToDelete.user?.id) && bookingToDelete.startTime < new Date()) {
|
||||
throw new HttpError({ statusCode: 403, message: "Cannot cancel past events" });
|
||||
}
|
||||
|
||||
if (!bookingToDelete.userId) {
|
||||
throw new HttpError({ statusCode: 404, message: "User not found" });
|
||||
}
|
||||
|
||||
const organizer = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: bookingToDelete.userId,
|
||||
},
|
||||
select: {
|
||||
name: true,
|
||||
email: true,
|
||||
timeZone: true,
|
||||
locale: true,
|
||||
},
|
||||
});
|
||||
|
||||
const attendeesListPromises = bookingToDelete.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 tOrganizer = await getTranslation(organizer.locale ?? "en", "common");
|
||||
|
||||
const evt: CalendarEvent = {
|
||||
title: bookingToDelete?.title,
|
||||
type: (bookingToDelete?.eventType?.title as string) || bookingToDelete?.title,
|
||||
description: bookingToDelete?.description || "",
|
||||
customInputs: isPrismaObjOrUndefined(bookingToDelete.customInputs),
|
||||
startTime: bookingToDelete?.startTime ? dayjs(bookingToDelete.startTime).format() : "",
|
||||
endTime: bookingToDelete?.endTime ? dayjs(bookingToDelete.endTime).format() : "",
|
||||
organizer: {
|
||||
email: organizer.email,
|
||||
name: organizer.name ?? "Nameless",
|
||||
timeZone: organizer.timeZone,
|
||||
language: { translate: tOrganizer, locale: organizer.locale ?? "en" },
|
||||
},
|
||||
attendees: attendeesList,
|
||||
uid: bookingToDelete?.uid,
|
||||
/* Include recurringEvent information only when cancelling all bookings */
|
||||
recurringEvent: allRemainingBookings
|
||||
? parseRecurringEvent(bookingToDelete.eventType?.recurringEvent)
|
||||
: undefined,
|
||||
location: bookingToDelete?.location,
|
||||
destinationCalendar: bookingToDelete?.destinationCalendar || bookingToDelete?.user.destinationCalendar,
|
||||
cancellationReason: cancellationReason,
|
||||
};
|
||||
// Hook up the webhook logic here
|
||||
const eventTrigger: WebhookTriggerEvents = "BOOKING_CANCELLED";
|
||||
// Send Webhook call if hooked to BOOKING.CANCELLED
|
||||
const subscriberOptions = {
|
||||
userId: bookingToDelete.userId,
|
||||
eventTypeId: (bookingToDelete.eventTypeId as number) || 0,
|
||||
triggerEvent: eventTrigger,
|
||||
};
|
||||
|
||||
const eventTypeInfo: EventTypeInfo = {
|
||||
eventTitle: bookingToDelete?.eventType?.title || null,
|
||||
eventDescription: bookingToDelete?.eventType?.description || null,
|
||||
requiresConfirmation: bookingToDelete?.eventType?.requiresConfirmation || null,
|
||||
price: bookingToDelete?.eventType?.price || null,
|
||||
currency: bookingToDelete?.eventType?.currency || null,
|
||||
length: bookingToDelete?.eventType?.length || null,
|
||||
};
|
||||
|
||||
const webhooks = await getWebhooks(subscriberOptions);
|
||||
const promises = webhooks.map((webhook) =>
|
||||
sendPayload(webhook.secret, eventTrigger, new Date().toISOString(), webhook, {
|
||||
...evt,
|
||||
...eventTypeInfo,
|
||||
status: "CANCELLED",
|
||||
}).catch((e) => {
|
||||
console.error(`Error executing webhook for event: ${eventTrigger}, URL: ${webhook.subscriberUrl}`, e);
|
||||
})
|
||||
);
|
||||
await Promise.all(promises);
|
||||
|
||||
//Workflows - schedule reminders
|
||||
if (bookingToDelete.eventType?.workflows) {
|
||||
await sendCancelledReminders(
|
||||
bookingToDelete.eventType?.workflows,
|
||||
bookingToDelete.smsReminderNumber,
|
||||
evt
|
||||
);
|
||||
}
|
||||
|
||||
let updatedBookings: {
|
||||
uid: string;
|
||||
workflowReminders: WorkflowReminder[];
|
||||
scheduledJobs: string[];
|
||||
}[] = [];
|
||||
|
||||
// by cancelling first, and blocking whilst doing so; we can ensure a cancel
|
||||
// action always succeeds even if subsequent integrations fail cancellation.
|
||||
if (bookingToDelete.eventType?.recurringEvent && bookingToDelete.recurringEventId && allRemainingBookings) {
|
||||
const recurringEventId = bookingToDelete.recurringEventId;
|
||||
// Proceed to mark as cancelled all remaining recurring events instances (greater than or equal to right now)
|
||||
await prisma.booking.updateMany({
|
||||
where: {
|
||||
recurringEventId,
|
||||
startTime: {
|
||||
gte: new Date(),
|
||||
},
|
||||
},
|
||||
data: {
|
||||
status: BookingStatus.CANCELLED,
|
||||
cancellationReason: cancellationReason,
|
||||
},
|
||||
});
|
||||
const allUpdatedBookings = await prisma.booking.findMany({
|
||||
where: {
|
||||
recurringEventId: bookingToDelete.recurringEventId,
|
||||
startTime: {
|
||||
gte: new Date(),
|
||||
},
|
||||
},
|
||||
select: {
|
||||
workflowReminders: true,
|
||||
uid: true,
|
||||
scheduledJobs: true,
|
||||
},
|
||||
});
|
||||
updatedBookings = updatedBookings.concat(allUpdatedBookings);
|
||||
} else {
|
||||
const updatedBooking = await prisma.booking.update({
|
||||
where: {
|
||||
uid,
|
||||
},
|
||||
data: {
|
||||
status: BookingStatus.CANCELLED,
|
||||
cancellationReason: cancellationReason,
|
||||
},
|
||||
select: {
|
||||
workflowReminders: true,
|
||||
uid: true,
|
||||
scheduledJobs: true,
|
||||
},
|
||||
});
|
||||
updatedBookings.push(updatedBooking);
|
||||
}
|
||||
|
||||
/** TODO: Remove this without breaking functionality */
|
||||
if (bookingToDelete.location === DailyLocationType) {
|
||||
bookingToDelete.user.credentials.push(FAKE_DAILY_CREDENTIAL);
|
||||
}
|
||||
|
||||
const apiDeletes = [];
|
||||
|
||||
const bookingCalendarReference = bookingToDelete.references.find((reference) =>
|
||||
reference.type.includes("_calendar")
|
||||
);
|
||||
|
||||
if (bookingCalendarReference) {
|
||||
const { credentialId, uid, externalCalendarId } = bookingCalendarReference;
|
||||
// If the booking calendar reference contains a credentialId
|
||||
if (credentialId) {
|
||||
// Find the correct calendar credential under user credentials
|
||||
const calendarCredential = bookingToDelete.user.credentials.find(
|
||||
(credential) => credential.id === credentialId
|
||||
);
|
||||
if (calendarCredential) {
|
||||
const calendar = getCalendar(calendarCredential);
|
||||
apiDeletes.push(calendar?.deleteEvent(uid, evt, externalCalendarId));
|
||||
}
|
||||
// For bookings made before the refactor we go through the old behaviour of running through each calendar credential
|
||||
} else {
|
||||
bookingToDelete.user.credentials
|
||||
.filter((credential) => credential.type.endsWith("_calendar"))
|
||||
.forEach((credential) => {
|
||||
const calendar = getCalendar(credential);
|
||||
apiDeletes.push(calendar?.deleteEvent(uid, evt, externalCalendarId));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const bookingVideoReference = bookingToDelete.references.find((reference) =>
|
||||
reference.type.includes("_video")
|
||||
);
|
||||
|
||||
// If the video reference has a credentialId find the specific credential
|
||||
if (bookingVideoReference && bookingVideoReference.credentialId) {
|
||||
const { credentialId, uid } = bookingVideoReference;
|
||||
if (credentialId) {
|
||||
const videoCredential = bookingToDelete.user.credentials.find(
|
||||
(credential) => credential.id === credentialId
|
||||
);
|
||||
|
||||
if (videoCredential) {
|
||||
apiDeletes.push(deleteMeeting(videoCredential, uid));
|
||||
}
|
||||
}
|
||||
// For bookings made before this refactor we go through the old behaviour of running through each video credential
|
||||
} else {
|
||||
bookingToDelete.user.credentials
|
||||
.filter((credential) => credential.type.endsWith("_video"))
|
||||
.forEach((credential) => {
|
||||
apiDeletes.push(deleteMeeting(credential, uid));
|
||||
});
|
||||
}
|
||||
|
||||
// Avoiding taking care of recurrence for now as Payments are not supported with Recurring Events at the moment
|
||||
if (bookingToDelete && bookingToDelete.paid) {
|
||||
const evt: CalendarEvent = {
|
||||
type: bookingToDelete?.eventType?.title as string,
|
||||
title: bookingToDelete.title,
|
||||
description: bookingToDelete.description ?? "",
|
||||
customInputs: isPrismaObjOrUndefined(bookingToDelete.customInputs),
|
||||
startTime: bookingToDelete.startTime.toISOString(),
|
||||
endTime: bookingToDelete.endTime.toISOString(),
|
||||
organizer: {
|
||||
email: bookingToDelete.user?.email ?? "dev@calendso.com",
|
||||
name: bookingToDelete.user?.name ?? "no user",
|
||||
timeZone: bookingToDelete.user?.timeZone ?? "",
|
||||
language: { translate: tOrganizer, locale: organizer.locale ?? "en" },
|
||||
},
|
||||
attendees: attendeesList,
|
||||
location: bookingToDelete.location ?? "",
|
||||
uid: bookingToDelete.uid ?? "",
|
||||
destinationCalendar: bookingToDelete?.destinationCalendar || bookingToDelete?.user.destinationCalendar,
|
||||
};
|
||||
await refund(bookingToDelete, evt);
|
||||
await prisma.booking.update({
|
||||
where: {
|
||||
id: bookingToDelete.id,
|
||||
},
|
||||
data: {
|
||||
status: BookingStatus.REJECTED,
|
||||
},
|
||||
});
|
||||
|
||||
// We skip the deletion of the event, because that would also delete the payment reference, which we should keep
|
||||
await apiDeletes;
|
||||
return res.status(200).json({ message: "Booking successfully deleted." });
|
||||
}
|
||||
|
||||
const attendeeDeletes = prisma.attendee.deleteMany({
|
||||
where: {
|
||||
bookingId: bookingToDelete.id,
|
||||
},
|
||||
});
|
||||
|
||||
const bookingReferenceDeletes = prisma.bookingReference.deleteMany({
|
||||
where: {
|
||||
bookingId: bookingToDelete.id,
|
||||
},
|
||||
});
|
||||
|
||||
// delete scheduled jobs of cancelled bookings
|
||||
updatedBookings.forEach((booking) => {
|
||||
cancelScheduledJobs(booking);
|
||||
});
|
||||
|
||||
//Workflows - delete all reminders for bookings
|
||||
const remindersToDelete: PrismaPromise<Prisma.BatchPayload>[] = [];
|
||||
updatedBookings.forEach((booking) => {
|
||||
booking.workflowReminders.forEach((reminder) => {
|
||||
if (reminder.scheduled && reminder.referenceId) {
|
||||
if (reminder.method === WorkflowMethods.EMAIL) {
|
||||
deleteScheduledEmailReminder(reminder.referenceId);
|
||||
} else if (reminder.method === WorkflowMethods.SMS) {
|
||||
deleteScheduledSMSReminder(reminder.referenceId);
|
||||
}
|
||||
}
|
||||
const reminderToDelete = prisma.workflowReminder.deleteMany({
|
||||
where: {
|
||||
id: reminder.id,
|
||||
},
|
||||
});
|
||||
remindersToDelete.push(reminderToDelete);
|
||||
});
|
||||
});
|
||||
|
||||
await Promise.all([apiDeletes, attendeeDeletes, bookingReferenceDeletes].concat(remindersToDelete));
|
||||
|
||||
await sendCancelledEmails(evt);
|
||||
|
||||
res.status(204).end();
|
||||
/* To mimic API behavior */
|
||||
req.userId = session?.user?.id;
|
||||
return await handleCancelBooking(req);
|
||||
}
|
||||
|
||||
export default defaultHandler({
|
||||
|
|
|
@ -460,7 +460,7 @@ export default function Success(props: SuccessProps) {
|
|||
</>
|
||||
) : (
|
||||
<CancelBooking
|
||||
booking={{ uid: bookingInfo?.uid, title: bookingInfo?.title }}
|
||||
booking={{ uid: bookingInfo?.uid, title: bookingInfo?.title, id: bookingInfo?.id }}
|
||||
profile={{ name: props.profile.name, slug: props.profile.slug }}
|
||||
recurringEvent={eventType.recurringEvent}
|
||||
team={eventType?.team?.name}
|
||||
|
@ -857,6 +857,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||
where,
|
||||
select: {
|
||||
title: true,
|
||||
id: true,
|
||||
uid: true,
|
||||
description: true,
|
||||
customInputs: true,
|
||||
|
|
|
@ -0,0 +1,392 @@
|
|||
import {
|
||||
BookingStatus,
|
||||
Prisma,
|
||||
PrismaPromise,
|
||||
WebhookTriggerEvents,
|
||||
WorkflowMethods,
|
||||
WorkflowReminder,
|
||||
} from "@prisma/client";
|
||||
import { NextApiRequest } from "next";
|
||||
|
||||
import { getCalendar } from "@calcom/app-store/_utils/getCalendar";
|
||||
import { FAKE_DAILY_CREDENTIAL } from "@calcom/app-store/dailyvideo/lib/VideoApiAdapter";
|
||||
import { DailyLocationType } from "@calcom/app-store/locations";
|
||||
import { refund } from "@calcom/app-store/stripepayment/lib/server";
|
||||
import { cancelScheduledJobs } from "@calcom/app-store/zapier/lib/nodeScheduler";
|
||||
import { deleteMeeting } from "@calcom/core/videoClient";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { sendCancelledEmails } from "@calcom/emails";
|
||||
import { deleteScheduledEmailReminder } from "@calcom/features/ee/workflows/lib/reminders/emailReminderManager";
|
||||
import { sendCancelledReminders } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler";
|
||||
import { deleteScheduledSMSReminder } from "@calcom/features/ee/workflows/lib/reminders/smsReminderManager";
|
||||
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
|
||||
import sendPayload, { EventTypeInfo } from "@calcom/features/webhooks/lib/sendPayload";
|
||||
import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
|
||||
import { schemaBookingCancelParams } from "@calcom/prisma/zod-utils";
|
||||
import type { CalendarEvent } from "@calcom/types/Calendar";
|
||||
|
||||
async function handler(req: NextApiRequest & { userId?: number }) {
|
||||
const { userId } = req;
|
||||
|
||||
const { id, uid, allRemainingBookings, cancellationReason } = schemaBookingCancelParams.parse(req.body);
|
||||
|
||||
const bookingToDelete = await prisma.booking.findUnique({
|
||||
where: {
|
||||
id,
|
||||
uid,
|
||||
},
|
||||
select: {
|
||||
...bookingMinimalSelect,
|
||||
recurringEventId: true,
|
||||
userId: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
credentials: true,
|
||||
email: true,
|
||||
timeZone: true,
|
||||
name: true,
|
||||
destinationCalendar: true,
|
||||
},
|
||||
},
|
||||
location: true,
|
||||
references: {
|
||||
select: {
|
||||
uid: true,
|
||||
type: true,
|
||||
externalCalendarId: true,
|
||||
credentialId: true,
|
||||
},
|
||||
},
|
||||
payment: true,
|
||||
paid: true,
|
||||
eventType: {
|
||||
select: {
|
||||
recurringEvent: true,
|
||||
title: true,
|
||||
description: true,
|
||||
requiresConfirmation: true,
|
||||
price: true,
|
||||
currency: true,
|
||||
length: true,
|
||||
workflows: {
|
||||
include: {
|
||||
workflow: {
|
||||
include: {
|
||||
steps: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
uid: true,
|
||||
eventTypeId: true,
|
||||
destinationCalendar: true,
|
||||
smsReminderNumber: true,
|
||||
workflowReminders: true,
|
||||
scheduledJobs: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!bookingToDelete || !bookingToDelete.user) {
|
||||
throw new HttpError({ statusCode: 400, message: "Booking not found" });
|
||||
}
|
||||
|
||||
if (userId !== bookingToDelete.user?.id && bookingToDelete.startTime < new Date()) {
|
||||
throw new HttpError({ statusCode: 400, message: "Cannot cancel past events" });
|
||||
}
|
||||
|
||||
if (!bookingToDelete.userId) {
|
||||
throw new HttpError({ statusCode: 400, message: "User not found" });
|
||||
}
|
||||
|
||||
const organizer = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: bookingToDelete.userId,
|
||||
},
|
||||
select: {
|
||||
name: true,
|
||||
email: true,
|
||||
timeZone: true,
|
||||
locale: true,
|
||||
},
|
||||
});
|
||||
|
||||
const attendeesListPromises = bookingToDelete.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 tOrganizer = await getTranslation(organizer.locale ?? "en", "common");
|
||||
|
||||
const evt: CalendarEvent = {
|
||||
title: bookingToDelete?.title,
|
||||
type: (bookingToDelete?.eventType?.title as string) || bookingToDelete?.title,
|
||||
description: bookingToDelete?.description || "",
|
||||
customInputs: isPrismaObjOrUndefined(bookingToDelete.customInputs),
|
||||
startTime: bookingToDelete?.startTime ? dayjs(bookingToDelete.startTime).format() : "",
|
||||
endTime: bookingToDelete?.endTime ? dayjs(bookingToDelete.endTime).format() : "",
|
||||
organizer: {
|
||||
email: organizer.email,
|
||||
name: organizer.name ?? "Nameless",
|
||||
timeZone: organizer.timeZone,
|
||||
language: { translate: tOrganizer, locale: organizer.locale ?? "en" },
|
||||
},
|
||||
attendees: attendeesList,
|
||||
uid: bookingToDelete?.uid,
|
||||
/* Include recurringEvent information only when cancelling all bookings */
|
||||
recurringEvent: allRemainingBookings
|
||||
? parseRecurringEvent(bookingToDelete.eventType?.recurringEvent)
|
||||
: undefined,
|
||||
location: bookingToDelete?.location,
|
||||
destinationCalendar: bookingToDelete?.destinationCalendar || bookingToDelete?.user.destinationCalendar,
|
||||
cancellationReason: cancellationReason,
|
||||
};
|
||||
// Hook up the webhook logic here
|
||||
const eventTrigger: WebhookTriggerEvents = "BOOKING_CANCELLED";
|
||||
// Send Webhook call if hooked to BOOKING.CANCELLED
|
||||
const subscriberOptions = {
|
||||
userId: bookingToDelete.userId,
|
||||
eventTypeId: (bookingToDelete.eventTypeId as number) || 0,
|
||||
triggerEvent: eventTrigger,
|
||||
};
|
||||
|
||||
const eventTypeInfo: EventTypeInfo = {
|
||||
eventTitle: bookingToDelete?.eventType?.title || null,
|
||||
eventDescription: bookingToDelete?.eventType?.description || null,
|
||||
requiresConfirmation: bookingToDelete?.eventType?.requiresConfirmation || null,
|
||||
price: bookingToDelete?.eventType?.price || null,
|
||||
currency: bookingToDelete?.eventType?.currency || null,
|
||||
length: bookingToDelete?.eventType?.length || null,
|
||||
};
|
||||
|
||||
const webhooks = await getWebhooks(subscriberOptions);
|
||||
const promises = webhooks.map((webhook) =>
|
||||
sendPayload(webhook.secret, eventTrigger, new Date().toISOString(), webhook, {
|
||||
...evt,
|
||||
...eventTypeInfo,
|
||||
status: "CANCELLED",
|
||||
}).catch((e) => {
|
||||
console.error(`Error executing webhook for event: ${eventTrigger}, URL: ${webhook.subscriberUrl}`, e);
|
||||
})
|
||||
);
|
||||
await Promise.all(promises);
|
||||
|
||||
//Workflows - schedule reminders
|
||||
if (bookingToDelete.eventType?.workflows) {
|
||||
await sendCancelledReminders(
|
||||
bookingToDelete.eventType?.workflows,
|
||||
bookingToDelete.smsReminderNumber,
|
||||
evt
|
||||
);
|
||||
}
|
||||
|
||||
let updatedBookings: {
|
||||
uid: string;
|
||||
workflowReminders: WorkflowReminder[];
|
||||
scheduledJobs: string[];
|
||||
}[] = [];
|
||||
|
||||
// by cancelling first, and blocking whilst doing so; we can ensure a cancel
|
||||
// action always succeeds even if subsequent integrations fail cancellation.
|
||||
if (bookingToDelete.eventType?.recurringEvent && bookingToDelete.recurringEventId && allRemainingBookings) {
|
||||
const recurringEventId = bookingToDelete.recurringEventId;
|
||||
// Proceed to mark as cancelled all remaining recurring events instances (greater than or equal to right now)
|
||||
await prisma.booking.updateMany({
|
||||
where: {
|
||||
recurringEventId,
|
||||
startTime: {
|
||||
gte: new Date(),
|
||||
},
|
||||
},
|
||||
data: {
|
||||
status: BookingStatus.CANCELLED,
|
||||
cancellationReason: cancellationReason,
|
||||
},
|
||||
});
|
||||
const allUpdatedBookings = await prisma.booking.findMany({
|
||||
where: {
|
||||
recurringEventId: bookingToDelete.recurringEventId,
|
||||
startTime: {
|
||||
gte: new Date(),
|
||||
},
|
||||
},
|
||||
select: {
|
||||
workflowReminders: true,
|
||||
uid: true,
|
||||
scheduledJobs: true,
|
||||
},
|
||||
});
|
||||
updatedBookings = updatedBookings.concat(allUpdatedBookings);
|
||||
} else {
|
||||
const updatedBooking = await prisma.booking.update({
|
||||
where: {
|
||||
id,
|
||||
uid,
|
||||
},
|
||||
data: {
|
||||
status: BookingStatus.CANCELLED,
|
||||
cancellationReason: cancellationReason,
|
||||
},
|
||||
select: {
|
||||
workflowReminders: true,
|
||||
uid: true,
|
||||
scheduledJobs: true,
|
||||
},
|
||||
});
|
||||
updatedBookings.push(updatedBooking);
|
||||
}
|
||||
|
||||
/** TODO: Remove this without breaking functionality */
|
||||
if (bookingToDelete.location === DailyLocationType) {
|
||||
bookingToDelete.user.credentials.push(FAKE_DAILY_CREDENTIAL);
|
||||
}
|
||||
|
||||
const apiDeletes = [];
|
||||
|
||||
const bookingCalendarReference = bookingToDelete.references.find((reference) =>
|
||||
reference.type.includes("_calendar")
|
||||
);
|
||||
|
||||
if (bookingCalendarReference) {
|
||||
const { credentialId, uid, externalCalendarId } = bookingCalendarReference;
|
||||
// If the booking calendar reference contains a credentialId
|
||||
if (credentialId) {
|
||||
// Find the correct calendar credential under user credentials
|
||||
const calendarCredential = bookingToDelete.user.credentials.find(
|
||||
(credential) => credential.id === credentialId
|
||||
);
|
||||
if (calendarCredential) {
|
||||
const calendar = getCalendar(calendarCredential);
|
||||
apiDeletes.push(calendar?.deleteEvent(uid, evt, externalCalendarId));
|
||||
}
|
||||
// For bookings made before the refactor we go through the old behaviour of running through each calendar credential
|
||||
} else {
|
||||
bookingToDelete.user.credentials
|
||||
.filter((credential) => credential.type.endsWith("_calendar"))
|
||||
.forEach((credential) => {
|
||||
const calendar = getCalendar(credential);
|
||||
apiDeletes.push(calendar?.deleteEvent(uid, evt, externalCalendarId));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const bookingVideoReference = bookingToDelete.references.find((reference) =>
|
||||
reference.type.includes("_video")
|
||||
);
|
||||
|
||||
// If the video reference has a credentialId find the specific credential
|
||||
if (bookingVideoReference && bookingVideoReference.credentialId) {
|
||||
const { credentialId, uid } = bookingVideoReference;
|
||||
if (credentialId) {
|
||||
const videoCredential = bookingToDelete.user.credentials.find(
|
||||
(credential) => credential.id === credentialId
|
||||
);
|
||||
|
||||
if (videoCredential) {
|
||||
apiDeletes.push(deleteMeeting(videoCredential, uid));
|
||||
}
|
||||
}
|
||||
// For bookings made before this refactor we go through the old behaviour of running through each video credential
|
||||
} else {
|
||||
bookingToDelete.user.credentials
|
||||
.filter((credential) => credential.type.endsWith("_video"))
|
||||
.forEach((credential) => {
|
||||
apiDeletes.push(deleteMeeting(credential, bookingToDelete.uid));
|
||||
});
|
||||
}
|
||||
|
||||
// Avoiding taking care of recurrence for now as Payments are not supported with Recurring Events at the moment
|
||||
if (bookingToDelete && bookingToDelete.paid) {
|
||||
const evt: CalendarEvent = {
|
||||
type: bookingToDelete?.eventType?.title as string,
|
||||
title: bookingToDelete.title,
|
||||
description: bookingToDelete.description ?? "",
|
||||
customInputs: isPrismaObjOrUndefined(bookingToDelete.customInputs),
|
||||
startTime: bookingToDelete.startTime.toISOString(),
|
||||
endTime: bookingToDelete.endTime.toISOString(),
|
||||
organizer: {
|
||||
email: bookingToDelete.user?.email ?? "dev@calendso.com",
|
||||
name: bookingToDelete.user?.name ?? "no user",
|
||||
timeZone: bookingToDelete.user?.timeZone ?? "",
|
||||
language: { translate: tOrganizer, locale: organizer.locale ?? "en" },
|
||||
},
|
||||
attendees: attendeesList,
|
||||
location: bookingToDelete.location ?? "",
|
||||
uid: bookingToDelete.uid ?? "",
|
||||
destinationCalendar: bookingToDelete?.destinationCalendar || bookingToDelete?.user.destinationCalendar,
|
||||
};
|
||||
await refund(bookingToDelete, evt);
|
||||
await prisma.booking.update({
|
||||
where: {
|
||||
id: bookingToDelete.id,
|
||||
},
|
||||
data: {
|
||||
status: BookingStatus.REJECTED,
|
||||
},
|
||||
});
|
||||
|
||||
// We skip the deletion of the event, because that would also delete the payment reference, which we should keep
|
||||
await apiDeletes;
|
||||
req.statusCode = 200;
|
||||
return { message: "Booking successfully cancelled." };
|
||||
}
|
||||
|
||||
const attendeeDeletes = prisma.attendee.deleteMany({
|
||||
where: {
|
||||
bookingId: bookingToDelete.id,
|
||||
},
|
||||
});
|
||||
|
||||
const bookingReferenceDeletes = prisma.bookingReference.deleteMany({
|
||||
where: {
|
||||
bookingId: bookingToDelete.id,
|
||||
},
|
||||
});
|
||||
|
||||
// delete scheduled jobs of cancelled bookings
|
||||
updatedBookings.forEach((booking) => {
|
||||
cancelScheduledJobs(booking);
|
||||
});
|
||||
|
||||
//Workflows - delete all reminders for bookings
|
||||
const remindersToDelete: PrismaPromise<Prisma.BatchPayload>[] = [];
|
||||
updatedBookings.forEach((booking) => {
|
||||
booking.workflowReminders.forEach((reminder) => {
|
||||
if (reminder.scheduled && reminder.referenceId) {
|
||||
if (reminder.method === WorkflowMethods.EMAIL) {
|
||||
deleteScheduledEmailReminder(reminder.referenceId);
|
||||
} else if (reminder.method === WorkflowMethods.SMS) {
|
||||
deleteScheduledSMSReminder(reminder.referenceId);
|
||||
}
|
||||
}
|
||||
const reminderToDelete = prisma.workflowReminder.deleteMany({
|
||||
where: {
|
||||
id: reminder.id,
|
||||
},
|
||||
});
|
||||
remindersToDelete.push(reminderToDelete);
|
||||
});
|
||||
});
|
||||
|
||||
await Promise.all([apiDeletes, attendeeDeletes, bookingReferenceDeletes].concat(remindersToDelete));
|
||||
|
||||
await sendCancelledEmails(evt);
|
||||
|
||||
req.statusCode = 200;
|
||||
return { message: "Booking successfully cancelled." };
|
||||
}
|
||||
|
||||
export default handler;
|
|
@ -15,7 +15,6 @@ function middleware(prisma: PrismaClient) {
|
|||
params.args["data"] = { deleted: true };
|
||||
}
|
||||
if (params.action === "deleteMany") {
|
||||
console.log("deletingMany");
|
||||
// Delete many queries
|
||||
params.action = "updateMany";
|
||||
if (params.args.data !== undefined) {
|
||||
|
|
|
@ -160,6 +160,13 @@ export const extendedBookingCreateBody = bookingCreateBodySchema.merge(
|
|||
})
|
||||
);
|
||||
|
||||
export const schemaBookingCancelParams = z.object({
|
||||
id: z.number().optional(),
|
||||
uid: z.string().optional(),
|
||||
allRemainingBookings: z.boolean().optional(),
|
||||
cancellationReason: z.string().optional(),
|
||||
});
|
||||
|
||||
export const vitalSettingsUpdateSchema = z.object({
|
||||
connected: z.boolean().optional(),
|
||||
selectedParam: z.string().optional(),
|
||||
|
|
Loading…
Reference in New Issue