From f98acb7301b23534ab9542ac4841c6b0808db1f7 Mon Sep 17 00:00:00 2001 From: Benny Joo Date: Tue, 16 Dec 2025 19:17:46 +0900 Subject: [PATCH] refactor: migrate workflows utilities from trpc to features layer (#25534) * refactor: migrate workflows utilities from trpc to features layer Move workflow utility functions from packages/trpc/server/routers/viewer/workflows/util.ts to packages/features/ee/workflows/lib/workflowUtils.ts to break circular dependencies. Functions migrated: - isAuthorized - getAllWorkflowsFromEventType - scheduleWorkflowNotifications - scheduleBookingReminders Changes: - Created new workflowUtils.ts in features layer with migrated functions - Added getBookingsForWorkflowReminders to WorkflowRepository (no prisma outside repositories) - Updated all imports in features layer to use new location - Updated trpc util.ts to re-export from features for backward compatibility - Fixed pre-existing lint warning (any -> unknown) in handleMarkNoShow.ts This is part of the effort to remove circular dependencies between packages/features and packages/trpc. Co-Authored-By: benny@cal.com * refactor: remove re-exports from trpc util.ts, update imports to use features layer Per user request, removed the backward compatibility re-exports from packages/trpc/server/routers/viewer/workflows/util.ts and updated all imports in the trpc package to import directly from the features layer. Files updated to import from @calcom/features/ee/workflows/lib/workflowUtils: - confirm.handler.ts (getAllWorkflowsFromEventType) - delete.handler.ts (isAuthorized) - update.handler.ts (isAuthorized, scheduleWorkflowNotifications) - getAllActiveWorkflows.handler.ts (getAllWorkflowsFromEventType) - get.handler.ts (isAuthorized) - util.test.ts (isAuthorized) - delete.handler.test.ts (isAuthorized) Co-Authored-By: benny@cal.com * fix: update test imports to use features layer for workflow utilities Updated test files to import scheduleBookingReminders and scheduleWorkflowNotifications from @calcom/features/ee/workflows/lib/workflowUtils instead of @calcom/trpc/server/routers/viewer/workflows/util. Files updated: - packages/features/ee/workflows/lib/test/workflows.test.ts - packages/features/tasker/tasks/scanWorkflowBody.test.ts Co-Authored-By: benny@cal.com * refactor: split workflowUtils.ts into individual files Split the monolithic workflowUtils.ts into separate files for each function: - isAuthorized.ts - Authorization check for workflow access - getAllWorkflowsFromEventType.ts - Get workflows for an event type - scheduleWorkflowNotifications.ts - Schedule workflow notifications - scheduleBookingReminders.ts - Schedule booking reminders The workflowUtils.ts now re-exports from these individual files for backward compatibility. Co-Authored-By: benny@cal.com * fix import * fix more * wip * wip * remove workflowUtils * wip * refactor deleteRemindersOfActiveOnIds * fix --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- apps/api/v1/test/lib/bookings/_post.test.ts | 2 +- .../bookings/lib/handleBookingRequested.ts | 2 +- .../bookings/lib/handleCancelBooking.ts | 2 +- .../bookings/lib/handleConfirmation.ts | 2 +- .../lib/service/RegularBookingService.ts | 2 +- .../tasker/BookingEmailAndSmsTaskService.ts | 2 +- .../lib/deleteRemindersOfActiveOnIds.ts | 54 ++ .../lib/getAllWorkflowsFromEventType.ts | 55 ++ .../features/ee/workflows/lib/isAuthorized.ts | 35 + .../workflows/lib/scheduleBookingReminders.ts | 221 ++++++ .../lib/scheduleWorkflowNotifications.ts | 193 +++++ .../ee/workflows/lib/test/workflows.test.ts | 8 +- .../ee/workflows/lib/verifyEmailSender.ts | 136 ++++ .../repositories/WorkflowRepository.ts | 51 ++ packages/features/handleMarkNoShow.ts | 2 +- .../tasker/tasks/scanWorkflowBody.test.ts | 4 +- .../features/tasker/tasks/scanWorkflowBody.ts | 2 +- .../WorkflowPermissionsRepository.ts | 2 +- .../viewer/bookings/confirm.handler.ts | 7 +- .../viewer/workflows/delete.handler.test.ts | 12 +- .../viewer/workflows/delete.handler.ts | 7 +- .../routers/viewer/workflows/get.handler.ts | 2 +- .../getAllActiveWorkflows.handler.ts | 3 +- .../viewer/workflows/update.handler.ts | 8 +- .../routers/viewer/workflows/util.test.ts | 3 +- .../server/routers/viewer/workflows/util.ts | 680 +----------------- 26 files changed, 783 insertions(+), 714 deletions(-) create mode 100644 packages/features/ee/workflows/lib/deleteRemindersOfActiveOnIds.ts create mode 100644 packages/features/ee/workflows/lib/getAllWorkflowsFromEventType.ts create mode 100644 packages/features/ee/workflows/lib/isAuthorized.ts create mode 100644 packages/features/ee/workflows/lib/scheduleBookingReminders.ts create mode 100644 packages/features/ee/workflows/lib/scheduleWorkflowNotifications.ts create mode 100644 packages/features/ee/workflows/lib/verifyEmailSender.ts diff --git a/apps/api/v1/test/lib/bookings/_post.test.ts b/apps/api/v1/test/lib/bookings/_post.test.ts index da25f3a596..3e397eefc2 100644 --- a/apps/api/v1/test/lib/bookings/_post.test.ts +++ b/apps/api/v1/test/lib/bookings/_post.test.ts @@ -161,7 +161,7 @@ vi.mock("@calcom/features/ee/workflows/lib/getAllWorkflows", () => ({ workflowSelect: {}, })); -vi.mock("@calcom/trpc/server/routers/viewer/workflows/util", () => ({ +vi.mock("@calcom/features/ee/workflows/lib/getAllWorkflowsFromEventType", () => ({ getAllWorkflowsFromEventType: vi.fn().mockResolvedValue([]), })); diff --git a/packages/features/bookings/lib/handleBookingRequested.ts b/packages/features/bookings/lib/handleBookingRequested.ts index 05a28d3bc4..671759b287 100644 --- a/packages/features/bookings/lib/handleBookingRequested.ts +++ b/packages/features/bookings/lib/handleBookingRequested.ts @@ -1,6 +1,7 @@ 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 { getAllWorkflowsFromEventType } from "@calcom/features/ee/workflows/lib/getAllWorkflowsFromEventType"; 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"; @@ -11,7 +12,6 @@ import { safeStringify } from "@calcom/lib/safeStringify"; import type { Prisma } from "@calcom/prisma/client"; import { WebhookTriggerEvents, WorkflowTriggerEvents } from "@calcom/prisma/enums"; import type { EventTypeMetadata } from "@calcom/prisma/zod-utils"; -import { getAllWorkflowsFromEventType } from "@calcom/trpc/server/routers/viewer/workflows/util"; import type { CalendarEvent } from "@calcom/types/Calendar"; const log = logger.getSubLogger({ prefix: ["[handleBookingRequested] book:user"] }); diff --git a/packages/features/bookings/lib/handleCancelBooking.ts b/packages/features/bookings/lib/handleCancelBooking.ts index 46bc3136af..be5f016a6b 100644 --- a/packages/features/bookings/lib/handleCancelBooking.ts +++ b/packages/features/bookings/lib/handleCancelBooking.ts @@ -11,6 +11,7 @@ import { processNoShowFeeOnCancellation } from "@calcom/features/bookings/lib/pa 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 { getAllWorkflowsFromEventType } from "@calcom/features/ee/workflows/lib/getAllWorkflowsFromEventType"; import { sendCancelledReminders } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler"; import { WorkflowRepository } from "@calcom/features/ee/workflows/repositories/WorkflowRepository"; import type { GetSubscriberOptions } from "@calcom/features/webhooks/lib/getWebhooks"; @@ -38,7 +39,6 @@ import type { WebhookTriggerEvents } from "@calcom/prisma/enums"; import { BookingStatus } from "@calcom/prisma/enums"; import { bookingMetadataSchema, bookingCancelInput } from "@calcom/prisma/zod-utils"; import type { EventTypeMetadata } from "@calcom/prisma/zod-utils"; -import { getAllWorkflowsFromEventType } from "@calcom/trpc/server/routers/viewer/workflows/util"; import type { CalendarEvent } from "@calcom/types/Calendar"; import type { diff --git a/packages/features/bookings/lib/handleConfirmation.ts b/packages/features/bookings/lib/handleConfirmation.ts index 19765e550d..e58bf66961 100644 --- a/packages/features/bookings/lib/handleConfirmation.ts +++ b/packages/features/bookings/lib/handleConfirmation.ts @@ -9,6 +9,7 @@ import { allowDisablingAttendeeConfirmationEmails, allowDisablingHostConfirmationEmails, } from "@calcom/features/ee/workflows/lib/allowDisablingStandardEmails"; +import { getAllWorkflowsFromEventType } from "@calcom/features/ee/workflows/lib/getAllWorkflowsFromEventType"; 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"; @@ -28,7 +29,6 @@ import type { SchedulingType } from "@calcom/prisma/enums"; import { BookingStatus, WebhookTriggerEvents, WorkflowTriggerEvents } from "@calcom/prisma/enums"; import type { PlatformClientParams } from "@calcom/prisma/zod-utils"; import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; -import { getAllWorkflowsFromEventType } from "@calcom/trpc/server/routers/viewer/workflows/util"; import type { AdditionalInformation, CalendarEvent } from "@calcom/types/Calendar"; import { getCalEventResponses } from "./getCalEventResponses"; diff --git a/packages/features/bookings/lib/service/RegularBookingService.ts b/packages/features/bookings/lib/service/RegularBookingService.ts index 5bc13cc2bb..cc903e9262 100644 --- a/packages/features/bookings/lib/service/RegularBookingService.ts +++ b/packages/features/bookings/lib/service/RegularBookingService.ts @@ -41,6 +41,7 @@ import { getSpamCheckService } from "@calcom/features/di/watchlist/containers/Sp 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 { getAllWorkflowsFromEventType } from "@calcom/features/ee/workflows/lib/getAllWorkflowsFromEventType"; import { WorkflowService } from "@calcom/features/ee/workflows/lib/service/WorkflowService"; import { WorkflowRepository } from "@calcom/features/ee/workflows/repositories/WorkflowRepository"; import { getUsernameList } from "@calcom/features/eventtypes/lib/defaultEvents"; @@ -87,7 +88,6 @@ import { CreationSource, } from "@calcom/prisma/enums"; import { userMetadata as userMetadataSchema } from "@calcom/prisma/zod-utils"; -import { getAllWorkflowsFromEventType } from "@calcom/trpc/server/routers/viewer/workflows/util"; import type { AdditionalInformation, AppsStatus, diff --git a/packages/features/bookings/lib/tasker/BookingEmailAndSmsTaskService.ts b/packages/features/bookings/lib/tasker/BookingEmailAndSmsTaskService.ts index 8948bdf72a..92636942c1 100644 --- a/packages/features/bookings/lib/tasker/BookingEmailAndSmsTaskService.ts +++ b/packages/features/bookings/lib/tasker/BookingEmailAndSmsTaskService.ts @@ -4,10 +4,10 @@ import { CalendarEventBuilder } from "@calcom/features/CalendarEventBuilder"; import type { BookingEmailSmsHandler } from "@calcom/features/bookings/lib/BookingEmailSmsHandler"; import { getOriginalRescheduledBooking } from "@calcom/features/bookings/lib/handleNewBooking/originalRescheduledBookingUtils"; import type { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository"; +import { getAllWorkflowsFromEventType } from "@calcom/features/ee/workflows/lib/getAllWorkflowsFromEventType"; import type { EventNameObjectType } from "@calcom/features/eventtypes/lib/eventNaming"; import type { ITaskerDependencies } from "@calcom/lib/tasker/types"; import { SchedulingType } from "@calcom/prisma/enums"; -import { getAllWorkflowsFromEventType } from "@calcom/trpc/server/routers/viewer/workflows/util"; import type { CalendarEvent } from "@calcom/types/Calendar"; import type { JsonObject } from "@calcom/types/Json"; diff --git a/packages/features/ee/workflows/lib/deleteRemindersOfActiveOnIds.ts b/packages/features/ee/workflows/lib/deleteRemindersOfActiveOnIds.ts new file mode 100644 index 0000000000..50d725184e --- /dev/null +++ b/packages/features/ee/workflows/lib/deleteRemindersOfActiveOnIds.ts @@ -0,0 +1,54 @@ +import { WorkflowRepository } from "@calcom/features/ee/workflows/repositories/WorkflowRepository"; +import { prisma } from "@calcom/prisma"; +import type { Prisma, WorkflowStep } from "@calcom/prisma/client"; + +async function getRemindersFromRemovedEventTypes(removedEventTypes: number[], workflowSteps: WorkflowStep[]) { + const remindersToDeletePromise: Prisma.PrismaPromise< + { + id: number; + referenceId: string | null; + method: string; + }[] + >[] = []; + removedEventTypes.forEach((eventTypeId) => { + const remindersToDelete = prisma.workflowReminder.findMany({ + where: { + booking: { + eventTypeId, + }, + workflowStepId: { + in: workflowSteps.map((step) => { + return step.id; + }), + }, + }, + select: { + id: true, + referenceId: true, + method: true, + }, + }); + + remindersToDeletePromise.push(remindersToDelete); + }); + + const remindersToDelete = (await Promise.all(remindersToDeletePromise)).flat(); + return remindersToDelete; +} + +export async function deleteRemindersOfActiveOnIds({ + removedActiveOnIds, + workflowSteps, + isOrg, + activeOnIds, +}: { + removedActiveOnIds: number[]; + workflowSteps: WorkflowStep[]; + isOrg: boolean; + activeOnIds?: number[]; +}) { + const remindersToDelete = !isOrg + ? await getRemindersFromRemovedEventTypes(removedActiveOnIds, workflowSteps) + : await WorkflowRepository.getRemindersFromRemovedTeams(removedActiveOnIds, workflowSteps, activeOnIds); + await WorkflowRepository.deleteAllWorkflowReminders(remindersToDelete); +} diff --git a/packages/features/ee/workflows/lib/getAllWorkflowsFromEventType.ts b/packages/features/ee/workflows/lib/getAllWorkflowsFromEventType.ts new file mode 100644 index 0000000000..ee3844d1a0 --- /dev/null +++ b/packages/features/ee/workflows/lib/getAllWorkflowsFromEventType.ts @@ -0,0 +1,55 @@ +import { getAllWorkflows } from "@calcom/ee/workflows/lib/getAllWorkflows"; +import type { Workflow as WorkflowType } from "@calcom/ee/workflows/lib/types"; +import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId"; +import { getTeamIdFromEventType } from "@calcom/lib/getTeamIdFromEventType"; +import type { Prisma } from "@calcom/prisma/client"; +import { WorkflowType as PrismaWorkflowType } from "@calcom/prisma/enums"; +import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; + +export async function getAllWorkflowsFromEventType( + eventType: { + workflows?: { + workflow: WorkflowType; + }[]; + teamId?: number | null; + parentId?: number | null; + parent?: { + id?: number | null; + teamId: number | null; + } | null; + metadata?: Prisma.JsonValue; + } | null, + userId?: number | null +) { + if (!eventType) return []; + + const eventTypeWorkflows = eventType?.workflows?.map((workflowRel) => workflowRel.workflow) ?? []; + + const teamId = await getTeamIdFromEventType({ + eventType: { + team: { id: eventType?.teamId ?? null }, + parentId: eventType?.parentId || eventType?.parent?.id || null, + }, + }); + + const orgId = await getOrgIdFromMemberOrTeamId({ memberId: userId, teamId }); + + const isManagedEventType = !!eventType?.parent; + + const eventTypeMetadata = EventTypeMetaDataSchema.parse(eventType?.metadata || {}); + + const workflowsLockedForUser = isManagedEventType + ? !eventTypeMetadata?.managedEventConfig?.unlockedFields?.workflows + : false; + + const allWorkflows = await getAllWorkflows({ + entityWorkflows: eventTypeWorkflows, + userId, + teamId, + orgId, + workflowsLockedForUser, + type: PrismaWorkflowType.EVENT_TYPE, + }); + + return allWorkflows; +} diff --git a/packages/features/ee/workflows/lib/isAuthorized.ts b/packages/features/ee/workflows/lib/isAuthorized.ts new file mode 100644 index 0000000000..c4b81a2f95 --- /dev/null +++ b/packages/features/ee/workflows/lib/isAuthorized.ts @@ -0,0 +1,35 @@ +import type { PermissionString } from "@calcom/features/pbac/domain/types/permission-registry"; +import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service"; +import type { Workflow } from "@calcom/prisma/client"; +import { MembershipRole } from "@calcom/prisma/enums"; + +export async function isAuthorized( + workflow: Pick | null, + currentUserId: number, + permission: PermissionString = "workflow.read" +) { + if (!workflow) { + return false; + } + + // For personal workflows (no teamId), check if user owns the workflow + if (!workflow.teamId) { + return workflow.userId === currentUserId; + } + + // For team workflows, use PBAC + const permissionService = new PermissionCheckService(); + + // Determine fallback roles based on permission type + const fallbackRoles = + permission === "workflow.read" + ? [MembershipRole.ADMIN, MembershipRole.OWNER, MembershipRole.MEMBER] + : [MembershipRole.ADMIN, MembershipRole.OWNER]; + + return await permissionService.checkPermission({ + userId: currentUserId, + teamId: workflow.teamId, + permission, + fallbackRoles, + }); +} diff --git a/packages/features/ee/workflows/lib/scheduleBookingReminders.ts b/packages/features/ee/workflows/lib/scheduleBookingReminders.ts new file mode 100644 index 0000000000..ff80b7fc05 --- /dev/null +++ b/packages/features/ee/workflows/lib/scheduleBookingReminders.ts @@ -0,0 +1,221 @@ +import { scheduleAIPhoneCall } from "@calcom/ee/workflows/lib/reminders/aiPhoneCallManager"; +import { scheduleEmailReminder } from "@calcom/ee/workflows/lib/reminders/emailReminderManager"; +import { scheduleSMSReminder } from "@calcom/ee/workflows/lib/reminders/smsReminderManager"; +import { scheduleWhatsappReminder } from "@calcom/ee/workflows/lib/reminders/whatsappReminderManager"; +import { CreditService } from "@calcom/features/ee/billing/credit-service"; +import { getBookerBaseUrl } from "@calcom/features/ee/organizations/lib/getBookerUrlServer"; +import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat"; +import type { WorkflowStep } from "@calcom/prisma/client"; +import type { TimeUnit } from "@calcom/prisma/enums"; +import { SchedulingType, WorkflowActions, WorkflowTriggerEvents } from "@calcom/prisma/enums"; +import type { CalEventResponses } from "@calcom/types/Calendar"; + +import type { getBookings } from "./scheduleWorkflowNotifications"; +import { verifyEmailSender } from "./verifyEmailSender"; + +type UnwrapPromise = T extends Promise ? U : T; +type Bookings = UnwrapPromise>; + +// some parts of scheduleWorkflowReminders (reminderSchedule.ts) is quite similar to this code +// we should consider refactoring this to reuse similar code snippets +export async function scheduleBookingReminders( + bookings: Bookings, + workflowSteps: Partial[], + time: number | null, + timeUnit: TimeUnit | null, + trigger: WorkflowTriggerEvents, + userId: number, + teamId: number | null, + isOrg: boolean +) { + if (!bookings.length) return; + if (trigger !== WorkflowTriggerEvents.BEFORE_EVENT && trigger !== WorkflowTriggerEvents.AFTER_EVENT) return; + + 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) => { + const defaultLocale = "en"; + const bookingInfo = { + uid: booking.uid, + bookerUrl, + type: booking.eventType?.slug || "event", + attendees: booking.attendees.map((attendee) => { + return { + name: attendee.name, + email: attendee.email, + timeZone: attendee.timeZone, + language: { locale: attendee.locale || defaultLocale }, + }; + }), + organizer: booking.user + ? { + language: { locale: booking.user.locale || defaultLocale }, + name: booking.user.name || "", + email: booking?.userPrimaryEmail ?? booking.user.email, + timeZone: booking.user.timeZone, + timeFormat: getTimeFormatStringFromUserTimeFormat(booking.user.timeFormat), + } + : { name: "", email: "", timeZone: "", language: { locale: "" } }, + startTime: booking.startTime?.toISOString(), + endTime: booking.endTime?.toISOString(), + title: booking.title, + language: { locale: booking?.user?.locale || defaultLocale }, + hideOrganizerEmail: booking.eventType?.hideOrganizerEmail, + eventType: { + slug: booking.eventType?.slug || "", + schedulingType: booking.eventType?.schedulingType, + hosts: booking.eventType?.hosts, + }, + metadata: booking.metadata, + customReplyToEmail: booking.eventType?.customReplyToEmail, + responses: booking.responses as CalEventResponses | null, + }; + if ( + step.action === WorkflowActions.EMAIL_HOST || + step.action === WorkflowActions.EMAIL_ATTENDEE || + step.action === WorkflowActions.EMAIL_ADDRESS + ) { + let sendTo: string[] = []; + + switch (step.action) { + case WorkflowActions.EMAIL_HOST: { + sendTo = [bookingInfo.organizer?.email]; + const schedulingType = bookingInfo.eventType.schedulingType; + const hosts = bookingInfo.eventType.hosts + ?.filter((host) => bookingInfo.attendees.some((attendee) => attendee.email === host.user.email)) + .map(({ user }) => user.destinationCalendar?.primaryEmail ?? user.email); + if ( + hosts && + (schedulingType === SchedulingType.ROUND_ROBIN || schedulingType === SchedulingType.COLLECTIVE) + ) { + sendTo = sendTo.concat(hosts); + } + break; + } + case WorkflowActions.EMAIL_ATTENDEE: + sendTo = bookingInfo.attendees.map((attendee) => attendee.email); + break; + case WorkflowActions.EMAIL_ADDRESS: + await verifyEmailSender(step.sendTo || "", userId, teamId); + sendTo = [step.sendTo || ""]; + break; + } + + await scheduleEmailReminder({ + evt: bookingInfo, + triggerEvent: trigger, + action: step.action, + timeSpan: { + time, + timeUnit, + }, + sendTo, + emailSubject: step.emailSubject || "", + emailBody: step.reminderBody || "", + template: step.template, + sender: step.sender, + workflowStepId: step.id, + verifiedAt: step?.verifiedAt ?? null, + }); + } else if (step.action === WorkflowActions.SMS_NUMBER && step.sendTo) { + await scheduleSMSReminder({ + evt: bookingInfo, + reminderPhone: step.sendTo, + triggerEvent: trigger, + action: step.action, + timeSpan: { + time, + timeUnit, + }, + message: step.reminderBody || "", + workflowStepId: step.id, + template: step.template, + sender: step.sender, + userId: userId, + teamId: teamId, + verifiedAt: step?.verifiedAt ?? null, + creditCheckFn: creditService.hasAvailableCredits.bind(creditService), + }); + } else if (step.action === WorkflowActions.WHATSAPP_NUMBER && step.sendTo) { + await scheduleWhatsappReminder({ + evt: bookingInfo, + reminderPhone: step.sendTo, + triggerEvent: trigger, + action: step.action, + timeSpan: { + time, + timeUnit, + }, + message: step.reminderBody || "", + workflowStepId: step.id || 0, + template: step.template, + userId: userId, + teamId: teamId, + verifiedAt: step?.verifiedAt ?? null, + creditCheckFn: creditService.hasAvailableCredits.bind(creditService), + }); + } else if (booking.smsReminderNumber) { + if (step.action === WorkflowActions.SMS_ATTENDEE) { + await scheduleSMSReminder({ + evt: bookingInfo, + reminderPhone: booking.smsReminderNumber, + triggerEvent: trigger, + action: step.action, + timeSpan: { + time, + timeUnit, + }, + message: step.reminderBody || "", + workflowStepId: step.id, + template: step.template, + sender: step.sender, + userId: userId, + teamId: teamId, + verifiedAt: step?.verifiedAt ?? null, + creditCheckFn: creditService.hasAvailableCredits.bind(creditService), + }); + } else if (step.action === WorkflowActions.WHATSAPP_ATTENDEE) { + await scheduleWhatsappReminder({ + evt: bookingInfo, + reminderPhone: booking.smsReminderNumber, + triggerEvent: trigger, + action: step.action, + timeSpan: { + time, + timeUnit, + }, + message: step.reminderBody || "", + workflowStepId: step.id, + template: step.template, + sender: step.sender, + userId: userId, + teamId: teamId, + verifiedAt: step?.verifiedAt ?? null, + creditCheckFn: creditService.hasAvailableCredits.bind(creditService), + }); + } + } else if (step.action === WorkflowActions.CAL_AI_PHONE_CALL) { + await scheduleAIPhoneCall({ + evt: bookingInfo, + triggerEvent: trigger, + timeSpan: { + time, + timeUnit, + }, + workflowStepId: step.id, + userId, + teamId, + verifiedAt: step?.verifiedAt ?? null, + submittedPhoneNumber: booking.smsReminderNumber, + routedEventTypeId: null, + }); + } + }); + await Promise.all(promiseScheduleReminders); + }); + return Promise.all(promiseSteps); +} diff --git a/packages/features/ee/workflows/lib/scheduleWorkflowNotifications.ts b/packages/features/ee/workflows/lib/scheduleWorkflowNotifications.ts new file mode 100644 index 0000000000..3429dc58cc --- /dev/null +++ b/packages/features/ee/workflows/lib/scheduleWorkflowNotifications.ts @@ -0,0 +1,193 @@ +import { prisma } from "@calcom/prisma"; +import type { WorkflowStep } from "@calcom/prisma/client"; +import { BookingStatus, WorkflowTriggerEvents } from "@calcom/prisma/enums"; +import type { TimeUnit } from "@calcom/prisma/enums"; + +import { scheduleBookingReminders } from "./scheduleBookingReminders"; + +export const bookingSelect = { + userPrimaryEmail: true, + startTime: true, + endTime: true, + title: true, + uid: true, + metadata: true, + smsReminderNumber: true, + responses: true, + attendees: { + select: { + name: true, + email: true, + timeZone: true, + locale: true, + }, + }, + eventType: { + select: { + slug: true, + id: true, + schedulingType: true, + hideOrganizerEmail: true, + customReplyToEmail: true, + hosts: { + select: { + user: { + select: { + email: true, + destinationCalendar: { + select: { + primaryEmail: true, + }, + }, + }, + }, + }, + }, + }, + }, + user: { + select: { + name: true, + timeZone: true, + timeFormat: true, + locale: true, + email: true, + }, + }, +}; + +export async function scheduleWorkflowNotifications({ + activeOn, + isOrg, + workflowSteps, + time, + timeUnit, + trigger, + userId, + teamId, + alreadyScheduledActiveOnIds, +}: { + activeOn: number[]; + isOrg: boolean; + workflowSteps: Partial[]; + time: number | null; + timeUnit: TimeUnit | null; + trigger: WorkflowTriggerEvents; + userId: number; + teamId: number | null; + alreadyScheduledActiveOnIds?: number[]; +}) { + if (trigger !== WorkflowTriggerEvents.BEFORE_EVENT && trigger !== WorkflowTriggerEvents.AFTER_EVENT) return; + + const bookingsToScheduleNotifications = await getBookings(activeOn, isOrg, alreadyScheduledActiveOnIds); + + await scheduleBookingReminders( + bookingsToScheduleNotifications, + workflowSteps, + time, + timeUnit, + trigger, + userId, + teamId, + isOrg + ); +} + +export async function getBookings( + activeOn: number[], + isOrg: boolean, + alreadyScheduledActiveOnIds: number[] = [] +) { + if (activeOn.length === 0) return []; + + if (isOrg) { + const bookingsForReminders = await prisma.booking.findMany({ + where: { + OR: [ + { + // bookings from team event types + children managed event types + eventType: { + OR: [ + { + teamId: { + in: activeOn, + }, + }, + { + teamId: null, + parent: { + teamId: { + in: activeOn, + }, + }, + }, + ], + }, + }, + { + // user bookings + user: { + teams: { + some: { + teamId: { + in: activeOn, + }, + accepted: true, + }, + }, + }, + eventType: { + teamId: null, + parentId: null, // children managed event types are handled above with team event types + }, + // if user is already part of an already scheduled activeOn connecting reminders are already scheduled + NOT: { + user: { + teams: { + some: { + teamId: { + in: alreadyScheduledActiveOnIds, + }, + }, + }, + }, + }, + }, + ], + status: BookingStatus.ACCEPTED, + startTime: { + gte: new Date(), + }, + }, + select: bookingSelect, + orderBy: { + startTime: "asc", + }, + }); + return bookingsForReminders; + } else { + const bookingsForReminders = await prisma.booking.findMany({ + where: { + OR: [ + { eventTypeId: { in: activeOn } }, + { + eventType: { + parentId: { + in: activeOn, // child event type can not disable workflows, so this should work + }, + }, + }, + ], + status: BookingStatus.ACCEPTED, + startTime: { + gte: new Date(), + }, + }, + select: bookingSelect, + orderBy: { + startTime: "asc", + }, + }); + return bookingsForReminders; + } +} diff --git a/packages/features/ee/workflows/lib/test/workflows.test.ts b/packages/features/ee/workflows/lib/test/workflows.test.ts index ac7a089146..593aea91a8 100644 --- a/packages/features/ee/workflows/lib/test/workflows.test.ts +++ b/packages/features/ee/workflows/lib/test/workflows.test.ts @@ -17,6 +17,7 @@ import { v4 as uuidv4 } from "uuid"; import { describe, expect, beforeAll, vi, beforeEach } from "vitest"; import dayjs from "@calcom/dayjs"; +import { scheduleBookingReminders } from "@calcom/features/ee/workflows/lib/scheduleBookingReminders"; import { FeaturesRepository } from "@calcom/features/flags/features.repository"; import tasker from "@calcom/features/tasker"; import * as rateLimitModule from "@calcom/lib/checkRateLimitAndThrowError"; @@ -28,17 +29,14 @@ import { WorkflowTriggerEvents, WorkflowActions, } from "@calcom/prisma/enums"; -import { - deleteRemindersOfActiveOnIds, - scheduleBookingReminders, - bookingSelect, -} from "@calcom/trpc/server/routers/viewer/workflows/util"; import { test } from "@calcom/web/test/fixtures/fixtures"; import { deleteWorkfowRemindersOfRemovedMember } from "../../../teams/lib/deleteWorkflowRemindersOfRemovedMember"; +import { deleteRemindersOfActiveOnIds } from "../deleteRemindersOfActiveOnIds"; import { scheduleAIPhoneCall } from "../reminders/aiPhoneCallManager"; import { scheduleEmailReminder } from "../reminders/emailReminderManager"; import * as emailProvider from "../reminders/providers/emailProvider"; +import { bookingSelect } from "../scheduleWorkflowNotifications"; const workflowSelect = { id: true, diff --git a/packages/features/ee/workflows/lib/verifyEmailSender.ts b/packages/features/ee/workflows/lib/verifyEmailSender.ts new file mode 100644 index 0000000000..c13c04354a --- /dev/null +++ b/packages/features/ee/workflows/lib/verifyEmailSender.ts @@ -0,0 +1,136 @@ +import { ErrorCode } from "@calcom/lib/errorCodes"; +import { ErrorWithCode } from "@calcom/lib/errors"; +import { prisma } from "@calcom/prisma"; + +export const verifyEmailSender = async (email: string, userId: number, teamId: number | null) => { + const verifiedEmail = await prisma.verifiedEmail.findFirst({ + where: { + email, + OR: [{ userId }, { teamId }], + }, + }); + + if (verifiedEmail) { + if (teamId) { + if (!verifiedEmail.teamId) { + await prisma.verifiedEmail.update({ + where: { + id: verifiedEmail.id, + }, + data: { + teamId, + }, + }); + } else if (verifiedEmail.teamId !== teamId) { + await prisma.verifiedEmail.create({ + data: { + email, + userId, + teamId, + }, + }); + } + } + return; + } + + const userEmail = await prisma.user.findFirst({ + where: { + id: userId, + email, + }, + }); + + if (userEmail) { + await prisma.verifiedEmail.create({ + data: { + email, + userId, + teamId, + }, + }); + return; + } + + // Check if it's a verified secondary email of the user + const secondaryEmail = await prisma.secondaryEmail.findFirst({ + where: { + userId, + email, + emailVerified: { + not: null, + }, + }, + }); + + if (secondaryEmail) { + await prisma.verifiedEmail.create({ + data: { + email, + userId, + teamId, + }, + }); + return; + } + + if (teamId) { + const team = await prisma.team.findUnique({ + where: { + id: teamId, + }, + select: { + members: { + include: { + user: { + select: { + id: true, + email: true, + secondaryEmails: { + select: { + email: true, + emailVerified: true, + }, + }, + }, + }, + }, + }, + }, + }); + + if (!team) { + throw new ErrorWithCode(ErrorCode.NotFound, "Team not found"); + } + + const isTeamMember = team.members.some((member) => member.userId === userId); + + if (!isTeamMember) { + throw new ErrorWithCode(ErrorCode.Forbidden, "You are not a member of this team"); + } + + let foundTeamMember = team.members.find((member) => member.user.email === email); + + // Only check secondary emails if no match was found with primary email + if (!foundTeamMember) { + foundTeamMember = team.members.find((member) => + member.user.secondaryEmails.some( + (secondary) => secondary.email === email && !!secondary.emailVerified + ) + ); + } + + if (foundTeamMember) { + await prisma.verifiedEmail.create({ + data: { + email, + userId, + teamId, + }, + }); + return; + } + } + + throw new ErrorWithCode(ErrorCode.NotFound, "Email not verified"); +}; diff --git a/packages/features/ee/workflows/repositories/WorkflowRepository.ts b/packages/features/ee/workflows/repositories/WorkflowRepository.ts index 9c19912000..c2c94d6b6a 100644 --- a/packages/features/ee/workflows/repositories/WorkflowRepository.ts +++ b/packages/features/ee/workflows/repositories/WorkflowRepository.ts @@ -857,4 +857,55 @@ export class WorkflowRepository { }, }); } + + static bookingSelectForReminders = { + userPrimaryEmail: true, + startTime: true, + endTime: true, + title: true, + uid: true, + metadata: true, + smsReminderNumber: true, + responses: true, + attendees: { + select: { + name: true, + email: true, + timeZone: true, + locale: true, + }, + }, + eventType: { + select: { + slug: true, + id: true, + schedulingType: true, + hideOrganizerEmail: true, + customReplyToEmail: true, + hosts: { + select: { + user: { + select: { + email: true, + destinationCalendar: { + select: { + primaryEmail: true, + }, + }, + }, + }, + }, + }, + }, + }, + user: { + select: { + name: true, + timeZone: true, + timeFormat: true, + locale: true, + email: true, + }, + }, + }; } diff --git a/packages/features/handleMarkNoShow.ts b/packages/features/handleMarkNoShow.ts index 54b149eb3a..e40380745d 100644 --- a/packages/features/handleMarkNoShow.ts +++ b/packages/features/handleMarkNoShow.ts @@ -4,6 +4,7 @@ import { BookingRepository } from "@calcom/features/bookings/repositories/Bookin 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 { getAllWorkflowsFromEventType } from "@calcom/features/ee/workflows/lib/getAllWorkflowsFromEventType"; import type { ExtendedCalendarEvent } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler"; import { WorkflowService } from "@calcom/features/ee/workflows/lib/service/WorkflowService"; import { WebhookService } from "@calcom/features/webhooks/lib/WebhookService"; @@ -16,7 +17,6 @@ import { prisma } from "@calcom/prisma"; import { WebhookTriggerEvents, WorkflowTriggerEvents } from "@calcom/prisma/enums"; import { bookingMetadataSchema, type PlatformClientParams } from "@calcom/prisma/zod-utils"; import type { TNoShowInputSchema } from "@calcom/trpc/server/routers/loggedInViewer/markNoShow.schema"; -import { getAllWorkflowsFromEventType } from "@calcom/trpc/server/routers/viewer/workflows/util"; import handleSendingAttendeeNoShowDataToApps from "./noShow/handleSendingAttendeeNoShowDataToApps"; diff --git a/packages/features/tasker/tasks/scanWorkflowBody.test.ts b/packages/features/tasker/tasks/scanWorkflowBody.test.ts index 2456dd5d02..c26508b782 100644 --- a/packages/features/tasker/tasks/scanWorkflowBody.test.ts +++ b/packages/features/tasker/tasks/scanWorkflowBody.test.ts @@ -3,7 +3,7 @@ import prismaMock from "../../../../tests/libs/__mocks__/prismaMock"; import { describe, expect, it, vi, beforeEach } from "vitest"; import { lockUser, LockReason } from "@calcom/features/ee/api-keys/lib/autoLock"; -import { scheduleWorkflowNotifications } from "@calcom/trpc/server/routers/viewer/workflows/util"; +import { scheduleWorkflowNotifications } from "@calcom/features/ee/workflows/lib/scheduleWorkflowNotifications"; import { scanWorkflowBody, iffyScanBody } from "./scanWorkflowBody"; @@ -15,7 +15,7 @@ vi.mock("@calcom/features/ee/api-keys/lib/autoLock", async (importActual) => { }; }); -vi.mock("@calcom/trpc/server/routers/viewer/workflows/util", () => ({ +vi.mock("@calcom/features/ee/workflows/lib/scheduleWorkflowNotifications", () => ({ scheduleWorkflowNotifications: vi.fn(), })); diff --git a/packages/features/tasker/tasks/scanWorkflowBody.ts b/packages/features/tasker/tasks/scanWorkflowBody.ts index 47be78b59f..1f9ff24d2e 100644 --- a/packages/features/tasker/tasks/scanWorkflowBody.ts +++ b/packages/features/tasker/tasks/scanWorkflowBody.ts @@ -2,12 +2,12 @@ import z from "zod"; import { getTemplateBodyForAction } from "@calcom/features/ee/workflows/lib/actionHelperFunctions"; import compareReminderBodyToTemplate from "@calcom/features/ee/workflows/lib/compareReminderBodyToTemplate"; +import { scheduleWorkflowNotifications } from "@calcom/features/ee/workflows/lib/scheduleWorkflowNotifications"; import { Task } from "@calcom/features/tasker/repository"; 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 { scheduleWorkflowNotifications } from "@calcom/trpc/server/routers/viewer/workflows/util"; export const scanWorkflowBodySchema = z.object({ userId: z.number(), diff --git a/packages/features/workflows/repositories/WorkflowPermissionsRepository.ts b/packages/features/workflows/repositories/WorkflowPermissionsRepository.ts index 5c0e27dcab..d6d7ed738b 100644 --- a/packages/features/workflows/repositories/WorkflowPermissionsRepository.ts +++ b/packages/features/workflows/repositories/WorkflowPermissionsRepository.ts @@ -1,5 +1,5 @@ +import { isAuthorized } from "@calcom/features/ee/workflows/lib/isAuthorized"; import type { Workflow } from "@calcom/prisma/client"; -import { isAuthorized } from "@calcom/trpc/server/routers/viewer/workflows/util"; export interface WorkflowPermissions { canView: boolean; diff --git a/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts b/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts index 426de305c9..763c415cfa 100644 --- a/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts +++ b/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts @@ -10,6 +10,7 @@ import { processPaymentRefund } from "@calcom/features/bookings/lib/payment/proc 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 { getAllWorkflowsFromEventType } from "@calcom/features/ee/workflows/lib/getAllWorkflowsFromEventType"; import { WorkflowService } from "@calcom/features/ee/workflows/lib/service/WorkflowService"; import type { GetSubscriberOptions } from "@calcom/features/webhooks/lib/getWebhooks"; import type { EventPayloadType, EventTypeInfo } from "@calcom/features/webhooks/lib/sendPayload"; @@ -31,7 +32,6 @@ import { UserPermissionRole, } from "@calcom/prisma/enums"; import type { EventTypeMetadata } from "@calcom/prisma/zod-utils"; -import { getAllWorkflowsFromEventType } from "@calcom/trpc/server/routers/viewer/workflows/util"; import type { CalendarEvent } from "@calcom/types/Calendar"; import { TRPCError } from "@trpc/server"; @@ -41,7 +41,10 @@ import type { TConfirmInputSchema } from "./confirm.schema"; type ConfirmOptions = { ctx: { - user: Pick, "id" | "uuid" | "email" | "username" | "role" | "destinationCalendar">; + user: Pick< + NonNullable, + "id" | "uuid" | "email" | "username" | "role" | "destinationCalendar" + >; traceContext: TraceContext; }; input: TConfirmInputSchema; diff --git a/packages/trpc/server/routers/viewer/workflows/delete.handler.test.ts b/packages/trpc/server/routers/viewer/workflows/delete.handler.test.ts index 5c7cec9f9e..ab86dca147 100644 --- a/packages/trpc/server/routers/viewer/workflows/delete.handler.test.ts +++ b/packages/trpc/server/routers/viewer/workflows/delete.handler.test.ts @@ -3,17 +3,14 @@ import { prisma } from "@calcom/prisma/__mocks__/prisma"; import { describe, it, expect, vi, beforeEach } from "vitest"; import { createDefaultAIPhoneServiceProvider } from "@calcom/features/calAIPhone"; +import { isAuthorized } from "@calcom/features/ee/workflows/lib/isAuthorized"; import { WorkflowRepository } from "@calcom/features/ee/workflows/repositories/WorkflowRepository"; import { WorkflowActions } from "@calcom/prisma/enums"; import { TRPCError } from "@trpc/server"; import { deleteHandler } from "./delete.handler"; -import { - isAuthorized, - removeSmsReminderFieldForEventTypes, - removeAIAgentCallPhoneNumberFieldForEventTypes, -} from "./util"; +import { removeSmsReminderFieldForEventTypes, removeAIAgentCallPhoneNumberFieldForEventTypes } from "./util"; vi.mock("@calcom/prisma", () => ({ prisma, @@ -29,8 +26,11 @@ vi.mock("@calcom/features/ee/workflows/repositories/WorkflowRepository", () => ( }, })); -vi.mock("./util", () => ({ +vi.mock("@calcom/features/ee/workflows/lib/isAuthorized", () => ({ isAuthorized: vi.fn(), +})); + +vi.mock("./util", () => ({ removeSmsReminderFieldForEventTypes: vi.fn(), removeAIAgentCallPhoneNumberFieldForEventTypes: vi.fn(), })); diff --git a/packages/trpc/server/routers/viewer/workflows/delete.handler.ts b/packages/trpc/server/routers/viewer/workflows/delete.handler.ts index d6dfc371d5..56b54fa374 100644 --- a/packages/trpc/server/routers/viewer/workflows/delete.handler.ts +++ b/packages/trpc/server/routers/viewer/workflows/delete.handler.ts @@ -1,4 +1,5 @@ import { createDefaultAIPhoneServiceProvider } from "@calcom/features/calAIPhone"; +import { isAuthorized } from "@calcom/features/ee/workflows/lib/isAuthorized"; import { WorkflowRepository } from "@calcom/features/ee/workflows/repositories/WorkflowRepository"; import logger from "@calcom/lib/logger"; import { prisma } from "@calcom/prisma"; @@ -8,11 +9,7 @@ import type { TrpcSessionUser } from "@calcom/trpc/server/types"; import { TRPCError } from "@trpc/server"; import type { TDeleteInputSchema } from "./delete.schema"; -import { - isAuthorized, - removeSmsReminderFieldForEventTypes, - removeAIAgentCallPhoneNumberFieldForEventTypes, -} from "./util"; +import { removeSmsReminderFieldForEventTypes, removeAIAgentCallPhoneNumberFieldForEventTypes } from "./util"; type DeleteOptions = { ctx: { diff --git a/packages/trpc/server/routers/viewer/workflows/get.handler.ts b/packages/trpc/server/routers/viewer/workflows/get.handler.ts index af03505d1f..85d7b970e7 100644 --- a/packages/trpc/server/routers/viewer/workflows/get.handler.ts +++ b/packages/trpc/server/routers/viewer/workflows/get.handler.ts @@ -1,3 +1,4 @@ +import { isAuthorized } from "@calcom/features/ee/workflows/lib/isAuthorized"; import { WorkflowRepository } from "@calcom/features/ee/workflows/repositories/WorkflowRepository"; import { addPermissionsToWorkflow } from "@calcom/features/workflows/repositories/WorkflowPermissionsRepository"; import type { TrpcSessionUser } from "@calcom/trpc/server/types"; @@ -5,7 +6,6 @@ import type { TrpcSessionUser } from "@calcom/trpc/server/types"; import { TRPCError } from "@trpc/server"; import type { TGetInputSchema } from "./get.schema"; -import { isAuthorized } from "./util"; type GetOptions = { ctx: { diff --git a/packages/trpc/server/routers/viewer/workflows/getAllActiveWorkflows.handler.ts b/packages/trpc/server/routers/viewer/workflows/getAllActiveWorkflows.handler.ts index e1efe345f1..9d07af6a01 100644 --- a/packages/trpc/server/routers/viewer/workflows/getAllActiveWorkflows.handler.ts +++ b/packages/trpc/server/routers/viewer/workflows/getAllActiveWorkflows.handler.ts @@ -1,4 +1,5 @@ import { eventTypeMetaDataSchemaWithTypedApps } from "@calcom/app-store/zod-utils"; +import { getAllWorkflowsFromEventType } from "@calcom/features/ee/workflows/lib/getAllWorkflowsFromEventType"; import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service"; import { addPermissionsToWorkflows } from "@calcom/features/workflows/repositories/WorkflowPermissionsRepository"; import { MembershipRole } from "@calcom/prisma/enums"; @@ -7,7 +8,7 @@ import type { TrpcSessionUser } from "@calcom/trpc/server/types"; import { TRPCError } from "@trpc/server"; import type { TGetAllActiveWorkflowsInputSchema } from "./getAllActiveWorkflows.schema"; -import { getAllWorkflowsFromEventType, getEventTypeWorkflows } from "./util"; +import { getEventTypeWorkflows } from "./util"; type GetAllActiveWorkflowsOptions = { ctx: { diff --git a/packages/trpc/server/routers/viewer/workflows/update.handler.ts b/packages/trpc/server/routers/viewer/workflows/update.handler.ts index dc0f6b6106..72f927c379 100755 --- a/packages/trpc/server/routers/viewer/workflows/update.handler.ts +++ b/packages/trpc/server/routers/viewer/workflows/update.handler.ts @@ -2,7 +2,11 @@ import { createDefaultAIPhoneServiceProvider } from "@calcom/features/calAIPhone import { PrismaAgentRepository } from "@calcom/features/calAIPhone/repositories/PrismaAgentRepository"; import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository"; import { isEmailAction, isFormTrigger } from "@calcom/features/ee/workflows/lib/actionHelperFunctions"; +import { deleteRemindersOfActiveOnIds } from "@calcom/features/ee/workflows/lib/deleteRemindersOfActiveOnIds"; +import { isAuthorized } from "@calcom/features/ee/workflows/lib/isAuthorized"; import { WorkflowReminderRepository } from "@calcom/features/ee/workflows/lib/repository/workflowReminder"; +import { scheduleWorkflowNotifications } from "@calcom/features/ee/workflows/lib/scheduleWorkflowNotifications"; +import { verifyEmailSender } from "@calcom/features/ee/workflows/lib/verifyEmailSender"; import { WorkflowRepository } from "@calcom/features/ee/workflows/repositories/WorkflowRepository"; import { EventTypeRepository } from "@calcom/features/eventtypes/repositories/eventTypeRepository"; import tasker from "@calcom/features/tasker"; @@ -23,12 +27,8 @@ import hasActiveTeamPlanHandler from "../teams/hasActiveTeamPlan.handler"; import type { TUpdateInputSchema } from "./update.schema"; import { getSender, - isAuthorized, upsertSmsReminderFieldForEventTypes, - deleteRemindersOfActiveOnIds, isAuthorizedToAddActiveOnIds, - scheduleWorkflowNotifications, - verifyEmailSender, removeSmsReminderFieldForEventTypes, isStepEdited, getEmailTemplateText, diff --git a/packages/trpc/server/routers/viewer/workflows/util.test.ts b/packages/trpc/server/routers/viewer/workflows/util.test.ts index f1e11af186..4f202a433a 100644 --- a/packages/trpc/server/routers/viewer/workflows/util.test.ts +++ b/packages/trpc/server/routers/viewer/workflows/util.test.ts @@ -2,10 +2,9 @@ import { prisma } from "@calcom/prisma/__mocks__/prisma"; import { describe, it, expect, vi, beforeEach } from "vitest"; +import { isAuthorized } from "@calcom/features/ee/workflows/lib/isAuthorized"; import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service"; -import { isAuthorized } from "./util"; - vi.mock("@calcom/features/pbac/services/permission-check.service"); vi.mock("@calcom/prisma", () => ({ diff --git a/packages/trpc/server/routers/viewer/workflows/util.ts b/packages/trpc/server/routers/viewer/workflows/util.ts index 99d5c0d02a..f9233baeba 100644 --- a/packages/trpc/server/routers/viewer/workflows/util.ts +++ b/packages/trpc/server/routers/viewer/workflows/util.ts @@ -1,267 +1,32 @@ import type { z } from "zod"; import { isSMSOrWhatsappAction } from "@calcom/ee/workflows/lib/actionHelperFunctions"; -import { getAllWorkflows } from "@calcom/ee/workflows/lib/getAllWorkflows"; -import { scheduleAIPhoneCall } from "@calcom/ee/workflows/lib/reminders/aiPhoneCallManager"; -import { scheduleEmailReminder } from "@calcom/ee/workflows/lib/reminders/emailReminderManager"; -import { scheduleSMSReminder } from "@calcom/ee/workflows/lib/reminders/smsReminderManager"; import emailRatingTemplate from "@calcom/ee/workflows/lib/reminders/templates/emailRatingTemplate"; import emailReminderTemplate from "@calcom/ee/workflows/lib/reminders/templates/emailReminderTemplate"; -import { scheduleWhatsappReminder } from "@calcom/ee/workflows/lib/reminders/whatsappReminderManager"; -import type { Workflow as WorkflowType } from "@calcom/ee/workflows/lib/types"; import { getSmsReminderNumberField, getSmsReminderNumberSource, 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"; -import type { PermissionString } from "@calcom/features/pbac/domain/types/permission-registry"; -import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service"; import { SMS_REMINDER_NUMBER_FIELD, CAL_AI_AGENT_PHONE_NUMBER_FIELD } from "@calcom/lib/bookings/SystemField"; import { SENDER_ID, SENDER_NAME } from "@calcom/lib/constants"; -import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId"; -import { getTeamIdFromEventType } from "@calcom/lib/getTeamIdFromEventType"; import { getTranslation } from "@calcom/lib/server/i18n"; import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat"; import prisma from "@calcom/prisma"; -import type { Workflow } from "@calcom/prisma/client"; -import type { Prisma, WorkflowStep } from "@calcom/prisma/client"; -import type { TimeUnit } from "@calcom/prisma/enums"; -import { WorkflowTemplates, WorkflowType as PrismaWorkflowType } from "@calcom/prisma/enums"; -import { SchedulingType } from "@calcom/prisma/enums"; -import { BookingStatus, MembershipRole, WorkflowActions, WorkflowTriggerEvents } from "@calcom/prisma/enums"; -import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; -import type { CalEventResponses } from "@calcom/types/Calendar"; - -import { TRPCError } from "@trpc/server"; +import type { WorkflowStep } from "@calcom/prisma/client"; +import { WorkflowTemplates } from "@calcom/prisma/enums"; +import { WorkflowActions } from "@calcom/prisma/enums"; import type { ZWorkflows } from "./getAllActiveWorkflows.schema"; -export const bookingSelect = { - userPrimaryEmail: true, - startTime: true, - endTime: true, - title: true, - uid: true, - metadata: true, - smsReminderNumber: true, - responses: true, - attendees: { - select: { - name: true, - email: true, - timeZone: true, - locale: true, - }, - }, - eventType: { - select: { - slug: true, - id: true, - schedulingType: true, - hideOrganizerEmail: true, - customReplyToEmail: true, - hosts: { - select: { - user: { - select: { - email: true, - destinationCalendar: { - select: { - primaryEmail: true, - }, - }, - }, - }, - }, - }, - }, - }, - user: { - select: { - name: true, - timeZone: true, - timeFormat: true, - locale: true, - email: true, - }, - }, -}; - -export const verifyEmailSender = async (email: string, userId: number, teamId: number | null) => { - const verifiedEmail = await prisma.verifiedEmail.findFirst({ - where: { - email, - OR: [{ userId }, { teamId }], - }, - }); - - if (verifiedEmail) { - if (teamId) { - if (!verifiedEmail.teamId) { - await prisma.verifiedEmail.update({ - where: { - id: verifiedEmail.id, - }, - data: { - teamId, - }, - }); - } else if (verifiedEmail.teamId !== teamId) { - await prisma.verifiedEmail.create({ - data: { - email, - userId, - teamId, - }, - }); - } - } - return; - } - - const userEmail = await prisma.user.findFirst({ - where: { - id: userId, - email, - }, - }); - - if (userEmail) { - await prisma.verifiedEmail.create({ - data: { - email, - userId, - teamId, - }, - }); - return; - } - - // Check if it's a verified secondary email of the user - const secondaryEmail = await prisma.secondaryEmail.findFirst({ - where: { - userId, - email, - emailVerified: { - not: null, - }, - }, - }); - - if (secondaryEmail) { - await prisma.verifiedEmail.create({ - data: { - email, - userId, - teamId, - }, - }); - return; - } - - if (teamId) { - const team = await prisma.team.findUnique({ - where: { - id: teamId, - }, - select: { - members: { - include: { - user: { - select: { - id: true, - email: true, - secondaryEmails: { - select: { - email: true, - emailVerified: true, - }, - }, - }, - }, - }, - }, - }, - }); - - if (!team) { - throw new TRPCError({ code: "NOT_FOUND", message: "Team not found" }); - } - - const isTeamMember = team.members.some((member) => member.userId === userId); - - if (!isTeamMember) { - throw new TRPCError({ code: "FORBIDDEN", message: "You are not a member of this team" }); - } - - let foundTeamMember = team.members.find((member) => member.user.email === email); - - // Only check secondary emails if no match was found with primary email - if (!foundTeamMember) { - foundTeamMember = team.members.find((member) => - member.user.secondaryEmails.some( - (secondary) => secondary.email === email && !!secondary.emailVerified - ) - ); - } - - if (foundTeamMember) { - await prisma.verifiedEmail.create({ - data: { - email, - userId, - teamId, - }, - }); - return; - } - } - - throw new TRPCError({ code: "NOT_FOUND", message: "Email not verified" }); -}; - export function getSender( step: Pick & { senderName: string | null | undefined } ) { return isSMSOrWhatsappAction(step.action) ? step.sender || SENDER_ID : step.senderName || SENDER_NAME; } -export async function isAuthorized( - workflow: Pick | null, - currentUserId: number, - permission: PermissionString = "workflow.read" -) { - if (!workflow) { - return false; - } - - // For personal workflows (no teamId), check if user owns the workflow - if (!workflow.teamId) { - return workflow.userId === currentUserId; - } - - // For team workflows, use PBAC - const permissionService = new PermissionCheckService(); - - // Determine fallback roles based on permission type - const fallbackRoles = - permission === "workflow.read" - ? [MembershipRole.ADMIN, MembershipRole.OWNER, MembershipRole.MEMBER] - : [MembershipRole.ADMIN, MembershipRole.OWNER]; - - return await permissionService.checkPermission({ - userId: currentUserId, - teamId: workflow.teamId, - permission, - fallbackRoles, - }); -} - export async function upsertSmsReminderFieldForEventTypes({ activeOn, workflowId, @@ -535,397 +300,6 @@ export async function isAuthorizedToAddActiveOnIds({ return true; } -export async function deleteRemindersOfActiveOnIds({ - removedActiveOnIds, - workflowSteps, - isOrg, - activeOnIds, -}: { - removedActiveOnIds: number[]; - workflowSteps: WorkflowStep[]; - isOrg: boolean; - activeOnIds?: number[]; -}) { - const remindersToDelete = !isOrg - ? await getRemindersFromRemovedEventTypes(removedActiveOnIds, workflowSteps) - : await WorkflowRepository.getRemindersFromRemovedTeams(removedActiveOnIds, workflowSteps, activeOnIds); - await WorkflowRepository.deleteAllWorkflowReminders(remindersToDelete); -} - -async function getRemindersFromRemovedEventTypes(removedEventTypes: number[], workflowSteps: WorkflowStep[]) { - const remindersToDeletePromise: Prisma.PrismaPromise< - { - id: number; - referenceId: string | null; - method: string; - }[] - >[] = []; - removedEventTypes.forEach((eventTypeId) => { - const remindersToDelete = prisma.workflowReminder.findMany({ - where: { - booking: { - eventTypeId, - }, - workflowStepId: { - in: workflowSteps.map((step) => { - return step.id; - }), - }, - }, - select: { - id: true, - referenceId: true, - method: true, - }, - }); - - remindersToDeletePromise.push(remindersToDelete); - }); - - const remindersToDelete = (await Promise.all(remindersToDeletePromise)).flat(); - return remindersToDelete; -} - -export async function scheduleWorkflowNotifications({ - activeOn, - isOrg, - workflowSteps, - time, - timeUnit, - trigger, - userId, - teamId, - alreadyScheduledActiveOnIds, -}: { - activeOn: number[]; - isOrg: boolean; - workflowSteps: Partial[]; - time: number | null; - timeUnit: TimeUnit | null; - trigger: WorkflowTriggerEvents; - userId: number; - teamId: number | null; - alreadyScheduledActiveOnIds?: number[]; -}) { - if (trigger !== WorkflowTriggerEvents.BEFORE_EVENT && trigger !== WorkflowTriggerEvents.AFTER_EVENT) return; - - const bookingsToScheduleNotifications = await getBookings(activeOn, isOrg, alreadyScheduledActiveOnIds); - - await scheduleBookingReminders( - bookingsToScheduleNotifications, - workflowSteps, - time, - timeUnit, - trigger, - userId, - teamId, - isOrg - ); -} - -async function getBookings(activeOn: number[], isOrg: boolean, alreadyScheduledActiveOnIds: number[] = []) { - if (activeOn.length === 0) return []; - - if (isOrg) { - const bookingsForReminders = await prisma.booking.findMany({ - where: { - OR: [ - { - // bookings from team event types + children managed event types - eventType: { - OR: [ - { - teamId: { - in: activeOn, - }, - }, - { - teamId: null, - parent: { - teamId: { - in: activeOn, - }, - }, - }, - ], - }, - }, - { - // user bookings - user: { - teams: { - some: { - teamId: { - in: activeOn, - }, - accepted: true, - }, - }, - }, - eventType: { - teamId: null, - parentId: null, // children managed event types are handled above with team event types - }, - // if user is already part of an already scheduled activeOn connecting reminders are already scheduled - NOT: { - user: { - teams: { - some: { - teamId: { - in: alreadyScheduledActiveOnIds, - }, - }, - }, - }, - }, - }, - ], - status: BookingStatus.ACCEPTED, - startTime: { - gte: new Date(), - }, - }, - select: bookingSelect, - orderBy: { - startTime: "asc", - }, - }); - return bookingsForReminders; - } else { - const bookingsForReminders = await prisma.booking.findMany({ - where: { - OR: [ - { eventTypeId: { in: activeOn } }, - { - eventType: { - parentId: { - in: activeOn, // child event type can not disable workflows, so this should work - }, - }, - }, - ], - status: BookingStatus.ACCEPTED, - startTime: { - gte: new Date(), - }, - }, - select: bookingSelect, - orderBy: { - startTime: "asc", - }, - }); - return bookingsForReminders; - } -} - -type UnwrapPromise = T extends Promise ? U : T; - -type Bookings = UnwrapPromise>; - -// some parts of scheduleWorkflowReminders (reminderSchedule.ts) is quite similar to this code -// we should consider refactoring this to reuse similar code snippets -export async function scheduleBookingReminders( - bookings: Bookings, - workflowSteps: Partial[], - time: number | null, - timeUnit: TimeUnit | null, - trigger: WorkflowTriggerEvents, - userId: number, - teamId: number | null, - isOrg: boolean -) { - if (!bookings.length) return; - if (trigger !== WorkflowTriggerEvents.BEFORE_EVENT && trigger !== WorkflowTriggerEvents.AFTER_EVENT) return; - - 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) => { - const defaultLocale = "en"; - const bookingInfo = { - uid: booking.uid, - bookerUrl, - type: booking.eventType?.slug || "event", - attendees: booking.attendees.map((attendee) => { - return { - name: attendee.name, - email: attendee.email, - timeZone: attendee.timeZone, - language: { locale: attendee.locale || defaultLocale }, - }; - }), - organizer: booking.user - ? { - language: { locale: booking.user.locale || defaultLocale }, - name: booking.user.name || "", - email: booking?.userPrimaryEmail ?? booking.user.email, - timeZone: booking.user.timeZone, - timeFormat: getTimeFormatStringFromUserTimeFormat(booking.user.timeFormat), - } - : { name: "", email: "", timeZone: "", language: { locale: "" } }, - startTime: booking.startTime?.toISOString(), - endTime: booking.endTime?.toISOString(), - title: booking.title, - language: { locale: booking?.user?.locale || defaultLocale }, - hideOrganizerEmail: booking.eventType?.hideOrganizerEmail, - eventType: { - slug: booking.eventType?.slug || "", - schedulingType: booking.eventType?.schedulingType, - hosts: booking.eventType?.hosts, - }, - metadata: booking.metadata, - customReplyToEmail: booking.eventType?.customReplyToEmail, - responses: booking.responses as CalEventResponses | null, - }; - if ( - step.action === WorkflowActions.EMAIL_HOST || - step.action === WorkflowActions.EMAIL_ATTENDEE || - step.action === WorkflowActions.EMAIL_ADDRESS - ) { - let sendTo: string[] = []; - - switch (step.action) { - case WorkflowActions.EMAIL_HOST: { - sendTo = [bookingInfo.organizer?.email]; - const schedulingType = bookingInfo.eventType.schedulingType; - const hosts = bookingInfo.eventType.hosts - ?.filter((host) => bookingInfo.attendees.some((attendee) => attendee.email === host.user.email)) - .map(({ user }) => user.destinationCalendar?.primaryEmail ?? user.email); - if ( - hosts && - (schedulingType === SchedulingType.ROUND_ROBIN || schedulingType === SchedulingType.COLLECTIVE) - ) { - sendTo = sendTo.concat(hosts); - } - break; - } - case WorkflowActions.EMAIL_ATTENDEE: - sendTo = bookingInfo.attendees.map((attendee) => attendee.email); - break; - case WorkflowActions.EMAIL_ADDRESS: - await verifyEmailSender(step.sendTo || "", userId, teamId); - sendTo = [step.sendTo || ""]; - break; - } - - await scheduleEmailReminder({ - evt: bookingInfo, - triggerEvent: trigger, - action: step.action, - timeSpan: { - time, - timeUnit, - }, - sendTo, - emailSubject: step.emailSubject || "", - emailBody: step.reminderBody || "", - template: step.template, - sender: step.sender, - workflowStepId: step.id, - verifiedAt: step?.verifiedAt ?? null, - }); - } else if (step.action === WorkflowActions.SMS_NUMBER && step.sendTo) { - await scheduleSMSReminder({ - evt: bookingInfo, - reminderPhone: step.sendTo, - triggerEvent: trigger, - action: step.action, - timeSpan: { - time, - timeUnit, - }, - message: step.reminderBody || "", - workflowStepId: step.id, - template: step.template, - sender: step.sender, - userId: userId, - teamId: teamId, - verifiedAt: step?.verifiedAt ?? null, - creditCheckFn: creditService.hasAvailableCredits.bind(creditService), - }); - } else if (step.action === WorkflowActions.WHATSAPP_NUMBER && step.sendTo) { - await scheduleWhatsappReminder({ - evt: bookingInfo, - reminderPhone: step.sendTo, - triggerEvent: trigger, - action: step.action, - timeSpan: { - time, - timeUnit, - }, - message: step.reminderBody || "", - workflowStepId: step.id || 0, - template: step.template, - userId: userId, - teamId: teamId, - verifiedAt: step?.verifiedAt ?? null, - creditCheckFn: creditService.hasAvailableCredits.bind(creditService), - }); - } else if (booking.smsReminderNumber) { - if (step.action === WorkflowActions.SMS_ATTENDEE) { - await scheduleSMSReminder({ - evt: bookingInfo, - reminderPhone: booking.smsReminderNumber, - triggerEvent: trigger, - action: step.action, - timeSpan: { - time, - timeUnit, - }, - message: step.reminderBody || "", - workflowStepId: step.id, - template: step.template, - sender: step.sender, - userId: userId, - teamId: teamId, - verifiedAt: step?.verifiedAt ?? null, - creditCheckFn: creditService.hasAvailableCredits.bind(creditService), - }); - } else if (step.action === WorkflowActions.WHATSAPP_ATTENDEE) { - await scheduleWhatsappReminder({ - evt: bookingInfo, - reminderPhone: booking.smsReminderNumber, - triggerEvent: trigger, - action: step.action, - timeSpan: { - time, - timeUnit, - }, - message: step.reminderBody || "", - workflowStepId: step.id, - template: step.template, - sender: step.sender, - userId: userId, - teamId: teamId, - verifiedAt: step?.verifiedAt ?? null, - creditCheckFn: creditService.hasAvailableCredits.bind(creditService), - }); - } - } else if (step.action === WorkflowActions.CAL_AI_PHONE_CALL) { - await scheduleAIPhoneCall({ - evt: bookingInfo, - triggerEvent: trigger, - timeSpan: { - time, - timeUnit, - }, - workflowStepId: step.id, - userId, - teamId, - verifiedAt: step?.verifiedAt ?? null, - submittedPhoneNumber: booking.smsReminderNumber, - routedEventTypeId: null, - }); - } - }); - await Promise.all(promiseScheduleReminders); - }); - return Promise.all(promiseSteps); -} - export function isStepEdited(oldStep: WorkflowStep, newStep: WorkflowStep) { const oldStepKeys = Object.keys(oldStep); const newStepKeys = Object.keys(newStep); @@ -943,54 +317,6 @@ export function isStepEdited(oldStep: WorkflowStep, newStep: WorkflowStep) { return false; } -export async function getAllWorkflowsFromEventType( - eventType: { - workflows?: { - workflow: WorkflowType; - }[]; - teamId?: number | null; - parentId?: number | null; - parent?: { - id?: number | null; - teamId: number | null; - } | null; - metadata?: Prisma.JsonValue; - } | null, - userId?: number | null -) { - if (!eventType) return []; - - const eventTypeWorkflows = eventType?.workflows?.map((workflowRel) => workflowRel.workflow) ?? []; - - const teamId = await getTeamIdFromEventType({ - eventType: { - team: { id: eventType?.teamId ?? null }, - parentId: eventType?.parentId || eventType?.parent?.id || null, - }, - }); - - const orgId = await getOrgIdFromMemberOrTeamId({ memberId: userId, teamId }); - - const isManagedEventType = !!eventType?.parent; - - const eventTypeMetadata = EventTypeMetaDataSchema.parse(eventType?.metadata || {}); - - const workflowsLockedForUser = isManagedEventType - ? !eventTypeMetadata?.managedEventConfig?.unlockedFields?.workflows - : false; - - const allWorkflows = await getAllWorkflows({ - entityWorkflows: eventTypeWorkflows, - userId, - teamId, - orgId, - workflowsLockedForUser, - type: PrismaWorkflowType.EVENT_TYPE, - }); - - return allWorkflows; -} - export const getEventTypeWorkflows = async ( userId: number, eventTypeId: number