chore: Add spam checking for workflow bodies (#18822)
* Add akismet package to tasker * Create scanWorkflowBody task * Schedule workflow body scan * Add AKISMET_API_KEY .env * Auto lock user if spam is detected * Uncommit key * Add safe param to workflow step * Migration for safe field * Do not process workflow steps is `safe` is false * Update migration to set previous records to true * Address comments * Refactor `scheduleWorkflowNotifications` to accept an object * If new steps or editing old ones send to tasker * Call `scheduleWorkflowNotifications` in task * Fix `IS_SELF_HOSTED` * Remove unused function * Make `safe` optional in schema * Type fix * Revert "Make `safe` optional in schema" This reverts commit d0964702affa87c35562300301473d25635c565b. * Revert "Type fix" This reverts commit d9a031303269a2994ae46f576ab2a3d31e4d977b. * Type fixes * Type fixes * Address comments * Fix tests * Add tests * Update tests * Typo fix * Update `safe` to `verifiedAt` * feat: Compare workflow reminder bodies to default template (#19060) * Add `getTemplateForAction` function * Use `getTemplateForAction` when creating a new step * Use `getTemplateForAction` when action changes * Have `emailReminderTemplate` accept an object as a param * Rename `getTemplateForAction` to `getTemplateBodyForAction` * Simplify changing body when changing templates * Create `compareReminderBodyToTemplate` * In task, compare if reminderBody is a template * Linting * Add tests * refactor: `emailReminderTemplate` to accept object as param (#19288) * Add `getTemplateForAction` function * Use `getTemplateForAction` when creating a new step * Use `getTemplateForAction` when action changes * Have `emailReminderTemplate` accept an object as a param * Rename `getTemplateForAction` to `getTemplateBodyForAction` * Simplify changing body when changing templates * Create `compareReminderBodyToTemplate` * In task, compare if reminderBody is a template * Linting * Add tests * Refactor `scheduleEmailReminders` * Refactor `create.handler` for new workflows * Refactor `emailReminderManager` * Refactor `getEmailTemplateText` * Fix typo * Type fix - whatsapp plain text template imports * Type fix - no template found * Type fix - add `isBrandingDisabled` to `emailReminderTemplate` * Add workflow and user to prisma mock * Fix imports for akismet dependencies * Record user lock reason * Undo linting changes * Fix tests * New workflow, at verify created step * Handle if `SCANNING_WORKFLOW_STEPS` is toggled * Move `verifiedAt` checks to specific schedule functions - `scheduleWhatsappReminder` - `scheduleEmailReminder` - `scheduleSMSReminder` * Update logic * Do not fallback verifiedAt * Add comment to next.config.js
This commit is contained in:
@@ -424,6 +424,8 @@ DIRECTORY_IDS_TO_LOG=
|
||||
# Read more about it in the README.md
|
||||
NEXT_PUBLIC_SINGLE_ORG_SLUG=
|
||||
|
||||
AKISMET_API_KEY=
|
||||
|
||||
## Env variables related to avoiding booking failures
|
||||
# Request for checking reservation would be attempted to send every these seconds if the request is stale at that time
|
||||
NEXT_PUBLIC_QUERY_RESERVATION_INTERVAL_SECONDS=
|
||||
|
||||
@@ -184,6 +184,8 @@ const nextConfig = {
|
||||
"http-cookie-agent", // Dependencies of @ewsjs/xhr
|
||||
"rest-facade",
|
||||
"superagent-proxy", // Dependencies of @tryvital/vital-node
|
||||
"superagent", // Dependencies of akismet
|
||||
"formidable", // Dependencies of akismet
|
||||
],
|
||||
experimental: {
|
||||
// externalize server-side node_modules with size > 1mb, to improve dev mode performance/RAM usage
|
||||
@@ -237,6 +239,8 @@ const nextConfig = {
|
||||
/(^@google-cloud\/spanner|^@mongodb-js\/zstd|^@sap\/hana-client\/extension\/Stream$|^@sap\/hana-client|^@sap\/hana-client$|^aws-crt|^aws4$|^better-sqlite3$|^bson-ext$|^cardinal$|^cloudflare:sockets$|^hdb-pool$|^ioredis$|^kerberos$|^mongodb-client-encryption$|^mysql$|^oracledb$|^pg-native$|^pg-query-stream$|^react-native-sqlite-storage$|^snappy\/package\.json$|^snappy$|^sql.js$|^sqlite3$|^typeorm-aurora-data-api-driver$)/,
|
||||
})
|
||||
);
|
||||
|
||||
config.externals.push("formidable");
|
||||
}
|
||||
|
||||
config.plugins.push(
|
||||
|
||||
@@ -29,7 +29,15 @@ export function createWorkflowPageFixture(page: Page) {
|
||||
page.getByText(trigger);
|
||||
await selectEventType("30 min");
|
||||
}
|
||||
await saveWorkflow();
|
||||
const workflow = await saveWorkflow();
|
||||
|
||||
for (const step of workflow.steps) {
|
||||
await prisma.workflowStep.update({
|
||||
where: { id: step.id },
|
||||
data: { verifiedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
await page.getByTestId("go-back-button").click();
|
||||
};
|
||||
|
||||
@@ -38,6 +46,8 @@ export function createWorkflowPageFixture(page: Page) {
|
||||
await page.getByTestId("save-workflow").click();
|
||||
const response = await submitPromise;
|
||||
expect(response.status()).toBe(200);
|
||||
const responseData = await response.json();
|
||||
return responseData[0].result.data.json.workflow;
|
||||
};
|
||||
|
||||
const assertListCount = async (count: number) => {
|
||||
|
||||
@@ -89,6 +89,7 @@ type InputWorkflow = {
|
||||
time?: number | null;
|
||||
timeUnit?: TimeUnit | null;
|
||||
sendTo?: string;
|
||||
verifiedAt?: Date;
|
||||
};
|
||||
|
||||
type InputPayment = {
|
||||
@@ -642,6 +643,7 @@ async function addWorkflowsToDb(workflows: InputWorkflow[]) {
|
||||
id: createdWorkflow.id,
|
||||
},
|
||||
},
|
||||
verifiedAt: workflow?.verifiedAt ?? new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -430,6 +430,7 @@ async function handleWorkflowsUpdate({
|
||||
reminderBody: true,
|
||||
sender: true,
|
||||
includeCalendarEvent: true,
|
||||
verifiedAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -464,6 +465,7 @@ async function handleWorkflowsUpdate({
|
||||
hideBranding: true,
|
||||
includeCalendarEvent: workflowStep.includeCalendarEvent,
|
||||
workflowStepId: workflowStep.id,
|
||||
verifiedAt: workflowStep.verifiedAt,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -467,6 +467,7 @@ export const roundRobinReassignment = async ({
|
||||
reminderBody: true,
|
||||
sender: true,
|
||||
includeCalendarEvent: true,
|
||||
verifiedAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -502,6 +503,7 @@ export const roundRobinReassignment = async ({
|
||||
hideBranding: true,
|
||||
includeCalendarEvent: workflowStep.includeCalendarEvent,
|
||||
workflowStepId: workflowStep.id,
|
||||
verifiedAt: workflowStep.verifiedAt,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -244,21 +244,21 @@ export async function handler(req: NextRequest) {
|
||||
? !!reminder.booking.eventType?.team?.hideBranding
|
||||
: !!reminder.booking.user?.hideBranding;
|
||||
|
||||
emailContent = emailReminderTemplate(
|
||||
false,
|
||||
reminder.booking.user?.locale || "en",
|
||||
reminder.workflowStep.action,
|
||||
getTimeFormatStringFromUserTimeFormat(reminder.booking.user?.timeFormat),
|
||||
reminder.booking.startTime.toISOString() || "",
|
||||
reminder.booking.endTime.toISOString() || "",
|
||||
reminder.booking.eventType?.title || "",
|
||||
timeZone || "",
|
||||
reminder.booking.location || "",
|
||||
bookingMetadataSchema.parse(reminder.booking.metadata || {})?.videoCallUrl || "",
|
||||
attendeeName || "",
|
||||
name || "",
|
||||
brandingDisabled
|
||||
);
|
||||
emailContent = emailReminderTemplate({
|
||||
isEditingMode: false,
|
||||
locale: reminder.booking.user?.locale || "en",
|
||||
action: reminder.workflowStep.action,
|
||||
timeFormat: getTimeFormatStringFromUserTimeFormat(reminder.booking.user?.timeFormat),
|
||||
startTime: reminder.booking.startTime.toISOString() || "",
|
||||
endTime: reminder.booking.endTime.toISOString() || "",
|
||||
eventName: reminder.booking.eventType?.title || "",
|
||||
timeZone: timeZone || "",
|
||||
location: reminder.booking.location || "",
|
||||
meetingUrl: bookingMetadataSchema.parse(reminder.booking.metadata || {})?.videoCallUrl || "",
|
||||
otherPerson: attendeeName || "",
|
||||
name: name || "",
|
||||
isBrandingDisabled: brandingDisabled,
|
||||
});
|
||||
} else if (reminder.workflowStep.template === WorkflowTemplates.RATING) {
|
||||
const organizerOrganizationProfile = await prisma.profile.findFirst({
|
||||
where: {
|
||||
@@ -380,21 +380,21 @@ export async function handler(req: NextRequest) {
|
||||
? !!reminder.booking.eventType?.team?.hideBranding
|
||||
: !!reminder.booking.user?.hideBranding;
|
||||
|
||||
emailContent = emailReminderTemplate(
|
||||
false,
|
||||
reminder.booking.user?.locale || "en",
|
||||
WorkflowActions.EMAIL_ATTENDEE,
|
||||
getTimeFormatStringFromUserTimeFormat(reminder.booking.user?.timeFormat),
|
||||
reminder.booking.startTime.toISOString() || "",
|
||||
reminder.booking.endTime.toISOString() || "",
|
||||
reminder.booking.eventType?.title || "",
|
||||
timeZone || "",
|
||||
reminder.booking.location || "",
|
||||
bookingMetadataSchema.parse(reminder.booking.metadata || {})?.videoCallUrl || "",
|
||||
attendeeName || "",
|
||||
name || "",
|
||||
brandingDisabled
|
||||
);
|
||||
emailContent = emailReminderTemplate({
|
||||
isEditingMode: false,
|
||||
locale: reminder.booking.user?.locale || "en",
|
||||
action: WorkflowActions.EMAIL_ATTENDEE,
|
||||
timeFormat: getTimeFormatStringFromUserTimeFormat(reminder.booking.user?.timeFormat),
|
||||
startTime: reminder.booking.startTime.toISOString() || "",
|
||||
endTime: reminder.booking.endTime.toISOString() || "",
|
||||
eventName: reminder.booking.eventType?.title || "",
|
||||
timeZone: timeZone || "",
|
||||
location: reminder.booking.location || "",
|
||||
meetingUrl: bookingMetadataSchema.parse(reminder.booking.metadata || {})?.videoCallUrl || "",
|
||||
otherPerson: attendeeName || "",
|
||||
name: name || "",
|
||||
isBrandingDisabled: brandingDisabled,
|
||||
});
|
||||
if (emailContent.emailSubject.length > 0 && !emailBodyEmpty && sendTo) {
|
||||
const batchId = await getBatchId();
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState, useEffect } from "react";
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
import { Controller } from "react-hook-form";
|
||||
|
||||
import { SENDER_ID, SENDER_NAME } from "@calcom/lib/constants";
|
||||
import { SENDER_ID, SENDER_NAME, SCANNING_WORKFLOW_STEPS } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { WorkflowActions } from "@calcom/prisma/enums";
|
||||
import { WorkflowTemplates } from "@calcom/prisma/enums";
|
||||
@@ -92,6 +92,7 @@ export default function WorkflowDetailsPage(props: Props) {
|
||||
senderName: !isSMSAction(action) ? senderName || SENDER_NAME : SENDER_NAME,
|
||||
numberVerificationPending: false,
|
||||
includeCalendarEvent: false,
|
||||
verifiedAt: SCANNING_WORKFLOW_STEPS ? null : new Date(),
|
||||
};
|
||||
steps?.push(step);
|
||||
form.setValue("steps", steps);
|
||||
|
||||
@@ -41,18 +41,15 @@ import { showToast } from "@calcom/ui/components/toast";
|
||||
import { Tooltip } from "@calcom/ui/components/tooltip";
|
||||
|
||||
import {
|
||||
getWhatsappTemplateForAction,
|
||||
isAttendeeAction,
|
||||
isSMSAction,
|
||||
isSMSOrWhatsappAction,
|
||||
isWhatsappAction,
|
||||
getTemplateBodyForAction,
|
||||
shouldScheduleEmailReminder,
|
||||
} from "../lib/actionHelperFunctions";
|
||||
import { DYNAMIC_TEXT_VARIABLES } from "../lib/constants";
|
||||
import { getWorkflowTemplateOptions, getWorkflowTriggerOptions } from "../lib/getOptions";
|
||||
import emailRatingTemplate from "../lib/reminders/templates/emailRatingTemplate";
|
||||
import emailReminderTemplate from "../lib/reminders/templates/emailReminderTemplate";
|
||||
import smsReminderTemplate from "../lib/reminders/templates/smsReminderTemplate";
|
||||
import { whatsappReminderTemplate } from "../lib/reminders/templates/whatsapp";
|
||||
import type { FormValues } from "../pages/workflow";
|
||||
import { TimeTimeUnitInput } from "./TimeTimeUnitInput";
|
||||
|
||||
@@ -131,37 +128,25 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
|
||||
const { data: actionOptions } = trpc.viewer.workflows.getWorkflowActionOptions.useQuery();
|
||||
const triggerOptions = getWorkflowTriggerOptions(t);
|
||||
const templateOptions = getWorkflowTemplateOptions(t, step?.action, hasActiveTeamPlan);
|
||||
if (step && !form.getValues(`steps.${step.stepNumber - 1}.reminderBody`)) {
|
||||
const action = form.getValues(`steps.${step.stepNumber - 1}.action`);
|
||||
const template = getTemplateBodyForAction({
|
||||
action,
|
||||
locale: i18n.language,
|
||||
template: step.template ?? WorkflowTemplates.REMINDER,
|
||||
timeFormat,
|
||||
});
|
||||
form.setValue(`steps.${step.stepNumber - 1}.reminderBody`, template);
|
||||
}
|
||||
|
||||
if (step && form.getValues(`steps.${step.stepNumber - 1}.template`) === WorkflowTemplates.REMINDER) {
|
||||
if (!form.getValues(`steps.${step.stepNumber - 1}.reminderBody`)) {
|
||||
const action = form.getValues(`steps.${step.stepNumber - 1}.action`);
|
||||
if (isSMSAction(action)) {
|
||||
form.setValue(
|
||||
`steps.${step.stepNumber - 1}.reminderBody`,
|
||||
smsReminderTemplate(true, i18n.language, action, timeFormat)
|
||||
);
|
||||
} else if (isWhatsappAction(action)) {
|
||||
form.setValue(
|
||||
`steps.${step.stepNumber - 1}.reminderBody`,
|
||||
whatsappReminderTemplate(true, i18n.language, action, timeFormat)
|
||||
);
|
||||
} else {
|
||||
const reminderBodyTemplate = emailReminderTemplate(true, i18n.language, action, timeFormat).emailBody;
|
||||
form.setValue(`steps.${step.stepNumber - 1}.reminderBody`, reminderBodyTemplate);
|
||||
}
|
||||
}
|
||||
if (!form.getValues(`steps.${step.stepNumber - 1}.emailSubject`)) {
|
||||
const subjectTemplate = emailReminderTemplate(
|
||||
true,
|
||||
i18n.language,
|
||||
form.getValues(`steps.${step.stepNumber - 1}.action`),
|
||||
timeFormat
|
||||
).emailSubject;
|
||||
form.setValue(`steps.${step.stepNumber - 1}.emailSubject`, subjectTemplate);
|
||||
}
|
||||
} else if (step && isWhatsappAction(step.action)) {
|
||||
const templateBody = getWhatsappTemplateForAction(step.action, i18n.language, step.template, timeFormat);
|
||||
form.setValue(`steps.${step.stepNumber - 1}.reminderBody`, templateBody);
|
||||
if (step && !form.getValues(`steps.${step.stepNumber - 1}.emailSubject`)) {
|
||||
const subjectTemplate = emailReminderTemplate({
|
||||
isEditingMode: true,
|
||||
locale: i18n.language,
|
||||
action: form.getValues(`steps.${step.stepNumber - 1}.action`),
|
||||
timeFormat,
|
||||
}).emailSubject;
|
||||
form.setValue(`steps.${step.stepNumber - 1}.emailSubject`, subjectTemplate);
|
||||
}
|
||||
|
||||
const { ref: emailSubjectFormRef, ...restEmailSubjectForm } = step
|
||||
@@ -455,6 +440,15 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
|
||||
if (val) {
|
||||
const oldValue = form.getValues(`steps.${step.stepNumber - 1}.action`);
|
||||
|
||||
const template = getTemplateBodyForAction({
|
||||
action: val.value,
|
||||
locale: i18n.language,
|
||||
template: WorkflowTemplates.REMINDER,
|
||||
timeFormat,
|
||||
});
|
||||
|
||||
form.setValue(`steps.${step.stepNumber - 1}.reminderBody`, template);
|
||||
|
||||
const setNumberRequiredConfigs = (
|
||||
phoneNumberIsNeeded: boolean,
|
||||
senderNeeded = true
|
||||
@@ -469,7 +463,6 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
|
||||
setNumberRequiredConfigs(val.value === WorkflowActions.SMS_NUMBER);
|
||||
// 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);
|
||||
}
|
||||
|
||||
@@ -478,7 +471,6 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
|
||||
setNumberRequiredConfigs(val.value === WorkflowActions.WHATSAPP_NUMBER, false);
|
||||
|
||||
if (!isWhatsappAction(oldValue)) {
|
||||
form.setValue(`steps.${step.stepNumber - 1}.reminderBody`, "");
|
||||
form.setValue(`steps.${step.stepNumber - 1}.sender`, "");
|
||||
}
|
||||
|
||||
@@ -490,65 +482,6 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
|
||||
setIsEmailSubjectNeeded(true);
|
||||
}
|
||||
|
||||
if (
|
||||
form.getValues(`steps.${step.stepNumber - 1}.template`) ===
|
||||
WorkflowTemplates.REMINDER
|
||||
) {
|
||||
if (isSMSOrWhatsappAction(val.value) === isSMSOrWhatsappAction(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 (!isSMSOrWhatsappAction(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, i18n.language, val.value, timeFormat)
|
||||
);
|
||||
} else if (isWhatsappAction(val.value)) {
|
||||
form.setValue(
|
||||
`steps.${step.stepNumber - 1}.reminderBody`,
|
||||
whatsappReminderTemplate(true, i18n.language, val.value, timeFormat)
|
||||
);
|
||||
} else {
|
||||
const emailReminderBody = emailReminderTemplate(
|
||||
true,
|
||||
i18n.language,
|
||||
val.value,
|
||||
timeFormat
|
||||
);
|
||||
form.setValue(
|
||||
`steps.${step.stepNumber - 1}.reminderBody`,
|
||||
emailReminderBody.emailBody
|
||||
);
|
||||
form.setValue(
|
||||
`steps.${step.stepNumber - 1}.emailSubject`,
|
||||
emailReminderBody.emailSubject
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const template = isWhatsappAction(val.value) ? "REMINDER" : "CUSTOM";
|
||||
template && form.setValue(`steps.${step.stepNumber - 1}.template`, template);
|
||||
}
|
||||
form.unregister(`steps.${step.stepNumber - 1}.sendTo`);
|
||||
form.clearErrors(`steps.${step.stepNumber - 1}.sendTo`);
|
||||
form.setValue(`steps.${step.stepNumber - 1}.action`, val.value);
|
||||
@@ -825,55 +758,37 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
|
||||
onChange={(val) => {
|
||||
if (val) {
|
||||
const action = form.getValues(`steps.${step.stepNumber - 1}.action`);
|
||||
if (val.value === WorkflowTemplates.REMINDER) {
|
||||
if (isWhatsappAction(action)) {
|
||||
form.setValue(
|
||||
`steps.${step.stepNumber - 1}.reminderBody`,
|
||||
whatsappReminderTemplate(true, i18n.language, action, timeFormat)
|
||||
);
|
||||
} else if (isSMSAction(action)) {
|
||||
form.setValue(
|
||||
`steps.${step.stepNumber - 1}.reminderBody`,
|
||||
smsReminderTemplate(true, i18n.language, action, timeFormat)
|
||||
);
|
||||
} else {
|
||||
form.setValue(
|
||||
`steps.${step.stepNumber - 1}.reminderBody`,
|
||||
emailReminderTemplate(true, i18n.language, action, timeFormat).emailBody
|
||||
);
|
||||
|
||||
const template = getTemplateBodyForAction({
|
||||
action,
|
||||
locale: i18n.language,
|
||||
template: val.value ?? WorkflowTemplates.REMINDER,
|
||||
timeFormat,
|
||||
});
|
||||
|
||||
form.setValue(`steps.${step.stepNumber - 1}.reminderBody`, template);
|
||||
|
||||
if (shouldScheduleEmailReminder(action)) {
|
||||
if (val.value === WorkflowTemplates.REMINDER) {
|
||||
form.setValue(
|
||||
`steps.${step.stepNumber - 1}.emailSubject`,
|
||||
emailReminderTemplate(true, i18n.language, action, timeFormat).emailSubject
|
||||
emailReminderTemplate({
|
||||
isEditingMode: true,
|
||||
locale: i18n.language,
|
||||
action,
|
||||
timeFormat,
|
||||
}).emailSubject
|
||||
);
|
||||
}
|
||||
} else if (val.value === WorkflowTemplates.RATING) {
|
||||
form.setValue(
|
||||
`steps.${step.stepNumber - 1}.reminderBody`,
|
||||
emailRatingTemplate({
|
||||
isEditingMode: true,
|
||||
locale: i18n.language,
|
||||
action,
|
||||
timeFormat,
|
||||
}).emailBody
|
||||
);
|
||||
form.setValue(
|
||||
`steps.${step.stepNumber - 1}.emailSubject`,
|
||||
emailRatingTemplate({
|
||||
isEditingMode: true,
|
||||
locale: i18n.language,
|
||||
action,
|
||||
timeFormat,
|
||||
}).emailSubject
|
||||
);
|
||||
} else {
|
||||
if (isWhatsappAction(action)) {
|
||||
} else if (val.value === WorkflowTemplates.RATING) {
|
||||
form.setValue(
|
||||
`steps.${step.stepNumber - 1}.reminderBody`,
|
||||
getWhatsappTemplateForAction(action, i18n.language, val.value, timeFormat)
|
||||
`steps.${step.stepNumber - 1}.emailSubject`,
|
||||
emailRatingTemplate({
|
||||
isEditingMode: true,
|
||||
locale: i18n.language,
|
||||
action,
|
||||
timeFormat,
|
||||
}).emailSubject
|
||||
);
|
||||
} else {
|
||||
form.setValue(`steps.${step.stepNumber - 1}.reminderBody`, "");
|
||||
form.setValue(`steps.${step.stepNumber - 1}.emailSubject`, "");
|
||||
}
|
||||
}
|
||||
field.onChange(val.value);
|
||||
|
||||
@@ -9,6 +9,9 @@ import {
|
||||
whatsappEventRescheduledTemplate,
|
||||
whatsappReminderTemplate,
|
||||
} from "../lib/reminders/templates/whatsapp";
|
||||
import emailRatingTemplate from "./reminders/templates/emailRatingTemplate";
|
||||
import emailReminderTemplate from "./reminders/templates/emailReminderTemplate";
|
||||
import smsReminderTemplate from "./reminders/templates/smsReminderTemplate";
|
||||
|
||||
export function shouldScheduleEmailReminder(action: WorkflowActions) {
|
||||
return action === WorkflowActions.EMAIL_ATTENDEE || action === WorkflowActions.EMAIL_HOST;
|
||||
@@ -86,6 +89,17 @@ export function getWhatsappTemplateFunction(template?: WorkflowTemplates): typeo
|
||||
}
|
||||
}
|
||||
|
||||
function getEmailTemplateFunction(template?: WorkflowTemplates) {
|
||||
switch (template) {
|
||||
case WorkflowTemplates.REMINDER:
|
||||
return emailReminderTemplate;
|
||||
case WorkflowTemplates.RATING:
|
||||
return emailRatingTemplate;
|
||||
default:
|
||||
return emailReminderTemplate;
|
||||
}
|
||||
}
|
||||
|
||||
export function getWhatsappTemplateForAction(
|
||||
action: WorkflowActions,
|
||||
locale: string,
|
||||
@@ -95,3 +109,28 @@ export function getWhatsappTemplateForAction(
|
||||
const templateFunction = getWhatsappTemplateFunction(template);
|
||||
return templateFunction(true, locale, action, timeFormat);
|
||||
}
|
||||
|
||||
export function getTemplateBodyForAction({
|
||||
action,
|
||||
locale,
|
||||
template,
|
||||
timeFormat,
|
||||
}: {
|
||||
action: WorkflowActions;
|
||||
locale: string;
|
||||
template: WorkflowTemplates;
|
||||
timeFormat: TimeFormat;
|
||||
}): string | null {
|
||||
if (isSMSAction(action)) {
|
||||
return smsReminderTemplate(true, locale, action, timeFormat);
|
||||
}
|
||||
|
||||
if (isWhatsappAction(action)) {
|
||||
const templateFunction = getWhatsappTemplateFunction(template);
|
||||
return templateFunction(true, locale, action, timeFormat);
|
||||
}
|
||||
|
||||
// If not a whatsapp action then it's an email action
|
||||
const templateFunction = getEmailTemplateFunction(template);
|
||||
return templateFunction({ isEditingMode: true, locale, action, timeFormat }).emailBody;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
const compareReminderBodyToTemplate = ({
|
||||
reminderBody,
|
||||
template,
|
||||
}: {
|
||||
reminderBody: string;
|
||||
template: string;
|
||||
}) => {
|
||||
const stripHTML = (html: string) => html.replace(/<[^>]+>/g, "").replace(/&/g, "&");
|
||||
|
||||
const stripedReminderBody = stripHTML(reminderBody);
|
||||
const stripedTemplate = stripHTML(template);
|
||||
|
||||
return stripedReminderBody === stripedTemplate;
|
||||
};
|
||||
|
||||
export default compareReminderBodyToTemplate;
|
||||
@@ -22,6 +22,7 @@ export const workflowSelect = {
|
||||
sender: true,
|
||||
includeCalendarEvent: true,
|
||||
numberRequired: true,
|
||||
verifiedAt: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -54,6 +54,7 @@ interface scheduleEmailReminderArgs extends ScheduleReminderArgs {
|
||||
hideBranding?: boolean;
|
||||
includeCalendarEvent?: boolean;
|
||||
isMandatoryReminder?: boolean;
|
||||
verifiedAt: Date | null;
|
||||
}
|
||||
|
||||
export const scheduleEmailReminder = async (args: scheduleEmailReminderArgs) => {
|
||||
@@ -72,7 +73,14 @@ export const scheduleEmailReminder = async (args: scheduleEmailReminderArgs) =>
|
||||
includeCalendarEvent,
|
||||
isMandatoryReminder,
|
||||
action,
|
||||
verifiedAt,
|
||||
} = args;
|
||||
|
||||
if (!verifiedAt) {
|
||||
log.warn(`Workflow step ${workflowStepId} not yet verified`);
|
||||
return;
|
||||
}
|
||||
|
||||
const { startTime, endTime } = evt;
|
||||
const uid = evt.uid as string;
|
||||
const currentDate = dayjs();
|
||||
@@ -185,20 +193,20 @@ export const scheduleEmailReminder = async (args: scheduleEmailReminderArgs) =>
|
||||
hideBranding
|
||||
).html;
|
||||
} else if (template === WorkflowTemplates.REMINDER) {
|
||||
emailContent = emailReminderTemplate(
|
||||
false,
|
||||
evt.organizer.language.locale,
|
||||
emailContent = emailReminderTemplate({
|
||||
isEditingMode: false,
|
||||
locale: evt.organizer.language.locale,
|
||||
action,
|
||||
evt.organizer.timeFormat,
|
||||
timeFormat: evt.organizer.timeFormat,
|
||||
startTime,
|
||||
endTime,
|
||||
evt.title,
|
||||
eventName: evt.title,
|
||||
timeZone,
|
||||
evt.location || "",
|
||||
bookingMetadataSchema.parse(evt.metadata || {})?.videoCallUrl || "",
|
||||
attendeeName,
|
||||
name
|
||||
);
|
||||
location: evt.location || "",
|
||||
meetingUrl: bookingMetadataSchema.parse(evt.metadata || {})?.videoCallUrl || "",
|
||||
otherPerson: attendeeName,
|
||||
name,
|
||||
});
|
||||
} else if (template === WorkflowTemplates.RATING) {
|
||||
emailContent = emailRatingTemplate({
|
||||
isEditingMode: true,
|
||||
|
||||
@@ -54,6 +54,8 @@ const processWorkflowStep = async (
|
||||
seatReferenceUid,
|
||||
}: ProcessWorkflowStepParams
|
||||
) => {
|
||||
if (!step?.verifiedAt) return;
|
||||
|
||||
if (isSMSOrWhatsappAction(step.action)) {
|
||||
await checkSMSRateLimit({
|
||||
identifier: `sms:${workflow.teamId ? "team:" : "user:"}${workflow.teamId || workflow.userId}`,
|
||||
@@ -80,6 +82,7 @@ const processWorkflowStep = async (
|
||||
teamId: workflow.teamId,
|
||||
isVerificationPending: step.numberVerificationPending,
|
||||
seatReferenceUid,
|
||||
verifiedAt: step.verifiedAt,
|
||||
});
|
||||
} else if (
|
||||
step.action === WorkflowActions.EMAIL_ATTENDEE ||
|
||||
@@ -147,6 +150,7 @@ const processWorkflowStep = async (
|
||||
hideBranding,
|
||||
seatReferenceUid,
|
||||
includeCalendarEvent: step.includeCalendarEvent,
|
||||
verifiedAt: step.verifiedAt,
|
||||
});
|
||||
} else if (isWhatsappAction(step.action)) {
|
||||
const sendTo = step.action === WorkflowActions.WHATSAPP_ATTENDEE ? smsReminderNumber : step.sendTo;
|
||||
@@ -166,6 +170,7 @@ const processWorkflowStep = async (
|
||||
teamId: workflow.teamId,
|
||||
isVerificationPending: step.numberVerificationPending,
|
||||
seatReferenceUid,
|
||||
verifiedAt: step.verifiedAt,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -63,6 +63,8 @@ export async function scheduleMandatoryReminder({
|
||||
seatReferenceUid,
|
||||
includeCalendarEvent: false,
|
||||
isMandatoryReminder: true,
|
||||
// Template is fixed so we don't have to verify
|
||||
verifiedAt: new Date(),
|
||||
});
|
||||
} catch (error) {
|
||||
log.error("Error while scheduling mandatory reminders", JSON.stringify({ error }));
|
||||
|
||||
@@ -74,6 +74,7 @@ export interface ScheduleTextReminderArgs extends ScheduleReminderArgs {
|
||||
teamId?: number | null;
|
||||
isVerificationPending?: boolean;
|
||||
prisma?: PrismaClient;
|
||||
verifiedAt: Date | null;
|
||||
}
|
||||
|
||||
export const scheduleSMSReminder = async (args: ScheduleTextReminderArgs) => {
|
||||
@@ -91,8 +92,14 @@ export const scheduleSMSReminder = async (args: ScheduleTextReminderArgs) => {
|
||||
teamId,
|
||||
isVerificationPending = false,
|
||||
seatReferenceUid,
|
||||
verifiedAt,
|
||||
} = args;
|
||||
|
||||
if (!verifiedAt) {
|
||||
log.warn(`Workflow step ${workflowStepId} not yet verified`);
|
||||
return;
|
||||
}
|
||||
|
||||
const { startTime, endTime } = evt;
|
||||
const uid = evt.uid as string;
|
||||
const currentDate = dayjs();
|
||||
|
||||
@@ -79,3 +79,6 @@ const emailRatingTemplate = ({
|
||||
};
|
||||
|
||||
export default emailRatingTemplate;
|
||||
|
||||
export const plainTextTemplate =
|
||||
"Hi {ORGANIZER},We're always looking to improve our customer's experience. How satisfied were you with your recent meeting?😠 🙁 😐 😄 😍{ORGANIZER} didn't join the meeting? Reschedule hereEvent: {EVENT_NAME}Date & Time: {EVENT_DATE_ddd, MMM D, YYYY h:mma} - {EVENT_END_TIME} ({TIMEZONE})Attendees: You & {ORGANIZER}This survey was triggered by a Workflow in Cal.";
|
||||
|
||||
@@ -4,21 +4,35 @@ import { APP_NAME } from "@calcom/lib/constants";
|
||||
import { TimeFormat } from "@calcom/lib/timeFormat";
|
||||
import { WorkflowActions } from "@calcom/prisma/enums";
|
||||
|
||||
const emailReminderTemplate = (
|
||||
isEditingMode: boolean,
|
||||
locale: string,
|
||||
action?: WorkflowActions,
|
||||
timeFormat?: TimeFormat,
|
||||
startTime?: string,
|
||||
endTime?: string,
|
||||
eventName?: string,
|
||||
timeZone?: string,
|
||||
location?: string,
|
||||
meetingUrl?: string,
|
||||
otherPerson?: string,
|
||||
name?: string,
|
||||
isBrandingDisabled?: boolean
|
||||
) => {
|
||||
const emailReminderTemplate = ({
|
||||
isEditingMode,
|
||||
locale,
|
||||
action,
|
||||
timeFormat,
|
||||
startTime,
|
||||
endTime,
|
||||
eventName,
|
||||
timeZone,
|
||||
location,
|
||||
meetingUrl,
|
||||
otherPerson,
|
||||
name,
|
||||
isBrandingDisabled,
|
||||
}: {
|
||||
isEditingMode: boolean;
|
||||
locale: string;
|
||||
action?: WorkflowActions;
|
||||
timeFormat?: TimeFormat;
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
eventName?: string;
|
||||
timeZone?: string;
|
||||
location?: string;
|
||||
meetingUrl?: string;
|
||||
otherPerson?: string;
|
||||
name?: string;
|
||||
isBrandingDisabled?: boolean;
|
||||
}) => {
|
||||
const currentTimeFormat = timeFormat || TimeFormat.TWELVE_HOUR;
|
||||
const dateTimeFormat = `ddd, MMM D, YYYY ${currentTimeFormat}`;
|
||||
|
||||
@@ -63,3 +77,6 @@ const emailReminderTemplate = (
|
||||
};
|
||||
|
||||
export default emailReminderTemplate;
|
||||
|
||||
export const plainTextTemplate =
|
||||
"Hi {ORGANIZER},This is a reminder about your upcoming event.Event: {EVENT_NAME}Date & Time: {EVENT_DATE_ddd, MMM D, YYYY h:mma} - {EVENT_END_TIME} ({TIMEZONE})Attendees: You & {ATTENDEE}Location: {LOCATION} {MEETING_URL}This reminder was triggered by a Workflow in Cal.";
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { plainTextTemplate as plainTextEmailRatingTemplate } from "./emailRatingTemplate";
|
||||
import { plainTextTemplate as plainTextEmailReminderTemplate } from "./emailReminderTemplate";
|
||||
import { plainTextTemplate as plainTextSMSReminderTemplate } from "./smsReminderTemplate";
|
||||
import { plainTextTemplate as plainTextWhatsappCanceledTemplate } from "./whatsapp/whatsappEventCancelledTemplate";
|
||||
import { plainTextTemplate as plainTextWhatsappCompletedTemplate } from "./whatsapp/whatsappEventCompletedTemplate";
|
||||
import { plainTextTemplate as plainTextWhatsappReminderTemplate } from "./whatsapp/whatsappEventReminderTemplate";
|
||||
import { plainTextTemplate as plainTextWhatsappRescheduledTemplate } from "./whatsapp/whatsappEventRescheduledTemplate";
|
||||
|
||||
const plainTextTemplates = {
|
||||
email: {
|
||||
reminder: plainTextEmailReminderTemplate,
|
||||
rating: plainTextEmailRatingTemplate,
|
||||
},
|
||||
sms: {
|
||||
reminder: plainTextSMSReminderTemplate,
|
||||
},
|
||||
whatsapp: {
|
||||
reminder: plainTextWhatsappReminderTemplate,
|
||||
rescheduled: plainTextWhatsappRescheduledTemplate,
|
||||
completed: plainTextWhatsappCompletedTemplate,
|
||||
canceled: plainTextWhatsappCanceledTemplate,
|
||||
},
|
||||
};
|
||||
|
||||
export default plainTextTemplates;
|
||||
@@ -45,3 +45,6 @@ const smsReminderTemplate = (
|
||||
};
|
||||
|
||||
export default smsReminderTemplate;
|
||||
|
||||
export const plainTextTemplate =
|
||||
"Hi {ATTENDEE}, this is a reminder that your meeting ({EVENT_NAME}) with {ORGANIZER} is on {EVENT_DATE_YYYY MMM D} at {EVENT_TIME_h:mma} {TIMEZONE}.";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export * from "./whatsappEventCancelledTemplate";
|
||||
export * from "./whatsappEventCompletedTemplate";
|
||||
export * from "./whatsappEventReminderTemplate";
|
||||
export * from "./whatsappEventRescheduledTemplate";
|
||||
export { whatsappEventCancelledTemplate } from "./whatsappEventCancelledTemplate";
|
||||
export { whatsappEventCompletedTemplate } from "./whatsappEventCompletedTemplate";
|
||||
export { whatsappReminderTemplate } from "./whatsappEventReminderTemplate";
|
||||
export { whatsappEventRescheduledTemplate } from "./whatsappEventRescheduledTemplate";
|
||||
|
||||
+3
@@ -39,3 +39,6 @@ export const whatsappEventCancelledTemplate = (
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const plainTextTemplate =
|
||||
"Hi {ATTENDEE}, your meeting (*{EVENT_NAME}*) with {ORGANIZER} on {EVENT_DATE_ddd, MMM D, YYYY h:mma} at {START_TIME_h:mma} {TIMEZONE} has been canceled.";
|
||||
|
||||
+3
@@ -39,3 +39,6 @@ export const whatsappEventCompletedTemplate = (
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const plainTextTemplate =
|
||||
"Hi {ATTENDEE}, thank you for attending the event (*{EVENT_NAME}*) on {EVENT_DATE_ddd, MMM D, YYYY h:mma} at {START_TIME_h:mma} {TIMEZONE}.";
|
||||
|
||||
+3
@@ -39,3 +39,6 @@ export const whatsappReminderTemplate = (
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const plainTextTemplate =
|
||||
"Hi {ATTENDEE}, this is a reminder that your meeting (*{EVENT_NAME}*) with {ORGANIZER} is on {EVENT_DATE_ddd, MMM D, YYYY h:mma} at {START_TIME_h:mma} {TIMEZONE}.";
|
||||
|
||||
+3
@@ -39,3 +39,6 @@ export const whatsappEventRescheduledTemplate = (
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const plainTextTemplate =
|
||||
"Hi {ATTENDEE}, your meeting (*{EVENT_NAME}*) with {ORGANIZER} on {EVENT_DATE_ddd, MMM D, YYYY h:mma} at {START_TIME_h:mma} {TIMEZONE} has been rescheduled.";
|
||||
|
||||
@@ -34,8 +34,14 @@ export const scheduleWhatsappReminder = async (args: ScheduleTextReminderArgs) =
|
||||
teamId,
|
||||
isVerificationPending = false,
|
||||
seatReferenceUid,
|
||||
verifiedAt,
|
||||
} = args;
|
||||
|
||||
if (!verifiedAt) {
|
||||
log.warn(`Workflow step ${workflowStepId} not verified`);
|
||||
return;
|
||||
}
|
||||
|
||||
const { startTime, endTime } = evt;
|
||||
const uid = evt.uid as string;
|
||||
const currentDate = dayjs();
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { expect, test, describe } from "vitest";
|
||||
|
||||
import { TimeFormat } from "@calcom/lib/timeFormat";
|
||||
import { WorkflowActions, WorkflowTemplates } from "@calcom/prisma/enums";
|
||||
|
||||
import { getTemplateBodyForAction } from "../actionHelperFunctions";
|
||||
import compareReminderBodyToTemplate from "../compareReminderBodyToTemplate";
|
||||
import plainTextReminderTemplates from "../reminders/templates/plainTextTemplates";
|
||||
|
||||
describe("compareReminderBodyToTemplate", () => {
|
||||
test("should return true if reminderBody and template are the same", () => {
|
||||
const reminderBody = "<p>Test</p>";
|
||||
const template = "<p>Test</p>";
|
||||
expect(compareReminderBodyToTemplate({ reminderBody, template })).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false if reminderBody and template are different", () => {
|
||||
const reminderBody = "<p>Test</p>";
|
||||
const template = "<p>Test2</p>";
|
||||
expect(compareReminderBodyToTemplate({ reminderBody, template })).toBe(false);
|
||||
});
|
||||
|
||||
describe("email templates", () => {
|
||||
test("reminder", () => {
|
||||
const template = getTemplateBodyForAction({
|
||||
action: WorkflowActions.EMAIL_HOST,
|
||||
template: WorkflowTemplates.REMINDER,
|
||||
timeFormat: TimeFormat.TWELVE_HOUR,
|
||||
locale: "en",
|
||||
});
|
||||
|
||||
if (!template) throw new Error("template not found");
|
||||
|
||||
const reminderBody = plainTextReminderTemplates.email.reminder;
|
||||
expect(compareReminderBodyToTemplate({ reminderBody, template })).toBe(true);
|
||||
});
|
||||
|
||||
test("rating", () => {
|
||||
const template = getTemplateBodyForAction({
|
||||
action: WorkflowActions.EMAIL_HOST,
|
||||
template: WorkflowTemplates.RATING,
|
||||
timeFormat: TimeFormat.TWELVE_HOUR,
|
||||
locale: "en",
|
||||
});
|
||||
|
||||
if (!template) throw new Error("template not found");
|
||||
|
||||
const reminderBody = plainTextReminderTemplates.email?.rating ?? "";
|
||||
expect(compareReminderBodyToTemplate({ reminderBody, template })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sms templates", () => {
|
||||
test("reminder", () => {
|
||||
const template = getTemplateBodyForAction({
|
||||
action: WorkflowActions.SMS_ATTENDEE,
|
||||
template: WorkflowTemplates.REMINDER,
|
||||
timeFormat: TimeFormat.TWELVE_HOUR,
|
||||
locale: "en",
|
||||
});
|
||||
|
||||
if (!template) throw new Error("template not found");
|
||||
|
||||
const reminderBody = plainTextReminderTemplates.sms.reminder;
|
||||
expect(compareReminderBodyToTemplate({ reminderBody, template })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("whatsapp templates", () => {
|
||||
test("reminder", () => {
|
||||
const template = getTemplateBodyForAction({
|
||||
action: WorkflowActions.WHATSAPP_ATTENDEE,
|
||||
template: WorkflowTemplates.REMINDER,
|
||||
timeFormat: TimeFormat.TWELVE_HOUR,
|
||||
locale: "en",
|
||||
});
|
||||
|
||||
if (!template) throw new Error("template not found");
|
||||
|
||||
const reminderBody = plainTextReminderTemplates.whatsapp.reminder;
|
||||
expect(compareReminderBodyToTemplate({ reminderBody, template })).toBe(true);
|
||||
});
|
||||
|
||||
test("rescheduled", () => {
|
||||
const template = getTemplateBodyForAction({
|
||||
action: WorkflowActions.WHATSAPP_ATTENDEE,
|
||||
template: WorkflowTemplates.RESCHEDULED,
|
||||
timeFormat: TimeFormat.TWELVE_HOUR,
|
||||
locale: "en",
|
||||
});
|
||||
|
||||
if (!template) throw new Error("template not found");
|
||||
|
||||
const reminderBody = plainTextReminderTemplates.whatsapp.rescheduled;
|
||||
expect(compareReminderBodyToTemplate({ reminderBody, template })).toBe(true);
|
||||
});
|
||||
|
||||
test("completed", () => {
|
||||
const template = getTemplateBodyForAction({
|
||||
action: WorkflowActions.WHATSAPP_ATTENDEE,
|
||||
template: WorkflowTemplates.COMPLETED,
|
||||
timeFormat: TimeFormat.TWELVE_HOUR,
|
||||
locale: "en",
|
||||
});
|
||||
|
||||
if (!template) throw new Error("template not found");
|
||||
|
||||
const reminderBody = plainTextReminderTemplates.whatsapp.completed;
|
||||
expect(compareReminderBodyToTemplate({ reminderBody, template })).toBe(true);
|
||||
});
|
||||
|
||||
test("canceled", () => {
|
||||
const template = getTemplateBodyForAction({
|
||||
action: WorkflowActions.WHATSAPP_ATTENDEE,
|
||||
template: WorkflowTemplates.CANCELLED,
|
||||
timeFormat: TimeFormat.TWELVE_HOUR,
|
||||
locale: "en",
|
||||
});
|
||||
|
||||
if (!template) throw new Error("template not found");
|
||||
|
||||
const reminderBody = plainTextReminderTemplates.whatsapp?.canceled ?? "";
|
||||
expect(compareReminderBodyToTemplate({ reminderBody, template })).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -27,4 +27,5 @@ export type WorkflowStep = {
|
||||
includeCalendarEvent: boolean;
|
||||
numberVerificationPending: boolean;
|
||||
numberRequired: boolean | null;
|
||||
verifiedAt?: Date | null;
|
||||
};
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"@tanstack/react-table": "^8.20.6",
|
||||
"@tanstack/react-virtual": "^3.10.9",
|
||||
"@vercel/functions": "^1.4.0",
|
||||
"akismet-api": "^6.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"framer-motion": "^10.12.8",
|
||||
"lexical": "^0.9.0",
|
||||
|
||||
@@ -18,6 +18,7 @@ type TaskPayloads = {
|
||||
typeof import("./tasks/translateEventTypeData").ZTranslateEventDataPayloadSchema
|
||||
>;
|
||||
createCRMEvent: z.infer<typeof import("./tasks/crm/schema").createCRMEventSchema>;
|
||||
scanWorkflowBody: z.infer<typeof import("./tasks/scanWorkflowBody").scanWorkflowBodySchema>;
|
||||
};
|
||||
export type TaskTypes = keyof TaskPayloads;
|
||||
export type TaskHandler = (payload: string) => Promise<void>;
|
||||
|
||||
@@ -22,6 +22,7 @@ const tasks: Record<TaskTypes, () => Promise<TaskHandler>> = {
|
||||
translateEventTypeData: () =>
|
||||
import("./translateEventTypeData").then((module) => module.translateEventTypeData),
|
||||
createCRMEvent: () => import("./crm/createCRMEvent").then((module) => module.createCRMEvent),
|
||||
scanWorkflowBody: () => import("./scanWorkflowBody").then((module) => module.scanWorkflowBody),
|
||||
};
|
||||
|
||||
export const tasksConfig = {
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import prismaMock from "../../../../tests/libs/__mocks__/prismaMock";
|
||||
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
import { lockUser, LockReason } from "@calcom/lib/autoLock";
|
||||
import { scheduleWorkflowNotifications } from "@calcom/trpc/server/routers/viewer/workflows/util";
|
||||
|
||||
import { scanWorkflowBody } from "./scanWorkflowBody";
|
||||
|
||||
const mockAkismetCheckSpam = vi.fn();
|
||||
|
||||
// Mock the entire module
|
||||
vi.mock("akismet-api", () => {
|
||||
return {
|
||||
AkismetClient: class {
|
||||
constructor() {
|
||||
return {
|
||||
checkSpam: mockAkismetCheckSpam,
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@calcom/lib/autoLock", async (importActual) => {
|
||||
const actual = await importActual<typeof import("@calcom/lib/autoLock")>();
|
||||
return {
|
||||
...actual, // Keep all original exports
|
||||
lockUser: vi.fn(), // Override just the lockUser function
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@calcom/trpc/server/routers/viewer/workflows/util", () => ({
|
||||
scheduleWorkflowNotifications: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockWorkflowStep = {
|
||||
id: 1,
|
||||
reminderBody: "Test reminder body",
|
||||
workflow: {
|
||||
user: {
|
||||
timeFormat: 24,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const mockWorkflow = {
|
||||
id: 1,
|
||||
time: 24,
|
||||
timeUnit: "hour",
|
||||
trigger: "BEFORE",
|
||||
activeOn: [{ eventTypeId: 1 }],
|
||||
team: null,
|
||||
};
|
||||
|
||||
describe("scanWorkflowBody", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
process.env.AKISMET_API_KEY = "test-key";
|
||||
prismaMock.workflowStep.findMany.mockResolvedValue([mockWorkflowStep]);
|
||||
prismaMock.workflow.findFirst.mockResolvedValue(mockWorkflow);
|
||||
});
|
||||
|
||||
it("should skip scan if AKISMET_API_KEY is not set", async () => {
|
||||
process.env.AKISMET_API_KEY = "";
|
||||
const payload = JSON.stringify({
|
||||
userId: 1,
|
||||
workflowStepIds: [1],
|
||||
});
|
||||
|
||||
await scanWorkflowBody(payload);
|
||||
|
||||
expect(prismaMock.workflowStep.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should mark workflow step as safe if no reminder body", async () => {
|
||||
const payload = JSON.stringify({
|
||||
userId: 1,
|
||||
workflowStepIds: [1],
|
||||
});
|
||||
|
||||
prismaMock.workflowStep.findMany.mockResolvedValue([{ ...mockWorkflowStep, reminderBody: null }]);
|
||||
prismaMock.workflow.findFirst.mockResolvedValue(mockWorkflow);
|
||||
|
||||
await scanWorkflowBody(payload);
|
||||
|
||||
expect(prismaMock.workflowStep.update).toHaveBeenCalledWith({
|
||||
where: { id: 1 },
|
||||
data: { verifiedAt: expect.any(Date) },
|
||||
});
|
||||
});
|
||||
|
||||
it("should mark workflow step as safe if content is not spam", async () => {
|
||||
const payload = JSON.stringify({
|
||||
userId: 1,
|
||||
workflowStepIds: [1],
|
||||
});
|
||||
|
||||
prismaMock.workflowStep.findMany.mockResolvedValue([mockWorkflowStep]);
|
||||
prismaMock.workflow.findFirst.mockResolvedValue(mockWorkflow);
|
||||
mockAkismetCheckSpam.mockResolvedValue(false);
|
||||
|
||||
await scanWorkflowBody(payload);
|
||||
|
||||
expect(mockAkismetCheckSpam).toHaveBeenCalledWith({
|
||||
user_ip: "127.0.0.1",
|
||||
content: mockWorkflowStep.reminderBody,
|
||||
});
|
||||
expect(prismaMock.workflowStep.update).toHaveBeenCalledWith({
|
||||
where: { id: 1 },
|
||||
data: { verifiedAt: expect.any(Date) },
|
||||
});
|
||||
});
|
||||
|
||||
it("should lock user and not update step if content is spam", async () => {
|
||||
const payload = JSON.stringify({
|
||||
userId: 1,
|
||||
workflowStepIds: [1],
|
||||
});
|
||||
|
||||
prismaMock.workflowStep.findMany.mockResolvedValue([mockWorkflowStep]);
|
||||
mockAkismetCheckSpam.mockResolvedValue(true);
|
||||
|
||||
await scanWorkflowBody(payload);
|
||||
|
||||
expect(mockAkismetCheckSpam).toHaveBeenCalled();
|
||||
expect(prismaMock.workflowStep.update).not.toHaveBeenCalled();
|
||||
expect(lockUser).toHaveBeenCalledWith("userId", "1", LockReason.SPAM_WORKFLOW_BODY);
|
||||
});
|
||||
|
||||
it("should schedule workflow notifications after successful scan", async () => {
|
||||
const payload = JSON.stringify({
|
||||
userId: 1,
|
||||
workflowStepIds: [1],
|
||||
});
|
||||
|
||||
prismaMock.workflowStep.findMany.mockResolvedValue([mockWorkflowStep]);
|
||||
prismaMock.workflow.findFirst.mockResolvedValue(mockWorkflow);
|
||||
mockAkismetCheckSpam.mockResolvedValue(false);
|
||||
|
||||
await scanWorkflowBody(payload);
|
||||
|
||||
expect(scheduleWorkflowNotifications).toHaveBeenCalledWith({
|
||||
activeOn: [1],
|
||||
isOrg: false,
|
||||
workflowSteps: [mockWorkflowStep],
|
||||
time: mockWorkflow.time,
|
||||
timeUnit: mockWorkflow.timeUnit,
|
||||
trigger: mockWorkflow.trigger,
|
||||
userId: 1,
|
||||
teamId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle invalid payload", async () => {
|
||||
const payload = "invalid-json";
|
||||
|
||||
await expect(scanWorkflowBody(payload)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("should handle workflow not found", async () => {
|
||||
const payload = JSON.stringify({
|
||||
userId: 1,
|
||||
workflowStepIds: [1],
|
||||
});
|
||||
|
||||
prismaMock.workflowStep.findMany.mockResolvedValue([mockWorkflowStep]);
|
||||
prismaMock.workflow.findFirst.mockResolvedValue(null);
|
||||
mockAkismetCheckSpam.mockResolvedValue(false);
|
||||
|
||||
await scanWorkflowBody(payload);
|
||||
|
||||
expect(scheduleWorkflowNotifications).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
import { AkismetClient } from "akismet-api";
|
||||
import type { Comment } from "akismet-api";
|
||||
import z from "zod";
|
||||
|
||||
import { getTemplateBodyForAction } from "@calcom/features/ee/workflows/lib/actionHelperFunctions";
|
||||
import compareReminderBodyToTemplate from "@calcom/features/ee/workflows/lib/compareReminderBodyToTemplate";
|
||||
import { lockUser, LockReason } from "@calcom/lib/autoLock";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { scheduleWorkflowNotifications } from "@calcom/trpc/server/routers/viewer/workflows/util";
|
||||
|
||||
export const scanWorkflowBodySchema = z.object({
|
||||
userId: z.number(),
|
||||
workflowStepIds: z.array(z.number()),
|
||||
});
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["[tasker] scanWorkflowBody"] });
|
||||
|
||||
export async function scanWorkflowBody(payload: string) {
|
||||
if (!process.env.AKISMET_API_KEY) {
|
||||
log.info("AKISMET_API_KEY not set, skipping scan");
|
||||
return;
|
||||
}
|
||||
|
||||
const { workflowStepIds, userId } = scanWorkflowBodySchema.parse(JSON.parse(payload));
|
||||
|
||||
const workflowSteps = await prisma.workflowStep.findMany({
|
||||
where: {
|
||||
id: {
|
||||
in: workflowStepIds,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
workflow: {
|
||||
select: {
|
||||
user: {
|
||||
select: {
|
||||
locale: true,
|
||||
timeFormat: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const client = new AkismetClient({ key: process.env.AKISMET_API_KEY, blog: WEBAPP_URL });
|
||||
|
||||
for (const workflowStep of workflowSteps) {
|
||||
if (!workflowStep.reminderBody) {
|
||||
await prisma.workflowStep.update({
|
||||
where: {
|
||||
id: workflowStep.id,
|
||||
},
|
||||
data: {
|
||||
verifiedAt: new Date(),
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const timeFormat = getTimeFormatStringFromUserTimeFormat(workflowStep.workflow.user?.timeFormat);
|
||||
|
||||
// Determine if body is a template
|
||||
const defaultTemplate = getTemplateBodyForAction({
|
||||
action: workflowStep.action,
|
||||
locale: workflowStep.workflow.user?.locale ?? "en",
|
||||
template: workflowStep.template,
|
||||
timeFormat,
|
||||
});
|
||||
|
||||
if (!defaultTemplate) {
|
||||
log.error(`Template not found for action ${workflowStep.action}, template ${workflowStep.template}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
compareReminderBodyToTemplate({ reminderBody: workflowStep.reminderBody, template: defaultTemplate })
|
||||
) {
|
||||
await prisma.workflowStep.update({
|
||||
where: {
|
||||
id: workflowStep.id,
|
||||
},
|
||||
data: {
|
||||
verifiedAt: new Date(),
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const comment: Comment = {
|
||||
user_ip: "127.0.0.1",
|
||||
content: workflowStep.reminderBody,
|
||||
};
|
||||
|
||||
const isSpam = await client.checkSpam(comment);
|
||||
|
||||
if (isSpam) {
|
||||
// We won't delete the workflow step incase it is flagged as a false positive
|
||||
log.warn(`Workflow step ${workflowStep.id} is spam with body ${workflowStep.reminderBody}`);
|
||||
await lockUser("userId", userId.toString(), LockReason.SPAM_WORKFLOW_BODY);
|
||||
|
||||
// Return early if spam is detected
|
||||
return;
|
||||
} else {
|
||||
await prisma.workflowStep.update({
|
||||
where: {
|
||||
id: workflowStep.id,
|
||||
},
|
||||
data: {
|
||||
verifiedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const workflow = await prisma.workflow.findFirst({
|
||||
where: {
|
||||
steps: {
|
||||
some: {
|
||||
id: {
|
||||
in: workflowStepIds,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
activeOn: true,
|
||||
team: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!workflow) {
|
||||
log.warn(`Workflow with steps ${workflowStepIds} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
const isOrg = !!workflow?.team?.isOrganization;
|
||||
|
||||
await scheduleWorkflowNotifications({
|
||||
activeOn: workflow.activeOn.map((activeOn) => activeOn.eventTypeId) ?? [],
|
||||
isOrg,
|
||||
workflowSteps,
|
||||
time: workflow.time,
|
||||
timeUnit: workflow.timeUnit,
|
||||
trigger: workflow.trigger,
|
||||
userId,
|
||||
teamId: workflow.team?.id || null,
|
||||
});
|
||||
}
|
||||
@@ -20,6 +20,11 @@ interface HandleAutoLockInput {
|
||||
autolockDuration?: number; // in milliseconds
|
||||
}
|
||||
|
||||
export enum LockReason {
|
||||
RATE_LIMIT = "Auto-locking user due to rate limit exceeded",
|
||||
SPAM_WORKFLOW_BODY = "Auto-locking user due to spam detected in workflow body",
|
||||
}
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["[autoLock]"] });
|
||||
|
||||
/**
|
||||
@@ -69,7 +74,7 @@ export async function handleAutoLock({
|
||||
currentCount + 1
|
||||
}/${autolockThreshold}`
|
||||
);
|
||||
await lockUser(identifierType, identifier);
|
||||
await lockUser(identifierType, identifier, LockReason.RATE_LIMIT);
|
||||
await redis.del(lockKey);
|
||||
return true;
|
||||
}
|
||||
@@ -90,7 +95,7 @@ export async function handleAutoLock({
|
||||
return false;
|
||||
}
|
||||
|
||||
async function lockUser(identifierType: string, identifier: string) {
|
||||
export async function lockUser(identifierType: string, identifier: string, lockReason: LockReason) {
|
||||
if (!identifier) {
|
||||
return;
|
||||
}
|
||||
@@ -163,7 +168,7 @@ async function lockUser(identifierType: string, identifier: string) {
|
||||
}
|
||||
|
||||
if (user && process.env.NEXT_PUBLIC_SENTRY_DSN) {
|
||||
log.warn("Auto-locking user due to rate limit exceeded", {
|
||||
log.warn(lockReason, {
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
|
||||
@@ -207,6 +207,7 @@ export const GOOGLE_CALENDAR_SCOPES = [
|
||||
"https://www.googleapis.com/auth/calendar.readonly",
|
||||
];
|
||||
export const DIRECTORY_IDS_TO_LOG = process.env.DIRECTORY_IDS_TO_LOG?.split(",") || [];
|
||||
export const SCANNING_WORKFLOW_STEPS = !IS_SELF_HOSTED && process.env.AKISMET_API_KEY;
|
||||
|
||||
export const IS_PLAIN_CHAT_ENABLED =
|
||||
!!process.env.NEXT_PUBLIC_PLAIN_CHAT_ID && process.env.NEXT_PUBLIC_PLAIN_CHAT_ID !== "";
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "WorkflowStep" ADD COLUMN "verifiedAt" TIMESTAMP(3);
|
||||
|
||||
-- Update existing records to set verifiedAt to the current date
|
||||
UPDATE "WorkflowStep" SET "verifiedAt" = NOW();
|
||||
@@ -1151,6 +1151,7 @@ model WorkflowStep {
|
||||
sender String?
|
||||
numberVerificationPending Boolean @default(true)
|
||||
includeCalendarEvent Boolean @default(false)
|
||||
verifiedAt DateTime?
|
||||
|
||||
@@index([workflowId])
|
||||
}
|
||||
|
||||
@@ -309,6 +309,7 @@ export const activateEventTypeHandler = async ({ ctx, input }: ActivateEventType
|
||||
template: step.template,
|
||||
sender: step.sender,
|
||||
workflowStepId: step.id,
|
||||
verifiedAt: step.verifiedAt,
|
||||
});
|
||||
} else if (step.action === WorkflowActions.SMS_NUMBER && step.sendTo) {
|
||||
await scheduleSMSReminder({
|
||||
@@ -326,6 +327,7 @@ export const activateEventTypeHandler = async ({ ctx, input }: ActivateEventType
|
||||
sender: step.sender,
|
||||
userId: booking.userId,
|
||||
teamId: eventTypeWorkflow.teamId,
|
||||
verifiedAt: step.verifiedAt,
|
||||
});
|
||||
} else if (step.action === WorkflowActions.WHATSAPP_NUMBER && step.sendTo) {
|
||||
await scheduleWhatsappReminder({
|
||||
@@ -342,6 +344,7 @@ export const activateEventTypeHandler = async ({ ctx, input }: ActivateEventType
|
||||
template: step.template,
|
||||
userId: booking.userId,
|
||||
teamId: eventTypeWorkflow.teamId,
|
||||
verifiedAt: step.verifiedAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,12 +66,12 @@ export const createHandler = async ({ ctx, input }: CreateOptions) => {
|
||||
},
|
||||
});
|
||||
|
||||
const renderedEmailTemplate = emailReminderTemplate(
|
||||
true,
|
||||
ctx.user.locale,
|
||||
WorkflowActions.EMAIL_ATTENDEE,
|
||||
getTimeFormatStringFromUserTimeFormat(ctx.user.timeFormat)
|
||||
);
|
||||
const renderedEmailTemplate = emailReminderTemplate({
|
||||
isEditingMode: true,
|
||||
locale: ctx.user.locale,
|
||||
action: WorkflowActions.EMAIL_ATTENDEE,
|
||||
timeFormat: getTimeFormatStringFromUserTimeFormat(ctx.user.timeFormat),
|
||||
});
|
||||
|
||||
await ctx.prisma.workflowStep.create({
|
||||
data: {
|
||||
@@ -83,6 +83,7 @@ export const createHandler = async ({ ctx, input }: CreateOptions) => {
|
||||
workflowId: workflow.id,
|
||||
sender: SENDER_NAME,
|
||||
numberVerificationPending: false,
|
||||
verifiedAt: new Date(),
|
||||
},
|
||||
});
|
||||
return { workflow };
|
||||
|
||||
@@ -2,7 +2,8 @@ import {
|
||||
isEmailAction,
|
||||
isSMSOrWhatsappAction,
|
||||
} from "@calcom/features/ee/workflows/lib/actionHelperFunctions";
|
||||
import { IS_SELF_HOSTED } from "@calcom/lib/constants";
|
||||
import tasker from "@calcom/features/tasker";
|
||||
import { IS_SELF_HOSTED, SCANNING_WORKFLOW_STEPS } from "@calcom/lib/constants";
|
||||
import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata";
|
||||
import { WorkflowRepository } from "@calcom/lib/server/repository/workflow";
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
@@ -265,29 +266,29 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
|
||||
isOrg,
|
||||
});
|
||||
|
||||
await scheduleWorkflowNotifications(
|
||||
await scheduleWorkflowNotifications({
|
||||
activeOn, // schedule for activeOn that stayed the same + new active on (old reminders were deleted)
|
||||
isOrg,
|
||||
userWorkflow.steps, // use old steps here, edited and deleted steps are handled below
|
||||
workflowSteps: userWorkflow.steps, // use old steps here, edited and deleted steps are handled below
|
||||
time,
|
||||
timeUnit,
|
||||
trigger,
|
||||
user.id,
|
||||
userWorkflow.teamId
|
||||
);
|
||||
userId: user.id,
|
||||
teamId: userWorkflow.teamId,
|
||||
});
|
||||
} else {
|
||||
// if trigger didn't change, only schedule reminders for all new activeOn
|
||||
await scheduleWorkflowNotifications(
|
||||
newActiveOn,
|
||||
await scheduleWorkflowNotifications({
|
||||
activeOn: newActiveOn,
|
||||
isOrg,
|
||||
userWorkflow.steps, // use old steps here, edited and deleted steps are handled below
|
||||
workflowSteps: userWorkflow.steps, // use old steps here, edited and deleted steps are handled below
|
||||
time,
|
||||
timeUnit,
|
||||
trigger,
|
||||
user.id,
|
||||
userWorkflow.teamId,
|
||||
activeOn.filter((activeOn) => !newActiveOn.includes(activeOn)) // alreadyScheduledActiveOnIds
|
||||
);
|
||||
userId: user.id,
|
||||
teamId: userWorkflow.teamId,
|
||||
alreadyScheduledActiveOnIds: activeOn.filter((activeOn) => !newActiveOn.includes(activeOn)), // alreadyScheduledActiveOnIds
|
||||
});
|
||||
}
|
||||
|
||||
// handle deleted and edited workflow steps
|
||||
@@ -333,7 +334,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
|
||||
id: oldStep.id,
|
||||
},
|
||||
});
|
||||
} else if (isStepEdited(oldStep, newStep)) {
|
||||
} else if (isStepEdited(oldStep, { ...newStep, verifiedAt: oldStep.verifiedAt })) {
|
||||
// check if step that require team plan already existed before
|
||||
if (!hasPaidPlan) {
|
||||
const isChangingToSMSOrWhatsapp =
|
||||
@@ -375,6 +376,8 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
|
||||
await verifyEmailSender(newStep.sendTo || "", user.id, userWorkflow.teamId);
|
||||
}
|
||||
|
||||
const didBodyChange = newStep.reminderBody !== oldStep.reminderBody;
|
||||
|
||||
await ctx.prisma.workflowStep.update({
|
||||
where: {
|
||||
id: oldStep.id,
|
||||
@@ -391,23 +394,28 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
|
||||
sender: newStep.sender,
|
||||
numberVerificationPending: false,
|
||||
includeCalendarEvent: newStep.includeCalendarEvent,
|
||||
verifiedAt: !SCANNING_WORKFLOW_STEPS ? new Date() : didBodyChange ? null : oldStep.verifiedAt,
|
||||
},
|
||||
});
|
||||
|
||||
if (SCANNING_WORKFLOW_STEPS && didBodyChange) {
|
||||
await tasker.create("scanWorkflowBody", { workflowStepIds: [oldStep.id], userId: ctx.user.id });
|
||||
} else {
|
||||
// schedule notifications for edited steps
|
||||
await scheduleWorkflowNotifications({
|
||||
activeOn,
|
||||
isOrg,
|
||||
workflowSteps: [newStep],
|
||||
time,
|
||||
timeUnit,
|
||||
trigger,
|
||||
userId: user.id,
|
||||
teamId: userWorkflow.teamId,
|
||||
});
|
||||
}
|
||||
|
||||
// cancel all notifications of edited step
|
||||
await WorkflowRepository.deleteAllWorkflowReminders(remindersFromStep);
|
||||
|
||||
// schedule notifications for edited steps
|
||||
await scheduleWorkflowNotifications(
|
||||
activeOn,
|
||||
isOrg,
|
||||
[newStep],
|
||||
time,
|
||||
timeUnit,
|
||||
trigger,
|
||||
user.id,
|
||||
userWorkflow.teamId
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -457,22 +465,34 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
|
||||
const createdSteps = await Promise.all(
|
||||
addedSteps.map((step) =>
|
||||
ctx.prisma.workflowStep.create({
|
||||
data: { ...step, numberVerificationPending: false },
|
||||
data: {
|
||||
...step,
|
||||
numberVerificationPending: false,
|
||||
...(!SCANNING_WORKFLOW_STEPS ? { verifiedAt: new Date() } : {}),
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
// schedule notification for new step
|
||||
await scheduleWorkflowNotifications(
|
||||
activeOn,
|
||||
isOrg,
|
||||
createdSteps,
|
||||
time,
|
||||
timeUnit,
|
||||
trigger,
|
||||
user.id,
|
||||
userWorkflow.teamId
|
||||
);
|
||||
if (SCANNING_WORKFLOW_STEPS) {
|
||||
// workflows are scanned then scheduled in the task
|
||||
await tasker.create("scanWorkflowBody", {
|
||||
workflowStepIds: createdSteps.map((step) => step.id),
|
||||
userId: ctx.user.id,
|
||||
});
|
||||
} else {
|
||||
// schedule notification for new step
|
||||
await scheduleWorkflowNotifications({
|
||||
activeOn,
|
||||
isOrg,
|
||||
workflowSteps: createdSteps,
|
||||
time,
|
||||
timeUnit,
|
||||
trigger,
|
||||
userId: user.id,
|
||||
teamId: userWorkflow.teamId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
//update trigger, name, time, timeUnit
|
||||
|
||||
@@ -464,17 +464,27 @@ async function getRemindersFromRemovedEventTypes(removedEventTypes: number[], wo
|
||||
return remindersToDelete;
|
||||
}
|
||||
|
||||
export async function scheduleWorkflowNotifications(
|
||||
activeOn: number[],
|
||||
isOrg: boolean,
|
||||
workflowSteps: Partial<WorkflowStep>[],
|
||||
time: number | null,
|
||||
timeUnit: TimeUnit | null,
|
||||
trigger: WorkflowTriggerEvents,
|
||||
userId: number,
|
||||
teamId: number | null,
|
||||
alreadyScheduledActiveOnIds?: number[]
|
||||
) {
|
||||
export async function scheduleWorkflowNotifications({
|
||||
activeOn,
|
||||
isOrg,
|
||||
workflowSteps,
|
||||
time,
|
||||
timeUnit,
|
||||
trigger,
|
||||
userId,
|
||||
teamId,
|
||||
alreadyScheduledActiveOnIds,
|
||||
}: {
|
||||
activeOn: number[];
|
||||
isOrg: boolean;
|
||||
workflowSteps: Partial<WorkflowStep>[];
|
||||
time: number | null;
|
||||
timeUnit: TimeUnit | null;
|
||||
trigger: WorkflowTriggerEvents;
|
||||
userId: number;
|
||||
teamId: number | null;
|
||||
alreadyScheduledActiveOnIds?: number[];
|
||||
}) {
|
||||
const bookingsToScheduleNotifications = await getBookings(activeOn, isOrg, alreadyScheduledActiveOnIds);
|
||||
|
||||
await scheduleBookingReminders(
|
||||
@@ -682,6 +692,7 @@ export async function scheduleBookingReminders(
|
||||
template: step.template,
|
||||
sender: step.sender,
|
||||
workflowStepId: step.id,
|
||||
verifiedAt: step?.verifiedAt ?? null,
|
||||
});
|
||||
} else if (step.action === WorkflowActions.SMS_NUMBER && step.sendTo) {
|
||||
await scheduleSMSReminder({
|
||||
@@ -699,6 +710,7 @@ export async function scheduleBookingReminders(
|
||||
sender: step.sender,
|
||||
userId: userId,
|
||||
teamId: teamId,
|
||||
verifiedAt: step?.verifiedAt ?? null,
|
||||
});
|
||||
} else if (step.action === WorkflowActions.WHATSAPP_NUMBER && step.sendTo) {
|
||||
await scheduleWhatsappReminder({
|
||||
@@ -715,6 +727,7 @@ export async function scheduleBookingReminders(
|
||||
template: step.template,
|
||||
userId: userId,
|
||||
teamId: teamId,
|
||||
verifiedAt: step?.verifiedAt ?? null,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -860,7 +873,12 @@ export function getEmailTemplateText(
|
||||
|
||||
const timeFormat = getTimeFormatStringFromUserTimeFormat(params.timeFormat);
|
||||
|
||||
let { emailBody, emailSubject } = emailReminderTemplate(true, locale, action, timeFormat);
|
||||
let { emailBody, emailSubject } = emailReminderTemplate({
|
||||
isEditingMode: true,
|
||||
locale,
|
||||
action,
|
||||
timeFormat,
|
||||
});
|
||||
|
||||
if (template === WorkflowTemplates.RATING) {
|
||||
const ratingTemplate = emailRatingTemplate({
|
||||
|
||||
@@ -241,6 +241,7 @@
|
||||
"globalEnv": [
|
||||
"ALLOWED_HOSTNAMES",
|
||||
"ANALYZE",
|
||||
"AKISMET_API_KEY",
|
||||
"API_KEY_PREFIX",
|
||||
"APP_USER_NAME",
|
||||
"BASECAMP3_CLIENT_ID",
|
||||
|
||||
@@ -2950,6 +2950,7 @@ __metadata:
|
||||
"@testing-library/react-hooks": ^8.0.1
|
||||
"@types/web-push": ^3.6.3
|
||||
"@vercel/functions": ^1.4.0
|
||||
akismet-api: ^6.0.0
|
||||
class-variance-authority: ^0.7.1
|
||||
framer-motion: ^10.12.8
|
||||
lexical: ^0.9.0
|
||||
@@ -19044,6 +19045,25 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"akismet-api@npm:^6.0.0":
|
||||
version: 6.0.0
|
||||
resolution: "akismet-api@npm:6.0.0"
|
||||
dependencies:
|
||||
bluebird: ^3.1.1
|
||||
superagent: ^8.0.0
|
||||
checksum: f93a9eb11e83e63b8ab2217ee3122bac69a911d6b02d2f7158a8f1c4a8c262a6e52a7461a3b7afeab6930e601df95121aff2f7c90015ea69b0ac65090586e6e7
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"ansi-align@npm:^3.0.1":
|
||||
version: 3.0.1
|
||||
resolution: "ansi-align@npm:3.0.1"
|
||||
dependencies:
|
||||
string-width: ^4.1.0
|
||||
checksum: 6abfa08f2141d231c257162b15292467081fa49a208593e055c866aa0455b57f3a86b5a678c190c618faa79b4c59e254493099cb700dd9cf2293c6be2c8f5d8d
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"ansi-colors@npm:4.1.3, ansi-colors@npm:^4.1.1, ansi-colors@npm:^4.1.3":
|
||||
version: 4.1.3
|
||||
resolution: "ansi-colors@npm:4.1.3"
|
||||
@@ -20343,7 +20363,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"bluebird@npm:^3.7.2":
|
||||
"bluebird@npm:^3.1.1, bluebird@npm:^3.7.2":
|
||||
version: 3.7.2
|
||||
resolution: "bluebird@npm:3.7.2"
|
||||
checksum: 869417503c722e7dc54ca46715f70e15f4d9c602a423a02c825570862d12935be59ed9c7ba34a9b31f186c017c23cac6b54e35446f8353059c101da73eac22ef
|
||||
@@ -43144,7 +43164,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"superagent@npm:^8.1.2":
|
||||
"superagent@npm:^8.0.0, superagent@npm:^8.1.2":
|
||||
version: 8.1.2
|
||||
resolution: "superagent@npm:8.1.2"
|
||||
dependencies:
|
||||
|
||||
Reference in New Issue
Block a user