Files
calendar/packages/features/ee/workflows/api/scheduleSMSReminders.ts
T
Benny JooGitHubDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
d9cddd85ff fix: break circular dependency by passing creditCheckFn in messageDispatcher (#25343)
* 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>
2025-11-27 16:31:26 +09:00

246 lines
9.4 KiB
TypeScript

/* Schedule any workflow reminder that falls within the next 2 hours for SMS */
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import dayjs from "@calcom/dayjs";
import { bulkShortenLinks } from "@calcom/ee/workflows/lib/reminders/utils";
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
import { CreditService } from "@calcom/features/ee/billing/credit-service";
import { getBookerBaseUrl } from "@calcom/features/ee/organizations/lib/getBookerUrlServer";
import { isAttendeeAction } from "@calcom/features/ee/workflows/lib/actionHelperFunctions";
import { scheduleSmsOrFallbackEmail } from "@calcom/features/ee/workflows/lib/reminders/messageDispatcher";
import { getTranslation } from "@calcom/lib/server/i18n";
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
import prisma from "@calcom/prisma";
import { WorkflowActions, WorkflowMethods, WorkflowTemplates } from "@calcom/prisma/enums";
import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
import { getSenderId } from "../lib/alphanumericSenderIdSupport";
import type { PartialWorkflowReminder } from "../lib/getWorkflowReminders";
import { select, getWorkflowRecipientEmail } from "../lib/getWorkflowReminders";
import type { VariablesType } from "../lib/reminders/templates/customTemplate";
import customTemplate from "../lib/reminders/templates/customTemplate";
import smsReminderTemplate from "../lib/reminders/templates/smsReminderTemplate";
import { WorkflowOptOutService } from "../lib/service/workflowOptOutService";
export async function handler(req: NextRequest) {
const apiKey = req.headers.get("authorization") || req.nextUrl.searchParams.get("apiKey");
if (process.env.CRON_API_KEY !== apiKey) {
return NextResponse.json({ message: "Not authenticated" }, { status: 401 });
}
//find all unscheduled SMS reminders
const unscheduledReminders = (await prisma.workflowReminder.findMany({
where: {
method: WorkflowMethods.SMS,
scheduled: false,
scheduledDate: {
gte: new Date(),
lte: dayjs().add(2, "hour").toISOString(),
},
retryCount: {
lt: 3, // Don't continue retrying if it's already failed 3 times
},
},
select: {
...select,
retryCount: true,
},
})) as (PartialWorkflowReminder & { retryCount: number })[];
if (!unscheduledReminders.length) {
return NextResponse.json({ ok: true });
}
for (const reminder of unscheduledReminders) {
if (!reminder.workflowStep || !reminder.booking) {
continue;
}
const userId = reminder.workflowStep.workflow.userId;
const teamId = reminder.workflowStep.workflow.teamId;
try {
const sendTo =
reminder.workflowStep.action === WorkflowActions.SMS_NUMBER
? reminder.workflowStep.sendTo
: reminder.booking?.smsReminderNumber;
const userName =
reminder.workflowStep.action === WorkflowActions.SMS_ATTENDEE
? reminder.booking?.attendees[0].name
: "";
const attendeeName =
reminder.workflowStep.action === WorkflowActions.SMS_ATTENDEE
? reminder.booking?.user?.name
: reminder.booking?.attendees[0].name;
const timeZone =
reminder.workflowStep.action === WorkflowActions.SMS_ATTENDEE
? reminder.booking?.attendees[0].timeZone
: reminder.booking?.user?.timeZone;
const senderID = getSenderId(sendTo, reminder.workflowStep.sender);
const locale =
reminder.workflowStep.action === WorkflowActions.EMAIL_ATTENDEE ||
reminder.workflowStep.action === WorkflowActions.SMS_ATTENDEE
? reminder.booking?.attendees[0].locale
: reminder.booking?.user?.locale;
let message: string | null = reminder.workflowStep.reminderBody || null;
if (reminder.workflowStep.reminderBody) {
const { responses } = getCalEventResponses({
bookingFields: reminder.booking.eventType?.bookingFields ?? null,
booking: reminder.booking,
});
const organizerOrganizationProfile = await prisma.profile.findFirst({
where: {
userId: reminder.booking.user?.id,
},
});
const organizerOrganizationId = organizerOrganizationProfile?.organizationId;
const bookerUrl = await getBookerBaseUrl(
reminder.booking.eventType?.team?.parentId ?? organizerOrganizationId ?? null
);
const recipientEmail = getWorkflowRecipientEmail({
action: reminder.workflowStep.action || WorkflowActions.SMS_NUMBER,
attendeeEmail: reminder.booking.attendees[0].email,
organizerEmail: reminder.booking.user?.email,
});
const urls = {
meetingUrl: bookingMetadataSchema.parse(reminder.booking?.metadata || {})?.videoCallUrl || "",
cancelLink: `${bookerUrl}/booking/${reminder.booking.uid}?cancel=true${
recipientEmail ? `&cancelledBy=${recipientEmail}` : ""
}`,
rescheduleLink: `${bookerUrl}/reschedule/${reminder.booking.uid}${
recipientEmail ? `?rescheduledBy=${recipientEmail}` : ""
}`,
};
const [{ shortLink: meetingUrl }, { shortLink: cancelLink }, { shortLink: rescheduleLink }] =
await bulkShortenLinks([urls.meetingUrl, urls.cancelLink, urls.rescheduleLink]);
const variables: VariablesType = {
eventName: reminder.booking?.eventType?.title,
organizerName: reminder.booking?.user?.name || "",
attendeeName: reminder.booking?.attendees[0].name,
attendeeEmail: reminder.booking?.attendees[0].email,
eventDate: dayjs(reminder.booking?.startTime).tz(timeZone),
eventEndTime: dayjs(reminder.booking?.endTime).tz(timeZone),
timeZone: timeZone,
location: reminder.booking?.location || "",
additionalNotes: reminder.booking?.description,
responses: responses,
meetingUrl,
cancelLink,
rescheduleLink,
attendeeTimezone: reminder.booking.attendees[0].timeZone,
eventTimeInAttendeeTimezone: dayjs(reminder.booking.startTime).tz(
reminder.booking.attendees[0].timeZone
),
eventEndTimeInAttendeeTimezone: dayjs(reminder.booking?.endTime).tz(
reminder.booking.attendees[0].timeZone
),
};
const customMessage = customTemplate(
reminder.workflowStep.reminderBody || "",
variables,
locale || "en",
getTimeFormatStringFromUserTimeFormat(reminder.booking.user?.timeFormat)
);
message = customMessage.text;
} else if (reminder.workflowStep.template === WorkflowTemplates.REMINDER) {
message = smsReminderTemplate(
false,
reminder.booking.user?.locale || "en",
reminder.workflowStep.action,
getTimeFormatStringFromUserTimeFormat(reminder.booking.user?.timeFormat),
reminder.booking?.startTime.toISOString() || "",
reminder.booking?.eventType?.title || "",
timeZone || "",
attendeeName || "",
userName
);
}
if (message?.length && message?.length > 0 && sendTo) {
const smsMessageWithoutOptOut = await WorkflowOptOutService.addOptOutMessage(message, locale || "en");
const creditService = new CreditService();
const scheduledNotification = await scheduleSmsOrFallbackEmail({
twilioData: {
phoneNumber: sendTo,
body: message,
scheduledDate: reminder.scheduledDate,
sender: senderID,
bodyWithoutOptOut: smsMessageWithoutOptOut,
bookingUid: reminder.booking.uid,
userId,
teamId,
},
fallbackData:
reminder.workflowStep.action && isAttendeeAction(reminder.workflowStep.action)
? {
email: reminder.booking.attendees[0].email,
t: await getTranslation(locale || "en", "common"),
replyTo: reminder.booking?.user?.email ?? "",
workflowStepId: reminder.workflowStep.id,
}
: undefined,
creditCheckFn: creditService.hasAvailableCredits.bind(creditService),
});
if (scheduledNotification) {
if (scheduledNotification.sid) {
await prisma.workflowReminder.update({
where: {
id: reminder.id,
},
data: {
scheduled: true,
referenceId: scheduledNotification.sid,
},
});
} else if (scheduledNotification.emailReminderId) {
await prisma.workflowReminder.delete({
where: {
id: reminder.id,
},
});
}
} else {
await prisma.workflowReminder.update({
where: {
id: reminder.id,
},
data: {
retryCount: reminder.retryCount + 1,
},
});
}
}
} catch (error) {
await prisma.workflowReminder.update({
where: {
id: reminder.id,
},
data: {
retryCount: reminder.retryCount + 1,
},
});
console.log(`Error scheduling SMS with error ${error}`);
}
}
return NextResponse.json({ message: "SMS scheduled" }, { status: 200 });
}