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:
Joe Au-Yeung
2025-04-02 08:16:26 -07:00
committed by GitHub
parent 0965fad72a
commit 8c9eb18463
43 changed files with 876 additions and 262 deletions
+2
View File
@@ -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=
+4
View File
@@ -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(
+11 -1
View File
@@ -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(/&amp;/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";
@@ -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.";
@@ -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}.";
@@ -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}.";
@@ -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;
};
+1
View File
@@ -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",
+1
View File
@@ -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>;
+1
View File
@@ -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,
});
}
+8 -3
View File
@@ -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,
+1
View File
@@ -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();
+1
View File
@@ -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({
+1
View File
@@ -241,6 +241,7 @@
"globalEnv": [
"ALLOWED_HOSTNAMES",
"ANALYZE",
"AKISMET_API_KEY",
"API_KEY_PREFIX",
"APP_USER_NAME",
"BASECAMP3_CLIENT_ID",
+22 -2
View File
@@ -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: