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>
This commit is contained in:
Benny Joo
2025-11-27 16:31:26 +09:00
committed by GitHub
co-authored by Devin AI
parent 1ab4b9dfab
commit d9cddd85ff
24 changed files with 247 additions and 109 deletions
@@ -158,25 +158,28 @@ describe("_onFormSubmission", () => {
await _onFormSubmission(mockForm, mockResponse, responseId);
expect(WorkflowService.getAllWorkflowsFromRoutingForm).toHaveBeenCalledWith(mockForm);
expect(WorkflowService.scheduleFormWorkflows).toHaveBeenCalledWith({
workflows: mockWorkflows,
responses: {
email: {
value: "test@response.com",
response: "test@response.com",
expect(WorkflowService.scheduleFormWorkflows).toHaveBeenCalledWith(
expect.objectContaining({
workflows: mockWorkflows,
responses: {
email: {
value: "test@response.com",
response: "test@response.com",
},
name: { value: "Test Name", response: "Test Name" },
},
name: { value: "Test Name", response: "Test Name" },
},
responseId,
routedEventTypeId: null,
form: {
...mockForm,
fields: mockForm.fields.map((field) => ({
type: field.type,
identifier: field.identifier,
})),
},
});
responseId,
routedEventTypeId: null,
form: {
...mockForm,
fields: mockForm.fields.map((field) => ({
type: field.type,
identifier: field.identifier,
})),
},
creditCheckFn: expect.any(Function),
})
);
});
it("should call WorkflowService.scheduleFormWorkflows for FORM_SUBMITTED_NO_EVENT workflows", async () => {
@@ -212,25 +215,28 @@ describe("_onFormSubmission", () => {
await _onFormSubmission(mockForm, mockResponse, responseId);
expect(WorkflowService.getAllWorkflowsFromRoutingForm).toHaveBeenCalledWith(mockForm);
expect(WorkflowService.scheduleFormWorkflows).toHaveBeenCalledWith({
workflows: mockWorkflows,
responses: {
email: {
value: "test@response.com",
response: "test@response.com",
expect(WorkflowService.scheduleFormWorkflows).toHaveBeenCalledWith(
expect.objectContaining({
workflows: mockWorkflows,
responses: {
email: {
value: "test@response.com",
response: "test@response.com",
},
name: { value: "Test Name", response: "Test Name" },
},
name: { value: "Test Name", response: "Test Name" },
},
routedEventTypeId: null,
responseId,
form: {
...mockForm,
fields: mockForm.fields.map((field) => ({
type: field.type,
identifier: field.identifier,
})),
},
});
routedEventTypeId: null,
responseId,
form: {
...mockForm,
fields: mockForm.fields.map((field) => ({
type: field.type,
identifier: field.identifier,
})),
},
creditCheckFn: expect.any(Function),
})
);
});
it("should pass routedEventTypeId when chosenAction is eventTypeRedirectUrl", async () => {
@@ -271,25 +277,28 @@ describe("_onFormSubmission", () => {
await _onFormSubmission(mockForm, mockResponse, responseId, chosenAction);
expect(WorkflowService.scheduleFormWorkflows).toHaveBeenCalledWith({
workflows: mockWorkflows,
responses: {
email: {
value: "test@response.com",
response: "test@response.com",
expect(WorkflowService.scheduleFormWorkflows).toHaveBeenCalledWith(
expect.objectContaining({
workflows: mockWorkflows,
responses: {
email: {
value: "test@response.com",
response: "test@response.com",
},
name: { value: "Test Name", response: "Test Name" },
},
name: { value: "Test Name", response: "Test Name" },
},
routedEventTypeId: 42,
responseId,
form: {
...mockForm,
fields: mockForm.fields.map((field) => ({
type: field.type,
identifier: field.identifier,
})),
},
});
routedEventTypeId: 42,
responseId,
form: {
...mockForm,
fields: mockForm.fields.map((field) => ({
type: field.type,
identifier: field.identifier,
})),
},
creditCheckFn: expect.any(Function),
})
);
});
});
@@ -1,4 +1,5 @@
import dayjs from "@calcom/dayjs";
import { CreditService } from "@calcom/features/ee/billing/credit-service";
import { WorkflowService } from "@calcom/features/ee/workflows/lib/service/WorkflowService";
import type { Tasker } from "@calcom/features/tasker/tasker";
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
@@ -224,11 +225,15 @@ export async function _onFormSubmission(
chosenAction && chosenAction.type === "eventTypeRedirectUrl" && chosenAction.eventTypeId
? chosenAction.eventTypeId
: null;
const creditService = new CreditService();
await WorkflowService.scheduleFormWorkflows({
workflows,
responseId,
responses: fieldResponsesByIdentifier,
routedEventTypeId,
creditCheckFn: creditService.hasAvailableCredits.bind(creditService),
form: {
...form,
fields: form.fields.map((field) => ({
@@ -1,5 +1,6 @@
import { sendAttendeeRequestEmailAndSMS, sendOrganizerRequestEmail } from "@calcom/emails/email-manager";
import { getWebhookPayloadForBooking } from "@calcom/features/bookings/lib/getWebhookPayloadForBooking";
import { CreditService } from "@calcom/features/ee/billing/credit-service";
import { WorkflowService } from "@calcom/features/ee/workflows/lib/service/WorkflowService";
import type { Workflow } from "@calcom/features/ee/workflows/lib/types";
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
@@ -103,6 +104,8 @@ export async function handleBookingRequested(args: {
const workflows = await getAllWorkflowsFromEventType(booking.eventType, booking.userId);
if (workflows.length > 0) {
const creditService = new CreditService();
await WorkflowService.scheduleWorkflowsFilteredByTriggerEvent({
workflows,
smsReminderNumber: booking.smsReminderNumber,
@@ -117,6 +120,7 @@ export async function handleBookingRequested(args: {
},
},
triggers: [WorkflowTriggerEvents.BOOKING_REQUESTED],
creditCheckFn: creditService.hasAvailableCredits.bind(creditService),
});
}
} catch (error) {
@@ -9,6 +9,7 @@ import EventManager from "@calcom/features/bookings/lib/EventManager";
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
import { processNoShowFeeOnCancellation } from "@calcom/features/bookings/lib/payment/processNoShowFeeOnCancellation";
import { processPaymentRefund } from "@calcom/features/bookings/lib/payment/processPaymentRefund";
import { CreditService } from "@calcom/features/ee/billing/credit-service";
import { getBookerBaseUrl } from "@calcom/features/ee/organizations/lib/getBookerUrlServer";
import { sendCancelledReminders } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler";
import { WorkflowRepository } from "@calcom/features/ee/workflows/repositories/WorkflowRepository";
@@ -353,6 +354,8 @@ async function handler(input: CancelBookingInput) {
const workflows = await getAllWorkflowsFromEventType(bookingToDelete.eventType, bookingToDelete.userId);
const parsedMetadata = bookingMetadataSchema.safeParse(bookingToDelete.metadata || {});
const creditService = new CreditService();
await sendCancelledReminders({
workflows,
smsReminderNumber: bookingToDelete.smsReminderNumber,
@@ -371,6 +374,7 @@ async function handler(input: CancelBookingInput) {
},
},
hideBranding: !!bookingToDelete.eventType?.owner?.hideBranding,
creditCheckFn: creditService.hasAvailableCredits.bind(creditService),
});
let updatedBookings: {
@@ -3,6 +3,7 @@ import { scheduleMandatoryReminder } from "@calcom/ee/workflows/lib/reminders/sc
import { sendScheduledEmailsAndSMS } from "@calcom/emails/email-manager";
import type { EventManagerUser } from "@calcom/features/bookings/lib/EventManager";
import EventManager, { placeholderCreatedEvent } from "@calcom/features/bookings/lib/EventManager";
import { CreditService } from "@calcom/features/ee/billing/credit-service";
import { getBookerBaseUrl } from "@calcom/features/ee/organizations/lib/getBookerUrlServer";
import {
allowDisablingAttendeeConfirmationEmails,
@@ -360,6 +361,8 @@ export async function handleConfirmation(args: {
});
}
const creditService = new CreditService();
await WorkflowService.scheduleWorkflowsForNewBooking({
workflows,
smsReminderNumber: updatedBookings[index].smsReminderNumber,
@@ -368,6 +371,7 @@ export async function handleConfirmation(args: {
isConfirmedByDefault: true,
isNormalBookingOrFirstRecurringSlot: isFirstBooking,
isRescheduleEvent: false,
creditCheckFn: creditService.hasAvailableCredits.bind(creditService),
});
}
} catch (error) {
@@ -571,12 +575,15 @@ export async function handleConfirmation(args: {
metadata: { videoCallUrl: meetingUrl },
};
const creditService = new CreditService();
await WorkflowService.scheduleWorkflowsFilteredByTriggerEvent({
workflows,
smsReminderNumber: booking.smsReminderNumber,
calendarEvent: calendarEventForWorkflow,
hideBranding: !!updatedBookings[0].eventType?.owner?.hideBranding,
triggers: [WorkflowTriggerEvents.BOOKING_PAID],
creditCheckFn: creditService.hasAvailableCredits.bind(creditService),
});
} catch (error) {
log.error("Error while scheduling workflow reminders for booking paid", safeStringify(error));
@@ -1,6 +1,6 @@
import dayjs from "@calcom/dayjs";
import { handleWebhookTrigger } from "@calcom/features/bookings/lib/handleWebhookTrigger";
import { CreditService } from "@calcom/features/ee/billing/credit-service";
import { WorkflowService } from "@calcom/features/ee/workflows/lib/service/WorkflowService";
import type { EventPayloadType } from "@calcom/features/webhooks/lib/sendPayload";
import { ErrorCode } from "@calcom/lib/errorCodes";
@@ -108,6 +108,8 @@ const handleSeats = async (newSeatedBookingObject: NewSeatedBookingObject) => {
...reqBodyMetadata,
};
try {
const creditService = new CreditService();
await WorkflowService.scheduleWorkflowsForNewBooking({
workflows: workflows,
smsReminderNumber: smsReminderNumber || null,
@@ -130,6 +132,7 @@ const handleSeats = async (newSeatedBookingObject: NewSeatedBookingObject) => {
isConfirmedByDefault: !evt.requiresConfirmation,
isRescheduleEvent: !!rescheduleUid,
isNormalBookingOrFirstRecurringSlot: true,
creditCheckFn: creditService.hasAvailableCredits.bind(creditService),
});
} catch (error) {
loggerWithEventDetails.error("Error while scheduling workflow reminders", JSON.stringify({ error }));
@@ -35,6 +35,7 @@ import { handleWebhookTrigger } from "@calcom/features/bookings/lib/handleWebhoo
import { isEventTypeLoggingEnabled } from "@calcom/features/bookings/lib/isEventTypeLoggingEnabled";
import { BookingEventHandlerService } from "@calcom/features/bookings/lib/onBookingEvents/BookingEventHandlerService";
import { getSpamCheckService } from "@calcom/features/di/watchlist/containers/SpamCheckService.container";
import { CreditService } from "@calcom/features/ee/billing/credit-service";
import { getBookerBaseUrl } from "@calcom/features/ee/organizations/lib/getBookerUrlServer";
import AssignmentReasonRecorder from "@calcom/features/ee/round-robin/assignmentReason/AssignmentReasonRecorder";
import { WorkflowService } from "@calcom/features/ee/workflows/lib/service/WorkflowService";
@@ -1261,9 +1262,9 @@ async function handler(
// This ensures that createMeeting isn't called for static video apps as bookingLocation becomes just a regular value for them.
const { bookingLocation, conferenceCredentialId } = organizerOrFirstDynamicGroupMemberDefaultLocationUrl
? {
bookingLocation: organizerOrFirstDynamicGroupMemberDefaultLocationUrl,
conferenceCredentialId: undefined,
}
bookingLocation: organizerOrFirstDynamicGroupMemberDefaultLocationUrl,
conferenceCredentialId: undefined,
}
: getLocationValueForDB(locationBodyString, eventType.locations);
tracingLogger.info("locationBodyString", locationBodyString);
@@ -1309,8 +1310,8 @@ async function handler(
const destinationCalendar = eventType.destinationCalendar
? [eventType.destinationCalendar]
: organizerUser.destinationCalendar
? [organizerUser.destinationCalendar]
: null;
? [organizerUser.destinationCalendar]
: null;
let organizerEmail = organizerUser.email || "Email-less";
if (eventType.useEventTypeDestinationCalendarEmail && destinationCalendar?.[0]?.primaryEmail) {
@@ -1931,14 +1932,14 @@ async function handler(
}
const updateManager = !skipCalendarSyncTaskCreation
? await eventManager.reschedule(
evt,
originalRescheduledBooking.uid,
undefined,
changedOrganizer,
previousHostDestinationCalendar,
isBookingRequestedReschedule,
skipDeleteEventsAndMeetings
)
evt,
originalRescheduledBooking.uid,
undefined,
changedOrganizer,
previousHostDestinationCalendar,
isBookingRequestedReschedule,
skipDeleteEventsAndMeetings
)
: placeholderCreatedEvent;
// This gets overridden when updating the event - to check if notes have been hidden or not. We just reset this back
// to the default description when we are sending the emails.
@@ -2237,8 +2238,8 @@ async function handler(
const metadata = videoCallUrl
? {
videoCallUrl: getVideoCallUrlFromCalEvent(evt) || videoCallUrl,
}
videoCallUrl: getVideoCallUrlFromCalEvent(evt) || videoCallUrl,
}
: undefined;
const bookingFlowConfig = {
@@ -2331,9 +2332,9 @@ async function handler(
...eventType,
metadata: eventType.metadata
? {
...eventType.metadata,
apps: eventType.metadata?.apps as Prisma.JsonValue,
}
...eventType.metadata,
apps: eventType.metadata?.apps as Prisma.JsonValue,
}
: {},
},
paymentAppCredentials: eventTypePaymentAppCredential as IEventTypePaymentCredentialType,
@@ -2377,6 +2378,8 @@ async function handler(
};
if (isNormalBookingOrFirstRecurringSlot) {
const creditService = new CreditService();
await WorkflowService.scheduleWorkflowsFilteredByTriggerEvent({
workflows,
smsReminderNumber: smsReminderNumber || null,
@@ -2385,6 +2388,7 @@ async function handler(
seatReferenceUid: evt.attendeeSeatId,
isDryRun,
triggers: [WorkflowTriggerEvents.BOOKING_PAYMENT_INITIATED],
creditCheckFn: creditService.hasAvailableCredits.bind(creditService),
});
}
} catch (error) {
@@ -2553,6 +2557,8 @@ async function handler(
}
try {
const creditService = new CreditService();
await WorkflowService.scheduleWorkflowsForNewBooking({
workflows,
smsReminderNumber: smsReminderNumber || null,
@@ -2563,6 +2569,7 @@ async function handler(
isConfirmedByDefault,
isNormalBookingOrFirstRecurringSlot,
isRescheduleEvent: !!rescheduleUid,
creditCheckFn: creditService.hasAvailableCredits.bind(creditService),
});
} catch (error) {
tracingLogger.error("Error while scheduling workflow reminders", JSON.stringify({ error }));
@@ -2630,7 +2637,7 @@ async function handler(
* We are open to renaming it to something more descriptive.
*/
export class RegularBookingService implements IBookingService {
constructor(private readonly deps: IBookingServiceDependencies) { }
constructor(private readonly deps: IBookingServiceDependencies) {}
async createBooking(input: { bookingData: CreateRegularBookingData; bookingMeta?: CreateBookingMeta }) {
return handler({ bookingData: input.bookingData, ...input.bookingMeta }, this.deps);
@@ -48,6 +48,8 @@ type LowCreditBalanceWarningResult = LowCreditBalanceResultBase & {
type LowCreditBalanceResult = LowCreditBalanceLimitReachedResult | LowCreditBalanceWarningResult | null;
export type CreditCheckFn = CreditService["hasAvailableCredits"];
export class CreditService {
async chargeCredits({
userId,
@@ -158,7 +160,13 @@ export class CreditService {
/*
also returns true if team has no available credits but limitReachedAt is not yet set
*/
async hasAvailableCredits({ userId, teamId }: { userId?: number | null; teamId?: number | null }) {
async hasAvailableCredits({
userId,
teamId,
}: {
userId?: number | null;
teamId?: number | null;
}): Promise<boolean> {
return await prisma.$transaction(async (tx) => {
if (!IS_SMS_CREDITS_ENABLED) return true;
@@ -13,6 +13,7 @@ import { getAllCredentialsIncludeServiceAccountKey } from "@calcom/features/book
import { getBookingResponsesPartialSchema } from "@calcom/features/bookings/lib/getBookingResponsesSchema";
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
import { getEventTypesFromDB } from "@calcom/features/bookings/lib/handleNewBooking/getEventTypesFromDB";
import { CreditService } from "@calcom/features/ee/billing/credit-service";
import { getBookerBaseUrl } from "@calcom/features/ee/organizations/lib/getBookerUrlServer";
import AssignmentReasonRecorder, {
RRReassignmentType,
@@ -602,6 +603,8 @@ export async function handleWorkflowsUpdate({
},
});
const creditService = new CreditService();
await scheduleWorkflowReminders({
workflows: newEventWorkflows,
smsReminderNumber: null,
@@ -612,6 +615,7 @@ export async function handleWorkflowsUpdate({
bookerUrl,
},
hideBranding: !!eventType?.owner?.hideBranding,
creditCheckFn: creditService.hasAvailableCredits.bind(creditService),
});
}
@@ -5,6 +5,7 @@ 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";
@@ -173,6 +174,8 @@ export async function handler(req: NextRequest) {
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,
@@ -193,6 +196,7 @@ export async function handler(req: NextRequest) {
workflowStepId: reminder.workflowStep.id,
}
: undefined,
creditCheckFn: creditService.hasAvailableCredits.bind(creditService),
});
if (scheduledNotification) {
@@ -3,6 +3,7 @@ import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import dayjs from "@calcom/dayjs";
import { CreditService } from "@calcom/features/ee/billing/credit-service";
import { getTranslation } from "@calcom/lib/server/i18n";
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
import prisma from "@calcom/prisma";
@@ -102,6 +103,8 @@ export async function handler(req: NextRequest) {
);
if (message?.length && message?.length > 0 && sendTo) {
const creditService = new CreditService();
const scheduledNotification = await scheduleSmsOrFallbackEmail({
twilioData: {
phoneNumber: sendTo,
@@ -124,6 +127,7 @@ export async function handler(req: NextRequest) {
workflowStepId: reminder.workflowStep.id,
}
: undefined,
creditCheckFn: creditService.hasAvailableCredits.bind(creditService),
});
if (scheduledNotification) {
@@ -1,5 +1,6 @@
import type { TFunction } from "i18next";
import type { CreditCheckFn } from "@calcom/features/ee/billing/credit-service";
import { sendOrScheduleWorkflowEmails } from "@calcom/features/ee/workflows/lib/reminders/providers/emailProvider";
import logger from "@calcom/lib/logger";
import prisma from "@calcom/prisma";
@@ -27,13 +28,11 @@ export async function sendSmsOrFallbackEmail(props: {
t: TFunction;
replyTo: string;
};
creditCheckFn: CreditCheckFn;
}) {
const { userId, teamId } = props.twilioData;
const { CreditService } = await import("@calcom/features/ee/billing/credit-service");
const creditService = new CreditService();
const hasCredits = await creditService.hasAvailableCredits({ userId, teamId });
const hasCredits = await props.creditCheckFn({ userId, teamId });
if (!hasCredits) {
const { fallbackData, twilioData } = props;
@@ -75,12 +74,11 @@ export async function scheduleSmsOrFallbackEmail(props: {
replyTo: string;
workflowStepId?: number;
};
creditCheckFn: CreditCheckFn;
}) {
const { userId, teamId } = props.twilioData;
const { CreditService } = await import("@calcom/features/ee/billing/credit-service");
const creditService = new CreditService();
const hasCredits = await creditService.hasAvailableCredits({ userId, teamId });
const hasCredits = await props.creditCheckFn({ userId, teamId });
if (!hasCredits) {
const { fallbackData, twilioData } = props;
@@ -1,4 +1,5 @@
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,
@@ -72,6 +73,7 @@ type ProcessWorkflowStepParams = (
export type ScheduleWorkflowRemindersArgs = ProcessWorkflowStepParams & {
workflows: Workflow[];
isDryRun?: boolean;
creditCheckFn: CreditCheckFn;
};
const processWorkflowStep = async (
@@ -84,7 +86,8 @@ const processWorkflowStep = async (
hideBranding,
seatReferenceUid,
formData,
}: ProcessWorkflowStepParams
}: ProcessWorkflowStepParams,
creditCheckFn: CreditCheckFn
) => {
if (!step?.verifiedAt) return;
@@ -114,6 +117,7 @@ const processWorkflowStep = async (
teamId: workflow.teamId,
seatReferenceUid,
verifiedAt: step.verifiedAt,
creditCheckFn,
};
if (isSMSAction(step.action)) {
@@ -243,6 +247,7 @@ const _scheduleWorkflowReminders = async (args: ScheduleWorkflowRemindersArgs) =
seatReferenceUid,
isDryRun = false,
formData,
creditCheckFn,
} = args;
if (isDryRun || !workflows.length) return;
@@ -250,13 +255,18 @@ const _scheduleWorkflowReminders = async (args: ScheduleWorkflowRemindersArgs) =
if (workflow.steps.length === 0) continue;
for (const step of workflow.steps) {
await processWorkflowStep(workflow, step, {
emailAttendeeSendToOverride,
smsReminderNumber,
hideBranding,
seatReferenceUid,
...(evt ? { calendarEvent: evt } : { formData }),
});
await processWorkflowStep(
workflow,
step,
{
emailAttendeeSendToOverride,
smsReminderNumber,
hideBranding,
seatReferenceUid,
...(evt ? { calendarEvent: evt } : { formData }),
},
creditCheckFn
);
}
}
};
@@ -266,10 +276,11 @@ export interface SendCancelledRemindersArgs {
smsReminderNumber: string | null;
evt: ExtendedCalendarEvent;
hideBranding?: boolean;
creditCheckFn: CreditCheckFn;
}
const _sendCancelledReminders = async (args: SendCancelledRemindersArgs) => {
const { smsReminderNumber, evt, workflows, hideBranding } = args;
const { smsReminderNumber, evt, workflows, hideBranding, creditCheckFn } = args;
if (!workflows.length) return;
@@ -277,11 +288,16 @@ const _sendCancelledReminders = async (args: SendCancelledRemindersArgs) => {
if (workflow.trigger !== WorkflowTriggerEvents.EVENT_CANCELLED) continue;
for (const step of workflow.steps) {
await processWorkflowStep(workflow, step, {
smsReminderNumber,
hideBranding,
calendarEvent: evt,
});
await processWorkflowStep(
workflow,
step,
{
smsReminderNumber,
hideBranding,
calendarEvent: evt,
},
creditCheckFn
);
}
}
};
@@ -4,6 +4,7 @@ import {
getSMSMessageWithVariables,
shouldUseTwilio,
} from "@calcom/ee/workflows/lib/reminders/utils";
import type { CreditCheckFn } from "@calcom/features/ee/billing/credit-service";
import { getSubmitterEmail } from "@calcom/features/tasker/tasks/triggerFormSubmittedNoEvent/formSubmissionValidation";
import { SENDER_ID } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
@@ -89,6 +90,7 @@ export type ScheduleTextReminderArgs = ScheduleReminderArgs & {
isVerificationPending?: boolean;
prisma?: PrismaClient;
verifiedAt: Date | null;
creditCheckFn: CreditCheckFn;
};
export type ScheduleTextReminderArgsWithRequiredFields = Omit<
@@ -173,6 +175,7 @@ const scheduleSMSReminderForEvt = async (
userId,
teamId,
seatReferenceUid,
creditCheckFn,
} = args;
const { startTime, endTime } = evt;
@@ -243,6 +246,7 @@ const scheduleSMSReminderForEvt = async (
replyTo: evt.organizer.email,
}
: undefined,
creditCheckFn,
});
} catch (error) {
log.error(`Error sending SMS with error ${error}`);
@@ -274,6 +278,7 @@ const scheduleSMSReminderForEvt = async (
workflowStepId,
}
: undefined,
creditCheckFn,
});
if (scheduledNotification?.sid) {
@@ -318,7 +323,8 @@ const scheduleSMSReminderForForm = async (
formData: FormSubmissionData;
}
) => {
const { message, triggerEvent, reminderPhone, sender, userId, teamId, action, formData } = args;
const { message, triggerEvent, reminderPhone, sender, userId, teamId, action, formData, creditCheckFn } =
args;
let smsMessage = message;
@@ -362,6 +368,7 @@ const scheduleSMSReminderForForm = async (
replyTo: formData.user.email,
}
: undefined,
creditCheckFn,
});
} catch (error) {
log.error(`Error sending SMS with error ${error}`);
@@ -41,6 +41,7 @@ export const scheduleWhatsappReminder = async (args: ScheduleTextReminderArgs &
isVerificationPending = false,
seatReferenceUid,
verifiedAt,
creditCheckFn,
} = args;
if (!verifiedAt) {
@@ -194,11 +195,12 @@ export const scheduleWhatsappReminder = async (args: ScheduleTextReminderArgs &
replyTo: evt.organizer.email,
}
: undefined,
creditCheckFn,
});
} catch (error) {
console.log(`Error sending WHATSAPP with error ${error}`);
}
} else if (
}else if (
(triggerEvent === WorkflowTriggerEvents.BEFORE_EVENT ||
triggerEvent === WorkflowTriggerEvents.AFTER_EVENT) &&
scheduledDate
@@ -230,6 +232,7 @@ export const scheduleWhatsappReminder = async (args: ScheduleTextReminderArgs &
workflowStepId,
}
: undefined,
creditCheckFn,
});
if (scheduledNotification?.sid) {
@@ -76,10 +76,15 @@ describe("WorkflowService.scheduleFormWorkflows", () => {
},
];
const mockCreditCheckFn = vi.fn().mockResolvedValue(true);
await WorkflowService.scheduleFormWorkflows({
workflows,
responses: mockResponses,
form: mockForm,
responseId: 123,
routedEventTypeId: null,
creditCheckFn: mockCreditCheckFn,
});
expect(mockScheduleWorkflowReminders).toHaveBeenCalledWith({
@@ -87,9 +92,11 @@ describe("WorkflowService.scheduleFormWorkflows", () => {
formData: {
responses: mockResponses,
user: { email: "formowner@example.com", timeFormat: 12, locale: "en" },
routedEventTypeId: null,
},
hideBranding: false,
workflows: [workflows[0]],
creditCheckFn: mockCreditCheckFn,
});
});
@@ -123,11 +130,15 @@ describe("WorkflowService.scheduleFormWorkflows", () => {
mockTasker.create.mockResolvedValue({ id: "task-123" });
const mockCreditCheckFn = vi.fn().mockResolvedValue(true);
await WorkflowService.scheduleFormWorkflows({
workflows,
responses: mockResponses,
responseId: 123,
form: mockForm,
routedEventTypeId: null,
creditCheckFn: mockCreditCheckFn,
});
expect(mockTasker.create).toHaveBeenCalledWith(
@@ -137,6 +148,7 @@ describe("WorkflowService.scheduleFormWorkflows", () => {
responses: mockResponses,
smsReminderNumber: "+1234567890",
hideBranding: false,
routedEventTypeId: null,
submittedAt: expect.any(Date),
form: {
id: "form-123",
@@ -190,10 +202,15 @@ describe("WorkflowService.scheduleFormWorkflows", () => {
},
];
const mockCreditCheckFn = vi.fn().mockResolvedValue(true);
await WorkflowService.scheduleFormWorkflows({
workflows,
responses: mockResponses,
form: formWithoutPhone,
responseId: 123,
routedEventTypeId: null,
creditCheckFn: mockCreditCheckFn,
});
expect(mockScheduleWorkflowReminders).toHaveBeenCalledWith({
@@ -201,9 +218,11 @@ describe("WorkflowService.scheduleFormWorkflows", () => {
formData: {
responses: mockResponses,
user: { email: "formowner@example.com", timeFormat: 12, locale: "en" },
routedEventTypeId: null,
},
hideBranding: false,
workflows: [workflows[0]],
creditCheckFn: mockCreditCheckFn,
});
});
});
@@ -4,6 +4,7 @@ import type { ScheduleWorkflowRemindersArgs } from "@calcom/ee/workflows/lib/rem
import { scheduleWorkflowReminders } from "@calcom/ee/workflows/lib/reminders/reminderScheduler";
import type { timeUnitLowerCase } from "@calcom/ee/workflows/lib/reminders/smsReminderManager";
import type { Workflow } from "@calcom/ee/workflows/lib/types";
import type { CreditCheckFn } from "@calcom/features/ee/billing/credit-service";
import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository";
import { WorkflowRepository } from "@calcom/features/ee/workflows/repositories/WorkflowRepository";
import { getHideBranding } from "@calcom/features/profile/lib/hideBranding";
@@ -87,11 +88,13 @@ export class WorkflowService {
form,
responseId,
routedEventTypeId,
creditCheckFn,
}: {
responseId: number;
workflows: Workflow[];
responses: FORM_SUBMITTED_WEBHOOK_RESPONSES;
routedEventTypeId: number | null;
creditCheckFn: CreditCheckFn;
form: {
id: string;
userId: number;
@@ -137,6 +140,7 @@ export class WorkflowService {
},
hideBranding,
workflows: workflowsToTrigger,
creditCheckFn,
});
const workflowsToSchedule: Workflow[] = [];
+4
View File
@@ -1,6 +1,7 @@
import { type TFunction } from "i18next";
import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository";
import { CreditService } from "@calcom/features/ee/billing/credit-service";
import { getBookerBaseUrl } from "@calcom/features/ee/organizations/lib/getBookerUrlServer";
import { workflowSelect } from "@calcom/features/ee/workflows/lib/getAllWorkflows";
import type { ExtendedCalendarEvent } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler";
@@ -293,12 +294,15 @@ const handleMarkNoShow = async ({
team,
};
const creditService = new CreditService();
await WorkflowService.scheduleWorkflowsFilteredByTriggerEvent({
workflows,
smsReminderNumber: booking.smsReminderNumber,
hideBranding: booking.eventType.owner?.hideBranding,
calendarEvent,
triggers: [WorkflowTriggerEvents.BOOKING_NO_SHOW_UPDATED],
creditCheckFn: creditService.hasAvailableCredits.bind(creditService),
});
} catch (error) {
logger.error("Error while scheduling workflow reminders for booking no-show updated", error);
@@ -3,6 +3,7 @@ import { z } from "zod";
import type { FORM_SUBMITTED_WEBHOOK_RESPONSES } from "@calcom/app-store/routing-forms/lib/formSubmissionUtils";
import { scheduleWorkflowReminders } from "@calcom/ee/workflows/lib/reminders/reminderScheduler";
import type { Workflow } from "@calcom/ee/workflows/lib/types";
import { CreditService } from "@calcom/features/ee/billing/credit-service";
import logger from "@calcom/lib/logger";
import { ZWorkflow } from "@calcom/trpc/server/routers/viewer/workflows/getAllActiveWorkflows.schema";
@@ -52,6 +53,8 @@ export async function triggerFormSubmittedNoEventWorkflow(payload: string): Prom
if (!shouldTrigger) return;
const creditService = new CreditService();
try {
await scheduleWorkflowReminders({
smsReminderNumber,
@@ -62,6 +65,7 @@ export async function triggerFormSubmittedNoEventWorkflow(payload: string): Prom
},
hideBranding,
workflows: [workflow as Workflow],
creditCheckFn: creditService.hasAvailableCredits.bind(creditService),
});
} catch (error) {
log.error("Error while triggering form submitted no event workflows", JSON.stringify({ error }));
+5 -1
View File
@@ -1,10 +1,11 @@
import dayjs from "@calcom/dayjs";
import { CreditService } from "@calcom/features/ee/billing/credit-service";
import { getSenderId } from "@calcom/features/ee/workflows/lib/alphanumericSenderIdSupport";
import { sendSmsOrFallbackEmail } from "@calcom/features/ee/workflows/lib/reminders/messageDispatcher";
import { checkSMSRateLimit } from "@calcom/lib/smsLockState";
import { SENDER_ID } from "@calcom/lib/constants";
import isSmsCalEmail from "@calcom/lib/isSmsCalEmail";
import { piiHasher } from "@calcom/lib/server/PiiHasher";
import { checkSMSRateLimit } from "@calcom/lib/smsLockState";
import { TimeFormat } from "@calcom/lib/timeFormat";
import prisma from "@calcom/prisma";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
@@ -37,6 +38,8 @@ const handleSendingSMS = async ({
rateLimitingType: "sms",
});
const creditService = new CreditService();
const smsOrFallbackEmail = await sendSmsOrFallbackEmail({
twilioData: {
phoneNumber: reminderPhone,
@@ -45,6 +48,7 @@ const handleSendingSMS = async ({
...(teamId ? { teamId } : { userId: organizerUserId }),
bookingUid,
},
creditCheckFn: creditService.hasAvailableCredits.bind(creditService),
});
return smsOrFallbackEmail;
+12 -10
View File
@@ -141,16 +141,18 @@ describe("SMSManager", () => {
rateLimitingType: "sms",
});
expect(sendSmsOrFallbackEmail).toHaveBeenCalledWith({
twilioData: {
phoneNumber: mockCalEvent.attendees[0].phoneNumber,
body: expect.stringContaining(mockCalEvent.attendees[0].name),
sender: expect.any(String),
teamId: undefined,
userId: 1,
bookingUid: "test-booking-uid",
},
});
expect(sendSmsOrFallbackEmail).toHaveBeenCalledWith(
expect.objectContaining({
twilioData: expect.objectContaining({
phoneNumber: mockCalEvent.attendees[0].phoneNumber,
body: expect.stringContaining(mockCalEvent.attendees[0].name),
sender: expect.any(String),
userId: 1,
bookingUid: "test-booking-uid",
}),
creditCheckFn: expect.any(Function),
})
);
expect(result).toEqual(mockSmsResponse);
});
@@ -7,6 +7,7 @@ import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventR
import { handleConfirmation } from "@calcom/features/bookings/lib/handleConfirmation";
import { handleWebhookTrigger } from "@calcom/features/bookings/lib/handleWebhookTrigger";
import { processPaymentRefund } from "@calcom/features/bookings/lib/payment/processPaymentRefund";
import { CreditService } from "@calcom/features/ee/billing/credit-service";
import { getBookerBaseUrl } from "@calcom/features/ee/organizations/lib/getBookerUrlServer";
import { workflowSelect } from "@calcom/features/ee/workflows/lib/getAllWorkflows";
import { WorkflowService } from "@calcom/features/ee/workflows/lib/service/WorkflowService";
@@ -394,6 +395,8 @@ export const confirmHandler = async ({ ctx, input }: ConfirmOptions) => {
const workflows = await getAllWorkflowsFromEventType(booking.eventType, user.id);
try {
const creditService = new CreditService();
await WorkflowService.scheduleWorkflowsFilteredByTriggerEvent({
workflows,
smsReminderNumber: booking.smsReminderNumber,
@@ -407,6 +410,7 @@ export const confirmHandler = async ({ ctx, input }: ConfirmOptions) => {
},
hideBranding: !!booking.eventType?.owner?.hideBranding,
triggers: [WorkflowTriggerEvents.BOOKING_REJECTED],
creditCheckFn: creditService.hasAvailableCredits.bind(creditService),
});
} catch (error) {
// Silently fail
@@ -1,3 +1,4 @@
import { CreditService } from "@calcom/features/ee/billing/credit-service";
import { getBookerBaseUrl } from "@calcom/features/ee/organizations/lib/getBookerUrlServer";
import { scheduleEmailReminder } from "@calcom/features/ee/workflows/lib/reminders/emailReminderManager";
import { scheduleSMSReminder } from "@calcom/features/ee/workflows/lib/reminders/smsReminderManager";
@@ -314,6 +315,8 @@ export const activateEventTypeHandler = async ({ ctx, input }: ActivateEventType
const bookerUrl = await getBookerBaseUrl(ctx.user.organizationId ?? null);
const creditService = new CreditService();
for (const booking of bookingsForReminders) {
// eventTypeId is technically nullable but we know it will be there
const bookingEventType = activeOnEventTypes.get(booking.eventTypeId!);
@@ -419,6 +422,7 @@ export const activateEventTypeHandler = async ({ ctx, input }: ActivateEventType
userId: booking.userId,
teamId: eventTypeWorkflow.teamId,
verifiedAt: step.verifiedAt,
creditCheckFn: creditService.hasAvailableCredits.bind(creditService),
});
} else if (step.action === WorkflowActions.WHATSAPP_NUMBER && step.sendTo) {
await scheduleWhatsappReminder({
@@ -436,6 +440,7 @@ export const activateEventTypeHandler = async ({ ctx, input }: ActivateEventType
userId: booking.userId,
teamId: eventTypeWorkflow.teamId,
verifiedAt: step.verifiedAt,
creditCheckFn: creditService.hasAvailableCredits.bind(creditService),
});
} else if (booking.smsReminderNumber) {
if (step.action === WorkflowActions.SMS_ATTENDEE) {
@@ -455,6 +460,7 @@ export const activateEventTypeHandler = async ({ ctx, input }: ActivateEventType
userId: booking.userId,
teamId: eventTypeWorkflow.teamId,
verifiedAt: step.verifiedAt,
creditCheckFn: creditService.hasAvailableCredits.bind(creditService),
});
} else if (step.action === WorkflowActions.WHATSAPP_ATTENDEE) {
await scheduleWhatsappReminder({
@@ -473,6 +479,7 @@ export const activateEventTypeHandler = async ({ ctx, input }: ActivateEventType
userId: booking.userId,
teamId: eventTypeWorkflow.teamId,
verifiedAt: step.verifiedAt,
creditCheckFn: creditService.hasAvailableCredits.bind(creditService),
});
}
}
@@ -15,6 +15,7 @@ import {
getAIAgentCallPhoneNumberField,
getAIAgentCallPhoneNumberSource,
} from "@calcom/features/bookings/lib/getBookingFields";
import { CreditService } from "@calcom/features/ee/billing/credit-service";
import { getBookerBaseUrl } from "@calcom/features/ee/organizations/lib/getBookerUrlServer";
import { WorkflowRepository } from "@calcom/features/ee/workflows/repositories/WorkflowRepository";
import { removeBookingField, upsertBookingField } from "@calcom/features/eventtypes/lib/bookingFieldsManager";
@@ -738,6 +739,8 @@ export async function scheduleBookingReminders(
const bookerUrl = await getBookerBaseUrl(isOrg ? teamId : null);
const creditService = new CreditService();
//create reminders for all bookings for each workflow step
const promiseSteps = workflowSteps.map(async (step) => {
const promiseScheduleReminders = bookings.map(async (booking) => {
@@ -841,6 +844,7 @@ export async function scheduleBookingReminders(
userId: userId,
teamId: teamId,
verifiedAt: step?.verifiedAt ?? null,
creditCheckFn: creditService.hasAvailableCredits.bind(creditService),
});
} else if (step.action === WorkflowActions.WHATSAPP_NUMBER && step.sendTo) {
await scheduleWhatsappReminder({
@@ -858,6 +862,7 @@ export async function scheduleBookingReminders(
userId: userId,
teamId: teamId,
verifiedAt: step?.verifiedAt ?? null,
creditCheckFn: creditService.hasAvailableCredits.bind(creditService),
});
} else if (booking.smsReminderNumber) {
if (step.action === WorkflowActions.SMS_ATTENDEE) {
@@ -877,6 +882,7 @@ export async function scheduleBookingReminders(
userId: userId,
teamId: teamId,
verifiedAt: step?.verifiedAt ?? null,
creditCheckFn: creditService.hasAvailableCredits.bind(creditService),
});
} else if (step.action === WorkflowActions.WHATSAPP_ATTENDEE) {
await scheduleWhatsappReminder({
@@ -895,6 +901,7 @@ export async function scheduleBookingReminders(
userId: userId,
teamId: teamId,
verifiedAt: step?.verifiedAt ?? null,
creditCheckFn: creditService.hasAvailableCredits.bind(creditService),
});
}
} else if (step.action === WorkflowActions.CAL_AI_PHONE_CALL) {