Files
calendar/packages/features/ee/workflows/lib/service/WorkflowService.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

243 lines
7.9 KiB
TypeScript

import dayjs from "@calcom/dayjs";
import { getAllWorkflows } from "@calcom/ee/workflows/lib/getAllWorkflows";
import type { ScheduleWorkflowRemindersArgs } from "@calcom/ee/workflows/lib/reminders/reminderScheduler";
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";
import { tasker } from "@calcom/features/tasker";
import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId";
import { prisma } from "@calcom/prisma";
import { WorkflowTriggerEvents, WorkflowType } from "@calcom/prisma/enums";
import type { FORM_SUBMITTED_WEBHOOK_RESPONSES } from "@calcom/routing-forms/lib/formSubmissionUtils";
// TODO (Sean): Move most of the logic migrated in 16861 to this service
export class WorkflowService {
static _beforeAfterEventTriggers: WorkflowTriggerEvents[] = [
WorkflowTriggerEvents.AFTER_EVENT,
WorkflowTriggerEvents.BEFORE_EVENT,
];
static async getAllWorkflowsFromRoutingForm(routingForm: {
id: string;
userId: number | null;
teamId: number | null;
}) {
const routingFormWorkflows = await WorkflowRepository.findWorkflowsActiveOnRoutingForm({
routingFormId: routingForm.id,
});
const teamId = routingForm.teamId;
const userId = routingForm.userId;
const orgId = await getOrgIdFromMemberOrTeamId({ memberId: userId, teamId });
const allWorkflows = await getAllWorkflows({
entityWorkflows: routingFormWorkflows,
userId,
teamId,
orgId,
workflowsLockedForUser: false,
type: WorkflowType.ROUTING_FORM,
});
return allWorkflows;
}
static async deleteWorkflowRemindersOfRemovedTeam(teamId: number) {
const teamRepository = new TeamRepository(prisma);
const team = await teamRepository.findById({ id: teamId });
if (team?.parentId) {
const activeWorkflowsOnTeam = await WorkflowRepository.findActiveWorkflowsOnTeam({
parentTeamId: team.parentId,
teamId: team.id,
});
for (const workflow of activeWorkflowsOnTeam) {
const workflowSteps = workflow.steps;
let remainingActiveOnIds = [];
if (workflow.isActiveOnAll) {
const teamRepository = new TeamRepository(prisma);
const allRemainingOrgTeams = await teamRepository.findOrgTeamsExcludingTeam({
parentId: team.parentId,
excludeTeamId: team.id,
});
remainingActiveOnIds = allRemainingOrgTeams.map((team) => team.id);
} else {
remainingActiveOnIds = workflow.activeOnTeams
.filter((activeOn) => activeOn.teamId !== team.id)
.map((activeOn) => activeOn.teamId);
}
const remindersToDelete = await WorkflowRepository.getRemindersFromRemovedTeams(
[team.id],
workflowSteps,
remainingActiveOnIds
);
await WorkflowRepository.deleteAllWorkflowReminders(remindersToDelete);
}
}
}
static async scheduleFormWorkflows({
workflows,
responses,
form,
responseId,
routedEventTypeId,
creditCheckFn,
}: {
responseId: number;
workflows: Workflow[];
responses: FORM_SUBMITTED_WEBHOOK_RESPONSES;
routedEventTypeId: number | null;
creditCheckFn: CreditCheckFn;
form: {
id: string;
userId: number;
teamId?: number | null;
fields?: { type: string; identifier?: string }[];
user: {
email: string;
timeFormat: number | null;
locale: string | null;
};
};
}) {
if (workflows.length <= 0) return;
const workflowsToTrigger: Workflow[] = [];
workflowsToTrigger.push(
...workflows.filter((workflow) => workflow.trigger === WorkflowTriggerEvents.FORM_SUBMITTED)
);
let smsReminderNumber: string | null = null;
if (form.fields) {
const phoneField = form.fields.find((field) => field.type === "phone");
if (phoneField && phoneField.identifier) {
const phoneResponse = responses[phoneField.identifier];
if (phoneResponse?.response && typeof phoneResponse.response === "string") {
smsReminderNumber = phoneResponse.response as string;
}
}
}
const hideBranding = await getHideBranding({
userId: form.userId,
teamId: form.teamId ?? undefined,
});
await scheduleWorkflowReminders({
smsReminderNumber,
formData: {
responses,
user: { email: form.user.email, timeFormat: form.user.timeFormat, locale: form.user.locale ?? "en" },
routedEventTypeId,
},
hideBranding,
workflows: workflowsToTrigger,
creditCheckFn,
});
const workflowsToSchedule: Workflow[] = [];
workflowsToSchedule.push(
...workflows.filter((workflow) => workflow.trigger === WorkflowTriggerEvents.FORM_SUBMITTED_NO_EVENT)
);
const promisesFormSubmittedNoEvent = workflowsToSchedule.map((workflow) => {
const timeUnit: timeUnitLowerCase = (workflow.timeUnit?.toLowerCase() as timeUnitLowerCase) ?? "minute";
const scheduledAt = dayjs()
.add(workflow.time ?? 15, timeUnit)
.toDate();
return tasker.create(
"triggerFormSubmittedNoEventWorkflow",
{
responseId,
responses,
smsReminderNumber,
hideBranding,
routedEventTypeId,
form: {
id: form.id,
userId: form.userId,
teamId: form.teamId ?? undefined,
user: {
email: form.user.email,
timeFormat: form.user.timeFormat,
locale: form.user.locale ?? "en",
},
},
workflow,
submittedAt: new Date(),
},
{ scheduledAt }
);
});
await Promise.all(promisesFormSubmittedNoEvent);
}
static async scheduleWorkflowsForNewBooking({
isNormalBookingOrFirstRecurringSlot,
isConfirmedByDefault,
isRescheduleEvent,
workflows,
...args
}: ScheduleWorkflowRemindersArgs & {
isConfirmedByDefault: boolean;
isRescheduleEvent: boolean;
isNormalBookingOrFirstRecurringSlot: boolean;
}) {
if (workflows.length <= 0) return;
const workflowsToTrigger: Workflow[] = [];
if (isRescheduleEvent) {
workflowsToTrigger.push(
...workflows.filter(
(workflow) =>
workflow.trigger === WorkflowTriggerEvents.RESCHEDULE_EVENT ||
this._beforeAfterEventTriggers.includes(workflow.trigger)
)
);
} else if (!isConfirmedByDefault) {
workflowsToTrigger.push(
...workflows.filter((workflow) => workflow.trigger === WorkflowTriggerEvents.BOOKING_REQUESTED)
);
} else if (isConfirmedByDefault) {
workflowsToTrigger.push(
...workflows.filter(
(workflow) =>
this._beforeAfterEventTriggers.includes(workflow.trigger) ||
(isNormalBookingOrFirstRecurringSlot && workflow.trigger === WorkflowTriggerEvents.NEW_EVENT)
)
);
}
if (workflowsToTrigger.length === 0) return;
await scheduleWorkflowReminders({
...args,
workflows: workflowsToTrigger,
});
}
static async scheduleWorkflowsFilteredByTriggerEvent({
workflows,
triggers,
...args
}: ScheduleWorkflowRemindersArgs & { triggers: WorkflowTriggerEvents[] }) {
if (workflows.length <= 0) return;
await scheduleWorkflowReminders({
...args,
workflows: workflows.filter((workflow) => triggers.includes(workflow.trigger)),
});
}
}