diff --git a/packages/features/ee/billing/credit-service.ts b/packages/features/ee/billing/credit-service.ts index 864114c4e355e5..a96e726c65046b 100644 --- a/packages/features/ee/billing/credit-service.ts +++ b/packages/features/ee/billing/credit-service.ts @@ -2,6 +2,7 @@ import type { TFunction } from "i18next"; import dayjs from "@calcom/dayjs"; import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository"; +import { cancelScheduledMessagesAndScheduleEmails } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler"; import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository"; import { IS_SMS_CREDITS_ENABLED } from "@calcom/lib/constants"; import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId"; @@ -601,15 +602,35 @@ export class CreditService { ]; if (!result.creditFor || result.creditFor === CreditUsageType.SMS) { - const { cancelScheduledMessagesAndScheduleEmails } = await import( - "@calcom/features/ee/workflows/lib/reminders/reminderScheduler" - ); + let userIdsWithoutCredits: number[] = []; + + if (result.teamId) { + const membershipRepo = new MembershipRepository(); + const teamMemberIds = await membershipRepo.listAcceptedTeamMemberIds({ teamId: result.teamId }); + + if (teamMemberIds && teamMemberIds.length > 0) { + const creditChecks = await Promise.all( + teamMemberIds.map(async (userId) => { + const hasCredits = await this.hasAvailableCredits({ userId }); + return { userId, hasCredits }; + }) + ); + + userIdsWithoutCredits = creditChecks + .filter(({ hasCredits }) => !hasCredits) + .map(({ userId }) => userId); + } + } else if (result.userId) { + userIdsWithoutCredits = [result.userId]; + } + promises.push( - cancelScheduledMessagesAndScheduleEmails({ teamId: result.teamId, userId: result.userId }).catch( - (error) => { - log.error("Failed to cancel scheduled messages", error, { result }); - } - ) + cancelScheduledMessagesAndScheduleEmails({ + teamId: result.teamId, + userIdsWithoutCredits, + }).catch((error) => { + log.error("Failed to cancel scheduled messages", error, { result }); + }) ); } diff --git a/packages/features/ee/workflows/lib/reminders/messageDispatcher.ts b/packages/features/ee/workflows/lib/reminders/messageDispatcher.ts index 086d8d92b717aa..de242d2553fb18 100644 --- a/packages/features/ee/workflows/lib/reminders/messageDispatcher.ts +++ b/packages/features/ee/workflows/lib/reminders/messageDispatcher.ts @@ -9,6 +9,11 @@ import * as twilio from "./providers/twilioProvider"; const log = logger.getSubLogger({ prefix: ["[reminderScheduler]"] }); +export type CreditCheckFn = (params: { + userId?: number | null; + teamId?: number | null; +}) => Promise; + export async function sendSmsOrFallbackEmail(props: { twilioData: { phoneNumber: string; @@ -27,13 +32,18 @@ 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 }); + + let hasCredits: boolean; + if (props.creditCheckFn) { + hasCredits = await props.creditCheckFn({ userId, teamId }); + } else { + const { CreditService } = await import("@calcom/features/ee/billing/credit-service"); + const creditService = new CreditService(); + hasCredits = await creditService.hasAvailableCredits({ userId, teamId }); + } if (!hasCredits) { const { fallbackData, twilioData } = props; @@ -75,12 +85,18 @@ 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 }); + + let hasCredits: boolean; + if (props.creditCheckFn) { + hasCredits = await props.creditCheckFn({ userId, teamId }); + } else { + const { CreditService } = await import("@calcom/features/ee/billing/credit-service"); + const creditService = new CreditService(); + hasCredits = await creditService.hasAvailableCredits({ userId, teamId }); + } if (!hasCredits) { const { fallbackData, twilioData } = props; diff --git a/packages/features/ee/workflows/lib/reminders/reminderScheduler.test.ts b/packages/features/ee/workflows/lib/reminders/reminderScheduler.test.ts index aa8549db6aa9b7..1a16069aa53706 100644 --- a/packages/features/ee/workflows/lib/reminders/reminderScheduler.test.ts +++ b/packages/features/ee/workflows/lib/reminders/reminderScheduler.test.ts @@ -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, userIdsWithoutCredits: [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({ userIdsWithoutCredits: [11] }); const callArgs = prismaMock.workflowReminder.findMany.mock.calls[0][0]; expect(callArgs.where.workflowStep.workflow.OR).toEqual([{ userId: { in: [11] } }]); diff --git a/packages/features/ee/workflows/lib/reminders/reminderScheduler.ts b/packages/features/ee/workflows/lib/reminders/reminderScheduler.ts index 8ddd80a8343693..3556640eaf23fc 100644 --- a/packages/features/ee/workflows/lib/reminders/reminderScheduler.ts +++ b/packages/features/ee/workflows/lib/reminders/reminderScheduler.ts @@ -18,11 +18,12 @@ import { withReporting } from "@calcom/lib/sentryWrapper"; import { getTranslation } from "@calcom/lib/server/i18n"; 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"; import { scheduleEmailReminder } from "./emailReminderManager"; +import type { CreditCheckFn } from "./messageDispatcher"; import type { BookingInfo } from "./smsReminderManager"; import { scheduleSMSReminder, type ScheduleTextReminderAction } from "./smsReminderManager"; import { scheduleWhatsappReminder } from "./whatsappReminderManager"; @@ -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 + ); } } }; @@ -288,86 +298,18 @@ const _sendCancelledReminders = async (args: SendCancelledRemindersArgs) => { const _cancelScheduledMessagesAndScheduleEmails = async ({ teamId, - userId, + userIdsWithoutCredits, }: { teamId?: number | null; - userId?: number | null; + userIdsWithoutCredits: number[]; }) => { - const { CreditService } = await import("@calcom/features/ee/billing/credit-service"); - - 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 { WorkflowReminderRepository } = await import( + "@calcom/features/ee/workflows/repositories/WorkflowReminderRepository" + ); - 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, + userIdsWithoutCredits, }); await Promise.allSettled(scheduledMessages.map((msg) => twilio.cancelSMS(msg.referenceId ?? ""))); @@ -393,16 +335,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 diff --git a/packages/features/ee/workflows/lib/reminders/smsReminderManager.ts b/packages/features/ee/workflows/lib/reminders/smsReminderManager.ts index 1b06e6e4de9151..c812b188d7d626 100644 --- a/packages/features/ee/workflows/lib/reminders/smsReminderManager.ts +++ b/packages/features/ee/workflows/lib/reminders/smsReminderManager.ts @@ -24,7 +24,11 @@ import { IMMEDIATE_WORKFLOW_TRIGGER_EVENTS } from "../constants"; import { WorkflowOptOutContactRepository } from "../repository/workflowOptOutContact"; import { WorkflowOptOutService } from "../service/workflowOptOutService"; import type { ScheduleReminderArgs } from "./emailReminderManager"; -import { scheduleSmsOrFallbackEmail, sendSmsOrFallbackEmail } from "./messageDispatcher"; +import { + scheduleSmsOrFallbackEmail, + sendSmsOrFallbackEmail, + type CreditCheckFn, +} from "./messageDispatcher"; import * as twilio from "./providers/twilioProvider"; import type { FormSubmissionData } from "./reminderScheduler"; import customTemplate, { transformRoutingFormResponsesToVariableFormat } from "./templates/customTemplate"; @@ -89,6 +93,7 @@ export type ScheduleTextReminderArgs = ScheduleReminderArgs & { isVerificationPending?: boolean; prisma?: PrismaClient; verifiedAt: Date | null; + creditCheckFn?: CreditCheckFn; }; export type ScheduleTextReminderArgsWithRequiredFields = Omit< @@ -173,6 +178,7 @@ const scheduleSMSReminderForEvt = async ( userId, teamId, seatReferenceUid, + creditCheckFn, } = args; const { startTime, endTime } = evt; @@ -243,6 +249,7 @@ const scheduleSMSReminderForEvt = async ( replyTo: evt.organizer.email, } : undefined, + creditCheckFn, }); } catch (error) { log.error(`Error sending SMS with error ${error}`); @@ -274,6 +281,7 @@ const scheduleSMSReminderForEvt = async ( workflowStepId, } : undefined, + creditCheckFn, }); if (scheduledNotification?.sid) { @@ -318,7 +326,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 +371,7 @@ const scheduleSMSReminderForForm = async ( replyTo: formData.user.email, } : undefined, + creditCheckFn, }); } catch (error) { log.error(`Error sending SMS with error ${error}`); diff --git a/packages/features/ee/workflows/lib/reminders/whatsappReminderManager.ts b/packages/features/ee/workflows/lib/reminders/whatsappReminderManager.ts index 24b6b550d83b58..9f0990522762be 100644 --- a/packages/features/ee/workflows/lib/reminders/whatsappReminderManager.ts +++ b/packages/features/ee/workflows/lib/reminders/whatsappReminderManager.ts @@ -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) { diff --git a/packages/features/ee/workflows/repositories/WorkflowReminderRepository.ts b/packages/features/ee/workflows/repositories/WorkflowReminderRepository.ts new file mode 100644 index 00000000000000..8e0bec3b2bc018 --- /dev/null +++ b/packages/features/ee/workflows/repositories/WorkflowReminderRepository.ts @@ -0,0 +1,106 @@ +import prisma, { type PrismaTransaction } from "@calcom/prisma"; +import { WorkflowActions, WorkflowMethods } from "@calcom/prisma/enums"; + +export type ScheduledMessageToCancel = { + referenceId: string | null; + workflowStep: { + action: WorkflowActions; + } | null; + scheduledDate: Date; + uuid: string | null; + id: number; + booking: { + attendees: { + email: string; + locale: string | null; + }[]; + user: { + email: string; + } | null; + } | null; +}; + +export class WorkflowReminderRepository { + static async findScheduledMessagesToCancel( + { + teamId, + userIdsWithoutCredits, + }: { + teamId?: number | null; + userIdsWithoutCredits: number[]; + }, + tx?: PrismaTransaction + ): Promise { + const prismaClient = tx ?? prisma; + + return await prismaClient.workflowReminder.findMany({ + where: { + workflowStep: { + workflow: { + OR: [ + { + userId: { + in: userIdsWithoutCredits, + }, + }, + ...(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[] }, + tx?: PrismaTransaction + ): Promise { + const prismaClient = tx ?? prisma; + + await prismaClient.workflowReminder.updateMany({ + where: { + id: { + in: reminderIds, + }, + }, + data: { + method: WorkflowMethods.EMAIL, + referenceId: null, + }, + }); + } +}