Files
calendar/packages/features/ee/workflows/lib/reminders/reminderScheduler.ts
T
Carina WollendorferGitHubcoderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>CarinaWolliAmit Sharmacoderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>Udit TakkarBenny Joocal.comMorgan
e639237088 feat: SMS workflow action for form triggers #3 (#23673)
* allow routing forms for activeOn

* use repository function to get routing forms

* remove unnecessary code

* adjust logic in update handler

* add triggers to api v2

* remvoe unused file

* rename to getAcitveOnOptions handler

* remove routingFormOptions handler

* clean up getActiveOnOptions

* refactor WorkflowService

* remove logs

* remove unused

* fix: type check

* fix: missed before after events for recurring

* fix: calendarEvent handleMarkNoShow

* fix error message

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* don't query disabled routing forms

* create tasker function

* add tasker code

* move isFormTrigger function

* small adjustments + todo comments

* remove email to host action for form triggers

* throw trpc error if email to host is added as step

* fix dialog on how to use form responses as variables

* remove add variable dropdown for form triggers

* remove form workfows in event workflows tab

* improvements for workflow logic on form submission

* review fixes

* base setup for seperate schedule functions (evt and form)

* add missing BOOKING_PAID workflow trigger

* fix pathname

* fix: test for BOOKING_REQUESTED

* fix activeOn ids

* pass hideBranding and smsReminderNumber

* adjustments to reminderScheduler

* create empty scheduelForForm functions

* pass locale and timezone with form user

* pass formData instead of responses

* pass timeFormat and locale

* reusable function for email sending and reminder creation

* implement scheduleEmailReminderForForm

* remove added editor field from merge conflict

* don't support cal.ai action with form triggers

* throw bad request if form trigger and cal.ai is combined

* add tests for scheduleFormWorkflows

* add form submission tests

* remove form response varibe info

* clean up workflow actions

* fixes for getting template options

* pass triggerType to getAllWorkflows

* move reusable logic to scheduleSMSReminder

* add formdata to param type

* type fixes for text reminder managers

* implement scheduleSMSReminderForForm

* fix import

* fix isAuthorizedToAddActiveOnIds

* disble whatsapp action

* implement triggerFormSubmittedNoEventWorkflow

* code clean up

* Merge branch 'devin/1755107037-add-workflow-triggers' into feat/routing-form-workflow-triggers

* fix type errors

* remove async from getSubmitterEmail

* fix type errors

* revert cal.ai changes

* fix type error

* add sublogger

* code clean up

* fix type errors

* remove label for attendee whatsapp action

* code clean up

* fixes saving teams on org workflows

* fix type error

* code improvements for activeOn ids

* Revert "code improvements for activeOn ids"

This reverts commit 0a3590a4e2ce541b17d63483ad86ed458795a6a3.

* improve variable name

* fix unit tests

* small fixes

* type fixes

* remove unused translation keys

* fix merge conflict issues

* code clean up

* remove SMS action support

* remove more SMS code

* add missing imports

* set custom template for form action

* type fixes

* fix tasker endpoint

* fix duplicate check

* fix workfows.test.ts

* use repository funciton to getHideBranding

* add  back SMS action

* add back changes to smsReminderManager

* code clean up

* fix hasDuplicateSubmission

* code clean up

* select only needed properties

* remove repository functions

* Revert "remove repository functions"

This reverts commit 7aa47b1c59c9abd7f964ebf26f746934c53a44f4.

* add scheduleWorkflows function

* Revert "add scheduleWorkflows function"

This reverts commit fe5db4fe3b65e2743c95475d585300cab98beed7.

* move type to /types

* Revert "move type to /types"

This reverts commit 91e0152154594b3772a801a426260068a8ccea54.

* revert changes causing type errors

* remove import

* remove unused import

* Revert "remove unused import"

This reverts commit 1916768c875ea5d0ac5598ccb8f9c796c5622dc9.

* revert changed from attempt to fix type errors

* pass object to gt all workflows

* fix isAuthorized check

* trigger filtering

* remove form submitted no event booked code

* remove form submitted no event from schema

* remove more code

* remove test

* fixes

* add getSubmitterEmail function

* add trigger

* small fixes

* add missing workflow DTOs

* small fixes

* use activeOnWithChildren

* fix active on when switching trigger type

* remove add variable dropdown

* add getAllWorkflowsFromRoutingForm to WorkflowService

* fix error caused by undefined evt

* fix type error

* fix type error

* fix tests

* code clean up

* final fixes and clean up

* remove console.log

* remove template text form from triggers

* add routing form repoditory function

* fix bug with key

* add comments

* fix test

* add missing await

* use predefined FormSubmissionData type

* add .trim() to sms message

* pass contextData instead

* add missing trigger in update-workflow.input.ts

