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:
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
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
>;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
@@ -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?
|
||||
|
||||
@@ -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;
|
||||
}),
|
||||
});
|
||||
|
||||
Vendored
+5
-3
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user