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 <wollencarina@gmail.com>
Co-authored-by: Udit Takkar <udit222001@gmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Benny Joo <sldisek783@gmail.com>
Co-authored-by: cal.com <morgan@cal.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
This commit is contained in:
Carina Wollendorfer
2025-11-11 15:24:31 +02:00
committed by GitHub
co-authored by Udit Takkar CarinaWolli Udit Takkar Benny Joo cal.com Morgan Peer Richelsen
parent a73b804d48
commit 9e1e50e90b
41 changed files with 936 additions and 327 deletions
@@ -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",
@@ -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();
@@ -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) {
@@ -331,6 +331,8 @@ export interface AIPhoneServiceProvider<T extends AIPhoneServiceProviderType = A
generalTools?: AIPhoneServiceTools<T>;
voiceId?: string;
language?: string;
outboundEventTypeId?: number;
timeZone?: string;
}): Promise<{ message: string }>;
/**
@@ -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<void> {
const agentRepo = new PrismaAgentRepository(prisma);
await agentRepo.updateOutboundEventTypeId(params);
}
}
@@ -74,6 +74,11 @@ export interface AgentRepositoryInterface {
* Link inbound agent to workflow
*/
linkInboundAgentToWorkflow(params: { workflowStepId: number; agentId: string }): Promise<void>;
/**
* Update outbound event type ID for an agent
*/
updateOutboundEventTypeId(params: { agentId: string; eventTypeId: number }): Promise<void>;
}
/**
@@ -87,6 +92,7 @@ export interface AgentData {
userId: number | null;
teamId: number | null;
inboundEventTypeId?: number | null;
outboundEventTypeId?: number | null;
createdAt: Date;
updatedAt: Date;
}
@@ -264,6 +264,8 @@ export class RetellAIPhoneServiceProvider
generalTools?: AIPhoneServiceTools<AIPhoneServiceProviderType.RETELL_AI>;
voiceId?: string;
language?: Language;
outboundEventTypeId?: number;
timeZone?: string;
}): Promise<{ message: string }> {
return await this.service.updateAgentConfiguration(params);
}
@@ -199,6 +199,8 @@ export class RetellAIService {
generalTools?: RetellLLMGeneralTools;
voiceId?: string;
language?: Language;
outboundEventTypeId?: number;
timeZone?: string;
}) {
return this.agentService.updateAgentConfiguration({
...params,
@@ -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,
@@ -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<AIPhoneServiceProviderType.RETELL_AI>;
voiceId?: string;
language?: Language;
outboundEventTypeId?: number;
timeZone?: string;
updateLLMConfiguration: (
llmId: string,
data: AIPhoneServiceUpdateModelParams<AIPhoneServiceProviderType.RETELL_AI>
@@ -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" };
}
@@ -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;
@@ -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<FormValues>;
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,
});
}
};
@@ -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<FormValues>;
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<CallStatus>("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]);
@@ -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<void>;
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}
@@ -89,6 +89,7 @@ type WorkflowStepProps = {
setSelectedOptions?: Dispatch<SetStateAction<Option[]>>;
isOrganization?: boolean;
allOptions?: Option[];
eventTypeOptions?: Option[];
onSaveWorkflow?: () => Promise<void>;
setIsDeleteStepDialogOpen?: Dispatch<SetStateAction<boolean>>;
isDeleteStepDialogOpen?: boolean;
@@ -122,19 +123,19 @@ const getTimeSectionText = (trigger: WorkflowTriggerEvents, t: TFunction) => {
const CalAIAgentDataSkeleton = () => {
return (
<div className="bg-muted mt-4 rounded-lg p-4">
<div className="flex items-center justify-between">
<div className="p-4 mt-4 rounded-lg bg-muted">
<div className="flex justify-between items-center">
<div>
<SkeletonText className="h-5 w-28" />
<div className="mt-2 flex items-center gap-2">
<SkeletonText className="h-4 w-4" />
<SkeletonText className="h-4 w-32" />
<SkeletonText className="h-5 w-12" />
<SkeletonText className="w-28 h-5" />
<div className="flex gap-2 items-center mt-2">
<SkeletonText className="w-4 h-4" />
<SkeletonText className="w-32 h-4" />
<SkeletonText className="w-12 h-5" />
</div>
</div>
<div className="flex items-center gap-1">
<SkeletonText className="h-8 w-24" />
<SkeletonText className="h-8 w-8" />
<div className="flex gap-1 items-center">
<SkeletonText className="w-24 h-8" />
<SkeletonText className="w-8 h-8" />
</div>
</div>
</div>
@@ -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 (
<>
<div className="mb-3">
<Label className="text-default text-sm leading-none">{t("when")}</Label>
<Label className="text-sm leading-none text-default">{t("when")}</Label>
<Controller
name="trigger"
control={form.control}
@@ -601,8 +601,8 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
</div>
<div>
{!!timeSectionText && (
<div className="mb-3 mt-3">
<Label className="text-default mb-0">{timeSectionText}</Label>
<div className="mt-3 mb-3">
<Label className="mb-0 text-default">{timeSectionText}</Label>
<TimeTimeUnitInput disabled={props.readOnly} />
</div>
)}
@@ -610,7 +610,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
{selectedOptions && setSelectedOptions && allOptions && (
<div className={classNames(!!timeSectionText && "mb-3")}>
{isOrganization ? (
<div className="text-default flex items-center gap-2">
<div className="flex gap-2 items-center text-default">
<Label>{t("which_team_apply")}</Label>
<div className="mb-2">
<InfoBadge content={t("team_select_info")} />
@@ -677,7 +677,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
</div>
)}
{!!timeSectionText && (
<div className="mt-1 flex text-gray-500">
<div className="flex mt-1 text-gray-500">
<Icon name="info" className="mr-1 mt-0.5 h-4 w-4" />
<p className="text-sm">{t("testing_sms_workflow_info_message")}</p>
</div>
@@ -824,12 +824,12 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
/>
<div className="mt-1.5 flex items-center gap-1">
<Icon name="info" size="10" className="text-gray-500" />
<div className="text-subtle text-xs">{t("sender_id_info")}</div>
<div className="text-xs text-subtle">{t("sender_id_info")}</div>
</div>
</div>
{form.formState.errors.steps &&
form.formState?.errors?.steps[step.stepNumber - 1]?.sender && (
<p className="text-error mt-1 text-xs">{t("sender_id_error_message")}</p>
<p className="mt-1 text-xs text-error">{t("sender_id_error_message")}</p>
)}
</>
) : (
@@ -848,23 +848,23 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
</div>
)}
{isCalAIAction(form.getValues(`steps.${step.stepNumber - 1}.action`)) && !stepAgentId && (
<div className="bg-muted border-muted mt-2 rounded-2xl border p-3">
<div className="flex flex-col justify-between gap-4 sm:flex-row sm:items-center sm:gap-0">
<div className="p-3 mt-2 rounded-2xl border bg-muted border-muted">
<div className="flex flex-col gap-4 justify-between sm:flex-row sm:items-center sm:gap-0">
<div>
<h2 className="text-emphasis text-sm font-medium leading-none">
<h2 className="text-sm font-medium leading-none text-emphasis">
{t("cal_ai_agent")}
<Badge startIcon="info" className="ms-2 rounded-md" variant="warning">
<Badge startIcon="info" className="rounded-md ms-2" variant="warning">
{t("set_up_required")}
</Badge>
</h2>
<p className="text-muted mt-1 text-sm font-medium leading-none">
<p className="mt-1 text-sm font-medium leading-none text-muted">
{t("no_phone_number_connected")}.
</p>
</div>
<Button
color="primary"
disabled={props.readOnly || isCreatingAgent.current || hasAutoCreated.current}
className="flex items-center justify-center"
className="flex justify-center items-center"
onClick={() => handleCreateAgent()}
loading={createAgentMutation.isPending || isCreatingAgent.current}>
{t("set_up_agent")}
@@ -875,9 +875,9 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
{stepAgentId && isAgentLoading && <CalAIAgentDataSkeleton />}
{stepAgentId && agentData && (
<div className="bg-muted mt-4 rounded-lg p-4">
<div className="p-4 mt-4 rounded-lg bg-muted">
<div
className="flex cursor-pointer items-center justify-between"
className="flex justify-between items-center cursor-pointer"
onClick={(e) => {
const target = e.target as HTMLElement;
if (
@@ -892,11 +892,11 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
}
}}>
<div>
<h3 className="text-emphasis text-base font-medium">{t("cal_ai_agent")}</h3>
<h3 className="text-base font-medium text-emphasis">{t("cal_ai_agent")}</h3>
{arePhoneNumbersActive.length > 0 ? (
<div className="flex items-center gap-2">
<Icon name="phone" className="text-emphasis h-4 w-4" />
<span className="text-emphasis text-sm">
<div className="flex gap-2 items-center">
<Icon name="phone" className="w-4 h-4 text-emphasis" />
<span className="text-sm text-emphasis">
{formatPhoneNumber(arePhoneNumbersActive[0].phoneNumber)}
</span>
<Badge variant="green" size="sm" withDot>
@@ -904,12 +904,12 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
</Badge>
</div>
) : (
<div className="flex items-center gap-1">
<span className="text-subtle text-sm">{t("no_phone_number_connected")}</span>
<div className="flex gap-1 items-center">
<span className="text-sm text-subtle">{t("no_phone_number_connected")}</span>
</div>
)}
</div>
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
<div className="flex gap-1 items-center" onClick={(e) => e.stopPropagation()}>
{arePhoneNumbersActive.length > 0 ? (
<Dropdown>
<DropdownMenuTrigger asChild>
@@ -1019,7 +1019,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
</div>
)}
{isPhoneNumberNeeded && (
<div className="bg-muted mt-2 rounded-md p-4 pt-0">
<div className="p-4 pt-0 mt-2 rounded-md bg-muted">
<Label className="pt-4">{t("custom_phone_number")}</Label>
<div className="block sm:flex">
<Controller
@@ -1059,7 +1059,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
</div>
{form.formState.errors.steps && form.formState?.errors?.steps[step.stepNumber - 1]?.sendTo && (
<p className="text-error mt-1 text-xs">
<p className="mt-1 text-xs text-error">
{form.formState?.errors?.steps[step.stepNumber - 1]?.sendTo?.message || ""}
</p>
)}
@@ -1070,9 +1070,9 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
) : (
!props.readOnly && (
<>
<div className="mt-6 flex w-full flex-col">
<div className="flex flex-col mt-6 w-full">
<Label className="">{t("verification_code")}</Label>
<div className="flex w-full items-center">
<div className="flex items-center w-full">
<TextField
containerClassName="w-full"
className="h-8 rounded-xl"
@@ -1102,7 +1102,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
</div>
{form.formState.errors.steps &&
form.formState?.errors?.steps[step.stepNumber - 1]?.sendTo && (
<p className="text-error mt-1 text-xs">
<p className="mt-1 text-xs text-error">
{form.formState?.errors?.steps[step.stepNumber - 1]?.sendTo?.message || ""}
</p>
)}
@@ -1132,9 +1132,9 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
</div>
)}
{isEmailAddressNeeded && !isCalAIAction(form.getValues(`steps.${step.stepNumber - 1}.action`)) && (
<div className="bg-muted border-muted mt-5 rounded-2xl border p-4">
<div className="p-4 mt-5 rounded-2xl border bg-muted border-muted">
<Label>{t("email_address")}</Label>
<div className="block items-center gap-2 sm:flex">
<div className="block gap-2 items-center sm:flex">
<Controller
name={`steps.${step.stepNumber - 1}.sendTo`}
render={({ field: { value, onChange } }) => (
@@ -1160,7 +1160,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
size="sm"
disabled={emailVerified || props.readOnly || false}
className={classNames(
"min-w-fit text-sm font-medium",
"text-sm font-medium min-w-fit",
emailVerified ? "hidden" : "mt-3 sm:mt-0"
)}
onClick={() => {
@@ -1174,7 +1174,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
</Button>
</div>
{form.formState.errors.steps && form.formState?.errors?.steps[step.stepNumber - 1]?.sendTo && (
<p className="text-error mt-1 text-xs">
<p className="mt-1 text-xs text-error">
{form.formState?.errors?.steps[step.stepNumber - 1]?.sendTo?.message || ""}
</p>
)}
@@ -1185,9 +1185,9 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
) : (
!props.readOnly && (
<>
<div className="mt-3 flex w-full flex-col">
<div className="flex flex-col mt-3 w-full">
<Label className="">{t("verification_code")}</Label>
<div className="flex w-full items-center">
<div className="flex items-center w-full">
<TextField
containerClassName="w-full"
className="h-8 rounded-xl"
@@ -1217,7 +1217,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
</div>
{form.formState.errors.steps &&
form.formState?.errors?.steps[step.stepNumber - 1]?.sendTo && (
<p className="text-error mt-1 text-xs">
<p className="mt-1 text-xs text-error">
{form.formState?.errors?.steps[step.stepNumber - 1]?.sendTo?.message || ""}
</p>
)}
@@ -1304,7 +1304,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
</div>
)}
{!isCalAIAction(form.getValues(`steps.${step.stepNumber - 1}.action`)) && (
<div className="bg-muted border-muted mt-3 rounded-2xl border p-3">
<div className="p-3 mt-3 rounded-2xl border bg-muted border-muted">
{isEmailSubjectNeeded && (
<div className="mb-6">
<div className="flex items-center">
@@ -1337,14 +1337,14 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
/>
{form.formState.errors.steps &&
form.formState?.errors?.steps[step.stepNumber - 1]?.emailSubject && (
<p className="text-error mt-1 text-xs">
<p className="mt-1 text-xs text-error">
{form.formState?.errors?.steps[step.stepNumber - 1]?.emailSubject?.message || ""}
</p>
)}
</div>
)}
<div className="mb-2 flex items-center pb-1">
<Label className="mb-0 flex-none">
<div className="flex items-center pb-1 mb-2">
<Label className="flex-none mb-0">
{isEmailSubjectNeeded ? t("email_body") : t("text_message")}
</Label>
</div>
@@ -1373,7 +1373,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
{form.formState.errors.steps &&
form.formState?.errors?.steps[step.stepNumber - 1]?.reminderBody && (
<p className="text-error mt-1 text-sm">
<p className="mt-1 text-sm text-error">
{form.formState?.errors?.steps[step.stepNumber - 1]?.reminderBody?.message || ""}
</p>
)}
@@ -1399,11 +1399,11 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
</div>
)}
{!props.readOnly && !isFormTrigger(trigger) && (
<div className="ml-1 mt-2">
<div className="mt-2 ml-1">
<button type="button" onClick={() => setIsAdditionalInputsDialogOpen(true)}>
<div className="text-subtle ml-1 flex items-center gap-2">
<Icon name="circle-help" className="h-3 w-3" />
<p className="text-left text-xs">{t("using_booking_questions_as_variables")}</p>
<div className="flex gap-2 items-center ml-1 text-subtle">
<Icon name="circle-help" className="w-3 h-3" />
<p className="text-xs text-left">{t("using_booking_questions_as_variables")}</p>
</div>
</button>
</div>
@@ -1512,23 +1512,23 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
<DialogContent enableOverflow type="creation" className="sm:max-w-[610px]">
<div>
<h1 className="w-full text-xl font-semibold">{t("how_booking_questions_as_variables")}</h1>
<div className="bg-muted-3 mb-6 rounded-md sm:p-4">
<p className="test-sm font-medium">{t("format")}</p>
<ul className="text-emphasis ml-5 mt-2 list-disc">
<div className="mb-6 rounded-md bg-muted-3 sm:p-4">
<p className="font-medium test-sm">{t("format")}</p>
<ul className="mt-2 ml-5 list-disc text-emphasis">
<li>{t("uppercase_for_letters")}</li>
<li>{t("replace_whitespaces_underscores")}</li>
<li>{t("ignore_special_characters_booking_questions")}</li>
</ul>
<div className="mt-4">
<p className="test-sm w-full font-medium">{t("example_1")}</p>
<div className="mt-2 grid grid-cols-12">
<div className="test-sm text-default col-span-5 ltr:mr-2 rtl:ml-2">
<p className="w-full font-medium test-sm">{t("example_1")}</p>
<div className="grid grid-cols-12 mt-2">
<div className="col-span-5 test-sm text-default ltr:mr-2 rtl:ml-2">
{t("booking_question_identifier")}
</div>
<div className="test-sm text-emphasis col-span-7">{t("company_size")}</div>
<div className="test-sm text-default col-span-5 w-full">{t("variable")}</div>
<div className="col-span-7 test-sm text-emphasis">{t("company_size")}</div>
<div className="col-span-5 w-full test-sm text-default">{t("variable")}</div>
<div className="test-sm text-emphasis col-span-7 break-words">
<div className="col-span-7 break-words test-sm text-emphasis">
{" "}
{`{${t("company_size")
.replace(/[^a-zA-Z0-9 ]/g, "")
@@ -1539,14 +1539,14 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
</div>
</div>
<div className="mt-4">
<p className="test-sm w-full font-medium">{t("example_2")}</p>
<div className="mt-2 grid grid-cols-12">
<div className="test-sm text-default col-span-5 ltr:mr-2 rtl:ml-2">
<p className="w-full font-medium test-sm">{t("example_2")}</p>
<div className="grid grid-cols-12 mt-2">
<div className="col-span-5 test-sm text-default ltr:mr-2 rtl:ml-2">
{t("booking_question_identifier")}
</div>
<div className="test-sm text-emphasis col-span-7">{t("what_help_needed")}</div>
<div className="test-sm text-default col-span-5">{t("variable")}</div>
<div className="test-sm text-emphasis col-span-7 break-words">
<div className="col-span-7 test-sm text-emphasis">{t("what_help_needed")}</div>
<div className="col-span-5 test-sm text-default">{t("variable")}</div>
<div className="col-span-7 break-words test-sm text-emphasis">
{" "}
{`{${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) {
<Dialog open={isUnsubscribeDialogOpen} onOpenChange={setIsUnsubscribeDialogOpen}>
<DialogContent type="creation" title={t("unsubscribe_phone_number")}>
<div className="space-y-4">
<p className="text-default text-sm">{t("do_you_still_want_to_unsubscribe")}</p>
<p className="text-sm text-default">{t("do_you_still_want_to_unsubscribe")}</p>
{getActivePhoneNumbers(
agentData?.outboundPhoneNumbers?.map((phone) => ({
...phone,
subscriptionStatus: phone.subscriptionStatus ?? undefined,
}))
).length > 0 && (
<div className="bg-muted rounded-lg p-3">
<div className="flex items-center gap-2">
<Icon name="phone" className="text-emphasis h-4 w-4" />
<span className="text-emphasis text-sm font-medium">
<div className="p-3 rounded-lg bg-muted">
<div className="flex gap-2 items-center">
<Icon name="phone" className="w-4 h-4 text-emphasis" />
<span className="text-sm font-medium text-emphasis">
{formatPhoneNumber(
getActivePhoneNumbers(
agentData?.outboundPhoneNumbers?.map((phone) => ({
@@ -1639,7 +1643,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
</div>
</div>
)}
<p className="text-subtle text-sm">{t("the_action_will_disconnect_phone_number")}</p>
<p className="text-sm text-subtle">{t("the_action_will_disconnect_phone_number")}</p>
</div>
<DialogFooter showDivider>
<Button type="button" color="secondary" onClick={() => setIsUnsubscribeDialogOpen(false)}>
@@ -1674,7 +1678,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
<Dialog open={isDeleteStepDialogOpen} onOpenChange={setIsDeleteStepDialogOpen}>
<DialogContent type="confirmation" title={t("delete_workflow_step")}>
<div className="space-y-4">
<p className="text-default text-sm">{t("are_you_sure_you_want_to_delete_workflow_step")}</p>
<p className="text-sm text-default">{t("are_you_sure_you_want_to_delete_workflow_step")}</p>
{(() => {
const relevantPhoneNumbers =
agentData?.outboundPhoneNumbers?.filter(
@@ -1684,12 +1688,12 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
return (
relevantPhoneNumbers.length > 0 && (
<>
<div className="bg-attention rounded-lg p-3">
<div className="flex items-start gap-2">
<div className="p-3 rounded-lg bg-attention">
<div className="flex gap-2 items-start">
<Icon name="info" className="text-attention mt-0.5 h-4 w-4" />
<div className="space-y-2">
<p className="text-attention text-sm font-medium">{t("this_action_will_also")}</p>
<ul className="text-attention list-inside list-disc space-y-1 text-sm">
<p className="text-sm font-medium text-attention">{t("this_action_will_also")}</p>
<ul className="space-y-1 text-sm list-disc list-inside text-attention">
{relevantPhoneNumbers.some(
(phone) => phone.subscriptionStatus === PhoneNumberSubscriptionStatus.ACTIVE
) && <li>{t("cancel_your_phone_number_subscription")}</li>}
@@ -1699,10 +1703,10 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
</div>
</div>
{relevantPhoneNumbers.map((phone) => (
<div key={phone.phoneNumber} className="bg-muted rounded-lg p-3">
<div className="flex items-center gap-2">
<Icon name="phone" className="text-emphasis h-4 w-4" />
<span className="text-emphasis text-sm font-medium">
<div key={phone.phoneNumber} className="p-3 rounded-lg bg-muted">
<div className="flex gap-2 items-center">
<Icon name="phone" className="w-4 h-4 text-emphasis" />
<span className="text-sm font-medium text-emphasis">
{formatPhoneNumber(phone.phoneNumber)}
</span>
{phone.subscriptionStatus === PhoneNumberSubscriptionStatus.ACTIVE && (
@@ -80,6 +80,8 @@ export function AgentConfigurationSheet({
const { outboundAgentForm, inboundAgentForm } = useAgentForms({ agentData, inboundAgentData });
const trigger = form.watch("trigger");
const updateAgentMutation = trpc.viewer.aiVoiceAgent.update.useMutation({
onSuccess: async () => {
if (activeAgentId) {
@@ -93,12 +95,17 @@ export function AgentConfigurationSheet({
const handleAgentUpdate = async (data: AgentFormValues) => {
if (!agentId) return;
if (trigger === "FORM_SUBMITTED" && !data.outboundEventTypeId) {
showToast(t("select_event_type_to_schedule_calls"), "error");
return;
}
const updatePayload = {
generalPrompt: restorePromptComplexity(data.generalPrompt),
beginMessage: data.beginMessage,
language: data.language as Language,
voiceId: data.voiceId,
outboundEventTypeId: data.outboundEventTypeId,
};
await updateAgentMutation.mutateAsync({
@@ -146,7 +153,12 @@ export function AgentConfigurationSheet({
</SheetHeader>
<SheetBody className="px-0">
{activeTab === "outgoingCalls" && (
<OutgoingCallsTab outboundAgentForm={outboundAgentForm} readOnly={readOnly} />
<OutgoingCallsTab
outboundAgentForm={outboundAgentForm}
readOnly={readOnly}
eventTypeOptions={eventTypeOptions}
trigger={trigger}
/>
)}
{activeTab === "phoneNumber" && (
@@ -158,6 +170,7 @@ export function AgentConfigurationSheet({
workflowId={workflowId}
isOrganization={isOrganization}
form={form}
eventTypeIds={eventTypeOptions?.map((opt) => parseInt(opt.value, 10))}
/>
)}
@@ -241,6 +254,8 @@ export function AgentConfigurationSheet({
teamId={teamId}
isOrganization={isOrganization}
form={form}
eventTypeIds={eventTypeOptions?.map((opt) => parseInt(opt.value, 10))}
outboundEventTypeId={agentData?.outboundEventTypeId}
/>
)}
@@ -86,14 +86,14 @@ export function IncomingCallsTab({
if (!hasActivePhoneNumbers) {
return (
<div className="border-subtle rounded-xl border p-8">
<div className="p-8 rounded-xl border border-subtle">
<div className="flex flex-col items-center space-y-6 text-center">
<div className="bg-muted flex h-16 w-16 items-center justify-center rounded-lg">
<Icon name="info" className="text-subtle h-8 w-8" />
<div className="flex justify-center items-center w-16 h-16 rounded-lg bg-muted">
<Icon name="info" className="w-8 h-8 text-subtle" />
</div>
<div className="space-y-2">
<h3 className="text-emphasis text-lg font-semibold">{t("setup_incoming_agent")}</h3>
<p className="text-subtle text-sm">{t("connect_a_phone_number_first")}</p>
<h3 className="text-lg font-semibold text-emphasis">{t("setup_incoming_agent")}</h3>
<p className="text-sm text-subtle">{t("connect_a_phone_number_first")}</p>
</div>
</div>
</div>
@@ -102,14 +102,14 @@ export function IncomingCallsTab({
if (!inboundAgentId) {
return (
<div className="border-subtle rounded-xl border p-8">
<div className="p-8 rounded-xl border border-subtle">
<div className="flex flex-col items-center space-y-6 text-center">
<div className="bg-muted flex h-16 w-16 items-center justify-center rounded-lg">
<Icon name="phone-incoming" className="text-subtle h-8 w-8" />
<div className="flex justify-center items-center w-16 h-16 rounded-lg bg-muted">
<Icon name="phone-incoming" className="w-8 h-8 text-subtle" />
</div>
<div className="space-y-2">
<h3 className="text-emphasis text-lg font-semibold">{t("setup_inbound_agent")}</h3>
<p className="text-subtle text-sm">{t("setup_inbound_agent_for_incoming_calls")}</p>
<h3 className="text-lg font-semibold text-emphasis">{t("setup_inbound_agent")}</h3>
<p className="text-sm text-subtle">{t("setup_inbound_agent_for_incoming_calls")}</p>
</div>
<Button
onClick={() => {
@@ -147,7 +147,7 @@ export function IncomingCallsTab({
return (
<div className="space-y-4">
<div>
<Label className="text-emphasis mb-1 block text-sm font-medium">{t("event_type")}</Label>
<Label className="block mb-1 text-sm font-medium text-emphasis">{t("event_type")}</Label>
<p className="text-subtle mb-1.5 text-xs">{t("select_event_type_for_inbound_calls")}</p>
<Select
isSearchable={false}
@@ -1,6 +1,12 @@
import { useState } from "react";
import { Controller } from "react-hook-form";
import type { UseFormReturn } from "react-hook-form";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { WorkflowTriggerEvents } from "@calcom/prisma/enums";
import { Label, Select } from "@calcom/ui/components/form";
import type { MultiSelectCheckboxesOptionType as Option } from "@calcom/ui/components/form";
import { VoiceSelectionDialog } from "../../../VoiceSelectionDialog";
import type { AgentFormValues } from "../../types/schemas";
import { AgentConfigForm } from "../forms/AgentConfigForm";
@@ -8,13 +14,44 @@ import { AgentConfigForm } from "../forms/AgentConfigForm";
interface OutgoingCallsTabProps {
outboundAgentForm: UseFormReturn<AgentFormValues>;
readOnly?: boolean;
eventTypeOptions?: Option[];
trigger?: WorkflowTriggerEvents;
}
export function OutgoingCallsTab({ outboundAgentForm, readOnly = false }: OutgoingCallsTabProps) {
export function OutgoingCallsTab({
outboundAgentForm,
readOnly = false,
eventTypeOptions = [],
trigger,
}: OutgoingCallsTabProps) {
const [isVoiceDialogOpen, setIsVoiceDialogOpen] = useState(false);
const { t } = useLocale();
return (
<>
{trigger === "FORM_SUBMITTED" && (
<div>
<Label className="text-emphasis mb-1 block text-sm font-medium">{t("event_type")}</Label>
<p className="text-subtle mb-1.5 text-xs">{t("select_event_type_for_outbound_calls")}</p>
<Controller
name="outboundEventTypeId"
control={outboundAgentForm.control}
render={({ field }) => (
<Select
isSearchable={false}
innerClassNames={{ valueContainer: "font-medium" }}
value={eventTypeOptions.find((option) => option.value === field.value?.toString())}
onChange={(option) => field.onChange(option?.value ? parseInt(option.value) : undefined)}
options={eventTypeOptions}
isDisabled={readOnly}
placeholder={t("select_event_type")}
className="mb-4"
/>
)}
/>
</div>
)}
<AgentConfigForm
form={outboundAgentForm}
readOnly={readOnly}
@@ -35,6 +35,7 @@ interface PhoneNumberTabProps {
workflowId?: string;
isOrganization?: boolean;
form?: UseFormReturn<FormValues>;
eventTypeIds?: number[];
}
export function PhoneNumberTab({
@@ -45,6 +46,7 @@ export function PhoneNumberTab({
workflowId,
isOrganization = false,
form,
eventTypeIds = [],
}: PhoneNumberTabProps) {
const { t } = useLocale();
const utils = trpc.useUtils();
@@ -110,6 +112,9 @@ export function PhoneNumberTab({
phone.subscriptionStatus === PhoneNumberSubscriptionStatus.ACTIVE || !phone.subscriptionStatus
).length > 0;
const outboundEventTypeId =
form?.watch("trigger") === "FORM_SUBMITTED" ? agentData?.outboundEventTypeId : null;
if (hasActivePhoneNumbers) {
const activePhoneNumbers = agentData.outboundPhoneNumbers.filter(
(phone) =>
@@ -237,6 +242,8 @@ export function PhoneNumberTab({
agentId={agentId}
teamId={teamId}
form={form}
eventTypeIds={eventTypeIds}
outboundEventTypeId={outboundEventTypeId}
/>
)}
@@ -248,6 +255,8 @@ export function PhoneNumberTab({
teamId={teamId}
isOrganization={isOrganization}
form={form}
eventTypeIds={eventTypeIds}
outboundEventTypeId={outboundEventTypeId}
/>
)}
@@ -324,6 +333,8 @@ export function PhoneNumberTab({
agentId={agentId}
teamId={teamId}
form={form}
eventTypeIds={eventTypeIds}
outboundEventTypeId={outboundEventTypeId}
/>
)}
@@ -335,6 +346,8 @@ export function PhoneNumberTab({
teamId={teamId}
isOrganization={isOrganization}
form={form}
eventTypeIds={eventTypeIds}
outboundEventTypeId={outboundEventTypeId}
/>
)}
@@ -48,6 +48,7 @@ export function useAgentForms({ agentData, inboundAgentData }: UseAgentFormsProp
language: retellData.language || "en-US",
voiceId: retellData.voiceId || "",
generalTools: retellData.generalTools || [],
outboundEventTypeId: agentData.outboundEventTypeId || undefined,
});
}
}, [agentData, outboundAgentForm]);
@@ -7,6 +7,7 @@ export const agentSchema = z.object({
numberToCall: z.string().optional(),
language: z.string().optional(),
voiceId: z.string().optional(),
outboundEventTypeId: z.number().optional(),
generalTools: z
.array(
z.object({
@@ -150,3 +150,42 @@ export function isFormTrigger(trigger: WorkflowTriggerEvents) {
export function hasCalAIAction(steps: WorkflowStep[]) {
return steps.some((step) => isCalAIAction(step.action));
}
export function getEventTypeIdForCalAiTest({
trigger,
outboundEventTypeId,
eventTypeIds,
activeOnEventTypeId,
t,
}: {
trigger: WorkflowTriggerEvents;
outboundEventTypeId?: number | null;
eventTypeIds?: number[];
activeOnEventTypeId?: string;
t: TFunction;
}): { eventTypeId: number | null; error: string | null } {
if (isFormTrigger(trigger)) {
if (trigger === WorkflowTriggerEvents.FORM_SUBMITTED) {
if (!outboundEventTypeId) {
return { eventTypeId: null, error: t("choose_event_type_in_agent_setup") };
}
return { eventTypeId: outboundEventTypeId, error: null };
} else {
// FORM_SUBMITTED_NO_EVENT
if (!eventTypeIds || eventTypeIds.length === 0) {
return { eventTypeId: null, error: t("no_event_types_available_for_test_call") };
}
return { eventTypeId: eventTypeIds[0], error: null };
}
} else {
// For regular event type triggers, use the selected event type
if (!activeOnEventTypeId) {
return { eventTypeId: null, error: t("choose_at_least_one_event_type_test_call") };
}
const parsedEventTypeId = parseInt(activeOnEventTypeId, 10);
if (isNaN(parsedEventTypeId)) {
return { eventTypeId: null, error: t("choose_at_least_one_event_type_test_call") };
}
return { eventTypeId: parsedEventTypeId, error: null };
}
}
@@ -110,4 +110,5 @@ export const ALLOWED_FORM_WORKFLOW_ACTIONS = [
WorkflowActions.EMAIL_ADDRESS,
WorkflowActions.SMS_ATTENDEE,
WorkflowActions.SMS_NUMBER,
WorkflowActions.CAL_AI_PHONE_CALL,
] as const;
@@ -1,5 +1,6 @@
import { v4 as uuidv4 } from "uuid";
import type { FORM_SUBMITTED_WEBHOOK_RESPONSES } from "@calcom/app-store/routing-forms/lib/formSubmissionUtils";
import dayjs from "@calcom/dayjs";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import tasker from "@calcom/features/tasker";
@@ -11,6 +12,7 @@ import { WorkflowMethods, WorkflowTriggerEvents } from "@calcom/prisma/enums";
import type { TimeUnit } from "@calcom/prisma/enums";
import { PhoneNumberSubscriptionStatus } from "@calcom/prisma/enums";
import type { FormSubmissionData, WorkflowContextData } from "./reminderScheduler";
import type { BookingInfo } from "./smsReminderManager";
type timeUnitLowerCase = "day" | "hour" | "minute";
@@ -37,12 +39,12 @@ function extractPhoneNumber(responses: BookingInfo["responses"]): string | undef
return undefined;
}
interface CreateWorkflowReminderAndExtractPhoneArgs {
evt: BookingInfo;
type CreateWorkflowReminderAndExtractPhoneArgs = WorkflowContextData & {
workflowStepId: number;
scheduledDate: dayjs.Dayjs;
seatReferenceUid?: string;
}
submittedPhoneNumber?: string | null;
};
interface CreateWorkflowReminderAndExtractPhoneResult {
workflowReminder: { id: number; uuid: string | null };
@@ -51,15 +53,17 @@ interface CreateWorkflowReminderAndExtractPhoneResult {
const createWorkflowReminderAndExtractPhone = async (
args: CreateWorkflowReminderAndExtractPhoneArgs
): Promise<CreateWorkflowReminderAndExtractPhoneResult> => {
const { evt, workflowStepId, scheduledDate, seatReferenceUid } = args;
): Promise<CreateWorkflowReminderAndExtractPhoneResult | null> => {
const { evt, workflowStepId, scheduledDate, seatReferenceUid, submittedPhoneNumber } = args;
// 1) Determine attendee phone first (fail early)
let attendeePhoneNumber = extractPhoneNumber(evt.responses);
let attendeePhoneNumber = extractPhoneNumber(evt?.responses) || submittedPhoneNumber;
if (!attendeePhoneNumber) {
const attendeePhone = evt.attendees?.[0]?.phoneNumber;
if (attendeePhone) {
attendeePhoneNumber = attendeePhone;
if (!evt) {
return null;
}
if (evt.attendees?.[0]?.phoneNumber) {
attendeePhoneNumber = evt.attendees?.[0]?.phoneNumber;
} else {
throw new Error(`No attendee phone number found for workflow step ${workflowStepId}`);
}
@@ -67,7 +71,7 @@ const createWorkflowReminderAndExtractPhone = async (
const workflowReminder = await prisma.workflowReminder.create({
data: {
bookingUid: evt.uid as string,
bookingUid: evt?.uid,
workflowStepId,
method: WorkflowMethods.AI_PHONE_CALL,
scheduledDate: scheduledDate.toDate(),
@@ -79,8 +83,8 @@ const createWorkflowReminderAndExtractPhone = async (
return { workflowReminder, attendeePhoneNumber };
};
interface ScheduleAIPhoneCallArgs {
evt: BookingInfo;
type ScheduleAIPhoneCallArgs = {
submittedPhoneNumber: string | null;
triggerEvent: WorkflowTriggerEvents;
timeSpan: {
time: number | null;
@@ -91,13 +95,33 @@ interface ScheduleAIPhoneCallArgs {
teamId: number | null;
seatReferenceUid?: string;
verifiedAt: Date | null;
}
routedEventTypeId: number | null;
} & WorkflowContextData;
export type ScheduleAIPhoneCallArgsWithRequiredFields = Omit<ScheduleAIPhoneCallArgs, "workflowStepId"> & {
workflowStepId: number; // Required
agent: Agent;
activePhoneNumber: string;
};
type Agent = {
id: string;
providerAgentId: string;
outboundPhoneNumbers: {
phoneNumber: string;
subscriptionStatus: PhoneNumberSubscriptionStatus | null;
}[];
};
export const scheduleAIPhoneCall = async (args: ScheduleAIPhoneCallArgs) => {
const { evt, triggerEvent, timeSpan, workflowStepId, userId, teamId, seatReferenceUid, verifiedAt } = args;
const { workflowStepId, verifiedAt } = args;
if (!workflowStepId) {
logger.warn(`Workflow step ID is required for AI phone call scheduling`);
return;
}
if (!verifiedAt || !workflowStepId) {
logger.warn(`Workflow step ${workflowStepId} not yet verified or not found`);
if (!verifiedAt) {
logger.warn(`Workflow step ${workflowStepId} not yet verified`);
return;
}
@@ -143,6 +167,35 @@ export const scheduleAIPhoneCall = async (args: ScheduleAIPhoneCallArgs) => {
logger.warn("Cal.ai voice agents are disabled - skipping AI phone call scheduling");
return;
}
const params = { ...args, agent: workflowStep.agent, activePhoneNumber: activePhoneNumbers[0].phoneNumber };
if (params.evt) {
await scheduleAIPhoneCallForEvt(
params as ScheduleAIPhoneCallArgsWithRequiredFields & { evt: BookingInfo }
);
} else {
await scheduleAIPhoneCallForForm(
params as ScheduleAIPhoneCallArgsWithRequiredFields & {
formData: FormSubmissionData;
}
);
}
};
const scheduleAIPhoneCallForEvt = async (
args: ScheduleAIPhoneCallArgsWithRequiredFields & { evt: BookingInfo }
) => {
const {
evt,
triggerEvent,
timeSpan,
workflowStepId,
userId,
teamId,
seatReferenceUid,
agent,
activePhoneNumber,
} = args;
const { startTime, endTime } = evt;
const uid = evt.uid as string;
@@ -172,25 +225,34 @@ export const scheduleAIPhoneCall = async (args: ScheduleAIPhoneCallArgs) => {
if (!shouldExecuteImmediately) {
try {
const { workflowReminder, attendeePhoneNumber } = await createWorkflowReminderAndExtractPhone({
const reminderAndPhone = await createWorkflowReminderAndExtractPhone({
evt,
workflowStepId,
scheduledDate,
seatReferenceUid,
});
if (!reminderAndPhone) {
logger.warn(`No phone number found for AI phone call workflow step ${workflowStepId} - skipping`);
return;
}
const { workflowReminder, attendeePhoneNumber } = reminderAndPhone;
// Schedule the actual AI phone call
await scheduleAIPhoneCallTask({
workflowReminderId: workflowReminder.id,
scheduledDate: scheduledDate.toDate(),
agentId: workflowStep.agent.id,
phoneNumber: activePhoneNumbers[0].phoneNumber,
agentId: agent.id,
phoneNumber: activePhoneNumber,
attendeePhoneNumber,
bookingUid: uid,
userId,
teamId,
providerAgentId: workflowStep.agent.providerAgentId,
providerAgentId: agent.providerAgentId,
referenceUid: workflowReminder.uuid || uuidv4(),
formResponses: null,
routedEventTypeId: null,
});
logger.info(`AI phone call scheduled for workflow step ${workflowStepId} at ${scheduledDate}`);
@@ -200,26 +262,35 @@ export const scheduleAIPhoneCall = async (args: ScheduleAIPhoneCallArgs) => {
} else {
// Execute immediately
try {
const { workflowReminder, attendeePhoneNumber } = await createWorkflowReminderAndExtractPhone({
const reminderAndPhone = await createWorkflowReminderAndExtractPhone({
evt,
workflowStepId,
scheduledDate: currentDate,
seatReferenceUid,
});
// Schedule the actual AI phone call immediatel
if (!reminderAndPhone) {
logger.warn(`No phone number found for AI phone call workflow step ${workflowStepId} - skipping`);
return;
}
const { workflowReminder, attendeePhoneNumber } = reminderAndPhone;
// Schedule the actual AI phone call immediately
// Should i execute the task immediately or schedule it for later?
await scheduleAIPhoneCallTask({
workflowReminderId: workflowReminder.id,
scheduledDate: currentDate.toDate(),
agentId: workflowStep.agent.id,
phoneNumber: activePhoneNumbers[0].phoneNumber,
agentId: agent.id,
phoneNumber: activePhoneNumber,
attendeePhoneNumber,
bookingUid: uid,
userId,
teamId,
providerAgentId: workflowStep.agent.providerAgentId,
providerAgentId: agent.providerAgentId,
referenceUid: workflowReminder.uuid || uuidv4(),
formResponses: null,
routedEventTypeId: null,
});
logger.info(`AI phone call scheduled for immediate execution for workflow step ${workflowStepId}`);
@@ -229,17 +300,73 @@ export const scheduleAIPhoneCall = async (args: ScheduleAIPhoneCallArgs) => {
}
};
// sends all immediately, no scheduling needed (tasker handles scheduling)
const scheduleAIPhoneCallForForm = async (
args: ScheduleAIPhoneCallArgsWithRequiredFields & {
formData: FormSubmissionData;
}
) => {
const {
formData,
workflowStepId,
userId,
teamId,
seatReferenceUid,
submittedPhoneNumber,
agent,
activePhoneNumber,
routedEventTypeId,
} = args;
try {
const reminderAndPhone = await createWorkflowReminderAndExtractPhone({
formData,
workflowStepId,
submittedPhoneNumber,
scheduledDate: dayjs(),
seatReferenceUid,
});
if (!reminderAndPhone) {
logger.warn(`No phone number found for AI phone call workflow step ${workflowStepId} - skipping`);
return;
}
const { workflowReminder, attendeePhoneNumber } = reminderAndPhone;
await scheduleAIPhoneCallTask({
workflowReminderId: workflowReminder.id,
agentId: agent.id,
phoneNumber: activePhoneNumber,
attendeePhoneNumber,
userId,
teamId,
scheduledDate: null,
formResponses: formData.responses,
bookingUid: null,
providerAgentId: agent.providerAgentId,
referenceUid: workflowReminder.uuid || uuidv4(),
routedEventTypeId: routedEventTypeId ?? null,
});
logger.info(`AI phone call scheduled for immediate execution for workflow step ${workflowStepId}`);
} catch (error) {
logger.error(`Error scheduling immediate AI phone call with error ${error}`);
}
};
interface ScheduleAIPhoneCallTaskArgs {
workflowReminderId: number;
scheduledDate: Date;
scheduledDate: Date | null;
agentId: string;
phoneNumber: string;
attendeePhoneNumber: string;
bookingUid: string;
bookingUid: string | null;
userId: number | null;
teamId: number | null;
providerAgentId: string;
referenceUid: string;
formResponses: FORM_SUBMITTED_WEBHOOK_RESPONSES | null;
routedEventTypeId: number | null;
}
const scheduleAIPhoneCallTask = async (args: ScheduleAIPhoneCallTaskArgs) => {
@@ -254,8 +381,15 @@ const scheduleAIPhoneCallTask = async (args: ScheduleAIPhoneCallTaskArgs) => {
teamId,
providerAgentId,
referenceUid,
formResponses,
routedEventTypeId,
} = args;
if (!formResponses && !bookingUid) {
logger.warn("context missing for creating AI phone call task");
return;
}
const featuresRepository = new FeaturesRepository(prisma);
const calAIVoiceAgents = await featuresRepository.checkIfFeatureIsEnabledGlobally("cal-ai-voice-agents");
if (!calAIVoiceAgents) {
@@ -282,9 +416,11 @@ const scheduleAIPhoneCallTask = async (args: ScheduleAIPhoneCallTaskArgs) => {
userId,
teamId,
providerAgentId,
responses: formResponses,
routedEventTypeId,
},
{
scheduledAt: scheduledDate,
scheduledAt: scheduledDate || undefined,
maxAttempts: 1,
referenceUid,
}
@@ -21,7 +21,7 @@ import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
import { getWorkflowRecipientEmail } from "../getWorkflowReminders";
import { sendOrScheduleWorkflowEmails } from "./providers/emailProvider";
import type { FormSubmissionData } from "./reminderScheduler";
import type { FormSubmissionData, WorkflowContextData } from "./reminderScheduler";
import type { AttendeeInBookingInfo, BookingInfo, timeUnitLowerCase } from "./smsReminderManager";
import type { VariablesType } from "./templates/customTemplate";
import customTemplate from "./templates/customTemplate";
@@ -45,13 +45,7 @@ export type ScheduleReminderArgs = {
sender?: string | null;
workflowStepId?: number;
seatReferenceUid?: string;
} & (
| { evt: BookingInfo; formData?: never }
| {
evt?: never;
formData: FormSubmissionData;
}
);
} & WorkflowContextData;
type scheduleEmailReminderArgs = ScheduleReminderArgs & {
sendTo: string[];
@@ -29,6 +29,7 @@ import { scheduleWhatsappReminder } from "./whatsappReminderManager";
export type FormSubmissionData = {
responses: FORM_SUBMITTED_WEBHOOK_RESPONSES;
routedEventTypeId: number | null;
user: {
email: string;
timeFormat: number | null;
@@ -36,6 +37,13 @@ export type FormSubmissionData = {
};
};
export type WorkflowContextData =
| { evt: BookingInfo; formData?: never }
| {
evt?: never;
formData: FormSubmissionData;
};
export type ExtendedCalendarEvent = Omit<CalendarEvent, "bookerUrl"> & {
metadata?: { videoCallUrl: string | undefined };
eventType: {
@@ -84,12 +92,7 @@ const processWorkflowStep = async (
if (!evt && !formData) return;
const contextData:
| { evt: BookingInfo; formData?: never }
| {
evt?: never;
formData: FormSubmissionData;
} = evt ? { evt } : { formData: formData as FormSubmissionData };
const contextData: WorkflowContextData = evt ? { evt } : { formData: formData as FormSubmissionData };
if (isSMSOrWhatsappAction(step.action)) {
await checkSMSRateLimit({
@@ -212,12 +215,7 @@ const processWorkflowStep = async (
evt,
});
} else if (isCalAIAction(step.action)) {
if (!evt) {
// cal.ai not yet supported for form triggers
return;
}
await scheduleAIPhoneCall({
evt,
triggerEvent: workflow.trigger,
timeSpan: {
time: workflow.time,
@@ -227,7 +225,10 @@ const processWorkflowStep = async (
userId: workflow.userId,
teamId: workflow.teamId,
seatReferenceUid,
submittedPhoneNumber: smsReminderNumber,
verifiedAt: step.verifiedAt,
routedEventTypeId: formData ? formData.routedEventTypeId : null,
...contextData,
});
}
};
@@ -1,4 +1,5 @@
import prisma from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
import { WorkflowMethods, WorkflowActions } from "@calcom/prisma/enums";
export class WorkflowReminderRepository {
@@ -47,4 +48,74 @@ export class WorkflowReminderRepository {
},
});
}
static async findWorkflowReminderForAIPhoneCallExecution(id: number) {
const bookingSelect = {
uid: true,
startTime: true,
endTime: true,
eventTypeId: true,
responses: true,
location: true,
description: true,
attendees: {
select: {
name: true,
email: true,
phoneNumber: true,
timeZone: true,
},
},
eventType: {
select: {
title: true,
bookingFields: true,
},
},
user: {
select: {
name: true,
timeZone: true,
},
},
} satisfies Prisma.BookingSelect;
return await prisma.workflowReminder.findUnique({
where: { id },
select: {
id: true,
scheduled: true,
referenceId: true,
workflowStep: {
select: {
workflow: {
select: {
trigger: true,
},
},
agent: {
select: {
outboundPhoneNumbers: { select: { phoneNumber: true } },
outboundEventTypeId: true,
},
},
},
},
booking: { select: bookingSelect },
},
});
}
static async updateWorkflowReminderReferenceAndScheduled(
id: number,
data: {
referenceId: string;
scheduled: boolean;
}
) {
return await prisma.workflowReminder.update({
where: { id },
data,
});
}
}
@@ -86,10 +86,12 @@ export class WorkflowService {
responses,
form,
responseId,
routedEventTypeId,
}: {
responseId: number;
workflows: Workflow[];
responses: FORM_SUBMITTED_WEBHOOK_RESPONSES;
routedEventTypeId: number | null;
form: {
id: string;
userId: number;
@@ -131,6 +133,7 @@ export class WorkflowService {
formData: {
responses,
user: { email: form.user.email, timeFormat: form.user.timeFormat, locale: form.user.locale ?? "en" },
routedEventTypeId,
},
hideBranding,
workflows: workflowsToTrigger,
@@ -156,6 +159,7 @@ export class WorkflowService {
responses,
smsReminderNumber,
hideBranding,
routedEventTypeId,
form: {
id: form.id,
userId: form.userId,
@@ -547,6 +547,7 @@ function WorkflowPage({
? routingFormOptions
: allEventTypeOptions
}
eventTypeOptions={allEventTypeOptions}
onSaveWorkflow={handleSaveWorkflow}
permissions={permissions}
/>
+5 -1
View File
@@ -1,5 +1,7 @@
import type { z } from "zod";
import type { FORM_SUBMITTED_WEBHOOK_RESPONSES } from "@calcom/app-store/routing-forms/lib/formSubmissionUtils";
export type TaskerTypes = "internal" | "redis";
type TaskPayloads = {
sendWebhook: string;
@@ -28,10 +30,12 @@ type TaskPayloads = {
agentId: string;
fromNumber: string;
toNumber: string;
bookingUid: string;
bookingUid: string | null;
userId: number | null;
teamId: number | null;
providerAgentId: string;
responses?: FORM_SUBMITTED_WEBHOOK_RESPONSES | null;
routedEventTypeId?: number | null;
};
};
export type TaskTypes = keyof TaskPayloads;
@@ -1,8 +1,14 @@
import type { FORM_SUBMITTED_WEBHOOK_RESPONSES } from "@calcom/app-store/routing-forms/lib/formSubmissionUtils";
import dayjs from "@calcom/dayjs";
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
import { createDefaultAIPhoneServiceProvider } from "@calcom/features/calAIPhone";
import { handleInsufficientCredits } from "@calcom/features/ee/billing/helpers/handleInsufficientCredits";
import { WorkflowReminderRepository } from "@calcom/features/ee/workflows/lib/repository/workflowReminder";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import {
getSubmitterEmail,
getSubmitterName,
} from "@calcom/features/tasker/tasks/triggerFormSubmittedNoEvent/formSubmissionValidation";
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import logger from "@calcom/lib/logger";
import prisma from "@calcom/prisma";
@@ -13,13 +19,95 @@ interface ExecuteAIPhoneCallPayload {
agentId: string;
fromNumber: string;
toNumber: string;
bookingUid: string;
bookingUid: string | null;
userId: number | null;
teamId: number | null;
providerAgentId: string;
responses?: FORM_SUBMITTED_WEBHOOK_RESPONSES | null;
routedEventTypeId?: number | null;
}
const log = logger.getSubLogger({ prefix: [`[[executeAIPhoneCall] `] });
type BookingWithRelations = NonNullable<
NonNullable<
Awaited<ReturnType<typeof WorkflowReminderRepository.findWorkflowReminderForAIPhoneCallExecution>>
>["booking"]
>;
function getVariablesFromFormResponse({
responses,
eventTypeId,
numberToCall,
}: {
responses: FORM_SUBMITTED_WEBHOOK_RESPONSES;
eventTypeId: number | null;
numberToCall: string;
}) {
const submittedEmail = getSubmitterEmail(responses);
const submittedName = getSubmitterName(responses);
return {
ATTENDEE_NAME: submittedName || "",
ATTENDEE_EMAIL: submittedEmail || "",
NUMBER_TO_CALL: numberToCall,
eventTypeId: eventTypeId?.toString() || "",
};
}
function getVariablesFromBooking(booking: BookingWithRelations, numberToCall: string) {
const attendee = booking.attendees[0];
const timeZone = booking.user?.timeZone || attendee?.timeZone || "UTC";
const { responses } = getCalEventResponses({
bookingFields: booking.eventType?.bookingFields ?? null,
booking: {
...booking,
customInputs: null,
},
});
const attendeeNameWords = attendee?.name?.trim().split(" ") || [];
const attendeeFirstName = attendeeNameWords[0] || "";
const attendeeLastName =
attendeeNameWords.length > 1 ? attendeeNameWords[attendeeNameWords.length - 1] : "";
return {
EVENT_NAME: booking.eventType?.title || "",
EVENT_DATE: dayjs(booking.startTime).tz(timeZone).format("dddd, MMMM D, YYYY"),
EVENT_TIME: dayjs(booking.startTime).tz(timeZone).format("h:mm A"),
EVENT_END_TIME: dayjs(booking.endTime).tz(timeZone).format("h:mm A"),
TIMEZONE: timeZone,
LOCATION: booking.location || "",
ORGANIZER_NAME: booking.user?.name || "",
ATTENDEE_NAME: attendee?.name || "",
ATTENDEE_FIRST_NAME: attendeeFirstName,
ATTENDEE_LAST_NAME: attendeeLastName,
ATTENDEE_EMAIL: attendee?.email || "",
NUMBER_TO_CALL: numberToCall,
ATTENDEE_TIMEZONE: attendee?.timeZone || "",
ADDITIONAL_NOTES: booking.description || "",
EVENT_START_TIME_IN_ATTENDEE_TIMEZONE: dayjs(booking.startTime)
.tz(attendee?.timeZone || timeZone)
.format("h:mm A"),
EVENT_END_TIME_IN_ATTENDEE_TIMEZONE: dayjs(booking.endTime)
.tz(attendee?.timeZone || timeZone)
.format("h:mm A"),
// DO NOT REMOVE THIS FIELD. It is used for conditional tool routing in prompts
eventTypeId: booking.eventTypeId?.toString() || "",
// Include any custom form responses
...Object.fromEntries(
Object.entries(responses || {}).map(([key, value]) => [
key
.replace(/[^a-zA-Z0-9 ]/g, "")
.trim()
.replaceAll(" ", "_")
.toUpperCase(),
value.value?.toString() || "",
])
),
};
}
export async function executeAIPhoneCall(payload: string) {
let data: ExecuteAIPhoneCallPayload;
try {
@@ -40,59 +128,9 @@ export async function executeAIPhoneCall(payload: string) {
log.info(`Executing AI phone call for workflow reminder ${data.workflowReminderId}`, data);
try {
// Check if the workflow reminder still exists and is scheduled
const workflowReminder = await prisma.workflowReminder.findUnique({
where: { id: data.workflowReminderId },
select: {
id: true,
scheduled: true,
referenceId: true,
workflowStep: {
select: {
agent: {
select: {
outboundPhoneNumbers: {
select: {
phoneNumber: true,
},
},
},
},
},
},
booking: {
select: {
uid: true,
startTime: true,
endTime: true,
eventTypeId: true,
responses: true,
location: true,
description: true,
attendees: {
select: {
name: true,
email: true,
phoneNumber: true,
timeZone: true,
},
},
eventType: {
select: {
title: true,
bookingFields: true,
},
},
user: {
select: {
name: true,
timeZone: true,
},
},
},
},
},
});
const workflowReminder = await WorkflowReminderRepository.findWorkflowReminderForAIPhoneCallExecution(
data.workflowReminderId
);
if (!workflowReminder || !workflowReminder.scheduled) {
log.warn(`Workflow reminder ${data.workflowReminderId} not found or not scheduled`);
@@ -147,83 +185,64 @@ export async function executeAIPhoneCall(payload: string) {
}
// TODO: add better rate limiting for AI phone calls
if (rateLimitIdentifier) {
await checkRateLimitAndThrowError({
rateLimitingType: "core",
identifier: rateLimitIdentifier,
});
}
await checkRateLimitAndThrowError({
rateLimitingType: "core",
identifier: rateLimitIdentifier,
});
const booking = workflowReminder.booking;
if (!booking) {
log.warn(`No booking found for workflow reminder ${data.workflowReminderId}`);
throw new Error("No booking found");
// form triggers don't have a booking
const booking = workflowReminder.booking as BookingWithRelations | null;
const responses = data.responses;
if (!booking && !responses) {
log.warn(`No form responses or booking found for workflow reminder ${data.workflowReminderId}`);
throw new Error("No booking, response, or form responses found");
}
const numberToCall = data.toNumber;
if (!numberToCall) {
log.warn(`No phone number found for attendee in booking ${booking.uid}`);
throw new Error("No phone number found for attendee");
log.warn(`No phone number found in booking ${booking?.uid} or the routing form responses`);
throw new Error("No phone number found");
}
const attendee = booking.attendees[0];
const timeZone = booking.user?.timeZone || attendee?.timeZone || "UTC";
type VariablesType = {
ATTENDEE_EMAIL: string;
ATTENDEE_NAME: string;
NUMBER_TO_CALL: string;
eventTypeId: string;
} & Partial<ReturnType<typeof getVariablesFromBooking>>;
const { responses } = getCalEventResponses({
bookingFields: booking.eventType?.bookingFields ?? null,
booking: {
...booking,
customInputs: null,
},
});
// Prefer response variables if present, else fall back to booking
let dynamicVariables: VariablesType | undefined;
if (responses) {
const workflowStep = workflowReminder.workflowStep;
const eventTypeId =
data.routedEventTypeId ??
(workflowStep?.workflow.trigger === "FORM_SUBMITTED"
? workflowStep.agent?.outboundEventTypeId
: null);
if (!eventTypeId) {
log.warn(
`Form not routed to an event type and no event type id found for workflow reminder ${data.workflowReminderId}`
);
return;
}
dynamicVariables = getVariablesFromFormResponse({
responses,
eventTypeId,
numberToCall,
});
} else if (booking) {
dynamicVariables = getVariablesFromBooking(booking, numberToCall);
}
const attendeeNameWords = attendee?.name?.trim().split(" ") || [];
const attendeeNameWordCount = attendeeNameWords.length;
const attendeeFirstName = attendeeNameWords[0] || "";
const attendeeLastName = attendeeNameWordCount > 1 ? attendeeNameWords[attendeeNameWordCount - 1] : "";
const dynamicVariables = {
EVENT_NAME: booking.eventType?.title || "",
EVENT_DATE: dayjs(booking.startTime).tz(timeZone).format("dddd, MMMM D, YYYY"),
EVENT_TIME: dayjs(booking.startTime).tz(timeZone).format("h:mm A"),
EVENT_END_TIME: dayjs(booking.endTime).tz(timeZone).format("h:mm A"),
TIMEZONE: timeZone,
LOCATION: booking.location || "",
ORGANIZER_NAME: booking.user?.name || "",
ATTENDEE_NAME: attendee?.name || "",
ATTENDEE_FIRST_NAME: attendeeFirstName,
ATTENDEE_LAST_NAME: attendeeLastName,
ATTENDEE_EMAIL: attendee?.email || "",
NUMBER_TO_CALL: numberToCall,
ATTENDEE_TIMEZONE: attendee?.timeZone || "",
ADDITIONAL_NOTES: booking.description || "",
EVENT_START_TIME_IN_ATTENDEE_TIMEZONE: dayjs(booking.startTime)
.tz(attendee?.timeZone || timeZone)
.format("h:mm A"),
EVENT_END_TIME_IN_ATTENDEE_TIMEZONE: dayjs(booking.endTime)
.tz(attendee?.timeZone || timeZone)
.format("h:mm A"),
// DO NOT REMOVE THIS FIELD. It is used for conditional tool routing in prompts
eventTypeId: booking.eventTypeId?.toString() || "",
// Include any custom form responses
...Object.fromEntries(
Object.entries(responses || {}).map(([key, value]) => [
key
.replace(/[^a-zA-Z0-9 ]/g, "")
.trim()
.replaceAll(" ", "_")
.toUpperCase(),
value.value?.toString() || "",
])
),
};
if (!dynamicVariables) return;
const aiService = createDefaultAIPhoneServiceProvider();
await aiService.updateToolsFromAgentId(data.providerAgentId, {
eventTypeId: booking.eventTypeId,
timeZone: attendee?.timeZone ?? "Europe/London",
eventTypeId: dynamicVariables.eventTypeId ? Number(dynamicVariables.eventTypeId) : null,
timeZone: dynamicVariables.TIMEZONE || "UTC",
userId: data.userId,
teamId: data.teamId,
});
@@ -236,13 +255,9 @@ export async function executeAIPhoneCall(payload: string) {
log.info("AI phone call created successfully:", call);
// Update the workflow reminder with the call reference
await prisma.workflowReminder.update({
where: { id: data.workflowReminderId },
data: {
referenceId: call.call_id,
scheduled: true,
},
await WorkflowReminderRepository.updateWorkflowReminderReferenceAndScheduled(data.workflowReminderId, {
referenceId: call.call_id,
scheduled: true,
});
log.info(`AI phone call executed successfully for workflow reminder ${data.workflowReminderId}`, {
@@ -251,6 +266,7 @@ export async function executeAIPhoneCall(payload: string) {
fromNumber: data.fromNumber,
toNumber: numberToCall,
bookingUid: data.bookingUid,
workflowReminderId: data.workflowReminderId,
});
} catch (error) {
log.error("Error executing AI phone call:", error);
@@ -1,11 +1,12 @@
import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository";
import { RoutingFormResponseRepository } from "@calcom/lib/server/repository/formResponse";
import prisma from "@calcom/prisma";
import type { FORM_SUBMITTED_WEBHOOK_RESPONSES } from "@calcom/routing-forms/lib/formSubmissionUtils";
export interface ValidationOptions {
responseId: number;
formId: string;
responses: any;
responses: FORM_SUBMITTED_WEBHOOK_RESPONSES;
submittedAt?: Date;
}
@@ -36,17 +37,33 @@ export async function shouldTriggerFormSubmittedNoEvent(options: ValidationOptio
return true;
}
export function getSubmitterEmail(responses: any) {
const submitterEmail = Object.values(responses).find(
(response): response is { value: string; label: string } => {
const value =
typeof response === "object" && response && "value" in response ? response.value : response;
return typeof value === "string" && value.includes("@");
}
)?.value;
export function getSubmitterEmail(responses: FORM_SUBMITTED_WEBHOOK_RESPONSES): string | undefined {
const submitterEmail = Object.values(responses).find((response) => {
const value = typeof response === "object" && response && "value" in response ? response.value : response;
return typeof value === "string" && value.includes("@");
})?.value;
if (typeof submitterEmail !== "string") return undefined;
return submitterEmail;
}
/**
* Extracts the submitter's name from form responses.
* Looks for fields with identifiers "name", "firstName", or "lastName" in that order of preference.
*/
export function getSubmitterName(responses: FORM_SUBMITTED_WEBHOOK_RESPONSES) {
const nameField = responses?.name || responses?.firstName || responses?.lastName;
if (nameField && typeof nameField === "object") {
// Try the new response property first, then fall back to value
if (typeof nameField.response === "string") {
return nameField.response;
}
if (typeof nameField.value === "string") {
return nameField.value;
}
}
}
/**
* Check for duplicate form submissions within the last 60 minutes
*/
@@ -57,7 +74,7 @@ async function hasDuplicateSubmission({
submittedAt,
}: {
formId: string;
responses: any;
responses: FORM_SUBMITTED_WEBHOOK_RESPONSES;
responseId: number;
submittedAt?: Date;
}): Promise<boolean> {
@@ -45,7 +45,7 @@ export async function triggerFormSubmittedNoEventWebhook(payload: string): Promi
const shouldTrigger = await shouldTriggerFormSubmittedNoEvent({
formId: form.id,
responses,
responses: responses as FORM_SUBMITTED_WEBHOOK_RESPONSES,
responseId,
});
@@ -62,7 +62,7 @@ export async function triggerFormSubmittedNoEventWebhook(payload: string): Promi
teamId: form.teamId,
redirect,
responseId,
responses,
responses: responses as FORM_SUBMITTED_WEBHOOK_RESPONSES,
},
}).catch((e) => {
console.error(`Error executing FORM_SUBMITTED_NO_EVENT webhook`, webhook, e);
@@ -82,7 +82,7 @@ export async function triggerFormSubmittedNoEventWebhook(payload: string): Promi
// Get action function
const bookingActionFunction = incompleteBookingActionFunctions[actionType];
const emailValue = getSubmitterEmail(responses);
const emailValue = getSubmitterEmail(responses as FORM_SUBMITTED_WEBHOOK_RESPONSES);
if (emailValue) {
await bookingActionFunction(incompleteBookingAction, emailValue);
}
@@ -44,6 +44,7 @@ function expectFormSubmittedNoEventWorkflowToBeCalled(payload: WorkflowPayload)
timeFormat: payload.form.user.timeFormat,
locale: payload.form.user.locale ?? "en",
},
routedEventTypeId: payload.routedEventTypeId ?? null,
},
hideBranding: payload.hideBranding,
smsReminderNumber: payload.smsReminderNumber,
@@ -78,6 +79,7 @@ describe("Form submitted, no event booked workflow trigger", () => {
response: "Test input 1",
},
},
routedEventTypeId: null,
hideBranding: false,
smsReminderNumber: null,
submittedAt: new Date("2024-01-01T10:00:00Z"),
@@ -136,6 +138,7 @@ describe("Form submitted, no event booked workflow trigger", () => {
response: "Test input 2",
},
},
routedEventTypeId: null,
hideBranding: false,
smsReminderNumber: null,
submittedAt: new Date("2024-01-01T11:00:00Z"),
@@ -1,5 +1,6 @@
import { z } from "zod";
import type { FORM_SUBMITTED_WEBHOOK_RESPONSES } from "@calcom/app-store/routing-forms/lib/formSubmissionUtils";
import { scheduleWorkflowReminders } from "@calcom/ee/workflows/lib/reminders/reminderScheduler";
import type { Workflow } from "@calcom/ee/workflows/lib/types";
import logger from "@calcom/lib/logger";
@@ -11,7 +12,8 @@ const log = logger.getSubLogger({ prefix: ["[tasker] triggerFormSubmittedNoEvent
export const ZTriggerFormSubmittedNoEventWorkflowPayloadSchema = z.object({
responseId: z.number(),
responses: z.any(),
responses: z.record(z.unknown()),
routedEventTypeId: z.number().nullable().optional(),
form: z.object({
id: z.string(),
userId: z.number(),
@@ -30,13 +32,21 @@ export const ZTriggerFormSubmittedNoEventWorkflowPayloadSchema = z.object({
});
export async function triggerFormSubmittedNoEventWorkflow(payload: string): Promise<void> {
const { responseId, form, responses, smsReminderNumber, hideBranding, workflow, submittedAt } =
ZTriggerFormSubmittedNoEventWorkflowPayloadSchema.parse(JSON.parse(payload));
const {
responseId,
form,
responses,
smsReminderNumber,
hideBranding,
workflow,
submittedAt,
routedEventTypeId,
} = ZTriggerFormSubmittedNoEventWorkflowPayloadSchema.parse(JSON.parse(payload));
const shouldTrigger = await shouldTriggerFormSubmittedNoEvent({
formId: form.id,
responseId,
responses,
responses: responses as FORM_SUBMITTED_WEBHOOK_RESPONSES,
submittedAt,
});
@@ -46,8 +56,9 @@ export async function triggerFormSubmittedNoEventWorkflow(payload: string): Prom
await scheduleWorkflowReminders({
smsReminderNumber,
formData: {
responses,
responses: responses as FORM_SUBMITTED_WEBHOOK_RESPONSES,
user: { email: form.user.email, timeFormat: form.user.timeFormat, locale: form.user.locale ?? "en" },
routedEventTypeId: routedEventTypeId ?? null,
},
hideBranding,
workflows: [workflow as Workflow],
@@ -9,6 +9,7 @@ interface _AgentRawResult {
userId: number;
teamId: number | null;
inboundEventTypeId?: number | null;
outboundEventTypeId?: number | null;
createdAt: Date;
updatedAt: Date;
user_id?: number;
@@ -101,6 +102,7 @@ export class PrismaAgentRepository {
"userId",
"teamId",
"inboundEventTypeId",
"outboundEventTypeId",
"createdAt",
"updatedAt"
FROM "Agent"
@@ -140,6 +142,7 @@ export class PrismaAgentRepository {
"userId",
"teamId",
"inboundEventTypeId",
"outboundEventTypeId",
"createdAt",
"updatedAt"
FROM "Agent"
@@ -162,6 +165,7 @@ export class PrismaAgentRepository {
userId: true,
teamId: true,
inboundEventTypeId: true,
outboundEventTypeId: true,
createdAt: true,
updatedAt: true,
},
@@ -181,6 +185,7 @@ export class PrismaAgentRepository {
userId: true,
teamId: true,
inboundEventTypeId: true,
outboundEventTypeId: true,
createdAt: true,
updatedAt: true,
team: {
@@ -252,6 +257,7 @@ export class PrismaAgentRepository {
a."userId",
a."teamId",
a."inboundEventTypeId",
a."outboundEventTypeId",
a."createdAt",
a."updatedAt",
u.id as user_id,
@@ -312,6 +318,7 @@ export class PrismaAgentRepository {
userId: agent.userId,
teamId: agent.teamId,
inboundEventTypeId: agent.inboundEventTypeId,
outboundEventTypeId: agent.outboundEventTypeId,
createdAt: agent.createdAt,
updatedAt: agent.updatedAt,
user: agent.user_id
@@ -372,6 +379,7 @@ export class PrismaAgentRepository {
a."userId",
a."teamId",
a."inboundEventTypeId",
a."outboundEventTypeId",
a."createdAt",
a."updatedAt",
u.id as user_id,
@@ -414,6 +422,7 @@ export class PrismaAgentRepository {
userId: agent.userId,
teamId: agent.teamId,
inboundEventTypeId: agent.inboundEventTypeId,
outboundEventTypeId: agent.outboundEventTypeId,
createdAt: agent.createdAt,
updatedAt: agent.updatedAt,
user: agent.user_id
@@ -490,7 +499,8 @@ export class PrismaAgentRepository {
"teamId",
"createdAt",
"updatedAt",
"inboundEventTypeId"
"inboundEventTypeId",
"outboundEventTypeId"
FROM "Agent"
WHERE ${whereCondition}
LIMIT 1
@@ -522,6 +532,7 @@ export class PrismaAgentRepository {
a."userId",
a."teamId",
a."inboundEventTypeId",
a."outboundEventTypeId",
a."createdAt",
a."updatedAt"
FROM "Agent" a
@@ -590,6 +601,17 @@ export class PrismaAgentRepository {
});
}
async updateOutboundEventTypeId({ agentId, eventTypeId }: { agentId: string; eventTypeId: number }) {
return await this.prismaClient.agent.update({
where: {
id: agentId,
},
data: {
outboundEventTypeId: eventTypeId,
},
});
}
async canManageTeamResources({ userId, teamId }: { userId: number; teamId: number }): Promise<boolean> {
const result = await this.prismaClient.$queryRaw<{ count: bigint }[]>`
SELECT COUNT(*) as count
@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "public"."Agent" ADD COLUMN "outboundEventTypeId" INTEGER;
-- CreateIndex
CREATE INDEX "Agent_outboundEventTypeId_idx" ON "public"."Agent"("outboundEventTypeId");
+4
View File
@@ -2651,6 +2651,9 @@ model Agent {
// Event type association for inbound agents
inboundEventTypeId Int?
// Event type association for outbound agents
outboundEventTypeId Int?
enabled Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -2663,6 +2666,7 @@ model Agent {
@@index([userId])
@@index([teamId])
@@index([inboundEventTypeId])
@@index([outboundEventTypeId])
}
model CalAiPhoneNumber {
@@ -12,19 +12,25 @@ type UpdateHandlerOptions = {
};
export const updateHandler = async ({ ctx, input }: UpdateHandlerOptions) => {
const { id, teamId, name, enabled, ...retellUpdates } = input;
const { id, teamId, name, outboundEventTypeId, ...retellUpdates } = input;
const aiService = createDefaultAIPhoneServiceProvider();
const userId = ctx.user.id;
const userTimeZone = ctx.user.timeZone || "UTC";
const updatedPrompt = retellUpdates.generalPrompt ?? undefined;
return await aiService.updateAgentConfiguration({
id,
userId: ctx.user.id,
userId,
teamId,
name,
generalPrompt: retellUpdates.generalPrompt ?? undefined,
generalPrompt: updatedPrompt,
beginMessage: retellUpdates.beginMessage ?? undefined,
generalTools: retellUpdates.generalTools as RetellLLMGeneralTools,
voiceId: retellUpdates.voiceId,
language: retellUpdates.language,
outboundEventTypeId,
timeZone: userTimeZone,
});
};
@@ -62,6 +62,7 @@ export const ZUpdateInputSchema = z.object({
.optional(),
voiceId: z.string().optional(),
language: languageSchema.optional(),
outboundEventTypeId: z.number().optional(),
});
export type TUpdateInputSchema = z.infer<typeof ZUpdateInputSchema>;
@@ -909,6 +909,8 @@ export async function scheduleBookingReminders(
userId,
teamId,
verifiedAt: step?.verifiedAt ?? null,
submittedPhoneNumber: booking.smsReminderNumber,
routedEventTypeId: null,
});
}
});