-
+ |
diff --git a/apps/web/lib/emails/templates/organizer-request-reschedule-email.ts b/apps/web/lib/emails/templates/organizer-request-reschedule-email.ts
new file mode 100644
index 00000000..667ca4e3
--- /dev/null
+++ b/apps/web/lib/emails/templates/organizer-request-reschedule-email.ts
@@ -0,0 +1,189 @@
+import dayjs from "dayjs";
+import localizedFormat from "dayjs/plugin/localizedFormat";
+import timezone from "dayjs/plugin/timezone";
+import toArray from "dayjs/plugin/toArray";
+import utc from "dayjs/plugin/utc";
+import { createEvent, DateArray, Person } from "ics";
+
+import { getCancelLink } from "@calcom/lib/CalEventParser";
+import { CalendarEvent } from "@calcom/types/Calendar";
+
+import {
+ emailHead,
+ emailSchedulingBodyHeader,
+ emailBodyLogo,
+ emailScheduledBodyHeaderContent,
+ emailSchedulingBodyDivider,
+} from "./common";
+import OrganizerScheduledEmail from "./organizer-scheduled-email";
+
+dayjs.extend(utc);
+dayjs.extend(timezone);
+dayjs.extend(localizedFormat);
+dayjs.extend(toArray);
+
+export default class OrganizerRequestRescheduledEmail extends OrganizerScheduledEmail {
+ private metadata: { rescheduleLink: string };
+ constructor(calEvent: CalendarEvent, metadata: { rescheduleLink: string }) {
+ super(calEvent);
+ this.metadata = metadata;
+ }
+ protected getNodeMailerPayload(): Record {
+ const toAddresses = [this.calEvent.organizer.email];
+
+ return {
+ icalEvent: {
+ filename: "event.ics",
+ content: this.getiCalEventAsString(),
+ },
+ from: `Cal.com <${this.getMailerOptions().from}>`,
+ to: toAddresses.join(","),
+ subject: `${this.calEvent.organizer.language.translate("rescheduled_event_type_subject", {
+ eventType: this.calEvent.type,
+ name: this.calEvent.attendees[0].name,
+ date: `${this.getOrganizerStart().format("h:mma")} - ${this.getOrganizerEnd().format(
+ "h:mma"
+ )}, ${this.calEvent.organizer.language.translate(
+ this.getOrganizerStart().format("dddd").toLowerCase()
+ )}, ${this.calEvent.organizer.language.translate(
+ this.getOrganizerStart().format("MMMM").toLowerCase()
+ )} ${this.getOrganizerStart().format("D")}, ${this.getOrganizerStart().format("YYYY")}`,
+ })}`,
+ html: this.getHtmlBody(),
+ text: this.getTextBody(),
+ };
+ }
+
+ // @OVERRIDE
+ protected getiCalEventAsString(): string | undefined {
+ console.log("overriding");
+ const icsEvent = createEvent({
+ start: dayjs(this.calEvent.startTime)
+ .utc()
+ .toArray()
+ .slice(0, 6)
+ .map((v, i) => (i === 1 ? v + 1 : v)) as DateArray,
+ startInputType: "utc",
+ productId: "calendso/ics",
+ title: this.calEvent.organizer.language.translate("ics_event_title", {
+ eventType: this.calEvent.type,
+ name: this.calEvent.attendees[0].name,
+ }),
+ 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,
+ email: attendee.email,
+ })),
+ status: "CANCELLED",
+ method: "CANCEL",
+ });
+ if (icsEvent.error) {
+ throw icsEvent.error;
+ }
+ return icsEvent.value;
+ }
+ // @OVERRIDE
+ protected getWhen(): string {
+ return `
+
+
+ ${this.calEvent.organizer.language.translate("when")}
+
+ ${this.calEvent.organizer.language.translate(
+ this.getOrganizerStart().format("dddd").toLowerCase()
+ )}, ${this.calEvent.organizer.language.translate(
+ this.getOrganizerStart().format("MMMM").toLowerCase()
+ )} ${this.getOrganizerStart().format("D")}, ${this.getOrganizerStart().format(
+ "YYYY"
+ )} | ${this.getOrganizerStart().format("h:mma")} - ${this.getOrganizerEnd().format(
+ "h:mma"
+ )} (${this.getTimezone()})
+
+ `;
+ }
+
+ protected getTextBody(): string {
+ return `
+${this.calEvent.organizer.language.translate("request_reschedule_title_organizer", {
+ attendee: this.calEvent.attendees[0].name,
+})}
+${this.calEvent.organizer.language.translate("request_reschedule_subtitle_organizer", {
+ attendee: this.calEvent.attendees[0].name,
+})},
+${this.getWhat()}
+${this.getWhen()}
+${this.getLocation()}
+${this.getAdditionalNotes()}
+${this.calEvent.organizer.language.translate("need_to_reschedule_or_cancel")}
+${getCancelLink(this.calEvent)}
+`.replace(/(<([^>]+)>)/gi, "");
+ }
+
+ protected getHtmlBody(): string {
+ const headerContent = this.calEvent.organizer.language.translate("rescheduled_event_type_subject", {
+ eventType: this.calEvent.type,
+ name: this.calEvent.attendees[0].name,
+ date: `${this.getOrganizerStart().format("h:mma")} - ${this.getOrganizerEnd().format(
+ "h:mma"
+ )}, ${this.calEvent.organizer.language.translate(
+ this.getOrganizerStart().format("dddd").toLowerCase()
+ )}, ${this.calEvent.organizer.language.translate(
+ this.getOrganizerStart().format("MMMM").toLowerCase()
+ )} ${this.getOrganizerStart().format("D")}, ${this.getOrganizerStart().format("YYYY")}`,
+ });
+
+ return `
+
+
+ ${emailHead(headerContent)}
+
+
+ ${emailSchedulingBodyHeader("calendarCircle")}
+ ${emailScheduledBodyHeaderContent(
+ this.calEvent.organizer.language.translate("request_reschedule_title_organizer", {
+ attendee: this.calEvent.attendees[0].name,
+ }),
+ this.calEvent.organizer.language.translate("request_reschedule_subtitle_organizer", {
+ attendee: this.calEvent.attendees[0].name,
+ })
+ )}
+ ${emailSchedulingBodyDivider()}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${this.getWhat()}
+ ${this.getWhen()}
+ ${this.getWho()}
+ ${this.getAdditionalNotes()}
+
+ |
+
+
+
+
+
+ |
+
+
+
+
+ ${emailBodyLogo()}
+
+
+
+
+ `;
+ }
+}
diff --git a/apps/web/lib/getBooking.tsx b/apps/web/lib/getBooking.tsx
new file mode 100644
index 00000000..2c61e93d
--- /dev/null
+++ b/apps/web/lib/getBooking.tsx
@@ -0,0 +1,33 @@
+import { PrismaClient } from "@prisma/client";
+
+import { Prisma } from "@calcom/prisma/client";
+
+async function getBooking(prisma: PrismaClient, uid: string) {
+ const booking = await prisma.booking.findFirst({
+ where: {
+ uid,
+ },
+ select: {
+ startTime: true,
+ description: true,
+ attendees: {
+ select: {
+ email: true,
+ name: true,
+ },
+ },
+ },
+ });
+
+ if (booking) {
+ // @NOTE: had to do this because Server side cant return [Object objects]
+ // probably fixable with json.stringify -> json.parse
+ booking["startTime"] = (booking?.startTime as Date)?.toISOString() as unknown as Date;
+ }
+
+ return booking;
+}
+
+export type GetBookingType = Prisma.PromiseReturnType;
+
+export default getBooking;
diff --git a/apps/web/lib/locationOptions.tsx b/apps/web/lib/locationOptions.tsx
new file mode 100644
index 00000000..c0d3b9d0
--- /dev/null
+++ b/apps/web/lib/locationOptions.tsx
@@ -0,0 +1,30 @@
+import { TFunction } from "next-i18next";
+
+import { LocationType } from "./location";
+
+export const LocationOptionsToString = (location: string, t: TFunction) => {
+ switch (location) {
+ case LocationType.InPerson:
+ return t("set_address_place");
+ case LocationType.Link:
+ return t("set_link_meeting");
+ case LocationType.Phone:
+ return t("cal_invitee_phone_number_scheduling");
+ case LocationType.GoogleMeet:
+ return t("cal_provide_google_meet_location");
+ case LocationType.Zoom:
+ return t("cal_provide_zoom_meeting_url");
+ case LocationType.Daily:
+ return t("cal_provide_video_meeting_url");
+ case LocationType.Jitsi:
+ return t("cal_provide_jitsi_meeting_url");
+ case LocationType.Huddle01:
+ return t("cal_provide_huddle01_meeting_url");
+ case LocationType.Tandem:
+ return t("cal_provide_tandem_meeting_url");
+ case LocationType.Teams:
+ return t("cal_provide_teams_meeting_url");
+ default:
+ return null;
+ }
+};
diff --git a/apps/web/lib/parseDate.ts b/apps/web/lib/parseDate.ts
new file mode 100644
index 00000000..755c98c7
--- /dev/null
+++ b/apps/web/lib/parseDate.ts
@@ -0,0 +1,14 @@
+import dayjs, { Dayjs } from "dayjs";
+import { I18n } from "next-i18next";
+
+import { detectBrowserTimeFormat } from "@lib/timeFormat";
+
+import { parseZone } from "./parseZone";
+
+export const parseDate = (date: string | null | Dayjs, i18n: I18n) => {
+ if (!date) return "No date";
+ const parsedZone = parseZone(date);
+ if (!parsedZone?.isValid()) return "Invalid date";
+ const formattedTime = parsedZone?.format(detectBrowserTimeFormat);
+ return formattedTime + ", " + dayjs(date).toDate().toLocaleString(i18n.language, { dateStyle: "full" });
+};
diff --git a/apps/web/pages/[user]/[type].tsx b/apps/web/pages/[user]/[type].tsx
index 557b5139..6f54f171 100644
--- a/apps/web/pages/[user]/[type].tsx
+++ b/apps/web/pages/[user]/[type].tsx
@@ -8,6 +8,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
import { asStringOrNull } from "@lib/asStringOrNull";
import { getWorkingHours } from "@lib/availability";
+import getBooking, { GetBookingType } from "@lib/getBooking";
import prisma from "@lib/prisma";
import { inferSSRProps } from "@lib/types/inferSSRProps";
@@ -48,6 +49,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
const userParam = asStringOrNull(context.query.user);
const typeParam = asStringOrNull(context.query.type);
const dateParam = asStringOrNull(context.query.date);
+ const rescheduleUid = asStringOrNull(context.query.rescheduleUid);
if (!userParam || !typeParam) {
throw new Error(`File is not named [type]/[user]`);
@@ -261,6 +263,11 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
eventTypeObject.schedule = null;
eventTypeObject.availability = [];
+ let booking: GetBookingType | null = null;
+ if (rescheduleUid) {
+ booking = await getBooking(prisma, rescheduleUid);
+ }
+
const dynamicNames = isDynamicGroup
? users.map((user) => {
return user.name || "";
@@ -302,6 +309,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
workingHours,
trpcState: ssr.dehydrate(),
previousPage: context.req.headers.referer ?? null,
+ booking,
},
};
};
diff --git a/apps/web/pages/[user]/book.tsx b/apps/web/pages/[user]/book.tsx
index 3966bcd9..804a3661 100644
--- a/apps/web/pages/[user]/book.tsx
+++ b/apps/web/pages/[user]/book.tsx
@@ -1,4 +1,3 @@
-import { Prisma } from "@prisma/client";
import dayjs from "dayjs";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
@@ -15,6 +14,7 @@ import {
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { asStringOrThrow } from "@lib/asStringOrNull";
+import getBooking, { GetBookingType } from "@lib/getBooking";
import prisma from "@lib/prisma";
import { inferSSRProps } from "@lib/types/inferSSRProps";
@@ -102,6 +102,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
disableGuests: true,
users: {
select: {
+ id: true,
username: true,
name: true,
email: true,
@@ -147,28 +148,9 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
};
})[0];
- async function getBooking() {
- return prisma.booking.findFirst({
- where: {
- uid: asStringOrThrow(context.query.rescheduleUid),
- },
- select: {
- description: true,
- attendees: {
- select: {
- email: true,
- name: true,
- },
- },
- },
- });
- }
-
- type Booking = Prisma.PromiseReturnType;
- let booking: Booking | null = null;
-
+ let booking: GetBookingType | null = null;
if (context.query.rescheduleUid) {
- booking = await getBooking();
+ booking = await getBooking(prisma, context.query.rescheduleUid as string);
}
const isDynamicGroupBooking = users.length > 1;
diff --git a/apps/web/pages/api/book/confirm.ts b/apps/web/pages/api/book/confirm.ts
index 3e39bde5..64a0b920 100644
--- a/apps/web/pages/api/book/confirm.ts
+++ b/apps/web/pages/api/book/confirm.ts
@@ -169,9 +169,15 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
metadata.conferenceData = results[0].createdEvent?.conferenceData;
metadata.entryPoints = results[0].createdEvent?.entryPoints;
}
- await sendScheduledEmails({ ...evt, additionInformation: metadata });
+ try {
+ await sendScheduledEmails({ ...evt, additionInformation: metadata });
+ } catch (error) {
+ log.error(error);
+ }
}
+ // @NOTE: be careful with this as if any error occurs before this booking doesn't get confirmed
+ // Should perform update on booking (confirm) -> then trigger the rest handlers
await prisma.booking.update({
where: {
id: bookingId,
diff --git a/apps/web/pages/api/book/event.ts b/apps/web/pages/api/book/event.ts
index 79f7f43c..8c8ef7d7 100644
--- a/apps/web/pages/api/book/event.ts
+++ b/apps/web/pages/api/book/event.ts
@@ -1,4 +1,11 @@
-import { Credential, Prisma, SchedulingType, WebhookTriggerEvents } from "@prisma/client";
+import {
+ BookingStatus,
+ Credential,
+ Payment,
+ Prisma,
+ SchedulingType,
+ WebhookTriggerEvents,
+} from "@prisma/client";
import async from "async";
import dayjs from "dayjs";
import dayjsBusinessTime from "dayjs-business-time";
@@ -12,12 +19,12 @@ import { v5 as uuidv5 } from "uuid";
import { getBusyCalendarTimes } from "@calcom/core/CalendarManager";
import EventManager from "@calcom/core/EventManager";
import { getBusyVideoTimes } from "@calcom/core/videoClient";
-import { getDefaultEvent, getUsernameList } from "@calcom/lib/defaultEvents";
+import { getDefaultEvent, getUsernameList, getGroupName } from "@calcom/lib/defaultEvents";
import { getErrorFromUnknown } from "@calcom/lib/errors";
import logger from "@calcom/lib/logger";
import notEmpty from "@calcom/lib/notEmpty";
import type { BufferedBusyTime } from "@calcom/types/BufferedBusyTime";
-import type { AdditionInformation, CalendarEvent, EventBusyDate } from "@calcom/types/Calendar";
+import type { AdditionInformation, CalendarEvent, EventBusyDate, Person } from "@calcom/types/Calendar";
import type { EventResult, PartialReference } from "@calcom/types/EventManager";
import { handlePayment } from "@ee/lib/stripe/server";
@@ -223,7 +230,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const reqBody = req.body as BookingCreateBody;
// handle dynamic user
- const dynamicUserList = getUsernameList(reqBody?.user);
+ const dynamicUserList = Array.isArray(reqBody.user)
+ ? getGroupName(req.body.user)
+ : getUsernameList(reqBody.user as string);
const eventTypeSlug = reqBody.eventTypeSlug;
const eventTypeId = reqBody.eventTypeId;
const tAttendees = await getTranslation(reqBody.language ?? "en", "common");
@@ -344,6 +353,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
(str, input) => str + "
" + input.label + ": " + input.value,
""
);
+
const evt: CalendarEvent = {
type: eventType.title,
title: getEventName(eventNameObject), //this needs to be either forced in english, or fetched for each attendee and organizer separately
@@ -373,6 +383,41 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
// Initialize EventManager with credentials
const rescheduleUid = reqBody.rescheduleUid;
+ async function getOriginalRescheduledBooking(uid: string) {
+ return prisma.booking.findFirst({
+ where: {
+ uid,
+ status: {
+ in: [BookingStatus.ACCEPTED, BookingStatus.CANCELLED],
+ },
+ },
+ include: {
+ attendees: {
+ select: {
+ name: true,
+ email: true,
+ locale: true,
+ timeZone: true,
+ },
+ },
+ user: {
+ select: {
+ id: true,
+ name: true,
+ email: true,
+ locale: true,
+ timeZone: true,
+ },
+ },
+ payment: true,
+ },
+ });
+ }
+ type BookingType = Prisma.PromiseReturnType;
+ let originalRescheduledBooking: BookingType = null;
+ if (rescheduleUid) {
+ originalRescheduledBooking = await getOriginalRescheduledBooking(rescheduleUid);
+ }
async function createBooking() {
// @TODO: check as metadata
@@ -381,6 +426,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
await verifyAccount(web3Details.userSignature, web3Details.userWallet);
}
+ if (originalRescheduledBooking) {
+ evt.title = originalRescheduledBooking?.title || evt.title;
+ evt.description = originalRescheduledBooking?.description || evt.additionalNotes;
+ evt.location = originalRescheduledBooking?.location;
+ }
+
const eventTypeRel = !eventTypeId
? {}
: {
@@ -392,51 +443,72 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const dynamicEventSlugRef = !eventTypeId ? eventTypeSlug : null;
const dynamicGroupSlugRef = !eventTypeId ? (reqBody.user as string).toLowerCase() : null;
- return prisma.booking.create({
+ const newBookingData: Prisma.BookingCreateInput = {
+ uid,
+ title: evt.title,
+ startTime: dayjs(evt.startTime).toDate(),
+ endTime: dayjs(evt.endTime).toDate(),
+ description: evt.additionalNotes,
+ confirmed: (!eventType.requiresConfirmation && !eventType.price) || !!rescheduleUid,
+ location: evt.location,
+ eventType: eventTypeRel,
+ attendees: {
+ createMany: {
+ data: evt.attendees.map((attendee) => {
+ //if attendee is team member, it should fetch their locale not booker's locale
+ //perhaps make email fetch request to see if his locale is stored, else
+ const retObj = {
+ name: attendee.name,
+ email: attendee.email,
+ timeZone: attendee.timeZone,
+ locale: attendee.language.locale,
+ };
+ return retObj;
+ }),
+ },
+ },
+ dynamicEventSlugRef,
+ dynamicGroupSlugRef,
+ user: {
+ connect: {
+ id: users[0].id,
+ },
+ },
+ destinationCalendar: evt.destinationCalendar
+ ? {
+ connect: { id: evt.destinationCalendar.id },
+ }
+ : undefined,
+ };
+ if (originalRescheduledBooking) {
+ newBookingData["paid"] = originalRescheduledBooking.paid;
+ newBookingData["fromReschedule"] = originalRescheduledBooking.uid;
+ if (newBookingData.attendees?.createMany?.data) {
+ newBookingData.attendees.createMany.data = originalRescheduledBooking.attendees;
+ }
+ }
+ const createBookingObj = {
include: {
user: {
select: { email: true, name: true, timeZone: true },
},
attendees: true,
+ payment: true,
},
- data: {
- uid,
- title: evt.title,
- startTime: dayjs(evt.startTime).toDate(),
- endTime: dayjs(evt.endTime).toDate(),
- description: evt.additionalNotes,
- confirmed: (!eventType.requiresConfirmation && !eventType.price) || !!rescheduleUid,
- location: evt.location,
- eventType: eventTypeRel,
- attendees: {
- createMany: {
- data: evt.attendees.map((attendee) => {
- //if attendee is team member, it should fetch their locale not booker's locale
- //perhaps make email fetch request to see if his locale is stored, else
- const retObj = {
- name: attendee.name,
- email: attendee.email,
- timeZone: attendee.timeZone,
- locale: attendee.language.locale,
- };
- return retObj;
- }),
- },
- },
- dynamicEventSlugRef,
- dynamicGroupSlugRef,
- user: {
- connect: {
- id: users[0].id,
- },
- },
- destinationCalendar: evt.destinationCalendar
- ? {
- connect: { id: evt.destinationCalendar.id },
- }
- : undefined,
- },
- });
+ data: newBookingData,
+ };
+
+ if (originalRescheduledBooking?.paid && originalRescheduledBooking?.payment) {
+ const bookingPayment = originalRescheduledBooking?.payment?.find((payment) => payment.success);
+
+ if (bookingPayment) {
+ createBookingObj.data.payment = {
+ connect: { id: bookingPayment.id },
+ };
+ }
+ }
+
+ return prisma.booking.create(createBookingObj);
}
let results: EventResult[] = [];
@@ -569,9 +641,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const credentials = await refreshCredentials(user.credentials);
const eventManager = new EventManager({ ...user, credentials });
- if (rescheduleUid) {
+ if (originalRescheduledBooking?.uid) {
// Use EventManager to conditionally use all needed integrations.
- const updateManager = await eventManager.update(evt, rescheduleUid);
+ const updateManager = await eventManager.update(evt, originalRescheduledBooking.uid, booking.id);
// This gets overridden when updating the event - to check if notes have been hidden or not. We just reset this back
// to the default description when we are sending the emails.
evt.description = eventType.description;
@@ -615,7 +687,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
results = createManager.results;
referencesToCreate = createManager.referencesToCreate;
-
if (results.length > 0 && results.every((res) => !res.success)) {
const error = {
errorCode: "BookingCreatingMeetingFailed",
@@ -641,9 +712,10 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
await sendAttendeeRequestEmail(evt, attendeesList[0]);
}
- if (typeof eventType.price === "number" && eventType.price > 0) {
+ if (typeof eventType.price === "number" && eventType.price > 0 && !originalRescheduledBooking?.paid) {
try {
const [firstStripeCredential] = user.credentials.filter((cred) => cred.type == "stripe_payment");
+
if (!booking.user) booking.user = user;
const payment = await handlePayment(evt, eventType, firstStripeCredential, booking);
diff --git a/apps/web/pages/api/book/request-reschedule.ts b/apps/web/pages/api/book/request-reschedule.ts
new file mode 100644
index 00000000..0c321678
--- /dev/null
+++ b/apps/web/pages/api/book/request-reschedule.ts
@@ -0,0 +1,217 @@
+import { BookingStatus, User, Booking, Attendee, BookingReference } from "@prisma/client";
+import dayjs from "dayjs";
+import type { NextApiRequest, NextApiResponse } from "next";
+import { getSession } from "next-auth/react";
+import type { TFunction } from "next-i18next";
+import { z, ZodError } from "zod";
+
+import { getCalendar } from "@calcom/core/CalendarManager";
+import EventManager from "@calcom/core/EventManager";
+import { CalendarEventBuilder } from "@calcom/core/builders/CalendarEvent/builder";
+import { CalendarEventDirector } from "@calcom/core/builders/CalendarEvent/director";
+import { deleteMeeting } from "@calcom/core/videoClient";
+import { getTranslation } from "@calcom/lib/server/i18n";
+import { Person } from "@calcom/types/Calendar";
+
+import { sendRequestRescheduleEmail } from "@lib/emails/email-manager";
+import prisma from "@lib/prisma";
+
+export type RescheduleResponse = Booking & {
+ attendees: Attendee[];
+};
+export type PersonAttendeeCommonFields = Pick<
+ User,
+ "id" | "email" | "name" | "locale" | "timeZone" | "username"
+>;
+
+const rescheduleSchema = z.object({
+ bookingId: z.string(),
+ rescheduleReason: z.string().optional(),
+});
+
+const findUserOwnerByUserId = async (userId: number) => {
+ return await prisma.user.findUnique({
+ rejectOnNotFound: true,
+ where: {
+ id: userId,
+ },
+ select: {
+ id: true,
+ name: true,
+ username: true,
+ email: true,
+ timeZone: true,
+ locale: true,
+ credentials: true,
+ destinationCalendar: true,
+ },
+ });
+};
+
+const handler = async (
+ req: NextApiRequest,
+ res: NextApiResponse
+): Promise => {
+ const session = await getSession({ req });
+ const {
+ bookingId,
+ rescheduleReason: cancellationReason,
+ }: { bookingId: string; rescheduleReason: string; cancellationReason: string } = req.body;
+ let userOwner: Awaited>;
+ try {
+ if (session?.user?.id) {
+ userOwner = await findUserOwnerByUserId(session?.user.id);
+ } else {
+ return res.status(501);
+ }
+
+ const bookingToReschedule = await prisma.booking.findFirst({
+ select: {
+ id: true,
+ uid: true,
+ title: true,
+ startTime: true,
+ endTime: true,
+ eventTypeId: true,
+ location: true,
+ attendees: true,
+ references: true,
+ },
+ rejectOnNotFound: true,
+ where: {
+ uid: bookingId,
+ NOT: {
+ status: {
+ in: [BookingStatus.CANCELLED, BookingStatus.REJECTED],
+ },
+ },
+ },
+ });
+
+ if (bookingToReschedule && bookingToReschedule.eventTypeId && userOwner) {
+ const event = await prisma.eventType.findFirst({
+ select: {
+ title: true,
+ users: true,
+ schedulingType: true,
+ },
+ rejectOnNotFound: true,
+ where: {
+ id: bookingToReschedule.eventTypeId,
+ },
+ });
+ await prisma.booking.update({
+ where: {
+ id: bookingToReschedule.id,
+ },
+ data: {
+ rescheduled: true,
+ cancellationReason,
+ status: BookingStatus.CANCELLED,
+ updatedAt: dayjs().toISOString(),
+ },
+ });
+
+ const [mainAttendee] = bookingToReschedule.attendees;
+ // @NOTE: Should we assume attendees language?
+ const tAttendees = await getTranslation(mainAttendee.locale ?? "en", "common");
+ const usersToPeopleType = (
+ users: PersonAttendeeCommonFields[],
+ selectedLanguage: TFunction
+ ): Person[] => {
+ return users?.map((user) => {
+ return {
+ email: user.email || "",
+ name: user.name || "",
+ username: user?.username || "",
+ language: { translate: selectedLanguage, locale: user.locale || "en" },
+ timeZone: user?.timeZone,
+ };
+ });
+ };
+
+ const userOwnerTranslation = await getTranslation(userOwner.locale ?? "en", "common");
+ const [userOwnerAsPeopleType] = usersToPeopleType([userOwner], userOwnerTranslation);
+
+ const builder = new CalendarEventBuilder();
+ builder.init({
+ title: bookingToReschedule.title,
+ type: event.title,
+ startTime: bookingToReschedule.startTime.toISOString(),
+ endTime: bookingToReschedule.endTime.toISOString(),
+ attendees: usersToPeopleType(
+ // username field doesn't exists on attendee but could be in the future
+ bookingToReschedule.attendees as unknown as PersonAttendeeCommonFields[],
+ tAttendees
+ ),
+ organizer: userOwnerAsPeopleType,
+ });
+
+ const director = new CalendarEventDirector();
+ director.setBuilder(builder);
+ director.setExistingBooking(bookingToReschedule as unknown as Booking);
+ director.setCancellationReason(cancellationReason);
+ await director.buildForRescheduleEmail();
+
+ // Handling calendar and videos cancellation
+ // This can set previous time as available, until virtual calendar is done
+ const credentialsMap = new Map();
+ userOwner.credentials.forEach((credential) => {
+ credentialsMap.set(credential.type, credential);
+ });
+ const bookingRefsFiltered: BookingReference[] = bookingToReschedule.references.filter(
+ (ref) => !!credentialsMap.get(ref.type)
+ );
+ bookingRefsFiltered.forEach((bookingRef) => {
+ if (bookingRef.uid) {
+ if (bookingRef.type.endsWith("_calendar")) {
+ const calendar = getCalendar(credentialsMap.get(bookingRef.type));
+
+ return calendar?.deleteEvent(bookingRef.uid, builder.calendarEvent);
+ } else if (bookingRef.type.endsWith("_video")) {
+ return deleteMeeting(credentialsMap.get(bookingRef.type), bookingRef.uid);
+ }
+ }
+ });
+
+ // Creating cancelled event as placeholders in calendars, remove when virtual calendar handles it
+ const eventManager = new EventManager({
+ credentials: userOwner.credentials,
+ destinationCalendar: userOwner.destinationCalendar,
+ });
+ builder.calendarEvent.title = `Cancelled: ${builder.calendarEvent.title}`;
+ await eventManager.updateAndSetCancelledPlaceholder(builder.calendarEvent, bookingToReschedule);
+
+ // Send emails
+ await sendRequestRescheduleEmail(builder.calendarEvent, {
+ rescheduleLink: builder.rescheduleLink,
+ });
+ }
+
+ return res.status(200).json(bookingToReschedule);
+ } catch (error) {
+ throw new Error("Error.request.reschedule");
+ }
+};
+
+function validate(
+ handler: (req: NextApiRequest, res: NextApiResponse) => Promise
+) {
+ return async (req: NextApiRequest, res: NextApiResponse) => {
+ if (req.method === "POST") {
+ try {
+ rescheduleSchema.parse(req.body);
+ } catch (error) {
+ if (error instanceof ZodError && error?.name === "ZodError") {
+ return res.status(400).json(error?.issues);
+ }
+ return res.status(402);
+ }
+ } else {
+ return res.status(405);
+ }
+ await handler(req, res);
+ };
+}
+
+export default validate(handler);
diff --git a/apps/web/pages/bookings/[status].tsx b/apps/web/pages/bookings/[status].tsx
index 717d9164..07a98418 100644
--- a/apps/web/pages/bookings/[status].tsx
+++ b/apps/web/pages/bookings/[status].tsx
@@ -2,11 +2,11 @@ import { CalendarIcon } from "@heroicons/react/outline";
import { useRouter } from "next/router";
import { Fragment } from "react";
+import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Alert } from "@calcom/ui/Alert";
import Button from "@calcom/ui/Button";
import { useInViewObserver } from "@lib/hooks/useInViewObserver";
-import { useLocale } from "@lib/hooks/useLocale";
import { inferQueryInput, trpc } from "@lib/trpc";
import BookingsShell from "@components/BookingsShell";
diff --git a/apps/web/pages/cancel/[uid].tsx b/apps/web/pages/cancel/[uid].tsx
index 3f64966e..538d81f6 100644
--- a/apps/web/pages/cancel/[uid].tsx
+++ b/apps/web/pages/cancel/[uid].tsx
@@ -101,8 +101,8 @@ export default function Type(props: inferSSRProps) {
className="mb-5 sm:mb-6"
/>
-
@@ -432,6 +443,7 @@ const getEventTypesFromDB = async (typeId: number) => {
successRedirectUrl: true,
users: {
select: {
+ id: true,
name: true,
hideBranding: true,
plan: true,
@@ -478,6 +490,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
id: eventType.userId,
},
select: {
+ id: true,
name: true,
hideBranding: true,
plan: true,
diff --git a/apps/web/pages/team/[slug]/[type].tsx b/apps/web/pages/team/[slug]/[type].tsx
index e8bf0039..8f15f1be 100644
--- a/apps/web/pages/team/[slug]/[type].tsx
+++ b/apps/web/pages/team/[slug]/[type].tsx
@@ -5,6 +5,7 @@ import { UserPlan } from "@calcom/prisma/client";
import { asStringOrNull } from "@lib/asStringOrNull";
import { getWorkingHours } from "@lib/availability";
+import getBooking, { GetBookingType } from "@lib/getBooking";
import prisma from "@lib/prisma";
import { inferSSRProps } from "@lib/types/inferSSRProps";
@@ -20,6 +21,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
const slugParam = asStringOrNull(context.query.slug);
const typeParam = asStringOrNull(context.query.type);
const dateParam = asStringOrNull(context.query.date);
+ const rescheduleUid = asStringOrNull(context.query.rescheduleUid);
if (!slugParam || !typeParam) {
throw new Error(`File is not named [idOrSlug]/[user]`);
@@ -110,6 +112,11 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
eventTypeObject.availability = [];
+ let booking: GetBookingType | null = null;
+ if (rescheduleUid) {
+ booking = await getBooking(prisma, rescheduleUid);
+ }
+
return {
props: {
// Team is always pro
@@ -127,6 +134,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
eventType: eventTypeObject,
workingHours,
previousPage: context.req.headers.referer ?? null,
+ booking,
},
};
};
diff --git a/apps/web/pages/team/[slug]/book.tsx b/apps/web/pages/team/[slug]/book.tsx
index 442c8c9e..faa08311 100644
--- a/apps/web/pages/team/[slug]/book.tsx
+++ b/apps/web/pages/team/[slug]/book.tsx
@@ -5,6 +5,7 @@ import { JSONObject } from "superjson/dist/types";
import { getLocationLabels } from "@calcom/app-store/utils";
import { asStringOrThrow } from "@lib/asStringOrNull";
+import getBooking, { GetBookingType } from "@lib/getBooking";
import prisma from "@lib/prisma";
import { inferSSRProps } from "@lib/types/inferSSRProps";
@@ -56,6 +57,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
},
users: {
select: {
+ id: true,
avatar: true,
name: true,
},
@@ -74,28 +76,9 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
};
})[0];
- async function getBooking() {
- return prisma.booking.findFirst({
- where: {
- uid: asStringOrThrow(context.query.rescheduleUid),
- },
- select: {
- description: true,
- attendees: {
- select: {
- email: true,
- name: true,
- },
- },
- },
- });
- }
-
- type Booking = Prisma.PromiseReturnType;
- let booking: Booking | null = null;
-
+ let booking: GetBookingType | null = null;
if (context.query.rescheduleUid) {
- booking = await getBooking();
+ booking = await getBooking(prisma, context.query.rescheduleUid as string);
}
const t = await getTranslation(context.locale ?? "en", "common");
diff --git a/apps/web/playwright/booking-pages.test.ts b/apps/web/playwright/booking-pages.test.ts
index 6c54ab66..6c0115b0 100644
--- a/apps/web/playwright/booking-pages.test.ts
+++ b/apps/web/playwright/booking-pages.test.ts
@@ -109,6 +109,7 @@ test.describe("pro user", () => {
await page.goto("/bookings/upcoming");
await page.locator('[data-testid="reschedule"]').click();
+ await page.locator('[data-testid="edit"]').click();
await page.waitForNavigation({
url: (url) => {
const bookingId = url.searchParams.get("rescheduleUid");
diff --git a/apps/web/playwright/dynamic-booking-pages.test.ts b/apps/web/playwright/dynamic-booking-pages.test.ts
index 5eba38df..ec3a3fb0 100644
--- a/apps/web/playwright/dynamic-booking-pages.test.ts
+++ b/apps/web/playwright/dynamic-booking-pages.test.ts
@@ -43,6 +43,7 @@ test.describe("dynamic booking", () => {
// Logged in
await page.goto("/bookings/upcoming");
await page.locator('[data-testid="reschedule"]').click();
+ await page.locator('[data-testid="edit"]').click();
await page.waitForNavigation({
url: (url) => {
const bookingId = url.searchParams.get("rescheduleUid");
diff --git a/apps/web/playwright/integrations-stripe.test.ts b/apps/web/playwright/integrations-stripe.test.ts
index f2a2f52c..f6426591 100644
--- a/apps/web/playwright/integrations-stripe.test.ts
+++ b/apps/web/playwright/integrations-stripe.test.ts
@@ -13,6 +13,7 @@ test.describe.serial("Stripe integration", () => {
test.afterAll(() => {
teardown.deleteAllPaymentsByEmail("pro@example.com");
teardown.deleteAllBookingsByEmail("pro@example.com");
+ teardown.deleteAllPaymentCredentialsByEmail("pro@example.com");
});
test.skip(!IS_STRIPE_ENABLED, "It should only run if Stripe is installed");
diff --git a/apps/web/playwright/lib/dbSetup.ts b/apps/web/playwright/lib/dbSetup.ts
new file mode 100644
index 00000000..8837f785
--- /dev/null
+++ b/apps/web/playwright/lib/dbSetup.ts
@@ -0,0 +1,80 @@
+import { Booking } from "@prisma/client";
+import dayjs from "dayjs";
+import utc from "dayjs/plugin/utc";
+import short from "short-uuid";
+import { v5 as uuidv5, v4 as uuidv4 } from "uuid";
+
+dayjs.extend(utc);
+
+const translator = short();
+
+const TestUtilCreateBookingOnUserId = async (
+ userId: number,
+ username: string,
+ eventTypeId: number,
+ { confirmed = true, rescheduled = false, paid = false, status = "ACCEPTED" }: Partial
+) => {
+ const startDate = dayjs().add(1, "day").toDate();
+ const seed = `${username}:${dayjs(startDate).utc().format()}:${new Date().getTime()}`;
+ const uid = translator.fromUUID(uuidv5(seed, uuidv5.URL));
+ return await prisma?.booking.create({
+ data: {
+ uid: uid,
+ title: "30min",
+ startTime: startDate,
+ endTime: dayjs().add(1, "day").add(30, "minutes").toDate(),
+ user: {
+ connect: {
+ id: userId,
+ },
+ },
+ attendees: {
+ create: {
+ email: "attendee@example.com",
+ name: "Attendee Example",
+ timeZone: "Europe/London",
+ },
+ },
+ eventType: {
+ connect: {
+ id: eventTypeId,
+ },
+ },
+ confirmed,
+ rescheduled,
+ paid,
+ status,
+ },
+ select: {
+ id: true,
+ uid: true,
+ user: true,
+ },
+ });
+};
+
+const TestUtilCreatePayment = async (
+ bookingId: number,
+ { success = false, refunded = false }: { success?: boolean; refunded?: boolean }
+) => {
+ return await prisma?.payment.create({
+ data: {
+ uid: uuidv4(),
+ amount: 20000,
+ fee: 160,
+ currency: "usd",
+ success,
+ refunded,
+ type: "STRIPE",
+ data: {},
+ externalId: "DEMO_PAYMENT_FROM_DB",
+ booking: {
+ connect: {
+ id: bookingId,
+ },
+ },
+ },
+ });
+};
+
+export { TestUtilCreateBookingOnUserId, TestUtilCreatePayment };
diff --git a/apps/web/playwright/lib/teardown.ts b/apps/web/playwright/lib/teardown.ts
index 51fbbb61..18e63172 100644
--- a/apps/web/playwright/lib/teardown.ts
+++ b/apps/web/playwright/lib/teardown.ts
@@ -1,11 +1,17 @@
+import { Prisma } from "@prisma/client";
+
import prisma from "@lib/prisma";
-export const deleteAllBookingsByEmail = async (email: string) =>
+export const deleteAllBookingsByEmail = async (
+ email: string,
+ whereConditional: Prisma.BookingWhereInput = {}
+) =>
prisma.booking.deleteMany({
where: {
user: {
email,
},
+ ...whereConditional,
},
});
@@ -38,3 +44,20 @@ export const deleteAllPaymentsByEmail = async (email: string) => {
},
});
};
+
+export const deleteAllPaymentCredentialsByEmail = async (email: string) => {
+ await prisma.user.update({
+ where: {
+ email,
+ },
+ data: {
+ credentials: {
+ deleteMany: {
+ type: {
+ endsWith: "_payment",
+ },
+ },
+ },
+ },
+ });
+};
diff --git a/apps/web/playwright/reschedule.test.ts b/apps/web/playwright/reschedule.test.ts
new file mode 100644
index 00000000..695d6d25
--- /dev/null
+++ b/apps/web/playwright/reschedule.test.ts
@@ -0,0 +1,246 @@
+import { expect, test } from "@playwright/test";
+import { BookingStatus } from "@prisma/client";
+import dayjs from "dayjs";
+
+import { TestUtilCreateBookingOnUserId, TestUtilCreatePayment } from "./lib/dbSetup";
+import { deleteAllBookingsByEmail } from "./lib/teardown";
+import { selectFirstAvailableTimeSlotNextMonth } from "./lib/testUtils";
+
+const IS_STRIPE_ENABLED = !!(
+ process.env.STRIPE_CLIENT_ID &&
+ process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY &&
+ process.env.STRIPE_PRIVATE_KEY
+);
+const findUserByEmail = async (email: string) => {
+ return await prisma?.user.findFirst({
+ select: {
+ id: true,
+ email: true,
+ username: true,
+ credentials: true,
+ },
+ where: {
+ email,
+ },
+ });
+};
+test.describe("Reschedule Tests", async () => {
+ let currentUser: Awaited>;
+ // Using logged in state from globalSetup
+ test.use({ storageState: "playwright/artifacts/proStorageState.json" });
+ test.beforeAll(async () => {
+ currentUser = await findUserByEmail("pro@example.com");
+ });
+ test.afterEach(async () => {
+ try {
+ await deleteAllBookingsByEmail("pro@example.com", {
+ createdAt: { gte: dayjs().startOf("day").toISOString() },
+ });
+ } catch (error) {
+ console.log("Error while trying to delete all bookings from pro user");
+ }
+ });
+
+ test("Should do a booking request reschedule from /bookings", async ({ page }) => {
+ const user = currentUser;
+ const eventType = await prisma?.eventType.findFirst({
+ where: {
+ userId: user?.id,
+ slug: "30min",
+ },
+ });
+ let originalBooking;
+ if (user && user.id && user.username && eventType) {
+ originalBooking = await TestUtilCreateBookingOnUserId(user?.id, user?.username, eventType?.id, {
+ status: BookingStatus.ACCEPTED,
+ });
+ }
+
+ await page.goto("/bookings/upcoming");
+
+ await page.locator('[data-testid="reschedule"]').click();
+
+ await page.locator('[data-testid="reschedule_request"]').click();
+
+ await page.fill('[data-testid="reschedule_reason"]', "I can't longer have it");
+
+ await page.locator('button[data-testid="send_request"]').click();
+
+ await page.goto("/bookings/cancelled");
+
+ // Find booking that was recently cancelled
+ const booking = await prisma?.booking.findFirst({
+ select: {
+ id: true,
+ uid: true,
+ cancellationReason: true,
+ status: true,
+ rescheduled: true,
+ },
+ where: { id: originalBooking?.id },
+ });
+
+ expect(booking?.rescheduled).toBe(true);
+ expect(booking?.cancellationReason).toBe("I can't longer have it");
+ expect(booking?.status).toBe(BookingStatus.CANCELLED);
+ });
+
+ test("Should display former time when rescheduling availability", async ({ page }) => {
+ const user = currentUser;
+ const eventType = await prisma?.eventType.findFirst({
+ where: {
+ userId: user?.id,
+ slug: "30min",
+ },
+ });
+ let originalBooking;
+ if (user && user.id && user.username && eventType) {
+ originalBooking = await TestUtilCreateBookingOnUserId(user?.id, user?.username, eventType?.id, {
+ status: BookingStatus.CANCELLED,
+ rescheduled: true,
+ });
+ }
+
+ await page.goto(
+ `/${originalBooking?.user?.username}/${eventType?.slug}?rescheduleUid=${originalBooking?.uid}`
+ );
+ const formerTimeElement = await page.locator('[data-testid="former_time_p"]');
+ await expect(formerTimeElement).toBeVisible();
+ });
+
+ test("Should display request reschedule send on bookings/cancelled", async ({ page }) => {
+ const user = currentUser;
+ const eventType = await prisma?.eventType.findFirst({
+ where: {
+ userId: user?.id,
+ slug: "30min",
+ },
+ });
+
+ if (user && user.id && user.username && eventType) {
+ await TestUtilCreateBookingOnUserId(user?.id, user?.username, eventType?.id, {
+ status: BookingStatus.CANCELLED,
+ rescheduled: true,
+ });
+ }
+ await page.goto("/bookings/cancelled");
+
+ const requestRescheduleSentElement = await page.locator('[data-testid="request_reschedule_sent"]').nth(1);
+ await expect(requestRescheduleSentElement).toBeVisible();
+ });
+
+ test("Should do a reschedule from user owner", async ({ page }) => {
+ const user = currentUser;
+
+ const eventType = await prisma?.eventType.findFirst({
+ where: {
+ userId: user?.id,
+ },
+ });
+ if (user?.id && user?.username && eventType?.id) {
+ const booking = await TestUtilCreateBookingOnUserId(user?.id, user?.username, eventType?.id, {
+ rescheduled: true,
+ status: BookingStatus.CANCELLED,
+ });
+
+ await page.goto(`/${user?.username}/${eventType?.slug}?rescheduleUid=${booking?.uid}`);
+
+ await selectFirstAvailableTimeSlotNextMonth(page);
+
+ await expect(page.locator('[name="name"]')).toBeDisabled();
+ await expect(page.locator('[name="email"]')).toBeDisabled();
+ await expect(page.locator('[name="notes"]')).toBeDisabled();
+
+ await page.locator('[data-testid="confirm-reschedule-button"]').click();
+
+ await page.waitForNavigation({
+ url(url) {
+ return url.pathname.endsWith("/success");
+ },
+ });
+
+ await expect(page).toHaveURL(/.*success/);
+
+ // NOTE: remove if old booking should not be deleted
+ const oldBooking = await prisma?.booking.findFirst({ where: { id: booking?.id } });
+ expect(oldBooking).toBeNull();
+
+ const newBooking = await prisma?.booking.findFirst({ where: { fromReschedule: booking?.uid } });
+ expect(newBooking).not.toBeNull();
+ }
+ });
+
+ test("Unpaid rescheduling should go to payment page", async ({ page }) => {
+ let user = currentUser;
+
+ test.skip(
+ IS_STRIPE_ENABLED && !(user && user.credentials.length > 0),
+ "Skipped as stripe is not installed and user is missing credentials"
+ );
+
+ const eventType = await prisma?.eventType.findFirst({
+ where: {
+ userId: user?.id,
+ slug: "paid",
+ },
+ });
+ if (user?.id && user?.username && eventType?.id) {
+ const booking = await TestUtilCreateBookingOnUserId(user?.id, user?.username, eventType?.id, {
+ rescheduled: true,
+ status: BookingStatus.CANCELLED,
+ paid: false,
+ });
+ if (booking?.id) {
+ await TestUtilCreatePayment(booking.id, {});
+ await page.goto(`/${user?.username}/${eventType?.slug}?rescheduleUid=${booking?.uid}`);
+
+ await selectFirstAvailableTimeSlotNextMonth(page);
+
+ await page.locator('[data-testid="confirm-reschedule-button"]').click();
+
+ await page.waitForNavigation({
+ url(url) {
+ return url.pathname.indexOf("/payment") > -1;
+ },
+ });
+
+ await expect(page).toHaveURL(/.*payment/);
+ }
+ }
+ });
+
+ test("Paid rescheduling should go to success page", async ({ page }) => {
+ let user = currentUser;
+ try {
+ const eventType = await prisma?.eventType.findFirst({
+ where: {
+ userId: user?.id,
+ slug: "paid",
+ },
+ });
+ if (user?.id && user?.username && eventType?.id) {
+ const booking = await TestUtilCreateBookingOnUserId(user?.id, user?.username, eventType?.id, {
+ rescheduled: true,
+ status: BookingStatus.CANCELLED,
+ paid: true,
+ });
+ if (booking?.id) {
+ await TestUtilCreatePayment(booking.id, {});
+ await page.goto(`/${user?.username}/${eventType?.slug}?rescheduleUid=${booking?.uid}`);
+
+ await selectFirstAvailableTimeSlotNextMonth(page);
+
+ await page.locator('[data-testid="confirm-reschedule-button"]').click();
+
+ await expect(page).toHaveURL(/.*success/);
+ }
+ }
+ } catch (error) {
+ await prisma?.payment.delete({
+ where: {
+ externalId: "DEMO_PAYMENT_FROM_DB",
+ },
+ });
+ }
+ });
+});
diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json
index cb3b43be..ab12bd5c 100644
--- a/apps/web/public/static/locales/en/common.json
+++ b/apps/web/public/static/locales/en/common.json
@@ -66,6 +66,11 @@
"your_meeting_has_been_booked": "Your meeting has been booked",
"event_type_has_been_rescheduled_on_time_date": "Your {{eventType}} with {{name}} has been rescheduled to {{time}} ({{timeZone}}) on {{date}}.",
"event_has_been_rescheduled": "Updated - Your event has been rescheduled",
+ "request_reschedule_title_attendee": "Request to reschedule your booking",
+ "request_reschedule_subtitle": "{{organizer}} has cancelled the booking and requested you to pick another time.",
+ "request_reschedule_title_organizer": "You have requested {{attendee}} to reschedule",
+ "request_reschedule_subtitle_organizer": "You have cancelled the booking and {{attendee}} should be pick a new booking time with you.",
+ "reschedule_reason": "Reason for reschedule",
"hi_user_name": "Hi {{name}}",
"ics_event_title": "{{eventType}} with {{name}}",
"new_event_subject": "New event: {{attendeeName}} - {{date}} - {{eventType}}",
@@ -84,6 +89,7 @@
"meeting_url": "Meeting URL",
"meeting_request_rejected": "Your meeting request has been rejected",
"rescheduled_event_type_subject": "Rescheduled: {{eventType}} with {{name}} at {{date}}",
+ "requested_to_reschedule_subject_attendee": "Action Required Reschedule: Please book a new to time for {{eventType}} with {{name}}",
"rejected_event_type_with_organizer": "Rejected: {{eventType}} with {{organizer}} on {{date}}",
"hi": "Hi",
"join_team": "Join team",
@@ -411,7 +417,7 @@
"booking_confirmation": "Confirm your {{eventTypeTitle}} with {{profileName}}",
"booking_reschedule_confirmation": "Reschedule your {{eventTypeTitle}} with {{profileName}}",
"in_person_meeting": "In-person meeting",
- "link_meeting":"Link meeting",
+ "link_meeting": "Link meeting",
"phone_call": "Phone call",
"phone_number": "Phone Number",
"enter_phone_number": "Enter phone number",
@@ -720,5 +726,15 @@
"external_redirect_url": "https://example.com/redirect-to-my-success-page",
"redirect_url_upgrade_description": "In order to use this feature, you need to upgrade to a Pro account.",
"duplicate": "Duplicate",
- "you_can_manage_your_schedules": "You can manage your schedules on the Availability page."
+ "you_can_manage_your_schedules": "You can manage your schedules on the Availability page.",
+ "request_reschedule_booking": "Request to reschedule your booking",
+ "reason_for_reschedule": "Reason for reschedule",
+ "book_a_new_time": "Book a new time",
+ "reschedule_request_sent": "Reschedule request sent",
+ "reschedule_modal_description": "This will cancel the scheduled meeting, notify the scheduler and ask them to pick a new time.",
+ "reason_for_reschedule_request": "Reason for reschedule request",
+ "send_reschedule_request": "Send reschedule request",
+ "edit_booking": "Edit booking",
+ "reschedule_booking": "Reschedule booking",
+ "former_time": "Former time"
}
diff --git a/apps/web/server/routers/viewer.tsx b/apps/web/server/routers/viewer.tsx
index 3f32214a..2eaa0fc8 100644
--- a/apps/web/server/routers/viewer.tsx
+++ b/apps/web/server/routers/viewer.tsx
@@ -393,6 +393,7 @@ const loggedInViewerRouter = createProtectedRouter()
id: true,
},
},
+ rescheduled: true,
},
orderBy,
take: take + 1,
diff --git a/apps/web/styles/globals.css b/apps/web/styles/globals.css
index a2b646b9..d3e54f5d 100644
--- a/apps/web/styles/globals.css
+++ b/apps/web/styles/globals.css
@@ -13,20 +13,20 @@
* Override the default tailwindcss-forms styling (default is: 'colors.blue.600')
* @see: https://github.com/tailwindlabs/tailwindcss-forms/issues/14#issuecomment-1005376006
*/
-[type='text']:focus,
-[type='email']:focus,
-[type='url']:focus,
-[type='password']:focus,
-[type='number']:focus,
-[type='date']:focus,
-[type='datetime-local']:focus,
-[type='month']:focus,
-[type='search']:focus,
-[type='tel']:focus,
-[type='checkbox']:focus,
-[type='radio']:focus,
-[type='time']:focus,
-[type='week']:focus,
+[type="text"]:focus,
+[type="email"]:focus,
+[type="url"]:focus,
+[type="password"]:focus,
+[type="number"]:focus,
+[type="date"]:focus,
+[type="datetime-local"]:focus,
+[type="month"]:focus,
+[type="search"]:focus,
+[type="tel"]:focus,
+[type="checkbox"]:focus,
+[type="radio"]:focus,
+[type="time"]:focus,
+[type="week"]:focus,
[multiple]:focus,
textarea:focus,
select:focus {
@@ -217,7 +217,7 @@ button[role="switch"][data-state="checked"] span {
}
.react-multi-email > [type="text"] {
- @apply block w-full rounded-md border-gray-300 shadow-sm dark:border-gray-900 dark:bg-gray-700 dark:text-white sm:text-sm;
+ @apply focus:border-brand block w-full rounded-[2px] border-gray-300 shadow-sm focus:ring-black dark:border-gray-900 dark:bg-gray-700 dark:text-white sm:text-sm;
}
.react-multi-email [data-tag] {
diff --git a/apps/website b/apps/website
index e3409f6a..fb5ce134 160000
--- a/apps/website
+++ b/apps/website
@@ -1 +1 @@
-Subproject commit e3409f6aff7e615e061826184a9de8eea3f38cbe
+Subproject commit fb5ce134e57d708cb46036c53d91bbb1f33072af
diff --git a/packages/core/EventManager.ts b/packages/core/EventManager.ts
index bf1289ac..899edb1c 100644
--- a/packages/core/EventManager.ts
+++ b/packages/core/EventManager.ts
@@ -161,7 +161,11 @@ export default class EventManager {
*
* @param event
*/
- public async update(event: CalendarEvent, rescheduleUid: string): Promise {
+ public async update(
+ event: CalendarEvent,
+ rescheduleUid: string,
+ newBookingId?: number
+ ): Promise {
const evt = processLocation(event);
if (!rescheduleUid) {
@@ -187,6 +191,7 @@ export default class EventManager {
},
},
destinationCalendar: true,
+ payment: true,
},
});
@@ -210,6 +215,23 @@ export default class EventManager {
// Update all calendar events.
results.push(...(await this.updateAllCalendarEvents(evt, booking)));
+ const bookingPayment = booking?.payment;
+
+ // Updating all payment to new
+ if (bookingPayment && newBookingId) {
+ const paymentIds = bookingPayment.map((payment) => payment.id);
+ await prisma.payment.updateMany({
+ where: {
+ id: {
+ in: paymentIds,
+ },
+ },
+ data: {
+ bookingId: newBookingId,
+ },
+ });
+ }
+
// Now we can delete the old booking and its references.
const bookingReferenceDeletes = prisma.bookingReference.deleteMany({
where: {
@@ -345,4 +367,16 @@ export default class EventManager {
return Promise.reject("No suitable credentials given for the requested integration name.");
}
}
+
+ /**
+ * Update event to set a cancelled event placeholder on users calendar
+ * remove if virtual calendar is already done and user availability its read from there
+ * and not only in their calendars
+ * @param event
+ * @param booking
+ * @public
+ */
+ public async updateAndSetCancelledPlaceholder(event: CalendarEvent, booking: PartialBooking) {
+ await this.updateAllCalendarEvents(event, booking);
+ }
}
diff --git a/packages/core/builders/CalendarEvent/builder.ts b/packages/core/builders/CalendarEvent/builder.ts
new file mode 100644
index 00000000..d6ff8108
--- /dev/null
+++ b/packages/core/builders/CalendarEvent/builder.ts
@@ -0,0 +1,283 @@
+import { Prisma } from "@prisma/client";
+import dayjs from "dayjs";
+import short from "short-uuid";
+import { v5 as uuidv5 } from "uuid";
+
+import { getTranslation } from "@calcom/lib/server/i18n";
+import prisma from "@calcom/prisma";
+
+import { CalendarEventClass } from "./class";
+
+const translator = short();
+const userSelect = Prisma.validator()({
+ select: {
+ id: true,
+ email: true,
+ name: true,
+ username: true,
+ timeZone: true,
+ credentials: true,
+ bufferTime: true,
+ destinationCalendar: true,
+ locale: true,
+ },
+});
+
+type User = Prisma.UserGetPayload;
+type PersonAttendeeCommonFields = Pick;
+interface ICalendarEventBuilder {
+ calendarEvent: CalendarEventClass;
+ eventType: Awaited>;
+ users: Awaited>[];
+ attendeesList: PersonAttendeeCommonFields[];
+ teamMembers: Awaited>;
+ rescheduleLink: string;
+}
+
+export class CalendarEventBuilder implements ICalendarEventBuilder {
+ calendarEvent!: CalendarEventClass;
+ eventType!: ICalendarEventBuilder["eventType"];
+ users!: ICalendarEventBuilder["users"];
+ attendeesList: ICalendarEventBuilder["attendeesList"] = [];
+ teamMembers: ICalendarEventBuilder["teamMembers"] = [];
+ rescheduleLink!: string;
+
+ constructor() {
+ this.reset();
+ }
+
+ private reset() {
+ this.calendarEvent = new CalendarEventClass();
+ }
+
+ public init(initProps: CalendarEventClass) {
+ this.calendarEvent = new CalendarEventClass(initProps);
+ }
+
+ public setEventType(eventType: ICalendarEventBuilder["eventType"]) {
+ this.eventType = eventType;
+ }
+
+ public async buildEventObjectFromInnerClass(eventId: number) {
+ const resultEvent = await this.getEventFromEventId(eventId);
+ if (resultEvent) {
+ this.eventType = resultEvent;
+ }
+ }
+
+ public async buildUsersFromInnerClass() {
+ if (!this.eventType) {
+ throw new Error("exec BuildEventObjectFromInnerClass before calling this function");
+ }
+ let users = this.eventType.users;
+
+ /* If this event was pre-relationship migration */
+ if (!users.length && this.eventType.userId) {
+ const eventTypeUser = await this.getUserById(this.eventType.userId);
+ if (!eventTypeUser) {
+ throw new Error("buildUsersFromINnerClass.eventTypeUser.notFound");
+ }
+ users.push(eventTypeUser);
+ }
+ this.users = users;
+ }
+
+ public buildAttendeesList() {
+ // Language Function was set on builder init
+ this.attendeesList = [
+ ...(this.calendarEvent.attendees as unknown as PersonAttendeeCommonFields[]),
+ ...this.teamMembers,
+ ];
+ }
+
+ private async getUserById(userId: number) {
+ let resultUser: User | null;
+ try {
+ resultUser = await prisma.user.findUnique({
+ rejectOnNotFound: true,
+ where: {
+ id: userId,
+ },
+ ...userSelect,
+ });
+ } catch (error) {
+ throw new Error("getUsersById.users.notFound");
+ }
+ return resultUser;
+ }
+
+ private async getEventFromEventId(eventTypeId: number) {
+ let resultEventType;
+ try {
+ resultEventType = await prisma.eventType.findUnique({
+ rejectOnNotFound: true,
+ where: {
+ id: eventTypeId,
+ },
+ select: {
+ id: true,
+ users: userSelect,
+ team: {
+ select: {
+ id: true,
+ name: true,
+ slug: true,
+ },
+ },
+ slug: true,
+ teamId: true,
+ title: true,
+ length: true,
+ eventName: true,
+ schedulingType: true,
+ periodType: true,
+ periodStartDate: true,
+ periodEndDate: true,
+ periodDays: true,
+ periodCountCalendarDays: true,
+ requiresConfirmation: true,
+ userId: true,
+ price: true,
+ currency: true,
+ metadata: true,
+ destinationCalendar: true,
+ hideCalendarNotes: true,
+ },
+ });
+ } catch (error) {
+ throw new Error("Error while getting eventType");
+ }
+ return resultEventType;
+ }
+
+ public async buildLuckyUsers() {
+ if (!this.eventType && this.users && this.users.length) {
+ throw new Error("exec buildUsersFromInnerClass before calling this function");
+ }
+
+ // @TODO: user?.username gets flagged as null somehow, maybe a filter before map?
+ const filterUsernames = this.users.filter((user) => user && typeof user.username === "string");
+ const userUsernames = filterUsernames.map((user) => user.username) as string[]; // @TODO: hack
+ const users = await prisma.user.findMany({
+ where: {
+ username: { in: userUsernames },
+ eventTypes: {
+ some: {
+ id: this.eventType.id,
+ },
+ },
+ },
+ select: {
+ id: true,
+ username: true,
+ locale: true,
+ },
+ });
+
+ const userNamesWithBookingCounts = await Promise.all(
+ users.map(async (user) => ({
+ username: user.username,
+ bookingCount: await prisma.booking.count({
+ where: {
+ user: {
+ id: user.id,
+ },
+ startTime: {
+ gt: new Date(),
+ },
+ eventTypeId: this.eventType.id,
+ },
+ }),
+ }))
+ );
+ const luckyUsers = this.getLuckyUsers(this.users, userNamesWithBookingCounts);
+ this.users = luckyUsers;
+ }
+
+ private getLuckyUsers(
+ users: User[],
+ bookingCounts: {
+ username: string | null;
+ bookingCount: number;
+ }[]
+ ) {
+ if (!bookingCounts.length) users.slice(0, 1);
+
+ const [firstMostAvailableUser] = bookingCounts.sort((a, b) => (a.bookingCount > b.bookingCount ? 1 : -1));
+ const luckyUser = users.find((user) => user.username === firstMostAvailableUser?.username);
+ return luckyUser ? [luckyUser] : users;
+ }
+
+ public async buildTeamMembers() {
+ this.teamMembers = await this.getTeamMembers();
+ }
+
+ private async getTeamMembers() {
+ // Users[0] its organizer so we are omitting with slice(1)
+ const teamMemberPromises = this.users.slice(1).map(async function (user) {
+ return {
+ id: user.id,
+ username: user.username,
+ email: user.email || "", // @NOTE: Should we change this "" to teamMemberId?
+ name: user.name || "",
+ timeZone: user.timeZone,
+ language: {
+ translate: await getTranslation(user.locale ?? "en", "common"),
+ locale: user.locale ?? "en",
+ },
+ locale: user.locale,
+ } as PersonAttendeeCommonFields;
+ });
+ return await Promise.all(teamMemberPromises);
+ }
+
+ public buildUIDCalendarEvent() {
+ if (this.users && this.users.length > 0) {
+ throw new Error("call buildUsers before calling this function");
+ }
+ const [mainOrganizer] = this.users;
+ const seed = `${mainOrganizer.username}:${dayjs(this.calendarEvent.startTime)
+ .utc()
+ .format()}:${new Date().getTime()}`;
+ const uid = translator.fromUUID(uuidv5(seed, uuidv5.URL));
+ this.calendarEvent.uid = uid;
+ }
+
+ public setLocation(location: CalendarEventClass["location"]) {
+ this.calendarEvent.location = location;
+ }
+
+ public setUId(uid: CalendarEventClass["uid"]) {
+ this.calendarEvent.uid = uid;
+ }
+
+ public setDestinationCalendar(destinationCalendar: CalendarEventClass["destinationCalendar"]) {
+ this.calendarEvent.destinationCalendar = destinationCalendar;
+ }
+
+ public setHideCalendarNotes(hideCalendarNotes: CalendarEventClass["hideCalendarNotes"]) {
+ this.calendarEvent.hideCalendarNotes = hideCalendarNotes;
+ }
+
+ public setDescription(description: CalendarEventClass["description"]) {
+ this.calendarEvent.description = description;
+ }
+
+ public setCancellationReason(cancellationReason: CalendarEventClass["cancellationReason"]) {
+ this.calendarEvent.cancellationReason = cancellationReason;
+ }
+
+ public buildRescheduleLink(originalBookingUId: string) {
+ if (!this.eventType) {
+ throw new Error("Run buildEventObjectFromInnerClass before this function");
+ }
+ const isTeam = !!this.eventType.teamId;
+
+ const queryParams = new URLSearchParams();
+ queryParams.set("rescheduleUid", `${originalBookingUId}`);
+ const rescheduleLink = `${process.env.NEXT_PUBLIC_WEBAPP_URL}/${
+ isTeam ? `/team/${this.eventType.team?.slug}` : this.users[0].username
+ }/${this.eventType.slug}?${queryParams.toString()}`;
+ this.rescheduleLink = rescheduleLink;
+ }
+}
diff --git a/packages/core/builders/CalendarEvent/class.ts b/packages/core/builders/CalendarEvent/class.ts
new file mode 100644
index 00000000..d433bda2
--- /dev/null
+++ b/packages/core/builders/CalendarEvent/class.ts
@@ -0,0 +1,31 @@
+import { AdditionInformation, CalendarEvent, ConferenceData, Person } from "@calcom/types/Calendar";
+
+import { DestinationCalendar } from ".prisma/client";
+
+class CalendarEventClass implements CalendarEvent {
+ type!: string;
+ title!: string;
+ startTime!: string;
+ endTime!: string;
+ organizer!: Person;
+ attendees!: Person[];
+ description?: string | null;
+ team?: { name: string; members: string[] };
+ location?: string | null;
+ conferenceData?: ConferenceData;
+ additionInformation?: AdditionInformation;
+ uid?: string | null;
+ videoCallData?: any;
+ paymentInfo?: any;
+ destinationCalendar?: DestinationCalendar | null;
+ cancellationReason?: string | null;
+ rejectionReason?: string | null;
+ hideCalendarNotes?: boolean;
+
+ constructor(initProps?: CalendarEvent) {
+ // If more parameters are given we update this
+ Object.assign(this, initProps);
+ }
+}
+
+export { CalendarEventClass };
diff --git a/packages/core/builders/CalendarEvent/director.ts b/packages/core/builders/CalendarEvent/director.ts
new file mode 100644
index 00000000..0eda82f9
--- /dev/null
+++ b/packages/core/builders/CalendarEvent/director.ts
@@ -0,0 +1,35 @@
+import { Booking } from "@prisma/client";
+
+import { CalendarEventBuilder } from "./builder";
+
+export class CalendarEventDirector {
+ private builder!: CalendarEventBuilder;
+ private existingBooking!: Partial;
+ private cancellationReason!: string;
+
+ public setBuilder(builder: CalendarEventBuilder): void {
+ this.builder = builder;
+ }
+
+ public setExistingBooking(booking: Booking) {
+ this.existingBooking = booking;
+ }
+
+ public setCancellationReason(reason: string) {
+ this.cancellationReason = reason;
+ }
+
+ public async buildForRescheduleEmail(): Promise {
+ if (this.existingBooking && this.existingBooking.eventTypeId && this.existingBooking.uid) {
+ await this.builder.buildEventObjectFromInnerClass(this.existingBooking.eventTypeId);
+ await this.builder.buildUsersFromInnerClass();
+ this.builder.buildAttendeesList();
+ this.builder.setLocation(this.existingBooking.location);
+ this.builder.setUId(this.existingBooking.uid);
+ this.builder.setCancellationReason(this.cancellationReason);
+ this.builder.buildRescheduleLink(this.existingBooking.uid);
+ } else {
+ throw new Error("buildForRescheduleEmail.missing.params.required");
+ }
+ }
+}
diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json
index 184d5dec..39577403 100644
--- a/packages/core/tsconfig.json
+++ b/packages/core/tsconfig.json
@@ -3,6 +3,6 @@
"compilerOptions": {
"baseUrl": "."
},
- "include": ["."],
+ "include": [".", "../types/*.d.ts"],
"exclude": ["dist", "build", "node_modules"]
}
diff --git a/packages/prisma/index.ts b/packages/prisma/index.ts
index 1f92195d..0cf3363f 100644
--- a/packages/prisma/index.ts
+++ b/packages/prisma/index.ts
@@ -1,5 +1,7 @@
import { PrismaClient } from "@prisma/client";
+import { bookingReferenceMiddleware } from "./middleware";
+
declare global {
var prisma: PrismaClient | undefined;
}
@@ -13,5 +15,7 @@ export const prisma =
if (process.env.NODE_ENV !== "production") {
globalThis.prisma = prisma;
}
+// If any changed on middleware server restart is required
+bookingReferenceMiddleware(prisma);
export default prisma;
diff --git a/packages/prisma/middleware/bookingReference.ts b/packages/prisma/middleware/bookingReference.ts
new file mode 100644
index 00000000..a2c771ff
--- /dev/null
+++ b/packages/prisma/middleware/bookingReference.ts
@@ -0,0 +1,51 @@
+import { PrismaClient } from "@prisma/client";
+
+async function middleware(prisma: PrismaClient) {
+ /***********************************/
+ /* SOFT DELETE MIDDLEWARE */
+ /***********************************/
+ prisma.$use(async (params, next) => {
+ // Check incoming query type
+
+ if (params.model === "BookingReference") {
+ if (params.action === "delete") {
+ // Delete queries
+ // Change action to an update
+ params.action = "update";
+ params.args["data"] = { deleted: true };
+ }
+ if (params.action === "deleteMany") {
+ console.log("deletingMany");
+ // Delete many queries
+ params.action = "updateMany";
+ if (params.args.data !== undefined) {
+ params.args.data["deleted"] = true;
+ } else {
+ params.args["data"] = { deleted: true };
+ }
+ }
+ if (params.action === "findUnique") {
+ // Change to findFirst - you cannot filter
+ // by anything except ID / unique with findUnique
+ params.action = "findFirst";
+ // Add 'deleted' filter
+ // ID filter maintained
+ params.args.where["deleted"] = null;
+ }
+ if (params.action === "findMany" || params.action === "findFirst") {
+ // Find many queries
+ if (params.args.where !== undefined) {
+ if (params.args.where.deleted === undefined) {
+ // Exclude deleted records if they have not been explicitly requested
+ params.args.where["deleted"] = null;
+ }
+ } else {
+ params.args["where"] = { deleted: null };
+ }
+ }
+ }
+ return next(params);
+ });
+}
+
+export default middleware;
diff --git a/packages/prisma/middleware/index.ts b/packages/prisma/middleware/index.ts
new file mode 100644
index 00000000..90097ed1
--- /dev/null
+++ b/packages/prisma/middleware/index.ts
@@ -0,0 +1 @@
+export { default as bookingReferenceMiddleware } from "./bookingReference";
diff --git a/packages/prisma/migrations/20220323033335_reschedule_fields_to_bookings_table/migration.sql b/packages/prisma/migrations/20220323033335_reschedule_fields_to_bookings_table/migration.sql
new file mode 100644
index 00000000..e539b327
--- /dev/null
+++ b/packages/prisma/migrations/20220323033335_reschedule_fields_to_bookings_table/migration.sql
@@ -0,0 +1,3 @@
+-- AlterTable
+ALTER TABLE "Booking" ADD COLUMN "fromReschedule" TEXT,
+ADD COLUMN "rescheduled" BOOLEAN;
\ No newline at end of file
diff --git a/packages/prisma/migrations/20220328185001_soft_delete_booking_references/migration.sql b/packages/prisma/migrations/20220328185001_soft_delete_booking_references/migration.sql
new file mode 100644
index 00000000..6e561ef0
--- /dev/null
+++ b/packages/prisma/migrations/20220328185001_soft_delete_booking_references/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "BookingReference" ADD COLUMN "deleted" BOOLEAN;
diff --git a/packages/prisma/migrations/20220412172742_payment_on_delete_cascade/migration.sql b/packages/prisma/migrations/20220412172742_payment_on_delete_cascade/migration.sql
new file mode 100644
index 00000000..0070f627
--- /dev/null
+++ b/packages/prisma/migrations/20220412172742_payment_on_delete_cascade/migration.sql
@@ -0,0 +1,5 @@
+-- DropForeignKey
+ALTER TABLE "Payment" DROP CONSTRAINT "Payment_bookingId_fkey";
+
+-- AddForeignKey
+ALTER TABLE "Payment" ADD CONSTRAINT "Payment_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma
index 3153a2b2..9e206f2d 100644
--- a/packages/prisma/schema.prisma
+++ b/packages/prisma/schema.prisma
@@ -204,8 +204,9 @@ model BookingReference {
meetingId String?
meetingPassword String?
meetingUrl String?
- booking Booking? @relation(fields: [bookingId], references: [id])
+ booking Booking? @relation(fields: [bookingId], references: [id], onDelete: Cascade)
bookingId Int?
+ deleted Boolean?
}
model Attendee {
@@ -260,6 +261,8 @@ model Booking {
rejectionReason String?
dynamicEventSlugRef String?
dynamicGroupSlugRef String?
+ rescheduled Boolean?
+ fromReschedule String?
}
model Schedule {
@@ -342,7 +345,7 @@ model Payment {
uid String @unique
type PaymentType
bookingId Int
- booking Booking? @relation(fields: [bookingId], references: [id])
+ booking Booking? @relation(fields: [bookingId], references: [id], onDelete: Cascade)
amount Int
fee Int
currency String
diff --git a/packages/types/Calendar.d.ts b/packages/types/Calendar.d.ts
index 00b9ab3f..f6cc7356 100644
--- a/packages/types/Calendar.d.ts
+++ b/packages/types/Calendar.d.ts
@@ -12,6 +12,8 @@ export type Person = {
email: string;
timeZone: string;
language: { translate: TFunction; locale: string };
+ username?: string;
+ id?: string;
};
export type EventBusyDate = Record<"start" | "end", Date | string>;
@@ -70,11 +72,14 @@ export interface AdditionInformation {
hangoutLink?: string;
}
+// If modifying this interface, probably should update builders/calendarEvent files
export interface CalendarEvent {
type: string;
title: string;
startTime: string;
endTime: string;
+ organizer: Person;
+ attendees: Person[];
additionalNotes?: string | null;
description?: string | null;
team?: {
@@ -82,8 +87,6 @@ export interface CalendarEvent {
members: string[];
};
location?: string | null;
- organizer: Person;
- attendees: Person[];
conferenceData?: ConferenceData;
additionInformation?: AdditionInformation;
uid?: string | null;
|