Files
cal.diy/packages/features/ee/round-robin/roundRobinManualReassignment.ts
T
Benny JooandDevin AI 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

623 lines
20 KiB
TypeScript

import { cloneDeep } from "lodash";
import { enrichUserWithDelegationCredentialsIncludeServiceAccountKey } from "@calcom/app-store/delegationCredential";
import { eventTypeAppMetadataOptionalSchema } from "@calcom/app-store/zod-utils";
import dayjs from "@calcom/dayjs";
import {
sendRoundRobinReassignedEmailsAndSMS,
sendRoundRobinScheduledEmailsAndSMS,
sendRoundRobinUpdatedEmailsAndSMS,
} from "@calcom/emails/email-manager";
import EventManager from "@calcom/features/bookings/lib/EventManager";
import { getAllCredentialsIncludeServiceAccountKey } from "@calcom/features/bookings/lib/getAllCredentialsForUsersOnEvent/getAllCredentials";
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,
} from "@calcom/features/ee/round-robin/assignmentReason/AssignmentReasonRecorder";
import { BookingLocationService } from "@calcom/features/ee/round-robin/lib/bookingLocationService";
import {
scheduleEmailReminder,
deleteScheduledEmailReminder,
} from "@calcom/features/ee/workflows/lib/reminders/emailReminderManager";
import { scheduleWorkflowReminders } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler";
import { getEventName } from "@calcom/features/eventtypes/lib/eventNaming";
import { getVideoCallUrlFromCalEvent } from "@calcom/lib/CalEventParser";
import { SENDER_NAME } from "@calcom/lib/constants";
import { IdempotencyKeyService } from "@calcom/lib/idempotencyKey/idempotencyKeyService";
import { isPrismaObjOrUndefined } from "@calcom/lib/isPrismaObj";
import logger from "@calcom/lib/logger";
import { getTranslation } from "@calcom/lib/server/i18n";
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
import { prisma } from "@calcom/prisma";
import { WorkflowActions, WorkflowMethods, WorkflowTriggerEvents } from "@calcom/prisma/enums";
import type { EventTypeMetadata, PlatformClientParams } from "@calcom/prisma/zod-utils";
import type { CalendarEvent } from "@calcom/types/Calendar";
import { handleRescheduleEventManager } from "./handleRescheduleEventManager";
import type { BookingSelectResult } from "./utils/bookingSelect";
import { bookingSelect } from "./utils/bookingSelect";
import { getDestinationCalendar } from "./utils/getDestinationCalendar";
import { getTeamMembers } from "./utils/getTeamMembers";
enum ErrorCode {
InvalidRoundRobinHost = "invalid_round_robin_host",
UserIsFixed = "user_is_round_robin_fixed",
}
export const roundRobinManualReassignment = async ({
bookingId,
newUserId,
orgId,
reassignReason,
reassignedById,
emailsEnabled = true,
platformClientParams,
}: {
bookingId: number;
newUserId: number;
orgId: number | null;
reassignReason?: string;
reassignedById: number;
emailsEnabled?: boolean;
platformClientParams?: PlatformClientParams;
}) => {
const roundRobinReassignLogger = logger.getSubLogger({
prefix: ["roundRobinManualReassign", `${bookingId}`],
});
roundRobinReassignLogger.info(`User ${reassignedById} initiating manual reassignment to user ${newUserId}`);
let booking = await prisma.booking.findUnique({
where: { id: bookingId },
select: bookingSelect,
});
if (!booking || !booking.user) {
roundRobinReassignLogger.error(`Booking ${bookingId} not found or has no associated user`);
throw new Error("Booking not found or has no associated user");
}
const eventTypeId = booking.eventTypeId;
if (!eventTypeId) {
roundRobinReassignLogger.error(`Booking ${bookingId} does not have an event type id`);
throw new Error("Event type not found");
}
const eventType = await getEventTypesFromDB(eventTypeId);
if (!eventType) {
roundRobinReassignLogger.error(`Event type ${eventTypeId} not found`);
throw new Error("Event type not found");
}
if (eventType.hostGroups && eventType.hostGroups.length > 1) {
roundRobinReassignLogger.error(
`Event type ${eventTypeId} has more than one round robin group, reassignment is not allowed`
);
throw new Error("Reassignment not allowed with more than one round robin group");
}
const eventTypeHosts = eventType.hosts.length
? eventType.hosts
: eventType.users.map((user) => ({
user,
isFixed: false,
priority: 2,
weight: 100,
schedule: null,
createdAt: new Date(0), // use earliest possible date as fallback
groupId: null,
}));
const fixedHost = eventTypeHosts.find((host) => host.isFixed);
const currentRRHost = booking.attendees.find((attendee) =>
eventTypeHosts.some((host) => !host.isFixed && host.user.email === attendee.email)
);
const newUserHost = eventTypeHosts.find((host) => host.user.id === newUserId);
if (!newUserHost) {
throw new Error(ErrorCode.InvalidRoundRobinHost);
}
if (newUserHost.isFixed) {
throw new Error(ErrorCode.UserIsFixed);
}
const originalOrganizer = booking.user;
const hasOrganizerChanged = !fixedHost && booking.userId !== newUserId;
const newUser = newUserHost.user;
const newUserT = await getTranslation(newUser.locale || "en", "common");
const originalOrganizerT = await getTranslation(originalOrganizer.locale || "en", "common");
const roundRobinHosts = eventType.hosts.filter((host) => !host.isFixed);
const attendeeEmailsSet = new Set(booking.attendees.map((attendee) => attendee.email));
// Find the current round robin host assigned
const previousRRHost = (() => {
for (const host of roundRobinHosts) {
if (host.user.id === booking.userId) {
return host.user;
}
if (attendeeEmailsSet.has(host.user.email)) {
return host.user;
}
}
})();
const previousRRHostT = await getTranslation(previousRRHost?.locale || "en", "common");
let bookingLocation = booking.location;
let conferenceCredentialId: number | null = null;
const organizer = hasOrganizerChanged ? newUser : booking.user ?? newUser;
const teamMembers = await getTeamMembers({
eventTypeHosts,
attendees: booking.attendees,
organizer,
previousHost: previousRRHost || null,
reassignedHost: newUser,
});
if (hasOrganizerChanged) {
const bookingResponses = booking.responses;
const responseSchema = getBookingResponsesPartialSchema({
bookingFields: eventType.bookingFields,
view: "reschedule",
});
const responseSafeParse = await responseSchema.safeParseAsync(bookingResponses);
const responses = responseSafeParse.success ? responseSafeParse.data : undefined;
// Determine the location for the new host
const isManagedEventType = !!eventType.parentId;
const isTeamEventType = !!eventType.teamId;
const locationResult = BookingLocationService.getLocationForHost({
hostMetadata: newUser.metadata,
eventTypeLocations: eventType.locations,
isManagedEventType,
isTeamEventType,
});
bookingLocation = locationResult.bookingLocation;
if (locationResult.requiresActualLink) {
conferenceCredentialId = locationResult.conferenceCredentialId;
}
const newBookingTitle = getEventName({
attendeeName: responses?.name || "Nameless",
eventType: eventType.title,
eventName: eventType.eventName,
// we send on behalf of team if >1 round robin attendee | collective
teamName: teamMembers.length > 1 ? eventType.team?.name : null,
host: newUser.name || "Nameless",
location: bookingLocation || "integrations:daily",
bookingFields: { ...responses },
eventDuration: dayjs(booking.endTime).diff(booking.startTime, "minutes"),
t: newUserT,
});
booking = await prisma.booking.update({
where: { id: bookingId },
data: {
userId: newUserId,
title: newBookingTitle,
userPrimaryEmail: newUser.email,
reassignReason,
reassignById: reassignedById,
idempotencyKey: IdempotencyKeyService.generate({
startTime: booking.startTime,
endTime: booking.endTime,
userId: newUser.id,
reassignedById,
}),
},
select: bookingSelect,
});
await AssignmentReasonRecorder.roundRobinReassignment({
bookingId,
reassignReason,
reassignById: reassignedById,
reassignmentType: RRReassignmentType.MANUAL,
});
} else if (currentRRHost) {
// Update the round-robin host attendee
await prisma.attendee.update({
where: { id: currentRRHost.id },
data: {
name: newUser.name || "",
email: newUser.email,
timeZone: newUser.timeZone,
locale: newUser.locale,
},
});
}
// When organizer hasn't changed, still extract conferenceCredentialId from event type locations
if (!hasOrganizerChanged && bookingLocation) {
const { conferenceCredentialId: extractedCredentialId } =
BookingLocationService.getLocationDetailsFromType({
locationType: bookingLocation,
eventTypeLocations: eventType.locations || [],
});
if (extractedCredentialId) {
conferenceCredentialId = extractedCredentialId;
}
}
const destinationCalendar = await getDestinationCalendar({
eventType,
booking,
newUserId,
hasOrganizerChanged,
});
const organizerT = await getTranslation(organizer?.locale || "en", "common");
const attendeePromises = [];
for (const attendee of booking.attendees) {
if (
attendee.email === newUser.email ||
attendee.email === previousRRHost?.email ||
teamMembers.some((member) => member.email === attendee.email)
) {
continue;
}
attendeePromises.push(
getTranslation(attendee.locale ?? "en", "common").then((tAttendee) => ({
email: attendee.email,
name: attendee.name,
timeZone: attendee.timeZone,
language: { translate: tAttendee, locale: attendee.locale ?? "en" },
phoneNumber: attendee.phoneNumber || undefined,
}))
);
}
const attendeeList = await Promise.all(attendeePromises);
const evt: CalendarEvent = {
type: eventType.slug,
title: booking.title,
description: eventType.description,
startTime: dayjs(booking.startTime).utc().format(),
endTime: dayjs(booking.endTime).utc().format(),
organizer: {
email: organizer.email,
name: organizer.name || "",
timeZone: organizer.timeZone,
language: { translate: organizerT, locale: organizer.locale || "en" },
},
attendees: attendeeList,
uid: booking.uid,
iCalUID: booking.iCalUID,
destinationCalendar,
team: {
members: teamMembers,
name: eventType.team?.name || "",
id: eventType.team?.id || 0,
},
hideOrganizerEmail: eventType.hideOrganizerEmail,
customInputs: isPrismaObjOrUndefined(booking.customInputs),
...getCalEventResponses({
bookingFields: eventType.bookingFields ?? null,
booking,
}),
customReplyToEmail: eventType?.customReplyToEmail,
location: bookingLocation,
...(platformClientParams ? platformClientParams : {}),
conferenceCredentialId: conferenceCredentialId ?? undefined,
};
if (hasOrganizerChanged) {
// location might changed and will be new created in eventManager.create (organizer default location)
evt.videoCallData = undefined;
// To prevent "The requested identifier already exists" error while updating event, we need to remove iCalUID
evt.iCalUID = undefined;
}
const credentials = await prisma.credential.findMany({
where: { userId: newUser.id },
include: { user: { select: { email: true } } },
});
const newUserWithCredentials = await enrichUserWithDelegationCredentialsIncludeServiceAccountKey({
user: { ...newUser, credentials },
});
const previousHostDestinationCalendar = hasOrganizerChanged
? await prisma.destinationCalendar.findFirst({
where: { userId: originalOrganizer.id },
})
: null;
const apps = eventTypeAppMetadataOptionalSchema.parse(eventType?.metadata?.apps);
// remove the event and meeting using the old host's credentials
if (hasOrganizerChanged && originalOrganizer.id !== newUser.id) {
const previousHostCredentials = await getAllCredentialsIncludeServiceAccountKey(
originalOrganizer,
eventType
);
const originalHostEventManager = new EventManager(
{ ...originalOrganizer, credentials: previousHostCredentials },
apps
);
const deletionEvent: CalendarEvent = {
...evt,
organizer: {
id: originalOrganizer.id,
name: originalOrganizer.name || "",
email: originalOrganizer.email,
username: originalOrganizer.username || undefined,
timeZone: originalOrganizer.timeZone,
language: { translate: originalOrganizerT, locale: originalOrganizer.locale ?? "en" },
timeFormat: getTimeFormatStringFromUserTimeFormat(originalOrganizer.timeFormat),
},
destinationCalendar: previousHostDestinationCalendar ? [previousHostDestinationCalendar] : [],
title: booking.title,
};
await originalHostEventManager.deleteEventsAndMeetings({
event: deletionEvent,
bookingReferences: booking.references,
});
}
const { evtWithAdditionalInfo } = await handleRescheduleEventManager({
evt,
rescheduleUid: booking.uid,
newBookingId: undefined,
changedOrganizer: hasOrganizerChanged,
previousHostDestinationCalendar: previousHostDestinationCalendar ? [previousHostDestinationCalendar] : [],
initParams: {
user: newUserWithCredentials,
eventType,
},
bookingId,
bookingLocation,
bookingICalUID: booking.iCalUID,
bookingMetadata: booking.metadata,
});
const { cancellationReason: _cancellationReason, ...evtWithoutCancellationReason } = evtWithAdditionalInfo;
// Send emails
if (emailsEnabled) {
await sendRoundRobinScheduledEmailsAndSMS({
calEvent: evtWithoutCancellationReason,
members: [
{
...newUser,
name: newUser.name || "",
username: newUser.username || "",
timeFormat: getTimeFormatStringFromUserTimeFormat(newUser.timeFormat),
language: { translate: newUserT, locale: newUser.locale || "en" },
},
],
reassigned: {
name: newUser.name,
email: newUser.email,
reason: reassignReason,
byUser: originalOrganizer.name || undefined,
},
});
}
// Send cancellation email to previous RR host
const cancelledEvt = cloneDeep(evtWithAdditionalInfo);
cancelledEvt.organizer = {
email: originalOrganizer.email,
name: originalOrganizer.name || "",
timeZone: originalOrganizer.timeZone,
language: { translate: originalOrganizerT, locale: originalOrganizer.locale || "en" },
};
if (previousRRHost && emailsEnabled) {
await sendRoundRobinReassignedEmailsAndSMS({
calEvent: cancelledEvt,
members: [
{
...previousRRHost,
name: previousRRHost.name || "",
username: previousRRHost.username || "",
timeFormat: getTimeFormatStringFromUserTimeFormat(previousRRHost.timeFormat),
language: { translate: previousRRHostT, locale: previousRRHost.locale || "en" },
},
],
reassignedTo: { name: newUser.name, email: newUser.email },
eventTypeMetadata: eventType?.metadata as EventTypeMetadata,
});
}
if (hasOrganizerChanged) {
if (emailsEnabled && dayjs(evt.startTime).isAfter(dayjs())) {
// send email with event updates to attendees
await sendRoundRobinUpdatedEmailsAndSMS({
calEvent: evtWithoutCancellationReason,
eventTypeMetadata: eventType?.metadata as EventTypeMetadata,
});
}
// Handle changing workflows with organizer
await handleWorkflowsUpdate({
booking,
newUser,
evt: evtWithAdditionalInfo,
eventType,
orgId,
});
}
return booking;
};
export async function handleWorkflowsUpdate({
booking,
newUser,
evt,
eventType,
orgId,
}: {
booking: BookingSelectResult;
newUser: {
id: number;
email: string;
locale?: string | null;
};
evt: CalendarEvent;
eventType: Awaited<ReturnType<typeof getEventTypesFromDB>>;
orgId: number | null;
}) {
const workflowReminders = await prisma.workflowReminder.findMany({
where: {
bookingUid: booking.uid,
method: WorkflowMethods.EMAIL,
scheduled: true,
OR: [{ cancelled: false }, { cancelled: null }],
workflowStep: {
action: WorkflowActions.EMAIL_HOST,
workflow: {
trigger: {
in: [
WorkflowTriggerEvents.BEFORE_EVENT,
WorkflowTriggerEvents.NEW_EVENT,
WorkflowTriggerEvents.AFTER_EVENT,
],
},
},
},
},
select: {
id: true,
referenceId: true,
workflowStep: {
select: {
id: true,
template: true,
workflow: {
select: {
userId: true,
teamId: true,
trigger: true,
time: true,
timeUnit: true,
},
},
emailSubject: true,
reminderBody: true,
sender: true,
includeCalendarEvent: true,
verifiedAt: true,
},
},
},
});
const workflowEventMetadata = { videoCallUrl: getVideoCallUrlFromCalEvent(evt) };
const bookerUrl = await getBookerBaseUrl(orgId);
for (const workflowReminder of workflowReminders) {
const workflowStep = workflowReminder.workflowStep;
if (workflowStep) {
const workflow = workflowStep.workflow;
await scheduleEmailReminder({
evt: {
...evt,
metadata: workflowEventMetadata,
eventType,
bookerUrl,
},
action: WorkflowActions.EMAIL_HOST,
triggerEvent: workflow.trigger,
timeSpan: {
time: workflow.time,
timeUnit: workflow.timeUnit,
},
sendTo: [newUser.email],
template: workflowStep.template,
emailSubject: workflowStep.emailSubject || undefined,
emailBody: workflowStep.reminderBody || undefined,
sender: workflowStep.sender || SENDER_NAME,
hideBranding: true,
includeCalendarEvent: workflowStep.includeCalendarEvent,
workflowStepId: workflowStep.id,
verifiedAt: workflowStep.verifiedAt,
});
}
await deleteScheduledEmailReminder(workflowReminder.id);
}
// Send new event workflows to new organizer
const newEventWorkflows = await prisma.workflow.findMany({
where: {
trigger: WorkflowTriggerEvents.NEW_EVENT,
OR: [
{
isActiveOnAll: true,
teamId: eventType?.teamId,
},
{
activeOn: {
some: {
eventTypeId: eventType.id,
},
},
},
...(eventType?.teamId
? [
{
activeOnTeams: {
some: {
teamId: eventType.teamId,
},
},
},
]
: []),
...(eventType?.team?.parentId
? [
{
isActiveOnAll: true,
teamId: eventType.team.parentId,
},
]
: []),
],
},
include: {
steps: {
where: {
action: WorkflowActions.EMAIL_HOST,
},
},
},
});
const creditService = new CreditService();
await scheduleWorkflowReminders({
workflows: newEventWorkflows,
smsReminderNumber: null,
calendarEvent: {
...evt,
metadata: workflowEventMetadata,
eventType: { slug: eventType.slug },
bookerUrl,
},
hideBranding: !!eventType?.owner?.hideBranding,
creditCheckFn: creditService.hasAvailableCredits.bind(creditService),
});
}
export default roundRobinManualReassignment;