* fix: break circular dependency in messageDispatcher via dependency injection Break the 4-file circular dependency chain: credit-service → reminderScheduler → smsReminderManager → messageDispatcher → credit-service Solution: - Add optional creditCheckFn parameter to messageDispatcher functions - Thread creditCheckFn through the call chain: scheduleWorkflowReminders → scheduleSMSReminder/scheduleWhatsappReminder → messageDispatcher - When creditCheckFn is provided, use it; otherwise fall back to dynamic CreditService import for backward compatibility - This breaks the workflows → billing import while preserving immediate fallback behavior Changes: - messageDispatcher: Accept optional creditCheckFn parameter, use it if provided - smsReminderManager: Thread creditCheckFn through scheduleSMSReminder - whatsappReminderManager: Thread creditCheckFn through scheduleWhatsappReminder - reminderScheduler: Add creditCheckFn to ScheduleWorkflowRemindersArgs and pass through processWorkflowStep All type checks, lint checks, and unit tests pass. Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * feat: wire creditCheckFn from all callers to complete circular dependency fix - Add creditCheckFn parameter to WorkflowService.scheduleFormWorkflows - Wire creditCheckFn from all 10 entry points that call workflow scheduling: * formSubmissionUtils.ts (form submissions) * roundRobinManualReassignment.ts (round-robin reassignment) * triggerFormSubmittedNoEventWorkflow.ts (form workflow trigger) * handleBookingRequested.ts (booking requests) * RegularBookingService.ts (2 calls - payment initiated & new bookings) * handleSeats.ts (seated bookings) * handleConfirmation.ts (2 calls - confirmation & payment) * handleMarkNoShow.ts (no-show updates) * confirm.handler.ts (booking rejection) - Update test expectations to use expect.objectContaining() - Fix pre-existing lint warning in handleMarkNoShow.ts (any type) - This completes the messageDispatcher circular dependency fix by ensuring creditCheckFn is actually passed through the call chain, breaking the 4-file circular dependency at runtime Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * fix: use generic type with type guard in logFailedResults to fix type check error - Replace constrained type with generic type parameter - Add proper type guard for rejected promises - Fixes CI type check failure in handleMarkNoShow.ts:385 - Avoids 'any' type while accepting any fulfilled value shape Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * wip * wip * wip * revert * revert * feat: wire creditCheckFn from all remaining callers to eliminate fallbacks - Wire creditCheckFn in packages/sms/sms-manager.ts (can safely import CreditService) - Create makeHandler factory pattern for CRON endpoints (scheduleSMSReminders.ts, scheduleWhatsappReminders.ts) - Wire creditCheckFn from apps/web CRON routes to factories - Add warning log in messageDispatcher when fallback is used - Complete creditCheckFn wiring from all direct callers (activateEventType.handler.ts, util.ts) This eliminates all fallbacks to dynamic import except as a safety net for unforeseen call sites. The circular dependency (workflows ↔ billing) remains acceptable as discussed with user (Option C). Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * test: update formSubmissionUtils tests to expect creditCheckFn parameter The scheduleFormWorkflows function now receives creditCheckFn as a parameter. Updated test assertions to use expect.objectContaining() with creditCheckFn: expect.any(Function) to account for the new dependency injection parameter. Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * test: update sms-manager test to expect creditCheckFn parameter The sendSmsOrFallbackEmail function now receives creditCheckFn as a parameter. Updated test assertion to use expect.objectContaining() with creditCheckFn: expect.any(Function) to account for the new dependency injection parameter. Also removed teamId: undefined assertion as the key may be omitted entirely from the actual call. Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * feat: make creditCheckFn required to fully break circular dependency This commit completes the circular dependency fix by making creditCheckFn required throughout the call chain, eliminating the dynamic import fallback entirely. Changes: - Make creditCheckFn required in messageDispatcher functions (sendSmsOrFallbackEmail, scheduleSmsOrFallbackEmail) - Remove dynamic import fallback and warning log from messageDispatcher - Make creditCheckFn required in ScheduleTextReminderArgs (smsReminderManager) - Make creditCheckFn required in processWorkflowStep and ScheduleWorkflowRemindersArgs (reminderScheduler) - Add creditCheckFn to SendCancelledRemindersArgs and wire from handleCancelBooking The circular dependency is now fully broken - no more dynamic imports of CreditService from within the workflows package. All callers must explicitly provide creditCheckFn via dependency injection. Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * fix: make creditCheckFn required in WorkflowService.scheduleFormWorkflows This commit fixes the CI type check error by making creditCheckFn required in WorkflowService.scheduleFormWorkflows. Previously, creditCheckFn was optional in scheduleFormWorkflows but required in scheduleWorkflowReminders, causing a type mismatch. Changes: - Make creditCheckFn required in scheduleFormWorkflows signature - Update WorkflowService.test.ts to pass mock creditCheckFn in all test cases - Add responseId and routedEventTypeId to test calls for completeness All callers of scheduleFormWorkflows already pass creditCheckFn, so this change is safe and completes the circular dependency fix. Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * remove * fix * refactor * refactor * refactor * wip * fix * fix * rm --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
358 lines
11 KiB
TypeScript
358 lines
11 KiB
TypeScript
import type { FORM_SUBMITTED_WEBHOOK_RESPONSES } from "@calcom/app-store/routing-forms/lib/formSubmissionUtils";
|
|
import type { CreditCheckFn } from "@calcom/features/ee/billing/credit-service";
|
|
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 { 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 { checkSMSRateLimit } from "@calcom/lib/smsLockState";
|
|
import prisma from "@calcom/prisma";
|
|
import { SchedulingType } from "@calcom/prisma/enums";
|
|
import { WorkflowActions, 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;
|
|
routedEventTypeId: number | null;
|
|
user: {
|
|
email: string;
|
|
timeFormat: number | null;
|
|
locale: string;
|
|
};
|
|
};
|
|
|
|
export type WorkflowContextData =
|
|
| { evt: BookingInfo; formData?: never }
|
|
| {
|
|
evt?: never;
|
|
formData: FormSubmissionData;
|
|
};
|
|
|
|
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;
|
|
creditCheckFn: CreditCheckFn;
|
|
};
|
|
|
|
const processWorkflowStep = async (
|
|
workflow: Workflow,
|
|
step: WorkflowStep,
|
|
{
|
|
smsReminderNumber,
|
|
calendarEvent,
|
|
emailAttendeeSendToOverride,
|
|
hideBranding,
|
|
seatReferenceUid,
|
|
formData,
|
|
}: ProcessWorkflowStepParams,
|
|
creditCheckFn: CreditCheckFn
|
|
) => {
|
|
if (!step?.verifiedAt) return;
|
|
|
|
const evt = calendarEvent ? formatCalEventExtended(calendarEvent) : undefined;
|
|
|
|
if (!evt && !formData) return;
|
|
|
|
const contextData: WorkflowContextData = 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,
|
|
creditCheckFn,
|
|
};
|
|
|
|
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)) {
|
|
await scheduleAIPhoneCall({
|
|
triggerEvent: workflow.trigger,
|
|
timeSpan: {
|
|
time: workflow.time,
|
|
timeUnit: workflow.timeUnit,
|
|
},
|
|
workflowStepId: step.id,
|
|
userId: workflow.userId,
|
|
teamId: workflow.teamId,
|
|
seatReferenceUid,
|
|
submittedPhoneNumber: smsReminderNumber,
|
|
verifiedAt: step.verifiedAt,
|
|
routedEventTypeId: formData ? formData.routedEventTypeId : null,
|
|
...contextData,
|
|
});
|
|
}
|
|
};
|
|
|
|
const _scheduleWorkflowReminders = async (args: ScheduleWorkflowRemindersArgs) => {
|
|
const {
|
|
workflows,
|
|
smsReminderNumber,
|
|
calendarEvent: evt,
|
|
emailAttendeeSendToOverride = "",
|
|
hideBranding,
|
|
seatReferenceUid,
|
|
isDryRun = false,
|
|
formData,
|
|
creditCheckFn,
|
|
} = 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 }),
|
|
},
|
|
creditCheckFn
|
|
);
|
|
}
|
|
}
|
|
};
|
|
|
|
export interface SendCancelledRemindersArgs {
|
|
workflows: Workflow[];
|
|
smsReminderNumber: string | null;
|
|
evt: ExtendedCalendarEvent;
|
|
hideBranding?: boolean;
|
|
creditCheckFn: CreditCheckFn;
|
|
}
|
|
|
|
const _sendCancelledReminders = async (args: SendCancelledRemindersArgs) => {
|
|
const { smsReminderNumber, evt, workflows, hideBranding, creditCheckFn } = 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,
|
|
},
|
|
creditCheckFn
|
|
);
|
|
}
|
|
}
|
|
};
|
|
|
|
const _cancelScheduledMessagesAndScheduleEmails = async ({
|
|
teamId,
|
|
userIdsWithNoCredits,
|
|
}: {
|
|
teamId?: number | null;
|
|
userIdsWithNoCredits: number[];
|
|
}) => {
|
|
const { WorkflowReminderRepository } = await import(
|
|
"@calcom/features/ee/workflows/repositories/WorkflowReminderRepository"
|
|
);
|
|
|
|
const scheduledMessages = await WorkflowReminderRepository.findScheduledMessagesToCancel({
|
|
teamId,
|
|
userIdsWithNoCredits,
|
|
});
|
|
|
|
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 WorkflowReminderRepository.updateRemindersToEmail({
|
|
reminderIds: scheduledMessages.map((msg) => msg.id),
|
|
});
|
|
};
|
|
// Export functions wrapped with withReporting
|
|
export const scheduleWorkflowReminders = withReporting(
|
|
_scheduleWorkflowReminders,
|
|
"scheduleWorkflowReminders"
|
|
);
|
|
export const sendCancelledReminders = withReporting(_sendCancelledReminders, "sendCancelledReminders");
|
|
export const cancelScheduledMessagesAndScheduleEmails = withReporting(
|
|
_cancelScheduledMessagesAndScheduleEmails,
|
|
"cancelScheduledMessagesAndScheduleEmails"
|
|
);
|