* ForEvt and ForForm function for aiPhoneCallManager

* chore: add support for form workflows on api v2

* fixup! chore: add support for form workflows on api v2

* use only repository functions in update handler

* move all prisma queries from list.handler

* review suggestions

* chore: handle workflows api v2

* chore: handle workflows api v2, split in 2 endpoints

* fix workflow step creation

* remove connect agent and fixes types

* add type to workflow

* chore: use workflow type in apiv2 WorkflowsOutputService

* update worklfow type on update

* chore: use workflow type in apiv2 WorkflowsOutputService

* fix template body for torm trigger

* some UI fixes for email subject/body

* resetting email body when changing form triggers

* use type field to query workflows

* clean up all old active on values

* remove responseId from all funciton calls

* remove undefined from updateTemplate

* refactor: split routing form and event-type workflows code

* refactor: split routing form and event-type workflows code

* fix template  text when adding action

* chore: don't rename WorkflowActivationDto to avoid ci blocking

* refine update schedule to use only allowed actions

* fix type error

* don't allow whatsapp action with form trigger

* fix type error

* return early if activeOn array is empty

* fix: from step type in BaseFormWorkflowStepDto

* fixup! fix: from step type in BaseFormWorkflowStepDto

* api v2 updates

* move all prisma calls to repository (service/workflows.ts)

* use FORM_TRIGGER_WORKFLOW_EVENTS for form queries

* use userRepository

* use FORM_TRIGGER_WORKFLOW_EVENTS in isFormTrigger

* code clean up

* code clean up

* use repository functions in formSubmissionValidation.ts

* remove action check in update handler

* add back trpc  import

* fix agent repository functions

* add SMS actions to allowed form action constants

* remove unsued import

* fixes for offset api v2

* add missing responseId

* fix failing test

* fix failing test

* remove unused imports

* chore: handle sms step action for form worklfow in dtos

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Benny Joo <sldisek783@gmail.com>
Co-authored-by: cal.com <morgan@cal.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2025-10-21 15:28:10 +00:00

417 lines
12 KiB
TypeScript

