feat: Add consistent iCalUID (#12122)
Co-authored-by: Hariom <hariombalhara@gmail.com> Co-authored-by: Keith Williams <keithwillcode@gmail.com>
This commit is contained in:
parent
0e8ac7e4ed
commit
9dfa596e3e
|
@ -62,6 +62,7 @@ export const createBookingsFixture = (page: Page) => {
|
|||
rescheduled,
|
||||
paid,
|
||||
status,
|
||||
iCalUID: `${uid}@cal.com`,
|
||||
},
|
||||
});
|
||||
const bookingFixture = createBookingFixture(booking, store.page);
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
|
@ -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 });
|
||||
});
|
||||
});
|
|
@ -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}`);
|
||||
});
|
||||
});
|
|
@ -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<Record<string, unknown>> {
|
||||
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,
|
||||
|
|
|
@ -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}>`,
|
||||
|
|
|
@ -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}>`,
|
||||
|
|
|
@ -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<Record<string, unknown>> {
|
||||
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}>`,
|
||||
|
|
|
@ -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 `
|
||||
|
|
|
@ -53,7 +53,7 @@ ${this.t(
|
|||
)}
|
||||
${this.t(subtitle)}
|
||||
${extraInfo}
|
||||
${getRichDescription(this.calEvent)}
|
||||
${getRichDescription(this.calEvent, this.t, true)}
|
||||
${callToAction}
|
||||
`.trim();
|
||||
}
|
||||
|
|
|
@ -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", {
|
||||
|
|
|
@ -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(","),
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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(","),
|
||||
|
|
|
@ -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<Record<string, unknown>> {
|
||||
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();
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -202,10 +202,12 @@ export default function ApiKeyDialogForm({
|
|||
);
|
||||
}}
|
||||
/>
|
||||
{!watchNeverExpires && <span className="text-subtle mt-2 text-xs">
|
||||
{t("api_key_expires_on")}
|
||||
<span className="font-bold"> {dayjs(expiryDate).format("DD-MM-YYYY")}</span>
|
||||
</span>}
|
||||
{!watchNeverExpires && (
|
||||
<span className="text-subtle mt-2 text-xs">
|
||||
{t("api_key_expires_on")}
|
||||
<span className="font-bold"> {dayjs(expiryDate).format("DD-MM-YYYY")}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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", () => ({
|
||||
|
|
|
@ -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>): Person => {
|
|||
};
|
||||
|
||||
export const buildBooking = (booking?: Partial<Booking>): 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>): Booking => {
|
|||
metadata: null,
|
||||
responses: null,
|
||||
isRecorded: false,
|
||||
iCalUID: getICalUID({ uid }),
|
||||
iCalSequence: 0,
|
||||
...booking,
|
||||
};
|
||||
};
|
||||
|
@ -155,8 +159,10 @@ export const buildSubscriberEvent = (booking?: Partial<Booking>) => {
|
|||
};
|
||||
|
||||
export const buildCalendarEvent = (event?: Partial<CalendarEvent>): 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(),
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "Booking" ADD COLUMN "iCalSequence" INTEGER NOT NULL DEFAULT 0,
|
||||
ADD COLUMN "iCalUID" TEXT DEFAULT '';
|
|
@ -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])
|
||||
|
|
|
@ -133,6 +133,7 @@ export async function createUserAndEventType({
|
|||
},
|
||||
},
|
||||
status: bookingInput.status,
|
||||
iCalUID: "",
|
||||
},
|
||||
});
|
||||
console.log(
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Reference in New Issue
Block a user