From 7e882eedf852a67ad183c269e1728cd432b7ca50 Mon Sep 17 00:00:00 2001 From: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> Date: Tue, 23 Sep 2025 21:28:56 +0530 Subject: [PATCH] fix: remove booking question after workflow deletion (#23901) * fix: remove booking question after workflow deletion * fix --- .../viewer/workflows/delete.handler.test.ts | 287 ++++++++++++++++++ .../viewer/workflows/delete.handler.ts | 11 +- 2 files changed, 297 insertions(+), 1 deletion(-) create mode 100644 packages/trpc/server/routers/viewer/workflows/delete.handler.test.ts diff --git a/packages/trpc/server/routers/viewer/workflows/delete.handler.test.ts b/packages/trpc/server/routers/viewer/workflows/delete.handler.test.ts new file mode 100644 index 0000000000..febd4d72ef --- /dev/null +++ b/packages/trpc/server/routers/viewer/workflows/delete.handler.test.ts @@ -0,0 +1,287 @@ +import { prisma } from "@calcom/prisma/__mocks__/prisma"; + +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import { createDefaultAIPhoneServiceProvider } from "@calcom/features/calAIPhone"; +import { WorkflowRepository } from "@calcom/lib/server/repository/workflow"; +import { WorkflowActions } from "@calcom/prisma/enums"; + +import { TRPCError } from "@trpc/server"; + +import { deleteHandler } from "./delete.handler"; +import { + isAuthorized, + removeSmsReminderFieldForEventTypes, + removeAIAgentCallPhoneNumberFieldForEventTypes, +} from "./util"; + +vi.mock("@calcom/prisma", () => ({ + prisma, +})); + +vi.mock("@calcom/features/calAIPhone", () => ({ + createDefaultAIPhoneServiceProvider: vi.fn(), +})); + +vi.mock("@calcom/lib/server/repository/workflow", () => ({ + WorkflowRepository: { + deleteAllWorkflowReminders: vi.fn(), + }, +})); + +vi.mock("./util", () => ({ + isAuthorized: vi.fn(), + removeSmsReminderFieldForEventTypes: vi.fn(), + removeAIAgentCallPhoneNumberFieldForEventTypes: vi.fn(), +})); + +describe("deleteHandler", () => { + const mockCreateDefaultAIPhoneServiceProvider = vi.mocked(createDefaultAIPhoneServiceProvider); + const mockIsAuthorized = vi.mocked(isAuthorized); + const mockRemoveSmsReminderFieldForEventTypes = vi.mocked(removeSmsReminderFieldForEventTypes); + const mockRemoveAIAgentCallPhoneNumberFieldForEventTypes = vi.mocked( + removeAIAgentCallPhoneNumberFieldForEventTypes + ); + const mockDeleteAllWorkflowReminders = vi.mocked(WorkflowRepository.deleteAllWorkflowReminders); + + const mockUser = { + id: 123, + name: "Test User", + email: "test@example.com", + }; + + const mockCtx = { + user: mockUser, + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("Authorization", () => { + it("should throw UNAUTHORIZED when user is not authorized or workflow not found", async () => { + const workflowId = 1; + + prisma.workflow.findUnique.mockResolvedValue({ + id: workflowId, + teamId: 456, + userId: 789, + activeOn: [], + activeOnTeams: [], + steps: [], + team: null, + }); + mockIsAuthorized.mockResolvedValue(false); + + await expect(deleteHandler({ ctx: mockCtx, input: { id: workflowId } })).rejects.toThrow( + new TRPCError({ code: "UNAUTHORIZED" }) + ); + + prisma.workflow.findUnique.mockResolvedValue(null); + + await expect(deleteHandler({ ctx: mockCtx, input: { id: workflowId } })).rejects.toThrow( + new TRPCError({ code: "UNAUTHORIZED" }) + ); + }); + }); + + describe("Booking field cleanup", () => { + it("should remove both SMS reminder and AI agent phone number fields", async () => { + const workflowId = 1; + const eventTypeIds = [10, 20]; + const mockWorkflow = { + id: workflowId, + teamId: null, + userId: mockUser.id, + activeOn: eventTypeIds.map((id) => ({ eventTypeId: id })), + activeOnTeams: [], + steps: [], + team: null, + }; + + prisma.workflow.findUnique.mockResolvedValue(mockWorkflow); + mockIsAuthorized.mockResolvedValue(true); + prisma.workflowReminder.findMany.mockResolvedValue([]); + prisma.workflow.deleteMany.mockResolvedValue({ count: 1 }); + + await deleteHandler({ ctx: mockCtx, input: { id: workflowId } }); + + expect(mockRemoveSmsReminderFieldForEventTypes).toHaveBeenCalledWith({ + activeOnToRemove: eventTypeIds, + workflowId: workflowId, + isOrg: false, + }); + + expect(mockRemoveAIAgentCallPhoneNumberFieldForEventTypes).toHaveBeenCalledWith({ + activeOnToRemove: eventTypeIds, + workflowId: workflowId, + isOrg: false, + }); + }); + + it("should handle organization workflows correctly", async () => { + const workflowId = 1; + const teamIds = [100, 200]; + const mockWorkflow = { + id: workflowId, + teamId: 456, + userId: mockUser.id, + activeOn: [], + activeOnTeams: teamIds.map((id) => ({ teamId: id })), + steps: [], + team: { + isOrganization: true, + }, + }; + + prisma.workflow.findUnique.mockResolvedValue(mockWorkflow); + mockIsAuthorized.mockResolvedValue(true); + prisma.workflowReminder.findMany.mockResolvedValue([]); + prisma.workflow.deleteMany.mockResolvedValue({ count: 1 }); + + await deleteHandler({ ctx: mockCtx, input: { id: workflowId } }); + + expect(mockRemoveSmsReminderFieldForEventTypes).toHaveBeenCalledWith({ + activeOnToRemove: teamIds, + workflowId: workflowId, + isOrg: true, + }); + + expect(mockRemoveAIAgentCallPhoneNumberFieldForEventTypes).toHaveBeenCalledWith({ + activeOnToRemove: teamIds, + workflowId: workflowId, + isOrg: true, + }); + }); + }); + + describe("CAL AI phone call cleanup", () => { + let mockAIPhoneService: { + cancelPhoneNumberSubscription: ReturnType; + deletePhoneNumber: ReturnType; + deleteAgent: ReturnType; + }; + + beforeEach(() => { + mockAIPhoneService = { + cancelPhoneNumberSubscription: vi.fn(), + deletePhoneNumber: vi.fn(), + deleteAgent: vi.fn(), + }; + mockCreateDefaultAIPhoneServiceProvider.mockReturnValue(mockAIPhoneService); + }); + + it("should cleanup AI phone resources based on subscription status", async () => { + const workflowId = 1; + const mockWorkflow = { + id: workflowId, + teamId: null, + userId: mockUser.id, + activeOn: [], + activeOnTeams: [], + steps: [ + { + action: WorkflowActions.CAL_AI_PHONE_CALL, + agent: { + id: "agent-1", + outboundPhoneNumbers: [ + { + id: "phone-active", + phoneNumber: "+1111111111", + subscriptionStatus: "ACTIVE", + }, + { + id: "phone-null", + phoneNumber: "+2222222222", + subscriptionStatus: null, + }, + ], + }, + }, + ], + team: null, + }; + + prisma.workflow.findUnique.mockResolvedValue(mockWorkflow); + mockIsAuthorized.mockResolvedValue(true); + prisma.workflowReminder.findMany.mockResolvedValue([]); + prisma.workflow.deleteMany.mockResolvedValue({ count: 1 }); + + await deleteHandler({ ctx: mockCtx, input: { id: workflowId } }); + + expect(mockAIPhoneService.cancelPhoneNumberSubscription).toHaveBeenCalledWith({ + phoneNumberId: "phone-active", + userId: mockUser.id, + }); + + expect(mockAIPhoneService.deletePhoneNumber).toHaveBeenCalledWith({ + phoneNumber: "+2222222222", + userId: mockUser.id, + deleteFromDB: true, + }); + + expect(mockAIPhoneService.deleteAgent).toHaveBeenCalledWith({ + id: "agent-1", + userId: mockUser.id, + teamId: undefined, + }); + + expect(mockRemoveAIAgentCallPhoneNumberFieldForEventTypes).toHaveBeenCalled(); + }); + }); + + describe("Workflow deletion flow", () => { + it("should complete full deletion flow successfully", async () => { + const workflowId = 1; + const mockReminders = [ + { + id: 1, + workflowStepId: 1, + scheduled: true, + referenceId: "ref-1", + }, + ]; + const mockWorkflow = { + id: workflowId, + teamId: null, + userId: mockUser.id, + activeOn: [{ eventTypeId: 10 }], + activeOnTeams: [], + steps: [], + team: null, + }; + + prisma.workflow.findUnique.mockResolvedValue(mockWorkflow); + mockIsAuthorized.mockResolvedValue(true); + prisma.workflowReminder.findMany.mockResolvedValue(mockReminders); + prisma.workflow.deleteMany.mockResolvedValue({ count: 1 }); + + const result = await deleteHandler({ ctx: mockCtx, input: { id: workflowId } }); + + expect(prisma.workflowReminder.findMany).toHaveBeenCalledWith({ + where: { + workflowStep: { + workflowId: workflowId, + }, + scheduled: true, + NOT: { + referenceId: null, + }, + }, + }); + + expect(mockDeleteAllWorkflowReminders).toHaveBeenCalledWith(mockReminders); + + expect(mockRemoveSmsReminderFieldForEventTypes).toHaveBeenCalled(); + expect(mockRemoveAIAgentCallPhoneNumberFieldForEventTypes).toHaveBeenCalled(); + + expect(prisma.workflow.deleteMany).toHaveBeenCalledWith({ + where: { + id: workflowId, + }, + }); + + expect(result).toEqual({ id: workflowId }); + }); + }); +}); diff --git a/packages/trpc/server/routers/viewer/workflows/delete.handler.ts b/packages/trpc/server/routers/viewer/workflows/delete.handler.ts index e51dcc5647..181b114d1d 100644 --- a/packages/trpc/server/routers/viewer/workflows/delete.handler.ts +++ b/packages/trpc/server/routers/viewer/workflows/delete.handler.ts @@ -7,7 +7,11 @@ import type { TrpcSessionUser } from "@calcom/trpc/server/types"; import { TRPCError } from "@trpc/server"; import type { TDeleteInputSchema } from "./delete.schema"; -import { isAuthorized, removeSmsReminderFieldForEventTypes } from "./util"; +import { + isAuthorized, + removeSmsReminderFieldForEventTypes, + removeAIAgentCallPhoneNumberFieldForEventTypes, +} from "./util"; type DeleteOptions = { ctx: { @@ -138,6 +142,11 @@ export const deleteHandler = async ({ ctx, input }: DeleteOptions) => { : workflowToDelete.activeOn.map((activeOn) => activeOn.eventTypeId); await removeSmsReminderFieldForEventTypes({ activeOnToRemove, workflowId: workflowToDelete.id, isOrg }); + await removeAIAgentCallPhoneNumberFieldForEventTypes({ + activeOnToRemove, + workflowId: workflowToDelete.id, + isOrg, + }); // automatically deletes all steps and reminders connected to this workflow await prisma.workflow.deleteMany({