import type { FORM_SUBMITTED_WEBHOOK_RESPONSES } from "@calcom/app-store/routing-forms/lib/formSubmissionUtils";
import {
isAttendeeAction,
isSMSAction,
isSMSOrWhatsappAction,
isWhatsappAction,
isCalAIAction,
} from "@calcom/features/ee/workflows/lib/actionHelperFunctions";
import { sendOrScheduleWorkflowEmails } from "@calcom/features/ee/workflows/lib/reminders/providers/emailProvider";
import * as twilio from "@calcom/features/ee/workflows/lib/reminders/providers/twilioProvider";
import type { Workflow, WorkflowStep } from "@calcom/features/ee/workflows/lib/types";
import { getSubmitterEmail } from "@calcom/features/tasker/tasks/triggerFormSubmittedNoEvent/formSubmissionValidation";
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import { checkSMSRateLimit } from "@calcom/lib/checkRateLimitAndThrowError";
import { SENDER_NAME } from "@calcom/lib/constants";
import { formatCalEventExtended } from "@calcom/lib/formatCalendarEvent";
import { withReporting } from "@calcom/lib/sentryWrapper";
import { getTranslation } from "@calcom/lib/server/i18n";
import prisma from "@calcom/prisma";
import { SchedulingType } from "@calcom/prisma/enums";
import { WorkflowActions, WorkflowMethods, WorkflowTriggerEvents } from "@calcom/prisma/enums";
import type { CalendarEvent } from "@calcom/types/Calendar";
import { scheduleAIPhoneCall } from "./aiPhoneCallManager";
import { scheduleEmailReminder } from "./emailReminderManager";
import type { BookingInfo } from "./smsReminderManager";
import { scheduleSMSReminder, type ScheduleTextReminderAction } from "./smsReminderManager";
import { scheduleWhatsappReminder } from "./whatsappReminderManager";
export type FormSubmissionData = {
responses: FORM_SUBMITTED_WEBHOOK_RESPONSES;
user: {
email: string;
timeFormat: number | null;
locale: string;
};
};
export type ExtendedCalendarEvent = Omit<CalendarEvent, "bookerUrl"> & {
metadata?: { videoCallUrl: string | undefined };
eventType: {
slug: string;
schedulingType?: SchedulingType | null;
hosts?: { user: { email: string; destinationCalendar?: { primaryEmail: string | null } | null } }[];
};
rescheduleReason?: string | null;
cancellationReason?: string | null;
bookerUrl: string;
};
type ProcessWorkflowStepParams = (
| { calendarEvent: ExtendedCalendarEvent; formData?: never }
| {
calendarEvent?: never;
formData: FormSubmissionData;
}
) & {
smsReminderNumber: string | null;
emailAttendeeSendToOverride?: string;
hideBranding?: boolean;
seatReferenceUid?: string;
};
export type ScheduleWorkflowRemindersArgs = ProcessWorkflowStepParams & {
workflows: Workflow[];
isDryRun?: boolean;
};
const processWorkflowStep = async (
workflow: Workflow,
step: WorkflowStep,
{
smsReminderNumber,
calendarEvent,
emailAttendeeSendToOverride,
hideBranding,
seatReferenceUid,
formData,
}: ProcessWorkflowStepParams
) => {
if (!step?.verifiedAt) return;
const evt = calendarEvent ? formatCalEventExtended(calendarEvent) : undefined;
if (!evt && !formData) return;
const contextData:
| { evt: BookingInfo; formData?: never }
| {
evt?: never;
formData: FormSubmissionData;
} = evt ? { evt } : { formData: formData as FormSubmissionData };
if (isSMSOrWhatsappAction(step.action)) {
await checkSMSRateLimit({
identifier: `sms:${workflow.teamId ? "team:" : "user:"}${workflow.teamId || workflow.userId}`,
rateLimitingType: "sms",
});
}
// Common parameters for all scheduling functions
const scheduleFunctionParams = {
triggerEvent: workflow.trigger,
timeSpan: {
time: workflow.time,
timeUnit: workflow.timeUnit,
},
workflowStepId: step.id,
template: step.template,
userId: workflow.userId,
teamId: workflow.teamId,
seatReferenceUid,
verifiedAt: step.verifiedAt,
};
if (isSMSAction(step.action)) {
const sendTo = step.action === WorkflowActions.SMS_ATTENDEE ? smsReminderNumber : step.sendTo;
await scheduleSMSReminder({
...scheduleFunctionParams,
reminderPhone: sendTo,
action: step.action as ScheduleTextReminderAction,
message: step.reminderBody || "",
sender: step.sender,
isVerificationPending: step.numberVerificationPending,
...contextData,
});
} else if (
step.action === WorkflowActions.EMAIL_ATTENDEE ||
step.action === WorkflowActions.EMAIL_HOST ||
step.action === WorkflowActions.EMAIL_ADDRESS
) {
let sendTo: string[] = [];
switch (step.action) {
case WorkflowActions.EMAIL_ADDRESS:
sendTo = [step.sendTo || ""];
break;
case WorkflowActions.EMAIL_HOST: {
if (!evt) {
// EMAIL_HOST is not supported for form triggers
return;
}
sendTo = [evt.organizer?.email || ""];
const schedulingType = evt.eventType.schedulingType;
const isTeamEvent =
schedulingType === SchedulingType.ROUND_ROBIN || schedulingType === SchedulingType.COLLECTIVE;
if (isTeamEvent && evt.team?.members) {
sendTo = sendTo.concat(evt.team.members.map((member) => member.email));
}
break;
}
case WorkflowActions.EMAIL_ATTENDEE:
if (evt) {
const attendees = emailAttendeeSendToOverride
? [emailAttendeeSendToOverride]
: evt.attendees?.map((attendee) => attendee.email);
const limitGuestsDate = new Date("2025-01-13");
if (workflow.userId) {
const userRepository = new UserRepository(prisma);
const user = await userRepository.findById({ id: workflow.userId });
if (user?.createdDate && user.createdDate > limitGuestsDate) {
sendTo = attendees.slice(0, 1);
} else {
sendTo = attendees;
}
} else {
sendTo = attendees;
}
}
if (formData) {
const submitterEmail = getSubmitterEmail(formData.responses);
if (submitterEmail) {
sendTo = [submitterEmail];
}
}
}
const emailParams = {
...scheduleFunctionParams,
action: step.action,
sendTo,
emailSubject: step.emailSubject || "",
emailBody: step.reminderBody || "",
sender: step.sender || SENDER_NAME,
hideBranding,
includeCalendarEvent: step.includeCalendarEvent,
...contextData,
verifiedAt: step.verifiedAt,
} as const;
await scheduleEmailReminder(emailParams);
} else if (isWhatsappAction(step.action)) {
if (!evt) {
// Whatsapp action not not yet supported for form triggers
return;
}
const sendTo = step.action === WorkflowActions.WHATSAPP_ATTENDEE ? smsReminderNumber : step.sendTo;
await scheduleWhatsappReminder({
...scheduleFunctionParams,
reminderPhone: sendTo,
action: step.action as ScheduleTextReminderAction,
message: step.reminderBody || "",
isVerificationPending: step.numberVerificationPending,
evt,
});
} else if (isCalAIAction(step.action)) {
if (!evt) {
// cal.ai not yet supported for form triggers
return;
}
await scheduleAIPhoneCall({
evt,
triggerEvent: workflow.trigger,
timeSpan: {
time: workflow.time,
timeUnit: workflow.timeUnit,
},
workflowStepId: step.id,
userId: workflow.userId,
teamId: workflow.teamId,
seatReferenceUid,
verifiedAt: step.verifiedAt,
});
}
};
const _scheduleWorkflowReminders = async (args: ScheduleWorkflowRemindersArgs) => {
const {
workflows,
smsReminderNumber,
calendarEvent: evt,
emailAttendeeSendToOverride = "",
hideBranding,
seatReferenceUid,
isDryRun = false,
formData,
} = args;
if (isDryRun || !workflows.length) return;
for (const workflow of workflows) {
if (workflow.steps.length === 0) continue;
for (const step of workflow.steps) {
await processWorkflowStep(workflow, step, {
emailAttendeeSendToOverride,
smsReminderNumber,
hideBranding,
seatReferenceUid,
...(evt ? { calendarEvent: evt } : { formData }),
});
}
}
};
export interface SendCancelledRemindersArgs {
workflows: Workflow[];
smsReminderNumber: string | null;
evt: ExtendedCalendarEvent;
hideBranding?: boolean;
}
const _sendCancelledReminders = async (args: SendCancelledRemindersArgs) => {
const { smsReminderNumber, evt, workflows, hideBranding } = args;
if (!workflows.length) return;
for (const workflow of workflows) {
if (workflow.trigger !== WorkflowTriggerEvents.EVENT_CANCELLED) continue;
for (const step of workflow.steps) {
await processWorkflowStep(workflow, step, {
smsReminderNumber,
hideBranding,
calendarEvent: evt,
});
}
}
};
const _cancelScheduledMessagesAndScheduleEmails = async ({
teamId,
userId,
}: {
teamId?: number | null;
userId?: number | null;
}) => {
const { CreditService } = await import("@calcom/features/ee/billing/credit-service");
let userIdsWithNoCredits: number[] = userId ? [userId] : [];
if (teamId) {
const teamMembers = await prisma.membership.findMany({
where: {
teamId,
accepted: true,
},
});
const creditService = new CreditService();
userIdsWithNoCredits = (
await Promise.all(
teamMembers.map(async (member) => {
const hasCredits = await creditService.hasAvailableCredits({ userId: member.userId });
return { userId: member.userId, hasCredits };
})
)
)
.filter(({ hasCredits }) => !hasCredits)
.map(({ userId }) => userId);
}
const scheduledMessages = await prisma.workflowReminder.findMany({
where: {
workflowStep: {
workflow: {
OR: [
{
userId: {
in: userIdsWithNoCredits,
},
},
...(teamId ? [{ teamId }] : []),
],
},
},
scheduled: true,
OR: [{ cancelled: false }, { cancelled: null }],
referenceId: {
not: null,
},
method: {
in: [WorkflowMethods.SMS, WorkflowMethods.WHATSAPP],
},
},
select: {
referenceId: true,
workflowStep: {
select: {
action: true,
},
},
scheduledDate: true,
uuid: true,
id: true,
booking: {
select: {
attendees: {
select: {
email: true,
locale: true,
},
},
user: {
select: {
email: true,
},
},
},
},
},
});
await Promise.allSettled(scheduledMessages.map((msg) => twilio.cancelSMS(msg.referenceId ?? "")));
await Promise.allSettled(
scheduledMessages.map(async (msg) => {
if (msg.workflowStep?.action && isAttendeeAction(msg.workflowStep.action)) {
const messageBody = await twilio.getMessageBody(msg.referenceId ?? "");
const sendTo = msg.booking?.attendees?.[0];
if (sendTo) {
const t = await getTranslation(sendTo.locale ?? "en", "common");
await sendOrScheduleWorkflowEmails({
to: [sendTo.email],
subject: t("notification_about_your_booking"),
html: messageBody,
replyTo: msg.booking?.user?.email ?? "",
sendAt: msg.scheduledDate,
referenceUid: msg.uuid || undefined,
});
}
}
})
);
await prisma.workflowReminder.updateMany({
where: {
id: {
in: scheduledMessages.map((msg) => msg.id),
},
},
data: {
method: WorkflowMethods.EMAIL,
referenceId: null,
},
});
};
// Export functions wrapped with withReporting
export const scheduleWorkflowReminders = withReporting(
_scheduleWorkflowReminders,
"scheduleWorkflowReminders"
);
export const sendCancelledReminders = withReporting(_sendCancelledReminders, "sendCancelledReminders");
export const cancelScheduledMessagesAndScheduleEmails = withReporting(
_cancelScheduledMessagesAndScheduleEmails,
"cancelScheduledMessagesAndScheduleEmails"
);