fix: break circular dependency between reminderScheduler and credit-service (#25312)

* fix: break circular dependency between reminderScheduler and credit-service

- Created WorkflowReminderRepository to handle workflow reminder queries
- Refactored cancelScheduledMessagesAndScheduleEmails to accept userIdsWithoutCredits parameter
- Moved credit-checking logic from workflows to CreditService
- Changed dynamic import to static import in CreditService
- Updated tests to pass userIdsWithoutCredits parameter
- Removed unused imports (prisma, WorkflowMethods)

This creates a one-way dependency (billing -> workflows) and follows the repository pattern by removing direct Prisma usage from reminderScheduler.

Co-Authored-By: benny@cal.com <sldisek783@gmail.com>

* fix: resolve type check errors in circular dependency fix

- Use MembershipRepository.listAcceptedTeamMemberIds instead of findAllAcceptedPublishedTeamMemberships
- Re-add prisma import to reminderScheduler.ts for UserRepository
- Update WorkflowReminderRepository to use WorkflowActions enum instead of string

Co-Authored-By: benny@cal.com <sldisek783@gmail.com>

* refactor

* refactor

* wip

* wip

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Benny Joo
2025-11-24 10:00:57 +02:00
committed by GitHub
co-authored by Devin AI
parent 8e1a0999d2
commit 8b652613dd
4 changed files with 134 additions and 100 deletions
+39 -5
View File
@@ -605,11 +605,15 @@ export class CreditService {
"@calcom/features/ee/workflows/lib/reminders/reminderScheduler"
);
promises.push(
cancelScheduledMessagesAndScheduleEmails({ teamId: result.teamId, userId: result.userId }).catch(
(error) => {
log.error("Failed to cancel scheduled messages", error, { result });
}
)
cancelScheduledMessagesAndScheduleEmails({
teamId: result.teamId,
userIdsWithNoCredits: await this._getUserIdsWithoutCredits({
teamId: result.teamId ?? null,
userId: result.userId ?? null,
}),
}).catch((error) => {
log.error("Failed to cancel scheduled messages", error, { result });
})
);
}
@@ -826,4 +830,34 @@ export class CreditService {
};
});
}
private async _getUserIdsWithoutCredits({
teamId,
userId,
}: {
teamId: number | null;
userId: number | null;
}) {
let userIdsWithNoCredits: number[] = userId ? [userId] : [];
if (teamId) {
const teamMembers = await prisma.membership.findMany({
where: {
teamId,
accepted: true,
},
});
userIdsWithNoCredits = (
await Promise.all(
teamMembers.map(async (member) => {
const hasCredits = await this.hasAvailableCredits({ userId: member.userId });
return { userId: member.userId, hasCredits };
})
)
)
.filter(({ hasCredits }) => !hasCredits)
.map(({ userId }) => userId);
}
return userIdsWithNoCredits;
}
}
@@ -34,8 +34,6 @@ describe("reminderScheduler", () => {
describe("cancelScheduledMessagesAndScheduleEmails", () => {
it("should cancel SMS messages and schedule emails for team", async () => {
prismaMock.membership.findMany.mockResolvedValue([]);
const mockScheduledMessages = [
{
id: 1,
@@ -63,7 +61,7 @@ describe("reminderScheduler", () => {
prismaMock.workflowReminder.updateMany.mockResolvedValue({ count: 1 });
await cancelScheduledMessagesAndScheduleEmails({ teamId: 10 });
await cancelScheduledMessagesAndScheduleEmails({ teamId: 10, userIdsWithNoCredits: [1, 2, 3] });
expect(twilioProvider.cancelSMS).toHaveBeenCalledWith("sms-123");
@@ -76,12 +74,13 @@ describe("reminderScheduler", () => {
);
const callArgs = prismaMock.workflowReminder.findMany.mock.calls[0][0];
expect(callArgs.where.workflowStep.workflow.OR).toEqual([{ userId: { in: [] } }, { teamId: 10 }]);
expect(callArgs.where.workflowStep.workflow.OR).toEqual([
{ userId: { in: [1, 2, 3] } },
{ teamId: 10 },
]);
});
it("should cancel SMS messages and schedule emails for user", async () => {
prismaMock.membership.findMany.mockResolvedValue([]);
const mockScheduledMessages = [
{
id: 1,
@@ -109,7 +108,7 @@ describe("reminderScheduler", () => {
prismaMock.workflowReminder.updateMany.mockResolvedValue({ count: 1 });
await cancelScheduledMessagesAndScheduleEmails({ userId: 11 });
await cancelScheduledMessagesAndScheduleEmails({ userIdsWithNoCredits: [11] });
const callArgs = prismaMock.workflowReminder.findMany.mock.calls[0][0];
expect(callArgs.where.workflowStep.workflow.OR).toEqual([{ userId: { in: [11] } }]);
@@ -11,14 +11,14 @@ import * as twilio from "@calcom/features/ee/workflows/lib/reminders/providers/t
import type { Workflow, WorkflowStep } from "@calcom/features/ee/workflows/lib/types";
import { getSubmitterEmail } from "@calcom/features/tasker/tasks/triggerFormSubmittedNoEvent/formSubmissionValidation";
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import { checkSMSRateLimit } from "@calcom/lib/smsLockState";
import { SENDER_NAME } from "@calcom/lib/constants";
import { formatCalEventExtended } from "@calcom/lib/formatCalendarEvent";
import { withReporting } from "@calcom/lib/sentryWrapper";
import { getTranslation } from "@calcom/lib/server/i18n";
import { checkSMSRateLimit } from "@calcom/lib/smsLockState";
import prisma from "@calcom/prisma";
import { SchedulingType } from "@calcom/prisma/enums";
import { WorkflowActions, WorkflowMethods, WorkflowTriggerEvents } from "@calcom/prisma/enums";
import { WorkflowActions, WorkflowTriggerEvents } from "@calcom/prisma/enums";
import type { CalendarEvent } from "@calcom/types/Calendar";
import { scheduleAIPhoneCall } from "./aiPhoneCallManager";
@@ -288,86 +288,18 @@ const _sendCancelledReminders = async (args: SendCancelledRemindersArgs) => {
const _cancelScheduledMessagesAndScheduleEmails = async ({
teamId,
userId,
userIdsWithNoCredits,
}: {
teamId?: number | null;
userId?: number | null;
userIdsWithNoCredits: number[];
}) => {
const { CreditService } = await import("@calcom/features/ee/billing/credit-service");
const { WorkflowReminderRepository } = await import(
"@calcom/features/ee/workflows/repositories/WorkflowReminderRepository"
);
let userIdsWithNoCredits: number[] = userId ? [userId] : [];
if (teamId) {
const teamMembers = await prisma.membership.findMany({
where: {
teamId,
accepted: true,
},
});
const creditService = new CreditService();
userIdsWithNoCredits = (
await Promise.all(
teamMembers.map(async (member) => {
const hasCredits = await creditService.hasAvailableCredits({ userId: member.userId });
return { userId: member.userId, hasCredits };
})
)
)
.filter(({ hasCredits }) => !hasCredits)
.map(({ userId }) => userId);
}
const scheduledMessages = await prisma.workflowReminder.findMany({
where: {
workflowStep: {
workflow: {
OR: [
{
userId: {
in: userIdsWithNoCredits,
},
},
...(teamId ? [{ teamId }] : []),
],
},
},
scheduled: true,
OR: [{ cancelled: false }, { cancelled: null }],
referenceId: {
not: null,
},
method: {
in: [WorkflowMethods.SMS, WorkflowMethods.WHATSAPP],
},
},
select: {
referenceId: true,
workflowStep: {
select: {
action: true,
},
},
scheduledDate: true,
uuid: true,
id: true,
booking: {
select: {
attendees: {
select: {
email: true,
locale: true,
},
},
user: {
select: {
email: true,
},
},
},
},
},
const scheduledMessages = await WorkflowReminderRepository.findScheduledMessagesToCancel({
teamId,
userIdsWithNoCredits,
});
await Promise.allSettled(scheduledMessages.map((msg) => twilio.cancelSMS(msg.referenceId ?? "")));
@@ -393,16 +325,8 @@ const _cancelScheduledMessagesAndScheduleEmails = async ({
})
);
await prisma.workflowReminder.updateMany({
where: {
id: {
in: scheduledMessages.map((msg) => msg.id),
},
},
data: {
method: WorkflowMethods.EMAIL,
referenceId: null,
},
await WorkflowReminderRepository.updateRemindersToEmail({
reminderIds: scheduledMessages.map((msg) => msg.id),
});
};
// Export functions wrapped with withReporting
@@ -0,0 +1,77 @@
import { prisma } from "@calcom/prisma";
import { WorkflowMethods } from "@calcom/prisma/enums";
export class WorkflowReminderRepository {
static async findScheduledMessagesToCancel({
teamId,
userIdsWithNoCredits,
}: {
teamId?: number | null;
userIdsWithNoCredits: number[];
}) {
return await prisma.workflowReminder.findMany({
where: {
workflowStep: {
workflow: {
OR: [
{
userId: {
in: userIdsWithNoCredits,
},
},
...(teamId ? [{ teamId }] : []),
],
},
},
scheduled: true,
OR: [{ cancelled: false }, { cancelled: null }],
referenceId: {
not: null,
},
method: {
in: [WorkflowMethods.SMS, WorkflowMethods.WHATSAPP],
},
},
select: {
referenceId: true,
workflowStep: {
select: {
action: true,
},
},
scheduledDate: true,
uuid: true,
id: true,
booking: {
select: {
attendees: {
select: {
email: true,
locale: true,
},
},
user: {
select: {
email: true,
},
},
},
},
},
});
}
static async updateRemindersToEmail({ reminderIds }: { reminderIds: number[] }): Promise<void> {
await prisma.workflowReminder.updateMany({
where: {
id: {
in: reminderIds,
},
},
data: {
method: WorkflowMethods.EMAIL,
referenceId: null,
},
});
}
}