Seated booking rescheduling. (#5427)

* WIP-already-reschedule-success-emails-missing

* WIP now saving bookingSeatsReferences and identifyin on reschedule/book page

* Remove logs and created test

* WIP saving progress

* Select second slot to pass test

* Delete attendee from event

* Clean up

* Update with main changes

* Fix emails not being sent

* Changed test end url from success to booking

* Remove unused pkg

* Fix new booking reschedule

* remove log

* Renable test

* remove unused pkg

* rename table name

* review changes

* Fix and and other test to reschedule with seats

* Fix api for cancel booking

* Typings

* Update [uid].tsx

* Abstracted common pattern

into maybeGetBookingUidFromSeat

* Reverts

* Nitpicks

* Update handleCancelBooking.ts

* Adds missing cascades

* Improve booking seats changes (#6858)

* Create sendCancelledSeatEmails

* Draft attendee cancelled seat email

* Send no longer attendee email to attendee

* Send email to organizer when attendee cancels

* Pass cloned event data to emails

* Send booked email for first seat

* Add seat reference uid from email link

* Query for seatReferenceUId and add to cancel & reschedule links

* WIP

* Display proper attendee when rescheduling seats

* Remove console.logs

* Only check for already invited when not rescheduling

* WIP sending reschedule email to just single attendee and owner

* Merge branch 'main' into send-email-on-seats-attendee-changes

* Remove console.logs

* Add cloned event to seat emails

* Do not show manage link for calendar event

* First seat, have both attendees on calendar

* WIP refactor booking seats reschedule logic

* WIP Refactor handleSeats

* Change relation of attendee & seat reference to a one-to-one

* Migration with relationship change

* Booking page handling unique seat references

* Abstract to handleSeats

* Remove console.logs and clean up

* New migration file, delete on cascade

* Check if attendee is already a part of the booking

* Move deleting booking logic to `handleSeats`

* When owner reschedule, move whole booking

* Prevent owner from rescheduling if not enough seats

* Add owner reschedule

* Send reschedule email when moving to new timeslot

* Add event data to reschedule email for seats

* Remove DB changes from event manager

* When a booking has no attendees then delete

* Update calendar when merging bookings

* Move both attendees and seat references when merging

* Remove guest list from seats booking page

* Update original booking when moving an attendee

* Delete calendar and video events if no more attendees

* Update or delete integrations when attendees cancel

* Show no longer attendee if a single attendee cancels

* Change booking to accepted if an attendee books on an empty booking

* If booking in same slot then just return the booking

* Clean up

* Clean up

* Remove booking select

* Typos

---------

Co-authored-by: zomars <zomars@me.com>

* Fix migration table name

* Add missing trpc import

* Rename bookingSeatReferences to bookingSeat

* Change relationship between Attendee & BookingSeat to one to one

* Fix some merge conflicts

* Minor merge artifact fixup

* Add the right 'Person' type

* Check on email, less (although still) editable than name

* Removed calEvent.attendeeUniqueId

* rename referenceUId -> referenceUid

* Squashes migrations

* Run cached installs

Should still be faster. Ensures prisma client is up to date.

* Solve attendee form on booking page

* Remove unused code

* Some type fixes

* Squash migrations

* Type fixes

* Fix for reschedule/[uid] redirect

* Fix e2e test

* Solve double declaration of host

* Solve lint errors

* Drop constraint only if exists

* Renamed UId to Uid

* Explicit vs. implicit

* Attempt to work around text flakiness by adding a little break between animations

* Various bugfixes

* Persistently apply seatReferenceUid (#7545)

* Persistently apply seatReferenceUid

* Small ts fix

* Setup guards correctly

* Type fixes

* Fix render 0 in conditional

* Test refactoring

* Fix type on handleSeats

* Fix handle seats conditional

* Fix type inference

* Update packages/features/bookings/lib/handleNewBooking.ts

* Update apps/web/components/booking/pages/BookingPage.tsx

* Fix type and missing logic for reschedule

* Fix delete of calendar event and booking

* Add handleSeats return type

* Fix seats booking creation

* Fall through normal booking for initial booking, handleSeats for secondary/reschedule

* Simplification of fetching booking

* Enable seats for round-robin events

* A lot harder than I expected

* ignore-owner-if-seat-reference-given

* Return seatReferenceUid when second seat

* negate userIsOwner

* Fix booking seats with a link without bookingUid

* Needed a time check otherwise the attendee will be in the older booking

* Can't open dialog twice in test..

* Allow passing the booking ID from the server

* Fixed isCancelled check, fixed test

* Delete through cascade instead of multiple deletes

---------

Co-authored-by: Joe Au-Yeung <j.auyeung419@gmail.com>
Co-authored-by: Peer Richelsen <peer@cal.com>
Co-authored-by: zomars <zomars@me.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Efraín Rochín <roae.85@gmail.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
This commit is contained in:
alannnc
2023-03-14 04:19:05 +00:00
committed by GitHub
co-authored by zomars Joe Au-Yeung Peer Richelsen Joe Au-Yeung Alex van Andel Efraín Rochín Peer Richelsen
parent d545a9e127
commit ed750c8df1
44 changed files with 1898 additions and 462 deletions
+14 -2
View File
@@ -24,6 +24,7 @@ type AvailableTimesProps = {
eventTypeSlug: string;
date?: Dayjs;
seatsPerTimeSlot?: number | null;
bookingAttendees?: number | null;
slots?: Slot[];
isLoading: boolean;
ethSignature?: string;
@@ -39,6 +40,7 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
timeFormat,
onTimeFormatChange,
seatsPerTimeSlot,
bookingAttendees,
ethSignature,
}) => {
const [slotPickerRef] = useAutoAnimate<HTMLDivElement>();
@@ -116,18 +118,28 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
bookingUrl.query.bookingUid = slot.bookingUid;
}
let slotFull, notEnoughSeats;
if (slot.attendees && seatsPerTimeSlot) slotFull = slot.attendees >= seatsPerTimeSlot;
if (slot.attendees && bookingAttendees && seatsPerTimeSlot)
notEnoughSeats = slot.attendees + bookingAttendees > seatsPerTimeSlot;
return (
<div data-slot-owner={(slot.userIds || []).join(",")} key={`${dayjs(slot.time).format()}`}>
{/* ^ data-slot-owner is helpful in debugging and used to identify the owners of the slot. Owners are the users which have the timeslot in their schedule. It doesn't consider if a user has that timeslot booked */}
{/* Current there is no way to disable Next.js Links */}
{seatsPerTimeSlot && slot.attendees && slot.attendees >= seatsPerTimeSlot ? (
{seatsPerTimeSlot && slot.attendees && (slotFull || notEnoughSeats) ? (
<div
className={classNames(
"text-primary-500 dark:bg-darkgray-200 dark:text-darkgray-900 mb-2 block rounded-sm border bg-white py-2 font-medium opacity-25 dark:border-transparent ",
brand === "#fff" || brand === "#ffffff" ? "" : ""
)}>
{dayjs(slot.time).tz(timeZone()).format(timeFormat)}
{!!seatsPerTimeSlot && <p className="text-sm">{t("booking_full")}</p>}
{notEnoughSeats ? (
<p className="text-sm">{t("not_enough_seats")}</p>
) : slots ? (
<p className="text-sm">{t("booking_full")}</p>
) : null}
</div>
) : (
<Link
@@ -94,6 +94,13 @@ function BookingListItem(booking: BookingItemProps) {
mutation.mutate(body);
};
const getSeatReferenceUid = () => {
if (!booking.seatsReferences[0]) {
return undefined;
}
return booking.seatsReferences[0].referenceUid;
};
const pendingActions: ActionType[] = [
{
id: "reject",
@@ -135,7 +142,8 @@ function BookingListItem(booking: BookingItemProps) {
cancel all remaining bookings or just that booking instance. */
href: `/booking/${booking.uid}?cancel=true${
isTabRecurring && isRecurring ? "&allRemainingBookings=true" : ""
}`,
}${booking.seatsReferences.length ? `&seatReferenceUid=${getSeatReferenceUid()}` : ""}
`,
icon: FiX,
},
{
@@ -146,7 +154,9 @@ function BookingListItem(booking: BookingItemProps) {
id: "reschedule",
icon: FiClock,
label: t("reschedule_booking"),
href: `/reschedule/${booking.uid}`,
href: `/reschedule/${booking.uid}${
booking.seatsReferences.length ? `?seatReferenceUid=${getSeatReferenceUid()}` : ""
}`,
},
{
id: "reschedule_request",
@@ -23,13 +23,14 @@ type Props = {
setIsCancellationMode: (value: boolean) => void;
theme: string | null;
allRemainingBookings: boolean;
seatReferenceUid?: string;
};
export default function CancelBooking(props: Props) {
const [cancellationReason, setCancellationReason] = useState<string>("");
const { t } = useLocale();
const router = useRouter();
const { booking, allRemainingBookings } = props;
const { booking, allRemainingBookings, seatReferenceUid } = props;
const [loading, setLoading] = useState(false);
const telemetry = useTelemetry();
const [error, setError] = useState<string | null>(booking ? null : t("booking_already_cancelled"));
@@ -73,16 +74,16 @@ export default function CancelBooking(props: Props) {
onClick={async () => {
setLoading(true);
const payload = {
id: booking?.id,
cancellationReason: cancellationReason,
allRemainingBookings,
};
telemetry.event(telemetryEventTypes.bookingCancelled, collectPageParameters());
const res = await fetch("/api/cancel", {
body: JSON.stringify(payload),
body: JSON.stringify({
id: booking?.id,
cancellationReason: cancellationReason,
allRemainingBookings,
// @NOTE: very important this shouldn't cancel with number ID use uid instead
seatReferenceUid,
}),
headers: {
"Content-Type": "application/json",
},
@@ -78,6 +78,7 @@ export const SlotPicker = ({
recurringEventCount,
users,
seatsPerTimeSlot,
bookingAttendees,
weekStart = 0,
ethSignature,
}: {
@@ -89,6 +90,7 @@ export const SlotPicker = ({
onTimeFormatChange: (is24Hour: boolean) => void;
timeZone?: string;
seatsPerTimeSlot?: number;
bookingAttendees?: number;
recurringEventCount?: number;
users: string[];
weekStart?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
@@ -192,6 +194,7 @@ export const SlotPicker = ({
eventTypeId={eventType.id}
eventTypeSlug={eventType.slug}
seatsPerTimeSlot={seatsPerTimeSlot}
bookingAttendees={bookingAttendees}
recurringCount={recurringEventCount}
ethSignature={ethSignature}
/>
@@ -23,8 +23,9 @@ import notEmpty from "@calcom/lib/notEmpty";
import { getRecurringFreq } from "@calcom/lib/recurringStrings";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
import { detectBrowserTimeFormat, setIs24hClockInLocalStorage, TimeFormat } from "@calcom/lib/timeFormat";
import { trpc } from "@calcom/trpc";
import { HeadSeo } from "@calcom/ui";
import { FiCreditCard, FiRefreshCcw } from "@calcom/ui/components/icon";
import { FiCreditCard, FiUser, FiRefreshCcw } from "@calcom/ui/components/icon";
import { timeZone as localStorageTimeZone } from "@lib/clock";
@@ -52,6 +53,7 @@ const dateQuerySchema = z.object({
rescheduleUid: z.string().optional().default(""),
date: z.string().optional().default(""),
timeZone: z.string().optional().default(""),
seatReferenceUid: z.string().optional(),
});
export type Props = AvailabilityTeamPageProps | AvailabilityPageProps | DynamicAvailabilityPageProps;
@@ -124,6 +126,15 @@ const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
: undefined,
];
const { data: bookingAttendees } = trpc.viewer.bookings.getBookingAttendees.useQuery(
{
seatReferenceUid: rescheduleUid,
},
{
enabled: !!(rescheduleUid && eventType.seatsPerTimeSlot),
}
);
return (
<Gates gates={gates} appData={rainbowAppData} dispatch={gateDispatcher}>
<HeadSeo
@@ -172,6 +183,22 @@ const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
recurringEventCount && "xl:w-[380px] xl:min-w-[380px]"
)}>
<BookingDescription profile={profile} eventType={eventType} rescheduleUid={rescheduleUid}>
{rescheduleUid && eventType.seatsPerTimeSlot && bookingAttendees && (
<div
className={classNames(
"flex flex-nowrap items-center text-sm font-medium",
"dark:text-darkgray-600 text-gray-600",
"ltr:mr-[10px] rtl:ml-[10px]"
)}>
<FiUser
className={classNames(
"min-h-4 min-w-4 ml-[2px] inline-block ltr:mr-[10px] rtl:ml-[10px]",
"mt-[2px]"
)}
/>{" "}
{t("event_type_seats", { numberOfSeats: bookingAttendees })}
</div>
)}
{!rescheduleUid && eventType.recurringEvent && (
<div className="flex items-start text-sm font-medium">
<FiRefreshCcw className="float-left mt-[7px] ml-[2px] inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px] " />
@@ -247,6 +274,7 @@ const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
timeZone={timeZone}
users={userList}
seatsPerTimeSlot={eventType.seatsPerTimeSlot || undefined}
bookingAttendees={bookingAttendees || undefined}
recurringEventCount={recurringEventCount}
ethSignature={gateState.rainbowToken}
/>
@@ -115,7 +115,10 @@ const BookingFields = ({
}
// We don't show `notes` field during reschedule
if (field.name === SystemField.Enum.notes && !!rescheduleUid) {
if (
(field.name === SystemField.Enum.notes || field.name === SystemField.Enum.guests) &&
!!rescheduleUid
) {
return null;
}
@@ -242,12 +245,12 @@ const BookingPage = ({
const mutation = useMutation(createBooking, {
onSuccess: async (responseData) => {
const { uid, paymentUid } = responseData;
const { uid } = responseData;
if (paymentUid) {
if ("paymentUid" in responseData && !!responseData.paymentUid) {
return await router.push(
createPaymentLink({
paymentUid,
paymentUid: responseData.paymentUid,
date,
name: bookingForm.getValues("responses.name"),
email: bookingForm.getValues("responses.email"),
@@ -262,6 +265,7 @@ const BookingPage = ({
isSuccessBookingPage: true,
email: bookingForm.getValues("responses.email"),
eventTypeSlug: eventType.slug,
seatReferenceUid: "seatReferenceUid" in responseData ? responseData.seatReferenceUid : null,
...(rescheduleUid && booking?.startTime && { formerTime: booking.startTime.toString() }),
},
});
@@ -409,7 +413,7 @@ const BookingPage = ({
);
}
const bookEvent = (booking: BookingFormValues) => {
const bookEvent = (bookingValues: BookingFormValues) => {
telemetry.event(
top !== window ? telemetryEventTypes.embedBookingConfirmed : telemetryEventTypes.bookingConfirmed,
{ isTeamBooking: document.URL.includes("team/") }
@@ -432,7 +436,7 @@ const BookingPage = ({
// Identify set of bookings to one intance of recurring event to support batch changes
const recurringEventId = uuidv4();
const recurringBookings = recurringDates.map((recurringDate) => ({
...booking,
...bookingValues,
start: dayjs(recurringDate).format(),
end: dayjs(recurringDate).add(duration, "minute").format(),
eventTypeId: eventType.id,
@@ -452,7 +456,7 @@ const BookingPage = ({
recurringMutation.mutate(recurringBookings);
} else {
mutation.mutate({
...booking,
...bookingValues,
start: dayjs(date).tz(timeZone()).format(),
end: dayjs(date).tz(timeZone()).add(duration, "minute").format(),
eventTypeId: eventType.id,
@@ -460,12 +464,13 @@ const BookingPage = ({
timeZone: timeZone(),
language: i18n.language,
rescheduleUid,
bookingUid: router.query.bookingUid as string,
bookingUid: (router.query.bookingUid as string) || booking?.uid,
user: router.query.user,
metadata,
hasHashedBookingLink,
hashedLink,
ethSignature: gateState.rainbowToken,
seatReferenceUid: router.query.seatReferenceUid as string,
});
}
};
@@ -264,6 +264,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
defaultValue={!!eventType.seatsPerTimeSlot}
render={({ field: { value, onChange } }) => (
<SettingsToggle
data-testid="offer-seats-toggle"
title={t("offer_seats")}
description={t("offer_seats_description")}
checked={value}
+8
View File
@@ -55,6 +55,8 @@ async function getBooking(
uid,
},
select: {
id: true,
uid: true,
startTime: true,
description: true,
customInputs: true,
@@ -65,6 +67,12 @@ async function getBooking(
select: {
email: true,
name: true,
bookingSeat: true,
},
},
user: {
select: {
id: true,
},
},
},
+3 -9
View File
@@ -1,9 +1,3 @@
import type { Attendee, Booking } from "@prisma/client";
import type { AppsStatus } from "@calcom/types/Calendar";
export type BookingResponse = Booking & {
paymentUid?: string;
attendees: Attendee[];
appsStatus?: AppsStatus[];
};
export type BookingResponse = Awaited<
ReturnType<typeof import("@calcom/features/bookings/lib/handleNewBooking").default>
>;
+92 -34
View File
@@ -1,8 +1,10 @@
import type { GetServerSidePropsContext } from "next";
import { z } from "zod";
import type { LocationObject } from "@calcom/app-store/locations";
import { privacyFilteredLocations } from "@calcom/app-store/locations";
import { getAppFromSlug } from "@calcom/app-store/utils";
import dayjs from "@calcom/dayjs";
import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields";
import { parseRecurringEvent } from "@calcom/lib";
import {
@@ -12,15 +14,13 @@ import {
getUsernameList,
} from "@calcom/lib/defaultEvents";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { bookEventTypeSelect } from "@calcom/prisma";
import prisma from "@calcom/prisma";
import prisma, { bookEventTypeSelect } from "@calcom/prisma";
import {
customInputSchema,
EventTypeMetaDataSchema,
userMetadata as userMetadataSchema,
} from "@calcom/prisma/zod-utils";
import { asStringOrNull, asStringOrThrow } from "@lib/asStringOrNull";
import type { GetBookingType } from "@lib/getBooking";
import getBooking from "@lib/getBooking";
import type { inferSSRProps } from "@lib/types/inferSSRProps";
@@ -70,11 +70,26 @@ export default function Book(props: BookPageProps) {
Book.isThemeSupported = true;
const querySchema = z.object({
bookingUid: z.string().optional(),
count: z.coerce.number().optional(),
embed: z.string().optional(),
rescheduleUid: z.string().optional(),
slug: z.string().optional(),
/** This is the event "type" ID */
type: z.coerce.number().optional(),
user: z.string(),
seatReferenceUid: z.string().optional(),
date: z.string().optional(),
duration: z.coerce.number().optional(),
});
export async function getServerSideProps(context: GetServerSidePropsContext) {
const ssr = await ssrInit(context);
const usernameList = getUsernameList(asStringOrThrow(context.query.user as string));
const eventTypeSlug = context.query.slug as string;
const recurringEventCountQuery = asStringOrNull(context.query.count);
const query = querySchema.parse(context.query);
const usernameList = getUsernameList(query.user);
const eventTypeSlug = query.slug;
const recurringEventCountQuery = query.count;
const users = await prisma.user.findMany({
where: {
username: {
@@ -99,24 +114,23 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
if (!users.length) return { notFound: true };
const [user] = users;
const isDynamicGroupBooking = users.length > 1;
const isDynamicGroupBooking = users.length > 1 && !!eventTypeSlug;
// Dynamic Group link doesn't need a type but it must have a slug
if ((!isDynamicGroupBooking && !context.query.type) || (isDynamicGroupBooking && !eventTypeSlug)) {
if ((!isDynamicGroupBooking && !query.type) || (users.length > 1 && !eventTypeSlug)) {
return { notFound: true };
}
const eventTypeRaw =
usernameList.length > 1
? getDefaultEvent(eventTypeSlug)
: await prisma.eventType.findUnique({
where: {
id: parseInt(asStringOrThrow(context.query.type)),
},
select: {
...bookEventTypeSelect,
},
});
const eventTypeRaw = isDynamicGroupBooking
? getDefaultEvent(eventTypeSlug)
: await prisma.eventType.findUnique({
where: {
id: query.type,
},
select: {
...bookEventTypeSelect,
},
});
if (!eventTypeRaw) return { notFound: true };
const eventType = {
@@ -178,22 +192,68 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
};
})[0];
// If rescheduleUid and event has seats lets convert Uid to bookingUid
let rescheduleUid = query.rescheduleUid;
const rescheduleEventTypeHasSeats = query.rescheduleUid && eventTypeRaw.seatsPerTimeSlot;
let attendeeEmail: string;
let bookingUidWithSeats: string | null = null;
if (rescheduleEventTypeHasSeats) {
const bookingSeat = await prisma.bookingSeat.findFirst({
where: {
referenceUid: query.rescheduleUid,
},
select: {
id: true,
attendee: true,
booking: {
select: {
uid: true,
},
},
},
});
if (bookingSeat) {
rescheduleUid = bookingSeat.booking.uid;
attendeeEmail = bookingSeat.attendee.email;
}
} else if (eventTypeRaw.seatsPerTimeSlot && query.duration && query.date) {
// If it's not reschedule but event Type has seats we should obtain
// the bookingUid regardless and use it to get the booking
const currentSeats = await prisma.booking.findFirst({
where: {
eventTypeId: eventTypeRaw.id,
startTime: dayjs(query.date).toISOString(),
endTime: dayjs(query.date).add(query.duration, "minutes").toISOString(),
},
select: {
uid: true,
},
});
if (currentSeats && currentSeats) {
bookingUidWithSeats = currentSeats.uid;
}
}
let booking: GetBookingType | null = null;
if (context.query.rescheduleUid || context.query.bookingUid) {
if (rescheduleUid || query.bookingUid || bookingUidWithSeats) {
booking = await getBooking(
prisma,
context.query.rescheduleUid
? (context.query.rescheduleUid as string)
: (context.query.bookingUid as string),
rescheduleUid || query.bookingUid || bookingUidWithSeats || "",
eventTypeObject.bookingFields
);
}
const dynamicNames = isDynamicGroupBooking
? users.map((user) => {
return user.name || "";
})
: [];
if (rescheduleEventTypeHasSeats && booking?.attendees && booking?.attendees.length > 0) {
const currentAttendee = booking?.attendees.find((attendee) => {
return attendee.email === attendeeEmail;
});
if (currentAttendee) {
booking.attendees = [currentAttendee] || [];
}
}
const dynamicNames = isDynamicGroupBooking ? users.map((user) => user.name || "") : [];
const profile = isDynamicGroupBooking
? {
@@ -203,9 +263,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
theme: null,
brandColor: "",
darkBrandColor: "",
allowDynamicBooking: !users.some((user) => {
return !user.allowDynamicBooking;
}),
allowDynamicBooking: !users.some((user) => !user.allowDynamicBooking),
eventName: getDynamicEventName(dynamicNames, eventTypeSlug),
}
: {
@@ -222,8 +280,8 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
const recurringEventCount =
(eventType.recurringEvent?.count &&
recurringEventCountQuery &&
(parseInt(recurringEventCountQuery) <= eventType.recurringEvent.count
? parseInt(recurringEventCountQuery)
(recurringEventCountQuery <= eventType.recurringEvent.count
? recurringEventCountQuery
: eventType.recurringEvent.count)) ||
null;
@@ -238,7 +296,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
isDynamicGroupBooking,
hasHashedBookingLink: false,
hashedLink: null,
isEmbed: typeof context.query.embed === "string",
isEmbed: !!query.embed,
},
};
}
+43 -25
View File
@@ -39,6 +39,7 @@ import { getDefaultEvent } from "@calcom/lib/defaultEvents";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import useTheme from "@calcom/lib/hooks/useTheme";
import { getEveryFreqFor } from "@calcom/lib/recurringStrings";
import { maybeGetBookingUidFromSeat } from "@calcom/lib/server/maybeGetBookingUidFromSeat";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
import { getIs24hClockFromLocalStorage, isBrowserLocale24h } from "@calcom/lib/timeFormat";
import { localStorage } from "@calcom/lib/webstorage";
@@ -182,6 +183,7 @@ const querySchema = z.object({
isSuccessBookingPage: z.string().optional(),
formerTime: z.string().optional(),
email: z.string().optional(),
seatReferenceUid: z.string().optional(),
});
export default function Success(props: SuccessProps) {
@@ -194,6 +196,7 @@ export default function Success(props: SuccessProps) {
changes,
formerTime,
email,
seatReferenceUid,
} = querySchema.parse(router.query);
if ((isCancellationMode || changes) && typeof window !== "undefined") {
@@ -259,7 +262,16 @@ export default function Success(props: SuccessProps) {
const eventName = getEventName(eventNameObject, true);
const needsConfirmation = eventType.requiresConfirmation && reschedule != true;
const isCancelled = status === "CANCELLED" || status === "REJECTED";
const userIsOwner = !!(session?.user?.id && eventType.owner?.id === session.user.id);
const isCancelled =
status === "CANCELLED" ||
status === "REJECTED" ||
(isCancellationMode &&
(!!seatReferenceUid
? !bookingInfo.seatsReferences.some((reference) => reference.referenceUid === seatReferenceUid)
: !userIsOwner));
const telemetry = useTelemetry();
useEffect(() => {
if (top !== window) {
@@ -343,7 +355,6 @@ export default function Success(props: SuccessProps) {
return t("emailed_you_and_attendees" + titleSuffix);
}
const userIsOwner = !!(session?.user?.id && eventType.owner?.id === session.user.id);
useTheme(isSuccessBookingPage ? props.profile.theme : "light");
const title = t(
`booking_${needsConfirmation ? "submitted" : "confirmed"}${props.recurringBookings ? "_recurring" : ""}`
@@ -437,7 +448,9 @@ export default function Success(props: SuccessProps) {
? t("submitted_recurring")
: t("submitted")
: isCancelled
? t("event_cancelled")
? seatReferenceUid
? t("no_longer_attending")
: t("event_cancelled")
: props.recurringBookings
? t("meeting_is_scheduled_recurring")
: t("meeting_is_scheduled")}
@@ -593,7 +606,9 @@ export default function Success(props: SuccessProps) {
{!props.recurringBookings && (
<span className="text-bookinglight inline text-gray-700">
<span className="underline">
<Link href={`/reschedule/${bookingInfo?.uid}`} legacyBehavior>
<Link
href={`/reschedule/${seatReferenceUid || bookingInfo?.uid}`}
legacyBehavior>
{t("reschedule")}
</Link>
</span>
@@ -623,6 +638,7 @@ export default function Success(props: SuccessProps) {
setIsCancellationMode={setIsCancellationMode}
theme={isSuccessBookingPage ? props.profile.theme : "light"}
allRemainingBookings={allRemainingBookings}
seatReferenceUid={seatReferenceUid}
/>
</>
))}
@@ -630,7 +646,7 @@ export default function Success(props: SuccessProps) {
!needsConfirmation &&
!isCancellationMode &&
!isCancelled &&
calculatedDuration && (
!!calculatedDuration && (
<>
<hr className="border-bookinglightest dark:border-darkgray-300 mt-8" />
<div className="text-bookingdark align-center flex flex-row justify-center pt-8">
@@ -738,6 +754,7 @@ export default function Success(props: SuccessProps) {
</div>
</>
)}
{session === null && !(userIsOwner || props.hideBranding) && (
<>
<hr className="border-bookinglightest dark:border-darkgray-300 mt-8" />
@@ -1005,7 +1022,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
const bookingInfoRaw = await prisma.booking.findFirst({
where: {
uid,
uid: await maybeGetBookingUidFromSeat(prisma, uid),
},
select: {
title: true,
@@ -1047,6 +1064,11 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
timeZone: true,
},
},
seatsReferences: {
select: {
referenceUid: true,
},
},
},
});
if (!bookingInfoRaw) {
@@ -1116,19 +1138,6 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
handleSeatsEventTypeOnBooking(eventType, bookingInfo, email);
}
let recurringBookings = null;
if (bookingInfo.recurringEventId) {
// We need to get the dates for the bookings to be able to show them in the UI
recurringBookings = await prisma.booking.findMany({
where: {
recurringEventId: bookingInfo.recurringEventId,
},
select: {
startTime: true,
},
});
}
const payment = await prisma.payment.findFirst({
where: {
bookingId: bookingInfo.id,
@@ -1138,21 +1147,30 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
refunded: true,
},
});
const paymentStatus = {
success: payment?.success,
refunded: payment?.refunded,
};
return {
props: {
hideBranding: eventType.team ? eventType.team.hideBranding : eventType.users[0].hideBranding,
profile,
eventType,
recurringBookings: recurringBookings ? recurringBookings.map((obj) => obj.startTime.toString()) : null,
recurringBookings: await getRecurringBookings(bookingInfo.recurringEventId),
trpcState: ssr.dehydrate(),
dynamicEventName: bookingInfo?.eventType?.eventName || "",
bookingInfo,
paymentStatus: payment ? paymentStatus : null,
paymentStatus: payment,
},
};
}
async function getRecurringBookings(recurringEventId: string | null) {
if (!recurringEventId) return null;
const recurringBookings = await prisma.booking.findMany({
where: {
recurringEventId,
},
select: {
startTime: true,
},
});
return recurringBookings.map((obj) => obj.startTime.toString());
}
+21 -7
View File
@@ -1,19 +1,28 @@
import type { GetServerSidePropsContext } from "next";
import { URLSearchParams } from "url";
import { z } from "zod";
import { getDefaultEvent } from "@calcom/lib/defaultEvents";
import { maybeGetBookingUidFromSeat } from "@calcom/lib/server/maybeGetBookingUidFromSeat";
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
import { asStringOrUndefined } from "@lib/asStringOrNull";
export default function Type() {
// Just redirect to the schedule page to reschedule it.
return null;
}
export async function getServerSideProps(context: GetServerSidePropsContext) {
const { uid: bookingId } = z
.object({ uid: z.string(), seatReferenceUid: z.string().optional() })
.parse(context.query);
let seatReferenceUid;
const uid = await maybeGetBookingUidFromSeat(prisma, bookingId);
if (uid) {
seatReferenceUid = bookingId;
}
const booking = await prisma.booking.findUnique({
where: {
uid: asStringOrUndefined(context.query.uid),
uid,
},
select: {
...bookingMinimalSelect,
@@ -42,9 +51,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
if (!booking) {
return {
notFound: true,
} as {
notFound: true;
};
} as const;
}
if (!booking?.eventType && !booking?.dynamicEventSlugRef) {
@@ -66,9 +73,16 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
: booking.user?.username || "rick") /* This shouldn't happen */ +
"/" +
eventType?.slug;
const destinationUrl = new URLSearchParams();
if (seatReferenceUid) {
destinationUrl.set("rescheduleUid", seatReferenceUid);
} else {
destinationUrl.set("rescheduleUid", bookingId);
}
return {
redirect: {
destination: "/" + eventPage + "?rescheduleUid=" + context.query.uid,
destination: `/${eventPage}?${destinationUrl.toString()}`,
permanent: false,
},
};
+9 -2
View File
@@ -1,4 +1,5 @@
import type { GetServerSidePropsContext } from "next";
import { z } from "zod";
import type { LocationObject } from "@calcom/app-store/locations";
import { privacyFilteredLocations } from "@calcom/app-store/locations";
@@ -24,6 +25,11 @@ export default function TeamBookingPage(props: TeamBookingPageProps) {
TeamBookingPage.isThemeSupported = true;
const querySchema = z.object({
rescheduleUid: z.string().optional(),
bookingUid: z.string().optional(),
});
export async function getServerSideProps(context: GetServerSidePropsContext) {
const ssr = await ssrInit(context);
const eventTypeId = parseInt(asStringOrThrow(context.query.type));
@@ -119,8 +125,9 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
})[0];
let booking: GetBookingType | null = null;
if (context.query.rescheduleUid) {
booking = await getBooking(prisma, context.query.rescheduleUid as string, eventTypeObject.bookingFields);
const { rescheduleUid, bookingUid } = querySchema.parse(context.query);
if (rescheduleUid || bookingUid) {
booking = await getBooking(prisma, rescheduleUid || bookingUid || "", eventTypeObject.bookingFields);
}
// Checking if number of recurring event ocurrances is valid against event type configuration
+317
View File
@@ -0,0 +1,317 @@
import { expect } from "@playwright/test";
import type { Prisma } from "@prisma/client";
import { BookingStatus } from "@prisma/client";
import { v4 as uuidv4 } from "uuid";
import prisma from "@calcom/prisma";
import type { Fixtures } from "./lib/fixtures";
import { test } from "./lib/fixtures";
import {
bookTimeSlot,
createNewSeatedEventType,
selectFirstAvailableTimeSlotNextMonth,
} from "./lib/testUtils";
test.afterEach(({ users }) => users.deleteAll());
async function createUserWithSeatedEvent(users: Fixtures["users"]) {
const slug = "seats";
const user = await users.create({
eventTypes: [
{ title: "Seated event", slug, seatsPerTimeSlot: 10, requiresConfirmation: true, length: 30 },
],
});
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const eventType = user.eventTypes.find((e) => e.slug === slug)!;
return { user, eventType };
}
async function createUserWithSeatedEventAndAttendees(
fixtures: Pick<Fixtures, "users" | "bookings">,
attendees: Prisma.AttendeeCreateManyBookingInput[]
) {
const { user, eventType } = await createUserWithSeatedEvent(fixtures.users);
const booking = await fixtures.bookings.create(user.id, user.username, eventType.id, {
status: BookingStatus.ACCEPTED,
// startTime with 1 day from now and endTime half hour after
startTime: new Date(Date.now() + 24 * 60 * 60 * 1000),
endTime: new Date(Date.now() + 24 * 60 * 60 * 1000 + 30 * 60 * 1000),
attendees: {
createMany: {
data: attendees,
},
},
});
return { user, eventType, booking };
}
test.describe("Booking with Seats", () => {
test("User can create a seated event (2 seats as example)", async ({ users, page }) => {
const user = await users.create({ name: "Seated event" });
await user.login();
const eventTitle = "My 2-seated event";
await createNewSeatedEventType(page, { eventTitle });
await expect(page.locator(`text=${eventTitle} 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}`);
await selectFirstAvailableTimeSlotNextMonth(page);
await page.waitForNavigation({
url(url) {
return url.pathname.endsWith("/book");
},
});
const bookingUrl = page.url();
await test.step("Attendee #1 can book a seated event time slot", async () => {
await page.goto(bookingUrl);
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(bookingUrl);
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 book the same seated event time slot", async () => {
await page.goto(bookingUrl);
await bookTimeSlot(page, { email: "rick@example.com", name: "Rick" });
await expect(page.locator("[data-testid=success-page]")).toBeHidden();
});
});
// TODO: Make E2E test: Attendee #1 should be able to cancel his booking
// todo("Attendee #1 should be able to cancel his booking");
// TODO: Make E2E test: Attendee #1 should be able to reschedule his booking
// todo("Attendee #1 should be able to reschedule his booking");
// TODO: Make E2E test: All attendees canceling should delete the booking for the User
// todo("All attendees canceling should delete the booking for the User");
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@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" },
]);
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() },
{ 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();
await page.waitForLoadState("networkidle");
await expect(page).toHaveURL(/.*booking/);
// 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.waitForNavigation({ url: /.*booking/ });
await page.goto(`/reschedule/${references[1].referenceUid}`);
await selectFirstAvailableTimeSlotNextMonth(page);
await page.locator('[data-testid="confirm-reschedule-button"]').click();
await page.waitForNavigation({ url: /.*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?.attendees).toBeFalsy();
});
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.login();
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="cancel"]').click();
await page.waitForLoadState("networkidle");
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,
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(
`/booking/${references[0].referenceUid}?cancel=true&seatReferenceUid=${references[0].referenceUid}`
);
await page.locator('[data-testid="cancel"]').click();
const oldBooking = await prisma.booking.findFirst({
where: { uid: booking.uid },
select: {
id: true,
status: true,
},
});
expect(oldBooking?.status).toBe(BookingStatus.ACCEPTED);
await page.goto(`/reschedule/${references[1].referenceUid}`);
await page.click('[data-testid="incrementMonth"]');
await page.locator('[data-testid="day"][data-disabled="false"]').nth(1).click();
// Validate that the number of seats its 10
expect(await page.locator("text=9 / 10 Seats available").count()).toEqual(0);
});
});
});
+3 -24
View File
@@ -4,7 +4,7 @@ import { WEBAPP_URL } from "@calcom/lib/constants";
import { randomString } from "@calcom/lib/random";
import { test } from "./lib/fixtures";
import { selectFirstAvailableTimeSlotNextMonth, bookTimeSlot } from "./lib/testUtils";
import { bookTimeSlot, createNewEventType, selectFirstAvailableTimeSlotNextMonth } from "./lib/testUtils";
test.describe.configure({ mode: "parallel" });
@@ -29,38 +29,17 @@ test.describe("Event Types tests", () => {
});
test("can add new event type", async ({ page }) => {
await page.click("[data-testid=new-event-type]");
const nonce = randomString(3);
const eventTitle = `hello ${nonce}`;
await page.fill("[name=title]", eventTitle);
await page.fill("[name=length]", "10");
await page.click("[type=submit]");
await page.waitForNavigation({
url(url) {
return url.pathname !== "/event-types";
},
});
await createNewEventType(page, { eventTitle });
await page.goto("/event-types");
await expect(page.locator(`text='${eventTitle}'`)).toBeVisible();
});
test("enabling recurring event comes with default options", async ({ page }) => {
await page.click("[data-testid=new-event-type]");
const nonce = randomString(3);
const eventTitle = `my recurring event ${nonce}`;
await page.fill("[name=title]", eventTitle);
await page.fill("[name=length]", "15");
await page.click("[type=submit]");
await page.waitForNavigation({
url(url) {
return url.pathname !== "/event-types";
},
});
await createNewEventType(page, { eventTitle });
await page.click("[data-testid=vertical-tab-recurring]");
await expect(page.locator("[data-testid=recurring-event-collapsible]")).toBeHidden();
+13 -8
View File
@@ -18,7 +18,18 @@ export const createBookingsFixture = (page: Page) => {
userId: number,
username: string | null,
eventTypeId = -1,
{ rescheduled = false, paid = false, status = "ACCEPTED" }: Partial<Prisma.BookingCreateInput> = {},
{
rescheduled = false,
paid = false,
status = "ACCEPTED",
attendees = {
create: {
email: "attendee@example.com",
name: "Attendee Example",
timeZone: "Europe/London",
},
},
}: Partial<Prisma.BookingCreateInput> = {},
startDateParam?: Date,
endDateParam?: Date
) => {
@@ -36,13 +47,7 @@ export const createBookingsFixture = (page: Page) => {
id: userId,
},
},
attendees: {
create: {
email: "attendee@example.com",
name: "Attendee Example",
timeZone: "Europe/London",
},
},
attendees,
eventType: {
connect: {
id: eventTypeId,
+24 -55
View File
@@ -69,61 +69,24 @@ export const createUsersFixture = (page: Page, workerInfo: WorkerInfo) => {
} = {}
) => {
const _user = await prisma.user.create({
data: await createUser(workerInfo, opts),
});
await prisma.eventType.create({
data: {
owner: {
connect: {
id: _user.id,
},
},
users: {
connect: {
id: _user.id,
},
},
title: "30 min",
slug: "30-min",
length: 30,
},
});
await prisma.eventType.create({
data: {
users: {
connect: {
id: _user.id,
},
},
owner: {
connect: {
id: _user.id,
},
},
title: "Paid",
slug: "paid",
length: 30,
price: 1000,
},
});
await prisma.eventType.create({
data: {
users: {
connect: {
id: _user.id,
},
},
owner: {
connect: {
id: _user.id,
},
},
title: "Opt in",
slug: "opt-in",
requiresConfirmation: true,
length: 30,
},
data: createUser(workerInfo, opts),
});
let defaultEventTypes: SupportedTestEventTypes[] = [
{ title: "30 min", slug: "30-min", length: 30 },
{ title: "Paid", slug: "paid", length: 30, price: 1000 },
{ title: "Opt in", slug: "opt-in", requiresConfirmation: true, length: 30 },
];
if (opts?.eventTypes) defaultEventTypes = defaultEventTypes.concat(opts.eventTypes);
for (const eventTypeData of defaultEventTypes) {
eventTypeData.owner = { connect: { id: _user.id } };
eventTypeData.users = { connect: { id: _user.id } };
await prisma.eventType.create({
data: eventTypeData,
});
}
if (scenario.seedRoutingForms) {
await prisma.app_RoutingForms_Form.create({
data: {
@@ -314,8 +277,14 @@ const createUserFixture = (user: UserWithIncludes, page: Page) => {
};
};
type SupportedTestEventTypes = PrismaType.EventTypeCreateInput & {
_bookings?: PrismaType.BookingCreateInput[];
};
type CustomUserOptsKeys = "username" | "password" | "completedOnboarding" | "locale" | "name";
type CustomUserOpts = Partial<Pick<Prisma.User, CustomUserOptsKeys>> & { timeZone?: TimeZoneEnum };
type CustomUserOpts = Partial<Pick<Prisma.User, CustomUserOptsKeys>> & {
timeZone?: TimeZoneEnum;
eventTypes?: SupportedTestEventTypes[];
};
// creates the actual user in the db.
const createUser = (workerInfo: WorkerInfo, opts?: CustomUserOpts | null): PrismaType.UserCreateInput => {
+25 -3
View File
@@ -133,10 +133,10 @@ export async function bookFirstEvent(page: Page) {
await bookEventOnThisPage(page);
}
export const bookTimeSlot = async (page: Page) => {
export const bookTimeSlot = async (page: Page, opts?: { name?: string; email?: string }) => {
// --- fill form
await page.fill('[name="name"]', "Test Testson");
await page.fill('[name="email"]', "test@example.com");
await page.fill('[name="name"]', opts?.name ?? "Test Testson");
await page.fill('[name="email"]', opts?.email ?? "test@example.com");
await page.press('[name="email"]', "Enter");
};
// Provide an standalone localize utility not managed by next-i18n
@@ -148,3 +148,25 @@ export async function localize(locale: string) {
throw "No locale found for the given entry message";
};
}
export const createNewEventType = async (page: Page, args: { eventTitle: string }) => {
await page.click("[data-testid=new-event-type]");
const eventTitle = args.eventTitle;
await page.fill("[name=title]", eventTitle);
await page.fill("[name=length]", "10");
await page.click("[type=submit]");
await page.waitForNavigation({
url(url) {
return url.pathname !== "/event-types";
},
});
};
export const createNewSeatedEventType = async (page: Page, args: { eventTitle: string }) => {
const eventTitle = args.eventTitle;
await createNewEventType(page, { eventTitle });
await page.locator('[data-testid="vertical-tab-event_advanced_tab_title"]').click();
await page.locator('[data-testid="offer-seats-toggle"]').click();
await page.locator('[data-testid="update-eventtype"]').click();
};
+13 -4
View File
@@ -19,7 +19,7 @@ test.afterEach(({ users }) => users.deleteAll());
test.describe("Reschedule Tests", async () => {
test("Should do a booking request reschedule from /bookings", async ({ page, users, bookings }) => {
const user = await users.create();
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const booking = await bookings.create(user.id, user.username, user.eventTypes[0].id!, {
status: BookingStatus.ACCEPTED,
});
@@ -45,15 +45,18 @@ test.describe("Reschedule Tests", async () => {
});
test("Should display former time when rescheduling availability", async ({ page, users, bookings }) => {
test.skip(true, "TODO: Re-enable after v1.7 launch");
const user = await users.create();
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const booking = await bookings.create(user.id, user.username, user.eventTypes[0].id!, {
status: BookingStatus.CANCELLED,
rescheduled: true,
});
await page.goto(`/${user.username}/${user.eventTypes[0].slug}?rescheduleUid=${booking.uid}`);
const formerTimeElement = page.locator('[data-testid="former_time_p_desktop"]');
await selectFirstAvailableTimeSlotNextMonth(page);
const formerTimeElement = page.locator('[data-testid="former_time_p"]');
await expect(formerTimeElement).toBeVisible();
await booking.delete();
});
@@ -90,10 +93,12 @@ test.describe("Reschedule Tests", async () => {
await page.locator('[data-testid="confirm-reschedule-button"]').click();
await page.waitForLoadState("networkidle");
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
// NOTE: remove if old booking should not be deleted
expect(await booking.self()).toBeNull();
expect(await (await booking.self()).status).toBe(BookingStatus.CANCELLED);
const newBooking = await prisma.booking.findFirst({ where: { fromReschedule: booking.uid } });
expect(newBooking).not.toBeNull();
@@ -106,6 +111,7 @@ test.describe("Reschedule Tests", async () => {
const user = await users.create();
await user.login();
await user.getPaymentCredential();
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const eventType = user.eventTypes.find((e) => e.slug === "paid")!;
const booking = await bookings.create(user.id, user.username, eventType.id, {
rescheduled: true,
@@ -150,6 +156,7 @@ test.describe("Reschedule Tests", async () => {
await user.login();
await user.getPaymentCredential();
await users.logout();
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const eventType = user.eventTypes.find((e) => e.slug === "paid")!;
const booking = await bookings.create(user.id, user.username, eventType.id, {
rescheduled: true,
@@ -171,6 +178,7 @@ test.describe("Reschedule Tests", async () => {
test("Opt in event should be PENDING when rescheduled by USER", async ({ page, users, bookings }) => {
const user = await users.create();
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const eventType = user.eventTypes.find((e) => e.slug === "opt-in")!;
const booking = await bookings.create(user.id, user.username, eventType.id, {
status: BookingStatus.ACCEPTED,
@@ -191,6 +199,7 @@ test.describe("Reschedule Tests", async () => {
test("Opt in event should be ACCEPTED when rescheduled by OWNER", async ({ page, users, bookings }) => {
const user = await users.create();
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const eventType = user.eventTypes.find((e) => e.slug === "opt-in")!;
const booking = await bookings.create(user.id, user.username, eventType.id, {
status: BookingStatus.ACCEPTED,
@@ -1597,6 +1597,11 @@
"create_a_one_one_template": "Create a one-one one template for an event type and distribute it to multiple members.",
"collective_or_roundrobin": "Collective or round-robin",
"book_your_team_members": "Book your team members together with collective events or cycle through to get the right person with round-robin.",
"event_no_longer_attending_subject": "No longer attending {{title}} at {{date}}",
"no_longer_attending": "You are no longer attending this event",
"attendee_no_longer_attending_subject": "An attendee is no longer attending {{title}} at {{date}}",
"attendee_no_longer_attending": "An attendee is no longer attending your event",
"attendee_no_longer_attending_subtitle": "{{name}} has cancelled. This means a seat has opened up for this time slot",
"create_event_on": "Create event on",
"default_app_link_title": "Set a default app link",
"default_app_link_description": "Setting a default app link allows all newly created event types to use the app link you set.",
@@ -1612,6 +1617,7 @@
"booking_with_payment_cancelled_already_paid": "A refund for this booking payment it's on the way.",
"booking_with_payment_cancelled_refunded": "This booking payment has been refunded.",
"booking_confirmation_failed": "Booking confirmation failed",
"not_enough_seats": "Not enough seats",
"form_builder_field_already_exists": "A field with this name already exists",
"form_builder_field_add_subtitle": "Customize the questions asked on the booking page",
"form_builder_system_field_cant_delete": "This system field can't be removed.",
@@ -71,6 +71,10 @@ export default class GoogleCalendarService implements Calendar {
};
async createEvent(calEventRaw: CalendarEvent): Promise<NewCalendarEventType> {
const eventAttendees = calEventRaw.attendees.map(({ id, ...rest }) => ({
...rest,
responseStatus: "accepted",
}));
return new Promise(async (resolve, reject) => {
const myGoogleAuth = await this.auth.getToken();
const payload: calendar_v3.Schema$Event = {
@@ -94,11 +98,7 @@ export default class GoogleCalendarService implements Calendar {
? calEventRaw.destinationCalendar.externalId
: calEventRaw.organizer.email,
},
// eslint-disable-next-line
...calEventRaw.attendees.map(({ id, ...rest }) => ({
...rest,
responseStatus: "accepted",
})),
...eventAttendees,
],
reminders: {
useDefault: true,
+1 -1
View File
@@ -236,7 +236,7 @@ export const createEvent = async (
calEvent.additionalNotes = "Notes have been hidden by the organiser"; // TODO: i18n this string?
}
// TODO: Surfice success/error messages coming from apps to improve end user visibility
// TODO: Surface success/error messages coming from apps to improve end user visibility
const creationResult = calendar
? await calendar.createEvent(calEvent).catch(async (error) => {
success = false;
+75 -43
View File
@@ -1,8 +1,10 @@
import type { DestinationCalendar } from "@prisma/client";
import type { DestinationCalendar, Booking } from "@prisma/client";
import { cloneDeep } from "lodash";
import merge from "lodash/merge";
import { v5 as uuidv5 } from "uuid";
import type { z } from "zod";
import { getCalendar } from "@calcom/app-store/_utils/getCalendar";
import { FAKE_DAILY_CREDENTIAL } from "@calcom/app-store/dailyvideo/lib/VideoApiAdapter";
import { getEventLocationTypeFromApp } from "@calcom/app-store/locations";
import { MeetLocationType } from "@calcom/app-store/locations";
@@ -119,8 +121,10 @@ export default class EventManager {
results.push(result);
}
// Some calendar libraries may edit the original event so let's clone it
const clonedCalEvent = cloneDeep(event);
// Create the calendar event with the proper video call data
results.push(...(await this.createAllCalendarEvents(evt)));
results.push(...(await this.createAllCalendarEvents(clonedCalEvent)));
const referencesToCreate = results.map((result) => {
let createdEventObj: createdEventSchema | null = null;
@@ -193,11 +197,10 @@ export default class EventManager {
public async reschedule(
event: CalendarEvent,
rescheduleUid: string,
newBookingId?: number,
rescheduleReason?: string
newBookingId?: number
): Promise<CreateUpdateResult> {
const evt = processLocation(event);
const originalEvt = processLocation(event);
const evt = cloneDeep(originalEvt);
if (!rescheduleUid) {
throw new Error("You called eventManager.update without an `rescheduleUid`. This should never happen.");
}
@@ -210,6 +213,7 @@ export default class EventManager {
select: {
id: true,
userId: true,
attendees: true,
references: {
// NOTE: id field removed from select as we don't require for deletingMany
// but was giving error on recreate for reschedule, probably because promise.all() didn't finished
@@ -225,6 +229,12 @@ export default class EventManager {
},
destinationCalendar: true,
payment: true,
eventType: {
select: {
seatsPerTimeSlot: true,
seatsShowAttendees: true,
},
},
},
});
@@ -232,22 +242,13 @@ export default class EventManager {
throw new Error("booking not found");
}
// Add reschedule reason to new booking
await prisma.booking.update({
where: {
id: newBookingId,
},
data: {
cancellationReason: rescheduleReason,
},
});
const isDedicated = evt.location ? isDedicatedIntegration(evt.location) : null;
const results: Array<EventResult<Event>> = [];
// If and only if event type is a dedicated meeting, update the dedicated video meeting.
if (isDedicated) {
const result = await this.updateVideoEvent(evt, booking);
const [updatedEvent] = Array.isArray(result.updatedEvent) ? result.updatedEvent : [result.updatedEvent];
if (updatedEvent) {
evt.videoCallData = updatedEvent;
evt.location = updatedEvent.url;
@@ -255,7 +256,11 @@ export default class EventManager {
results.push(result);
}
results.push(...(await this.updateAllCalendarEvents(evt, booking)));
// There was a case that booking didn't had any reference and we don't want to throw error on function
if (booking.references.find((reference) => reference.type.includes("_calendar"))) {
// Update all calendar events.
results.push(...(await this.updateAllCalendarEvents(evt, booking, newBookingId)));
}
const bookingPayment = booking?.payment;
@@ -274,27 +279,6 @@ export default class EventManager {
});
}
// Now we can delete the old booking and its references.
const bookingReferenceDeletes = prisma.bookingReference.deleteMany({
where: {
bookingId: booking.id,
},
});
const attendeeDeletes = prisma.attendee.deleteMany({
where: {
bookingId: booking.id,
},
});
const bookingDeletes = prisma.booking.delete({
where: {
id: booking.id,
},
});
// Wait for all deletions to be applied.
await Promise.all([bookingReferenceDeletes, attendeeDeletes, bookingDeletes]);
return {
results,
referencesToCreate: [...booking.references],
@@ -302,7 +286,10 @@ export default class EventManager {
}
public async updateCalendarAttendees(event: CalendarEvent, booking: PartialBooking) {
// @NOTE: This function is only used for updating attendees on a calendar event. Can we remove this?
if (booking.references.length === 0) {
console.error("Tried to update references but there wasn't any.");
return;
}
await this.updateAllCalendarEvents(event, booking);
}
@@ -386,7 +373,7 @@ export default class EventManager {
let videoCredential = this.videoCredentials
// Whenever a new video connection is added, latest credentials are added with the highest ID.
// Because you can't rely on having them in the higgest first order here, ensure this by sorting in DESC order
// Because you can't rely on having them in the highest first order here, ensure this by sorting in DESC order
.sort((a, b) => {
return b.id - a.id;
})
@@ -433,13 +420,32 @@ export default class EventManager {
*/
private async updateAllCalendarEvents(
event: CalendarEvent,
booking: PartialBooking
booking: PartialBooking,
newBookingId?: number
): Promise<Array<EventResult<NewCalendarEventType>>> {
let calendarReference: PartialReference | undefined = undefined,
credential;
try {
// Bookings should only have one calendar reference
calendarReference = booking.references.filter((reference) => reference.type.includes("_calendar"))[0];
// If a newBookingId is given, update that calendar event
let newBooking;
if (newBookingId) {
newBooking = await prisma.booking.findUnique({
where: {
id: newBookingId,
},
select: {
references: true,
},
});
}
if (newBooking) {
calendarReference = newBooking.references.find((reference) => reference.type.includes("_calendar"));
} else {
// Bookings should only have one calendar reference
calendarReference = booking.references.find((reference) => reference.type.includes("_calendar"));
}
if (!calendarReference) {
return [];
}
@@ -464,6 +470,22 @@ export default class EventManager {
}
}
// If we are merging two calendar events we should delete the old calendar event
if (newBookingId) {
const oldCalendarEvent = booking.references.find((reference) => reference.type.includes("_calendar"));
if (oldCalendarEvent?.credentialId) {
const calendarCredential = await prisma.credential.findUnique({
where: {
id: oldCalendarEvent.credentialId,
},
});
const calendar = getCalendar(calendarCredential);
await calendar?.deleteEvent(oldCalendarEvent.uid, event, oldCalendarEvent.externalCalendarId);
}
}
// Taking care of non-traditional calendar integrations
result = result.concat(
this.calendarCredentials
@@ -536,4 +558,14 @@ export default class EventManager {
public async updateAndSetCancelledPlaceholder(event: CalendarEvent, booking: PartialBooking) {
await this.updateAllCalendarEvents(event, booking);
}
public async rescheduleBookingWithSeats(
originalBooking: Booking,
newTimeSlotBooking?: Booking,
owner?: boolean
) {
// Get originalBooking
// If originalBooking has only one attendee we should do normal reschedule
// Change current event attendees in everyone calendar
}
}
+21 -1
View File
@@ -1,3 +1,4 @@
import { cloneDeep } from "lodash";
import type { TFunction } from "next-i18next";
import type { EventNameObjectType } from "@calcom/core/event";
@@ -7,6 +8,7 @@ import type { CalendarEvent, Person } from "@calcom/types/Calendar";
import AttendeeAwaitingPaymentEmail from "./templates/attendee-awaiting-payment-email";
import AttendeeCancelledEmail from "./templates/attendee-cancelled-email";
import AttendeeCancelledSeatEmail from "./templates/attendee-cancelled-seat-email";
import AttendeeDeclinedEmail from "./templates/attendee-declined-email";
import AttendeeLocationChangeEmail from "./templates/attendee-location-change-email";
import AttendeeRequestEmail from "./templates/attendee-request-email";
@@ -19,6 +21,7 @@ import type { Feedback } from "./templates/feedback-email";
import FeedbackEmail from "./templates/feedback-email";
import type { PasswordReset } from "./templates/forgot-password-email";
import ForgotPasswordEmail from "./templates/forgot-password-email";
import OrganizerAttendeeCancelledSeatEmail from "./templates/organizer-attendee-cancelled-seat-email";
import OrganizerCancelledEmail from "./templates/organizer-cancelled-email";
import OrganizerLocationChangeEmail from "./templates/organizer-location-change-email";
import OrganizerPaymentRefundFailedEmail from "./templates/organizer-payment-refund-failed-email";
@@ -83,7 +86,6 @@ export const sendRescheduledEmails = async (calEvent: CalendarEvent) => {
}
}
// @TODO: we should obtain who is rescheduling the event and send them a different email
emailsToSend.push(
...calEvent.attendees.map((attendee) => {
return sendEmail(() => new AttendeeRescheduledEmail(calEvent, attendee));
@@ -93,6 +95,16 @@ export const sendRescheduledEmails = async (calEvent: CalendarEvent) => {
await Promise.all(emailsToSend);
};
export const sendRescheduledSeatEmail = async (calEvent: CalendarEvent, attendee: Person) => {
const clonedCalEvent = cloneDeep(calEvent);
const emailsToSend: Promise<unknown>[] = [
sendEmail(() => new AttendeeRescheduledEmail(clonedCalEvent, attendee)),
sendEmail(() => new OrganizerRescheduledEmail({ calEvent })),
];
await Promise.all(emailsToSend);
};
export const sendScheduledSeatsEmails = async (
calEvent: CalendarEvent,
invitee: Person,
@@ -114,6 +126,14 @@ export const sendScheduledSeatsEmails = async (
await Promise.all(emailsToSend);
};
export const sendCancelledSeatEmails = async (calEvent: CalendarEvent, cancelledAttendee: Person) => {
const clonedCalEvent = cloneDeep(calEvent);
await Promise.all([
sendEmail(() => new AttendeeCancelledSeatEmail(clonedCalEvent, cancelledAttendee)),
sendEmail(() => new OrganizerAttendeeCancelledSeatEmail({ calEvent })),
]);
};
export const sendOrganizerRequestEmail = async (calEvent: CalendarEvent) => {
const emailsToSend: Promise<unknown>[] = [];
@@ -6,7 +6,7 @@ export function ManageLink(props: { calEvent: CalendarEvent; attendee: Person })
// Guests cannot
const t = props.attendee.language.translate;
if (
props.attendee.email === props.calEvent.attendees[0].email ||
props.attendee.email === props.calEvent.attendees[0]?.email ||
props.calEvent.organizer.email === props.attendee.email
) {
return (
@@ -0,0 +1,12 @@
import { AttendeeScheduledEmail } from "./AttendeeScheduledEmail";
export const AttendeeCancelledSeatEmail = (props: React.ComponentProps<typeof AttendeeScheduledEmail>) => (
<AttendeeScheduledEmail
title="no_longer_attending"
headerType="xCircle"
subject="event_no_longer_attending_subject"
subtitle=""
callToAction={null}
{...props}
/>
);
@@ -58,13 +58,15 @@ export const BaseScheduledEmail = (
: props.callToAction || <ManageLink attendee={props.attendee} calEvent={props.calEvent} />
}
subtitle={props.subtitle || <>{t("emailed_you_and_any_other_attendees")}</>}>
<Info
label={t("cancellation_reason")}
description={
props.calEvent.cancellationReason && props.calEvent.cancellationReason.replace("$RCH$", "")
} // Removing flag to distinguish reschedule from cancellation
withSpacer
/>
{props.calEvent.cancellationReason && (
<Info
label={t("cancellation_reason")}
description={
!!props.calEvent.cancellationReason && props.calEvent.cancellationReason.replace("$RCH$", "")
} // Removing flag to distinguish reschedule from cancellation
withSpacer
/>
)}
<Info label={t("rejection_reason")} description={props.calEvent.rejectionReason} withSpacer />
<Info label={t("what")} description={props.calEvent.title} withSpacer />
<WhenInfo calEvent={props.calEvent} t={t} timeZone={timeZone} />
@@ -0,0 +1,14 @@
import { OrganizerScheduledEmail } from "./OrganizerScheduledEmail";
export const OrganizerAttendeeCancelledSeatEmail = (
props: React.ComponentProps<typeof OrganizerScheduledEmail>
) => (
<OrganizerScheduledEmail
title="attendee_no_longer_attending"
headerType="xCircle"
subject="event_cancelled_subject"
callToAction={null}
attendeeCancelled
{...props}
/>
);
@@ -7,6 +7,7 @@ export const OrganizerScheduledEmail = (
calEvent: CalendarEvent;
attendee: Person;
newSeat?: boolean;
attendeeCancelled?: boolean;
teamMember?: Person;
} & Partial<React.ComponentProps<typeof BaseScheduledEmail>>
) => {
@@ -35,6 +36,13 @@ export const OrganizerScheduledEmail = (
subject={t(subject)}
title={t(title)}
includeAppsStatus
subtitle={
<>
{props.attendeeCancelled
? t("attendee_no_longer_attending_subtitle", { name: props.attendee.name })
: ""}
</>
}
{...props}
/>
);
+2
View File
@@ -1,5 +1,6 @@
export { AttendeeAwaitingPaymentEmail } from "./AttendeeAwaitingPaymentEmail";
export { AttendeeCancelledEmail } from "./AttendeeCancelledEmail";
export { AttendeeCancelledSeatEmail } from "./AttendeeCancelledSeatEmail";
export { AttendeeDeclinedEmail } from "./AttendeeDeclinedEmail";
export { AttendeeLocationChangeEmail } from "./AttendeeLocationChangeEmail";
export { AttendeeRequestEmail } from "./AttendeeRequestEmail";
@@ -19,4 +20,5 @@ export { OrganizerRescheduledEmail } from "./OrganizerRescheduledEmail";
export { OrganizerScheduledEmail } from "./OrganizerScheduledEmail";
export { TeamInviteEmail } from "./TeamInviteEmail";
export { BrokenIntegrationEmail } from "./BrokenIntegrationEmail";
export { OrganizerAttendeeCancelledSeatEmail } from "./OrganizerAttendeeCancelledSeatEmail";
export * from "@calcom/app-store/routing-forms/emails/components";
@@ -0,0 +1,21 @@
import { renderEmail } from "../";
import AttendeeScheduledEmail from "./attendee-scheduled-email";
export default class AttendeeCancelledSeatEmail extends AttendeeScheduledEmail {
protected getNodeMailerPayload(): Record<string, unknown> {
return {
to: `${this.attendee.name} <${this.attendee.email}>`,
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
replyTo: this.calEvent.organizer.email,
subject: `${this.t("event_no_longer_attending_subject", {
title: this.calEvent.title,
date: this.getFormattedDate(),
})}`,
html: renderEmail("AttendeeCancelledSeatEmail", {
calEvent: this.calEvent,
attendee: this.attendee,
}),
text: this.getTextBody("event_request_cancelled", "emailed_you_and_any_other_attendees"),
};
}
}
@@ -1,5 +1,6 @@
import type { DateArray, ParticipationStatus, ParticipationRole } from "ics";
import { createEvent } from "ics";
import { cloneDeep } from "lodash";
import type { TFunction } from "next-i18next";
import { RRule } from "rrule";
@@ -15,6 +16,7 @@ export default class AttendeeScheduledEmail extends BaseEmail {
attendee: Person;
showAttendees: boolean | undefined;
t: TFunction;
attendees: Person[];
constructor(calEvent: CalendarEvent, attendee: Person, showAttendees?: boolean | undefined) {
super();
@@ -23,8 +25,9 @@ export default class AttendeeScheduledEmail extends BaseEmail {
this.attendee = attendee;
this.showAttendees = showAttendees;
this.t = attendee.language.translate;
if (!this.showAttendees) {
this.attendees = [...this.calEvent.attendees];
if (!this.showAttendees && this.calEvent.seatsPerTimeSlot) {
this.attendees = [this.attendee];
this.calEvent.attendees = [this.attendee];
}
}
@@ -50,6 +53,7 @@ export default class AttendeeScheduledEmail extends BaseEmail {
description: this.getTextBody(),
duration: { minutes: dayjs(this.calEvent.endTime).diff(dayjs(this.calEvent.startTime), "minute") },
organizer: { name: this.calEvent.organizer.name, email: this.calEvent.organizer.email },
attendees: [
...this.calEvent.attendees.map((attendee: Person) => ({
name: attendee.name,
@@ -79,6 +83,10 @@ export default class AttendeeScheduledEmail extends BaseEmail {
}
protected getNodeMailerPayload(): Record<string, unknown> {
const clonedCalEvent = cloneDeep(this.calEvent);
this.getiCalEventAsString();
return {
icalEvent: {
filename: "event.ics",
@@ -90,7 +98,7 @@ export default class AttendeeScheduledEmail extends BaseEmail {
replyTo: [...this.calEvent.attendees.map(({ email }) => email), this.calEvent.organizer.email],
subject: `${this.calEvent.title}`,
html: renderEmail("AttendeeScheduledEmail", {
calEvent: this.calEvent,
calEvent: clonedCalEvent,
attendee: this.attendee,
}),
text: this.getTextBody(),
@@ -0,0 +1,32 @@
import { APP_NAME } from "@calcom/lib/constants";
import { renderEmail } from "../";
import OrganizerScheduledEmail from "./organizer-scheduled-email";
export default class OrganizerCancelledEmail extends OrganizerScheduledEmail {
protected getNodeMailerPayload(): Record<string, unknown> {
const toAddresses = [this.calEvent.organizer.email];
if (this.calEvent.team) {
this.calEvent.team.members.forEach((member) => {
const memberAttendee = this.calEvent.attendees.find((attendee) => attendee.email === member.email);
if (memberAttendee) {
toAddresses.push(memberAttendee.email);
}
});
}
return {
from: `${APP_NAME} <${this.getMailerOptions().from}>`,
to: toAddresses.join(","),
subject: `${this.t("event_cancelled_subject", {
title: this.calEvent.title,
date: this.getFormattedDate(),
})}`,
html: renderEmail("OrganizerAttendeeCancelledSeatEmail", {
attendee: this.calEvent.organizer,
calEvent: this.calEvent,
}),
text: this.getTextBody("event_request_cancelled"),
};
}
}
@@ -25,7 +25,7 @@ export default class OrganizerRescheduledEmail extends OrganizerScheduledEmail {
)} ${this.getOrganizerStart("D")}, ${this.getOrganizerStart("YYYY")}`,
})}`,
html: renderEmail("OrganizerRescheduledEmail", {
calEvent: this.calEvent,
calEvent: { ...this.calEvent, attendeeSeatId: undefined },
attendee: this.calEvent.organizer,
}),
text: this.getTextBody("event_has_been_rescheduled"),
@@ -1,5 +1,6 @@
import type { DateArray } from "ics";
import { createEvent } from "ics";
import { cloneDeep } from "lodash";
import type { TFunction } from "next-i18next";
import { RRule } from "rrule";
@@ -67,7 +68,17 @@ export default class OrganizerScheduledEmail extends BaseEmail {
}
protected getNodeMailerPayload(): Record<string, unknown> {
const toAddresses = [this.teamMember?.email || this.calEvent.organizer.email];
const clonedCalEvent = cloneDeep(this.calEvent);
const toAddresses = [this.calEvent.organizer.email];
if (this.calEvent.team) {
this.calEvent.team.members.forEach((member) => {
const memberAttendee = this.calEvent.attendees.find((attendee) => attendee.email === member.email);
if (memberAttendee) {
toAddresses.push(memberAttendee.email);
}
});
}
return {
icalEvent: {
@@ -78,7 +89,7 @@ export default class OrganizerScheduledEmail extends BaseEmail {
to: toAddresses.join(","),
subject: `${this.newSeat ? this.t("new_attendee") + ":" : ""} ${this.calEvent.title}`,
html: renderEmail("OrganizerScheduledEmail", {
calEvent: this.calEvent,
calEvent: clonedCalEvent,
attendee: this.calEvent.organizer,
teamMember: this.teamMember,
newSeat: this.newSeat,
@@ -7,9 +7,9 @@ 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 { cancelScheduledJobs } from "@calcom/app-store/zapier/lib/nodeScheduler";
import { deleteMeeting } from "@calcom/core/videoClient";
import { deleteMeeting, updateMeeting } from "@calcom/core/videoClient";
import dayjs from "@calcom/dayjs";
import { sendCancelledEmails } from "@calcom/emails";
import { sendCancelledEmails, sendCancelledSeatEmails } 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";
@@ -24,12 +24,8 @@ 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({
async function getBookingToDelete(id: number | undefined, uid: string | undefined) {
return await prisma.booking.findUnique({
where: {
id,
uid,
@@ -70,6 +66,7 @@ async function handler(req: NextApiRequest & { userId?: number }) {
price: true,
currency: true,
length: true,
seatsPerTimeSlot: true,
hosts: {
select: {
user: true,
@@ -92,8 +89,21 @@ async function handler(req: NextApiRequest & { userId?: number }) {
smsReminderNumber: true,
workflowReminders: true,
scheduledJobs: true,
seatsReferences: true,
},
});
}
type CustomRequest = NextApiRequest & {
userId?: number;
bookingToDelete?: Awaited<ReturnType<typeof getBookingToDelete>>;
};
async function handler(req: CustomRequest) {
const { id, uid, allRemainingBookings, cancellationReason, seatReferenceUid } =
schemaBookingCancelParams.parse(req.body);
req.bookingToDelete = await getBookingToDelete(id, uid);
const { bookingToDelete, userId } = req;
if (!bookingToDelete || !bookingToDelete.user) {
throw new HttpError({ statusCode: 400, message: "Booking not found" });
@@ -107,6 +117,10 @@ async function handler(req: NextApiRequest & { userId?: number }) {
throw new HttpError({ statusCode: 400, message: "User not found" });
}
// If it's just an attendee of a booking then just remove them from that booking
const result = await handleSeatedEventCancellation(req);
if (result) return { success: true };
const organizer = await prisma.user.findFirstOrThrow({
where: {
id: bookingToDelete.userId,
@@ -178,6 +192,99 @@ async function handler(req: NextApiRequest & { userId?: number }) {
...(teamMembers && { team: { name: "", members: teamMembers } }),
};
// 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,
},
});
/* If there are references then we should update them as well */
const lastAttendee =
bookingToDelete.attendees.filter((bookingAttendee) => attendee.email !== bookingAttendee.email).length <
0;
const integrationsToDelete = [];
for (const reference of bookingToDelete.references) {
if (reference.credentialId) {
const credential = await prisma.credential.findUnique({
where: {
id: reference.credentialId,
},
});
if (credential) {
if (lastAttendee) {
if (reference.type.includes("_video")) {
integrationsToDelete.push(deleteMeeting(credential, reference.uid));
}
if (reference.type.includes("_calendar")) {
const calendar = getCalendar(credential);
if (calendar) {
integrationsToDelete.push(
calendar?.deleteEvent(reference.uid, evt, reference.externalCalendarId)
);
}
}
} else {
const updatedEvt = {
...evt,
attendees: evt.attendees.filter((evtAttendee) => attendee.email !== evtAttendee.email),
};
if (reference.type.includes("_video")) {
integrationsToDelete.push(
updateMeeting(
{ ...credential, appName: evt.location?.replace("integrations:", "") || "" },
updatedEvt,
reference
)
);
}
if (reference.type.includes("_calendar")) {
const calendar = getCalendar(credential);
if (calendar) {
integrationsToDelete.push(
calendar?.updateEvent(reference.uid, updatedEvt, reference.externalCalendarId)
);
}
}
}
}
}
}
await Promise.all(integrationsToDelete).then(async () => {
if (lastAttendee) {
await prisma.booking.delete({
where: {
id: bookingToDelete.id,
},
});
}
});
const tAttendees = await getTranslation(attendee.locale ?? "en", "common");
await sendCancelledSeatEmails(evt, {
...attendee,
language: { translate: tAttendees, locale: attendee.locale ?? "en" },
});
req.statusCode = 200;
return { message: "No longer attending event" };
}
// Hook up the webhook logic here
const eventTrigger: WebhookTriggerEvents = "BOOKING_CANCELLED";
// Send Webhook call if hooked to BOOKING.CANCELLED
@@ -274,6 +381,13 @@ async function handler(req: NextApiRequest & { userId?: number }) {
});
updatedBookings = updatedBookings.concat(allUpdatedBookings);
} else {
if (bookingToDelete?.eventType?.seatsPerTimeSlot) {
await prisma.attendee.deleteMany({
where: {
bookingId: bookingToDelete.id,
},
});
}
const updatedBooking = await prisma.booking.update({
where: {
id,
@@ -524,4 +638,31 @@ async function handler(req: NextApiRequest & { userId?: number }) {
return { message: "Booking successfully cancelled." };
}
async function handleSeatedEventCancellation(req: CustomRequest) {
const { seatReferenceUid } = schemaBookingCancelParams.parse(req.body);
if (!seatReferenceUid) return;
if (!req.bookingToDelete?.attendees.length || req.bookingToDelete.attendees.length < 2) return;
const seatReference = req.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;
return { success: true };
}
export default handler;
File diff suppressed because it is too large Load Diff
+18 -6
View File
@@ -30,10 +30,11 @@ ${calEvent.attendees[0].timeZone}
};
export const getWho = (calEvent: CalendarEvent) => {
let attendeesFromCalEvent = [...calEvent.attendees];
if (calEvent.seatsPerTimeSlot && !calEvent.seatsShowAttendees) {
calEvent.attendees = [];
attendeesFromCalEvent = [];
}
const attendees = calEvent.attendees
const attendees = attendeesFromCalEvent
.map((attendee) => {
return `
${attendee?.name || calEvent.organizer.language.translate("guest")}
@@ -144,7 +145,12 @@ export const getProviderName = (calEvent: CalendarEvent): string => {
};
export const getUid = (calEvent: CalendarEvent): string => {
return calEvent.uid ?? translator.fromUUID(uuidv5(JSON.stringify(calEvent), uuidv5.URL));
const uid = calEvent.uid;
return uid ?? translator.fromUUID(uuidv5(JSON.stringify(calEvent), uuidv5.URL));
};
const getSeatReferenceId = (calEvent: CalendarEvent): string => {
return calEvent.attendeeSeatId ? `seatReferenceUid=${calEvent.attendeeSeatId}` : "";
};
export const getManageLink = (calEvent: CalendarEvent) => {
@@ -156,12 +162,18 @@ ${WEBAPP_URL + "/booking/" + getUid(calEvent) + "?changes=true"}
export const getCancelLink = (calEvent: CalendarEvent): string => {
return (
WEBAPP_URL + `/booking/${getUid(calEvent)}?cancel=true&allRemainingBookings=${!!calEvent.recurringEvent}`
WEBAPP_URL +
`/booking/${getUid(
calEvent
)}?cancel=true&allRemainingBookings=${!!calEvent.recurringEvent}&${getSeatReferenceId}`
);
};
export const getRescheduleLink = (calEvent: CalendarEvent): string => {
return WEBAPP_URL + "/reschedule/" + getUid(calEvent);
const Uid = getUid(calEvent);
const seatUid = getSeatReferenceId(calEvent);
return `${WEBAPP_URL}/reschedule/${seatUid ? seatUid : Uid}`;
};
export const getRichDescription = (calEvent: CalendarEvent /*, attendee?: Person*/) => {
@@ -179,7 +191,7 @@ ${getAppsStatus(calEvent)}
${
// TODO: Only the original attendee can make changes to the event
// Guests cannot
getManageLink(calEvent)
!calEvent.seatsPerTimeSlot && getManageLink(calEvent)
}
${
calEvent.paymentInfo
@@ -0,0 +1,20 @@
import type { PrismaClient } from "@prisma/client";
export async function maybeGetBookingUidFromSeat(prisma: PrismaClient, uid: string) {
// Look bookingUid in bookingSeat
const bookingSeat = await prisma.bookingSeat.findUnique({
where: {
referenceUid: uid,
},
select: {
booking: {
select: {
id: true,
uid: true,
},
},
},
});
if (bookingSeat) return bookingSeat.booking.uid;
return uid;
}
@@ -0,0 +1,34 @@
-- DropForeignKey
ALTER TABLE "Attendee" DROP CONSTRAINT IF EXISTS "Attendee_bookingId_fkey";
-- DropForeignKey
ALTER TABLE "Booking" DROP CONSTRAINT IF EXISTS "Booking_userId_fkey";
-- CreateTable
CREATE TABLE "BookingSeat" (
"id" SERIAL NOT NULL,
"referenceUid" TEXT NOT NULL,
"bookingId" INTEGER NOT NULL,
"attendeeId" INTEGER NOT NULL,
"data" JSONB,
CONSTRAINT "BookingSeat_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "BookingSeat_referenceUid_key" ON "BookingSeat"("referenceUid");
-- CreateIndex
CREATE UNIQUE INDEX "BookingSeat_attendeeId_key" ON "BookingSeat"("attendeeId");
-- AddForeignKey
ALTER TABLE "Attendee" ADD CONSTRAINT "Attendee_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BookingSeat" ADD CONSTRAINT "BookingSeat_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BookingSeat" ADD CONSTRAINT "BookingSeat_attendeeId_fkey" FOREIGN KEY ("attendeeId") REFERENCES "Attendee"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+20 -8
View File
@@ -275,13 +275,14 @@ model BookingReference {
}
model Attendee {
id Int @id @default(autoincrement())
email String
name String
timeZone String
locale String? @default("en")
booking Booking? @relation(fields: [bookingId], references: [id])
bookingId Int?
id Int @id @default(autoincrement())
email String
name String
timeZone String
locale String? @default("en")
booking Booking? @relation(fields: [bookingId], references: [id], onDelete: Cascade)
bookingId Int?
bookingSeat BookingSeat?
}
enum BookingStatus {
@@ -294,7 +295,7 @@ enum BookingStatus {
model Booking {
id Int @id @default(autoincrement())
uid String @unique
user User? @relation(fields: [userId], references: [id])
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
userId Int?
references BookingReference[]
eventType EventType? @relation(fields: [eventTypeId], references: [id])
@@ -325,6 +326,7 @@ model Booking {
smsReminderNumber String?
workflowReminders WorkflowReminder[]
scheduledJobs String[]
seatsReferences BookingSeat[]
/// @zod.custom(imports.bookingMetadataSchema)
metadata Json?
}
@@ -665,6 +667,16 @@ enum WorkflowMethods {
SMS
}
model BookingSeat {
id Int @id @default(autoincrement())
referenceUid String @unique
bookingId Int
booking Booking @relation(fields: [bookingId], references: [id], onDelete: Cascade)
attendeeId Int @unique
attendee Attendee @relation(fields: [attendeeId], references: [id], onDelete: Cascade)
data Json?
}
model VerifiedNumber {
id Int @id @default(autoincrement())
userId Int?
+2
View File
@@ -158,6 +158,7 @@ export const bookingCreateBodySchema = z.object({
hasHashedBookingLink: z.boolean().optional(),
hashedLink: z.string().nullish(),
ethSignature: z.string().optional(),
seatReferenceUid: z.string().optional(),
});
export const requiredCustomInputSchema = z.union([
@@ -220,6 +221,7 @@ export const schemaBookingCancelParams = z.object({
uid: z.string().optional(),
allRemainingBookings: z.boolean().optional(),
cancellationReason: z.string().optional(),
seatReferenceUid: z.string().optional(),
});
export const vitalSettingsUpdateSchema = z.object({
@@ -234,6 +234,15 @@ export const bookingsRouter = router({
},
},
},
{
seatsReferences: {
some: {
attendee: {
email: user.email,
},
},
},
},
],
AND: [passedBookingsStatusFilter, ...(filtersCombined ?? [])],
},
@@ -267,6 +276,21 @@ export const bookingsRouter = router({
},
rescheduled: true,
references: true,
seatsReferences: {
where: {
attendee: {
email: user.email,
},
},
select: {
referenceUid: true,
attendee: {
select: {
email: true,
},
},
},
},
},
orderBy,
take: take + 1,
@@ -956,4 +980,29 @@ export const bookingsRouter = router({
return { message, status };
}),
getBookingAttendees: authedProcedure
.input(z.object({ seatReferenceUid: z.string().uuid() }))
.query(async ({ ctx, input }) => {
const bookingSeat = await ctx.prisma.bookingSeat.findUniqueOrThrow({
where: {
referenceUid: input.seatReferenceUid,
},
select: {
booking: {
select: {
_count: {
select: {
seatsReferences: true,
},
},
},
},
},
});
if (!bookingSeat) {
throw new Error("Booking not found");
}
return bookingSeat.booking._count.seatsReferences;
}),
});
+5 -3
View File
@@ -1,4 +1,4 @@
import type { Prisma, DestinationCalendar, SelectedCalendar } from "@prisma/client";
import type { Prisma, DestinationCalendar, SelectedCalendar, BookingSeat } from "@prisma/client";
import type { Dayjs } from "dayjs";
import type { calendar_v3 } from "googleapis";
import type { Time } from "ical.js";
@@ -25,9 +25,10 @@ export type Person = {
language: { translate: TFunction; locale: string };
username?: string;
id?: number;
bookingId?: number;
locale?: string;
bookingId?: number | null;
locale?: string | null;
timeFormat?: TimeFormat;
bookingSeat?: BookingSeat | null;
};
export type TeamMember = {
@@ -165,6 +166,7 @@ export interface CalendarEvent {
eventTypeId?: number | null;
appsStatus?: AppsStatus[];
seatsShowAttendees?: boolean | null;
attendeeSeatId?: string;
seatsPerTimeSlot?: number | null;
// It has responses to all the fields(system + user)