fix: seated event custom questions (#12779)

* fix: seated event custom questions

* updating tests & code refactor

* addition of prisma custom json schema and type fixes

* attendeeName parsing refactor

* response parsing fix

* response parsing fix

---------

Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
This commit is contained in:
authored and GitHub committed 2024-01-29 14:37:52 +00:00
1 parent 00e96ac7ad
commit 41b0589ab0
14 files changed
+186 -87

No files matched your search

+33 -15
View File
@@ -1,4 +1,5 @@
import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields";
import { bookingResponsesDbSchema } from "@calcom/features/bookings/lib/getBookingResponsesSchema";
import prisma from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
import { BookingStatus } from "@calcom/prisma/enums";
@@ -115,21 +116,23 @@ export const handleSeatsEventTypeOnBooking = async (
}>
>,
seatReferenceUid?: string,
userId?: number
isHost?: boolean
) => {
if (eventType?.seatsPerTimeSlot !== null) {
// @TODO: right now bookings with seats doesn't save every description that its entered by every user
delete bookingInfo.description;
} else {
return;
}
// @TODO: If handling teams, we need to do more check ups for this.
if (bookingInfo?.user?.id === userId) {
return;
}
if (!eventType.seatsShowAttendees) {
const seatAttendee = await prisma.bookingSeat.findFirst({
bookingInfo["responses"] = {};
type seatAttendee = {
attendee: {
email: string;
name: string;
};
id: number;
data: Prisma.JsonValue;
bookingId: number;
attendeeId: number;
referenceUid: string;
} | null;
let seatAttendee: seatAttendee = null;
if (seatReferenceUid) {
seatAttendee = await prisma.bookingSeat.findFirst({
where: {
referenceUid: seatReferenceUid,
},
@@ -142,16 +145,31 @@ export const handleSeatsEventTypeOnBooking = async (
},
},
});
}
if (seatAttendee) {
const seatAttendeeData = seatAttendee.data as unknown as {
description?: string;
responses: Prisma.JsonValue;
};
bookingInfo["description"] = seatAttendeeData.description ?? null;
bookingInfo["responses"] = bookingResponsesDbSchema.parse(seatAttendeeData.responses ?? {});
}
if (!eventType.seatsShowAttendees && !isHost) {
if (seatAttendee) {
const attendee = bookingInfo?.attendees?.find((a) => {
return a.email === seatAttendee.attendee?.email;
return a.email === seatAttendee?.attendee?.email;
});
bookingInfo["attendees"] = attendee ? [attendee] : [];
} else {
bookingInfo["attendees"] = [];
}
}
// // @TODO: If handling teams, we need to do more check ups for this.
// if (bookingInfo?.user?.id === userId) {
// return;
// }
return bookingInfo;
};
@@ -56,12 +56,15 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
const parsedQuery = querySchema.safeParse(context.query);
if (!parsedQuery.success) return { notFound: true } as const;
const { uid, eventTypeSlug, seatReferenceUid } = parsedQuery.data;
const { eventTypeSlug } = parsedQuery.data;
let { uid, seatReferenceUid } = parsedQuery.data;
const { uid: maybeUid } = await maybeGetBookingUidFromSeat(prisma, uid);
const maybeBookingUidFromSeat = await maybeGetBookingUidFromSeat(prisma, uid);
if (maybeBookingUidFromSeat.uid) uid = maybeBookingUidFromSeat.uid;
if (maybeBookingUidFromSeat.seatReferenceUid) seatReferenceUid = maybeBookingUidFromSeat.seatReferenceUid;
const bookingInfoRaw = await prisma.booking.findFirst({
where: {
uid: maybeUid,
uid: uid,
},
select: {
title: true,
@@ -168,7 +171,12 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
};
if (bookingInfo !== null && eventType.seatsPerTimeSlot) {
await handleSeatsEventTypeOnBooking(eventType, bookingInfo, seatReferenceUid, session?.user.id);
await handleSeatsEventTypeOnBooking(
eventType,
bookingInfo,
seatReferenceUid,
session?.user.id === eventType.userId
);
}
const payment = await prisma.payment.findFirst({
@@ -14,6 +14,7 @@ import BookingPageTagManager from "@calcom/app-store/BookingPageTagManager";
import type { getEventLocationValue } from "@calcom/app-store/locations";
import { getSuccessPageLocationMessage, guessEventLocationType } from "@calcom/app-store/locations";
import { getEventTypeAppData } from "@calcom/app-store/utils";
import type { nameObjectSchema } from "@calcom/core/event";
import { getEventName } from "@calcom/core/event";
import type { ConfigType } from "@calcom/dayjs";
import dayjs from "@calcom/dayjs";
@@ -90,6 +91,7 @@ export default function Success(props: PageProps) {
const routerQuery = useRouterQuery();
const pathname = usePathname();
const searchParams = useCompatSearchParams();
const { eventType, bookingInfo, requiresLoginToUpdate } = props;
const {
allRemainingBookings,
isSuccessBookingPage,
@@ -99,35 +101,28 @@ export default function Success(props: PageProps) {
seatReferenceUid,
} = querySchema.parse(routerQuery);
const attendeeTimeZone = props?.bookingInfo?.attendees.find(
(attendee) => attendee.email === email
)?.timeZone;
const attendeeTimeZone = bookingInfo?.attendees.find((attendee) => attendee.email === email)?.timeZone;
const tz = props.tz ? props.tz : isSuccessBookingPage && attendeeTimeZone ? attendeeTimeZone : timeZone();
const location = props.bookingInfo.location as ReturnType<typeof getEventLocationValue>;
const location = bookingInfo.location as ReturnType<typeof getEventLocationValue>;
let rescheduleLocation: string | undefined;
if (
typeof props.bookingInfo.responses.location === "object" &&
"optionValue" in props.bookingInfo.responses.location
typeof bookingInfo.responses?.location === "object" &&
"optionValue" in bookingInfo.responses.location
) {
rescheduleLocation = props.bookingInfo.responses.location.optionValue;
rescheduleLocation = bookingInfo.responses.location.optionValue;
}
const locationVideoCallUrl: string | undefined = bookingMetadataSchema.parse(
props?.bookingInfo?.metadata || {}
bookingInfo?.metadata || {}
)?.videoCallUrl;
const status = props.bookingInfo?.status;
const reschedule = props.bookingInfo.status === BookingStatus.ACCEPTED;
const cancellationReason = props.bookingInfo.cancellationReason || props.bookingInfo.rejectionReason;
const status = bookingInfo?.status;
const reschedule = bookingInfo.status === BookingStatus.ACCEPTED;
const cancellationReason = bookingInfo.cancellationReason || bookingInfo.rejectionReason;
const attendeeName =
typeof props?.bookingInfo?.attendees?.[0]?.name === "string"
? props?.bookingInfo?.attendees?.[0]?.name
: "Nameless";
const attendees = props?.bookingInfo?.attendees;
const attendees = bookingInfo?.attendees;
const isGmail = !!attendees.find((attendee) => attendee.email.includes("gmail.com"));
@@ -136,15 +131,14 @@ export default function Success(props: PageProps) {
);
const { data: session } = useSession();
const [date, setDate] = useState(dayjs.utc(props.bookingInfo.startTime));
const { eventType, bookingInfo } = props;
const [date, setDate] = useState(dayjs.utc(bookingInfo.startTime));
const isBackgroundTransparent = useIsBackgroundTransparent();
const isEmbed = useIsEmbed();
const shouldAlignCentrallyInEmbed = useEmbedNonStylesConfig("align") !== "left";
const shouldAlignCentrally = !isEmbed || shouldAlignCentrallyInEmbed;
const [calculatedDuration, setCalculatedDuration] = useState<number | undefined>(undefined);
const { requiresLoginToUpdate } = props;
function setIsCancellationMode(value: boolean) {
const _searchParams = new URLSearchParams(searchParams ?? undefined);
@@ -159,13 +153,13 @@ export default function Success(props: PageProps) {
router.replace(`${pathname}?${_searchParams.toString()}`);
}
let evtName = props.eventType.eventName;
let evtName = eventType.eventName;
if (eventType.isDynamic && bookingInfo.responses?.title) {
evtName = bookingInfo.responses.title as string;
}
const eventNameObject = {
attendeeName,
eventType: props.eventType.title,
attendeeName: bookingInfo.responses.name as z.infer<typeof nameObjectSchema> | string,
eventType: eventType.title,
eventName: evtName,
host: props.profile.name || "Nameless",
location: location,
@@ -223,9 +217,7 @@ export default function Success(props: PageProps) {
}, [eventType, needsConfirmation]);
useEffect(() => {
setCalculatedDuration(
dayjs(props.bookingInfo.endTime).diff(dayjs(props.bookingInfo.startTime), "minutes")
);
setCalculatedDuration(dayjs(bookingInfo.endTime).diff(dayjs(bookingInfo.startTime), "minutes"));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -245,7 +237,7 @@ export default function Success(props: PageProps) {
],
startInputType: "utc",
title: eventName,
description: props.eventType.description ? props.eventType.description : undefined,
description: eventType.description ? eventType.description : undefined,
/** formatted to required type of description ^ */
duration: {
minutes: calculatedDuration,
@@ -424,7 +416,7 @@ export default function Success(props: PageProps) {
{reschedule && !!formerTime && (
<p className="line-through">
<RecurringBookings
eventType={props.eventType}
eventType={eventType}
duration={calculatedDuration}
recurringBookings={props.recurringBookings}
allRemainingBookings={allRemainingBookings}
@@ -436,7 +428,7 @@ export default function Success(props: PageProps) {
</p>
)}
<RecurringBookings
eventType={props.eventType}
eventType={eventType}
duration={calculatedDuration}
recurringBookings={props.recurringBookings}
allRemainingBookings={allRemainingBookings}
@@ -638,15 +630,15 @@ export default function Success(props: PageProps) {
.add(calculatedDuration, "minute")
.utc()
.format("YYYYMMDDTHHmmss[Z]")}&text=${eventName}&details=${
props.eventType.description
eventType.description
}${
typeof locationVideoCallUrl === "string"
? `&location=${encodeURIComponent(locationVideoCallUrl)}`
: ""
}${
props.eventType.recurringEvent
eventType.recurringEvent
? `&recur=${encodeURIComponent(
new RRule(props.eventType.recurringEvent).toString()
new RRule(eventType.recurringEvent).toString()
)}`
: ""
}`}
@@ -664,7 +656,7 @@ export default function Success(props: PageProps) {
href={
encodeURI(
`https://outlook.live.com/calendar/0/deeplink/compose?body=${
props.eventType.description
eventType.description
}&enddt=${date
.add(calculatedDuration, "minute")
.utc()
@@ -691,7 +683,7 @@ export default function Success(props: PageProps) {
href={
encodeURI(
`https://outlook.office.com/calendar/0/deeplink/compose?body=${
props.eventType.description
eventType.description
}&enddt=${date
.add(calculatedDuration, "minute")
.utc()
@@ -717,7 +709,7 @@ export default function Success(props: PageProps) {
<Link
href={`data:text/calendar,${eventLink()}`}
className="border-subtle text-default mx-2 h-10 w-10 rounded-sm border px-3 py-2"
download={`${props.eventType.title}.ics`}>
download={`${eventType.title}.ics`}>
<svg
version="1.1"
fill="currentColor"
+53 -18
View File
@@ -38,14 +38,22 @@ test.describe("Booking with Seats", () => {
where: { bookingId: booking.id },
select: {
id: true,
name: true,
email: true,
},
});
const bookingSeats = [
{ bookingId: booking.id, attendeeId: bookingAttendees[0].id, referenceUid: uuidv4() },
{ bookingId: booking.id, attendeeId: bookingAttendees[1].id, referenceUid: uuidv4() },
{ bookingId: booking.id, attendeeId: bookingAttendees[2].id, referenceUid: uuidv4() },
];
const bookingSeats = bookingAttendees.map((attendee) => ({
bookingId: booking.id,
attendeeId: attendee.id,
referenceUid: uuidv4(),
data: {
responses: {
name: attendee.name,
email: attendee.email,
},
},
}));
await prisma.bookingSeat.createMany({
data: bookingSeats,
@@ -122,13 +130,22 @@ test.describe("Reschedule for booking with seats", () => {
where: { bookingId: booking.id },
select: {
id: true,
name: true,
email: true,
},
});
const bookingSeats = [
{ bookingId: booking.id, attendeeId: bookingAttendees[0].id, referenceUid: uuidv4() },
{ bookingId: booking.id, attendeeId: bookingAttendees[1].id, referenceUid: uuidv4() },
];
const bookingSeats = bookingAttendees.map((attendee) => ({
bookingId: booking.id,
attendeeId: attendee.id,
referenceUid: uuidv4(),
data: {
responses: {
name: attendee.name,
email: attendee.email,
},
},
}));
await prisma.bookingSeat.createMany({
data: bookingSeats,
@@ -181,13 +198,22 @@ test.describe("Reschedule for booking with seats", () => {
where: { bookingId: booking.id },
select: {
id: true,
name: true,
email: true,
},
});
const bookingSeats = [
{ bookingId: booking.id, attendeeId: bookingAttendees[0].id, referenceUid: uuidv4() },
{ bookingId: booking.id, attendeeId: bookingAttendees[1].id, referenceUid: uuidv4() },
];
const bookingSeats = bookingAttendees.map((attendee) => ({
bookingId: booking.id,
attendeeId: attendee.id,
referenceUid: uuidv4(),
data: {
responses: {
name: attendee.name,
email: attendee.email,
},
},
}));
await prisma.bookingSeat.createMany({
data: bookingSeats,
@@ -248,13 +274,22 @@ test.describe("Reschedule for booking with seats", () => {
where: { bookingId: booking.id },
select: {
id: true,
name: true,
email: true,
},
});
const bookingSeats = [
{ bookingId: booking.id, attendeeId: bookingAttendees[0].id, referenceUid: uuidv4() },
{ bookingId: booking.id, attendeeId: bookingAttendees[1].id, referenceUid: uuidv4() },
];
const bookingSeats = bookingAttendees.map((attendee) => ({
bookingId: booking.id,
attendeeId: attendee.id,
referenceUid: uuidv4(),
data: {
responses: {
name: attendee.name,
email: attendee.email,
},
},
}));
await prisma.bookingSeat.createMany({
data: bookingSeats,
@@ -350,7 +385,7 @@ test.describe("Reschedule for booking with seats", () => {
// data displayed in form should be user owner
const nameElement = await page.locator("input[name=name]");
const name = await nameElement.inputValue();
expect(name).toBe(user.username);
expect(name).toBe(user.name);
//same for email
const emailElement = await page.locator("input[name=email]");
+1
View File
@@ -293,6 +293,7 @@ export const createUserWithLimits = ({
async function createUserWithSeatedEvent(users: Fixtures["users"]) {
const slug = "seats";
const user = await users.create({
name: "Seated event user",
eventTypes: [
{
title: "Seated event",
+7
View File
@@ -478,6 +478,7 @@ test.describe("Webhook tests", async () => {
select: {
id: true,
email: true,
name: true,
},
});
@@ -485,6 +486,12 @@ test.describe("Webhook tests", async () => {
bookingId: booking.id,
attendeeId: attendee.id,
referenceUid: uuidv4(),
data: {
responses: {
name: attendee.name,
email: attendee.email,
},
},
}));
await prisma.bookingSeat.createMany({
+21 -6
View File
@@ -1,10 +1,23 @@
import type { TFunction } from "next-i18next";
import z from "zod";
import { guessEventLocationType } from "@calcom/app-store/locations";
import type { Prisma } from "@calcom/prisma/client";
export const nameObjectSchema = z.object({
firstName: z.string(),
lastName: z.string().optional(),
});
function parseName(name: z.infer<typeof nameObjectSchema> | string | undefined) {
if (typeof name === "string") return name;
else if (typeof name === "object" && nameObjectSchema.parse(name))
return `${name.firstName} ${name.lastName}`.trim();
else return "Nameless";
}
export type EventNameObjectType = {
attendeeName: string;
attendeeName: z.infer<typeof nameObjectSchema> | string;
eventType: string;
eventName?: string | null;
teamName?: string | null;
@@ -15,11 +28,13 @@ export type EventNameObjectType = {
};
export function getEventName(eventNameObj: EventNameObjectType, forAttendeeView = false) {
const attendeeName = parseName(eventNameObj.attendeeName);
if (!eventNameObj.eventName)
return eventNameObj.t("event_between_users", {
eventName: eventNameObj.eventType,
host: eventNameObj.teamName || eventNameObj.host,
attendeeName: eventNameObj.attendeeName,
attendeeName,
interpolation: {
escapeValue: false,
},
@@ -40,12 +55,12 @@ export function getEventName(eventNameObj: EventNameObjectType, forAttendeeView
let dynamicEventName = eventName
// Need this for compatibility with older event names
.replaceAll("{Event type title}", eventNameObj.eventType)
.replaceAll("{Scheduler}", eventNameObj.attendeeName)
.replaceAll("{Scheduler}", attendeeName)
.replaceAll("{Organiser}", eventNameObj.host)
.replaceAll("{USER}", eventNameObj.attendeeName)
.replaceAll("{ATTENDEE}", eventNameObj.attendeeName)
.replaceAll("{USER}", attendeeName)
.replaceAll("{ATTENDEE}", attendeeName)
.replaceAll("{HOST}", eventNameObj.host)
.replaceAll("{HOST/ATTENDEE}", forAttendeeView ? eventNameObj.host : eventNameObj.attendeeName);
.replaceAll("{HOST/ATTENDEE}", forAttendeeView ? eventNameObj.host : attendeeName);
const customInputvariables = dynamicEventName.match(/\{(.+?)}/g)?.map((variable) => {
return variable.replace("{", "").replace("}", "");
+15 -5
View File
@@ -45,7 +45,7 @@ function getResponsesFromOldBooking(
};
}
async function getBooking(prisma: PrismaClient, uid: string) {
async function getBooking(prisma: PrismaClient, uid: string, isSeatedEvent?: boolean) {
const rawBooking = await prisma.booking.findFirst({
where: {
uid,
@@ -79,7 +79,7 @@ async function getBooking(prisma: PrismaClient, uid: string) {
return rawBooking;
}
const booking = getBookingWithResponses(rawBooking);
const booking = getBookingWithResponses(rawBooking, isSeatedEvent);
if (booking) {
// @NOTE: had to do this because Server side cant return [Object objects]
@@ -99,11 +99,14 @@ export const getBookingWithResponses = <
};
}>
>(
booking: T
booking: T,
isSeatedEvent?: boolean
) => {
return {
...booking,
responses: bookingResponsesDbSchema.parse(booking.responses || getResponsesFromOldBooking(booking)),
responses: isSeatedEvent
? bookingResponsesDbSchema.parse(booking.responses || {})
: bookingResponsesDbSchema.parse(booking.responses || getResponsesFromOldBooking(booking)),
} as Omit<T, "responses"> & { responses: z.infer<typeof bookingResponsesDbSchema> };
};
@@ -140,6 +143,7 @@ export const getBookingForReschedule = async (uid: string, userId?: number) => {
// If no booking is found via the uid, it's probably a booking seat
// that its being rescheduled, which we query next.
let attendeeEmail: string | null = null;
let bookingSeatData: { description?: string; responses: Prisma.JsonValue } | null = null;
if (!theBooking) {
const bookingSeat = await prisma.bookingSeat.findFirst({
where: {
@@ -153,6 +157,7 @@ export const getBookingForReschedule = async (uid: string, userId?: number) => {
email: true,
},
},
data: true,
booking: {
select: {
uid: true,
@@ -161,6 +166,7 @@ export const getBookingForReschedule = async (uid: string, userId?: number) => {
},
});
if (bookingSeat) {
bookingSeatData = bookingSeat.data as unknown as { description?: string; responses: Prisma.JsonValue };
bookingSeatReferenceUid = bookingSeat.id;
rescheduleUid = bookingSeat.booking.uid;
attendeeEmail = bookingSeat.attendee.email;
@@ -185,10 +191,14 @@ export const getBookingForReschedule = async (uid: string, userId?: number) => {
// and we return null here.
if (!theBooking && !rescheduleUid) return null;
const booking = await getBooking(prisma, rescheduleUid || uid);
const booking = await getBooking(prisma, rescheduleUid || uid, bookingSeatReferenceUid ? true : false);
if (!booking) return null;
if (bookingSeatReferenceUid) {
booking["description"] = bookingSeatData?.description ?? null;
booking["responses"] = bookingResponsesDbSchema.parse(bookingSeatData?.responses ?? {});
}
return {
...booking,
attendees: rescheduleUid
@@ -727,11 +727,11 @@ async function createBooking({
const newBookingData: Prisma.BookingCreateInput = {
uid,
responses: responses === null ? Prisma.JsonNull : responses,
responses: responses === null || evt.seatsPerTimeSlot ? Prisma.JsonNull : responses,
title: evt.title,
startTime: dayjs.utc(evt.startTime).toDate(),
endTime: dayjs.utc(evt.endTime).toDate(),
description: evt.additionalNotes,
description: evt.seatsPerTimeSlot ? null : evt.additionalNotes,
customInputs: isPrismaObjOrUndefined(evt.customInputs),
status: isConfirmedByDefault ? BookingStatus.ACCEPTED : BookingStatus.PENDING,
location: evt.location,
@@ -1573,6 +1573,7 @@ async function handler(
reqBodyMetadata: reqBody.metadata,
subscriberOptions,
eventTrigger,
responses,
});
if (newBooking) {
req.statusCode = 201;
@@ -1658,7 +1659,8 @@ async function handler(
data: {
referenceUid: uniqueAttendeeId,
data: {
description: evt.additionalNotes,
description: additionalNotes,
responses,
},
booking: {
connect: {
@@ -35,6 +35,7 @@ const createNewSeat = async (
organizerUser,
fullName,
bookerEmail,
responses,
} = rescheduleSeatedBookingObject;
let { evt } = rescheduleSeatedBookingObject;
let resultBooking: HandleSeatsResultBooking;
@@ -83,6 +84,7 @@ const createNewSeat = async (
referenceUid: attendeeUniqueId,
data: {
description: additionalNotes,
responses,
},
booking: {
connect: {
@@ -28,8 +28,9 @@ const handleSeats = async (newSeatedBookingObject: NewSeatedBookingObject) => {
eventTypeId,
subscriberOptions,
eventTrigger,
evt,
} = newSeatedBookingObject;
const { evt } = newSeatedBookingObject;
const loggerWithEventDetails = createLoggerWithEventDetails(eventType.id, reqBodyUser, eventType.slug);
let resultBooking: HandleSeatsResultBooking = null;
+2
View File
@@ -1,4 +1,5 @@
import type { Prisma } from "@prisma/client";
import type z from "zod";
import type { AppsStatus } from "@calcom/types/Calendar";
@@ -35,6 +36,7 @@ export type NewSeatedBookingObject = {
reqBodyMetadata: ReqBodyMetadata;
subscriberOptions: GetSubscriberOptions;
eventTrigger: WebhookTriggerEvents;
responses: z.infer<ReturnType<typeof getBookingDataSchema>>["responses"] | null;
};
export type RescheduleSeatedBookingObject = NewSeatedBookingObject & { rescheduleUid: string };
+1
View File
@@ -902,6 +902,7 @@ model BookingSeat {
booking Booking @relation(fields: [bookingId], references: [id], onDelete: Cascade)
attendeeId Int @unique
attendee Attendee @relation(fields: [attendeeId], references: [id], onDelete: Cascade)
/// @zod.custom(imports.bookingSeatDataSchema)
data Json?
@@index([bookingId])
+5
View File
@@ -662,3 +662,8 @@ export type ZVerifyCodeInputSchema = z.infer<typeof ZVerifyCodeInputSchema>;
export const coerceToDate = z.coerce.date();
export const getStringAsNumberRequiredSchema = (t: TFunction) =>
z.string().min(1, t("error_required_field")).pipe(z.coerce.number());
export const bookingSeatDataSchema = z.object({
description: z.string().optional(),
responses: bookingResponses,
});