From 9e1e50e90bf8372c78d6d6dfe1190e514f04e16e Mon Sep 17 00:00:00 2001 From: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com> Date: Tue, 11 Nov 2025 14:24:31 +0100 Subject: [PATCH] feat: cal.ai form triggers #4 (#23587) * add trigger * small fixes * add missing workflow DTOs * small fixes * use activeOnWithChildren * fix active on when switching trigger type * remove add variable dropdown * feat: lang support * fix: type errors * feat: select voice agent * refactor: address feedback * refactor: address feedback * refactor: missing import * fix: types * add getAllWorkflowsFromRoutingForm to WorkflowService * fix error caused by undefined evt * fix type error * fix type error * fix tests * feat: add inbound calls * chore: formatting * chore * feat: finish inbound call * chore: formatting * fix: update bug * fix: types * code clean up * final fixes and clean up * remove console.log * remove template text form from triggers * add routing form repoditory function * refactor: Agent Configuration Sheet (#23930) * refactor: agent configuration sheet * chore: use default phone numbre * refactor: improvements * refactor: improvements * fix: types * fix: feedback * fix bug with key * chore: * fix: feedback * fix: prompt * add comments * fix: review * fix: review * refactor: class * refactor: class * fix test * allow cal ai action on form triggers * move any reusable code to scheduleAIPhoneCall * add missing await * use predefined FormSubmissionData type * add .trim() to sms message * pass contextData instead * finish base setup * add missing trigger in update-workflow.input.ts * allow cal.ai action for form triggers in handler * chore: add support for form workflows on api v2 * fixup! chore: add support for form workflows on api v2 * ai phone call on form submissions (WIP) * use existing type for Option array * pass chosen event type id * refactor: rename * Update apps/web/public/static/locales/en/common.json * Update apps/web/public/static/locales/en/common.json * add missing imports * chore: update set value * fix: remove index * fix: type error * fix: update tetss * use only repository functions in update handler * move all prisma queries from list.handler * review suggestions * fix: use logger * chore: handle workflows api v2 * chore: handle workflows api v2, split in 2 endpoints * fix workflow step creation * remove connect agent and fixes types * add type to workflow * chore: use workflow type in apiv2 WorkflowsOutputService * update worklfow type on update * chore: use workflow type in apiv2 WorkflowsOutputService * fix template body for torm trigger * some UI fixes for email subject/body * resetting email body when changing form triggers * use type field to query workflows * clean up all old active on values * remove responseId from all funciton calls * remove undefined from updateTemplate * refactor: don't use static * fix: type * refactor: split routing form and event-type workflows code * refactor: split routing form and event-type workflows code * fix template text when adding action * chore: don't rename WorkflowActivationDto to avoid ci blocking * refine update schedule to use only allowed actions * fix type error * don't allow whatsapp action with form trigger * fix type error * return early if activeOn array is empty * fix: from step type in BaseFormWorkflowStepDto * fixup! fix: from step type in BaseFormWorkflowStepDto * api v2 updates * move all prisma calls to repository (service/workflows.ts) * use FORM_TRIGGER_WORKFLOW_EVENTS for form queries * use userRepository * use FORM_TRIGGER_WORKFLOW_EVENTS in isFormTrigger * code clean up * code clean up * use repository functions in formSubmissionValidation.ts * fix: schema * refactor: * remove action check in update handler * add event type selection * event type selector improvements * adjust update.handler * set outboundEventTypeId * add back trpc import * fix agent repository functions * clean up * fix bugs caused by merge * pass eventTypeId to updateToolsFromAgentId * add migration for outboundEventTypeId * add SMS actions to allowed form action constants * add cal ai to allowed form actions * pick correct event type for web call * pass correct routed event type id * remove unsued import * fixes for offset api v2 * add missing responseId * fix failing test * fix failing test * improve error message * remove unused imports * chore: handle sms step action for form worklfow in dtos * fix typo * missing missing newStep * minor fixes * remove changes * add routedEventTypeId * fix type error * fix type error * fix typ error in executAPIPhoneCall.tsx * add back inboundEventTypeId * remove console.log * remove outdated code * small fixes * don't throw error for missing phone number * add back filtered triggerOptions * fix eventTypeId in testCall handler * fix type error * update migration * fix trigger is not defined * convert eventTypeId to string * only use outboundEventTypeId for FORM_SUBMITTED trigger * show toast when no event type selected * fix type errors * add missing translation * fix type error * remove callType * fix tests * small fixes * clean up AgentConfigurationSheet * remove EventTypeSelector file * code clean up * clean up * clean up * use resusable function for TestPhoneCallDialog and WebCallDialog * rename result * fix types for event type id * use repository runction in workflowReminder.ts * fix type error * pass eventTypeIds correctly * fix typo * Update apps/web/public/static/locales/en/common.json Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> * use watch instead of getValues * change to z.record(z.unknown()) instead of any() * fix type of eventTypeId * check permissinon for outBoundEventTypeId * add isNaN check * improve function name * update tools when outbound agent event type id changes * pass missing outboundEventTypeId * update migration * fix test * remove cal-ai step from test --------- Co-authored-by: CarinaWolli Co-authored-by: Udit Takkar Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> Co-authored-by: Benny Joo Co-authored-by: cal.com Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com> Co-authored-by: Peer Richelsen --- apps/web/public/static/locales/en/common.json | 5 + .../lib/formSubmissionUtils.test.ts | 120 ++++++-- .../routing-forms/lib/formSubmissionUtils.ts | 8 +- .../interfaces/AIPhoneService.interface.ts | 2 + .../adapters/PrismaAgentRepositoryAdapter.ts | 5 + .../interfaces/AgentRepositoryInterface.ts | 6 + .../retellAI/RetellAIPhoneServiceProvider.ts | 2 + .../providers/retellAI/RetellAIService.ts | 2 + .../retellAI/RetellAIServiceMapper.ts | 1 + .../retellAI/services/AgentService.ts | 41 +++ .../calAIPhone/providers/retellAI/types.ts | 1 + .../components/TestPhoneCallDialog.tsx | 29 +- .../ee/workflows/components/WebCallDialog.tsx | 21 +- .../components/WorkflowDetailsPage.tsx | 21 +- .../components/WorkflowStepContainer.tsx | 176 ++++++------ .../AgentConfigurationSheet.tsx | 17 +- .../components/tabs/IncomingCallsTab.tsx | 22 +- .../components/tabs/OutgoingCallsTab.tsx | 39 ++- .../components/tabs/PhoneNumberTab.tsx | 13 + .../hooks/useAgentForms.ts | 1 + .../agent-configuration/types/schemas.ts | 1 + .../ee/workflows/lib/actionHelperFunctions.ts | 39 +++ .../features/ee/workflows/lib/constants.ts | 1 + .../lib/reminders/aiPhoneCallManager.ts | 194 +++++++++++-- .../lib/reminders/emailReminderManager.ts | 10 +- .../lib/reminders/reminderScheduler.ts | 23 +- .../lib/repository/workflowReminder.ts | 71 +++++ .../workflows/lib/service/WorkflowService.ts | 4 + .../features/ee/workflows/pages/workflow.tsx | 1 + packages/features/tasker/tasker.ts | 6 +- .../tasker/tasks/executeAIPhoneCall.ts | 266 ++++++++++-------- .../formSubmissionValidation.ts | 37 ++- .../triggerFormSubmittedNoEventWebhook.ts | 6 +- ...riggerFormSubmittedNoEventWorkflow.test.ts | 3 + .../triggerFormSubmittedNoEventWorkflow.ts | 21 +- .../repository/PrismaAgentRepository.ts | 24 +- .../migration.sql | 5 + packages/prisma/schema.prisma | 4 + .../viewer/aiVoiceAgent/update.handler.ts | 12 +- .../viewer/aiVoiceAgent/update.schema.ts | 1 + .../server/routers/viewer/workflows/util.ts | 2 + 41 files changed, 936 insertions(+), 327 deletions(-) create mode 100644 packages/prisma/migrations/20251110160435_add_outbound_event_type_id_to_agent/migration.sql diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 9b13c3ebf2..7c68cfd51c 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -2853,7 +2853,9 @@ "call_to_confirm_booking": "Call to confirm booking", "cal_ai_phone_call_action_description": "2 hrs before event starts", "cal_ai_agent_configuration": "Cal.ai Agent Configuration", + "select_event_type_to_schedule_calls": "Please select an event type to schedule calls", "choose_at_least_one_event_type_test_call": "Please choose at least one event type to make a test call.", + "choose_event_type_in_agent_setup": "Please choose an event type in the agent setup.", "mark_as_no_show": "Mark as no-show", "unmark_as_no_show": "Unmark no-show", "account_unlinked_success": "Account unlinked successfully", @@ -3865,7 +3867,10 @@ "please_select_event_type_first": "Please select an event type first", "edit_configuration": "Edit Configuration", "select_event_type_for_inbound_calls": "Inbound calls can book only one event type. Select the event type where meetings will be scheduled when callers reach your agent.", + "select_event_type_for_outbound_calls": "Select the event type that should be used to schedule meetings.", "setup_inbound_agent_for_incoming_calls": "Set up inbound agent for incoming calls", + "no_event_types_available_for_test_call": "No event types available for a test call", + "create_block_entry": "Create Block Entry", "blocklist_entry_details": "Blocklist Entry Details", "blocklist_entry_not_found": "Blocklist entry not found", "blocklist_entry_created": "Blocklist entry created successfully", diff --git a/packages/app-store/routing-forms/lib/formSubmissionUtils.test.ts b/packages/app-store/routing-forms/lib/formSubmissionUtils.test.ts index b2b77bb137..16a00c943d 100644 --- a/packages/app-store/routing-forms/lib/formSubmissionUtils.test.ts +++ b/packages/app-store/routing-forms/lib/formSubmissionUtils.test.ts @@ -4,6 +4,8 @@ import { prisma } from "@calcom/prisma/__mocks__/prisma"; import { describe, it, vi, expect, beforeEach, afterEach } from "vitest"; import { WorkflowService } from "@calcom/features/ee/workflows/lib/service/WorkflowService"; +import type { Workflow } from "@calcom/features/ee/workflows/lib/types"; +import type { GetWebhooksReturnType } from "@calcom/features/webhooks/lib/getWebhooks"; import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks"; import { sendGenericWebhookPayload } from "@calcom/features/webhooks/lib/sendPayload"; import { @@ -11,8 +13,10 @@ import { WorkflowTriggerEvents, WorkflowActions, WorkflowTemplates, + TimeUnit, } from "@calcom/prisma/enums"; +import type { FormResponse, Field } from "../types/types"; import { _onFormSubmission } from "./formSubmissionUtils"; vi.mock("@calcom/prisma", () => ({ @@ -59,16 +63,27 @@ describe("_onFormSubmission", () => { const mockForm = { id: "form-1", name: "Test Form", + disabled: false, + userId: 1, + position: 0, + description: null, + updatedById: null, fields: [ - { id: "field-1", identifier: "email", label: "Email", type: "email" }, - { id: "field-2", identifier: "name", label: "Name", type: "text" }, - ], + { id: "field-1", identifier: "email", label: "Email", type: "email", required: false }, + { id: "field-2", identifier: "name", label: "Name", type: "text", required: false }, + ] as Field[], user: { id: 1, email: "test@example.com", timeFormat: 12, locale: "en" }, teamId: null, settings: { emailOwnerOnSubmission: true }, + routes: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + connectedForms: [], + routers: [], + teamMembers: [], }; - const mockResponse = { + const mockResponse: FormResponse = { "field-1": { label: "Email", value: "test@response.com" }, "field-2": { label: "Name", value: "Test Name" }, }; @@ -85,9 +100,19 @@ describe("_onFormSubmission", () => { describe("Webhooks", () => { it("should call FORM_SUBMITTED webhooks", async () => { - vi.mocked(getWebhooks).mockResolvedValueOnce([{ id: "wh-1", secret: "secret" } as any]); + const mockWebhook: GetWebhooksReturnType[number] = { + id: "wh-1", + secret: "secret", + subscriberUrl: "https://example.com/webhook", + payloadTemplate: null, + appId: null, + eventTriggers: [WebhookTriggerEvents.FORM_SUBMITTED], + time: null, + timeUnit: null, + }; + vi.mocked(getWebhooks).mockResolvedValueOnce([mockWebhook]); - await _onFormSubmission(mockForm as any, mockResponse, responseId); + await _onFormSubmission(mockForm, mockResponse, responseId); expect(getWebhooks).toHaveBeenCalledWith({ userId: 1, @@ -101,7 +126,7 @@ describe("_onFormSubmission", () => { describe("Workflows", () => { it("should call WorkflowService.scheduleFormWorkflows for FORM_SUBMITTED workflows", async () => { - const mockWorkflows = [ + const mockWorkflows: Workflow[] = [ { id: 1, name: "Form Submitted Workflow", @@ -115,6 +140,7 @@ describe("_onFormSubmission", () => { id: 1, action: WorkflowActions.EMAIL_ATTENDEE, sendTo: null, + sender: null, reminderBody: "Thank you for your submission!", emailSubject: "Form Received", template: WorkflowTemplates.CUSTOM, @@ -127,9 +153,9 @@ describe("_onFormSubmission", () => { }, ]; - vi.mocked(WorkflowService.getAllWorkflowsFromRoutingForm).mockResolvedValueOnce(mockWorkflows as any); + vi.mocked(WorkflowService.getAllWorkflowsFromRoutingForm).mockResolvedValueOnce(mockWorkflows); - await _onFormSubmission(mockForm as any, mockResponse, responseId); + await _onFormSubmission(mockForm, mockResponse, responseId); expect(WorkflowService.getAllWorkflowsFromRoutingForm).toHaveBeenCalledWith(mockForm); expect(WorkflowService.scheduleFormWorkflows).toHaveBeenCalledWith({ @@ -142,6 +168,7 @@ describe("_onFormSubmission", () => { name: { value: "Test Name", response: "Test Name" }, }, responseId, + routedEventTypeId: null, form: { ...mockForm, fields: mockForm.fields.map((field) => ({ @@ -153,7 +180,7 @@ describe("_onFormSubmission", () => { }); it("should call WorkflowService.scheduleFormWorkflows for FORM_SUBMITTED_NO_EVENT workflows", async () => { - const mockWorkflows = [ + const mockWorkflows: Workflow[] = [ { id: 2, name: "Form Follow-up Workflow", @@ -161,12 +188,13 @@ describe("_onFormSubmission", () => { teamId: null, trigger: WorkflowTriggerEvents.FORM_SUBMITTED_NO_EVENT, time: 30, - timeUnit: "MINUTE", + timeUnit: TimeUnit.MINUTE, steps: [ { id: 2, action: WorkflowActions.EMAIL_ATTENDEE, sendTo: null, + sender: null, reminderBody: "Follow up on your form submission", emailSubject: "Follow Up", template: WorkflowTemplates.CUSTOM, @@ -179,9 +207,9 @@ describe("_onFormSubmission", () => { }, ]; - vi.mocked(WorkflowService.getAllWorkflowsFromRoutingForm).mockResolvedValueOnce(mockWorkflows as any); + vi.mocked(WorkflowService.getAllWorkflowsFromRoutingForm).mockResolvedValueOnce(mockWorkflows); - await _onFormSubmission(mockForm as any, mockResponse, responseId); + await _onFormSubmission(mockForm, mockResponse, responseId); expect(WorkflowService.getAllWorkflowsFromRoutingForm).toHaveBeenCalledWith(mockForm); expect(WorkflowService.scheduleFormWorkflows).toHaveBeenCalledWith({ @@ -193,6 +221,66 @@ describe("_onFormSubmission", () => { }, name: { value: "Test Name", response: "Test Name" }, }, + routedEventTypeId: null, + responseId, + form: { + ...mockForm, + fields: mockForm.fields.map((field) => ({ + type: field.type, + identifier: field.identifier, + })), + }, + }); + }); + + it("should pass routedEventTypeId when chosenAction is eventTypeRedirectUrl", async () => { + const mockWorkflows: Workflow[] = [ + { + id: 3, + name: "Event Type Workflow", + userId: 1, + teamId: null, + trigger: WorkflowTriggerEvents.FORM_SUBMITTED, + time: null, + timeUnit: null, + steps: [ + { + id: 3, + action: WorkflowActions.CAL_AI_PHONE_CALL, + sendTo: null, + sender: null, + reminderBody: null, + emailSubject: null, + template: WorkflowTemplates.CUSTOM, + verifiedAt: new Date(), + includeCalendarEvent: false, + numberVerificationPending: false, + numberRequired: false, + }, + ], + }, + ]; + + const chosenAction = { + type: "eventTypeRedirectUrl" as const, + value: "/team/test-team/test-event", + eventTypeId: 42, + }; + + vi.mocked(WorkflowService.getAllWorkflowsFromRoutingForm).mockResolvedValueOnce(mockWorkflows); + + await _onFormSubmission(mockForm, mockResponse, responseId, chosenAction); + + expect(WorkflowService.scheduleFormWorkflows).toHaveBeenCalledWith({ + workflows: mockWorkflows, + responses: { + email: { + value: "test@response.com", + response: "test@response.com", + }, + name: { value: "Test Name", response: "Test Name" }, + }, + routedEventTypeId: 42, responseId, form: { ...mockForm, @@ -214,7 +302,7 @@ describe("_onFormSubmission", () => { user: { id: 1, email: "test@example.com", timeFormat: 12, locale: "en" }, }; - await _onFormSubmission(teamForm as any, mockResponse, responseId); + await _onFormSubmission(teamForm, mockResponse, responseId); expect(mockResponseEmailConstructor).toHaveBeenCalledWith({ form: teamForm, @@ -230,7 +318,7 @@ describe("_onFormSubmission", () => { settings: { emailOwnerOnSubmission: true }, }; - await _onFormSubmission(ownerForm as any, mockResponse, responseId); + await _onFormSubmission(ownerForm, mockResponse, responseId); expect(mockResponseEmailConstructor).toHaveBeenCalledWith({ form: ownerForm, @@ -246,7 +334,7 @@ describe("_onFormSubmission", () => { settings: { emailOwnerOnSubmission: false }, }; - await _onFormSubmission(ownerForm as any, mockResponse, responseId); + await _onFormSubmission(ownerForm, mockResponse, responseId); expect(mockResponseEmailConstructor).not.toHaveBeenCalled(); expect(mockSendEmail).not.toHaveBeenCalled(); diff --git a/packages/app-store/routing-forms/lib/formSubmissionUtils.ts b/packages/app-store/routing-forms/lib/formSubmissionUtils.ts index 0c2e8172b0..965eb0ba79 100644 --- a/packages/app-store/routing-forms/lib/formSubmissionUtils.ts +++ b/packages/app-store/routing-forms/lib/formSubmissionUtils.ts @@ -125,6 +125,7 @@ export async function _onFormSubmission( chosenAction?: { type: "customPageMessage" | "externalRedirectUrl" | "eventTypeRedirectUrl"; value: string; + eventTypeId?: number; } ) { const fieldResponsesByIdentifier: FORM_SUBMITTED_WEBHOOK_RESPONSES = {}; @@ -219,11 +220,15 @@ export async function _onFormSubmission( await Promise.all(promises); const workflows = await WorkflowService.getAllWorkflowsFromRoutingForm(form); - + const routedEventTypeId: number | null = + chosenAction && chosenAction.type === "eventTypeRedirectUrl" && chosenAction.eventTypeId + ? chosenAction.eventTypeId + : null; await WorkflowService.scheduleFormWorkflows({ workflows, responseId, responses: fieldResponsesByIdentifier, + routedEventTypeId, form: { ...form, fields: form.fields.map((field) => ({ @@ -287,6 +292,7 @@ export const onSubmissionOfFormResponse = async ({ chosenRouteAction: { type: "customPageMessage" | "externalRedirectUrl" | "eventTypeRedirectUrl"; value: string; + eventTypeId?: number; } | null; }) => { if (!form.fields) { diff --git a/packages/features/calAIPhone/interfaces/AIPhoneService.interface.ts b/packages/features/calAIPhone/interfaces/AIPhoneService.interface.ts index 5be00ba24a..67c1010a32 100644 --- a/packages/features/calAIPhone/interfaces/AIPhoneService.interface.ts +++ b/packages/features/calAIPhone/interfaces/AIPhoneService.interface.ts @@ -331,6 +331,8 @@ export interface AIPhoneServiceProvider; voiceId?: string; language?: string; + outboundEventTypeId?: number; + timeZone?: string; }): Promise<{ message: string }>; /** diff --git a/packages/features/calAIPhone/providers/adapters/PrismaAgentRepositoryAdapter.ts b/packages/features/calAIPhone/providers/adapters/PrismaAgentRepositoryAdapter.ts index 101cdf397e..4285e4e2be 100644 --- a/packages/features/calAIPhone/providers/adapters/PrismaAgentRepositoryAdapter.ts +++ b/packages/features/calAIPhone/providers/adapters/PrismaAgentRepositoryAdapter.ts @@ -90,4 +90,9 @@ export class PrismaAgentRepositoryAdapter implements AgentRepositoryInterface { const agentRepo = new PrismaAgentRepository(prisma); await agentRepo.linkInboundAgentToWorkflow(params); } + + async updateOutboundEventTypeId(params: { agentId: string; eventTypeId: number }): Promise { + const agentRepo = new PrismaAgentRepository(prisma); + await agentRepo.updateOutboundEventTypeId(params); + } } diff --git a/packages/features/calAIPhone/providers/interfaces/AgentRepositoryInterface.ts b/packages/features/calAIPhone/providers/interfaces/AgentRepositoryInterface.ts index 5452c314ad..9cede3aa38 100644 --- a/packages/features/calAIPhone/providers/interfaces/AgentRepositoryInterface.ts +++ b/packages/features/calAIPhone/providers/interfaces/AgentRepositoryInterface.ts @@ -74,6 +74,11 @@ export interface AgentRepositoryInterface { * Link inbound agent to workflow */ linkInboundAgentToWorkflow(params: { workflowStepId: number; agentId: string }): Promise; + + /** + * Update outbound event type ID for an agent + */ + updateOutboundEventTypeId(params: { agentId: string; eventTypeId: number }): Promise; } /** @@ -87,6 +92,7 @@ export interface AgentData { userId: number | null; teamId: number | null; inboundEventTypeId?: number | null; + outboundEventTypeId?: number | null; createdAt: Date; updatedAt: Date; } diff --git a/packages/features/calAIPhone/providers/retellAI/RetellAIPhoneServiceProvider.ts b/packages/features/calAIPhone/providers/retellAI/RetellAIPhoneServiceProvider.ts index 533ba682a6..44294ebf16 100644 --- a/packages/features/calAIPhone/providers/retellAI/RetellAIPhoneServiceProvider.ts +++ b/packages/features/calAIPhone/providers/retellAI/RetellAIPhoneServiceProvider.ts @@ -264,6 +264,8 @@ export class RetellAIPhoneServiceProvider generalTools?: AIPhoneServiceTools; voiceId?: string; language?: Language; + outboundEventTypeId?: number; + timeZone?: string; }): Promise<{ message: string }> { return await this.service.updateAgentConfiguration(params); } diff --git a/packages/features/calAIPhone/providers/retellAI/RetellAIService.ts b/packages/features/calAIPhone/providers/retellAI/RetellAIService.ts index e75dd7e1aa..7df281dcb6 100644 --- a/packages/features/calAIPhone/providers/retellAI/RetellAIService.ts +++ b/packages/features/calAIPhone/providers/retellAI/RetellAIService.ts @@ -199,6 +199,8 @@ export class RetellAIService { generalTools?: RetellLLMGeneralTools; voiceId?: string; language?: Language; + outboundEventTypeId?: number; + timeZone?: string; }) { return this.agentService.updateAgentConfiguration({ ...params, diff --git a/packages/features/calAIPhone/providers/retellAI/RetellAIServiceMapper.ts b/packages/features/calAIPhone/providers/retellAI/RetellAIServiceMapper.ts index 4800dca139..4585dd3179 100644 --- a/packages/features/calAIPhone/providers/retellAI/RetellAIServiceMapper.ts +++ b/packages/features/calAIPhone/providers/retellAI/RetellAIServiceMapper.ts @@ -174,6 +174,7 @@ export class RetellAIServiceMapper { userId: agent.userId, teamId: agent.teamId, inboundEventTypeId: agent.inboundEventTypeId, + outboundEventTypeId: agent.outboundEventTypeId, outboundPhoneNumbers: agent.outboundPhoneNumbers, retellData: { agentId: retellAgent.agent_id, diff --git a/packages/features/calAIPhone/providers/retellAI/services/AgentService.ts b/packages/features/calAIPhone/providers/retellAI/services/AgentService.ts index 871d64342e..f05e5af6b2 100644 --- a/packages/features/calAIPhone/providers/retellAI/services/AgentService.ts +++ b/packages/features/calAIPhone/providers/retellAI/services/AgentService.ts @@ -2,11 +2,13 @@ import { isValidPhoneNumber } from "libphonenumber-js/max"; import { v4 as uuidv4 } from "uuid"; import { replaceEventTypePlaceholders } from "@calcom/features/ee/workflows/components/agent-configuration/utils/promptUtils"; +import { EventTypeRepository } from "@calcom/features/eventtypes/repositories/eventTypeRepository"; import { RETELL_AI_TEST_MODE, RETELL_AI_TEST_EVENT_TYPE_MAP } from "@calcom/lib/constants"; import { timeZoneSchema } from "@calcom/lib/dayjs/timeZone.schema"; import { HttpError } from "@calcom/lib/http-error"; import logger from "@calcom/lib/logger"; import { PrismaApiKeyRepository } from "@calcom/lib/server/repository/PrismaApiKeyRepository"; +import prisma from "@calcom/prisma"; import type { AIPhoneServiceUpdateModelParams, @@ -623,6 +625,8 @@ export class AgentService { generalTools, voiceId, language, + outboundEventTypeId, + timeZone, updateLLMConfiguration, }: { id: string; @@ -634,6 +638,8 @@ export class AgentService { generalTools?: AIPhoneServiceTools; voiceId?: string; language?: Language; + outboundEventTypeId?: number; + timeZone?: string; updateLLMConfiguration: ( llmId: string, data: AIPhoneServiceUpdateModelParams @@ -712,6 +718,41 @@ export class AgentService { } } + if (outboundEventTypeId && agent.outboundEventTypeId !== outboundEventTypeId) { + const eventTypeRepository = new EventTypeRepository(prisma); + + const outBoundEventType = await eventTypeRepository.findByIdMinimal({ + id: outboundEventTypeId, + }); + + if (!outBoundEventType) { + throw new HttpError({ + statusCode: 404, + message: "Event type not found.", + }); + } + + if (userId !== outBoundEventType.userId && teamId !== outBoundEventType.teamId) { + throw new HttpError({ + statusCode: 403, + message: "You don't have permission to use this event type.", + }); + } + + const userTimeZone = timeZone || "UTC"; + await this.updateToolsFromAgentId(agent.providerAgentId, { + eventTypeId: outboundEventTypeId, + timeZone: userTimeZone, + userId, + teamId, + }); + + await this.deps.agentRepository.updateOutboundEventTypeId({ + agentId: id, + eventTypeId: outboundEventTypeId, + }); + } + return { message: "Agent updated successfully" }; } diff --git a/packages/features/calAIPhone/providers/retellAI/types.ts b/packages/features/calAIPhone/providers/retellAI/types.ts index fe14777da8..be0a57b658 100644 --- a/packages/features/calAIPhone/providers/retellAI/types.ts +++ b/packages/features/calAIPhone/providers/retellAI/types.ts @@ -114,6 +114,7 @@ export type RetellAgentWithDetails = { userId: number | null; teamId: number | null; inboundEventTypeId?: number | null; + outboundEventTypeId?: number | null; outboundPhoneNumbers: Array<{ id: number; phoneNumber: string; diff --git a/packages/features/ee/workflows/components/TestPhoneCallDialog.tsx b/packages/features/ee/workflows/components/TestPhoneCallDialog.tsx index fa4fb921a9..83ca2892cf 100644 --- a/packages/features/ee/workflows/components/TestPhoneCallDialog.tsx +++ b/packages/features/ee/workflows/components/TestPhoneCallDialog.tsx @@ -11,6 +11,7 @@ import { Label } from "@calcom/ui/components/form"; import { Icon } from "@calcom/ui/components/icon"; import { showToast } from "@calcom/ui/components/toast"; +import { getEventTypeIdForCalAiTest } from "../lib/actionHelperFunctions"; import type { FormValues } from "../pages/workflow"; interface TestPhoneCallDialogProps { @@ -19,9 +20,19 @@ interface TestPhoneCallDialogProps { agentId: string; teamId?: number; form: UseFormReturn; + eventTypeIds?: number[]; + outboundEventTypeId?: number | null; } -export function TestPhoneCallDialog({ open, onOpenChange, agentId, teamId, form }: TestPhoneCallDialogProps) { +export function TestPhoneCallDialog({ + open, + onOpenChange, + agentId, + teamId, + form, + eventTypeIds = [], + outboundEventTypeId, +}: TestPhoneCallDialogProps) { const { t } = useLocale(); const [testPhoneNumber, setTestPhoneNumber] = useState(""); @@ -41,9 +52,17 @@ export function TestPhoneCallDialog({ open, onOpenChange, agentId, teamId, form showToast(t("please_enter_phone_number"), "error"); return; } - const firstEventTypeId = form.getValues("activeOn")?.[0]?.value; - if (!firstEventTypeId) { - showToast(t("choose_at_least_one_event_type_test_call"), "error"); + + const eventTypeValidation = getEventTypeIdForCalAiTest({ + trigger: form.getValues("trigger"), + outboundEventTypeId, + eventTypeIds, + activeOnEventTypeId: form.getValues("activeOn")?.[0]?.value, + t, + }); + + if (eventTypeValidation.error || !eventTypeValidation.eventTypeId) { + showToast(eventTypeValidation.error || t("no_event_type_selected"), "error"); return; } @@ -52,7 +71,7 @@ export function TestPhoneCallDialog({ open, onOpenChange, agentId, teamId, form agentId: agentId, phoneNumber: testPhoneNumber, teamId: teamId, - eventTypeId: parseInt(firstEventTypeId, 10), + eventTypeId: eventTypeValidation.eventTypeId, }); } }; diff --git a/packages/features/ee/workflows/components/WebCallDialog.tsx b/packages/features/ee/workflows/components/WebCallDialog.tsx index 00614fe2f4..8e8acc4f0c 100644 --- a/packages/features/ee/workflows/components/WebCallDialog.tsx +++ b/packages/features/ee/workflows/components/WebCallDialog.tsx @@ -13,6 +13,7 @@ import { Icon } from "@calcom/ui/components/icon"; import { showToast } from "@calcom/ui/components/toast"; import { Tooltip } from "@calcom/ui/components/tooltip"; +import { getEventTypeIdForCalAiTest } from "../lib/actionHelperFunctions"; import type { FormValues } from "../pages/workflow"; interface WebCallDialogProps { @@ -22,6 +23,8 @@ interface WebCallDialogProps { teamId?: number; isOrganization?: boolean; form: UseFormReturn; + eventTypeIds?: number[]; + outboundEventTypeId?: number | null; } interface TranscriptEntry { @@ -39,6 +42,8 @@ export function WebCallDialog({ teamId, isOrganization = false, form, + eventTypeIds = [], + outboundEventTypeId, }: WebCallDialogProps) { const { t } = useLocale(); const [callStatus, setCallStatus] = useState("idle"); @@ -195,9 +200,16 @@ export function WebCallDialog({ }; const handleStartCall = () => { - const firstEventTypeId = form.getValues("activeOn")?.[0]?.value; - if (!firstEventTypeId) { - showToast(t("choose_at_least_one_event_type_test_call"), "error"); + const eventTypeValidation = getEventTypeIdForCalAiTest({ + trigger: form.getValues("trigger"), + outboundEventTypeId, + eventTypeIds, + activeOnEventTypeId: form.getValues("activeOn")?.[0]?.value, + t, + }); + + if (eventTypeValidation.error || !eventTypeValidation.eventTypeId) { + showToast(eventTypeValidation.error || t("no_event_type_selected"), "error"); return; } @@ -208,7 +220,7 @@ export function WebCallDialog({ createWebCallMutation.mutate({ agentId: agentId, teamId: teamId, - eventTypeId: parseInt(firstEventTypeId, 10), + eventTypeId: eventTypeValidation.eventTypeId, }); } }; @@ -278,7 +290,6 @@ export function WebCallDialog({ useEffect(() => { if (transcriptEndRef.current) { - // eslint-disable-next-line @calcom/eslint/no-scroll-into-view-embed transcriptEndRef.current.scrollIntoView({ behavior: "smooth" }); } }, [transcript]); diff --git a/packages/features/ee/workflows/components/WorkflowDetailsPage.tsx b/packages/features/ee/workflows/components/WorkflowDetailsPage.tsx index edd0f863ab..9c9976d7d5 100644 --- a/packages/features/ee/workflows/components/WorkflowDetailsPage.tsx +++ b/packages/features/ee/workflows/components/WorkflowDetailsPage.tsx @@ -10,15 +10,14 @@ import { useLocale } from "@calcom/lib/hooks/useLocale"; import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat"; import { WorkflowActions } from "@calcom/prisma/enums"; import { WorkflowTemplates } from "@calcom/prisma/enums"; -import type { RouterOutputs } from "@calcom/trpc/react"; -import { trpc } from "@calcom/trpc/react"; +import { trpc, type RouterOutputs } from "@calcom/trpc/react"; import { Button } from "@calcom/ui/components/button"; import { FormCard, FormCardBody } from "@calcom/ui/components/card"; import type { MultiSelectCheckboxesOptionType as Option } from "@calcom/ui/components/form"; import { Icon } from "@calcom/ui/components/icon"; import { useAgentsData } from "../hooks/useAgentsData"; -import { isCalAIAction, isFormTrigger, isSMSAction } from "../lib/actionHelperFunctions"; +import { isCalAIAction, isSMSAction, isFormTrigger, isWhatsappAction } from "../lib/actionHelperFunctions"; import { ALLOWED_FORM_WORKFLOW_ACTIONS } from "../lib/constants"; import emailReminderTemplate from "../lib/reminders/templates/emailReminderTemplate"; import type { FormValues } from "../pages/workflow"; @@ -36,13 +35,23 @@ interface Props { user: User; isOrg: boolean; allOptions: Option[]; + eventTypeOptions: Option[]; onSaveWorkflow?: () => Promise; permissions: WorkflowPermissions; } export default function WorkflowDetailsPage(props: Props) { - const { form, workflowId, selectedOptions, setSelectedOptions, teamId, isOrg, allOptions, permissions } = - props; + const { + form, + workflowId, + selectedOptions, + setSelectedOptions, + teamId, + isOrg, + allOptions, + eventTypeOptions, + permissions, + } = props; const { t, i18n } = useLocale(); const { hasActiveTeamPlan } = useHasActiveTeamPlan(); @@ -194,6 +203,7 @@ export default function WorkflowDetailsPage(props: Props) { setSelectedOptions={setSelectedOptions} isOrganization={isOrg} allOptions={allOptions} + eventTypeOptions={eventTypeOptions} onSaveWorkflow={props.onSaveWorkflow} actionOptions={transformedActionOptions} updateTemplate={updateTemplate} @@ -267,6 +277,7 @@ export default function WorkflowDetailsPage(props: Props) { setReload={setReload} teamId={teamId} readOnly={permissions.readOnly} + eventTypeOptions={eventTypeOptions} onSaveWorkflow={props.onSaveWorkflow} setIsDeleteStepDialogOpen={setIsDeleteStepDialogOpen} isDeleteStepDialogOpen={isDeleteStepDialogOpen} diff --git a/packages/features/ee/workflows/components/WorkflowStepContainer.tsx b/packages/features/ee/workflows/components/WorkflowStepContainer.tsx index 8c6e32ba58..8938afec89 100644 --- a/packages/features/ee/workflows/components/WorkflowStepContainer.tsx +++ b/packages/features/ee/workflows/components/WorkflowStepContainer.tsx @@ -89,6 +89,7 @@ type WorkflowStepProps = { setSelectedOptions?: Dispatch>; isOrganization?: boolean; allOptions?: Option[]; + eventTypeOptions?: Option[]; onSaveWorkflow?: () => Promise; setIsDeleteStepDialogOpen?: Dispatch>; isDeleteStepDialogOpen?: boolean; @@ -122,19 +123,19 @@ const getTimeSectionText = (trigger: WorkflowTriggerEvents, t: TFunction) => { const CalAIAgentDataSkeleton = () => { return ( -
-
+
+
- -
- - - + +
+ + +
-
- - +
+ +
@@ -380,11 +381,10 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) { name: "steps", }); - const hasAiAction = hasCalAIAction(steps); const hasEmailToHostAction = steps.some((s) => s.action === WorkflowActions.EMAIL_HOST); const hasWhatsappAction = steps.some((s) => isWhatsappAction(s.action)); - const disallowFormTriggers = hasAiAction || hasEmailToHostAction || hasWhatsappAction; + const disallowFormTriggers = hasEmailToHostAction || hasWhatsappAction; const filteredTriggerOptions = triggerOptions.filter( (option) => !(isFormTrigger(option.value) && disallowFormTriggers) @@ -518,7 +518,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) { return ( <>
- +
{!!timeSectionText && ( -
- +
+
)} @@ -610,7 +610,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) { {selectedOptions && setSelectedOptions && allOptions && (
{isOrganization ? ( -
+
@@ -677,7 +677,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
)} {!!timeSectionText && ( -
+

{t("testing_sms_workflow_info_message")}

@@ -824,12 +824,12 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) { />
-
{t("sender_id_info")}
+
{t("sender_id_info")}
{form.formState.errors.steps && form.formState?.errors?.steps[step.stepNumber - 1]?.sender && ( -

{t("sender_id_error_message")}

+

{t("sender_id_error_message")}

)} ) : ( @@ -848,23 +848,23 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
)} {isCalAIAction(form.getValues(`steps.${step.stepNumber - 1}.action`)) && !stepAgentId && ( -
-
+
+
-

+

{t("cal_ai_agent")} - + {t("set_up_required")}

-

+

{t("no_phone_number_connected")}.

{form.formState.errors.steps && form.formState?.errors?.steps[step.stepNumber - 1]?.sendTo && ( -

+

{form.formState?.errors?.steps[step.stepNumber - 1]?.sendTo?.message || ""}

)} @@ -1185,9 +1185,9 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) { ) : ( !props.readOnly && ( <> -
+
-
+
{form.formState.errors.steps && form.formState?.errors?.steps[step.stepNumber - 1]?.sendTo && ( -

+

{form.formState?.errors?.steps[step.stepNumber - 1]?.sendTo?.message || ""}

)} @@ -1304,7 +1304,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
)} {!isCalAIAction(form.getValues(`steps.${step.stepNumber - 1}.action`)) && ( -
+
{isEmailSubjectNeeded && (
@@ -1337,14 +1337,14 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) { /> {form.formState.errors.steps && form.formState?.errors?.steps[step.stepNumber - 1]?.emailSubject && ( -

+

{form.formState?.errors?.steps[step.stepNumber - 1]?.emailSubject?.message || ""}

)}
)} -
-
)} {!props.readOnly && !isFormTrigger(trigger) && ( -
+
@@ -1512,23 +1512,23 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {

{t("how_booking_questions_as_variables")}

-
-

{t("format")}

-
    +
    +

    {t("format")}

    +
    • {t("uppercase_for_letters")}
    • {t("replace_whitespaces_underscores")}
    • {t("ignore_special_characters_booking_questions")}
    -

    {t("example_1")}

    -
    -
    +

    {t("example_1")}

    +
    +
    {t("booking_question_identifier")}
    -
    {t("company_size")}
    -
    {t("variable")}
    +
    {t("company_size")}
    +
    {t("variable")}
    -
    +
    {" "} {`{${t("company_size") .replace(/[^a-zA-Z0-9 ]/g, "") @@ -1539,14 +1539,14 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
    -

    {t("example_2")}

    -
    -
    +

    {t("example_2")}

    +
    +
    {t("booking_question_identifier")}
    -
    {t("what_help_needed")}
    -
    {t("variable")}
    -
    +
    {t("what_help_needed")}
    +
    {t("variable")}
    +
    {" "} {`{${t("what_help_needed") .replace(/[^a-zA-Z0-9 ]/g, "") @@ -1587,7 +1587,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) { workflowId={params?.workflow as string} workflowStepId={step?.id} form={form} - eventTypeOptions={props.allOptions} + eventTypeOptions={props.eventTypeOptions} /> )} @@ -1598,6 +1598,8 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) { agentId={stepAgentId || ""} teamId={teamId} form={form} + eventTypeIds={props.eventTypeOptions?.map((opt) => parseInt(opt.value, 10))} + outboundEventTypeId={agentData?.outboundEventTypeId} /> )} @@ -1609,6 +1611,8 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) { teamId={teamId} isOrganization={props.isOrganization} form={form} + eventTypeIds={props.eventTypeOptions?.map((opt) => parseInt(opt.value, 10)) || []} + outboundEventTypeId={agentData?.outboundEventTypeId} /> )} @@ -1616,17 +1620,17 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
    -

    {t("do_you_still_want_to_unsubscribe")}

    +

    {t("do_you_still_want_to_unsubscribe")}

    {getActivePhoneNumbers( agentData?.outboundPhoneNumbers?.map((phone) => ({ ...phone, subscriptionStatus: phone.subscriptionStatus ?? undefined, })) ).length > 0 && ( -
    -
    - - +
    +
    + + {formatPhoneNumber( getActivePhoneNumbers( agentData?.outboundPhoneNumbers?.map((phone) => ({ @@ -1639,7 +1643,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
    )} -

    {t("the_action_will_disconnect_phone_number")}

    +

    {t("the_action_will_disconnect_phone_number")}