Allow editing workflow templates (#8028)
* add event end time as variable
* add timezone as new variable
* add first version of template prefill
* set template body when template is updated
* set reminder template body and subject when creating workflow
* set email subject when changes templates
* save emailBody and emailsubject for all templates + fix duplicate template text
* add more flexibility for templates
* remove console.log
* fix {ORAGANIZER} and {ATTENDEE} variable
* make sure to always send reminder body and not default template
* fix import
* remove email body text and match variables in templates
* handle translations of formatted variables
* fix email reminder template
* add cancel and reschedule link as variable
* add cancel and reschedule link for scheduled emails/sms
* make sure empty empty body and subject are set for reminder template
* add info message for testing workflow
* fix typo
* add sms template
* add migration to remove reminderBody and emailSubject
* add branding
* code clean up
* add hide branding everywhere
* fix sms reminder template
* set sms reminder template if sms body is empty
* fix custom inputs variables everywhere
* fix variable translations + other small fixes
* fix some type errors
* fix more type errors
* fix everything missing around cron job scheduling
* make sure to always use custom template for sms messages
* fix type error
* code clean up
* rename link to url
* Add debug logs
* Update handleNewBooking.ts
* Add debug logs
* removed unneded responses
* fix booking questions + UI improvements
* remove html email body when changing to sms action
* code clean up + comments
* code clean up
* code clean up
* remove comment
* more clear info message for timezone variable
---------
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: zomars <zomars@me.com>
Co-authored-by: alannnc <alannnc@gmail.com>
This commit is contained in:
co-authored by
CarinaWolli
Hariom Balhara
zomars
alannnc
parent
c0bda6257e
commit
0aca769d06
@@ -1150,13 +1150,13 @@
|
||||
"current_username": "Current username",
|
||||
"example_1": "Example 1",
|
||||
"example_2": "Example 2",
|
||||
"additional_input_label": "Additional Input Label",
|
||||
"booking_question_identifier": "Booking Question Identifier",
|
||||
"company_size": "Company size",
|
||||
"what_help_needed": "What do you need help with?",
|
||||
"variable_format": "Variable format",
|
||||
"webhook_subscriber_url_reserved": "Webhook subscriber url is already defined",
|
||||
"custom_input_as_variable_info": "Ignore all special characters of the additional input label (use only letters and numbers), use uppercase for all letters and replace whitespaces with underscores.",
|
||||
"using_additional_inputs_as_variables": "How do I use Additional Inputs as variables?",
|
||||
"using_booking_questions_as_variables": "How do I use booking questions as variables?",
|
||||
"download_desktop_app": "Download desktop app",
|
||||
"set_ping_link": "Set Ping link",
|
||||
"rate_limit_exceeded": "Rate limit exceeded",
|
||||
@@ -1382,7 +1382,7 @@
|
||||
"add_limit": "Add Limit",
|
||||
"team_name_required": "Team name required",
|
||||
"show_attendees": "Share attendee information between guests",
|
||||
"how_additional_inputs_as_variables": "How to use Additional Inputs as Variables",
|
||||
"how_booking_questions_as_variables": "How to use booking questions as variables?",
|
||||
"format": "Format",
|
||||
"uppercase_for_letters": "Use uppercase for all letters",
|
||||
"replace_whitespaces_underscores": "Replace whitespaces with underscores",
|
||||
@@ -1397,7 +1397,7 @@
|
||||
"billing_help_title": "Need anything else?",
|
||||
"billing_help_description": "If you need any further help with billing, our support team are here to help.",
|
||||
"billing_help_cta": "Contact support",
|
||||
"ignore_special_characters": "Ignore special characters in your Additional Input label. Use only letters and numbers",
|
||||
"ignore_special_characters_booking_questions": "Ignore special characters in your booking question identifier. Use only letters and numbers",
|
||||
"retry": "Retry",
|
||||
"fetching_calendars_error": "There was a problem fetching your calendars. Please <1>try again</1> or reach out to customer support.",
|
||||
"calendar_connection_fail": "Calendar connection failed",
|
||||
@@ -1705,9 +1705,17 @@
|
||||
"verification_code": "Verification code",
|
||||
"can_you_try_again": "Can you try again with a different time?",
|
||||
"verify": "Verify",
|
||||
"timezone_variable": "Timezone",
|
||||
"timezone_info": "The timezone of the person receiving",
|
||||
"event_end_time_variable": "Event end time",
|
||||
"event_end_time_info": "The event end time",
|
||||
"cancel_url_variable": "Cancel URL",
|
||||
"cancel_url_info": "The URL to cancel the booking",
|
||||
"reschedule_url_variable": "Reschedule URL",
|
||||
"reschedule_url_info": "The URL to reschedule the booking",
|
||||
"invalid_event_name_variables": "There is an invalid variable in your event name",
|
||||
"select_all": "Select All",
|
||||
"default_conferencing_bulk_title": "Bulk update existing event types",
|
||||
"invalid_event_name_variables": "There is an invalid variable in your event name",
|
||||
"members_default_schedule": "Member's default schedule",
|
||||
"set_by_admin": "Set by team admin",
|
||||
"members_default_location": "Member's default location",
|
||||
@@ -1769,6 +1777,7 @@
|
||||
"collect_no_show_fee": "Collect no-show fee",
|
||||
"no_show_fee_charged": "No-show fee charged",
|
||||
"insights": "Insights",
|
||||
"testing_workflow_info_message": "When testing this workflow, be aware that Emails and SMS can only be scheduled at least 1 hour in advance",
|
||||
"insights_no_data_found_for_filter": "No data found for the selected filter or selected dates.",
|
||||
"acknowledge_booking_no_show_fee": "I acknowledge that if I do not attend this event that a {{amount, currency}} no show fee will be applied to my card.",
|
||||
"card_details": "Card details",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { WebhookTriggerEvents, WorkflowReminder, Prisma } from "@prisma/client";
|
||||
import type { Prisma, WebhookTriggerEvents, WorkflowReminder } from "@prisma/client";
|
||||
import { BookingStatus, MembershipRole, WorkflowMethods } from "@prisma/client";
|
||||
import type { NextApiRequest } from "next";
|
||||
|
||||
@@ -58,6 +58,7 @@ async function getBookingToDelete(id: number | undefined, uid: string | undefine
|
||||
paid: true,
|
||||
eventType: {
|
||||
select: {
|
||||
slug: true,
|
||||
owner: true,
|
||||
teamId: true,
|
||||
recurringEvent: true,
|
||||
@@ -334,7 +335,11 @@ async function handler(req: CustomRequest) {
|
||||
await sendCancelledReminders({
|
||||
workflows: bookingToDelete.eventType?.workflows,
|
||||
smsReminderNumber: bookingToDelete.smsReminderNumber,
|
||||
evt,
|
||||
evt: {
|
||||
...evt,
|
||||
...{ eventType: { slug: bookingToDelete.eventType.slug } },
|
||||
},
|
||||
hideBranding: !!bookingToDelete.eventType.owner?.hideBranding,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PrismaClient, Workflow, WorkflowsOnEventTypes, WorkflowStep } from "@prisma/client";
|
||||
import type { Prisma, PrismaClient, Workflow, WorkflowsOnEventTypes, WorkflowStep } from "@prisma/client";
|
||||
import { BookingStatus, WebhookTriggerEvents } from "@prisma/client";
|
||||
|
||||
import { scheduleTrigger } from "@calcom/app-store/zapier/lib/nodeScheduler";
|
||||
@@ -10,6 +10,7 @@ import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
|
||||
import type { EventTypeInfo } from "@calcom/features/webhooks/lib/sendPayload";
|
||||
import sendPayload from "@calcom/features/webhooks/lib/sendPayload";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
import type { AdditionalInformation, CalendarEvent } from "@calcom/types/Calendar";
|
||||
|
||||
const log = logger.getChildLogger({ prefix: ["[handleConfirmation] book:user"] });
|
||||
@@ -66,11 +67,24 @@ export async function handleConfirmation(args: {
|
||||
let updatedBookings: {
|
||||
scheduledJobs: string[];
|
||||
id: number;
|
||||
description: string | null;
|
||||
location: string | null;
|
||||
attendees: {
|
||||
name: string;
|
||||
email: string;
|
||||
}[];
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
uid: string;
|
||||
smsReminderNumber: string | null;
|
||||
metadata: Prisma.JsonValue | null;
|
||||
customInputs: Prisma.JsonValue;
|
||||
eventType: {
|
||||
bookingFields: Prisma.JsonValue | null;
|
||||
slug: string;
|
||||
owner: {
|
||||
hideBranding?: boolean | null;
|
||||
} | null;
|
||||
workflows: (WorkflowsOnEventTypes & {
|
||||
workflow: Workflow & {
|
||||
steps: WorkflowStep[];
|
||||
@@ -104,6 +118,13 @@ export async function handleConfirmation(args: {
|
||||
select: {
|
||||
eventType: {
|
||||
select: {
|
||||
slug: true,
|
||||
bookingFields: true,
|
||||
owner: {
|
||||
select: {
|
||||
hideBranding: true,
|
||||
},
|
||||
},
|
||||
workflows: {
|
||||
include: {
|
||||
workflow: {
|
||||
@@ -115,15 +136,21 @@ export async function handleConfirmation(args: {
|
||||
},
|
||||
},
|
||||
},
|
||||
description: true,
|
||||
attendees: true,
|
||||
location: true,
|
||||
uid: true,
|
||||
startTime: true,
|
||||
metadata: true,
|
||||
endTime: true,
|
||||
smsReminderNumber: true,
|
||||
customInputs: true,
|
||||
id: true,
|
||||
scheduledJobs: true,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const updatedBookingsResult = await Promise.all(updateBookingsPromise);
|
||||
updatedBookings = updatedBookings.concat(updatedBookingsResult);
|
||||
} else {
|
||||
@@ -142,6 +169,13 @@ export async function handleConfirmation(args: {
|
||||
select: {
|
||||
eventType: {
|
||||
select: {
|
||||
slug: true,
|
||||
bookingFields: true,
|
||||
owner: {
|
||||
select: {
|
||||
hideBranding: true,
|
||||
},
|
||||
},
|
||||
workflows: {
|
||||
include: {
|
||||
workflow: {
|
||||
@@ -155,8 +189,13 @@ export async function handleConfirmation(args: {
|
||||
},
|
||||
uid: true,
|
||||
startTime: true,
|
||||
metadata: true,
|
||||
endTime: true,
|
||||
smsReminderNumber: true,
|
||||
description: true,
|
||||
attendees: true,
|
||||
location: true,
|
||||
customInputs: true,
|
||||
id: true,
|
||||
scheduledJobs: true,
|
||||
},
|
||||
@@ -172,14 +211,21 @@ export async function handleConfirmation(args: {
|
||||
evtOfBooking.startTime = updatedBookings[index].startTime.toISOString();
|
||||
evtOfBooking.endTime = updatedBookings[index].endTime.toISOString();
|
||||
evtOfBooking.uid = updatedBookings[index].uid;
|
||||
const eventTypeSlug = updatedBookings[index].eventType?.slug || "";
|
||||
|
||||
const isFirstBooking = index === 0;
|
||||
const videoCallUrl =
|
||||
bookingMetadataSchema.parse(updatedBookings[index].metadata || {})?.videoCallUrl || "";
|
||||
|
||||
await scheduleWorkflowReminders({
|
||||
workflows: updatedBookings[index]?.eventType?.workflows || [],
|
||||
smsReminderNumber: updatedBookings[index].smsReminderNumber,
|
||||
calendarEvent: evtOfBooking,
|
||||
calendarEvent: {
|
||||
...evtOfBooking,
|
||||
...{ metadata: { videoCallUrl }, eventType: { slug: eventTypeSlug } },
|
||||
},
|
||||
isFirstRecurringEvent: isFirstBooking,
|
||||
hideBranding: !!updatedBookings[index].eventType?.owner?.hideBranding,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,6 +198,7 @@ const getEventTypesFromDB = async (eventTypeId: number) => {
|
||||
customInputs: true,
|
||||
disableGuests: true,
|
||||
users: userSelect,
|
||||
slug: true,
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
@@ -227,6 +228,11 @@ const getEventTypesFromDB = async (eventTypeId: number) => {
|
||||
seatsShowAttendees: true,
|
||||
bookingLimits: true,
|
||||
durationLimits: true,
|
||||
owner: {
|
||||
select: {
|
||||
hideBranding: true,
|
||||
},
|
||||
},
|
||||
workflows: {
|
||||
include: {
|
||||
workflow: {
|
||||
@@ -800,6 +806,7 @@ async function handler(
|
||||
id: organizerUser.id,
|
||||
name: organizerUser.name || "Nameless",
|
||||
email: organizerUser.email || "Email-less",
|
||||
username: organizerUser.username || undefined,
|
||||
timeZone: organizerUser.timeZone,
|
||||
language: { translate: tOrganizer, locale: organizerUser.locale ?? "en" },
|
||||
timeFormat: organizerUser.timeFormat === 24 ? TimeFormat.TWENTY_FOUR_HOUR : TimeFormat.TWELVE_HOUR,
|
||||
@@ -1441,7 +1448,7 @@ async function handler(
|
||||
await scheduleWorkflowReminders({
|
||||
workflows: eventType.workflows,
|
||||
smsReminderNumber: smsReminderNumber || null,
|
||||
calendarEvent: { ...evt, responses, ...{ metadata } },
|
||||
calendarEvent: { ...evt, ...{ metadata, eventType: { slug: eventType.slug } } },
|
||||
requiresConfirmation: evt.requiresConfirmation || false,
|
||||
isRescheduleEvent: !!rescheduleUid,
|
||||
isFirstRecurringEvent: true,
|
||||
@@ -2058,10 +2065,14 @@ async function handler(
|
||||
await scheduleWorkflowReminders({
|
||||
workflows: eventType.workflows,
|
||||
smsReminderNumber: smsReminderNumber || null,
|
||||
calendarEvent: { ...evt, responses, ...{ metadata: metadataFromEvent } },
|
||||
calendarEvent: {
|
||||
...evt,
|
||||
...{ metadata: metadataFromEvent, eventType: { slug: eventType.slug } },
|
||||
},
|
||||
requiresConfirmation: evt.requiresConfirmation || false,
|
||||
isRescheduleEvent: !!rescheduleUid,
|
||||
isFirstRecurringEvent: true,
|
||||
hideBranding: !!eventType.owner?.hideBranding,
|
||||
});
|
||||
} catch (error) {
|
||||
log.error("Error while scheduling workflow reminders", error);
|
||||
|
||||
@@ -5,6 +5,7 @@ import sgMail from "@sendgrid/mail";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
|
||||
import { defaultHandler } from "@calcom/lib/server";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
@@ -164,51 +165,63 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
|
||||
let emailContent = {
|
||||
emailSubject: reminder.workflowStep.emailSubject || "",
|
||||
emailBody: {
|
||||
text: reminder.workflowStep.reminderBody || "",
|
||||
html: `<body style="white-space: pre-wrap;">${reminder.workflowStep.reminderBody || ""}</body>`,
|
||||
},
|
||||
emailBody: `<body style="white-space: pre-wrap;">${reminder.workflowStep.reminderBody || ""}</body>`,
|
||||
};
|
||||
|
||||
switch (reminder.workflowStep.template) {
|
||||
case WorkflowTemplates.REMINDER:
|
||||
emailContent = emailReminderTemplate(
|
||||
reminder.booking.startTime.toISOString() || "",
|
||||
reminder.booking.endTime.toISOString() || "",
|
||||
reminder.booking.eventType?.title || "",
|
||||
timeZone || "",
|
||||
attendeeName || "",
|
||||
name || ""
|
||||
);
|
||||
break;
|
||||
case WorkflowTemplates.CUSTOM:
|
||||
const variables: VariablesType = {
|
||||
eventName: reminder.booking?.eventType?.title || "",
|
||||
organizerName: reminder.booking.user?.name || "",
|
||||
attendeeName: reminder.booking.attendees[0].name,
|
||||
attendeeEmail: reminder.booking.attendees[0].email,
|
||||
eventDate: dayjs(reminder.booking.startTime).tz(timeZone),
|
||||
eventTime: dayjs(reminder.booking.startTime).tz(timeZone),
|
||||
timeZone: timeZone,
|
||||
location: reminder.booking.location || "",
|
||||
additionalNotes: reminder.booking.description,
|
||||
responses: reminder.booking.responses,
|
||||
meetingUrl: bookingMetadataSchema.parse(reminder.booking.metadata || {})?.videoCallUrl,
|
||||
};
|
||||
const emailSubject = await customTemplate(
|
||||
reminder.workflowStep.emailSubject || "",
|
||||
variables,
|
||||
locale || ""
|
||||
);
|
||||
emailContent.emailSubject = emailSubject.text;
|
||||
emailContent.emailBody = await customTemplate(
|
||||
reminder.workflowStep.reminderBody || "",
|
||||
variables,
|
||||
locale || ""
|
||||
);
|
||||
break;
|
||||
let emailBodyEmpty = false;
|
||||
|
||||
if (reminder.workflowStep.reminderBody) {
|
||||
const { responses } = getCalEventResponses({
|
||||
bookingFields: reminder.booking.eventType?.bookingFields ?? null,
|
||||
booking: reminder.booking,
|
||||
});
|
||||
|
||||
const variables: VariablesType = {
|
||||
eventName: reminder.booking.eventType?.title || "",
|
||||
organizerName: reminder.booking.user?.name || "",
|
||||
attendeeName: reminder.booking.attendees[0].name,
|
||||
attendeeEmail: reminder.booking.attendees[0].email,
|
||||
eventDate: dayjs(reminder.booking.startTime).tz(timeZone),
|
||||
eventEndTime: dayjs(reminder.booking?.endTime).tz(timeZone),
|
||||
timeZone: timeZone,
|
||||
location: reminder.booking.location || "",
|
||||
additionalNotes: reminder.booking.description,
|
||||
responses: responses,
|
||||
meetingUrl: bookingMetadataSchema.parse(reminder.booking.metadata || {})?.videoCallUrl,
|
||||
cancelLink: `/booking/${reminder.booking.uid}?cancel=true`,
|
||||
rescheduleLink: `/${reminder.booking.user?.username}/${reminder.booking.eventType?.slug}?rescheduleUid=${reminder.booking.uid}`,
|
||||
};
|
||||
const emailSubject = customTemplate(
|
||||
reminder.workflowStep.emailSubject || "",
|
||||
variables,
|
||||
locale || "",
|
||||
!!reminder.booking.user?.hideBranding
|
||||
).text;
|
||||
emailContent.emailSubject = emailSubject;
|
||||
emailContent.emailBody = customTemplate(
|
||||
reminder.workflowStep.reminderBody || "",
|
||||
variables,
|
||||
locale || "",
|
||||
!!reminder.booking.user?.hideBranding
|
||||
).html;
|
||||
|
||||
emailBodyEmpty =
|
||||
customTemplate(reminder.workflowStep.reminderBody || "", variables, locale || "").text.length === 0;
|
||||
} else if (reminder.workflowStep.template === WorkflowTemplates.REMINDER) {
|
||||
emailContent = emailReminderTemplate(
|
||||
false,
|
||||
reminder.workflowStep.action,
|
||||
reminder.booking.startTime.toISOString() || "",
|
||||
reminder.booking.endTime.toISOString() || "",
|
||||
reminder.booking.eventType?.title || "",
|
||||
timeZone || "",
|
||||
attendeeName || "",
|
||||
name || "",
|
||||
!!reminder.booking.user?.hideBranding
|
||||
);
|
||||
}
|
||||
if (emailContent.emailSubject.length > 0 && emailContent.emailBody.text.length > 0 && sendTo) {
|
||||
|
||||
if (emailContent.emailSubject.length > 0 && !emailBodyEmpty && sendTo) {
|
||||
const batchIdResponse = await client.request({
|
||||
url: "/v3/mail/batch",
|
||||
method: "POST",
|
||||
@@ -224,8 +237,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
name: reminder.workflowStep.sender || "Cal.com",
|
||||
},
|
||||
subject: emailContent.emailSubject,
|
||||
text: emailContent.emailBody.text,
|
||||
html: emailContent.emailBody.html,
|
||||
html: emailContent.emailBody,
|
||||
batchId: batchId,
|
||||
sendAt: dayjs(reminder.scheduledDate).unix(),
|
||||
replyTo: reminder.booking.user?.email || senderEmail,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { WorkflowActions, WorkflowMethods, WorkflowTemplates } from "@prisma/cli
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
|
||||
import { defaultHandler } from "@calcom/lib/server";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
@@ -87,38 +88,46 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
: reminder.booking?.user?.locale;
|
||||
|
||||
let message: string | null = reminder.workflowStep.reminderBody;
|
||||
switch (reminder.workflowStep.template) {
|
||||
case WorkflowTemplates.REMINDER:
|
||||
message = smsReminderTemplate(
|
||||
reminder.booking?.startTime.toISOString() || "",
|
||||
reminder.booking?.eventType?.title || "",
|
||||
timeZone || "",
|
||||
attendeeName || "",
|
||||
userName
|
||||
);
|
||||
break;
|
||||
case WorkflowTemplates.CUSTOM:
|
||||
const variables: VariablesType = {
|
||||
eventName: reminder.booking?.eventType?.title,
|
||||
organizerName: reminder.booking?.user?.name || "",
|
||||
attendeeName: reminder.booking?.attendees[0].name,
|
||||
attendeeEmail: reminder.booking?.attendees[0].email,
|
||||
eventDate: dayjs(reminder.booking?.startTime).tz(timeZone),
|
||||
eventTime: dayjs(reminder.booking?.startTime).tz(timeZone),
|
||||
timeZone: timeZone,
|
||||
location: reminder.booking?.location || "",
|
||||
additionalNotes: reminder.booking?.description,
|
||||
responses: reminder.booking?.responses,
|
||||
meetingUrl: bookingMetadataSchema.parse(reminder.booking?.metadata || {})?.videoCallUrl,
|
||||
};
|
||||
const customMessage = await customTemplate(
|
||||
reminder.workflowStep.reminderBody || "",
|
||||
variables,
|
||||
locale || ""
|
||||
);
|
||||
message = customMessage.text;
|
||||
break;
|
||||
|
||||
if (reminder.workflowStep.reminderBody) {
|
||||
const { responses } = getCalEventResponses({
|
||||
bookingFields: reminder.booking.eventType?.bookingFields ?? null,
|
||||
booking: reminder.booking,
|
||||
});
|
||||
|
||||
const variables: VariablesType = {
|
||||
eventName: reminder.booking?.eventType?.title,
|
||||
organizerName: reminder.booking?.user?.name || "",
|
||||
attendeeName: reminder.booking?.attendees[0].name,
|
||||
attendeeEmail: reminder.booking?.attendees[0].email,
|
||||
eventDate: dayjs(reminder.booking?.startTime).tz(timeZone),
|
||||
eventEndTime: dayjs(reminder.booking?.endTime).tz(timeZone),
|
||||
timeZone: timeZone,
|
||||
location: reminder.booking?.location || "",
|
||||
additionalNotes: reminder.booking?.description,
|
||||
responses: responses,
|
||||
meetingUrl: bookingMetadataSchema.parse(reminder.booking?.metadata || {})?.videoCallUrl,
|
||||
cancelLink: `/booking/${reminder.booking.uid}?cancel=true`,
|
||||
rescheduleLink: `/${reminder.booking.user?.username}/${reminder.booking.eventType?.slug}?rescheduleUid=${reminder.booking.uid}`,
|
||||
};
|
||||
const customMessage = customTemplate(
|
||||
reminder.workflowStep.reminderBody || "",
|
||||
variables,
|
||||
locale || ""
|
||||
);
|
||||
message = customMessage.text;
|
||||
} else if (reminder.workflowStep.template === WorkflowTemplates.REMINDER) {
|
||||
message = smsReminderTemplate(
|
||||
false,
|
||||
reminder.workflowStep.action,
|
||||
reminder.booking?.startTime.toISOString() || "",
|
||||
reminder.booking?.eventType?.title || "",
|
||||
timeZone || "",
|
||||
attendeeName || "",
|
||||
userName
|
||||
);
|
||||
}
|
||||
|
||||
if (message?.length && message?.length > 0 && sendTo) {
|
||||
const scheduledSMS = await twilio.scheduleSMS(sendTo, message, reminder.scheduledDate, senderID);
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import type { MultiSelectCheckboxesOptionType as Option } from "@calcom/ui";
|
||||
import { Button, Label, MultiSelectCheckboxes, TextField } from "@calcom/ui";
|
||||
import { ArrowDown, Trash2 } from "@calcom/ui/components/icon";
|
||||
|
||||
import { isSMSAction } from "../lib/isSMSAction";
|
||||
import { isSMSAction } from "../lib/actionHelperFunctions";
|
||||
import type { FormValues } from "../pages/workflow";
|
||||
import { AddActionDialog } from "./AddActionDialog";
|
||||
import { DeleteDialog } from "./DeleteDialog";
|
||||
|
||||
@@ -35,11 +35,13 @@ import {
|
||||
AddVariablesDropdown,
|
||||
Input,
|
||||
} from "@calcom/ui";
|
||||
import { ArrowDown, MoreHorizontal, Trash2, HelpCircle } from "@calcom/ui/components/icon";
|
||||
import { ArrowDown, MoreHorizontal, Trash2, HelpCircle, Info } from "@calcom/ui/components/icon";
|
||||
|
||||
import { isAttendeeAction, isSMSAction } from "../lib/actionHelperFunctions";
|
||||
import { DYNAMIC_TEXT_VARIABLES } from "../lib/constants";
|
||||
import { getWorkflowTemplateOptions, getWorkflowTriggerOptions } from "../lib/getOptions";
|
||||
import { isSMSAction } from "../lib/isSMSAction";
|
||||
import emailReminderTemplate from "../lib/reminders/templates/emailReminderTemplate";
|
||||
import smsReminderTemplate from "../lib/reminders/templates/smsReminderTemplate";
|
||||
import type { FormValues } from "../pages/workflow";
|
||||
import { TimeTimeUnitInput } from "./TimeTimeUnitInput";
|
||||
|
||||
@@ -69,12 +71,14 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
|
||||
step?.action === WorkflowActions.SMS_NUMBER ? true : false
|
||||
);
|
||||
|
||||
const [updateTemplate, setUpdateTemplate] = useState(false);
|
||||
const [firstRender, setFirstRender] = useState(true);
|
||||
|
||||
const [isSenderIdNeeded, setIsSenderIdNeeded] = useState(
|
||||
step?.action === WorkflowActions.SMS_NUMBER || step?.action === WorkflowActions.SMS_ATTENDEE
|
||||
? true
|
||||
: false
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setNumberVerified(
|
||||
!!step &&
|
||||
@@ -86,10 +90,6 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
|
||||
step?.action === WorkflowActions.EMAIL_ADDRESS ? true : false
|
||||
);
|
||||
|
||||
const [isCustomReminderBodyNeeded, setIsCustomReminderBodyNeeded] = useState(
|
||||
step?.template === WorkflowTemplates.CUSTOM ? true : false
|
||||
);
|
||||
|
||||
const [isEmailSubjectNeeded, setIsEmailSubjectNeeded] = useState(
|
||||
step?.action === WorkflowActions.EMAIL_ATTENDEE ||
|
||||
step?.action === WorkflowActions.EMAIL_HOST ||
|
||||
@@ -110,6 +110,28 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
|
||||
const triggerOptions = getWorkflowTriggerOptions(t);
|
||||
const templateOptions = getWorkflowTemplateOptions(t);
|
||||
|
||||
if (step && form.getValues(`steps.${step.stepNumber - 1}.template`) === WorkflowTemplates.REMINDER) {
|
||||
if (!form.getValues(`steps.${step.stepNumber - 1}.reminderBody`)) {
|
||||
if (isSMSAction(form.getValues(`steps.${step.stepNumber - 1}.action`))) {
|
||||
const smsBody = smsReminderTemplate(true, form.getValues(`steps.${step.stepNumber - 1}.action`));
|
||||
form.setValue(`steps.${step.stepNumber - 1}.reminderBody`, smsBody);
|
||||
} else {
|
||||
const reminderBodyTemplate = emailReminderTemplate(
|
||||
true,
|
||||
form.getValues(`steps.${step.stepNumber - 1}.action`)
|
||||
).emailBody;
|
||||
form.setValue(`steps.${step.stepNumber - 1}.reminderBody`, reminderBodyTemplate);
|
||||
}
|
||||
}
|
||||
if (!form.getValues(`steps.${step.stepNumber - 1}.emailSubject`)) {
|
||||
const subjectTemplate = emailReminderTemplate(
|
||||
true,
|
||||
form.getValues(`steps.${step.stepNumber - 1}.action`)
|
||||
).emailSubject;
|
||||
form.setValue(`steps.${step.stepNumber - 1}.emailSubject`, subjectTemplate);
|
||||
}
|
||||
}
|
||||
|
||||
const { ref: emailSubjectFormRef, ...restEmailSubjectForm } = step
|
||||
? form.register(`steps.${step.stepNumber - 1}.emailSubject`)
|
||||
: { ref: null, name: "" };
|
||||
@@ -259,6 +281,10 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
|
||||
<div className="mt-5">
|
||||
<Label>{showTimeSectionAfter ? t("how_long_after") : t("how_long_before")}</Label>
|
||||
<TimeTimeUnitInput form={form} />
|
||||
<div className="mt-1 flex text-gray-500">
|
||||
<Info className="mr-1 mt-0.5 h-4 w-4" />
|
||||
<p className="text-sm">{t("testing_workflow_info_message")}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -352,33 +378,71 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
|
||||
setIsEmailAddressNeeded(false);
|
||||
setIsPhoneNumberNeeded(val.value === WorkflowActions.SMS_NUMBER);
|
||||
setNumberVerified(false);
|
||||
|
||||
// email action changes to sms action
|
||||
if (!isSMSAction(oldValue)) {
|
||||
form.setValue(`steps.${step.stepNumber - 1}.reminderBody`, "");
|
||||
form.setValue(`steps.${step.stepNumber - 1}.sender`, SENDER_ID);
|
||||
}
|
||||
|
||||
setIsEmailSubjectNeeded(false);
|
||||
} else {
|
||||
setIsPhoneNumberNeeded(false);
|
||||
setIsSenderIdNeeded(false);
|
||||
setIsEmailAddressNeeded(val.value === WorkflowActions.EMAIL_ADDRESS);
|
||||
setIsEmailSubjectNeeded(true);
|
||||
}
|
||||
|
||||
if (isSMSAction(oldValue)) {
|
||||
form.setValue(`steps.${step.stepNumber - 1}.reminderBody`, "");
|
||||
form.setValue(`steps.${step.stepNumber - 1}.senderName`, SENDER_NAME);
|
||||
if (
|
||||
form.getValues(`steps.${step.stepNumber - 1}.template`) ===
|
||||
WorkflowTemplates.REMINDER
|
||||
) {
|
||||
if (isSMSAction(val.value) === isSMSAction(oldValue)) {
|
||||
if (isAttendeeAction(oldValue) !== isAttendeeAction(val.value)) {
|
||||
const currentReminderBody =
|
||||
form.getValues(`steps.${step.stepNumber - 1}.reminderBody`) || "";
|
||||
const newReminderBody = currentReminderBody
|
||||
.replaceAll("{ORGANIZER}", "{PLACEHOLDER}")
|
||||
.replaceAll("{ATTENDEE}", "{ORGANIZER}")
|
||||
.replaceAll("{PLACEHOLDER}", "{ATTENDEE}");
|
||||
form.setValue(`steps.${step.stepNumber - 1}.reminderBody`, newReminderBody);
|
||||
|
||||
if (!isSMSAction(val.value)) {
|
||||
const currentEmailSubject =
|
||||
form.getValues(`steps.${step.stepNumber - 1}.emailSubject`) || "";
|
||||
const newEmailSubject = isAttendeeAction(val.value)
|
||||
? currentEmailSubject.replace("{ORGANIZER}", "{ATTENDEE}")
|
||||
: currentEmailSubject.replace("{ATTENDEE}", "{ORGANIZER}");
|
||||
|
||||
form.setValue(
|
||||
`steps.${step.stepNumber - 1}.emailSubject`,
|
||||
newEmailSubject || ""
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (isSMSAction(val.value)) {
|
||||
form.setValue(
|
||||
`steps.${step.stepNumber - 1}.reminderBody`,
|
||||
smsReminderTemplate(true, val.value)
|
||||
);
|
||||
} else {
|
||||
const emailReminderBody = emailReminderTemplate(true, val.value);
|
||||
form.setValue(
|
||||
`steps.${step.stepNumber - 1}.reminderBody`,
|
||||
emailReminderBody.emailBody
|
||||
);
|
||||
form.setValue(
|
||||
`steps.${step.stepNumber - 1}.emailSubject`,
|
||||
emailReminderBody.emailSubject
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
form.unregister(`steps.${step.stepNumber - 1}.sendTo`);
|
||||
form.clearErrors(`steps.${step.stepNumber - 1}.sendTo`);
|
||||
if (
|
||||
val.value === WorkflowActions.EMAIL_ATTENDEE ||
|
||||
val.value === WorkflowActions.EMAIL_HOST ||
|
||||
val.value === WorkflowActions.EMAIL_ADDRESS
|
||||
) {
|
||||
setIsEmailSubjectNeeded(true);
|
||||
} else {
|
||||
setIsEmailSubjectNeeded(false);
|
||||
}
|
||||
form.setValue(`steps.${step.stepNumber - 1}.action`, val.value);
|
||||
setUpdateTemplate(!updateTemplate);
|
||||
}
|
||||
}}
|
||||
defaultValue={selectedAction}
|
||||
@@ -478,7 +542,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="bg-muted-4 mt-2 rounded-md pt-0">
|
||||
<div className="bg-muted mt-2 rounded-md p-4 pt-0">
|
||||
{isSenderIdNeeded ? (
|
||||
<>
|
||||
<div className="pt-4">
|
||||
@@ -528,7 +592,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
|
||||
</div>
|
||||
)}
|
||||
{isEmailAddressNeeded && (
|
||||
<div className="bg-muted-4 mt-5 rounded-md">
|
||||
<div className="bg-muted mt-5 rounded-md p-4">
|
||||
<EmailField
|
||||
required
|
||||
label={t("email_address")}
|
||||
@@ -548,9 +612,37 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
|
||||
className="text-sm"
|
||||
onChange={(val) => {
|
||||
if (val) {
|
||||
if (val.value === WorkflowTemplates.REMINDER) {
|
||||
if (isSMSAction(form.getValues(`steps.${step.stepNumber - 1}.action`))) {
|
||||
form.setValue(
|
||||
`steps.${step.stepNumber - 1}.reminderBody`,
|
||||
smsReminderTemplate(
|
||||
true,
|
||||
form.getValues(`steps.${step.stepNumber - 1}.action`)
|
||||
)
|
||||
);
|
||||
} else {
|
||||
form.setValue(
|
||||
`steps.${step.stepNumber - 1}.reminderBody`,
|
||||
emailReminderTemplate(
|
||||
true,
|
||||
form.getValues(`steps.${step.stepNumber - 1}.action`)
|
||||
).emailBody
|
||||
);
|
||||
form.setValue(
|
||||
`steps.${step.stepNumber - 1}.emailSubject`,
|
||||
emailReminderTemplate(
|
||||
true,
|
||||
form.getValues(`steps.${step.stepNumber - 1}.action`)
|
||||
).emailSubject
|
||||
);
|
||||
}
|
||||
} else {
|
||||
form.setValue(`steps.${step.stepNumber - 1}.reminderBody`, "");
|
||||
form.setValue(`steps.${step.stepNumber - 1}.emailSubject`, "");
|
||||
}
|
||||
form.setValue(`steps.${step.stepNumber - 1}.template`, val.value);
|
||||
const isCustomTemplate = val.value === WorkflowTemplates.CUSTOM;
|
||||
setIsCustomReminderBodyNeeded(isCustomTemplate);
|
||||
setUpdateTemplate(!updateTemplate);
|
||||
}
|
||||
}}
|
||||
defaultValue={selectedTemplate}
|
||||
@@ -560,98 +652,100 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{isCustomReminderBodyNeeded && (
|
||||
<div className="bg-muted-4 mt-2 rounded-md pt-2 md:p-6 md:pt-4">
|
||||
{isEmailSubjectNeeded && (
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center">
|
||||
<Label className="mb-0 flex-none">{t("subject")}</Label>
|
||||
<div className="flex-grow text-right">
|
||||
<AddVariablesDropdown
|
||||
addVariable={addVariableEmailSubject}
|
||||
variables={DYNAMIC_TEXT_VARIABLES}
|
||||
/>
|
||||
</div>
|
||||
<div className="bg-muted mt-2 rounded-md pt-2 md:p-6 md:pt-4">
|
||||
{isEmailSubjectNeeded && (
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center">
|
||||
<Label className="mb-0 flex-none">{t("subject")}</Label>
|
||||
<div className="flex-grow text-right">
|
||||
<AddVariablesDropdown
|
||||
addVariable={addVariableEmailSubject}
|
||||
variables={DYNAMIC_TEXT_VARIABLES}
|
||||
/>
|
||||
</div>
|
||||
<TextArea
|
||||
ref={(e) => {
|
||||
emailSubjectFormRef?.(e);
|
||||
refEmailSubject.current = e;
|
||||
}}
|
||||
rows={1}
|
||||
className="my-0 focus:ring-transparent"
|
||||
required
|
||||
{...restEmailSubjectForm}
|
||||
/>
|
||||
{form.formState.errors.steps &&
|
||||
form.formState?.errors?.steps[step.stepNumber - 1]?.emailSubject && (
|
||||
<p className="mt-1 text-xs text-red-500">
|
||||
{form.formState?.errors?.steps[step.stepNumber - 1]?.emailSubject?.message || ""}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step.action !== WorkflowActions.SMS_ATTENDEE &&
|
||||
step.action !== WorkflowActions.SMS_NUMBER ? (
|
||||
<>
|
||||
<div className="mb-2 flex items-center pb-[1.5px]">
|
||||
<Label className="mb-0 flex-none ">
|
||||
{isEmailSubjectNeeded ? t("email_body") : t("text_message")}
|
||||
</Label>
|
||||
</div>
|
||||
<Editor
|
||||
getText={() => {
|
||||
return props.form.getValues(`steps.${step.stepNumber - 1}.reminderBody`) || "";
|
||||
}}
|
||||
setText={(text: string) => {
|
||||
props.form.setValue(`steps.${step.stepNumber - 1}.reminderBody`, text);
|
||||
props.form.clearErrors();
|
||||
}}
|
||||
variables={DYNAMIC_TEXT_VARIABLES}
|
||||
height="200px"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center">
|
||||
<Label className="mb-0 flex-none">
|
||||
{isEmailSubjectNeeded ? t("email_body") : t("text_message")}
|
||||
</Label>
|
||||
<div className="flex-grow text-right">
|
||||
<AddVariablesDropdown
|
||||
addVariable={addVariableBody}
|
||||
variables={DYNAMIC_TEXT_VARIABLES}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<TextArea
|
||||
ref={(e) => {
|
||||
reminderBodyFormRef?.(e);
|
||||
refReminderBody.current = e;
|
||||
}}
|
||||
className="my-0 h-24"
|
||||
required
|
||||
{...restReminderBodyForm}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{form.formState.errors.steps &&
|
||||
form.formState?.errors?.steps[step.stepNumber - 1]?.reminderBody && (
|
||||
<p className="mt-1 text-sm text-red-500">
|
||||
{form.formState?.errors?.steps[step.stepNumber - 1]?.reminderBody?.message || ""}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-3 ">
|
||||
<button type="button" onClick={() => setIsAdditionalInputsDialogOpen(true)}>
|
||||
<div className="text-default mt-2 flex text-sm">
|
||||
<HelpCircle className="mt-[3px] h-3 w-3 ltr:mr-2 rtl:ml-2" />
|
||||
<p className="text-left">{t("using_additional_inputs_as_variables")}</p>
|
||||
</div>
|
||||
</button>
|
||||
<TextArea
|
||||
ref={(e) => {
|
||||
emailSubjectFormRef?.(e);
|
||||
refEmailSubject.current = e;
|
||||
}}
|
||||
rows={1}
|
||||
className="my-0 focus:ring-transparent"
|
||||
required
|
||||
{...restEmailSubjectForm}
|
||||
/>
|
||||
{form.formState.errors.steps &&
|
||||
form.formState?.errors?.steps[step.stepNumber - 1]?.emailSubject && (
|
||||
<p className="mt-1 text-xs text-red-500">
|
||||
{form.formState?.errors?.steps[step.stepNumber - 1]?.emailSubject?.message || ""}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step.action !== WorkflowActions.SMS_ATTENDEE &&
|
||||
step.action !== WorkflowActions.SMS_NUMBER ? (
|
||||
<>
|
||||
<div className="mb-2 flex items-center pb-[1.5px]">
|
||||
<Label className="mb-0 flex-none ">
|
||||
{isEmailSubjectNeeded ? t("email_body") : t("text_message")}
|
||||
</Label>
|
||||
</div>
|
||||
<Editor
|
||||
getText={() => {
|
||||
return props.form.getValues(`steps.${step.stepNumber - 1}.reminderBody`) || "";
|
||||
}}
|
||||
setText={(text: string) => {
|
||||
props.form.setValue(`steps.${step.stepNumber - 1}.reminderBody`, text);
|
||||
props.form.clearErrors();
|
||||
}}
|
||||
variables={DYNAMIC_TEXT_VARIABLES}
|
||||
height="200px"
|
||||
updateTemplate={updateTemplate}
|
||||
firstRender={firstRender}
|
||||
setFirstRender={setFirstRender}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center">
|
||||
<Label className="mb-0 flex-none">
|
||||
{isEmailSubjectNeeded ? t("email_body") : t("text_message")}
|
||||
</Label>
|
||||
<div className="flex-grow text-right">
|
||||
<AddVariablesDropdown
|
||||
addVariable={addVariableBody}
|
||||
variables={DYNAMIC_TEXT_VARIABLES}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<TextArea
|
||||
ref={(e) => {
|
||||
reminderBodyFormRef?.(e);
|
||||
refReminderBody.current = e;
|
||||
}}
|
||||
className="my-0 h-24"
|
||||
required
|
||||
{...restReminderBodyForm}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{form.formState.errors.steps &&
|
||||
form.formState?.errors?.steps[step.stepNumber - 1]?.reminderBody && (
|
||||
<p className="mt-1 text-sm text-red-500">
|
||||
{form.formState?.errors?.steps[step.stepNumber - 1]?.reminderBody?.message || ""}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-3 ">
|
||||
<button type="button" onClick={() => setIsAdditionalInputsDialogOpen(true)}>
|
||||
<div className="text-default mt-2 flex text-sm">
|
||||
<HelpCircle className="mt-[3px] h-3 w-3 ltr:mr-2 rtl:ml-2" />
|
||||
<p className="text-left">{t("using_booking_questions_as_variables")}</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* {form.getValues(`steps.${step.stepNumber - 1}.action`) !== WorkflowActions.SMS_ATTENDEE && (
|
||||
<Button
|
||||
type="button"
|
||||
@@ -754,19 +848,19 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
|
||||
<Dialog open={isAdditionalInputsDialogOpen} onOpenChange={setIsAdditionalInputsDialogOpen}>
|
||||
<DialogContent type="creation" className="sm:max-w-[610px] md:h-[570px]">
|
||||
<div className="-m-3 h-[430px] overflow-x-hidden overflow-y-scroll sm:m-0">
|
||||
<h1 className="w-full text-xl font-semibold ">{t("how_additional_inputs_as_variables")}</h1>
|
||||
<h1 className="w-full text-xl font-semibold ">{t("how_booking_questions_as_variables")}</h1>
|
||||
<div className="bg-muted-3 mb-7 mt-7 rounded-md sm:p-4">
|
||||
<p className="test-sm font-medium">{t("format")}</p>
|
||||
<ul className="text-emphasis mt-2 ml-5 list-disc">
|
||||
<li>{t("uppercase_for_letters")}</li>
|
||||
<li>{t("replace_whitespaces_underscores")}</li>
|
||||
<li>{t("ignore_special_characters")}</li>
|
||||
<li>{t("ignore_special_characters_booking_questions")}</li>
|
||||
</ul>
|
||||
<div className="mt-6">
|
||||
<p className="test-sm w-full font-medium">{t("example_1")}</p>
|
||||
<div className="mt-2 grid grid-cols-12">
|
||||
<div className="test-sm text-default col-span-5 ltr:mr-2 rtl:ml-2">
|
||||
{t("additional_input_label")}
|
||||
{t("booking_question_identifier")}
|
||||
</div>
|
||||
<div className="test-sm text-emphasis col-span-7">{t("company_size")}</div>
|
||||
<div className="test-sm text-default col-span-5 w-full">{t("variable")}</div>
|
||||
@@ -785,7 +879,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
|
||||
<p className="test-sm w-full font-medium">{t("example_2")}</p>
|
||||
<div className="mt-2 grid grid-cols-12">
|
||||
<div className="test-sm text-default col-span-5 ltr:mr-2 rtl:ml-2">
|
||||
{t("additional_input_label")}
|
||||
{t("booking_question_identifier")}
|
||||
</div>
|
||||
<div className="test-sm text-emphasis col-span-7">{t("what_help_needed")}</div>
|
||||
<div className="test-sm text-default col-span-5">{t("variable")}</div>
|
||||
|
||||
+4
@@ -3,3 +3,7 @@ import { WorkflowActions } from "@prisma/client";
|
||||
export function isSMSAction(action: WorkflowActions) {
|
||||
return action === WorkflowActions.SMS_ATTENDEE || action === WorkflowActions.SMS_NUMBER;
|
||||
}
|
||||
|
||||
export function isAttendeeAction(action: WorkflowActions) {
|
||||
return action === WorkflowActions.SMS_ATTENDEE || action === WorkflowActions.EMAIL_ATTENDEE;
|
||||
}
|
||||
@@ -24,10 +24,16 @@ export const DYNAMIC_TEXT_VARIABLES = [
|
||||
"event_name",
|
||||
"event_date",
|
||||
"event_time",
|
||||
"event_end_time",
|
||||
"timezone",
|
||||
"location",
|
||||
"organizer_name",
|
||||
"attendee_name",
|
||||
"attendee_email",
|
||||
"additional_notes",
|
||||
"meeting_url",
|
||||
"cancel_url",
|
||||
"reschedule_url",
|
||||
];
|
||||
|
||||
export const FORMATTED_DYNAMIC_TEXT_VARIABLES = ["event_date_", "event_time_", "event_end_time_"];
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { WorkflowActions } from "@prisma/client";
|
||||
import { TFunction } from "next-i18next";
|
||||
import type { TFunction } from "next-i18next";
|
||||
|
||||
import { isSMSAction } from "./actionHelperFunctions";
|
||||
import { TIME_UNIT, WORKFLOW_ACTIONS, WORKFLOW_TEMPLATES, WORKFLOW_TRIGGER_EVENTS } from "./constants";
|
||||
import { isSMSAction } from "./isSMSAction";
|
||||
|
||||
export function getWorkflowActionOptions(t: TFunction, isTeamsPlan?: boolean) {
|
||||
return WORKFLOW_ACTIONS.filter((action) => action !== WorkflowActions.EMAIL_ADDRESS) //removing EMAIL_ADDRESS for now due to abuse episode
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { TimeUnit } from "@prisma/client";
|
||||
import { WorkflowTriggerEvents, WorkflowTemplates, WorkflowActions, WorkflowMethods } from "@prisma/client";
|
||||
import { WorkflowTemplates } from "@prisma/client";
|
||||
import { WorkflowTriggerEvents, WorkflowActions, WorkflowMethods } from "@prisma/client";
|
||||
import client from "@sendgrid/client";
|
||||
import type { MailData } from "@sendgrid/helpers/classes/mail";
|
||||
import sgMail from "@sendgrid/mail";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
@@ -15,6 +17,7 @@ import emailReminderTemplate from "./templates/emailReminderTemplate";
|
||||
|
||||
let sendgridAPIKey, senderEmail: string;
|
||||
|
||||
const log = logger.getChildLogger({ prefix: ["[emailReminderManager]"] });
|
||||
if (process.env.SENDGRID_API_KEY) {
|
||||
sendgridAPIKey = process.env.SENDGRID_API_KEY as string;
|
||||
senderEmail = process.env.SENDGRID_EMAIL as string;
|
||||
@@ -36,7 +39,8 @@ export const scheduleEmailReminder = async (
|
||||
emailBody: string,
|
||||
workflowStepId: number,
|
||||
template: WorkflowTemplates,
|
||||
sender: string
|
||||
sender: string,
|
||||
hideBranding?: boolean
|
||||
) => {
|
||||
if (action === WorkflowActions.EMAIL_ADDRESS) return;
|
||||
const { startTime, endTime } = evt;
|
||||
@@ -51,16 +55,12 @@ export const scheduleEmailReminder = async (
|
||||
} else if (triggerEvent === WorkflowTriggerEvents.AFTER_EVENT) {
|
||||
scheduledDate = timeSpan.time && timeUnit ? dayjs(endTime).add(timeSpan.time, timeUnit) : null;
|
||||
}
|
||||
|
||||
if (!process.env.SENDGRID_API_KEY || !process.env.SENDGRID_EMAIL) {
|
||||
console.error("Sendgrid credentials are missing from the .env file");
|
||||
return;
|
||||
}
|
||||
|
||||
const batchIdResponse = await client.request({
|
||||
url: "/v3/mail/batch",
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
let name = "";
|
||||
let attendeeName = "";
|
||||
let timeZone = "";
|
||||
@@ -80,42 +80,54 @@ export const scheduleEmailReminder = async (
|
||||
|
||||
let emailContent = {
|
||||
emailSubject,
|
||||
emailBody: {
|
||||
text: emailBody,
|
||||
html: `<body style="white-space: pre-wrap;">${emailBody}</body>`,
|
||||
},
|
||||
emailBody: `<body style="white-space: pre-wrap;">${emailBody}</body>`,
|
||||
};
|
||||
if (emailBody) {
|
||||
const variables: VariablesType = {
|
||||
eventName: evt.title || "",
|
||||
organizerName: evt.organizer.name,
|
||||
attendeeName: evt.attendees[0].name,
|
||||
attendeeEmail: evt.attendees[0].email,
|
||||
eventDate: dayjs(startTime).tz(timeZone),
|
||||
eventEndTime: dayjs(endTime).tz(timeZone),
|
||||
timeZone: timeZone,
|
||||
location: evt.location,
|
||||
additionalNotes: evt.additionalNotes,
|
||||
responses: evt.responses,
|
||||
meetingUrl: bookingMetadataSchema.parse(evt.metadata || {})?.videoCallUrl,
|
||||
cancelLink: `/booking/${evt.uid}?cancel=true`,
|
||||
rescheduleLink: `/${evt.organizer.username}/${evt.eventType.slug}?rescheduleUid=${evt.uid}`,
|
||||
};
|
||||
|
||||
switch (template) {
|
||||
case WorkflowTemplates.REMINDER:
|
||||
emailContent = emailReminderTemplate(startTime, endTime, evt.title, timeZone, attendeeName, name);
|
||||
break;
|
||||
case WorkflowTemplates.CUSTOM:
|
||||
const variables: VariablesType = {
|
||||
eventName: evt.title || "",
|
||||
organizerName: evt.organizer.name,
|
||||
attendeeName: evt.attendees[0].name,
|
||||
attendeeEmail: evt.attendees[0].email,
|
||||
eventDate: dayjs(startTime).tz(timeZone),
|
||||
eventTime: dayjs(startTime).tz(timeZone),
|
||||
timeZone: timeZone,
|
||||
location: evt.location,
|
||||
additionalNotes: evt.additionalNotes,
|
||||
responses: evt.responses,
|
||||
meetingUrl: bookingMetadataSchema.parse(evt.metadata || {})?.videoCallUrl,
|
||||
};
|
||||
const locale =
|
||||
action === WorkflowActions.EMAIL_ATTENDEE || action === WorkflowActions.SMS_ATTENDEE
|
||||
? evt.attendees[0].language?.locale
|
||||
: evt.organizer.language.locale;
|
||||
|
||||
const locale =
|
||||
action === WorkflowActions.EMAIL_ATTENDEE || action === WorkflowActions.SMS_ATTENDEE
|
||||
? evt.attendees[0].language?.locale
|
||||
: evt.organizer.language.locale;
|
||||
|
||||
const emailSubjectTemplate = await customTemplate(emailSubject, variables, locale);
|
||||
emailContent.emailSubject = emailSubjectTemplate.text;
|
||||
emailContent.emailBody = await customTemplate(emailBody, variables, locale);
|
||||
break;
|
||||
const emailSubjectTemplate = customTemplate(emailSubject, variables, locale);
|
||||
emailContent.emailSubject = emailSubjectTemplate.text;
|
||||
emailContent.emailBody = customTemplate(emailBody, variables, locale, hideBranding).html;
|
||||
} else if (template === WorkflowTemplates.REMINDER) {
|
||||
emailContent = emailReminderTemplate(
|
||||
false,
|
||||
action,
|
||||
startTime,
|
||||
endTime,
|
||||
evt.title,
|
||||
timeZone,
|
||||
attendeeName,
|
||||
name
|
||||
);
|
||||
}
|
||||
|
||||
// Allows debugging generated email content without waiting for sendgrid to send emails
|
||||
log.debug(`Sending Email for trigger ${triggerEvent}`, JSON.stringify(emailContent));
|
||||
|
||||
const batchIdResponse = await client.request({
|
||||
url: "/v3/mail/batch",
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
if (
|
||||
triggerEvent === WorkflowTriggerEvents.NEW_EVENT ||
|
||||
triggerEvent === WorkflowTriggerEvents.EVENT_CANCELLED ||
|
||||
@@ -129,8 +141,7 @@ export const scheduleEmailReminder = async (
|
||||
name: sender,
|
||||
},
|
||||
subject: emailContent.emailSubject,
|
||||
text: emailContent.emailBody.text,
|
||||
html: emailContent.emailBody.html,
|
||||
html: emailContent.emailBody,
|
||||
batchId: batchIdResponse[1].batch_id,
|
||||
replyTo: evt.organizer.email,
|
||||
});
|
||||
@@ -156,8 +167,7 @@ export const scheduleEmailReminder = async (
|
||||
name: sender,
|
||||
},
|
||||
subject: emailContent.emailSubject,
|
||||
text: emailContent.emailBody.text,
|
||||
html: emailContent.emailBody.html,
|
||||
html: emailContent.emailBody,
|
||||
batchId: batchIdResponse[1].batch_id,
|
||||
sendAt: scheduledDate.unix(),
|
||||
replyTo: evt.organizer.email,
|
||||
|
||||
@@ -8,6 +8,11 @@ import type { CalendarEvent } from "@calcom/types/Calendar";
|
||||
import { scheduleEmailReminder } from "./emailReminderManager";
|
||||
import { scheduleSMSReminder } from "./smsReminderManager";
|
||||
|
||||
type ExtendedCalendarEvent = CalendarEvent & {
|
||||
metadata?: { videoCallUrl: string | undefined };
|
||||
eventType: { slug?: string };
|
||||
};
|
||||
|
||||
export interface ScheduleWorkflowRemindersArgs {
|
||||
workflows: (WorkflowsOnEventTypes & {
|
||||
workflow: Workflow & {
|
||||
@@ -15,11 +20,12 @@ export interface ScheduleWorkflowRemindersArgs {
|
||||
};
|
||||
})[];
|
||||
smsReminderNumber: string | null;
|
||||
calendarEvent: CalendarEvent & { metadata?: { videoCallUrl: string | undefined } };
|
||||
calendarEvent: ExtendedCalendarEvent;
|
||||
requiresConfirmation?: boolean;
|
||||
isRescheduleEvent?: boolean;
|
||||
isFirstRecurringEvent?: boolean;
|
||||
emailAttendeeSendToOverride?: string;
|
||||
hideBranding?: boolean;
|
||||
}
|
||||
|
||||
export const scheduleWorkflowReminders = async (args: ScheduleWorkflowRemindersArgs) => {
|
||||
@@ -31,6 +37,7 @@ export const scheduleWorkflowReminders = async (args: ScheduleWorkflowRemindersA
|
||||
isRescheduleEvent = false,
|
||||
isFirstRecurringEvent = false,
|
||||
emailAttendeeSendToOverride = "",
|
||||
hideBranding,
|
||||
} = args;
|
||||
if (workflows.length > 0 && !requiresConfirmation) {
|
||||
for (const workflowReference of workflows) {
|
||||
@@ -95,7 +102,8 @@ export const scheduleWorkflowReminders = async (args: ScheduleWorkflowRemindersA
|
||||
step.reminderBody || "",
|
||||
step.id,
|
||||
step.template,
|
||||
step.sender || SENDER_NAME
|
||||
step.sender || SENDER_NAME,
|
||||
hideBranding
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -111,11 +119,12 @@ export interface SendCancelledRemindersArgs {
|
||||
};
|
||||
})[];
|
||||
smsReminderNumber: string | null;
|
||||
evt: CalendarEvent;
|
||||
evt: ExtendedCalendarEvent;
|
||||
hideBranding?: boolean;
|
||||
}
|
||||
|
||||
export const sendCancelledReminders = async (args: SendCancelledRemindersArgs) => {
|
||||
const { workflows, smsReminderNumber, evt } = args;
|
||||
const { workflows, smsReminderNumber, evt, hideBranding } = args;
|
||||
|
||||
if (workflows.length > 0) {
|
||||
for (const workflowRef of workflows) {
|
||||
@@ -170,7 +179,8 @@ export const sendCancelledReminders = async (args: SendCancelledRemindersArgs) =
|
||||
step.reminderBody || "",
|
||||
step.id,
|
||||
step.template,
|
||||
step.sender || SENDER_NAME
|
||||
step.sender || SENDER_NAME,
|
||||
hideBranding
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,11 @@ import type { TimeUnit } from "@prisma/client";
|
||||
import { WorkflowTriggerEvents, WorkflowTemplates, WorkflowActions, WorkflowMethods } from "@prisma/client";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
import type { CalEventResponses } from "@calcom/types/Calendar";
|
||||
|
||||
import { getSenderId } from "../alphanumericSenderIdSupport";
|
||||
import * as twilio from "./smsProviders/twilioProvider";
|
||||
@@ -17,6 +19,7 @@ export enum timeUnitLowerCase {
|
||||
MINUTE = "minute",
|
||||
YEAR = "year",
|
||||
}
|
||||
const log = logger.getChildLogger({ prefix: ["[smsReminderManager]"] });
|
||||
|
||||
export type BookingInfo = {
|
||||
uid?: string | null;
|
||||
@@ -26,13 +29,17 @@ export type BookingInfo = {
|
||||
name: string;
|
||||
email: string;
|
||||
timeZone: string;
|
||||
username?: string;
|
||||
};
|
||||
eventType: {
|
||||
slug?: string;
|
||||
};
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
title: string;
|
||||
location?: string | null;
|
||||
additionalNotes?: string | null;
|
||||
responses?: Prisma.JsonValue;
|
||||
responses?: CalEventResponses | null;
|
||||
metadata?: Prisma.JsonValue;
|
||||
};
|
||||
|
||||
@@ -92,29 +99,32 @@ export const scheduleSMSReminder = async (
|
||||
? evt.attendees[0].language?.locale
|
||||
: evt.organizer.language.locale;
|
||||
|
||||
switch (template) {
|
||||
case WorkflowTemplates.REMINDER:
|
||||
message = smsReminderTemplate(evt.startTime, evt.title, timeZone, attendeeName, name) || message;
|
||||
break;
|
||||
case WorkflowTemplates.CUSTOM:
|
||||
const variables: VariablesType = {
|
||||
eventName: evt.title,
|
||||
organizerName: evt.organizer.name,
|
||||
attendeeName: evt.attendees[0].name,
|
||||
attendeeEmail: evt.attendees[0].email,
|
||||
eventDate: dayjs(evt.startTime).tz(timeZone),
|
||||
eventTime: dayjs(evt.startTime).tz(timeZone),
|
||||
timeZone: timeZone,
|
||||
location: evt.location,
|
||||
additionalNotes: evt.additionalNotes,
|
||||
responses: evt.responses,
|
||||
meetingUrl: bookingMetadataSchema.parse(evt.metadata || {})?.videoCallUrl,
|
||||
};
|
||||
const customMessage = await customTemplate(message, variables, locale);
|
||||
message = customMessage.text;
|
||||
break;
|
||||
if (message) {
|
||||
const variables: VariablesType = {
|
||||
eventName: evt.title,
|
||||
organizerName: evt.organizer.name,
|
||||
attendeeName: evt.attendees[0].name,
|
||||
attendeeEmail: evt.attendees[0].email,
|
||||
eventDate: dayjs(evt.startTime).tz(timeZone),
|
||||
eventEndTime: dayjs(evt.endTime).tz(timeZone),
|
||||
timeZone: timeZone,
|
||||
location: evt.location,
|
||||
additionalNotes: evt.additionalNotes,
|
||||
responses: evt.responses,
|
||||
meetingUrl: bookingMetadataSchema.parse(evt.metadata || {})?.videoCallUrl,
|
||||
cancelLink: `/booking/${evt.uid}?cancel=true`,
|
||||
rescheduleLink: `/${evt.organizer.username}/${evt.eventType.slug}?rescheduleUid=${evt.uid}`,
|
||||
};
|
||||
const customMessage = customTemplate(message, variables, locale);
|
||||
message = customMessage.text;
|
||||
} else if (template === WorkflowTemplates.REMINDER) {
|
||||
message =
|
||||
smsReminderTemplate(false, action, evt.startTime, evt.title, timeZone, attendeeName, name) || message;
|
||||
}
|
||||
|
||||
// Allows debugging generated email content without waiting for sendgrid to send emails
|
||||
log.debug(`Sending sms for trigger ${triggerEvent}`, message);
|
||||
|
||||
if (message.length > 0 && reminderPhone && isNumberVerified) {
|
||||
//send SMS when event is booked/cancelled/rescheduled
|
||||
if (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { guessEventLocationType } from "@calcom/app-store/locations";
|
||||
import type { Dayjs } from "@calcom/dayjs";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
import { APP_NAME, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import type { CalEventResponses } from "@calcom/types/Calendar";
|
||||
|
||||
export type VariablesType = {
|
||||
eventName?: string;
|
||||
@@ -8,15 +9,22 @@ export type VariablesType = {
|
||||
attendeeName?: string;
|
||||
attendeeEmail?: string;
|
||||
eventDate?: Dayjs;
|
||||
eventTime?: Dayjs;
|
||||
eventEndTime?: Dayjs;
|
||||
timeZone?: string;
|
||||
location?: string | null;
|
||||
additionalNotes?: string | null;
|
||||
responses?: Prisma.JsonValue;
|
||||
responses?: CalEventResponses | null;
|
||||
meetingUrl?: string;
|
||||
cancelLink?: string;
|
||||
rescheduleLink?: string;
|
||||
};
|
||||
|
||||
const customTemplate = async (text: string, variables: VariablesType, locale: string) => {
|
||||
const customTemplate = (
|
||||
text: string,
|
||||
variables: VariablesType,
|
||||
locale: string,
|
||||
isBrandingDisabled?: boolean
|
||||
) => {
|
||||
const translatedDate = new Intl.DateTimeFormat(locale, {
|
||||
weekday: "long",
|
||||
month: "long",
|
||||
@@ -24,13 +32,15 @@ const customTemplate = async (text: string, variables: VariablesType, locale: st
|
||||
year: "numeric",
|
||||
}).format(variables.eventDate?.toDate());
|
||||
|
||||
const timeWithTimeZone = `${variables.eventTime?.format("HH:mm")} (${variables.timeZone})`;
|
||||
let locationString = variables.location || "";
|
||||
|
||||
if (text.includes("{LOCATION}")) {
|
||||
locationString = guessEventLocationType(locationString)?.label || locationString;
|
||||
}
|
||||
|
||||
const cancelLink = variables.cancelLink ? `${WEBAPP_URL}${variables.cancelLink}` : "";
|
||||
const rescheduleLink = variables.rescheduleLink ? `${WEBAPP_URL}${variables.rescheduleLink}` : "";
|
||||
|
||||
let dynamicText = text
|
||||
.replaceAll("{EVENT_NAME}", variables.eventName || "")
|
||||
.replaceAll("{ORGANIZER}", variables.organizerName || "")
|
||||
@@ -38,17 +48,36 @@ const customTemplate = async (text: string, variables: VariablesType, locale: st
|
||||
.replaceAll("{ORGANIZER_NAME}", variables.organizerName || "") //old variable names
|
||||
.replaceAll("{ATTENDEE_NAME}", variables.attendeeName || "") //old variable names
|
||||
.replaceAll("{EVENT_DATE}", translatedDate)
|
||||
.replaceAll("{EVENT_TIME}", timeWithTimeZone)
|
||||
.replaceAll("{EVENT_TIME}", variables.eventDate?.format("H:mmA") || "")
|
||||
.replaceAll("{EVENT_END_TIME}", variables.eventEndTime?.format("H:mmA") || "")
|
||||
.replaceAll("{LOCATION}", locationString)
|
||||
.replaceAll("{ADDITIONAL_NOTES}", variables.additionalNotes || "")
|
||||
.replaceAll("{ATTENDEE_EMAIL}", variables.attendeeEmail || "")
|
||||
.replaceAll("{TIMEZONE}", variables.timeZone || "")
|
||||
.replaceAll("{CANCEL_URL}", cancelLink)
|
||||
.replaceAll("{RESCHEDULE_URL}", rescheduleLink)
|
||||
.replaceAll("{MEETING_URL}", variables.meetingUrl || "");
|
||||
|
||||
const customInputvariables = dynamicText.match(/\{(.+?)}/g)?.map((variable) => {
|
||||
return variable.replace("{", "").replace("}", "");
|
||||
});
|
||||
|
||||
// event date/time with formatting
|
||||
customInputvariables?.forEach((variable) => {
|
||||
if (variable.startsWith("EVENT_DATE_") || variable.startsWith("EVENT_TIME_")) {
|
||||
const dateFormat = variable.substring(11, text.length);
|
||||
const formattedDate = variables.eventDate?.format(dateFormat);
|
||||
dynamicText = dynamicText.replace(`{${variable}}`, formattedDate || "");
|
||||
return;
|
||||
}
|
||||
|
||||
if (variable.startsWith("EVENT_END_TIME_")) {
|
||||
const dateFormat = variable.substring(15, text.length);
|
||||
const formattedDate = variables.eventEndTime?.format(dateFormat);
|
||||
dynamicText = dynamicText.replace(`{${variable}}`, formattedDate || "");
|
||||
return;
|
||||
}
|
||||
|
||||
if (variables.responses) {
|
||||
Object.keys(variables.responses).forEach((customInput) => {
|
||||
const formatedToVariable = customInput
|
||||
@@ -56,17 +85,24 @@ const customTemplate = async (text: string, variables: VariablesType, locale: st
|
||||
.trim()
|
||||
.replaceAll(" ", "_")
|
||||
.toUpperCase();
|
||||
if (variable === formatedToVariable && variables.responses) {
|
||||
|
||||
if (
|
||||
variable === formatedToVariable &&
|
||||
variables.responses &&
|
||||
variables.responses[customInput as keyof typeof variables.responses].value
|
||||
) {
|
||||
dynamicText = dynamicText.replace(
|
||||
`{${variable}}`,
|
||||
variables.responses[customInput as keyof typeof variables.responses]
|
||||
variables.responses[customInput as keyof typeof variables.responses].value.toString()
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const textHtml = `<body style="white-space: pre-wrap;">${dynamicText}</body>`;
|
||||
const branding = !isBrandingDisabled ? `<br><br>_<br><br>Scheduling by ${APP_NAME}` : "";
|
||||
|
||||
const textHtml = `<body style="white-space: pre-wrap;">${dynamicText}${branding}</body>`;
|
||||
return { text: dynamicText, html: textHtml };
|
||||
};
|
||||
|
||||
|
||||
@@ -1,43 +1,50 @@
|
||||
import { WorkflowActions } from "@prisma/client";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
|
||||
const emailReminderTemplate = (
|
||||
startTime: string,
|
||||
endTime: string,
|
||||
eventName: string,
|
||||
timeZone: string,
|
||||
attendee: string,
|
||||
name: string
|
||||
isEditingMode: boolean,
|
||||
action?: WorkflowActions,
|
||||
startTime?: string,
|
||||
endTime?: string,
|
||||
eventName?: string,
|
||||
timeZone?: string,
|
||||
otherPerson?: string,
|
||||
name?: string,
|
||||
isBrandingDisabled?: boolean
|
||||
) => {
|
||||
const emailSubject = `Reminder: ${eventName} - ${dayjs(startTime)
|
||||
.tz(timeZone)
|
||||
.format("ddd, MMM D, YYYY")} ${dayjs(startTime).tz(timeZone).format("H:mmA")}`;
|
||||
let eventDate = "";
|
||||
|
||||
const templateBodyText = `Hi ${name},this is a reminder that your meeting (${eventName}) with ${attendee} is on ${dayjs(
|
||||
startTime
|
||||
)
|
||||
.tz(timeZone)
|
||||
.format("YYYY MMM D")} at ${dayjs(startTime).tz(timeZone).format("h:mmA")} ${timeZone}.`;
|
||||
if (isEditingMode) {
|
||||
endTime = "{EVENT_END_TIME}";
|
||||
eventName = "{EVENT_NAME}";
|
||||
timeZone = "{TIMEZONE}";
|
||||
otherPerson = action === WorkflowActions.EMAIL_ATTENDEE ? "{ORGANIZER}" : "{ATTENDEE}";
|
||||
name = action === WorkflowActions.EMAIL_ATTENDEE ? "{ATTENDEE}" : "{ORGANIZER}";
|
||||
eventDate = "{EVENT_DATE_ddd, MMM D, YYYY H:mmA}";
|
||||
} else {
|
||||
eventDate = dayjs(startTime).tz(timeZone).format("ddd, MMM D, YYYY H:mmA");
|
||||
|
||||
endTime = dayjs(endTime).tz(timeZone).format("H:mmA");
|
||||
}
|
||||
const emailSubject = `Reminder: ${eventName} - ${eventDate}`;
|
||||
|
||||
const introHtml = `<body>Hi${
|
||||
name ? " " + name : ""
|
||||
},<br><br>This is a reminder about your upcoming event.<br><br>`;
|
||||
|
||||
const eventHtml = `<div style="font-weight: bold;">Event:</div>${eventName}<br><br>`;
|
||||
const eventHtml = `<div><strong class="editor-text-bold">Event:</strong></div>${eventName}<br><br>`;
|
||||
|
||||
const dateTimeHtml = `<div style="font-weight: bold;">Date & Time:</div>${dayjs(startTime)
|
||||
.tz(timeZone)
|
||||
.format("ddd, MMM D, YYYY H:mmA")} - ${dayjs(endTime)
|
||||
.tz(timeZone)
|
||||
.format("H:mmA")} (${timeZone})<br><br>`;
|
||||
const dateTimeHtml = `<div><strong class="editor-text-bold">Date & Time:</strong></div>${eventDate} - ${endTime} (${timeZone})<br><br>`;
|
||||
|
||||
const attendeeHtml = `<div style="font-weight: bold;">Attendees:</div>You & ${attendee}<br><br>`;
|
||||
const attendeeHtml = `<div><strong class="editor-text-bold">Attendees:</strong></div>You & ${otherPerson}<br><br>`;
|
||||
|
||||
const endingHtml = `This reminder was triggered by a Workflow in Cal.<br><br>_<br><br>Scheduling by ${APP_NAME}</body>`;
|
||||
const branding = !isBrandingDisabled && !isEditingMode ? `<br><br>_<br><br>Scheduling by ${APP_NAME}` : "";
|
||||
|
||||
const templateBodyHtml = introHtml + eventHtml + dateTimeHtml + attendeeHtml + endingHtml;
|
||||
const endingHtml = `This reminder was triggered by a Workflow in Cal.${branding}</body>`;
|
||||
|
||||
const emailBody = { text: templateBodyText, html: templateBodyHtml };
|
||||
const emailBody = introHtml + eventHtml + dateTimeHtml + attendeeHtml + endingHtml;
|
||||
|
||||
return { emailSubject, emailBody };
|
||||
};
|
||||
|
||||
@@ -1,23 +1,38 @@
|
||||
import { WorkflowActions } from "@prisma/client";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
|
||||
const smsReminderTemplate = (
|
||||
startTime: string,
|
||||
eventName: string,
|
||||
timeZone: string,
|
||||
attendee: string,
|
||||
isEditingMode: boolean,
|
||||
action?: WorkflowActions,
|
||||
startTime?: string,
|
||||
eventName?: string,
|
||||
timeZone?: string,
|
||||
attendee?: string,
|
||||
name?: string
|
||||
) => {
|
||||
const templateOne = `Hi${name ? ` ${name}` : ``}, this is a reminder that your meeting (${eventName}) with
|
||||
${attendee} is on ${dayjs(startTime).tz(timeZone).format("MMM D")} at ${dayjs(startTime)
|
||||
.tz(timeZone)
|
||||
.format("h:mmA")} ${timeZone}`;
|
||||
let eventDate;
|
||||
if (isEditingMode) {
|
||||
eventName = "{EVENT_NAME}";
|
||||
timeZone = "{TIMEZONE}";
|
||||
startTime = "{START_TIME_h:mmA}";
|
||||
|
||||
eventDate = "{EVENT_DATE_YYYY MMM D}";
|
||||
attendee = action === WorkflowActions.SMS_ATTENDEE ? "{ORGANIZER}" : "{ATTENDEE}";
|
||||
name = action === WorkflowActions.SMS_ATTENDEE ? "{ATTENDEE}" : "{ORGANIZER}";
|
||||
} else {
|
||||
eventDate = dayjs(startTime).tz(timeZone).format("YYYY MMM D");
|
||||
startTime = dayjs(startTime).tz(timeZone).format("h:mmA");
|
||||
}
|
||||
|
||||
const templateOne = `Hi${
|
||||
name ? ` ${name}` : ``
|
||||
}, this is a reminder that your meeting (${eventName}) with ${attendee} is on ${eventDate} at ${startTime} ${timeZone}.`;
|
||||
|
||||
//Twilio recomments message to be no longer than 320 characters
|
||||
if (templateOne.length <= 320) return templateOne;
|
||||
|
||||
const templateTwo = `Hi, this is a reminder that your meeting with ${attendee} is on ${dayjs(startTime)
|
||||
.tz(timeZone)
|
||||
.format("MMM D")} at ${dayjs(startTime).tz(timeZone).format("h:mmA")} ${timeZone}`;
|
||||
const templateTwo = `Hi, this is a reminder that your meeting with ${attendee} is on ${eventDate} at ${startTime} ${timeZone}`;
|
||||
|
||||
//Twilio supports up to 1600 characters
|
||||
if (templateTwo.length <= 1600) return templateTwo;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { TFunction } from "next-i18next";
|
||||
import type { TFunction } from "next-i18next";
|
||||
|
||||
import { DYNAMIC_TEXT_VARIABLES } from "./constants";
|
||||
import { DYNAMIC_TEXT_VARIABLES, FORMATTED_DYNAMIC_TEXT_VARIABLES } from "./constants";
|
||||
|
||||
// variables are saved in the db always in english, so here we translate them to the user's language
|
||||
export function getTranslatedText(text: string, language: { locale: string; t: TFunction }) {
|
||||
let translatedText = text;
|
||||
|
||||
@@ -12,12 +13,31 @@ export function getTranslatedText(text: string, language: { locale: string; t: T
|
||||
|
||||
variables?.forEach((variable) => {
|
||||
const regex = new RegExp(`{${variable}}`, "g"); // .replaceAll is not available here for some reason
|
||||
const translatedVariable = DYNAMIC_TEXT_VARIABLES.includes(variable.toLowerCase())
|
||||
let translatedVariable = DYNAMIC_TEXT_VARIABLES.includes(variable.toLowerCase())
|
||||
? language.t(variable.toLowerCase().concat("_variable")).replace(/ /g, "_").toLocaleUpperCase()
|
||||
: DYNAMIC_TEXT_VARIABLES.includes(variable.toLowerCase().concat("_name")) //for the old variables names (ORGANIZER_NAME, ATTENDEE_NAME)
|
||||
? language.t(variable.toLowerCase().concat("_name_variable")).replace(/ /g, "_").toLocaleUpperCase()
|
||||
: variable;
|
||||
|
||||
// this takes care of translating formatted variables (e.g. {EVENT_DATE_DD MM YYYY})
|
||||
const formattedVarToTranslate = FORMATTED_DYNAMIC_TEXT_VARIABLES.map((formattedVar) => {
|
||||
if (variable.toLowerCase().startsWith(formattedVar)) return variable;
|
||||
})[0];
|
||||
|
||||
if (formattedVarToTranslate) {
|
||||
// only translate the variable part not the formatting
|
||||
const variableName = formattedVarToTranslate
|
||||
.substring(0, formattedVarToTranslate?.lastIndexOf("_"))
|
||||
.toLowerCase()
|
||||
.concat("_variable");
|
||||
|
||||
translatedVariable = language
|
||||
.t(variableName)
|
||||
.replace(/ /g, "_")
|
||||
.toLocaleUpperCase()
|
||||
.concat(formattedVarToTranslate?.substring(formattedVarToTranslate?.lastIndexOf("_")));
|
||||
}
|
||||
|
||||
translatedText = translatedText.replace(regex, `{${translatedVariable}}`);
|
||||
});
|
||||
}
|
||||
@@ -48,6 +68,13 @@ export function translateVariablesToEnglish(text: string, language: { locale: st
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
FORMATTED_DYNAMIC_TEXT_VARIABLES.forEach((formattedVar) => {
|
||||
const translatedVariable = language.t(`${formattedVar}variable`).replace(/ /g, "_").toUpperCase();
|
||||
if (variable.startsWith(translatedVariable)) {
|
||||
newText = newText.replace(translatedVariable, formattedVar.slice(0, -1).toUpperCase());
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ import { Alert, Button, Form, showToast, Badge } from "@calcom/ui";
|
||||
import LicenseRequired from "../../common/components/v2/LicenseRequired";
|
||||
import SkeletonLoader from "../components/SkeletonLoaderEdit";
|
||||
import WorkflowDetailsPage from "../components/WorkflowDetailsPage";
|
||||
import { isSMSAction } from "../lib/isSMSAction";
|
||||
import { isSMSAction } from "../lib/actionHelperFunctions";
|
||||
import { getTranslatedText, translateVariablesToEnglish } from "../lib/variableTranslations";
|
||||
|
||||
export type FormValues = {
|
||||
@@ -204,10 +204,7 @@ function WorkflowPage() {
|
||||
values.steps.forEach((step) => {
|
||||
const strippedHtml = step.reminderBody?.replace(/<[^>]+>/g, "") || "";
|
||||
|
||||
const isBodyEmpty =
|
||||
step.template === WorkflowTemplates.CUSTOM &&
|
||||
!isSMSAction(step.action) &&
|
||||
strippedHtml.length <= 1;
|
||||
const isBodyEmpty = !isSMSAction(step.action) && strippedHtml.length <= 1;
|
||||
|
||||
if (isBodyEmpty) {
|
||||
form.setError(`steps.${step.stepNumber - 1}.reminderBody`, {
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
-- Until now reminderBody and emailSubject were ignored for the REMINDER template
|
||||
-- From now on we use the reminderBody and emailSubject also for templates
|
||||
|
||||
UPDATE "WorkflowStep"
|
||||
SET "reminderBody" = NULL, "emailSubject" = NULL
|
||||
WHERE "template" = 'REMINDER';
|
||||
@@ -844,6 +844,7 @@ export const bookingsRouter = router({
|
||||
organizer: {
|
||||
email: user.email,
|
||||
name: user.name || "Unnamed",
|
||||
username: user.username || undefined,
|
||||
timeZone: user.timeZone,
|
||||
language: { translate: tOrganizer, locale: user.locale ?? "en" },
|
||||
},
|
||||
|
||||
@@ -10,13 +10,14 @@ import {
|
||||
} from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
|
||||
import emailReminderTemplate from "@calcom/ee/workflows/lib/reminders/templates/emailReminderTemplate";
|
||||
import {
|
||||
SMS_REMINDER_NUMBER_FIELD,
|
||||
getSmsReminderNumberField,
|
||||
getSmsReminderNumberSource,
|
||||
} from "@calcom/features/bookings/lib/getBookingFields";
|
||||
import type { WorkflowType } from "@calcom/features/ee/workflows/components/WorkflowListPage";
|
||||
// import dayjs from "@calcom/dayjs";
|
||||
import { isSMSAction } from "@calcom/features/ee/workflows/lib/actionHelperFunctions";
|
||||
import {
|
||||
WORKFLOW_TEMPLATES,
|
||||
WORKFLOW_TRIGGER_EVENTS,
|
||||
@@ -24,7 +25,6 @@ import {
|
||||
TIME_UNIT,
|
||||
} from "@calcom/features/ee/workflows/lib/constants";
|
||||
import { getWorkflowActionOptions } from "@calcom/features/ee/workflows/lib/getOptions";
|
||||
import { isSMSAction } from "@calcom/features/ee/workflows/lib/isSMSAction";
|
||||
import {
|
||||
deleteScheduledEmailReminder,
|
||||
scheduleEmailReminder,
|
||||
@@ -359,8 +359,10 @@ export const workflowsRouter = router({
|
||||
await ctx.prisma.workflowStep.create({
|
||||
data: {
|
||||
stepNumber: 1,
|
||||
action: WorkflowActions.EMAIL_HOST,
|
||||
action: WorkflowActions.EMAIL_ATTENDEE,
|
||||
template: WorkflowTemplates.REMINDER,
|
||||
reminderBody: emailReminderTemplate(true, WorkflowActions.EMAIL_ATTENDEE).emailBody,
|
||||
emailSubject: emailReminderTemplate(true, WorkflowActions.EMAIL_ATTENDEE).emailSubject,
|
||||
workflowId: workflow.id,
|
||||
sender: SENDER_NAME,
|
||||
numberVerificationPending: false,
|
||||
@@ -652,6 +654,9 @@ export const workflowsRouter = router({
|
||||
endTime: booking.endTime.toISOString(),
|
||||
title: booking.title,
|
||||
language: { locale: booking?.user?.locale || "" },
|
||||
eventType: {
|
||||
slug: booking.eventType?.slug,
|
||||
},
|
||||
};
|
||||
if (
|
||||
step.action === WorkflowActions.EMAIL_HOST ||
|
||||
@@ -771,8 +776,8 @@ export const workflowsRouter = router({
|
||||
: null,
|
||||
stepNumber: newStep.stepNumber,
|
||||
workflowId: newStep.workflowId,
|
||||
reminderBody: newStep.template === WorkflowTemplates.CUSTOM ? newStep.reminderBody : null,
|
||||
emailSubject: newStep.template === WorkflowTemplates.CUSTOM ? newStep.emailSubject : null,
|
||||
reminderBody: newStep.reminderBody,
|
||||
emailSubject: newStep.emailSubject,
|
||||
template: newStep.template,
|
||||
numberRequired: newStep.numberRequired,
|
||||
sender: getSender({
|
||||
@@ -846,6 +851,9 @@ export const workflowsRouter = router({
|
||||
endTime: booking.endTime.toISOString(),
|
||||
title: booking.title,
|
||||
language: { locale: booking?.user?.locale || "" },
|
||||
eventType: {
|
||||
slug: booking.eventType?.slug,
|
||||
},
|
||||
};
|
||||
if (
|
||||
newStep.action === WorkflowActions.EMAIL_HOST ||
|
||||
@@ -973,6 +981,9 @@ export const workflowsRouter = router({
|
||||
endTime: booking.endTime.toISOString(),
|
||||
title: booking.title,
|
||||
language: { locale: booking?.user?.locale || "" },
|
||||
eventType: {
|
||||
slug: booking.eventType?.slug,
|
||||
},
|
||||
};
|
||||
|
||||
if (
|
||||
|
||||
Vendored
+1
-1
@@ -135,7 +135,7 @@ export type AppsStatus = {
|
||||
warnings?: string[];
|
||||
};
|
||||
|
||||
type CalEventResponses = Record<
|
||||
export type CalEventResponses = Record<
|
||||
string,
|
||||
{
|
||||
label: string;
|
||||
|
||||
@@ -10,6 +10,7 @@ import { MarkdownShortcutPlugin } from "@lexical/react/LexicalMarkdownShortcutPl
|
||||
import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin";
|
||||
import { HeadingNode, QuoteNode } from "@lexical/rich-text";
|
||||
import { TableCellNode, TableNode, TableRowNode } from "@lexical/table";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
|
||||
import { classNames } from "@calcom/lib";
|
||||
|
||||
@@ -33,6 +34,9 @@ export type TextEditorProps = {
|
||||
height?: string;
|
||||
placeholder?: string;
|
||||
disableLists?: boolean;
|
||||
updateTemplate?: boolean;
|
||||
firstRender?: boolean;
|
||||
setFirstRender?: Dispatch<SetStateAction<boolean>>;
|
||||
editable?: boolean;
|
||||
};
|
||||
|
||||
@@ -69,6 +73,9 @@ export const Editor = (props: TextEditorProps) => {
|
||||
editable={editable}
|
||||
excludedToolbarItems={props.excludedToolbarItems}
|
||||
variables={props.variables}
|
||||
updateTemplate={props.updateTemplate}
|
||||
firstRender={props.firstRender}
|
||||
setFirstRender={props.setFirstRender}
|
||||
/>
|
||||
<div
|
||||
className={classNames("editor-inner scroll-bar", !editable && "bg-muted")}
|
||||
|
||||
@@ -32,29 +32,31 @@ export const AddVariablesDropdown = (props: IAddVariablesDropdown) => {
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="h-40 overflow-scroll">
|
||||
<DropdownMenuContent>
|
||||
<div className="pt-4 pb-1">
|
||||
<div className="text-subtle mb-2 px-4 text-left text-xs">
|
||||
{t("add_dynamic_variables").toLocaleUpperCase()}
|
||||
</div>
|
||||
{props.variables.map((variable) => (
|
||||
<DropdownMenuItem key={variable} className="hover:ring-0">
|
||||
<button
|
||||
key={variable}
|
||||
type="button"
|
||||
className="w-full px-4 py-2"
|
||||
onClick={() => props.addVariable(t(`${variable}_variable`))}>
|
||||
<div className="sm:grid sm:grid-cols-2">
|
||||
<div className="mr-3 text-left md:col-span-1">
|
||||
{`{${t(`${variable}_variable`).toUpperCase().replace(/ /g, "_")}}`}
|
||||
<div className="h-64 overflow-scroll md:h-80">
|
||||
{props.variables.map((variable) => (
|
||||
<DropdownMenuItem key={variable} className="hover:ring-0">
|
||||
<button
|
||||
key={variable}
|
||||
type="button"
|
||||
className="w-full px-4 py-2"
|
||||
onClick={() => props.addVariable(t(`${variable}_variable`))}>
|
||||
<div className="sm:grid sm:grid-cols-2">
|
||||
<div className="mr-3 text-left md:col-span-1">
|
||||
{`{${t(`${variable}_variable`).toUpperCase().replace(/ /g, "_")}}`}
|
||||
</div>
|
||||
<div className="text-default hidden text-left sm:col-span-1 sm:flex">
|
||||
{t(`${variable}_info`)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-default hidden text-left sm:col-span-1 sm:flex">
|
||||
{t(`${variable}_info`)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
|
||||
@@ -340,23 +340,47 @@ export default function ToolbarPlugin(props: TextEditorProps) {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
editor.update(() => {
|
||||
const parser = new DOMParser();
|
||||
const dom = parser.parseFromString(props.getText(), "text/html");
|
||||
if (!props.firstRender) {
|
||||
editor.update(() => {
|
||||
const root = $getRoot();
|
||||
if (root) {
|
||||
editor.update(() => {
|
||||
const parser = new DOMParser();
|
||||
// Create a new TextNode
|
||||
const dom = parser.parseFromString(props.getText(), "text/html");
|
||||
|
||||
const nodes = $generateNodesFromDOM(editor, dom);
|
||||
|
||||
$getRoot().select();
|
||||
$insertNodes(nodes);
|
||||
|
||||
editor.registerUpdateListener(({ editorState, prevEditorState }) => {
|
||||
editorState.read(() => {
|
||||
const textInHtml = $generateHtmlFromNodes(editor).replace(/</g, "<").replace(/>/g, ">");
|
||||
props.setText(textInHtml);
|
||||
});
|
||||
if (!prevEditorState._selection) editor.blur();
|
||||
const nodes = $generateNodesFromDOM(editor, dom);
|
||||
const paragraph = $createParagraphNode();
|
||||
root.clear().append(paragraph);
|
||||
paragraph.select();
|
||||
$insertNodes(nodes);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}, [props.updateTemplate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (props.setFirstRender) {
|
||||
props.setFirstRender(false);
|
||||
editor.update(() => {
|
||||
const parser = new DOMParser();
|
||||
const dom = parser.parseFromString(props.getText(), "text/html");
|
||||
|
||||
const nodes = $generateNodesFromDOM(editor, dom);
|
||||
|
||||
$getRoot().select();
|
||||
$insertNodes(nodes);
|
||||
|
||||
editor.registerUpdateListener(({ editorState, prevEditorState }) => {
|
||||
editorState.read(() => {
|
||||
const textInHtml = $generateHtmlFromNodes(editor).replace(/</g, "<").replace(/>/g, ">");
|
||||
props.setText(textInHtml);
|
||||
});
|
||||
if (!prevEditorState._selection) editor.blur();
|
||||
});
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
Reference in New Issue
Block a user