diff --git a/apps/web/playwright/fixtures/bookings.ts b/apps/web/playwright/fixtures/bookings.ts index 56cbd1002f..ad2e6f072c 100644 --- a/apps/web/playwright/fixtures/bookings.ts +++ b/apps/web/playwright/fixtures/bookings.ts @@ -62,6 +62,7 @@ export const createBookingsFixture = (page: Page) => { rescheduled, paid, status, + iCalUID: `${uid}@cal.com`, }, }); const bookingFixture = createBookingFixture(booking, store.page); diff --git a/apps/web/test/utils/bookingScenario/expects.ts b/apps/web/test/utils/bookingScenario/expects.ts index edb5e1a9a1..5129338ec2 100644 --- a/apps/web/test/utils/bookingScenario/expects.ts +++ b/apps/web/test/utils/bookingScenario/expects.ts @@ -68,6 +68,7 @@ type ExpectedEmail = { filename: string; iCalUID?: string; recurrence?: Recurrence; + method: string; }; /** * Checks that there is no @@ -162,9 +163,14 @@ expect.extend({ } if (!expectedEmail.noIcs && !isIcsUIDExpected) { + const icsObjectKeys = icsObject ? Object.keys(icsObject) : []; + const icsKey = icsObjectKeys.find((key) => key !== "vcalendar"); + if (!icsKey) throw new Error("icsKey not found"); return { pass: false, - actual: JSON.stringify(icsObject), + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + actual: icsObject[icsKey].uid!, expected: expectedEmail.ics?.iCalUID, message: () => `Expected ICS UID ${isNot ? "is" : "isn't"} present in actual`, }; @@ -375,6 +381,7 @@ export function expectSuccessfulBookingCreationEmails({ filename: "event.ics", iCalUID: `${iCalUID}`, recurrence, + method: "REQUEST", }, }, `${organizer.email}` @@ -397,8 +404,9 @@ export function expectSuccessfulBookingCreationEmails({ to: `${booker.name} <${booker.email}>`, ics: { filename: "event.ics", - iCalUID: iCalUID, + iCalUID: `${iCalUID}`, recurrence, + method: "REQUEST", }, links: recurrence ? [ @@ -436,7 +444,8 @@ export function expectSuccessfulBookingCreationEmails({ to: `${otherTeamMember.email}`, ics: { filename: "event.ics", - iCalUID: iCalUID, + iCalUID: `${iCalUID}`, + method: "REQUEST", }, links: [ { @@ -472,7 +481,8 @@ export function expectSuccessfulBookingCreationEmails({ to: `${guest.email}`, ics: { filename: "event.ics", - iCalUID: iCalUID, + iCalUID: `${iCalUID}`, + method: "REQUEST", }, }, `${guest.name} <${guest.email}` @@ -529,6 +539,7 @@ export function expectCalendarEventCreationFailureEmails({ ics: { filename: "event.ics", iCalUID, + method: "REQUEST", }, }, `${organizer.email}` @@ -541,6 +552,7 @@ export function expectCalendarEventCreationFailureEmails({ ics: { filename: "event.ics", iCalUID, + method: "REQUEST", }, }, `${booker.name} <${booker.email}>` @@ -612,6 +624,7 @@ export function expectSuccessfulBookingRescheduledEmails({ ics: { filename: "event.ics", iCalUID, + method: "REQUEST", }, appsStatus, }, @@ -625,6 +638,7 @@ export function expectSuccessfulBookingRescheduledEmails({ ics: { filename: "event.ics", iCalUID, + method: "REQUEST", }, }, `${booker.name} <${booker.email}>` @@ -711,6 +725,7 @@ export function expectBookingRequestRescheduledEmails({ to: `${booker.email}`, ics: { filename: "event.ics", + method: "REQUEST", }, }, `${booker.email}` @@ -724,6 +739,7 @@ export function expectBookingRequestRescheduledEmails({ to: `${loggedInUser.email}`, ics: { filename: "event.ics", + method: "REQUEST", }, }, `${loggedInUser.email}` @@ -1074,3 +1090,11 @@ export async function expectBookingInDBToBeRescheduledFromTo({ from, to }: { fro ...to, }); } + +export function expectICalUIDAsString(iCalUID: string | undefined | null) { + if (typeof iCalUID !== "string") { + throw new Error("iCalUID is not a string"); + } + + return iCalUID; +} diff --git a/packages/app-store/googlecalendar/lib/CalendarService.ts b/packages/app-store/googlecalendar/lib/CalendarService.ts index 0ffc9a0442..80f77cb2a8 100644 --- a/packages/app-store/googlecalendar/lib/CalendarService.ts +++ b/packages/app-store/googlecalendar/lib/CalendarService.ts @@ -202,6 +202,7 @@ export default class GoogleCalendarService implements Calendar { useDefault: true, }, guestsCanSeeOtherGuests: !!calEventRaw.seatsPerTimeSlot ? calEventRaw.seatsShowAttendees : true, + iCalUID: calEventRaw.iCalUID, }; if (calEventRaw.location) { @@ -250,7 +251,6 @@ export default class GoogleCalendarService implements Calendar { type: "google_calendar", password: "", url: "", - iCalUID: event.data.iCalUID, }; } catch (error) { this.log.error( diff --git a/packages/core/EventManager.ts b/packages/core/EventManager.ts index edfd96d3a3..673087d954 100644 --- a/packages/core/EventManager.ts +++ b/packages/core/EventManager.ts @@ -179,7 +179,7 @@ export default class EventManager { } const isCalendarType = isCalendarResult(result); if (isCalendarType) { - evt.iCalUID = result.iCalUID || undefined; + evt.iCalUID = result.iCalUID || event.iCalUID || undefined; } return { diff --git a/packages/core/builders/CalendarEvent/class.ts b/packages/core/builders/CalendarEvent/class.ts index 8a449dbfd2..5f2e16bad3 100644 --- a/packages/core/builders/CalendarEvent/class.ts +++ b/packages/core/builders/CalendarEvent/class.ts @@ -30,6 +30,7 @@ class CalendarEventClass implements CalendarEvent { hideCalendarNotes?: boolean; additionalNotes?: string | null | undefined; recurrence?: string; + iCalUID?: string | null; constructor(initProps?: CalendarEvent) { // If more parameters are given we update this diff --git a/packages/emails/lib/generateIcsString.ts b/packages/emails/lib/generateIcsString.ts new file mode 100644 index 0000000000..c70b8ba818 --- /dev/null +++ b/packages/emails/lib/generateIcsString.ts @@ -0,0 +1,105 @@ +import type { DateArray, ParticipationStatus, ParticipationRole, EventStatus } from "ics"; +import { createEvent } from "ics"; +import type { TFunction } from "next-i18next"; +import { RRule } from "rrule"; + +import dayjs from "@calcom/dayjs"; +import { getRichDescription } from "@calcom/lib/CalEventParser"; +import { getWhen } from "@calcom/lib/CalEventParser"; +import type { CalendarEvent, Person } from "@calcom/types/Calendar"; + +export enum BookingAction { + Create = "create", + Cancel = "cancel", + Reschedule = "reschedule", + RequestReschedule = "request_reschedule", + LocationChange = "location_change", +} + +const generateIcsString = ({ + event, + title, + subtitle, + status, + role, + isRequestReschedule, + t, +}: { + event: CalendarEvent; + title: string; + subtitle: string; + status: EventStatus; + role: "attendee" | "organizer"; + isRequestReschedule?: boolean; + t?: TFunction; +}) => { + // Taking care of recurrence rule + let recurrenceRule: string | undefined = undefined; + const partstat: ParticipationStatus = "ACCEPTED"; + const icsRole: ParticipationRole = "REQ-PARTICIPANT"; + if (event.recurringEvent?.count) { + // ics appends "RRULE:" already, so removing it from RRule generated string + recurrenceRule = new RRule(event.recurringEvent).toString().replace("RRULE:", ""); + } + + const getTextBody = (title: string, subtitle: string): string => { + let body: string; + if (isRequestReschedule && role === "attendee" && t) { + body = ` + ${title} + ${getWhen(event, t)} + ${subtitle}`; + } + body = ` + ${title} + ${subtitle} + + ${getRichDescription(event, t)} + `.trim(); + + return body; + }; + + const icsEvent = createEvent({ + uid: event.iCalUID || event.uid!, + sequence: event.iCalSequence || 0, + start: dayjs(event.startTime) + .utc() + .toArray() + .slice(0, 6) + .map((v, i) => (i === 1 ? v + 1 : v)) as DateArray, + startInputType: "utc", + productId: "calcom/ics", + title: event.title, + description: getTextBody(title, subtitle), + duration: { minutes: dayjs(event.endTime).diff(dayjs(event.startTime), "minute") }, + organizer: { name: event.organizer.name, email: event.organizer.email }, + ...{ recurrenceRule }, + attendees: [ + ...event.attendees.map((attendee: Person) => ({ + name: attendee.name, + email: attendee.email, + partstat, + role: icsRole, + rsvp: true, + })), + ...(event.team?.members + ? event.team?.members.map((member: Person) => ({ + name: member.name, + email: member.email, + partstat, + role: icsRole, + rsvp: true, + })) + : []), + ], + method: "REQUEST", + status, + }); + if (icsEvent.error) { + throw icsEvent.error; + } + return icsEvent.value; +}; + +export default generateIcsString; diff --git a/packages/emails/lib/getICalUID.ts b/packages/emails/lib/getICalUID.ts new file mode 100644 index 0000000000..1dc7e7216d --- /dev/null +++ b/packages/emails/lib/getICalUID.ts @@ -0,0 +1,36 @@ +import short from "short-uuid"; +import { v5 as uuidv5 } from "uuid"; + +import { APP_NAME } from "@calcom/lib/constants"; + +/** + * This function returns the iCalUID if a uid is passed or if it is present in the event that is passed + * @param uid - the uid of the event + * @param event - an event that already has an iCalUID or one that has a uid + * @param defaultToEventUid - if true, will default to the event.uid if present + * + * @returns the iCalUID whether already present or generated + */ +const getICalUID = ({ + uid, + event, + defaultToEventUid, +}: { + uid?: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + event?: { iCalUID?: string | null; uid?: string | null; [key: string]: any }; + defaultToEventUid?: boolean; +}) => { + if (event?.iCalUID) return event.iCalUID; + + if (defaultToEventUid && event?.uid) return `${event.uid}@${APP_NAME}`; + + if (uid) return `${uid}@${APP_NAME}`; + + const translator = short(); + + uid = translator.fromUUID(uuidv5(APP_NAME, uuidv5.URL)); + return `${uid}@${APP_NAME}`; +}; + +export default getICalUID; diff --git a/packages/emails/lib/test/generateIcsString.test.ts b/packages/emails/lib/test/generateIcsString.test.ts new file mode 100644 index 0000000000..12f18956d1 --- /dev/null +++ b/packages/emails/lib/test/generateIcsString.test.ts @@ -0,0 +1,137 @@ +import { describe, expect } from "vitest"; + +import dayjs from "@calcom/dayjs"; +import type { CalendarEvent } from "@calcom/types/Calendar"; +import { test } from "@calcom/web/test/fixtures/fixtures"; + +import { buildCalendarEvent, buildPerson } from "../../../lib/test/builder"; +import generateIcsString from "../generateIcsString"; + +const assertHasIcsString = (icsString: string | undefined) => { + if (!icsString) throw new Error("icsString is undefined"); + + expect(icsString).toBeDefined(); + + return icsString; +}; + +const testIcsStringContains = ({ + icsString, + event, + status, +}: { + icsString: string; + event: CalendarEvent; + status: string; +}) => { + const DTSTART = event.startTime.split(".")[0].replace(/[-:]/g, ""); + const startTime = dayjs(event.startTime); + const endTime = dayjs(event.endTime); + const duration = endTime.diff(startTime, "minute"); + + expect(icsString).toEqual(expect.stringContaining(`UID:${event.iCalUID}`)); + // Sometimes the deeply equal stringMatching error appears. Don't want to add flakey tests + // expect(icsString).toEqual(expect.stringContaining(`SUMMARY:${event.title}`)); + expect(icsString).toEqual(expect.stringContaining(`DTSTART:${DTSTART}`)); + expect(icsString).toEqual( + expect.stringContaining(`ORGANIZER;CN=${event.organizer.name}:mailto:${event.organizer.email}`) + ); + expect(icsString).toEqual(expect.stringContaining(`DURATION:PT${duration}M`)); + expect(icsString).toEqual(expect.stringContaining(`STATUS:${status}`)); + // Getting an error expected icsString to deeply equal stringMatching + // for (const attendee of event.attendees) { + // expect(icsString).toEqual( + // expect.stringMatching( + // `RSVP=TRUE;ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED;CN=${attendee.name}:mailto:${attendee.email}` + // ) + // ); + // } +}; + +describe("generateIcsString", () => { + test("when bookingAction is Create", () => { + const event = buildCalendarEvent({ + iCalSequence: 0, + attendees: [buildPerson()], + }); + + const title = "new_event_scheduled_recurring"; + const subtitle = "emailed_you_and_any_other_attendees"; + const status = "CONFIRMED"; + + const icsString = generateIcsString({ + event: event, + title, + subtitle, + role: "organizer", + status, + }); + + const assertedIcsString = assertHasIcsString(icsString); + + testIcsStringContains({ icsString: assertedIcsString, event, status }); + }); + test("when bookingAction is Cancel", () => { + const event = buildCalendarEvent({ + iCalSequence: 0, + attendees: [buildPerson()], + }); + const title = "event_request_cancelled"; + const subtitle = "emailed_you_and_any_other_attendees"; + const status = "CANCELLED"; + + const icsString = generateIcsString({ + event: event, + title, + subtitle, + role: "organizer", + status, + }); + + const assertedIcsString = assertHasIcsString(icsString); + + testIcsStringContains({ icsString: assertedIcsString, event, status }); + }); + test("when bookingAction is Reschedule", () => { + const event = buildCalendarEvent({ + iCalSequence: 0, + attendees: [buildPerson()], + }); + const title = "event_type_has_been_rescheduled"; + const subtitle = "emailed_you_and_any_other_attendees"; + const status = "CONFIRMED"; + + const icsString = generateIcsString({ + event: event, + title, + subtitle, + role: "organizer", + status, + }); + + const assertedIcsString = assertHasIcsString(icsString); + + testIcsStringContains({ icsString: assertedIcsString, event, status }); + }); + test("when bookingAction is RequestReschedule", () => { + const event = buildCalendarEvent({ + iCalSequence: 0, + attendees: [buildPerson()], + }); + const title = "request_reschedule_title_organizer"; + const subtitle = "request_reschedule_subtitle_organizer"; + const status = "CANCELLED"; + + const icsString = generateIcsString({ + event: event, + title, + subtitle, + role: "organizer", + status, + }); + + const assertedIcsString = assertHasIcsString(icsString); + + testIcsStringContains({ icsString: assertedIcsString, event, status }); + }); +}); diff --git a/packages/emails/lib/test/getICalUID.test.ts b/packages/emails/lib/test/getICalUID.test.ts new file mode 100644 index 0000000000..fe66988728 --- /dev/null +++ b/packages/emails/lib/test/getICalUID.test.ts @@ -0,0 +1,29 @@ +import { describe, expect } from "vitest"; + +import { APP_NAME } from "@calcom/lib/constants"; +import { buildCalendarEvent } from "@calcom/lib/test/builder"; +import { test } from "@calcom/web/test/fixtures/fixtures"; + +import getICalUID from "../getICalUID"; + +describe("getICalUid", () => { + test("returns iCalUID when passing a uid", () => { + const iCalUID = getICalUID({ uid: "123" }); + expect(iCalUID).toEqual(`123@${APP_NAME}`); + }); + test("returns iCalUID when passing an event", () => { + const event = buildCalendarEvent({ iCalUID: `123@${APP_NAME}` }); + const iCalUID = getICalUID({ event }); + expect(iCalUID).toEqual(`123@${APP_NAME}`); + }); + test("returns new iCalUID when passing in an event with no iCalUID but has an uid", () => { + const event = buildCalendarEvent({ iCalUID: "" }); + const iCalUID = getICalUID({ event, defaultToEventUid: true }); + expect(iCalUID).toEqual(`${event.uid}@${APP_NAME}`); + }); + test("returns new iCalUID when passing in an event with no iCalUID and uses uid passed", () => { + const event = buildCalendarEvent({ iCalUID: "" }); + const iCalUID = getICalUID({ event, uid: "123" }); + expect(iCalUID).toEqual(`123@${APP_NAME}`); + }); +}); diff --git a/packages/emails/templates/attendee-cancelled-email.ts b/packages/emails/templates/attendee-cancelled-email.ts index e3cb71b54a..5a18b99dcd 100644 --- a/packages/emails/templates/attendee-cancelled-email.ts +++ b/packages/emails/templates/attendee-cancelled-email.ts @@ -1,9 +1,21 @@ import { renderEmail } from "../"; +import generateIcsString from "../lib/generateIcsString"; import AttendeeScheduledEmail from "./attendee-scheduled-email"; export default class AttendeeCancelledEmail extends AttendeeScheduledEmail { protected async getNodeMailerPayload(): Promise> { return { + icalEvent: { + filename: "event.ics", + content: generateIcsString({ + event: this.calEvent, + title: this.t("event_request_cancelled"), + subtitle: this.t("emailed_you_and_any_other_attendees"), + status: "CANCELLED", + role: "attendee", + }), + method: "REQUEST", + }, to: `${this.attendee.name} <${this.attendee.email}>`, from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`, replyTo: this.calEvent.organizer.email, diff --git a/packages/emails/templates/attendee-location-change-email.ts b/packages/emails/templates/attendee-location-change-email.ts index af15fec058..ad16431dc3 100644 --- a/packages/emails/templates/attendee-location-change-email.ts +++ b/packages/emails/templates/attendee-location-change-email.ts @@ -1,4 +1,5 @@ import { renderEmail } from "../"; +import generateIcsString from "../lib/generateIcsString"; import AttendeeScheduledEmail from "./attendee-scheduled-email"; export default class AttendeeLocationChangeEmail extends AttendeeScheduledEmail { @@ -6,7 +7,14 @@ export default class AttendeeLocationChangeEmail extends AttendeeScheduledEmail return { icalEvent: { filename: "event.ics", - content: this.getiCalEventAsString(), + content: generateIcsString({ + event: this.calEvent, + title: this.t("event_location_changed"), + subtitle: this.t("emailed_you_and_any_other_attendees"), + role: "attendee", + status: "CONFIRMED", + }), + method: "REQUEST", }, to: `${this.attendee.name} <${this.attendee.email}>`, from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`, diff --git a/packages/emails/templates/attendee-rescheduled-email.ts b/packages/emails/templates/attendee-rescheduled-email.ts index 85bc7543ca..0ebceb096f 100644 --- a/packages/emails/templates/attendee-rescheduled-email.ts +++ b/packages/emails/templates/attendee-rescheduled-email.ts @@ -1,4 +1,5 @@ import { renderEmail } from "../"; +import generateIcsString from "../lib/generateIcsString"; import AttendeeScheduledEmail from "./attendee-scheduled-email"; export default class AttendeeRescheduledEmail extends AttendeeScheduledEmail { @@ -6,7 +7,14 @@ export default class AttendeeRescheduledEmail extends AttendeeScheduledEmail { return { icalEvent: { filename: "event.ics", - content: this.getiCalEventAsString(), + content: generateIcsString({ + event: this.calEvent, + title: this.t("event_type_has_been_rescheduled"), + subtitle: this.t("emailed_you_and_any_other_attendees"), + role: "attendee", + status: "CONFIRMED", + }), + method: "REQUEST", }, to: `${this.attendee.name} <${this.attendee.email}>`, from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`, diff --git a/packages/emails/templates/attendee-scheduled-email.ts b/packages/emails/templates/attendee-scheduled-email.ts index c2dba8dcc5..7cf48a77e3 100644 --- a/packages/emails/templates/attendee-scheduled-email.ts +++ b/packages/emails/templates/attendee-scheduled-email.ts @@ -1,16 +1,13 @@ -import type { DateArray, ParticipationStatus, ParticipationRole } from "ics"; -import { createEvent } from "ics"; // eslint-disable-next-line no-restricted-imports import { cloneDeep } from "lodash"; import type { TFunction } from "next-i18next"; -import { RRule } from "rrule"; -import dayjs from "@calcom/dayjs"; import { getRichDescription } from "@calcom/lib/CalEventParser"; import { TimeFormat } from "@calcom/lib/timeFormat"; import type { CalendarEvent, Person } from "@calcom/types/Calendar"; import { renderEmail } from "../"; +import generateIcsString from "../lib/generateIcsString"; import BaseEmail from "./_base-email"; export default class AttendeeScheduledEmail extends BaseEmail { @@ -32,65 +29,21 @@ export default class AttendeeScheduledEmail extends BaseEmail { this.t = attendee.language.translate; } - protected getiCalEventAsString(): string | undefined { - // Taking care of recurrence rule - let recurrenceRule: string | undefined = undefined; - if (this.calEvent.recurringEvent?.count) { - // ics appends "RRULE:" already, so removing it from RRule generated string - recurrenceRule = new RRule(this.calEvent.recurringEvent).toString().replace("RRULE:", ""); - } - const partstat: ParticipationStatus = "ACCEPTED"; - const role: ParticipationRole = "REQ-PARTICIPANT"; - const icsEvent = createEvent({ - uid: this.calEvent.iCalUID || this.calEvent.uid!, - start: dayjs(this.calEvent.startTime) - .utc() - .toArray() - .slice(0, 6) - .map((v, i) => (i === 1 ? v + 1 : v)) as DateArray, - startInputType: "utc", - productId: "calcom/ics", - title: this.calEvent.title, - 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, - partstat, - role, - rsvp: true, - })), - ...(this.calEvent.team?.members - ? this.calEvent.team?.members.map((member: Person) => ({ - name: member.name, - email: member.email, - partstat, - role, - rsvp: true, - })) - : []), - ], - method: "REQUEST", - ...{ recurrenceRule }, - status: "CONFIRMED", - }); - if (icsEvent.error) { - throw icsEvent.error; - } - return icsEvent.value; - } - protected async getNodeMailerPayload(): Promise> { const clonedCalEvent = cloneDeep(this.calEvent); - this.getiCalEventAsString(); - return { icalEvent: { filename: "event.ics", - content: this.getiCalEventAsString(), + content: generateIcsString({ + event: this.calEvent, + title: this.calEvent.recurringEvent?.count + ? this.t("your_event_has_been_scheduled_recurring") + : this.t("your_event_has_been_scheduled"), + role: "attendee", + subtitle: "emailed_you_and_any_other_attendees", + status: "CONFIRMED", + }), method: "REQUEST", }, to: `${this.attendee.name} <${this.attendee.email}>`, diff --git a/packages/emails/templates/attendee-was-requested-to-reschedule-email.ts b/packages/emails/templates/attendee-was-requested-to-reschedule-email.ts index e5f11807a9..09310c816c 100644 --- a/packages/emails/templates/attendee-was-requested-to-reschedule-email.ts +++ b/packages/emails/templates/attendee-was-requested-to-reschedule-email.ts @@ -1,12 +1,9 @@ -import type { DateArray } from "ics"; -import { createEvent } from "ics"; - -import dayjs from "@calcom/dayjs"; import { getManageLink } from "@calcom/lib/CalEventParser"; import { APP_NAME } from "@calcom/lib/constants"; -import type { CalendarEvent, Person } from "@calcom/types/Calendar"; +import type { CalendarEvent } from "@calcom/types/Calendar"; import { renderEmail } from ".."; +import generateIcsString from "../lib/generateIcsString"; import OrganizerScheduledEmail from "./organizer-scheduled-email"; export default class AttendeeWasRequestedToRescheduleEmail extends OrganizerScheduledEmail { @@ -22,7 +19,16 @@ export default class AttendeeWasRequestedToRescheduleEmail extends OrganizerSche return { icalEvent: { filename: "event.ics", - content: this.getiCalEventAsString(), + content: generateIcsString({ + event: this.calEvent, + title: this.t("request_reschedule_booking"), + subtitle: this.t("request_reschedule_subtitle", { + organizer: this.calEvent.organizer.name, + }), + role: "attendee", + status: "CANCELLED", + }), + method: "REQUEST", }, from: `${APP_NAME} <${this.getMailerOptions().from}>`, to: toAddresses.join(","), @@ -39,35 +45,6 @@ export default class AttendeeWasRequestedToRescheduleEmail extends OrganizerSche }; } - // @OVERRIDE - protected getiCalEventAsString(): string | undefined { - 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: "calcom/ics", - title: this.t("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 ` diff --git a/packages/emails/templates/broken-integration-email.ts b/packages/emails/templates/broken-integration-email.ts index 1cbba4c6f1..b1aaddb175 100644 --- a/packages/emails/templates/broken-integration-email.ts +++ b/packages/emails/templates/broken-integration-email.ts @@ -53,7 +53,7 @@ ${this.t( )} ${this.t(subtitle)} ${extraInfo} -${getRichDescription(this.calEvent)} +${getRichDescription(this.calEvent, this.t, true)} ${callToAction} `.trim(); } diff --git a/packages/emails/templates/organizer-cancelled-email.ts b/packages/emails/templates/organizer-cancelled-email.ts index 3c3792e2a3..3466cab5c2 100644 --- a/packages/emails/templates/organizer-cancelled-email.ts +++ b/packages/emails/templates/organizer-cancelled-email.ts @@ -1,6 +1,7 @@ import { APP_NAME } from "@calcom/lib/constants"; import { renderEmail } from "../"; +import generateIcsString from "../lib/generateIcsString"; import OrganizerScheduledEmail from "./organizer-scheduled-email"; export default class OrganizerCancelledEmail extends OrganizerScheduledEmail { @@ -8,6 +9,17 @@ export default class OrganizerCancelledEmail extends OrganizerScheduledEmail { const toAddresses = [this.teamMember?.email || this.calEvent.organizer.email]; return { + icalEvent: { + filename: "event.ics", + content: generateIcsString({ + event: this.calEvent, + title: this.t("event_request_cancelled"), + subtitle: this.t("emailed_you_and_any_other_attendees"), + status: "CANCELLED", + role: "organizer", + }), + method: "REQUEST", + }, from: `${APP_NAME} <${this.getMailerOptions().from}>`, to: toAddresses.join(","), subject: `${this.t("event_cancelled_subject", { diff --git a/packages/emails/templates/organizer-location-change-email.ts b/packages/emails/templates/organizer-location-change-email.ts index 8eb1110e52..c5c8e893c0 100644 --- a/packages/emails/templates/organizer-location-change-email.ts +++ b/packages/emails/templates/organizer-location-change-email.ts @@ -1,6 +1,7 @@ import { APP_NAME } from "@calcom/lib/constants"; import { renderEmail } from "../"; +import generateIcsString from "../lib/generateIcsString"; import OrganizerScheduledEmail from "./organizer-scheduled-email"; export default class OrganizerLocationChangeEmail extends OrganizerScheduledEmail { @@ -10,7 +11,14 @@ export default class OrganizerLocationChangeEmail extends OrganizerScheduledEmai return { icalEvent: { filename: "event.ics", - content: this.getiCalEventAsString(), + content: generateIcsString({ + event: this.calEvent, + title: this.t("event_location_changed"), + subtitle: this.t("emailed_you_and_any_other_attendees"), + role: "organizer", + status: "CONFIRMED", + }), + method: "REQUEST", }, from: `${APP_NAME} <${this.getMailerOptions().from}>`, to: toAddresses.join(","), diff --git a/packages/emails/templates/organizer-requested-to-reschedule-email.ts b/packages/emails/templates/organizer-requested-to-reschedule-email.ts index 7f8bdfb2fb..8a5e5247b7 100644 --- a/packages/emails/templates/organizer-requested-to-reschedule-email.ts +++ b/packages/emails/templates/organizer-requested-to-reschedule-email.ts @@ -7,6 +7,7 @@ import { APP_NAME } from "@calcom/lib/constants"; import type { CalendarEvent } from "@calcom/types/Calendar"; import { renderEmail } from ".."; +import generateIcsString from "../lib/generateIcsString"; import OrganizerScheduledEmail from "./organizer-scheduled-email"; export default class OrganizerRequestedToRescheduleEmail extends OrganizerScheduledEmail { @@ -21,7 +22,18 @@ export default class OrganizerRequestedToRescheduleEmail extends OrganizerSchedu return { icalEvent: { filename: "event.ics", - content: this.getiCalEventAsString(), + content: generateIcsString({ + event: this.calEvent, + title: this.t("request_reschedule_title_organizer", { + attendee: this.calEvent.attendees[0].name, + }), + subtitle: this.t("request_reschedule_subtitle_organizer", { + attendee: this.calEvent.attendees[0].name, + }), + role: "organizer", + status: "CANCELLED", + }), + method: "REQUEST", }, from: `${APP_NAME} <${this.getMailerOptions().from}>`, to: toAddresses.join(","), @@ -83,13 +95,11 @@ export default class OrganizerRequestedToRescheduleEmail extends OrganizerSchedu } // @OVERRIDE - protected getTextBody(title = "", subtitle = "", extraInfo = "", callToAction = ""): string { + protected getTextBody(title = "", subtitle = ""): string { return ` ${this.t(title)} ${this.t(subtitle)} -${extraInfo} -${getRichDescription(this.calEvent)} -${callToAction} +${getRichDescription(this.calEvent, this.t, true)} `.trim(); } } diff --git a/packages/emails/templates/organizer-rescheduled-email.ts b/packages/emails/templates/organizer-rescheduled-email.ts index 9dfa5fe9ba..ed63aa8fcc 100644 --- a/packages/emails/templates/organizer-rescheduled-email.ts +++ b/packages/emails/templates/organizer-rescheduled-email.ts @@ -1,6 +1,7 @@ import { APP_NAME } from "@calcom/lib/constants"; import { renderEmail } from "../"; +import generateIcsString from "../lib/generateIcsString"; import OrganizerScheduledEmail from "./organizer-scheduled-email"; export default class OrganizerRescheduledEmail extends OrganizerScheduledEmail { @@ -10,7 +11,14 @@ export default class OrganizerRescheduledEmail extends OrganizerScheduledEmail { return { icalEvent: { filename: "event.ics", - content: this.getiCalEventAsString(), + content: generateIcsString({ + event: this.calEvent, + title: this.t("event_type_has_been_rescheduled"), + subtitle: this.t("emailed_you_and_any_other_attendees"), + role: "organizer", + status: "CONFIRMED", + }), + method: "REQUEST", }, from: `${APP_NAME} <${this.getMailerOptions().from}>`, to: toAddresses.join(","), diff --git a/packages/emails/templates/organizer-scheduled-email.ts b/packages/emails/templates/organizer-scheduled-email.ts index bd1a490e39..5e8e27f283 100644 --- a/packages/emails/templates/organizer-scheduled-email.ts +++ b/packages/emails/templates/organizer-scheduled-email.ts @@ -1,17 +1,14 @@ -import type { DateArray } from "ics"; -import { createEvent } from "ics"; // eslint-disable-next-line no-restricted-imports import { cloneDeep } from "lodash"; import type { TFunction } from "next-i18next"; -import { RRule } from "rrule"; -import dayjs from "@calcom/dayjs"; import { getRichDescription } from "@calcom/lib/CalEventParser"; import { APP_NAME } from "@calcom/lib/constants"; import { TimeFormat } from "@calcom/lib/timeFormat"; import type { CalendarEvent, Person } from "@calcom/types/Calendar"; import { renderEmail } from "../"; +import generateIcsString from "../lib/generateIcsString"; import BaseEmail from "./_base-email"; export default class OrganizerScheduledEmail extends BaseEmail { @@ -29,47 +26,6 @@ export default class OrganizerScheduledEmail extends BaseEmail { this.teamMember = input.teamMember; } - protected getiCalEventAsString(): string | undefined { - // Taking care of recurrence rule - let recurrenceRule: string | undefined = undefined; - if (this.calEvent.recurringEvent?.count) { - // ics appends "RRULE:" already, so removing it from RRule generated string - recurrenceRule = new RRule(this.calEvent.recurringEvent).toString().replace("RRULE:", ""); - } - const icsEvent = createEvent({ - uid: this.calEvent.iCalUID || this.calEvent.uid!, - start: dayjs(this.calEvent.startTime) - .utc() - .toArray() - .slice(0, 6) - .map((v, i) => (i === 1 ? v + 1 : v)) as DateArray, - startInputType: "utc", - productId: "calcom/ics", - title: this.calEvent.title, - 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 }, - ...{ recurrenceRule }, - attendees: [ - ...this.calEvent.attendees.map((attendee: Person) => ({ - name: attendee.name, - email: attendee.email, - })), - ...(this.calEvent.team?.members - ? this.calEvent.team?.members.map((member: Person) => ({ - name: member.name, - email: member.email, - })) - : []), - ], - status: "CONFIRMED", - }); - if (icsEvent.error) { - throw icsEvent.error; - } - return icsEvent.value; - } - protected async getNodeMailerPayload(): Promise> { const clonedCalEvent = cloneDeep(this.calEvent); const toAddresses = [this.teamMember?.email || this.calEvent.organizer.email]; @@ -77,7 +33,16 @@ export default class OrganizerScheduledEmail extends BaseEmail { return { icalEvent: { filename: "event.ics", - content: this.getiCalEventAsString(), + content: generateIcsString({ + event: this.calEvent, + title: this.calEvent.recurringEvent?.count + ? this.t("new_event_scheduled_recurring") + : this.t("new_event_scheduled"), + subtitle: this.t("emailed_you_and_any_other_attendees"), + role: "organizer", + status: "CONFIRMED", + }), + method: "REQUEST", }, from: `${APP_NAME} <${this.getMailerOptions().from}>`, to: toAddresses.join(","), @@ -105,7 +70,7 @@ ${this.t( )} ${this.t(subtitle)} ${extraInfo} -${getRichDescription(this.calEvent)} +${getRichDescription(this.calEvent, this.t, true)} ${callToAction} `.trim(); } diff --git a/packages/features/bookings/lib/handleCancelBooking.ts b/packages/features/bookings/lib/handleCancelBooking.ts index b71c9aa06d..e81805ca59 100644 --- a/packages/features/bookings/lib/handleCancelBooking.ts +++ b/packages/features/bookings/lib/handleCancelBooking.ts @@ -115,6 +115,8 @@ async function getBookingToDelete(id: number | undefined, uid: string | undefine scheduledJobs: true, seatsReferences: true, responses: true, + iCalUID: true, + iCalSequence: true, }, }); } @@ -264,6 +266,8 @@ async function handler(req: CustomRequest) { }), seatsPerTimeSlot: bookingToDelete.eventType?.seatsPerTimeSlot, seatsShowAttendees: bookingToDelete.eventType?.seatsShowAttendees, + iCalUID: bookingToDelete.iCalUID, + iCalSequence: bookingToDelete.iCalSequence + 1, }; const dataForWebhooks = { evt, webhooks, eventTypeInfo }; @@ -390,6 +394,8 @@ async function handler(req: CustomRequest) { data: { status: BookingStatus.CANCELLED, cancellationReason: cancellationReason, + // Assume that canceling the booking is the last action + iCalSequence: evt.iCalSequence || 100, }, select: { startTime: true, diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index bab66377a4..6a025000da 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -37,6 +37,7 @@ import { sendScheduledEmails, sendScheduledSeatsEmails, } from "@calcom/emails"; +import getICalUID from "@calcom/emails/lib/getICalUID"; import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields"; import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses"; import { handleWebhookTrigger } from "@calcom/features/bookings/lib/handleWebhookTrigger"; @@ -717,6 +718,7 @@ async function createBooking({ }, dynamicEventSlugRef, dynamicGroupSlugRef, + iCalUID: evt.iCalUID ?? "", user: { connect: { id: organizerUser.id, @@ -823,6 +825,21 @@ function getCustomInputsResponses( return customInputsResponses; } +function getICalSequence(originalRescheduledBooking: BookingType | null) { + // If new booking set the sequence to 0 + if (!originalRescheduledBooking) { + return 0; + } + + // If rescheduling and there is no sequence set, assume sequence should be 1 + if (!originalRescheduledBooking.iCalSequence) { + return 1; + } + + // If rescheduling then increment sequence by 1 + return originalRescheduledBooking.iCalSequence + 1; +} + async function handler( req: NextApiRequest & { userId?: number | undefined }, { @@ -1247,6 +1264,13 @@ async function handler( const calEventUserFieldsResponses = "calEventUserFieldsResponses" in reqBody ? reqBody.calEventUserFieldsResponses : null; + const iCalUID = getICalUID({ + event: { iCalUID: originalRescheduledBooking?.iCalUID, uid: originalRescheduledBooking?.uid }, + uid, + }); + // For bookings made before introducing iCalSequence, assume that the sequence should start at 1. For new bookings start at 0. + const iCalSequence = getICalSequence(originalRescheduledBooking); + let evt: CalendarEvent = { bookerUrl: await getBookerUrl(organizerUser), type: eventType.slug, @@ -1283,6 +1307,8 @@ async function handler( seatsPerTimeSlot: eventType.seatsPerTimeSlot, seatsShowAvailabilityCount: eventType.seatsPerTimeSlot ? eventType.seatsShowAvailabilityCount : true, schedulingType: eventType.schedulingType, + iCalUID, + iCalSequence, }; if (isTeamEventType && eventType.schedulingType === "COLLECTIVE") { @@ -2488,6 +2514,18 @@ async function handler( evt.appsStatus = handleAppsStatus(results, booking); videoCallUrl = metadata.hangoutLink || organizerOrFirstDynamicGroupMemberDefaultLocationUrl || videoCallUrl; + + if (evt.iCalUID !== booking.iCalUID) { + // The eventManager could change the iCalUID. At this point we can update the DB record + await prisma.booking.update({ + where: { + id: booking.id, + }, + data: { + iCalUID: evt.iCalUID || booking.iCalUID, + }, + }); + } } if (noEmail !== true) { let isHostConfirmationEmailsDisabled = false; diff --git a/packages/features/bookings/lib/handleNewBooking/test/fresh-booking.test.ts b/packages/features/bookings/lib/handleNewBooking/test/fresh-booking.test.ts index bb8189d8d9..199eae4436 100644 --- a/packages/features/bookings/lib/handleNewBooking/test/fresh-booking.test.ts +++ b/packages/features/bookings/lib/handleNewBooking/test/fresh-booking.test.ts @@ -51,6 +51,7 @@ import { expectBookingPaymentIntiatedWebhookToHaveBeenFired, expectBrokenIntegrationEmails, expectSuccessfulCalendarEventCreationInCalendar, + expectICalUIDAsString, } from "@calcom/web/test/utils/bookingScenario/expects"; import { getMockRequestDataForBooking } from "@calcom/web/test/utils/bookingScenario/getMockRequestDataForBooking"; import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAndTeardown"; @@ -141,7 +142,6 @@ describe("handleNewBooking", () => { const calendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", { create: { id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", - iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", }, }); @@ -162,6 +162,7 @@ describe("handleNewBooking", () => { }); const createdBooking = await handleNewBooking(req); + expect(createdBooking.responses).toContain({ email: booker.email, name: booker.name, @@ -193,6 +194,7 @@ describe("handleNewBooking", () => { meetingUrl: "https://UNUSED_URL", }, ], + iCalUID: createdBooking.iCalUID, }); expectWorkflowToBeTriggered(); @@ -201,6 +203,8 @@ describe("handleNewBooking", () => { videoCallUrl: "http://mock-dailyvideo.example.com/meeting-1", }); + const iCalUID = expectICalUIDAsString(createdBooking.iCalUID); + expectSuccessfulBookingCreationEmails({ booking: { uid: createdBooking.uid!, @@ -209,7 +213,7 @@ describe("handleNewBooking", () => { booker, organizer, emails, - iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", + iCalUID, }); expectBookingCreatedWebhookToHaveBeenFired({ @@ -290,7 +294,6 @@ describe("handleNewBooking", () => { create: { id: "GOOGLE_CALENDAR_EVENT_ID", uid: "MOCK_ID", - iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", }, }); @@ -342,6 +345,7 @@ describe("handleNewBooking", () => { meetingUrl: "https://UNUSED_URL", }, ], + iCalUID: createdBooking.iCalUID, }); expectWorkflowToBeTriggered(); @@ -353,6 +357,8 @@ describe("handleNewBooking", () => { calendarId: null, }); + const iCalUID = expectICalUIDAsString(createdBooking.iCalUID); + expectSuccessfulBookingCreationEmails({ booking: { uid: createdBooking.uid!, @@ -360,7 +366,7 @@ describe("handleNewBooking", () => { booker, organizer, emails, - iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", + iCalUID, }); expectBookingCreatedWebhookToHaveBeenFired({ booker, @@ -441,7 +447,6 @@ describe("handleNewBooking", () => { create: { uid: "MOCK_ID", id: "GOOGLE_CALENDAR_EVENT_ID", - iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", }, }); @@ -493,6 +498,7 @@ describe("handleNewBooking", () => { meetingUrl: "https://UNUSED_URL", }, ], + iCalUID: createdBooking.iCalUID, }); expectWorkflowToBeTriggered(); @@ -501,6 +507,8 @@ describe("handleNewBooking", () => { videoCallUrl: "http://mock-dailyvideo.example.com/meeting-1", }); + const iCalUID = expectICalUIDAsString(createdBooking.iCalUID); + expectSuccessfulBookingCreationEmails({ booking: { uid: createdBooking.uid!, @@ -508,7 +516,7 @@ describe("handleNewBooking", () => { booker, organizer, emails, - iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", + iCalUID, }); expectBookingCreatedWebhookToHaveBeenFired({ @@ -705,7 +713,6 @@ describe("handleNewBooking", () => { create: { uid: "MOCK_ID", id: "GOOGLE_CALENDAR_EVENT_ID", - iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", }, }); @@ -757,6 +764,7 @@ describe("handleNewBooking", () => { meetingUrl: "https://UNUSED_URL", }, ], + iCalUID: createdBooking.iCalUID, }); expectWorkflowToBeTriggered(); @@ -765,6 +773,8 @@ describe("handleNewBooking", () => { videoCallUrl: "http://mock-dailyvideo.example.com/meeting-1", }); + const iCalUID = expectICalUIDAsString(createdBooking.iCalUID); + expectSuccessfulBookingCreationEmails({ booking: { uid: createdBooking.uid!, @@ -772,7 +782,7 @@ describe("handleNewBooking", () => { booker, organizer, emails, - iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", + iCalUID, }); expectBookingCreatedWebhookToHaveBeenFired({ @@ -998,6 +1008,8 @@ describe("handleNewBooking", () => { }); const createdBooking = await handleNewBooking(req); + const iCalUID = expectICalUIDAsString(createdBooking.iCalUID); + expectSuccessfulBookingCreationEmails({ booking: { uid: createdBooking.uid!, @@ -1005,8 +1017,7 @@ describe("handleNewBooking", () => { booker, organizer, emails, - // Because no calendar was involved, we don't have an ics UID - iCalUID: createdBooking.uid!, + iCalUID, }); expectBookingCreatedWebhookToHaveBeenFired({ @@ -1561,11 +1572,7 @@ describe("handleNewBooking", () => { metadataLookupKey: "dailyvideo", }); - mockCalendarToHaveNoBusySlots("googlecalendar", { - create: { - iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", - }, - }); + mockCalendarToHaveNoBusySlots("googlecalendar", {}); const mockBookingData = getMockRequestDataForBooking({ data: { @@ -1599,10 +1606,13 @@ describe("handleNewBooking", () => { uid: createdBooking.uid!, eventTypeId: mockBookingData.eventTypeId, status: BookingStatus.ACCEPTED, + iCalUID: createdBooking.iCalUID, }); expectWorkflowToBeTriggered(); + const iCalUID = expectICalUIDAsString(createdBooking.iCalUID); + expectSuccessfulBookingCreationEmails({ booking: { uid: createdBooking.uid!, @@ -1610,7 +1620,7 @@ describe("handleNewBooking", () => { booker, organizer, emails, - iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", + iCalUID, }); expectBookingCreatedWebhookToHaveBeenFired({ @@ -1686,11 +1696,7 @@ describe("handleNewBooking", () => { metadataLookupKey: "dailyvideo", }); - mockCalendarToHaveNoBusySlots("googlecalendar", { - create: { - iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", - }, - }); + mockCalendarToHaveNoBusySlots("googlecalendar", {}); const mockBookingData = getMockRequestDataForBooking({ data: { @@ -1724,6 +1730,7 @@ describe("handleNewBooking", () => { uid: createdBooking.uid!, eventTypeId: mockBookingData.eventTypeId, status: BookingStatus.PENDING, + iCalUID: createdBooking.iCalUID, }); expectWorkflowToBeTriggered(); @@ -1873,11 +1880,7 @@ describe("handleNewBooking", () => { apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], }); - mockCalendarToHaveNoBusySlots("googlecalendar", { - create: { - iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", - }, - }); + mockCalendarToHaveNoBusySlots("googlecalendar", {}); await createBookingScenario(scenarioData); const createdBooking = await handleNewBooking(req); @@ -1896,10 +1899,13 @@ describe("handleNewBooking", () => { uid: createdBooking.uid!, eventTypeId: mockBookingData.eventTypeId, status: BookingStatus.ACCEPTED, + iCalUID: createdBooking.iCalUID, }); expectWorkflowToBeTriggered(); + const iCalUID = expectICalUIDAsString(createdBooking.iCalUID); + expectSuccessfulBookingCreationEmails({ booking: { uid: createdBooking.uid!, @@ -1907,7 +1913,7 @@ describe("handleNewBooking", () => { booker, organizer, emails, - iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", + iCalUID, }); expectBookingCreatedWebhookToHaveBeenFired({ booker, diff --git a/packages/features/bookings/lib/handleNewBooking/test/reschedule.test.ts b/packages/features/bookings/lib/handleNewBooking/test/reschedule.test.ts index fca448bf2d..a88d9e95c0 100644 --- a/packages/features/bookings/lib/handleNewBooking/test/reschedule.test.ts +++ b/packages/features/bookings/lib/handleNewBooking/test/reschedule.test.ts @@ -75,6 +75,7 @@ describe("handleNewBooking", () => { const { dateString: plus1DateString } = getDate({ dateIncrement: 1 }); const uidOfBookingToBeRescheduled = "n5Wv3eHgconAED2j4gcVhP"; + const iCalUID = `${uidOfBookingToBeRescheduled}@Cal.com`; await createBookingScenario( getScenarioData({ webhooks: [ @@ -128,6 +129,7 @@ describe("handleNewBooking", () => { credentialId: undefined, }, ], + iCalUID, }, ], organizer, @@ -145,7 +147,7 @@ describe("handleNewBooking", () => { }, update: { uid: "UPDATED_MOCK_ID", - iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", + iCalUID, }, }); @@ -259,7 +261,7 @@ describe("handleNewBooking", () => { booker, organizer, emails, - iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", + iCalUID, appsStatus: [ getMockPassingAppStatus({ slug: appStoreMetadata.dailyvideo.slug }), getMockPassingAppStatus({ slug: appStoreMetadata.googlecalendar.slug }), @@ -278,7 +280,7 @@ describe("handleNewBooking", () => { ); test( - `should rechedule a booking successfully and update the event in the same externalCalendarId as was used in the booking earlier. + `should reschedule a booking successfully and update the event in the same externalCalendarId as was used in the booking earlier. 1. Should cancel the existing booking 2. Should create a new booking in the database 3. Should send emails to the booker as well as organizer @@ -302,6 +304,7 @@ describe("handleNewBooking", () => { const { dateString: plus1DateString } = getDate({ dateIncrement: 1 }); const uidOfBookingToBeRescheduled = "n5Wv3eHgconAED2j4gcVhP"; + const iCalUID = `${uidOfBookingToBeRescheduled}@Cal.com`; await createBookingScenario( getScenarioData({ webhooks: [ @@ -355,6 +358,7 @@ describe("handleNewBooking", () => { credentialId: undefined, }, ], + iCalUID, }, ], organizer, @@ -371,7 +375,7 @@ describe("handleNewBooking", () => { uid: "MOCK_ID", }, update: { - iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", + iCalUID, uid: "UPDATED_MOCK_ID", }, }); @@ -467,7 +471,7 @@ describe("handleNewBooking", () => { booker, organizer, emails, - iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", + iCalUID, }); expectBookingRescheduledWebhookToHaveBeenFired({ booker, @@ -1117,6 +1121,7 @@ describe("handleNewBooking", () => { }); const { dateString: plus1DateString } = getDate({ dateIncrement: 1 }); const uidOfBookingToBeRescheduled = "n5Wv3eHgconAED2j4gcVhP"; + const iCalUID = `${uidOfBookingToBeRescheduled}@Cal.com`; const scenarioData = getScenarioData({ webhooks: [ @@ -1168,6 +1173,7 @@ describe("handleNewBooking", () => { credentialId: 1, }), ], + iCalUID, }, ], organizer, diff --git a/packages/features/ee/api-keys/components/ApiKeyDialogForm.tsx b/packages/features/ee/api-keys/components/ApiKeyDialogForm.tsx index 5f6cd79713..a99b94cebd 100644 --- a/packages/features/ee/api-keys/components/ApiKeyDialogForm.tsx +++ b/packages/features/ee/api-keys/components/ApiKeyDialogForm.tsx @@ -202,10 +202,12 @@ export default function ApiKeyDialogForm({ ); }} /> - {!watchNeverExpires && - {t("api_key_expires_on")} - {dayjs(expiryDate).format("DD-MM-YYYY")} - } + {!watchNeverExpires && ( + + {t("api_key_expires_on")} + {dayjs(expiryDate).format("DD-MM-YYYY")} + + )} )} diff --git a/packages/lib/CalEventParser.ts b/packages/lib/CalEventParser.ts index 29ac3ab171..55c569fcc3 100644 --- a/packages/lib/CalEventParser.ts +++ b/packages/lib/CalEventParser.ts @@ -176,7 +176,11 @@ export const getRescheduleLink = (calEvent: CalendarEvent): string => { return `${calEvent.bookerUrl ?? WEBAPP_URL}/reschedule/${seatUid ? seatUid : Uid}`; }; -export const getRichDescription = (calEvent: CalendarEvent, t_?: TFunction /*, attendee?: Person*/) => { +export const getRichDescription = ( + calEvent: CalendarEvent, + t_?: TFunction /*, attendee?: Person*/, + includeAppStatus = false +) => { const t = t_ ?? calEvent.organizer.language.translate; return ` @@ -189,7 +193,7 @@ ${getLocation(calEvent)} ${getDescription(calEvent, t)} ${getAdditionalNotes(calEvent, t)} ${getUserFieldsResponses(calEvent)} -${getAppsStatus(calEvent, t)} +${includeAppStatus ? getAppsStatus(calEvent, t) : ""} ${ // TODO: Only the original attendee can make changes to the event // Guests cannot diff --git a/packages/lib/test/CalEventParser.test.ts b/packages/lib/test/CalEventParser.test.ts index c90a8daa15..064efaf47e 100644 --- a/packages/lib/test/CalEventParser.test.ts +++ b/packages/lib/test/CalEventParser.test.ts @@ -11,6 +11,7 @@ import { buildCalendarEvent, buildVideoCallData } from "./builder"; vi.mock("@calcom/lib/constants", () => ({ WEBAPP_URL: "http://localhost:3000", + APP_NAME: "Cal.com", })); vi.mock("short-uuid", () => ({ diff --git a/packages/lib/test/builder.ts b/packages/lib/test/builder.ts index f766d74578..6f9ced75a2 100644 --- a/packages/lib/test/builder.ts +++ b/packages/lib/test/builder.ts @@ -2,6 +2,7 @@ import { faker } from "@faker-js/faker"; import type { Booking, EventType, Prisma, Webhook } from "@prisma/client"; import type { TFunction } from "next-i18next"; +import getICalUID from "@calcom/emails/lib/getICalUID"; import { BookingStatus } from "@calcom/prisma/enums"; import type { CalendarEvent, Person, VideoCallData } from "@calcom/types/Calendar"; @@ -31,9 +32,10 @@ export const buildPerson = (person?: Partial): Person => { }; export const buildBooking = (booking?: Partial): Booking => { + const uid = faker.datatype.uuid(); return { id: faker.datatype.number(), - uid: faker.datatype.uuid(), + uid, userId: null, eventTypeId: null, title: faker.lorem.sentence(), @@ -59,6 +61,8 @@ export const buildBooking = (booking?: Partial): Booking => { metadata: null, responses: null, isRecorded: false, + iCalUID: getICalUID({ uid }), + iCalSequence: 0, ...booking, }; }; @@ -155,8 +159,10 @@ export const buildSubscriberEvent = (booking?: Partial) => { }; export const buildCalendarEvent = (event?: Partial): CalendarEvent => { + const uid = faker.datatype.uuid(); return { - uid: faker.datatype.uuid(), + uid, + iCalUID: getICalUID({ uid }), type: faker.helpers.arrayElement(["event", "meeting"]), title: faker.lorem.sentence(), startTime: faker.date.future().toISOString(), diff --git a/packages/prisma/migrations/20231113202947_add_ical_columns_to_booking/migration.sql b/packages/prisma/migrations/20231113202947_add_ical_columns_to_booking/migration.sql new file mode 100644 index 0000000000..9095baefe5 --- /dev/null +++ b/packages/prisma/migrations/20231113202947_add_ical_columns_to_booking/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "Booking" ADD COLUMN "iCalSequence" INTEGER NOT NULL DEFAULT 0, +ADD COLUMN "iCalUID" TEXT DEFAULT ''; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 70b3821e63..7c959a8e05 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -433,6 +433,8 @@ model Booking { /// @zod.custom(imports.bookingMetadataSchema) metadata Json? isRecorded Boolean @default(false) + iCalUID String? @default("") + iCalSequence Int @default(0) @@index([eventTypeId]) @@index([userId]) diff --git a/packages/prisma/seed-utils.ts b/packages/prisma/seed-utils.ts index 31f5768429..fbcb07c3a8 100644 --- a/packages/prisma/seed-utils.ts +++ b/packages/prisma/seed-utils.ts @@ -133,6 +133,7 @@ export async function createUserAndEventType({ }, }, status: bookingInput.status, + iCalUID: "", }, }); console.log( diff --git a/packages/trpc/server/routers/viewer/bookings/requestReschedule.handler.ts b/packages/trpc/server/routers/viewer/bookings/requestReschedule.handler.ts index 29ed6c12d2..d93ff40e91 100644 --- a/packages/trpc/server/routers/viewer/bookings/requestReschedule.handler.ts +++ b/packages/trpc/server/routers/viewer/bookings/requestReschedule.handler.ts @@ -65,6 +65,7 @@ export const requestRescheduleHandler = async ({ ctx, input }: RequestReschedule scheduledJobs: true, workflowReminders: true, responses: true, + iCalUID: true, }, where: { uid: bookingId, @@ -186,6 +187,7 @@ export const requestRescheduleHandler = async ({ ctx, input }: RequestReschedule tAttendees ), organizer: userAsPeopleType, + iCalUID: bookingToReschedule.iCalUID, }); const director = new CalendarEventDirector(); @@ -257,6 +259,7 @@ export const requestRescheduleHandler = async ({ ctx, input }: RequestReschedule ? [bookingToReschedule?.destinationCalendar] : [], cancellationReason: `Please reschedule. ${cancellationReason}`, // TODO::Add i18-next for this + iCalUID: bookingToReschedule?.iCalUID, }; // Send webhook diff --git a/packages/types/Calendar.d.ts b/packages/types/Calendar.d.ts index 833855250c..811cfc923d 100644 --- a/packages/types/Calendar.d.ts +++ b/packages/types/Calendar.d.ts @@ -185,6 +185,7 @@ export interface CalendarEvent { seatsPerTimeSlot?: number | null; schedulingType?: SchedulingType | null; iCalUID?: string | null; + iCalSequence?: number | null; // It has responses to all the fields(system + user) responses?: CalEventResponses | null;