Skip to content

Commit b285f27

Browse files
montocoderSMLukwiyaUdit Takkar
authored
feat: option for adding ics events to workflow reminders (#10856)
Co-authored-by: SMLukwiya <[email protected]> Co-authored-by: Monto <[email protected]> Co-authored-by: Udit Takkar <[email protected]>
1 parent 1fa87ae commit b285f27

File tree

13 files changed

+192
-10
lines changed

13 files changed

+192
-10
lines changed

apps/web/public/static/locales/en/common.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2026,5 +2026,6 @@
20262026
"value": "Value",
20272027
"your_organization_updated_sucessfully": "Your organization updated successfully",
20282028
"seat_options_doesnt_multiple_durations": "Seat option doesn't support multiple durations",
2029+
"include_calendar_event": "Include calendar event",
20292030
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
20302031
}

packages/features/ee/workflows/api/scheduleEmailReminders.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
/* Schedule any workflow reminder that falls within 72 hours for email */
2+
import type { Prisma } from "@prisma/client";
23
import client from "@sendgrid/client";
34
import sgMail from "@sendgrid/mail";
5+
import { createEvent } from "ics";
6+
import type { DateArray } from "ics";
47
import type { NextApiRequest, NextApiResponse } from "next";
8+
import { RRule } from "rrule";
9+
import { v4 as uuidv4 } from "uuid";
510

611
import dayjs from "@calcom/dayjs";
712
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
13+
import { parseRecurringEvent } from "@calcom/lib";
814
import { defaultHandler } from "@calcom/lib/server";
915
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
1016
import prisma from "@calcom/prisma";
@@ -20,6 +26,65 @@ const senderEmail = process.env.SENDGRID_EMAIL as string;
2026

2127
sgMail.setApiKey(sendgridAPIKey);
2228

29+
type Booking = Prisma.BookingGetPayload<{
30+
include: {
31+
eventType: true;
32+
user: true;
33+
attendees: true;
34+
};
35+
}>;
36+
37+
function getiCalEventAsString(booking: Booking) {
38+
let recurrenceRule: string | undefined = undefined;
39+
const recurringEvent = parseRecurringEvent(booking.eventType?.recurringEvent);
40+
if (recurringEvent?.count) {
41+
recurrenceRule = new RRule(recurringEvent).toString().replace("RRULE:", "");
42+
}
43+
44+
const uid = uuidv4();
45+
46+
const icsEvent = createEvent({
47+
uid,
48+
startInputType: "utc",
49+
start: dayjs(booking.startTime.toISOString() || "")
50+
.utc()
51+
.toArray()
52+
.slice(0, 6)
53+
.map((v, i) => (i === 1 ? v + 1 : v)) as DateArray,
54+
duration: {
55+
minutes: dayjs(booking.endTime.toISOString() || "").diff(
56+
dayjs(booking.startTime.toISOString() || ""),
57+
"minute"
58+
),
59+
},
60+
title: booking.eventType?.title || "",
61+
description: booking.description || "",
62+
location: booking.location || "",
63+
organizer: {
64+
email: booking.user?.email || "",
65+
name: booking.user?.name || "",
66+
},
67+
attendees: [
68+
{
69+
name: booking.attendees[0].name,
70+
email: booking.attendees[0].email,
71+
partstat: "ACCEPTED",
72+
role: "REQ-PARTICIPANT",
73+
rsvp: true,
74+
},
75+
],
76+
method: "REQUEST",
77+
...{ recurrenceRule },
78+
status: "CONFIRMED",
79+
});
80+
81+
if (icsEvent.error) {
82+
throw icsEvent.error;
83+
}
84+
85+
return icsEvent.value;
86+
}
87+
2388
async function handler(req: NextApiRequest, res: NextApiResponse) {
2489
const apiKey = req.headers.authorization || req.query.apiKey;
2590
if (process.env.CRON_API_KEY !== apiKey) {
@@ -258,6 +323,17 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
258323
enable: sandboxMode,
259324
},
260325
},
326+
attachments: reminder.workflowStep.includeCalendarEvent
327+
? [
328+
{
329+
content: Buffer.from(getiCalEventAsString(reminder.booking) || "").toString("base64"),
330+
filename: "event.ics",
331+
type: "text/calendar; method=REQUEST",
332+
disposition: "attachment",
333+
contentId: uuidv4(),
334+
},
335+
]
336+
: undefined,
261337
});
262338
}
263339

packages/features/ee/workflows/components/WorkflowDetailsPage.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ export default function WorkflowDetailsPage(props: Props) {
113113
sender: isSMSAction(action) ? sender || SENDER_ID : SENDER_ID,
114114
senderName: !isSMSAction(action) ? senderName || SENDER_NAME : SENDER_NAME,
115115
numberVerificationPending: false,
116+
includeCalendarEvent: false,
116117
};
117118
steps?.push(step);
118119
form.setValue("steps", steps);

