feat: option for adding ics events to workflow reminders (#10856)
Co-authored-by: SMLukwiya <sundaymorganl@gmail.com> Co-authored-by: Monto <138862352+monto7926@users.noreply.github.com> Co-authored-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>
This commit is contained in:
co-authored by
SMLukwiya
Monto
Udit Takkar
parent
1fa87ae179
commit
b285f27d00
@@ -2026,5 +2026,6 @@
|
||||
"value": "Value",
|
||||
"your_organization_updated_sucessfully": "Your organization updated successfully",
|
||||
"seat_options_doesnt_multiple_durations": "Seat option doesn't support multiple durations",
|
||||
"include_calendar_event": "Include calendar event",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
/* Schedule any workflow reminder that falls within 72 hours for email */
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import client from "@sendgrid/client";
|
||||
import sgMail from "@sendgrid/mail";
|
||||
import { createEvent } from "ics";
|
||||
import type { DateArray } from "ics";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { RRule } from "rrule";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
|
||||
import { parseRecurringEvent } from "@calcom/lib";
|
||||
import { defaultHandler } from "@calcom/lib/server";
|
||||
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
|
||||
import prisma from "@calcom/prisma";
|
||||
@@ -20,6 +26,65 @@ const senderEmail = process.env.SENDGRID_EMAIL as string;
|
||||
|
||||
sgMail.setApiKey(sendgridAPIKey);
|
||||
|
||||
type Booking = Prisma.BookingGetPayload<{
|
||||
include: {
|
||||
eventType: true;
|
||||
user: true;
|
||||
attendees: true;
|
||||
};
|
||||
}>;
|
||||
|
||||
function getiCalEventAsString(booking: Booking) {
|
||||
let recurrenceRule: string | undefined = undefined;
|
||||
const recurringEvent = parseRecurringEvent(booking.eventType?.recurringEvent);
|
||||
if (recurringEvent?.count) {
|
||||
recurrenceRule = new RRule(recurringEvent).toString().replace("RRULE:", "");
|
||||
}
|
||||
|
||||
const uid = uuidv4();
|
||||
|
||||
const icsEvent = createEvent({
|
||||
uid,
|
||||
startInputType: "utc",
|
||||
start: dayjs(booking.startTime.toISOString() || "")
|
||||
.utc()
|
||||
.toArray()
|
||||
.slice(0, 6)
|
||||
.map((v, i) => (i === 1 ? v + 1 : v)) as DateArray,
|
||||
duration: {
|
||||
minutes: dayjs(booking.endTime.toISOString() || "").diff(
|
||||
dayjs(booking.startTime.toISOString() || ""),
|
||||
"minute"
|
||||
),
|
||||
},
|
||||
title: booking.eventType?.title || "",
|
||||
description: booking.description || "",
|
||||
location: booking.location || "",
|
||||
organizer: {
|
||||
email: booking.user?.email || "",
|
||||
name: booking.user?.name || "",
|
||||
},
|
||||
attendees: [
|
||||
{
|
||||
name: booking.attendees[0].name,
|
||||
email: booking.attendees[0].email,
|
||||
partstat: "ACCEPTED",
|
||||
role: "REQ-PARTICIPANT",
|
||||
rsvp: true,
|
||||
},
|
||||
],
|
||||
method: "REQUEST",
|
||||
...{ recurrenceRule },
|
||||
status: "CONFIRMED",
|
||||
});
|
||||
|
||||
if (icsEvent.error) {
|
||||
throw icsEvent.error;
|
||||
}
|
||||
|
||||
return icsEvent.value;
|
||||
}
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const apiKey = req.headers.authorization || req.query.apiKey;
|
||||
if (process.env.CRON_API_KEY !== apiKey) {
|
||||
@@ -258,6 +323,17 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
enable: sandboxMode,
|
||||
},
|
||||
},
|
||||
attachments: reminder.workflowStep.includeCalendarEvent
|
||||
? [
|
||||
{
|
||||
content: Buffer.from(getiCalEventAsString(reminder.booking) || "").toString("base64"),
|
||||
filename: "event.ics",
|
||||
type: "text/calendar; method=REQUEST",
|
||||
disposition: "attachment",
|
||||
contentId: uuidv4(),
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -113,6 +113,7 @@ export default function WorkflowDetailsPage(props: Props) {
|
||||
sender: isSMSAction(action) ? sender || SENDER_ID : SENDER_ID,
|
||||
senderName: !isSMSAction(action) ? senderName || SENDER_NAME : SENDER_NAME,
|
||||
numberVerificationPending: false,
|
||||
includeCalendarEvent: false,
|
||||
};
|
||||
steps?.push(step);
|
||||
form.setValue("steps", steps);
|
||||
|
||||
@@ -861,6 +861,29 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
|
||||
{form.formState?.errors?.steps[step.stepNumber - 1]?.reminderBody?.message || ""}
|
||||
</p>
|
||||
)}
|
||||
{isEmailSubjectNeeded && (
|
||||
<div className="mt-2">
|
||||
<Controller
|
||||
name={`steps.${step.stepNumber - 1}.includeCalendarEvent`}
|
||||
control={form.control}
|
||||
render={() => (
|
||||
<CheckboxField
|
||||
disabled={props.readOnly}
|
||||
defaultChecked={
|
||||
form.getValues(`steps.${step.stepNumber - 1}.includeCalendarEvent`) || false
|
||||
}
|
||||
description={t("include_calendar_event")}
|
||||
onChange={(e) =>
|
||||
form.setValue(
|
||||
`steps.${step.stepNumber - 1}.includeCalendarEvent`,
|
||||
e.target.checked
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!props.readOnly && (
|
||||
<div className="mt-3 ">
|
||||
<button type="button" onClick={() => setIsAdditionalInputsDialogOpen(true)}>
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import client from "@sendgrid/client";
|
||||
import type { MailData } from "@sendgrid/helpers/classes/mail";
|
||||
import sgMail from "@sendgrid/mail";
|
||||
import { createEvent } from "ics";
|
||||
import type { ParticipationStatus } from "ics";
|
||||
import type { DateArray } from "ics";
|
||||
import { RRule } from "rrule";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { preprocessNameFieldDataWithVariant } from "@calcom/features/form-builder/utils";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { TimeUnit } from "@calcom/prisma/enums";
|
||||
@@ -42,6 +48,47 @@ async function getBatchId() {
|
||||
return batchIdResponse[1].batch_id as string;
|
||||
}
|
||||
|
||||
function getiCalEventAsString(evt: BookingInfo, status?: ParticipationStatus) {
|
||||
const uid = uuidv4();
|
||||
let recurrenceRule: string | undefined = undefined;
|
||||
if (evt.eventType.recurringEvent?.count) {
|
||||
recurrenceRule = new RRule(evt.eventType.recurringEvent).toString().replace("RRULE:", "");
|
||||
}
|
||||
|
||||
const icsEvent = createEvent({
|
||||
uid,
|
||||
startInputType: "utc",
|
||||
start: dayjs(evt.startTime)
|
||||
.utc()
|
||||
.toArray()
|
||||
.slice(0, 6)
|
||||
.map((v, i) => (i === 1 ? v + 1 : v)) as DateArray,
|
||||
duration: { minutes: dayjs(evt.endTime).diff(dayjs(evt.startTime), "minute") },
|
||||
title: evt.title,
|
||||
description: evt.additionalNotes || "",
|
||||
location: evt.location || "",
|
||||
organizer: { email: evt.organizer.email || "", name: evt.organizer.name },
|
||||
attendees: [
|
||||
{
|
||||
name: preprocessNameFieldDataWithVariant("fullName", evt.attendees[0].name) as string,
|
||||
email: evt.attendees[0].email,
|
||||
partstat: status,
|
||||
role: "REQ-PARTICIPANT",
|
||||
rsvp: true,
|
||||
},
|
||||
],
|
||||
method: "REQUEST",
|
||||
...{ recurrenceRule },
|
||||
status: "CONFIRMED",
|
||||
});
|
||||
|
||||
if (icsEvent.error) {
|
||||
throw icsEvent.error;
|
||||
}
|
||||
|
||||
return icsEvent.value;
|
||||
}
|
||||
|
||||
type ScheduleEmailReminderAction = Extract<
|
||||
WorkflowActions,
|
||||
"EMAIL_HOST" | "EMAIL_ATTENDEE" | "EMAIL_ADDRESS"
|
||||
@@ -62,7 +109,8 @@ export const scheduleEmailReminder = async (
|
||||
template: WorkflowTemplates,
|
||||
sender: string,
|
||||
hideBranding?: boolean,
|
||||
seatReferenceUid?: string
|
||||
seatReferenceUid?: string,
|
||||
includeCalendarEvent?: boolean
|
||||
) => {
|
||||
if (action === WorkflowActions.EMAIL_ADDRESS) return;
|
||||
const { startTime, endTime } = evt;
|
||||
@@ -186,11 +234,19 @@ export const scheduleEmailReminder = async (
|
||||
|
||||
const batchId = await getBatchId();
|
||||
|
||||
function sendEmail(data: Partial<MailData>) {
|
||||
function sendEmail(data: Partial<MailData>, triggerEvent?: WorkflowTriggerEvents) {
|
||||
if (!process.env.SENDGRID_API_KEY) {
|
||||
console.info("No sendgrid API key provided, skipping email");
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
const status: ParticipationStatus =
|
||||
triggerEvent === WorkflowTriggerEvents.AFTER_EVENT
|
||||
? "COMPLETED"
|
||||
: triggerEvent === WorkflowTriggerEvents.EVENT_CANCELLED
|
||||
? "DECLINED"
|
||||
: "ACCEPTED";
|
||||
|
||||
return sgMail.send({
|
||||
to: data.to,
|
||||
from: {
|
||||
@@ -206,6 +262,17 @@ export const scheduleEmailReminder = async (
|
||||
enable: sandboxMode,
|
||||
},
|
||||
},
|
||||
attachments: includeCalendarEvent
|
||||
? [
|
||||
{
|
||||
content: Buffer.from(getiCalEventAsString(evt, status) || "").toString("base64"),
|
||||
filename: "event.ics",
|
||||
type: "text/calendar; method=REQUEST",
|
||||
disposition: "attachment",
|
||||
contentId: uuidv4(),
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
sendAt: data.sendAt,
|
||||
});
|
||||
}
|
||||
@@ -218,7 +285,7 @@ export const scheduleEmailReminder = async (
|
||||
try {
|
||||
if (!sendTo) throw new Error("No email addresses provided");
|
||||
const addressees = Array.isArray(sendTo) ? sendTo : [sendTo];
|
||||
const promises = addressees.map((email) => sendEmail({ to: email }));
|
||||
const promises = addressees.map((email) => sendEmail({ to: email }, triggerEvent));
|
||||
// TODO: Maybe don't await for this?
|
||||
await Promise.all(promises);
|
||||
} catch (error) {
|
||||
@@ -237,10 +304,13 @@ export const scheduleEmailReminder = async (
|
||||
) {
|
||||
try {
|
||||
// If sendEmail failed then workflowReminer will not be created, failing E2E tests
|
||||
await sendEmail({
|
||||
to: sendTo,
|
||||
sendAt: scheduledDate.unix(),
|
||||
});
|
||||
await sendEmail(
|
||||
{
|
||||
to: sendTo,
|
||||
sendAt: scheduledDate.unix(),
|
||||
},
|
||||
triggerEvent
|
||||
);
|
||||
await prisma.workflowReminder.create({
|
||||
data: {
|
||||
bookingUid: uid,
|
||||
|
||||
@@ -106,7 +106,8 @@ const processWorkflowStep = async (
|
||||
step.template,
|
||||
step.sender || SENDER_NAME,
|
||||
hideBranding,
|
||||
seatReferenceUid
|
||||
seatReferenceUid,
|
||||
step.includeCalendarEvent
|
||||
);
|
||||
} else if (isWhatsappAction(step.action)) {
|
||||
const sendTo = step.action === WorkflowActions.WHATSAPP_ATTENDEE ? smsReminderNumber : step.sendTo;
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { TimeUnit } from "@calcom/prisma/enums";
|
||||
import { WorkflowTemplates, WorkflowActions, WorkflowMethods } from "@calcom/prisma/enums";
|
||||
import { WorkflowTriggerEvents } from "@calcom/prisma/enums";
|
||||
import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
import type { CalEventResponses } from "@calcom/types/Calendar";
|
||||
import type { CalEventResponses, RecurringEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import { getSenderId } from "../alphanumericSenderIdSupport";
|
||||
import * as twilio from "./smsProviders/twilioProvider";
|
||||
@@ -44,6 +44,7 @@ export type BookingInfo = {
|
||||
};
|
||||
eventType: {
|
||||
slug?: string;
|
||||
recurringEvent?: RecurringEvent | null;
|
||||
};
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
|
||||
@@ -64,6 +64,7 @@ const formSchema = z.object({
|
||||
emailSubject: z.string().nullable(),
|
||||
template: z.nativeEnum(WorkflowTemplates),
|
||||
numberRequired: z.boolean().nullable(),
|
||||
includeCalendarEvent: z.boolean().nullable(),
|
||||
sendTo: z
|
||||
.string()
|
||||
.refine((val) => isValidPhoneNumber(val) || val.includes("@"))
|
||||
|
||||
@@ -24,7 +24,10 @@ export const getFullName = (name: string | { firstName: string; lastName?: strin
|
||||
if (typeof name === "string") {
|
||||
nameString = name;
|
||||
} else {
|
||||
nameString = name.firstName + " " + name.lastName;
|
||||
nameString = name.firstName;
|
||||
if (name.lastName) {
|
||||
nameString = nameString + " " + name.lastName;
|
||||
}
|
||||
}
|
||||
return nameString;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "WorkflowStep" ADD COLUMN "includeCalendarEvent" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -738,6 +738,7 @@ model WorkflowStep {
|
||||
numberRequired Boolean?
|
||||
sender String?
|
||||
numberVerificationPending Boolean @default(true)
|
||||
includeCalendarEvent Boolean @default(false)
|
||||
|
||||
@@index([workflowId])
|
||||
}
|
||||
|
||||
@@ -454,6 +454,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
|
||||
senderName: newStep.senderName,
|
||||
}),
|
||||
numberVerificationPending: false,
|
||||
includeCalendarEvent: newStep.includeCalendarEvent,
|
||||
},
|
||||
});
|
||||
//cancel all reminders of step and create new ones (not for newEventTypes)
|
||||
|
||||
@@ -24,6 +24,7 @@ export const ZUpdateInputSchema = z.object({
|
||||
numberRequired: z.boolean().nullable(),
|
||||
sender: z.string().optional().nullable(),
|
||||
senderName: z.string().optional().nullable(),
|
||||
includeCalendarEvent: z.boolean(),
|
||||
})
|
||||
.array(),
|
||||
trigger: z.enum(WORKFLOW_TRIGGER_EVENTS),
|
||||
|
||||
Reference in New Issue
Block a user