Skip to content

Commit 7e882ee

Browse files
authored
fix: remove booking question after workflow deletion (calcom#23901)
* fix: remove booking question after workflow deletion * fix
1 parent 224e606 commit 7e882ee

2 files changed

Lines changed: 297 additions & 1 deletion

File tree

Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
import { prisma } from "@calcom/prisma/__mocks__/prisma";
2+
3+
import { describe, it, expect, vi, beforeEach } from "vitest";
4+
5+
import { createDefaultAIPhoneServiceProvider } from "@calcom/features/calAIPhone";
6+
import { WorkflowRepository } from "@calcom/lib/server/repository/workflow";
7+
import { WorkflowActions } from "@calcom/prisma/enums";
8+
9+
import { TRPCError } from "@trpc/server";
10+
11+
import { deleteHandler } from "./delete.handler";
12+
import {
13+
isAuthorized,
14+
removeSmsReminderFieldForEventTypes,
15+
removeAIAgentCallPhoneNumberFieldForEventTypes,
16+
} from "./util";
17+
18+
vi.mock("@calcom/prisma", () => ({
19+
prisma,
20+
}));
21+
22+
vi.mock("@calcom/features/calAIPhone", () => ({
23+
createDefaultAIPhoneServiceProvider: vi.fn(),
24+
}));
25+
26+
vi.mock("@calcom/lib/server/repository/workflow", () => ({
27+
WorkflowRepository: {
28+
deleteAllWorkflowReminders: vi.fn(),
29+
},
30+
}));
31+
32+
vi.mock("./util", () => ({
33+
isAuthorized: vi.fn(),
34+
removeSmsReminderFieldForEventTypes: vi.fn(),
35+
removeAIAgentCallPhoneNumberFieldForEventTypes: vi.fn(),
36+
}));
37+
38+
describe("deleteHandler", () => {
39+
const mockCreateDefaultAIPhoneServiceProvider = vi.mocked(createDefaultAIPhoneServiceProvider);
40+
const mockIsAuthorized = vi.mocked(isAuthorized);
41+
const mockRemoveSmsReminderFieldForEventTypes = vi.mocked(removeSmsReminderFieldForEventTypes);
42+
const mockRemoveAIAgentCallPhoneNumberFieldForEventTypes = vi.mocked(
43+
removeAIAgentCallPhoneNumberFieldForEventTypes
44+
);
45+
const mockDeleteAllWorkflowReminders = vi.mocked(WorkflowRepository.deleteAllWorkflowReminders);
46+
47+
const mockUser = {
48+
id: 123,
49+
name: "Test User",
50+
email: "test@example.com",
51+
};
52+
53+
const mockCtx = {
54+
user: mockUser,
55+
};
56+
57+
beforeEach(() => {
58+
vi.clearAllMocks();
59+
});
60+
61+
describe("Authorization", () => {
62+
it("should throw UNAUTHORIZED when user is not authorized or workflow not found", async () => {
63+
const workflowId = 1;
64+
65+
prisma.workflow.findUnique.mockResolvedValue({
66+
id: workflowId,
67+
teamId: 456,
68+
userId: 789,
69+
activeOn: [],
70+
activeOnTeams: [],
71+
steps: [],
72+
team: null,
73+
});
74+
mockIsAuthorized.mockResolvedValue(false);
75+
76+
await expect(deleteHandler({ ctx: mockCtx, input: { id: workflowId } })).rejects.toThrow(
77+
new TRPCError({ code: "UNAUTHORIZED" })
78+
);
79+
80+
prisma.workflow.findUnique.mockResolvedValue(null);
81+
82+
await expect(deleteHandler({ ctx: mockCtx, input: { id: workflowId } })).rejects.toThrow(
83+
new TRPCError({ code: "UNAUTHORIZED" })
84+
);
85+
});
86+
});
87+
88+
describe("Booking field cleanup", () => {
89+
it("should remove both SMS reminder and AI agent phone number fields", async () => {
90+
const workflowId = 1;
91+
const eventTypeIds = [10, 20];
92+
const mockWorkflow = {
93+
id: workflowId,
94+
teamId: null,
95+
userId: mockUser.id,
96+
activeOn: eventTypeIds.map((id) => ({ eventTypeId: id })),
97+
activeOnTeams: [],
98+
steps: [],
99+
team: null,
100+
};
101+
102+
prisma.workflow.findUnique.mockResolvedValue(mockWorkflow);
103+
mockIsAuthorized.mockResolvedValue(true);
104+
prisma.workflowReminder.findMany.mockResolvedValue([]);
105+
prisma.workflow.deleteMany.mockResolvedValue({ count: 1 });
106+
107+
await deleteHandler({ ctx: mockCtx, input: { id: workflowId } });
108+
109+
expect(mockRemoveSmsReminderFieldForEventTypes).toHaveBeenCalledWith({
110+
activeOnToRemove: eventTypeIds,
111+
workflowId: workflowId,
112+
isOrg: false,
113+
});
114+
115+
expect(mockRemoveAIAgentCallPhoneNumberFieldForEventTypes).toHaveBeenCalledWith({
116+
activeOnToRemove: eventTypeIds,
117+
workflowId: workflowId,
118+
isOrg: false,
119+
});
120+
});
121+
122+
it("should handle organization workflows correctly", async () => {
123+
const workflowId = 1;
124+
const teamIds = [100, 200];
125+
const mockWorkflow = {
126+
id: workflowId,
127+
teamId: 456,
128+
userId: mockUser.id,
129+
activeOn: [],
130+
activeOnTeams: teamIds.map((id) => ({ teamId: id })),
131+
steps: [],
132+
team: {
133+
isOrganization: true,
134+
},
135+
};
136+
137+
prisma.workflow.findUnique.mockResolvedValue(mockWorkflow);
138+
mockIsAuthorized.mockResolvedValue(true);
139+
prisma.workflowReminder.findMany.mockResolvedValue([]);
140+
prisma.workflow.deleteMany.mockResolvedValue({ count: 1 });
141+
142+
await deleteHandler({ ctx: mockCtx, input: { id: workflowId } });
143+
144+
expect(mockRemoveSmsReminderFieldForEventTypes).toHaveBeenCalledWith({
145+
activeOnToRemove: teamIds,
146+
workflowId: workflowId,
147+
isOrg: true,
148+
});
149+
150+
expect(mockRemoveAIAgentCallPhoneNumberFieldForEventTypes).toHaveBeenCalledWith({
151+
activeOnToRemove: teamIds,
152+
workflowId: workflowId,
153+
isOrg: true,
154+
});
155+
});
156+
});
157+
158+
describe("CAL AI phone call cleanup", () => {
159+
let mockAIPhoneService: {
160+
cancelPhoneNumberSubscription: ReturnType<typeof vi.fn>;
161+
deletePhoneNumber: ReturnType<typeof vi.fn>;
162+
deleteAgent: ReturnType<typeof vi.fn>;
163+
};
164+
165+
beforeEach(() => {
166+
mockAIPhoneService = {
167+
cancelPhoneNumberSubscription: vi.fn(),
168+
deletePhoneNumber: vi.fn(),
169+
deleteAgent: vi.fn(),
170+
};
171+
mockCreateDefaultAIPhoneServiceProvider.mockReturnValue(mockAIPhoneService);
172+
});
173+
174+
it("should cleanup AI phone resources based on subscription status", async () => {
175+
const workflowId = 1;
176+
const mockWorkflow = {
177+
id: workflowId,
178+
teamId: null,
179+
userId: mockUser.id,
180+
activeOn: [],
181+
activeOnTeams: [],
182+
steps: [
183+
{
184+
action: WorkflowActions.CAL_AI_PHONE_CALL,
185+
agent: {
186+
id: "agent-1",
187+
outboundPhoneNumbers: [
188+
{
189+
id: "phone-active",
190+
phoneNumber: "+1111111111",
191+
subscriptionStatus: "ACTIVE",
192+
},
193+
{
194+
id: "phone-null",
195+
phoneNumber: "+2222222222",
196+
subscriptionStatus: null,
197+
},
198+
],
199+
},
200+
},
201+
],
202+
team: null,
203+
};
204+
205+
prisma.workflow.findUnique.mockResolvedValue(mockWorkflow);
206+
mockIsAuthorized.mockResolvedValue(true);
207+
prisma.workflowReminder.findMany.mockResolvedValue([]);
208+
prisma.workflow.deleteMany.mockResolvedValue({ count: 1 });
209+
210+
await deleteHandler({ ctx: mockCtx, input: { id: workflowId } });
211+
212+
expect(mockAIPhoneService.cancelPhoneNumberSubscription).toHaveBeenCalledWith({
213+
phoneNumberId: "phone-active",
214+
userId: mockUser.id,
215+
});
216+
217+
expect(mockAIPhoneService.deletePhoneNumber).toHaveBeenCalledWith({
218+
phoneNumber: "+2222222222",
219+
userId: mockUser.id,
220+
deleteFromDB: true,
221+
});
222+
223+
expect(mockAIPhoneService.deleteAgent).toHaveBeenCalledWith({
224+
id: "agent-1",
225+
userId: mockUser.id,
226+
teamId: undefined,
227+
});
228+
229+
expect(mockRemoveAIAgentCallPhoneNumberFieldForEventTypes).toHaveBeenCalled();
230+
});
231+
});
232+
233+
describe("Workflow deletion flow", () => {
234+
it("should complete full deletion flow successfully", async () => {
235+
const workflowId = 1;
236+
const mockReminders = [
237+
{
238+
id: 1,
239+
workflowStepId: 1,
240+
scheduled: true,
241+
referenceId: "ref-1",
242+
},
243+
];
244+
const mockWorkflow = {
245+
id: workflowId,
246+
teamId: null,
247+
userId: mockUser.id,
248+
activeOn: [{ eventTypeId: 10 }],
249+
activeOnTeams: [],
250+
steps: [],
251+
team: null,
252+
};
253+
254+
prisma.workflow.findUnique.mockResolvedValue(mockWorkflow);
255+
mockIsAuthorized.mockResolvedValue(true);
256+
prisma.workflowReminder.findMany.mockResolvedValue(mockReminders);
257+
prisma.workflow.deleteMany.mockResolvedValue({ count: 1 });
258+
259+
const result = await deleteHandler({ ctx: mockCtx, input: { id: workflowId } });
260+
261+
expect(prisma.workflowReminder.findMany).toHaveBeenCalledWith({
262+
where: {
263+
workflowStep: {
264+
workflowId: workflowId,
265+
},
266+
scheduled: true,
267+
NOT: {
268+
referenceId: null,
269+
},
270+
},
271+
});
272+
273+
expect(mockDeleteAllWorkflowReminders).toHaveBeenCalledWith(mockReminders);
274+
275+
expect(mockRemoveSmsReminderFieldForEventTypes).toHaveBeenCalled();
276+
expect(mockRemoveAIAgentCallPhoneNumberFieldForEventTypes).toHaveBeenCalled();
277+
278+
expect(prisma.workflow.deleteMany).toHaveBeenCalledWith({
279+
where: {
280+
id: workflowId,
281+
},
282+
});
283+
284+
expect(result).toEqual({ id: workflowId });
285+
});
286+
});
287+
});

packages/trpc/server/routers/viewer/workflows/delete.handler.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@ import type { TrpcSessionUser } from "@calcom/trpc/server/types";
77
import { TRPCError } from "@trpc/server";
88

99
import type { TDeleteInputSchema } from "./delete.schema";
10-
import { isAuthorized, removeSmsReminderFieldForEventTypes } from "./util";
10+
import {
11+
isAuthorized,
12+
removeSmsReminderFieldForEventTypes,
13+
removeAIAgentCallPhoneNumberFieldForEventTypes,
14+
} from "./util";
1115

1216
type DeleteOptions = {
1317
ctx: {
@@ -138,6 +142,11 @@ export const deleteHandler = async ({ ctx, input }: DeleteOptions) => {
138142
: workflowToDelete.activeOn.map((activeOn) => activeOn.eventTypeId);
139143

140144
await removeSmsReminderFieldForEventTypes({ activeOnToRemove, workflowId: workflowToDelete.id, isOrg });
145+
await removeAIAgentCallPhoneNumberFieldForEventTypes({
146+
activeOnToRemove,
147+
workflowId: workflowToDelete.id,
148+
isOrg,
149+
});
141150

142151
// automatically deletes all steps and reminders connected to this workflow
143152
await prisma.workflow.deleteMany({

0 commit comments

Comments
 (0)