packages/features/ee/workflows/components/WorkflowStepContainer.tsx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -861,6 +861,29 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
861861
{form.formState?.errors?.steps[step.stepNumber - 1]?.reminderBody?.message || ""}
862862
</p>
863863
)}
864+
{isEmailSubjectNeeded && (
865+
<div className="mt-2">
866+
<Controller
867+
name={`steps.${step.stepNumber - 1}.includeCalendarEvent`}
868+
control={form.control}
869+
render={() => (
870+
<CheckboxField
871+
disabled={props.readOnly}
872+
defaultChecked={
873+
form.getValues(`steps.${step.stepNumber - 1}.includeCalendarEvent`) || false
874+
}
875+
description={t("include_calendar_event")}
876+
onChange={(e) =>
877+
form.setValue(
878+
`steps.${step.stepNumber - 1}.includeCalendarEvent`,
879+
e.target.checked
880+
)
881+
}
882+
/>
883+
)}
884+
/>
885+
</div>
886+
)}
864887
{!props.readOnly && (
865888
<div className="mt-3 ">
866889
<button type="button" onClick={() => setIsAdditionalInputsDialogOpen(true)}>

packages/features/ee/workflows/lib/reminders/emailReminderManager.ts

Lines changed: 77 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
import client from "@sendgrid/client";
22
import type { MailData } from "@sendgrid/helpers/classes/mail";
33
import sgMail from "@sendgrid/mail";
4+
import { createEvent } from "ics";
5+
import type { ParticipationStatus } from "ics";
6+
import type { DateArray } from "ics";
7+
import { RRule } from "rrule";
8+
import { v4 as uuidv4 } from "uuid";
49

510
import dayjs from "@calcom/dayjs";
11+
import { preprocessNameFieldDataWithVariant } from "@calcom/features/form-builder/utils";
612
import logger from "@calcom/lib/logger";
713
import prisma from "@calcom/prisma";
814
import type { TimeUnit } from "@calcom/prisma/enums";
@@ -42,6 +48,47 @@ async function getBatchId() {
4248
return batchIdResponse[1].batch_id as string;
4349
}
4450

51+
function getiCalEventAsString(evt: BookingInfo, status?: ParticipationStatus) {
52+
const uid = uuidv4();
53+
let recurrenceRule: string | undefined = undefined;
54+
if (evt.eventType.recurringEvent?.count) {
55+
recurrenceRule = new RRule(evt.eventType.recurringEvent).toString().replace("RRULE:", "");
56+
}
57+
58+
const icsEvent = createEvent({
59+
uid,
60+
startInputType: "utc",
61+
start: dayjs(evt.startTime)
62+
.utc()
63+
.toArray()
64+
.slice(0, 6)
65+
.map((v, i) => (i === 1 ? v + 1 : v)) as DateArray,
66+
duration: { minutes: dayjs(evt.endTime).diff(dayjs(evt.startTime), "minute") },
67+
title: evt.title,
68+
description: evt.additionalNotes || "",
69+
location: evt.location || "",
70+
organizer: { email: evt.organizer.email || "", name: evt.organizer.name },
71+
attendees: [
72+
{
73+
name: preprocessNameFieldDataWithVariant("fullName", evt.attendees[0].name) as string,
74+
email: evt.attendees[0].email,
75+
partstat: status,
76+
role: "REQ-PARTICIPANT",
77+
rsvp: true,
78+
},
79+
],
80+
method: "REQUEST",
81+
...{ recurrenceRule },
82+
status: "CONFIRMED",
83+
});
84+
85+
if (icsEvent.error) {
86+
throw icsEvent.error;
87+
}
88+
89+
return icsEvent.value;
90+
}
91+
4592
type ScheduleEmailReminderAction = Extract<
4693
WorkflowActions,
4794
"EMAIL_HOST" | "EMAIL_ATTENDEE" | "EMAIL_ADDRESS"
@@ -62,7 +109,8 @@ export const scheduleEmailReminder = async (
62109
template: WorkflowTemplates,
63110
sender: string,
64111
hideBranding?: boolean,
65-
seatReferenceUid?: string
112+
seatReferenceUid?: string,
113+
includeCalendarEvent?: boolean
66114
) => {
67115
if (action === WorkflowActions.EMAIL_ADDRESS) return;
68116
const { startTime, endTime } = evt;
@@ -186,11 +234,19 @@ export const scheduleEmailReminder = async (
186234

187235
const batchId = await getBatchId();
188236

189-
function sendEmail(data: Partial<MailData>) {
237+
function sendEmail(data: Partial<MailData>, triggerEvent?: WorkflowTriggerEvents) {
190238
if (!process.env.SENDGRID_API_KEY) {
191239
console.info("No sendgrid API key provided, skipping email");
192240
return Promise.resolve();
193241
}
242+
243+
const status: ParticipationStatus =
244+
triggerEvent === WorkflowTriggerEvents.AFTER_EVENT
245+
? "COMPLETED"
246+
: triggerEvent === WorkflowTriggerEvents.EVENT_CANCELLED
247+
? "DECLINED"
248+
: "ACCEPTED";
249+
194250
return sgMail.send({
195251
to: data.to,
196252
from: {
@@ -206,6 +262,17 @@ export const scheduleEmailReminder = async (
206262
enable: sandboxMode,
207263
},
208264
},
265+
attachments: includeCalendarEvent
266+
? [
267+
{
268+
content: Buffer.from(getiCalEventAsString(evt, status) || "").toString("base64"),
269+
filename: "event.ics",
270+
type: "text/calendar; method=REQUEST",
271+
disposition: "attachment",
272+
contentId: uuidv4(),
273+
},
274+
]
275+
: undefined,
209276
sendAt: data.sendAt,
210277
});
211278
}
@@ -218,7 +285,7 @@ export const scheduleEmailReminder = async (
218285
try {
219286
if (!sendTo) throw new Error("No email addresses provided");
220287
const addressees = Array.isArray(sendTo) ? sendTo : [sendTo];
221-
const promises = addressees.map((email) => sendEmail({ to: email }));
288+
const promises = addressees.map((email) => sendEmail({ to: email }, triggerEvent));
222289
// TODO: Maybe don't await for this?
223290
await Promise.all(promises);
224291
} catch (error) {
@@ -237,10 +304,13 @@ export const scheduleEmailReminder = async (
237304
) {
238305
try {
239306
// If sendEmail failed then workflowReminer will not be created, failing E2E tests
240-
await sendEmail({
241-
to: sendTo,
242-
sendAt: scheduledDate.unix(),
243-
});
307+
await sendEmail(
308+
{
309+
to: sendTo,
310+
sendAt: scheduledDate.unix(),
311+
},
312+
triggerEvent
313+
);
244314
await prisma.workflowReminder.create({
245315
data: {
246316
bookingUid: uid,

packages/features/ee/workflows/lib/reminders/reminderScheduler.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,8 @@ const processWorkflowStep = async (
106106
step.template,
107107
step.sender || SENDER_NAME,
108108
hideBranding,
109-
seatReferenceUid
109+
seatReferenceUid,
110+
step.includeCalendarEvent
110111
);
111112
} else if (isWhatsappAction(step.action)) {
112113
const sendTo = step.action === WorkflowActions.WHATSAPP_ATTENDEE ? smsReminderNumber : step.sendTo;

packages/features/ee/workflows/lib/reminders/smsReminderManager.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import type { TimeUnit } from "@calcom/prisma/enums";
77
import { WorkflowTemplates, WorkflowActions, WorkflowMethods } from "@calcom/prisma/enums";
88
import { WorkflowTriggerEvents } from "@calcom/prisma/enums";
99
import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
10-
import type { CalEventResponses } from "@calcom/types/Calendar";
10+
import type { CalEventResponses, RecurringEvent } from "@calcom/types/Calendar";
1111

1212
import { getSenderId } from "../alphanumericSenderIdSupport";
1313
import * as twilio from "./smsProviders/twilioProvider";
@@ -44,6 +44,7 @@ export type BookingInfo = {
4444
};
4545
eventType: {
4646
slug?: string;
47+
recurringEvent?: RecurringEvent | null;
4748
};
4849
startTime: string;
4950
endTime: string;

packages/features/ee/workflows/pages/workflow.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ const formSchema = z.object({
6464
emailSubject: z.string().nullable(),
6565
template: z.nativeEnum(WorkflowTemplates),
6666
numberRequired: z.boolean().nullable(),
67+
includeCalendarEvent: z.boolean().nullable(),
6768
sendTo: z
6869
.string()
6970
.refine((val) => isValidPhoneNumber(val) || val.includes("@"))

packages/features/form-builder/utils.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,10 @@ export const getFullName = (name: string | { firstName: string; lastName?: strin
2424
if (typeof name === "string") {
2525
nameString = name;
2626
} else {
27-
nameString = name.firstName + " " + name.lastName;
27+
nameString = name.firstName;
28+
if (name.lastName) {
29+
nameString = nameString + " " + name.lastName;
30+
}
2831
}
2932
return nameString;
3033
};
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- AlterTable
2+
ALTER TABLE "WorkflowStep" ADD COLUMN "includeCalendarEvent" BOOLEAN NOT NULL DEFAULT false;

0 commit comments

Comments
 (0)