* 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:
co-authored by
Udit Takkar
CarinaWolli
Udit Takkar
Benny Joo
cal.com
Morgan
Peer Richelsen
parent
a73b804d48
commit
9e1e50e90b
@@ -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 && (
|
||||
|
||||
+16
-1
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
+11
-11
@@ -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}
|
||||
|
||||
+38
-1
@@ -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}
|
||||
|
||||
+13
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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);
|
||||
|
||||
+27
-10
@@ -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> {
|
||||
|
||||
+3
-3
@@ -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);
|
||||
}
|
||||
|
||||
+3
@@ -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"),
|
||||
|
||||
+16
-5
@@ -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
|
||||
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."Agent" ADD COLUMN "outboundEventTypeId" INTEGER;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Agent_outboundEventTypeId_idx" ON "public"."Agent"("outboundEventTypeId");
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user