Compare commits
78 Commits
main
...
refactor/c
Author | SHA1 | Date | |
---|---|---|---|
|
3548e68421 | ||
|
92a0ce02c4 | ||
|
71b0a377a4 | ||
|
daa1c00194 | ||
|
e1b1e64a42 | ||
|
922a2afb22 | ||
|
8d27cff430 | ||
|
818b367fb1 | ||
|
25c89f2a8d | ||
|
0e4d03f75f | ||
|
d030f61c7e | ||
|
27b04fb03a | ||
|
294152dc58 | ||
|
6fc8a65892 | ||
|
45eb62ddba | ||
|
2e8e599b70 | ||
|
f9a054127e | ||
|
2f9efede44 | ||
|
d3657c0397 | ||
|
deeafa3a76 | ||
|
6c945e1b24 | ||
|
f50e425d5a | ||
|
26656dd42d | ||
|
e8c2f52048 | ||
|
fb075f0bc2 | ||
|
2669006b79 | ||
|
878eec0041 | ||
|
435a4ab452 | ||
|
c6cf2a295d | ||
|
93ea6497e7 | ||
|
a73dabbfa2 | ||
|
553441f552 | ||
|
bf7e59dc77 | ||
|
b94e8201aa | ||
|
ebb822d98e | ||
|
ffaf5f8259 | ||
|
4c4816db0f | ||
|
64f3a2df45 | ||
|
afa8d6c5c3 | ||
|
e48efcac47 | ||
|
0bb475b173 | ||
|
1b3612dfdb | ||
|
64f465d6ef | ||
|
6ca8ab2877 | ||
|
97e998f107 | ||
|
f7630f50db | ||
|
343e59bd6c | ||
|
b9dd07b7ef | ||
|
7a9fca4f9c | ||
|
1d96a3b8f8 | ||
|
9175760cd8 | ||
|
49a7617265 | ||
|
d375e78ea2 | ||
|
8e21da36df | ||
|
76905a7407 | ||
|
6a9bb9a156 | ||
|
d80f8c0477 | ||
|
ffd67c5eb1 | ||
|
bc8b1e7f45 | ||
|
94c988101b | ||
|
776519e262 | ||
|
9c93891d27 | ||
|
4cf5e30059 | ||
|
593ac641b1 | ||
|
21b86b1e4e | ||
|
0c54667fad | ||
|
d4e7ae0858 | ||
|
b29acc01b8 | ||
|
d768b6bab8 | ||
|
34e5b5d917 | ||
|
d8f9855b7b | ||
|
ba99a3c8da | ||
|
63cd63de7e | ||
|
ad76c74b91 | ||
|
f02119e47f | ||
|
010fd62c8e | ||
|
1e1ceb9bf6 | ||
|
251146a61c |
|
@ -1,5 +1,4 @@
|
|||
import { expect } from "@playwright/test";
|
||||
import { uuid } from "short-uuid";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import { randomString } from "@calcom/lib/random";
|
||||
|
@ -8,7 +7,6 @@ import { BookingStatus } from "@calcom/prisma/enums";
|
|||
|
||||
import { test } from "./lib/fixtures";
|
||||
import {
|
||||
bookTimeSlot,
|
||||
createNewSeatedEventType,
|
||||
selectFirstAvailableTimeSlotNextMonth,
|
||||
createUserWithSeatedEventAndAttendees,
|
||||
|
@ -29,75 +27,8 @@ test.describe("Booking with Seats", () => {
|
|||
await expect(page.locator(`text=Event type updated successfully`)).toBeVisible();
|
||||
});
|
||||
|
||||
test("Multiple Attendees can book a seated event time slot", async ({ users, page }) => {
|
||||
const slug = "my-2-seated-event";
|
||||
const user = await users.create({
|
||||
name: "Seated event user",
|
||||
eventTypes: [
|
||||
{
|
||||
title: "My 2-seated event",
|
||||
slug,
|
||||
length: 60,
|
||||
seatsPerTimeSlot: 2,
|
||||
seatsShowAttendees: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
await page.goto(`/${user.username}/${slug}`);
|
||||
|
||||
let bookingUrl = "";
|
||||
|
||||
await test.step("Attendee #1 can book a seated event time slot", async () => {
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
await bookTimeSlot(page);
|
||||
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
||||
});
|
||||
await test.step("Attendee #2 can book the same seated event time slot", async () => {
|
||||
await page.goto(`/${user.username}/${slug}`);
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
|
||||
await page.waitForURL(/bookingUid/);
|
||||
bookingUrl = page.url();
|
||||
await bookTimeSlot(page, { email: "jane.doe@example.com", name: "Jane Doe" });
|
||||
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
||||
});
|
||||
await test.step("Attendee #3 cannot click on the same seated event time slot", async () => {
|
||||
await page.goto(`/${user.username}/${slug}`);
|
||||
|
||||
await page.click('[data-testid="incrementMonth"]');
|
||||
|
||||
// TODO: Find out why the first day is always booked on tests
|
||||
await page.locator('[data-testid="day"][data-disabled="false"]').nth(0).click();
|
||||
await expect(page.locator('[data-testid="time"][data-disabled="true"]')).toBeVisible();
|
||||
});
|
||||
await test.step("Attendee #3 cannot book the same seated event time slot accessing via url", async () => {
|
||||
await page.goto(bookingUrl);
|
||||
|
||||
await bookTimeSlot(page, { email: "rick@example.com", name: "Rick" });
|
||||
await expect(page.locator("[data-testid=success-page]")).toBeHidden();
|
||||
});
|
||||
|
||||
await test.step("User owner should have only 1 booking with 3 attendees", async () => {
|
||||
// Make sure user owner has only 1 booking with 3 attendees
|
||||
const bookings = await prisma.booking.findMany({
|
||||
where: { eventTypeId: user.eventTypes.find((e) => e.slug === slug)?.id },
|
||||
select: {
|
||||
id: true,
|
||||
attendees: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(bookings).toHaveLength(1);
|
||||
expect(bookings[0].attendees).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
test(`Attendees can cancel a seated event time slot`, async ({ page, users, bookings }) => {
|
||||
const { booking, user } = await createUserWithSeatedEventAndAttendees({ users, bookings }, [
|
||||
test(`Prevent attendees from cancel when having invalid URL params`, async ({ page, users, bookings }) => {
|
||||
const { booking } = await createUserWithSeatedEventAndAttendees({ users, bookings }, [
|
||||
{ name: "John First", email: "first+seats@cal.com", timeZone: "Europe/Berlin" },
|
||||
{ name: "Jane Second", email: "second+seats@cal.com", timeZone: "Europe/Berlin" },
|
||||
{ name: "John Third", email: "third+seats@cal.com", timeZone: "Europe/Berlin" },
|
||||
|
@ -120,30 +51,6 @@ test.describe("Booking with Seats", () => {
|
|||
data: bookingSeats,
|
||||
});
|
||||
|
||||
await test.step("Attendee #1 should be able to cancel their booking", async () => {
|
||||
await page.goto(`/booking/${booking.uid}?seatReferenceUid=${bookingSeats[0].referenceUid}`);
|
||||
|
||||
await page.locator('[data-testid="cancel"]').click();
|
||||
await page.fill('[data-testid="cancel_reason"]', "Double booked!");
|
||||
await page.locator('[data-testid="confirm_cancel"]').click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
await expect(page).toHaveURL(/\/booking\/.*/);
|
||||
|
||||
const cancelledHeadline = page.locator('[data-testid="cancelled-headline"]');
|
||||
await expect(cancelledHeadline).toBeVisible();
|
||||
|
||||
// Old booking should still exist, with one less attendee
|
||||
const updatedBooking = await prisma.booking.findFirst({
|
||||
where: { id: bookingSeats[0].bookingId },
|
||||
include: { attendees: true },
|
||||
});
|
||||
|
||||
const attendeeIds = updatedBooking?.attendees.map(({ id }) => id);
|
||||
expect(attendeeIds).toHaveLength(2);
|
||||
expect(attendeeIds).not.toContain(bookingAttendees[0].id);
|
||||
});
|
||||
|
||||
await test.step("Attendee #2 shouldn't be able to cancel booking using only booking/uid", async () => {
|
||||
await page.goto(`/booking/${booking.uid}`);
|
||||
|
||||
|
@ -156,29 +63,6 @@ test.describe("Booking with Seats", () => {
|
|||
// expect cancel button to don't be in the page
|
||||
await expect(page.locator("[text=Cancel]")).toHaveCount(0);
|
||||
});
|
||||
|
||||
await test.step("All attendees cancelling should delete the booking for the user", async () => {
|
||||
// The remaining 2 attendees cancel
|
||||
for (let i = 1; i < bookingSeats.length; i++) {
|
||||
await page.goto(`/booking/${booking.uid}?seatReferenceUid=${bookingSeats[i].referenceUid}`);
|
||||
|
||||
await page.locator('[data-testid="cancel"]').click();
|
||||
await page.fill('[data-testid="cancel_reason"]', "Double booked!");
|
||||
await page.locator('[data-testid="confirm_cancel"]').click();
|
||||
|
||||
await expect(page).toHaveURL(/\/booking\/.*/);
|
||||
|
||||
const cancelledHeadline = page.locator('[data-testid="cancelled-headline"]');
|
||||
await expect(cancelledHeadline).toBeVisible();
|
||||
}
|
||||
|
||||
// Should expect old booking to be cancelled
|
||||
const updatedBooking = await prisma.booking.findFirst({
|
||||
where: { id: bookingSeats[0].bookingId },
|
||||
});
|
||||
expect(updatedBooking).not.toBeNull();
|
||||
expect(updatedBooking?.status).toBe(BookingStatus.CANCELLED);
|
||||
});
|
||||
});
|
||||
|
||||
test("Owner shouldn't be able to cancel booking without login in", async ({ page, bookings, users }) => {
|
||||
|
@ -224,181 +108,6 @@ test.describe("Booking with Seats", () => {
|
|||
});
|
||||
|
||||
test.describe("Reschedule for booking with seats", () => {
|
||||
test("Should reschedule booking with seats", async ({ page, users, bookings }) => {
|
||||
const { booking } = await createUserWithSeatedEventAndAttendees({ users, bookings }, [
|
||||
{ name: "John First", email: `first+seats-${uuid()}@cal.com`, timeZone: "Europe/Berlin" },
|
||||
{ name: "Jane Second", email: `second+seats-${uuid()}@cal.com`, timeZone: "Europe/Berlin" },
|
||||
{ name: "John Third", email: `third+seats-${uuid()}@cal.com`, timeZone: "Europe/Berlin" },
|
||||
]);
|
||||
const bookingAttendees = await prisma.attendee.findMany({
|
||||
where: { bookingId: booking.id },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
},
|
||||
});
|
||||
|
||||
const bookingSeats = [
|
||||
{ bookingId: booking.id, attendeeId: bookingAttendees[0].id, referenceUid: uuidv4() },
|
||||
{ bookingId: booking.id, attendeeId: bookingAttendees[1].id, referenceUid: uuidv4() },
|
||||
{ bookingId: booking.id, attendeeId: bookingAttendees[2].id, referenceUid: uuidv4() },
|
||||
];
|
||||
|
||||
await prisma.bookingSeat.createMany({
|
||||
data: bookingSeats,
|
||||
});
|
||||
|
||||
const references = await prisma.bookingSeat.findMany({
|
||||
where: { bookingId: booking.id },
|
||||
});
|
||||
|
||||
await page.goto(`/reschedule/${references[2].referenceUid}`);
|
||||
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
|
||||
// expect input to be filled with attendee number 3 data
|
||||
const thirdAttendeeElement = await page.locator("input[name=name]");
|
||||
const attendeeName = await thirdAttendeeElement.inputValue();
|
||||
expect(attendeeName).toBe("John Third");
|
||||
|
||||
await page.locator('[data-testid="confirm-reschedule-button"]').click();
|
||||
|
||||
// should wait for URL but that path starts with booking/
|
||||
await page.waitForURL(/\/booking\/.*/);
|
||||
|
||||
await expect(page).toHaveURL(/\/booking\/.*/);
|
||||
|
||||
// Should expect new booking to be created for John Third
|
||||
const newBooking = await prisma.booking.findFirst({
|
||||
where: {
|
||||
attendees: {
|
||||
some: { email: bookingAttendees[2].email },
|
||||
},
|
||||
},
|
||||
include: { seatsReferences: true, attendees: true },
|
||||
});
|
||||
expect(newBooking?.status).toBe(BookingStatus.PENDING);
|
||||
expect(newBooking?.attendees.length).toBe(1);
|
||||
expect(newBooking?.attendees[0].name).toBe("John Third");
|
||||
expect(newBooking?.seatsReferences.length).toBe(1);
|
||||
|
||||
// Should expect old booking to be accepted with two attendees
|
||||
const oldBooking = await prisma.booking.findFirst({
|
||||
where: { uid: booking.uid },
|
||||
include: { seatsReferences: true, attendees: true },
|
||||
});
|
||||
|
||||
expect(oldBooking?.status).toBe(BookingStatus.ACCEPTED);
|
||||
expect(oldBooking?.attendees.length).toBe(2);
|
||||
expect(oldBooking?.seatsReferences.length).toBe(2);
|
||||
});
|
||||
|
||||
test("Should reschedule booking with seats and if everyone rescheduled it should be deleted", async ({
|
||||
page,
|
||||
users,
|
||||
bookings,
|
||||
}) => {
|
||||
const { booking } = await createUserWithSeatedEventAndAttendees({ users, bookings }, [
|
||||
{ name: "John First", email: "first+seats@cal.com", timeZone: "Europe/Berlin" },
|
||||
{ name: "Jane Second", email: "second+seats@cal.com", timeZone: "Europe/Berlin" },
|
||||
]);
|
||||
|
||||
const bookingAttendees = await prisma.attendee.findMany({
|
||||
where: { bookingId: booking.id },
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
const bookingSeats = [
|
||||
{ bookingId: booking.id, attendeeId: bookingAttendees[0].id, referenceUid: uuidv4() },
|
||||
{ bookingId: booking.id, attendeeId: bookingAttendees[1].id, referenceUid: uuidv4() },
|
||||
];
|
||||
|
||||
await prisma.bookingSeat.createMany({
|
||||
data: bookingSeats,
|
||||
});
|
||||
|
||||
const references = await prisma.bookingSeat.findMany({
|
||||
where: { bookingId: booking.id },
|
||||
});
|
||||
|
||||
await page.goto(`/reschedule/${references[0].referenceUid}`);
|
||||
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
|
||||
await page.locator('[data-testid="confirm-reschedule-button"]').click();
|
||||
|
||||
await page.waitForURL(/\/booking\/.*/);
|
||||
|
||||
await page.goto(`/reschedule/${references[1].referenceUid}`);
|
||||
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
|
||||
await page.locator('[data-testid="confirm-reschedule-button"]').click();
|
||||
|
||||
// Using waitForUrl here fails the assertion `expect(oldBooking?.status).toBe(BookingStatus.CANCELLED);` probably because waitForUrl is considered complete before waitForNavigation and till that time the booking is not cancelled
|
||||
await page.waitForURL(/\/booking\/.*/);
|
||||
|
||||
// Should expect old booking to be cancelled
|
||||
const oldBooking = await prisma.booking.findFirst({
|
||||
where: { uid: booking.uid },
|
||||
include: {
|
||||
seatsReferences: true,
|
||||
attendees: true,
|
||||
eventType: {
|
||||
include: { users: true, hosts: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(oldBooking?.status).toBe(BookingStatus.CANCELLED);
|
||||
});
|
||||
|
||||
test("Should cancel with seats and have no attendees and cancelled", async ({ page, users, bookings }) => {
|
||||
const { user, booking } = await createUserWithSeatedEventAndAttendees({ users, bookings }, [
|
||||
{ name: "John First", email: "first+seats@cal.com", timeZone: "Europe/Berlin" },
|
||||
{ name: "Jane Second", email: "second+seats@cal.com", timeZone: "Europe/Berlin" },
|
||||
]);
|
||||
await user.apiLogin();
|
||||
|
||||
const oldBooking = await prisma.booking.findFirst({
|
||||
where: { uid: booking.uid },
|
||||
include: { seatsReferences: true, attendees: true },
|
||||
});
|
||||
|
||||
const bookingAttendees = await prisma.attendee.findMany({
|
||||
where: { bookingId: booking.id },
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
const bookingSeats = [
|
||||
{ bookingId: booking.id, attendeeId: bookingAttendees[0].id, referenceUid: uuidv4() },
|
||||
{ bookingId: booking.id, attendeeId: bookingAttendees[1].id, referenceUid: uuidv4() },
|
||||
];
|
||||
|
||||
await prisma.bookingSeat.createMany({
|
||||
data: bookingSeats,
|
||||
});
|
||||
|
||||
// Now we cancel the booking as the organizer
|
||||
await page.goto(`/booking/${booking.uid}?cancel=true`);
|
||||
|
||||
await page.locator('[data-testid="confirm_cancel"]').click();
|
||||
|
||||
await expect(page).toHaveURL(/\/booking\/.*/);
|
||||
|
||||
// Should expect old booking to be cancelled
|
||||
const updatedBooking = await prisma.booking.findFirst({
|
||||
where: { uid: booking.uid },
|
||||
include: { seatsReferences: true, attendees: true },
|
||||
});
|
||||
|
||||
expect(oldBooking?.startTime).not.toBe(updatedBooking?.startTime);
|
||||
});
|
||||
|
||||
test("If rescheduled/cancelled booking with seats it should display the correct number of seats", async ({
|
||||
page,
|
||||
users,
|
||||
|
@ -457,7 +166,7 @@ test.describe("Reschedule for booking with seats", () => {
|
|||
expect(await page.locator("text=9 / 10 Seats available").count()).toEqual(0);
|
||||
});
|
||||
|
||||
test("Should cancel with seats but event should be still accesible and with one less attendee/seat", async ({
|
||||
test("Should cancel with seats but event should be still accessible and with one less attendee/seat", async ({
|
||||
page,
|
||||
users,
|
||||
bookings,
|
||||
|
|
|
@ -126,6 +126,7 @@ export type InputEventType = {
|
|||
schedule?: InputUser["schedules"][number];
|
||||
bookingLimits?: IntervalLimit;
|
||||
durationLimits?: IntervalLimit;
|
||||
owner?: number;
|
||||
} & Partial<Omit<Prisma.EventTypeCreateInput, "users" | "schedule" | "bookingLimits" | "durationLimits">>;
|
||||
|
||||
type WhiteListedBookingProps = {
|
||||
|
@ -142,6 +143,7 @@ type WhiteListedBookingProps = {
|
|||
// TODO: Make sure that all references start providing credentialId and then remove this intersection of optional credentialId
|
||||
credentialId?: number | null;
|
||||
})[];
|
||||
bookingSeat?: Prisma.BookingSeatCreateInput[];
|
||||
};
|
||||
|
||||
type InputBooking = Partial<Omit<Booking, keyof WhiteListedBookingProps>> & WhiteListedBookingProps;
|
||||
|
@ -272,6 +274,7 @@ async function addEventTypes(eventTypes: InputEventType[], usersStore: InputUser
|
|||
},
|
||||
}
|
||||
: eventType.schedule,
|
||||
owner: eventType.owner ? { connect: { id: eventType.owner } } : undefined,
|
||||
};
|
||||
});
|
||||
log.silly("TestData: Creating EventType", JSON.stringify(eventTypesWithUsers));
|
||||
|
@ -299,9 +302,10 @@ async function addBookingsToDb(
|
|||
// Make sure that we store the date in Date object always. This is to ensure consistency which Prisma does but not prismock
|
||||
log.silly("Handling Prismock bug-3");
|
||||
const fixedBookings = bookings.map((booking) => {
|
||||
const startTime = getDateObj(booking.startTime);
|
||||
const endTime = getDateObj(booking.endTime);
|
||||
return { ...booking, startTime, endTime };
|
||||
// const startTime = getDateObj(booking.startTime);
|
||||
// const endTime = getDateObj(booking.endTime);
|
||||
// return { ...booking, startTime, endTime };
|
||||
return { ...booking };
|
||||
});
|
||||
|
||||
await prismock.booking.createMany({
|
||||
|
@ -334,7 +338,7 @@ async function addBookings(bookings: InputBooking[]) {
|
|||
);
|
||||
}
|
||||
return {
|
||||
uid: uuidv4(),
|
||||
uid: booking.uid || uuidv4(),
|
||||
workflowReminders: [],
|
||||
references: [],
|
||||
title: "Test Booking Title",
|
||||
|
@ -361,10 +365,23 @@ async function addBookings(bookings: InputBooking[]) {
|
|||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
//@ts-ignore
|
||||
createMany: {
|
||||
data: booking.attendees,
|
||||
data: booking.attendees.map((attendee) => {
|
||||
if (attendee.bookingSeat) {
|
||||
const { bookingSeat, ...attendeeWithoutBookingSeat } = attendee;
|
||||
return {
|
||||
...attendeeWithoutBookingSeat,
|
||||
bookingSeat: {
|
||||
create: { ...bookingSeat, bookingId: booking.id },
|
||||
},
|
||||
};
|
||||
} else {
|
||||
return attendee;
|
||||
}
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return bookingCreate;
|
||||
})
|
||||
);
|
||||
|
@ -1389,13 +1406,18 @@ export function getMockBookingReference(
|
|||
};
|
||||
}
|
||||
|
||||
export function getMockBookingAttendee(attendee: Omit<Attendee, "bookingId">) {
|
||||
export function getMockBookingAttendee(
|
||||
attendee: Omit<Attendee, "bookingId"> & {
|
||||
bookingSeat?: Pick<Prisma.BookingSeatCreateInput, "referenceUid" | "data">;
|
||||
}
|
||||
) {
|
||||
return {
|
||||
id: attendee.id,
|
||||
timeZone: attendee.timeZone,
|
||||
name: attendee.name,
|
||||
email: attendee.email,
|
||||
locale: attendee.locale,
|
||||
bookingSeat: attendee.bookingSeat || null,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
import type z from "zod";
|
||||
|
||||
import type { schemaBookingCancelParams } from "@calcom/prisma/zod-utils";
|
||||
|
||||
export function getMockRequestDataForCancelBooking(data: z.infer<typeof schemaBookingCancelParams>) {
|
||||
return data;
|
||||
}
|
|
@ -5,9 +5,9 @@ import appStore from "@calcom/app-store";
|
|||
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 { deleteMeeting, updateMeeting } from "@calcom/core/videoClient";
|
||||
import { deleteMeeting } from "@calcom/core/videoClient";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { sendCancelledEmails, sendCancelledSeatEmails } from "@calcom/emails";
|
||||
import { sendCancelledEmails } from "@calcom/emails";
|
||||
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
|
||||
import { deleteScheduledEmailReminder } from "@calcom/features/ee/workflows/lib/reminders/emailReminderManager";
|
||||
import { sendCancelledReminders } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler";
|
||||
|
@ -25,12 +25,15 @@ import { handleRefundError } from "@calcom/lib/payment/handleRefundError";
|
|||
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
|
||||
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
|
||||
import { BookingStatus, MembershipRole, WebhookTriggerEvents, WorkflowMethods } from "@calcom/prisma/enums";
|
||||
import type { WebhookTriggerEvents } from "@calcom/prisma/enums";
|
||||
import { BookingStatus, MembershipRole, WorkflowMethods } from "@calcom/prisma/enums";
|
||||
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
|
||||
import { schemaBookingCancelParams } from "@calcom/prisma/zod-utils";
|
||||
import type { CalendarEvent } from "@calcom/types/Calendar";
|
||||
import type { IAbstractPaymentService, PaymentApp } from "@calcom/types/PaymentService";
|
||||
|
||||
import cancelAttendeeSeat from "./handleSeats/cancel/cancelAttendeeSeat";
|
||||
|
||||
async function getBookingToDelete(id: number | undefined, uid: string | undefined) {
|
||||
return await prisma.booking.findUnique({
|
||||
where: {
|
||||
|
@ -121,7 +124,7 @@ async function getBookingToDelete(id: number | undefined, uid: string | undefine
|
|||
});
|
||||
}
|
||||
|
||||
type CustomRequest = NextApiRequest & {
|
||||
export type CustomRequest = NextApiRequest & {
|
||||
userId?: number;
|
||||
bookingToDelete?: Awaited<ReturnType<typeof getBookingToDelete>>;
|
||||
};
|
||||
|
@ -273,30 +276,9 @@ async function handler(req: CustomRequest) {
|
|||
const dataForWebhooks = { evt, webhooks, eventTypeInfo };
|
||||
|
||||
// If it's just an attendee of a booking then just remove them from that booking
|
||||
const result = await handleSeatedEventCancellation(req, dataForWebhooks);
|
||||
const result = await cancelAttendeeSeat(req, dataForWebhooks);
|
||||
if (result) return { success: true };
|
||||
|
||||
// If it's just an attendee of a booking then just remove them from that booking
|
||||
if (seatReferenceUid && bookingToDelete.attendees.length > 1) {
|
||||
const seatReference = bookingToDelete.seatsReferences.find(
|
||||
(reference) => reference.referenceUid === seatReferenceUid
|
||||
);
|
||||
|
||||
const attendee = bookingToDelete.attendees.find((attendee) => attendee.id === seatReference?.attendeeId);
|
||||
|
||||
if (!seatReference || !attendee)
|
||||
throw new HttpError({ statusCode: 400, message: "User not a part of this booking" });
|
||||
|
||||
await prisma.attendee.delete({
|
||||
where: {
|
||||
id: seatReference.attendeeId,
|
||||
},
|
||||
});
|
||||
|
||||
req.statusCode = 200;
|
||||
return { message: "No longer attending event" };
|
||||
}
|
||||
|
||||
const promises = webhooks.map((webhook) =>
|
||||
sendPayload(webhook.secret, eventTrigger, new Date().toISOString(), webhook, {
|
||||
...evt,
|
||||
|
@ -680,147 +662,4 @@ async function handler(req: CustomRequest) {
|
|||
return { message: "Booking successfully cancelled." };
|
||||
}
|
||||
|
||||
async function handleSeatedEventCancellation(
|
||||
req: CustomRequest,
|
||||
dataForWebhooks: {
|
||||
webhooks: {
|
||||
id: string;
|
||||
subscriberUrl: string;
|
||||
payloadTemplate: string | null;
|
||||
appId: string | null;
|
||||
secret: string | null;
|
||||
}[];
|
||||
evt: CalendarEvent;
|
||||
eventTypeInfo: EventTypeInfo;
|
||||
}
|
||||
) {
|
||||
const { seatReferenceUid } = schemaBookingCancelParams.parse(req.body);
|
||||
const { webhooks, evt, eventTypeInfo } = dataForWebhooks;
|
||||
if (!seatReferenceUid) return;
|
||||
const bookingToDelete = req.bookingToDelete;
|
||||
if (!bookingToDelete?.attendees.length || bookingToDelete.attendees.length < 2) return;
|
||||
|
||||
if (!bookingToDelete.userId) {
|
||||
throw new HttpError({ statusCode: 400, message: "User not found" });
|
||||
}
|
||||
|
||||
const seatReference = bookingToDelete.seatsReferences.find(
|
||||
(reference) => reference.referenceUid === seatReferenceUid
|
||||
);
|
||||
|
||||
if (!seatReference) throw new HttpError({ statusCode: 400, message: "User not a part of this booking" });
|
||||
|
||||
await Promise.all([
|
||||
prisma.bookingSeat.delete({
|
||||
where: {
|
||||
referenceUid: seatReferenceUid,
|
||||
},
|
||||
}),
|
||||
prisma.attendee.delete({
|
||||
where: {
|
||||
id: seatReference.attendeeId,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
req.statusCode = 200;
|
||||
|
||||
const attendee = bookingToDelete?.attendees.find((attendee) => attendee.id === seatReference.attendeeId);
|
||||
|
||||
if (attendee) {
|
||||
/* If there are references then we should update them as well */
|
||||
|
||||
const integrationsToUpdate = [];
|
||||
|
||||
for (const reference of bookingToDelete.references) {
|
||||
if (reference.credentialId) {
|
||||
const credential = await prisma.credential.findUnique({
|
||||
where: {
|
||||
id: reference.credentialId,
|
||||
},
|
||||
select: credentialForCalendarServiceSelect,
|
||||
});
|
||||
|
||||
if (credential) {
|
||||
const updatedEvt = {
|
||||
...evt,
|
||||
attendees: evt.attendees.filter((evtAttendee) => attendee.email !== evtAttendee.email),
|
||||
};
|
||||
if (reference.type.includes("_video")) {
|
||||
integrationsToUpdate.push(updateMeeting(credential, updatedEvt, reference));
|
||||
}
|
||||
if (reference.type.includes("_calendar")) {
|
||||
const calendar = await getCalendar(credential);
|
||||
if (calendar) {
|
||||
integrationsToUpdate.push(
|
||||
calendar?.updateEvent(reference.uid, updatedEvt, reference.externalCalendarId)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await Promise.all(integrationsToUpdate);
|
||||
} catch (error) {
|
||||
// Shouldn't stop code execution if integrations fail
|
||||
// as integrations was already updated
|
||||
}
|
||||
|
||||
const tAttendees = await getTranslation(attendee.locale ?? "en", "common");
|
||||
|
||||
await sendCancelledSeatEmails(evt, {
|
||||
...attendee,
|
||||
language: { translate: tAttendees, locale: attendee.locale ?? "en" },
|
||||
});
|
||||
}
|
||||
|
||||
evt.attendees = attendee
|
||||
? [
|
||||
{
|
||||
...attendee,
|
||||
language: {
|
||||
translate: await getTranslation(attendee.locale ?? "en", "common"),
|
||||
locale: attendee.locale ?? "en",
|
||||
},
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
const promises = webhooks.map((webhook) =>
|
||||
sendPayload(webhook.secret, WebhookTriggerEvents.BOOKING_CANCELLED, new Date().toISOString(), webhook, {
|
||||
...evt,
|
||||
...eventTypeInfo,
|
||||
status: "CANCELLED",
|
||||
smsReminderNumber: bookingToDelete.smsReminderNumber || undefined,
|
||||
}).catch((e) => {
|
||||
console.error(
|
||||
`Error executing webhook for event: ${WebhookTriggerEvents.BOOKING_CANCELLED}, URL: ${webhook.subscriberUrl}`,
|
||||
e
|
||||
);
|
||||
})
|
||||
);
|
||||
await Promise.all(promises);
|
||||
|
||||
const workflowRemindersForAttendee = bookingToDelete?.workflowReminders.filter(
|
||||
(reminder) => reminder.seatReferenceId === seatReferenceUid
|
||||
);
|
||||
|
||||
if (workflowRemindersForAttendee && workflowRemindersForAttendee.length !== 0) {
|
||||
const deletionPromises = workflowRemindersForAttendee.map((reminder) => {
|
||||
if (reminder.method === WorkflowMethods.EMAIL) {
|
||||
return deleteScheduledEmailReminder(reminder.id, reminder.referenceId);
|
||||
} else if (reminder.method === WorkflowMethods.SMS) {
|
||||
return deleteScheduledSMSReminder(reminder.id, reminder.referenceId);
|
||||
} else if (reminder.method === WorkflowMethods.WHATSAPP) {
|
||||
return deleteScheduledWhatsappReminder(reminder.id, reminder.referenceId);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.allSettled(deletionPromises);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export default handler;
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,162 @@
|
|||
import { getCalendar } from "@calcom/app-store/_utils/getCalendar";
|
||||
import { updateMeeting } from "@calcom/core/videoClient";
|
||||
import { sendCancelledSeatEmails } from "@calcom/emails";
|
||||
import { deleteScheduledEmailReminder } from "@calcom/features/ee/workflows/lib/reminders/emailReminderManager";
|
||||
import { deleteScheduledSMSReminder } from "@calcom/features/ee/workflows/lib/reminders/smsReminderManager";
|
||||
import { deleteScheduledWhatsappReminder } from "@calcom/features/ee/workflows/lib/reminders/whatsappReminderManager";
|
||||
import type { EventTypeInfo } from "@calcom/features/webhooks/lib/sendPayload";
|
||||
import sendPayload from "@calcom/features/webhooks/lib/sendPayload";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { WebhookTriggerEvents, WorkflowMethods } from "@calcom/prisma/enums";
|
||||
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
|
||||
import { schemaBookingCancelParams } from "@calcom/prisma/zod-utils";
|
||||
import type { CalendarEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import type { CustomRequest } from "../../handleCancelBooking";
|
||||
|
||||
async function cancelAttendeeSeat(
|
||||
req: CustomRequest,
|
||||
dataForWebhooks: {
|
||||
webhooks: {
|
||||
id: string;
|
||||
subscriberUrl: string;
|
||||
payloadTemplate: string | null;
|
||||
appId: string | null;
|
||||
secret: string | null;
|
||||
}[];
|
||||
evt: CalendarEvent;
|
||||
eventTypeInfo: EventTypeInfo;
|
||||
}
|
||||
) {
|
||||
const { seatReferenceUid } = schemaBookingCancelParams.parse(req.body);
|
||||
const { webhooks, evt, eventTypeInfo } = dataForWebhooks;
|
||||
if (!seatReferenceUid) return;
|
||||
const bookingToDelete = req.bookingToDelete;
|
||||
if (!bookingToDelete?.attendees.length || bookingToDelete.attendees.length < 2) return;
|
||||
|
||||
if (!bookingToDelete.userId) {
|
||||
throw new HttpError({ statusCode: 400, message: "User not found" });
|
||||
}
|
||||
|
||||
const seatReference = bookingToDelete.seatsReferences.find(
|
||||
(reference) => reference.referenceUid === seatReferenceUid
|
||||
);
|
||||
|
||||
if (!seatReference) throw new HttpError({ statusCode: 400, message: "User not a part of this booking" });
|
||||
|
||||
await Promise.all([
|
||||
prisma.bookingSeat.delete({
|
||||
where: {
|
||||
referenceUid: seatReferenceUid,
|
||||
},
|
||||
}),
|
||||
prisma.attendee.delete({
|
||||
where: {
|
||||
id: seatReference.attendeeId,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
req.statusCode = 200;
|
||||
|
||||
const attendee = bookingToDelete?.attendees.find((attendee) => attendee.id === seatReference.attendeeId);
|
||||
|
||||
if (attendee) {
|
||||
/* If there are references then we should update them as well */
|
||||
|
||||
const integrationsToUpdate = [];
|
||||
|
||||
for (const reference of bookingToDelete.references) {
|
||||
if (reference.credentialId) {
|
||||
const credential = await prisma.credential.findUnique({
|
||||
where: {
|
||||
id: reference.credentialId,
|
||||
},
|
||||
select: credentialForCalendarServiceSelect,
|
||||
});
|
||||
|
||||
if (credential) {
|
||||
const updatedEvt = {
|
||||
...evt,
|
||||
attendees: evt.attendees.filter((evtAttendee) => attendee.email !== evtAttendee.email),
|
||||
};
|
||||
if (reference.type.includes("_video")) {
|
||||
integrationsToUpdate.push(updateMeeting(credential, updatedEvt, reference));
|
||||
}
|
||||
if (reference.type.includes("_calendar")) {
|
||||
const calendar = await getCalendar(credential);
|
||||
if (calendar) {
|
||||
integrationsToUpdate.push(
|
||||
calendar?.updateEvent(reference.uid, updatedEvt, reference.externalCalendarId)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await Promise.all(integrationsToUpdate);
|
||||
} catch (error) {
|
||||
// Shouldn't stop code execution if integrations fail
|
||||
// as integrations was already updated
|
||||
}
|
||||
|
||||
const tAttendees = await getTranslation(attendee.locale ?? "en", "common");
|
||||
|
||||
await sendCancelledSeatEmails(evt, {
|
||||
...attendee,
|
||||
language: { translate: tAttendees, locale: attendee.locale ?? "en" },
|
||||
});
|
||||
}
|
||||
|
||||
evt.attendees = attendee
|
||||
? [
|
||||
{
|
||||
...attendee,
|
||||
language: {
|
||||
translate: await getTranslation(attendee.locale ?? "en", "common"),
|
||||
locale: attendee.locale ?? "en",
|
||||
},
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
const promises = webhooks.map((webhook) =>
|
||||
sendPayload(webhook.secret, WebhookTriggerEvents.BOOKING_CANCELLED, new Date().toISOString(), webhook, {
|
||||
...evt,
|
||||
...eventTypeInfo,
|
||||
status: "CANCELLED",
|
||||
smsReminderNumber: bookingToDelete.smsReminderNumber || undefined,
|
||||
}).catch((e) => {
|
||||
console.error(
|
||||
`Error executing webhook for event: ${WebhookTriggerEvents.BOOKING_CANCELLED}, URL: ${webhook.subscriberUrl}`,
|
||||
e
|
||||
);
|
||||
})
|
||||
);
|
||||
await Promise.all(promises);
|
||||
|
||||
const workflowRemindersForAttendee = bookingToDelete?.workflowReminders.filter(
|
||||
(reminder) => reminder.seatReferenceId === seatReferenceUid
|
||||
);
|
||||
|
||||
if (workflowRemindersForAttendee && workflowRemindersForAttendee.length !== 0) {
|
||||
const deletionPromises = workflowRemindersForAttendee.map((reminder) => {
|
||||
if (reminder.method === WorkflowMethods.EMAIL) {
|
||||
return deleteScheduledEmailReminder(reminder.id, reminder.referenceId);
|
||||
} else if (reminder.method === WorkflowMethods.SMS) {
|
||||
return deleteScheduledSMSReminder(reminder.id, reminder.referenceId);
|
||||
} else if (reminder.method === WorkflowMethods.WHATSAPP) {
|
||||
return deleteScheduledWhatsappReminder(reminder.id, reminder.referenceId);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.allSettled(deletionPromises);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export default cancelAttendeeSeat;
|
|
@ -0,0 +1,201 @@
|
|||
// eslint-disable-next-line no-restricted-imports
|
||||
import { cloneDeep } from "lodash";
|
||||
import { uuid } from "short-uuid";
|
||||
|
||||
import EventManager from "@calcom/core/EventManager";
|
||||
import { sendScheduledSeatsEmails } from "@calcom/emails";
|
||||
import {
|
||||
allowDisablingAttendeeConfirmationEmails,
|
||||
allowDisablingHostConfirmationEmails,
|
||||
} from "@calcom/features/ee/workflows/lib/allowDisablingStandardEmails";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { handlePayment } from "@calcom/lib/payment/handlePayment";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
|
||||
import type { IEventTypePaymentCredentialType } from "../../handleNewBooking";
|
||||
import { refreshCredentials, findBookingQuery } from "../../handleNewBooking";
|
||||
import type { SeatedBooking, NewSeatedBookingObject, HandleSeatsResultBooking } from "../types";
|
||||
|
||||
const createNewSeat = async (
|
||||
rescheduleSeatedBookingObject: NewSeatedBookingObject,
|
||||
seatedBooking: SeatedBooking
|
||||
) => {
|
||||
const {
|
||||
tAttendees,
|
||||
attendeeLanguage,
|
||||
invitee,
|
||||
eventType,
|
||||
reqBookingUid,
|
||||
additionalNotes,
|
||||
noEmail,
|
||||
paymentAppData,
|
||||
allCredentials,
|
||||
organizerUser,
|
||||
fullName,
|
||||
bookerEmail,
|
||||
} = rescheduleSeatedBookingObject;
|
||||
let { evt } = rescheduleSeatedBookingObject;
|
||||
let resultBooking: HandleSeatsResultBooking;
|
||||
// Need to add translation for attendees to pass type checks. Since these values are never written to the db we can just use the new attendee language
|
||||
const bookingAttendees = seatedBooking.attendees.map((attendee) => {
|
||||
return { ...attendee, language: { translate: tAttendees, locale: attendeeLanguage ?? "en" } };
|
||||
});
|
||||
|
||||
evt = { ...evt, attendees: [...bookingAttendees, invitee[0]] };
|
||||
|
||||
if (eventType.seatsPerTimeSlot && eventType.seatsPerTimeSlot <= seatedBooking.attendees.length) {
|
||||
throw new HttpError({ statusCode: 409, message: "Booking seats are full" });
|
||||
}
|
||||
|
||||
const videoCallReference = seatedBooking.references.find((reference) => reference.type.includes("_video"));
|
||||
|
||||
if (videoCallReference) {
|
||||
evt.videoCallData = {
|
||||
type: videoCallReference.type,
|
||||
id: videoCallReference.meetingId,
|
||||
password: videoCallReference?.meetingPassword,
|
||||
url: videoCallReference.meetingUrl,
|
||||
};
|
||||
}
|
||||
|
||||
const attendeeUniqueId = uuid();
|
||||
|
||||
await prisma.booking.update({
|
||||
where: {
|
||||
uid: reqBookingUid,
|
||||
},
|
||||
include: {
|
||||
attendees: true,
|
||||
},
|
||||
data: {
|
||||
attendees: {
|
||||
create: {
|
||||
email: invitee[0].email,
|
||||
name: invitee[0].name,
|
||||
timeZone: invitee[0].timeZone,
|
||||
locale: invitee[0].language.locale,
|
||||
bookingSeat: {
|
||||
create: {
|
||||
referenceUid: attendeeUniqueId,
|
||||
data: {
|
||||
description: additionalNotes,
|
||||
},
|
||||
booking: {
|
||||
connect: {
|
||||
id: seatedBooking.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
...(seatedBooking.status === BookingStatus.CANCELLED && { status: BookingStatus.ACCEPTED }),
|
||||
},
|
||||
});
|
||||
|
||||
evt.attendeeSeatId = attendeeUniqueId;
|
||||
|
||||
const newSeat = seatedBooking.attendees.length !== 0;
|
||||
|
||||
/**
|
||||
* Remember objects are passed into functions as references
|
||||
* so if you modify it in a inner function it will be modified in the outer function
|
||||
* deep cloning evt to avoid this
|
||||
*/
|
||||
if (!evt?.uid) {
|
||||
evt.uid = seatedBooking?.uid ?? null;
|
||||
}
|
||||
const copyEvent = cloneDeep(evt);
|
||||
copyEvent.uid = seatedBooking.uid;
|
||||
if (noEmail !== true) {
|
||||
let isHostConfirmationEmailsDisabled = false;
|
||||
let isAttendeeConfirmationEmailDisabled = false;
|
||||
|
||||
const workflows = eventType.workflows.map((workflow) => workflow.workflow);
|
||||
|
||||
if (eventType.workflows) {
|
||||
isHostConfirmationEmailsDisabled =
|
||||
eventType.metadata?.disableStandardEmails?.confirmation?.host || false;
|
||||
isAttendeeConfirmationEmailDisabled =
|
||||
eventType.metadata?.disableStandardEmails?.confirmation?.attendee || false;
|
||||
|
||||
if (isHostConfirmationEmailsDisabled) {
|
||||
isHostConfirmationEmailsDisabled = allowDisablingHostConfirmationEmails(workflows);
|
||||
}
|
||||
|
||||
if (isAttendeeConfirmationEmailDisabled) {
|
||||
isAttendeeConfirmationEmailDisabled = allowDisablingAttendeeConfirmationEmails(workflows);
|
||||
}
|
||||
}
|
||||
await sendScheduledSeatsEmails(
|
||||
copyEvent,
|
||||
invitee[0],
|
||||
newSeat,
|
||||
!!eventType.seatsShowAttendees,
|
||||
isHostConfirmationEmailsDisabled,
|
||||
isAttendeeConfirmationEmailDisabled
|
||||
);
|
||||
}
|
||||
const credentials = await refreshCredentials(allCredentials);
|
||||
const eventManager = new EventManager({ ...organizerUser, credentials });
|
||||
await eventManager.updateCalendarAttendees(evt, seatedBooking);
|
||||
|
||||
const foundBooking = await findBookingQuery(seatedBooking.id);
|
||||
|
||||
if (!Number.isNaN(paymentAppData.price) && paymentAppData.price > 0 && !!seatedBooking) {
|
||||
const credentialPaymentAppCategories = await prisma.credential.findMany({
|
||||
where: {
|
||||
...(paymentAppData.credentialId ? { id: paymentAppData.credentialId } : { userId: organizerUser.id }),
|
||||
app: {
|
||||
categories: {
|
||||
hasSome: ["payment"],
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
key: true,
|
||||
appId: true,
|
||||
app: {
|
||||
select: {
|
||||
categories: true,
|
||||
dirName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const eventTypePaymentAppCredential = credentialPaymentAppCategories.find((credential) => {
|
||||
return credential.appId === paymentAppData.appId;
|
||||
});
|
||||
|
||||
if (!eventTypePaymentAppCredential) {
|
||||
throw new HttpError({ statusCode: 400, message: "Missing payment credentials" });
|
||||
}
|
||||
if (!eventTypePaymentAppCredential?.appId) {
|
||||
throw new HttpError({ statusCode: 400, message: "Missing payment app id" });
|
||||
}
|
||||
|
||||
const payment = await handlePayment(
|
||||
evt,
|
||||
eventType,
|
||||
eventTypePaymentAppCredential as IEventTypePaymentCredentialType,
|
||||
seatedBooking,
|
||||
fullName,
|
||||
bookerEmail
|
||||
);
|
||||
|
||||
resultBooking = { ...foundBooking };
|
||||
resultBooking["message"] = "Payment required";
|
||||
resultBooking["paymentUid"] = payment?.uid;
|
||||
resultBooking["id"] = payment?.id;
|
||||
} else {
|
||||
resultBooking = { ...foundBooking };
|
||||
}
|
||||
|
||||
resultBooking["seatReferenceUid"] = evt.attendeeSeatId;
|
||||
|
||||
return resultBooking;
|
||||
};
|
||||
|
||||
export default createNewSeat;
|
|
@ -0,0 +1,132 @@
|
|||
// eslint-disable-next-line no-restricted-imports
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { handleWebhookTrigger } from "@calcom/features/bookings/lib/handleWebhookTrigger";
|
||||
import { scheduleWorkflowReminders } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
|
||||
import { createLoggerWithEventDetails } from "../handleNewBooking";
|
||||
import createNewSeat from "./create/createNewSeat";
|
||||
import rescheduleSeatedBooking from "./reschedule/rescheduleSeatedBooking";
|
||||
import type { NewSeatedBookingObject, SeatedBooking, HandleSeatsResultBooking } from "./types";
|
||||
|
||||
const handleSeats = async (newSeatedBookingObject: NewSeatedBookingObject) => {
|
||||
const {
|
||||
eventType,
|
||||
reqBodyUser,
|
||||
rescheduleUid,
|
||||
reqBookingUid,
|
||||
invitee,
|
||||
bookerEmail,
|
||||
smsReminderNumber,
|
||||
eventTypeInfo,
|
||||
uid,
|
||||
originalRescheduledBooking,
|
||||
reqBodyMetadata,
|
||||
eventTypeId,
|
||||
subscriberOptions,
|
||||
eventTrigger,
|
||||
} = newSeatedBookingObject;
|
||||
const { evt } = newSeatedBookingObject;
|
||||
const loggerWithEventDetails = createLoggerWithEventDetails(eventType.id, reqBodyUser, eventType.slug);
|
||||
|
||||
let resultBooking: HandleSeatsResultBooking = null;
|
||||
|
||||
const seatedBooking: SeatedBooking | null = await prisma.booking.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
uid: rescheduleUid || reqBookingUid,
|
||||
},
|
||||
{
|
||||
eventTypeId: eventType.id,
|
||||
startTime: evt.startTime,
|
||||
},
|
||||
],
|
||||
status: BookingStatus.ACCEPTED,
|
||||
},
|
||||
select: {
|
||||
uid: true,
|
||||
id: true,
|
||||
attendees: { include: { bookingSeat: true } },
|
||||
userId: true,
|
||||
references: true,
|
||||
startTime: true,
|
||||
user: true,
|
||||
status: true,
|
||||
smsReminderNumber: true,
|
||||
endTime: true,
|
||||
scheduledJobs: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!seatedBooking) {
|
||||
throw new HttpError({ statusCode: 404, message: "Could not find booking" });
|
||||
}
|
||||
|
||||
// See if attendee is already signed up for timeslot
|
||||
if (
|
||||
seatedBooking.attendees.find((attendee) => attendee.email === invitee[0].email) &&
|
||||
dayjs.utc(seatedBooking.startTime).format() === evt.startTime
|
||||
) {
|
||||
throw new HttpError({ statusCode: 409, message: "Already signed up for this booking." });
|
||||
}
|
||||
|
||||
// There are two paths here, reschedule a booking with seats and booking seats without reschedule
|
||||
if (rescheduleUid) {
|
||||
resultBooking = await rescheduleSeatedBooking(
|
||||
// Assert that the rescheduleUid is defined
|
||||
{ ...newSeatedBookingObject, rescheduleUid },
|
||||
seatedBooking,
|
||||
resultBooking,
|
||||
loggerWithEventDetails
|
||||
);
|
||||
} else {
|
||||
resultBooking = await createNewSeat(newSeatedBookingObject, seatedBooking);
|
||||
}
|
||||
|
||||
// Here we should handle every after action that needs to be done after booking creation
|
||||
|
||||
// Obtain event metadata that includes videoCallUrl
|
||||
const metadata = evt.videoCallData?.url ? { videoCallUrl: evt.videoCallData.url } : undefined;
|
||||
try {
|
||||
await scheduleWorkflowReminders({
|
||||
workflows: eventType.workflows,
|
||||
smsReminderNumber: smsReminderNumber || null,
|
||||
calendarEvent: { ...evt, ...{ metadata, eventType: { slug: eventType.slug } } },
|
||||
isNotConfirmed: evt.requiresConfirmation || false,
|
||||
isRescheduleEvent: !!rescheduleUid,
|
||||
isFirstRecurringEvent: true,
|
||||
emailAttendeeSendToOverride: bookerEmail,
|
||||
seatReferenceUid: evt.attendeeSeatId,
|
||||
eventTypeRequiresConfirmation: eventType.requiresConfirmation,
|
||||
});
|
||||
} catch (error) {
|
||||
loggerWithEventDetails.error("Error while scheduling workflow reminders", JSON.stringify({ error }));
|
||||
}
|
||||
|
||||
const webhookData = {
|
||||
...evt,
|
||||
...eventTypeInfo,
|
||||
uid: resultBooking?.uid || uid,
|
||||
bookingId: seatedBooking?.id,
|
||||
rescheduleUid,
|
||||
rescheduleStartTime: originalRescheduledBooking?.startTime
|
||||
? dayjs(originalRescheduledBooking?.startTime).utc().format()
|
||||
: undefined,
|
||||
rescheduleEndTime: originalRescheduledBooking?.endTime
|
||||
? dayjs(originalRescheduledBooking?.endTime).utc().format()
|
||||
: undefined,
|
||||
metadata: { ...metadata, ...reqBodyMetadata },
|
||||
eventTypeId,
|
||||
status: "ACCEPTED",
|
||||
smsReminderNumber: seatedBooking?.smsReminderNumber || undefined,
|
||||
};
|
||||
|
||||
await handleWebhookTrigger({ subscriberOptions, eventTrigger, webhookData });
|
||||
|
||||
return resultBooking;
|
||||
};
|
||||
|
||||
export default handleSeats;
|
|
@ -0,0 +1,64 @@
|
|||
import type { Attendee } from "@prisma/client";
|
||||
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { getCalendar } from "@calcom/app-store/_utils/getCalendar";
|
||||
import { deleteMeeting } from "@calcom/core/videoClient";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
|
||||
import type { CalendarEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import type { OriginalRescheduledBooking } from "../../handleNewBooking";
|
||||
|
||||
/* Check if the original booking has no more attendees, if so delete the booking
|
||||
and any calendar or video integrations */
|
||||
const lastAttendeeDeleteBooking = async (
|
||||
originalRescheduledBooking: OriginalRescheduledBooking,
|
||||
filteredAttendees: Partial<Attendee>[],
|
||||
originalBookingEvt?: CalendarEvent
|
||||
) => {
|
||||
let deletedReferences = false;
|
||||
if (filteredAttendees && filteredAttendees.length === 0 && originalRescheduledBooking) {
|
||||
const integrationsToDelete = [];
|
||||
|
||||
for (const reference of originalRescheduledBooking.references) {
|
||||
if (reference.credentialId) {
|
||||
const credential = await prisma.credential.findUnique({
|
||||
where: {
|
||||
id: reference.credentialId,
|
||||
},
|
||||
select: credentialForCalendarServiceSelect,
|
||||
});
|
||||
|
||||
if (credential) {
|
||||
if (reference.type.includes("_video")) {
|
||||
integrationsToDelete.push(deleteMeeting(credential, reference.uid));
|
||||
}
|
||||
if (reference.type.includes("_calendar") && originalBookingEvt) {
|
||||
const calendar = await getCalendar(credential);
|
||||
if (calendar) {
|
||||
integrationsToDelete.push(
|
||||
calendar?.deleteEvent(reference.uid, originalBookingEvt, reference.externalCalendarId)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(integrationsToDelete).then(async () => {
|
||||
await prisma.booking.update({
|
||||
where: {
|
||||
id: originalRescheduledBooking.id,
|
||||
},
|
||||
data: {
|
||||
status: BookingStatus.CANCELLED,
|
||||
},
|
||||
});
|
||||
});
|
||||
deletedReferences = true;
|
||||
}
|
||||
return deletedReferences;
|
||||
};
|
||||
|
||||
export default lastAttendeeDeleteBooking;
|
|
@ -0,0 +1,102 @@
|
|||
// eslint-disable-next-line no-restricted-imports
|
||||
import { cloneDeep } from "lodash";
|
||||
|
||||
import type EventManager from "@calcom/core/EventManager";
|
||||
import { sendRescheduledSeatEmail } from "@calcom/emails";
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { Person, CalendarEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import { findBookingQuery } from "../../../handleNewBooking";
|
||||
import lastAttendeeDeleteBooking from "../../lib/lastAttendeeDeleteBooking";
|
||||
import type { RescheduleSeatedBookingObject, SeatAttendee, NewTimeSlotBooking } from "../../types";
|
||||
|
||||
const attendeeRescheduleSeatedBooking = async (
|
||||
rescheduleSeatedBookingObject: RescheduleSeatedBookingObject,
|
||||
seatAttendee: SeatAttendee,
|
||||
newTimeSlotBooking: NewTimeSlotBooking | null,
|
||||
originalBookingEvt: CalendarEvent,
|
||||
eventManager: EventManager
|
||||
) => {
|
||||
const { tAttendees, bookingSeat, bookerEmail, rescheduleUid, evt } = rescheduleSeatedBookingObject;
|
||||
let { originalRescheduledBooking } = rescheduleSeatedBookingObject;
|
||||
|
||||
seatAttendee["language"] = { translate: tAttendees, locale: bookingSeat?.attendee.locale ?? "en" };
|
||||
|
||||
// If there is no booking then remove the attendee from the old booking and create a new one
|
||||
if (!newTimeSlotBooking) {
|
||||
await prisma.attendee.delete({
|
||||
where: {
|
||||
id: seatAttendee?.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Update the original calendar event by removing the attendee that is rescheduling
|
||||
if (originalBookingEvt && originalRescheduledBooking) {
|
||||
// Event would probably be deleted so we first check than instead of updating references
|
||||
const filteredAttendees = originalRescheduledBooking?.attendees.filter((attendee) => {
|
||||
return attendee.email !== bookerEmail;
|
||||
});
|
||||
const deletedReference = await lastAttendeeDeleteBooking(
|
||||
originalRescheduledBooking,
|
||||
filteredAttendees,
|
||||
originalBookingEvt
|
||||
);
|
||||
|
||||
if (!deletedReference) {
|
||||
await eventManager.updateCalendarAttendees(originalBookingEvt, originalRescheduledBooking);
|
||||
}
|
||||
}
|
||||
|
||||
// We don't want to trigger rescheduling logic of the original booking
|
||||
originalRescheduledBooking = null;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Need to change the new seat reference and attendee record to remove it from the old booking and add it to the new booking
|
||||
// https://stackoverflow.com/questions/4980963/database-insert-new-rows-or-update-existing-ones
|
||||
if (seatAttendee?.id && bookingSeat?.id) {
|
||||
await Promise.all([
|
||||
await prisma.attendee.update({
|
||||
where: {
|
||||
id: seatAttendee.id,
|
||||
},
|
||||
data: {
|
||||
bookingId: newTimeSlotBooking.id,
|
||||
},
|
||||
}),
|
||||
await prisma.bookingSeat.update({
|
||||
where: {
|
||||
id: bookingSeat.id,
|
||||
},
|
||||
data: {
|
||||
bookingId: newTimeSlotBooking.id,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
const copyEvent = cloneDeep(evt);
|
||||
|
||||
const updateManager = await eventManager.reschedule(copyEvent, rescheduleUid, newTimeSlotBooking.id);
|
||||
|
||||
const results = updateManager.results;
|
||||
|
||||
const calendarResult = results.find((result) => result.type.includes("_calendar"));
|
||||
|
||||
evt.iCalUID = Array.isArray(calendarResult?.updatedEvent)
|
||||
? calendarResult?.updatedEvent[0]?.iCalUID
|
||||
: calendarResult?.updatedEvent?.iCalUID || undefined;
|
||||
|
||||
await sendRescheduledSeatEmail(copyEvent, seatAttendee as Person);
|
||||
const filteredAttendees = originalRescheduledBooking?.attendees.filter((attendee) => {
|
||||
return attendee.email !== bookerEmail;
|
||||
});
|
||||
await lastAttendeeDeleteBooking(originalRescheduledBooking, filteredAttendees, originalBookingEvt);
|
||||
|
||||
const foundBooking = await findBookingQuery(newTimeSlotBooking.id);
|
||||
|
||||
return { ...foundBooking, seatReferenceUid: bookingSeat?.referenceUid };
|
||||
};
|
||||
|
||||
export default attendeeRescheduleSeatedBooking;
|
|
@ -0,0 +1,159 @@
|
|||
// eslint-disable-next-line no-restricted-imports
|
||||
import { cloneDeep } from "lodash";
|
||||
import { uuid } from "short-uuid";
|
||||
|
||||
import type EventManager from "@calcom/core/EventManager";
|
||||
import { sendRescheduledEmails } from "@calcom/emails";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
|
||||
import type { createLoggerWithEventDetails } from "../../../handleNewBooking";
|
||||
import { addVideoCallDataToEvt, findBookingQuery } from "../../../handleNewBooking";
|
||||
import type { SeatedBooking, RescheduleSeatedBookingObject, NewTimeSlotBooking } from "../../types";
|
||||
|
||||
const combineTwoSeatedBookings = async (
|
||||
rescheduleSeatedBookingObject: RescheduleSeatedBookingObject,
|
||||
seatedBooking: SeatedBooking,
|
||||
newTimeSlotBooking: NewTimeSlotBooking,
|
||||
eventManager: EventManager,
|
||||
loggerWithEventDetails: ReturnType<typeof createLoggerWithEventDetails>
|
||||
) => {
|
||||
const {
|
||||
eventType,
|
||||
tAttendees,
|
||||
attendeeLanguage,
|
||||
rescheduleUid,
|
||||
noEmail,
|
||||
isConfirmedByDefault,
|
||||
additionalNotes,
|
||||
rescheduleReason,
|
||||
} = rescheduleSeatedBookingObject;
|
||||
let { evt } = rescheduleSeatedBookingObject;
|
||||
// Merge two bookings together
|
||||
const attendeesToMove = [],
|
||||
attendeesToDelete = [];
|
||||
|
||||
for (const attendee of seatedBooking.attendees) {
|
||||
// If the attendee already exists on the new booking then delete the attendee record of the old booking
|
||||
if (
|
||||
newTimeSlotBooking.attendees.some((newBookingAttendee) => newBookingAttendee.email === attendee.email)
|
||||
) {
|
||||
attendeesToDelete.push(attendee.id);
|
||||
// If the attendee does not exist on the new booking then move that attendee record to the new booking
|
||||
} else {
|
||||
attendeesToMove.push({ id: attendee.id, seatReferenceId: attendee.bookingSeat?.id });
|
||||
}
|
||||
}
|
||||
|
||||
// Confirm that the new event will have enough available seats
|
||||
if (
|
||||
!eventType.seatsPerTimeSlot ||
|
||||
attendeesToMove.length + newTimeSlotBooking.attendees.filter((attendee) => attendee.bookingSeat).length >
|
||||
eventType.seatsPerTimeSlot
|
||||
) {
|
||||
throw new HttpError({ statusCode: 409, message: "Booking does not have enough available seats" });
|
||||
}
|
||||
|
||||
const moveAttendeeCalls = [];
|
||||
for (const attendeeToMove of attendeesToMove) {
|
||||
moveAttendeeCalls.push(
|
||||
prisma.attendee.update({
|
||||
where: {
|
||||
id: attendeeToMove.id,
|
||||
},
|
||||
data: {
|
||||
bookingId: newTimeSlotBooking.id,
|
||||
bookingSeat: {
|
||||
upsert: {
|
||||
create: {
|
||||
referenceUid: uuid(),
|
||||
bookingId: newTimeSlotBooking.id,
|
||||
},
|
||||
update: {
|
||||
bookingId: newTimeSlotBooking.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
...moveAttendeeCalls,
|
||||
// Delete any attendees that are already a part of that new time slot booking
|
||||
prisma.attendee.deleteMany({
|
||||
where: {
|
||||
id: {
|
||||
in: attendeesToDelete,
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const updatedNewBooking = await prisma.booking.findUnique({
|
||||
where: {
|
||||
id: newTimeSlotBooking.id,
|
||||
},
|
||||
include: {
|
||||
attendees: true,
|
||||
references: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!updatedNewBooking) {
|
||||
throw new HttpError({ statusCode: 404, message: "Updated booking not found" });
|
||||
}
|
||||
|
||||
// Update the evt object with the new attendees
|
||||
const updatedBookingAttendees = updatedNewBooking.attendees.map((attendee) => {
|
||||
const evtAttendee = {
|
||||
...attendee,
|
||||
language: { translate: tAttendees, locale: attendeeLanguage ?? "en" },
|
||||
};
|
||||
return evtAttendee;
|
||||
});
|
||||
|
||||
evt.attendees = updatedBookingAttendees;
|
||||
|
||||
evt = addVideoCallDataToEvt(updatedNewBooking.references, evt);
|
||||
|
||||
const copyEvent = cloneDeep(evt);
|
||||
|
||||
const updateManager = await eventManager.reschedule(copyEvent, rescheduleUid, newTimeSlotBooking.id);
|
||||
|
||||
const results = updateManager.results;
|
||||
|
||||
const calendarResult = results.find((result) => result.type.includes("_calendar"));
|
||||
|
||||
evt.iCalUID = Array.isArray(calendarResult?.updatedEvent)
|
||||
? calendarResult?.updatedEvent[0]?.iCalUID
|
||||
: calendarResult?.updatedEvent?.iCalUID || undefined;
|
||||
|
||||
if (noEmail !== true && isConfirmedByDefault) {
|
||||
// TODO send reschedule emails to attendees of the old booking
|
||||
loggerWithEventDetails.debug("Emails: Sending reschedule emails - handleSeats");
|
||||
await sendRescheduledEmails({
|
||||
...copyEvent,
|
||||
additionalNotes, // Resets back to the additionalNote input and not the override value
|
||||
cancellationReason: `$RCH$${rescheduleReason ? rescheduleReason : ""}`, // Removable code prefix to differentiate cancellation from rescheduling for email
|
||||
});
|
||||
}
|
||||
|
||||
// Update the old booking with the cancelled status
|
||||
await prisma.booking.update({
|
||||
where: {
|
||||
id: seatedBooking.id,
|
||||
},
|
||||
data: {
|
||||
status: BookingStatus.CANCELLED,
|
||||
},
|
||||
});
|
||||
|
||||
const foundBooking = await findBookingQuery(newTimeSlotBooking.id);
|
||||
|
||||
return { ...foundBooking };
|
||||
};
|
||||
|
||||
export default combineTwoSeatedBookings;
|
|
@ -0,0 +1,101 @@
|
|||
// eslint-disable-next-line no-restricted-imports
|
||||
import { cloneDeep } from "lodash";
|
||||
|
||||
import type EventManager from "@calcom/core/EventManager";
|
||||
import { sendRescheduledEmails } from "@calcom/emails";
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { AdditionalInformation, AppsStatus } from "@calcom/types/Calendar";
|
||||
|
||||
import { addVideoCallDataToEvt, handleAppsStatus, findBookingQuery } from "../../../handleNewBooking";
|
||||
import type { Booking, createLoggerWithEventDetails } from "../../../handleNewBooking";
|
||||
import type { SeatedBooking, RescheduleSeatedBookingObject } from "../../types";
|
||||
|
||||
const moveSeatedBookingToNewTimeSlot = async (
|
||||
rescheduleSeatedBookingObject: RescheduleSeatedBookingObject,
|
||||
seatedBooking: SeatedBooking,
|
||||
eventManager: EventManager,
|
||||
loggerWithEventDetails: ReturnType<typeof createLoggerWithEventDetails>
|
||||
) => {
|
||||
const {
|
||||
rescheduleReason,
|
||||
rescheduleUid,
|
||||
eventType,
|
||||
organizerUser,
|
||||
reqAppsStatus,
|
||||
noEmail,
|
||||
isConfirmedByDefault,
|
||||
additionalNotes,
|
||||
} = rescheduleSeatedBookingObject;
|
||||
let { evt } = rescheduleSeatedBookingObject;
|
||||
|
||||
const newBooking: (Booking & { appsStatus?: AppsStatus[] }) | null = await prisma.booking.update({
|
||||
where: {
|
||||
id: seatedBooking.id,
|
||||
},
|
||||
data: {
|
||||
startTime: evt.startTime,
|
||||
endTime: evt.endTime,
|
||||
cancellationReason: rescheduleReason,
|
||||
},
|
||||
include: {
|
||||
user: true,
|
||||
references: true,
|
||||
payment: true,
|
||||
attendees: true,
|
||||
},
|
||||
});
|
||||
|
||||
evt = addVideoCallDataToEvt(newBooking.references, evt);
|
||||
|
||||
const copyEvent = cloneDeep(evt);
|
||||
|
||||
const updateManager = await eventManager.reschedule(copyEvent, rescheduleUid, newBooking.id);
|
||||
|
||||
// @NOTE: This code is duplicated and should be moved to a function
|
||||
// This gets overridden when updating the event - to check if notes have been hidden or not. We just reset this back
|
||||
// to the default description when we are sending the emails.
|
||||
evt.description = eventType.description;
|
||||
|
||||
const results = updateManager.results;
|
||||
|
||||
const calendarResult = results.find((result) => result.type.includes("_calendar"));
|
||||
|
||||
evt.iCalUID = calendarResult?.updatedEvent.iCalUID || undefined;
|
||||
|
||||
if (results.length > 0 && results.some((res) => !res.success)) {
|
||||
const error = {
|
||||
errorCode: "BookingReschedulingMeetingFailed",
|
||||
message: "Booking Rescheduling failed",
|
||||
};
|
||||
loggerWithEventDetails.error(`Booking ${organizerUser.name} failed`, JSON.stringify({ error, results }));
|
||||
} else {
|
||||
const metadata: AdditionalInformation = {};
|
||||
if (results.length) {
|
||||
// TODO: Handle created event metadata more elegantly
|
||||
const [updatedEvent] = Array.isArray(results[0].updatedEvent)
|
||||
? results[0].updatedEvent
|
||||
: [results[0].updatedEvent];
|
||||
if (updatedEvent) {
|
||||
metadata.hangoutLink = updatedEvent.hangoutLink;
|
||||
metadata.conferenceData = updatedEvent.conferenceData;
|
||||
metadata.entryPoints = updatedEvent.entryPoints;
|
||||
evt.appsStatus = handleAppsStatus(results, newBooking, reqAppsStatus);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (noEmail !== true && isConfirmedByDefault) {
|
||||
const copyEvent = cloneDeep(evt);
|
||||
loggerWithEventDetails.debug("Emails: Sending reschedule emails - handleSeats");
|
||||
await sendRescheduledEmails({
|
||||
...copyEvent,
|
||||
additionalNotes, // Resets back to the additionalNote input and not the override value
|
||||
cancellationReason: `$RCH$${rescheduleReason ? rescheduleReason : ""}`, // Removable code prefix to differentiate cancellation from rescheduling for email
|
||||
});
|
||||
}
|
||||
const foundBooking = await findBookingQuery(newBooking.id);
|
||||
|
||||
return { ...foundBooking, appsStatus: newBooking.appsStatus };
|
||||
};
|
||||
|
||||
export default moveSeatedBookingToNewTimeSlot;
|
|
@ -0,0 +1,55 @@
|
|||
// eslint-disable-next-line no-restricted-imports
|
||||
import type EventManager from "@calcom/core/EventManager";
|
||||
|
||||
import type { createLoggerWithEventDetails } from "../../../handleNewBooking";
|
||||
import type {
|
||||
NewTimeSlotBooking,
|
||||
SeatedBooking,
|
||||
RescheduleSeatedBookingObject,
|
||||
HandleSeatsResultBooking,
|
||||
} from "../../types";
|
||||
import combineTwoSeatedBookings from "./combineTwoSeatedBookings";
|
||||
import moveSeatedBookingToNewTimeSlot from "./moveSeatedBookingToNewTimeSlot";
|
||||
|
||||
const ownerRescheduleSeatedBooking = async (
|
||||
rescheduleSeatedBookingObject: RescheduleSeatedBookingObject,
|
||||
newTimeSlotBooking: NewTimeSlotBooking | null,
|
||||
seatedBooking: SeatedBooking,
|
||||
resultBooking: HandleSeatsResultBooking | null,
|
||||
eventManager: EventManager,
|
||||
loggerWithEventDetails: ReturnType<typeof createLoggerWithEventDetails>
|
||||
) => {
|
||||
const { originalRescheduledBooking, tAttendees } = rescheduleSeatedBookingObject;
|
||||
const { evt } = rescheduleSeatedBookingObject;
|
||||
// Moving forward in this block is the owner making changes to the booking. All attendees should be affected
|
||||
evt.attendees = originalRescheduledBooking.attendees.map((attendee) => {
|
||||
return {
|
||||
name: attendee.name,
|
||||
email: attendee.email,
|
||||
timeZone: attendee.timeZone,
|
||||
language: { translate: tAttendees, locale: attendee.locale ?? "en" },
|
||||
};
|
||||
});
|
||||
|
||||
// If there is no booking during the new time slot then update the current booking to the new date
|
||||
if (!newTimeSlotBooking) {
|
||||
resultBooking = await moveSeatedBookingToNewTimeSlot(
|
||||
rescheduleSeatedBookingObject,
|
||||
seatedBooking,
|
||||
eventManager,
|
||||
loggerWithEventDetails
|
||||
);
|
||||
} else {
|
||||
// If a booking already exists during the new time slot then merge the two bookings together
|
||||
resultBooking = await combineTwoSeatedBookings(
|
||||
rescheduleSeatedBookingObject,
|
||||
seatedBooking,
|
||||
newTimeSlotBooking,
|
||||
eventManager,
|
||||
loggerWithEventDetails
|
||||
);
|
||||
}
|
||||
return resultBooking;
|
||||
};
|
||||
|
||||
export default ownerRescheduleSeatedBooking;
|
|
@ -0,0 +1,222 @@
|
|||
import type {
|
||||
HandleSeatsResultBooking,
|
||||
SeatedBooking,
|
||||
RescheduleSeatedBookingObject,
|
||||
SeatAttendee,
|
||||
} from "bookings/lib/handleSeats/types";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { cloneDeep } from "lodash";
|
||||
|
||||
import EventManager from "@calcom/core/EventManager";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { sendRescheduledSeatEmail } from "@calcom/emails";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
import type { Person } from "@calcom/types/Calendar";
|
||||
|
||||
import { refreshCredentials, findBookingQuery } from "../../handleNewBooking";
|
||||
import type { createLoggerWithEventDetails } from "../../handleNewBooking";
|
||||
import lastAttendeeDeleteBooking from "../lib/lastAttendeeDeleteBooking";
|
||||
import ownerRescheduleSeatedBooking from "./owner/ownerRescheduleSeatedBooking";
|
||||
|
||||
const rescheduleSeatedBooking = async (
|
||||
// If this function is being called then rescheduleUid is defined
|
||||
rescheduleSeatedBookingObject: RescheduleSeatedBookingObject,
|
||||
seatedBooking: SeatedBooking,
|
||||
resultBooking: HandleSeatsResultBooking | null,
|
||||
loggerWithEventDetails: ReturnType<typeof createLoggerWithEventDetails>
|
||||
) => {
|
||||
const {
|
||||
evt,
|
||||
eventType,
|
||||
allCredentials,
|
||||
organizerUser,
|
||||
bookerEmail,
|
||||
tAttendees,
|
||||
bookingSeat,
|
||||
reqUserId,
|
||||
rescheduleUid,
|
||||
} = rescheduleSeatedBookingObject;
|
||||
|
||||
let { originalRescheduledBooking } = rescheduleSeatedBookingObject;
|
||||
|
||||
// See if the new date has a booking already
|
||||
const newTimeSlotBooking = await prisma.booking.findFirst({
|
||||
where: {
|
||||
startTime: evt.startTime,
|
||||
eventTypeId: eventType.id,
|
||||
status: BookingStatus.ACCEPTED,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
uid: true,
|
||||
attendees: {
|
||||
include: {
|
||||
bookingSeat: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const credentials = await refreshCredentials(allCredentials);
|
||||
const eventManager = new EventManager({ ...organizerUser, credentials });
|
||||
|
||||
if (!originalRescheduledBooking) {
|
||||
// typescript isn't smart enough;
|
||||
throw new Error("Internal Error.");
|
||||
}
|
||||
|
||||
const updatedBookingAttendees = originalRescheduledBooking.attendees.reduce(
|
||||
(filteredAttendees, attendee) => {
|
||||
if (attendee.email === bookerEmail) {
|
||||
return filteredAttendees; // skip current booker, as we know the language already.
|
||||
}
|
||||
filteredAttendees.push({
|
||||
name: attendee.name,
|
||||
email: attendee.email,
|
||||
timeZone: attendee.timeZone,
|
||||
language: { translate: tAttendees, locale: attendee.locale ?? "en" },
|
||||
});
|
||||
return filteredAttendees;
|
||||
},
|
||||
[] as Person[]
|
||||
);
|
||||
|
||||
// If original booking has video reference we need to add the videoCallData to the new evt
|
||||
const videoReference = originalRescheduledBooking.references.find((reference) =>
|
||||
reference.type.includes("_video")
|
||||
);
|
||||
|
||||
const originalBookingEvt = {
|
||||
...evt,
|
||||
title: originalRescheduledBooking.title,
|
||||
startTime: dayjs(originalRescheduledBooking.startTime).utc().format(),
|
||||
endTime: dayjs(originalRescheduledBooking.endTime).utc().format(),
|
||||
attendees: updatedBookingAttendees,
|
||||
// If the location is a video integration then include the videoCallData
|
||||
...(videoReference && {
|
||||
videoCallData: {
|
||||
type: videoReference.type,
|
||||
id: videoReference.meetingId,
|
||||
password: videoReference.meetingPassword,
|
||||
url: videoReference.meetingUrl,
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
if (!bookingSeat) {
|
||||
// if no bookingSeat is given and the userId != owner, 401.
|
||||
// TODO: Next step; Evaluate ownership, what about teams?
|
||||
if (seatedBooking.user?.id !== reqUserId) {
|
||||
throw new HttpError({ statusCode: 401 });
|
||||
}
|
||||
|
||||
// Moving forward in this block is the owner making changes to the booking. All attendees should be affected
|
||||
evt.attendees = originalRescheduledBooking.attendees.map((attendee) => {
|
||||
return {
|
||||
name: attendee.name,
|
||||
email: attendee.email,
|
||||
timeZone: attendee.timeZone,
|
||||
language: { translate: tAttendees, locale: attendee.locale ?? "en" },
|
||||
};
|
||||
});
|
||||
|
||||
// If owner reschedules the event we want to update the entire booking
|
||||
// Also if owner is rescheduling there should be no bookingSeat
|
||||
resultBooking = await ownerRescheduleSeatedBooking(
|
||||
rescheduleSeatedBookingObject,
|
||||
newTimeSlotBooking,
|
||||
seatedBooking,
|
||||
resultBooking,
|
||||
eventManager,
|
||||
loggerWithEventDetails
|
||||
);
|
||||
}
|
||||
|
||||
// seatAttendee is null when the organizer is rescheduling.
|
||||
const seatAttendee: SeatAttendee | null = bookingSeat?.attendee || null;
|
||||
if (seatAttendee) {
|
||||
seatAttendee["language"] = { translate: tAttendees, locale: bookingSeat?.attendee.locale ?? "en" };
|
||||
|
||||
// If there is no booking then remove the attendee from the old booking and create a new one
|
||||
if (!newTimeSlotBooking) {
|
||||
await prisma.attendee.delete({
|
||||
where: {
|
||||
id: seatAttendee?.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Update the original calendar event by removing the attendee that is rescheduling
|
||||
if (originalBookingEvt && originalRescheduledBooking) {
|
||||
// Event would probably be deleted so we first check than instead of updating references
|
||||
const filteredAttendees = originalRescheduledBooking?.attendees.filter((attendee) => {
|
||||
return attendee.email !== bookerEmail;
|
||||
});
|
||||
const deletedReference = await lastAttendeeDeleteBooking(
|
||||
originalRescheduledBooking,
|
||||
filteredAttendees,
|
||||
originalBookingEvt
|
||||
);
|
||||
|
||||
if (!deletedReference) {
|
||||
await eventManager.updateCalendarAttendees(originalBookingEvt, originalRescheduledBooking);
|
||||
}
|
||||
}
|
||||
|
||||
// We don't want to trigger rescheduling logic of the original booking
|
||||
originalRescheduledBooking = null;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Need to change the new seat reference and attendee record to remove it from the old booking and add it to the new booking
|
||||
// https://stackoverflow.com/questions/4980963/database-insert-new-rows-or-update-existing-ones
|
||||
if (seatAttendee?.id && bookingSeat?.id) {
|
||||
await Promise.all([
|
||||
await prisma.attendee.update({
|
||||
where: {
|
||||
id: seatAttendee.id,
|
||||
},
|
||||
data: {
|
||||
bookingId: newTimeSlotBooking.id,
|
||||
},
|
||||
}),
|
||||
await prisma.bookingSeat.update({
|
||||
where: {
|
||||
id: bookingSeat.id,
|
||||
},
|
||||
data: {
|
||||
bookingId: newTimeSlotBooking.id,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
const copyEvent = cloneDeep(evt);
|
||||
|
||||
const updateManager = await eventManager.reschedule(copyEvent, rescheduleUid, newTimeSlotBooking.id);
|
||||
|
||||
const results = updateManager.results;
|
||||
|
||||
const calendarResult = results.find((result) => result.type.includes("_calendar"));
|
||||
|
||||
evt.iCalUID = Array.isArray(calendarResult?.updatedEvent)
|
||||
? calendarResult?.updatedEvent[0]?.iCalUID
|
||||
: calendarResult?.updatedEvent?.iCalUID || undefined;
|
||||
|
||||
await sendRescheduledSeatEmail(copyEvent, seatAttendee as Person);
|
||||
const filteredAttendees = originalRescheduledBooking?.attendees.filter((attendee) => {
|
||||
return attendee.email !== bookerEmail;
|
||||
});
|
||||
await lastAttendeeDeleteBooking(originalRescheduledBooking, filteredAttendees, originalBookingEvt);
|
||||
|
||||
const foundBooking = await findBookingQuery(newTimeSlotBooking.id);
|
||||
|
||||
resultBooking = { ...foundBooking, seatReferenceUid: bookingSeat?.referenceUid };
|
||||
}
|
||||
|
||||
return resultBooking;
|
||||
};
|
||||
|
||||
export default rescheduleSeatedBooking;
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,80 @@
|
|||
import type { Prisma } from "@prisma/client";
|
||||
|
||||
import type { AppsStatus } from "@calcom/types/Calendar";
|
||||
|
||||
import type { Booking } from "../handleNewBooking";
|
||||
|
||||
export type BookingSeat = Prisma.BookingSeatGetPayload<{ include: { booking: true; attendee: true } }> | null;
|
||||
|
||||
export type NewSeatedBookingObject = {
|
||||
rescheduleUid: string | undefined;
|
||||
reqBookingUid: string | undefined;
|
||||
eventType: NewBookingEventType;
|
||||
evt: CalendarEvent;
|
||||
invitee: Invitee;
|
||||
allCredentials: Awaited<ReturnType<typeof getAllCredentials>>;
|
||||
organizerUser: OrganizerUser;
|
||||
originalRescheduledBooking: OriginalRescheduledBooking;
|
||||
bookerEmail: string;
|
||||
tAttendees: TFunction;
|
||||
bookingSeat: BookingSeat;
|
||||
reqUserId: number | undefined;
|
||||
rescheduleReason: RescheduleReason;
|
||||
reqBodyUser: string | string[] | undefined;
|
||||
noEmail: NoEmail;
|
||||
isConfirmedByDefault: IsConfirmedByDefault;
|
||||
additionalNotes: AdditionalNotes;
|
||||
reqAppsStatus: ReqAppsStatus;
|
||||
attendeeLanguage: string | null;
|
||||
paymentAppData: PaymentAppData;
|
||||
fullName: ReturnType<typeof getFullName>;
|
||||
smsReminderNumber: SmsReminderNumber;
|
||||
eventTypeInfo: EventTypeInfo;
|
||||
uid: short.SUUID;
|
||||
eventTypeId: EventTypeId;
|
||||
reqBodyMetadata: ReqBodyMetadata;
|
||||
subscriberOptions: GetSubscriberOptions;
|
||||
eventTrigger: WebhookTriggerEvents;
|
||||
};
|
||||
|
||||
export type RescheduleSeatedBookingObject = NewSeatedBookingObject & { rescheduleUid: string };
|
||||
|
||||
export type SeatedBooking = Prisma.BookingGetPayload<{
|
||||
select: {
|
||||
uid: true;
|
||||
id: true;
|
||||
attendees: { include: { bookingSeat: true } };
|
||||
userId: true;
|
||||
references: true;
|
||||
startTime: true;
|
||||
user: true;
|
||||
status: true;
|
||||
smsReminderNumber: true;
|
||||
endTime: true;
|
||||
scheduledJobs: true;
|
||||
};
|
||||
}>;
|
||||
|
||||
export type HandleSeatsResultBooking =
|
||||
| (Partial<Booking> & {
|
||||
appsStatus?: AppsStatus[];
|
||||
seatReferenceUid?: string;
|
||||
paymentUid?: string;
|
||||
message?: string;
|
||||
paymentId?: number;
|
||||
})
|
||||
| null;
|
||||
|
||||
export type NewTimeSlotBooking = Prisma.BookingGetPayload<{
|
||||
select: {
|
||||
id: true;
|
||||
uid: true;
|
||||
attendees: {
|
||||
include: {
|
||||
bookingSeat: true;
|
||||
};
|
||||
};
|
||||
};
|
||||
}>;
|
||||
|
||||
export type SeatAttendee = Partial<Person>;
|
Loading…
Reference in New Issue
Block a user