feat: inbound calls in cal ai (#23890)
* feat: lang support * fix: type errors * feat: select voice agent * refactor: address feedback * refactor: address feedback * refactor: missing import * fix: types * feat: add inbound calls * chore: formatting * chore * feat: finish inbound call * chore: formatting * fix: update bug * fix: types * refactor: Agent Configuration Sheet (#23930) * refactor: agent configuration sheet * chore: use default phone numbre * refactor: improvements * refactor: improvements * fix: types * fix: feedback * chore: * fix: feedback * fix: prompt * fix: review * fix: review * refactor: class * refactor: class * refactor: rename * Update apps/web/public/static/locales/en/common.json * Update apps/web/public/static/locales/en/common.json * chore: update set value * fix: remove index * fix: type error * fix: update tetss * fix: use logger * refactor: don't use static * fix: type * fix: schema * refactor: --------- Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
This commit is contained in:
co-authored by
Carina Wollendorfer
parent
bec49ef417
commit
aa48e72c12
@@ -2,8 +2,6 @@ import type { NextRequest } from "next/server";
|
||||
import { Retell } from "retell-sdk";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import { PrismaAgentRepository } from "@calcom/lib/server/repository/PrismaAgentRepository";
|
||||
import { PrismaPhoneNumberRepository } from "@calcom/lib/server/repository/PrismaPhoneNumberRepository";
|
||||
import type { CalAiPhoneNumber, User, Team, Agent } from "@calcom/prisma/client";
|
||||
|
||||
import { POST } from "../route";
|
||||
@@ -81,16 +79,19 @@ vi.mock("@calcom/features/ee/billing/credit-service", () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
const mockFindByPhoneNumber = vi.fn();
|
||||
const mockFindByProviderAgentId = vi.fn();
|
||||
|
||||
vi.mock("@calcom/lib/server/repository/PrismaPhoneNumberRepository", () => ({
|
||||
PrismaPhoneNumberRepository: {
|
||||
findByPhoneNumber: vi.fn(),
|
||||
},
|
||||
PrismaPhoneNumberRepository: vi.fn().mockImplementation(() => ({
|
||||
findByPhoneNumber: mockFindByPhoneNumber,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/lib/server/repository/PrismaAgentRepository", () => ({
|
||||
PrismaAgentRepository: {
|
||||
findByProviderAgentId: vi.fn(),
|
||||
},
|
||||
PrismaAgentRepository: vi.fn().mockImplementation(() => ({
|
||||
findByProviderAgentId: mockFindByProviderAgentId,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("next/server", () => ({
|
||||
@@ -196,7 +197,7 @@ describe("Retell AI Webhook Handler", () => {
|
||||
team: null,
|
||||
};
|
||||
|
||||
vi.mocked(PrismaPhoneNumberRepository.findByPhoneNumber).mockResolvedValue(mockPhoneNumber);
|
||||
mockFindByPhoneNumber.mockResolvedValue(mockPhoneNumber);
|
||||
|
||||
mockHasAvailableCredits.mockResolvedValue(true);
|
||||
mockChargeCredits.mockResolvedValue(undefined);
|
||||
@@ -255,7 +256,7 @@ describe("Retell AI Webhook Handler", () => {
|
||||
user: null,
|
||||
};
|
||||
|
||||
vi.mocked(PrismaPhoneNumberRepository.findByPhoneNumber).mockResolvedValue(mockTeamPhoneNumber);
|
||||
mockFindByPhoneNumber.mockResolvedValue(mockTeamPhoneNumber);
|
||||
|
||||
mockHasAvailableCredits.mockResolvedValue(true);
|
||||
mockChargeCredits.mockResolvedValue(undefined);
|
||||
@@ -314,12 +315,12 @@ describe("Retell AI Webhook Handler", () => {
|
||||
const response = await callPOST(request);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(PrismaPhoneNumberRepository.findByPhoneNumber).not.toHaveBeenCalled();
|
||||
expect(mockFindByPhoneNumber).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle phone number not found", async () => {
|
||||
vi.mocked(Retell.verify).mockReturnValue(true);
|
||||
vi.mocked(PrismaPhoneNumberRepository.findByPhoneNumber).mockResolvedValue(null);
|
||||
mockFindByPhoneNumber.mockResolvedValue(null);
|
||||
|
||||
const body: RetellWebhookBody = {
|
||||
event: "call_analyzed",
|
||||
@@ -365,7 +366,7 @@ describe("Retell AI Webhook Handler", () => {
|
||||
team: null,
|
||||
};
|
||||
|
||||
vi.mocked(PrismaPhoneNumberRepository.findByPhoneNumber).mockResolvedValue(mockPhoneNumber);
|
||||
mockFindByPhoneNumber.mockResolvedValue(mockPhoneNumber);
|
||||
|
||||
mockHasAvailableCredits.mockResolvedValue(false);
|
||||
|
||||
@@ -440,7 +441,7 @@ describe("Retell AI Webhook Handler", () => {
|
||||
team: null,
|
||||
};
|
||||
|
||||
vi.mocked(PrismaPhoneNumberRepository.findByPhoneNumber).mockResolvedValue(mockPhoneNumber);
|
||||
mockFindByPhoneNumber.mockResolvedValue(mockPhoneNumber);
|
||||
mockHasAvailableCredits.mockResolvedValue(true);
|
||||
mockChargeCredits.mockResolvedValue(undefined);
|
||||
|
||||
@@ -494,7 +495,7 @@ describe("Retell AI Webhook Handler", () => {
|
||||
user: { id: 42, email: "u@example.com", name: "U" },
|
||||
team: null,
|
||||
};
|
||||
vi.mocked(PrismaPhoneNumberRepository.findByPhoneNumber).mockResolvedValue(mockPhoneNumber);
|
||||
mockFindByPhoneNumber.mockResolvedValue(mockPhoneNumber);
|
||||
mockHasAvailableCredits.mockResolvedValue(true);
|
||||
mockChargeCredits.mockResolvedValue(undefined);
|
||||
|
||||
@@ -544,7 +545,7 @@ describe("Retell AI Webhook Handler", () => {
|
||||
user: { id: 1, email: "test@example.com", name: "Test User" },
|
||||
team: null,
|
||||
};
|
||||
vi.mocked(PrismaPhoneNumberRepository.findByPhoneNumber).mockResolvedValue(mockPhoneNumber);
|
||||
mockFindByPhoneNumber.mockResolvedValue(mockPhoneNumber);
|
||||
mockChargeCredits.mockResolvedValue({ userId: 1 });
|
||||
|
||||
const body: RetellWebhookBody = {
|
||||
@@ -596,7 +597,7 @@ describe("Retell AI Webhook Handler", () => {
|
||||
user: { id: 1, email: "test@example.com", name: "Test User" },
|
||||
team: null,
|
||||
};
|
||||
vi.mocked(PrismaPhoneNumberRepository.findByPhoneNumber).mockResolvedValue(mockPhoneNumber);
|
||||
mockFindByPhoneNumber.mockResolvedValue(mockPhoneNumber);
|
||||
|
||||
const body: RetellWebhookBody = {
|
||||
event: "call_analyzed",
|
||||
@@ -661,7 +662,7 @@ describe("Retell AI Webhook Handler", () => {
|
||||
user: { id: 1, email: "test@example.com", name: "Test User" },
|
||||
team: null,
|
||||
};
|
||||
vi.mocked(PrismaPhoneNumberRepository.findByPhoneNumber).mockResolvedValue(mockPhoneNumber);
|
||||
mockFindByPhoneNumber.mockResolvedValue(mockPhoneNumber);
|
||||
|
||||
// Mock chargeCredits to throw an error
|
||||
mockChargeCredits.mockRejectedValue(new Error("Credit service error"));
|
||||
@@ -695,7 +696,7 @@ describe("Retell AI Webhook Handler", () => {
|
||||
describe("Web Call Tests", () => {
|
||||
const mockAgent: Pick<
|
||||
Agent,
|
||||
"id" | "name" | "providerAgentId" | "enabled" | "userId" | "teamId" | "createdAt" | "updatedAt"
|
||||
"id" | "name" | "providerAgentId" | "enabled" | "userId" | "teamId" | "createdAt" | "updatedAt" | "inboundEventTypeId"
|
||||
> = {
|
||||
id: "agent-123",
|
||||
name: "Test Agent",
|
||||
@@ -705,6 +706,7 @@ describe("Retell AI Webhook Handler", () => {
|
||||
teamId: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
inboundEventTypeId: null,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -713,7 +715,7 @@ describe("Retell AI Webhook Handler", () => {
|
||||
});
|
||||
|
||||
it("should process web call with valid agent and charge credits", async () => {
|
||||
vi.mocked(PrismaAgentRepository.findByProviderAgentId).mockResolvedValue(mockAgent);
|
||||
mockFindByProviderAgentId.mockResolvedValue(mockAgent);
|
||||
mockChargeCredits.mockResolvedValue(undefined);
|
||||
|
||||
const body: RetellWebhookBody = {
|
||||
@@ -739,7 +741,7 @@ describe("Retell AI Webhook Handler", () => {
|
||||
const data = await response.json();
|
||||
expect(data.success).toBe(true);
|
||||
|
||||
expect(PrismaAgentRepository.findByProviderAgentId).toHaveBeenCalledWith({
|
||||
expect(mockFindByProviderAgentId).toHaveBeenCalledWith({
|
||||
providerAgentId: "agent_5e3e0d29d692172c2c24d8f9a7",
|
||||
});
|
||||
|
||||
@@ -755,8 +757,8 @@ describe("Retell AI Webhook Handler", () => {
|
||||
});
|
||||
|
||||
it("should handle web call with team agent", async () => {
|
||||
const teamAgent = { ...mockAgent, userId: 2, teamId: 10 };
|
||||
vi.mocked(PrismaAgentRepository.findByProviderAgentId).mockResolvedValue(teamAgent);
|
||||
const teamAgent = { ...mockAgent, userId: 2, teamId: 10, inboundEventTypeId: null };
|
||||
mockFindByProviderAgentId.mockResolvedValue(teamAgent);
|
||||
mockChargeCredits.mockResolvedValue(undefined);
|
||||
|
||||
const body: RetellWebhookBody = {
|
||||
@@ -789,7 +791,7 @@ describe("Retell AI Webhook Handler", () => {
|
||||
});
|
||||
|
||||
it("should handle web call without from_number", async () => {
|
||||
vi.mocked(PrismaAgentRepository.findByProviderAgentId).mockResolvedValue(mockAgent);
|
||||
mockFindByProviderAgentId.mockResolvedValue(mockAgent);
|
||||
mockChargeCredits.mockResolvedValue(undefined);
|
||||
|
||||
const body: RetellWebhookBody = {
|
||||
@@ -810,8 +812,8 @@ describe("Retell AI Webhook Handler", () => {
|
||||
const response = await callPOST(request);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(PrismaAgentRepository.findByProviderAgentId).toHaveBeenCalled();
|
||||
expect(PrismaPhoneNumberRepository.findByPhoneNumber).not.toHaveBeenCalled();
|
||||
expect(mockFindByProviderAgentId).toHaveBeenCalled();
|
||||
expect(mockFindByPhoneNumber).not.toHaveBeenCalled();
|
||||
expect(mockChargeCredits).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -833,12 +835,12 @@ describe("Retell AI Webhook Handler", () => {
|
||||
const response = await callPOST(request);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(PrismaAgentRepository.findByProviderAgentId).not.toHaveBeenCalled();
|
||||
expect(mockFindByProviderAgentId).not.toHaveBeenCalled();
|
||||
expect(mockChargeCredits).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle web call with agent not found", async () => {
|
||||
vi.mocked(PrismaAgentRepository.findByProviderAgentId).mockResolvedValue(null);
|
||||
mockFindByProviderAgentId.mockResolvedValue(null);
|
||||
|
||||
const body: RetellWebhookBody = {
|
||||
event: "call_analyzed",
|
||||
@@ -860,7 +862,7 @@ describe("Retell AI Webhook Handler", () => {
|
||||
const response = await callPOST(request);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(PrismaAgentRepository.findByProviderAgentId).toHaveBeenCalledWith({
|
||||
expect(mockFindByProviderAgentId).toHaveBeenCalledWith({
|
||||
providerAgentId: "non-existent-agent",
|
||||
});
|
||||
expect(mockChargeCredits).not.toHaveBeenCalled();
|
||||
|
||||
@@ -9,6 +9,7 @@ import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import { PrismaAgentRepository } from "@calcom/lib/server/repository/PrismaAgentRepository";
|
||||
import { PrismaPhoneNumberRepository } from "@calcom/lib/server/repository/PrismaPhoneNumberRepository";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { CreditUsageType } from "@calcom/prisma/enums";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["retell-ai-webhook"] });
|
||||
@@ -133,7 +134,7 @@ async function handleCallAnalyzed(callData: any) {
|
||||
);
|
||||
return {
|
||||
success: true,
|
||||
message: `Invalid or missing call_cost.total_duration_seconds for call ${call_id}`
|
||||
message: `Invalid or missing call_cost.total_duration_seconds for call ${call_id}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -146,11 +147,12 @@ async function handleCallAnalyzed(callData: any) {
|
||||
log.error(`Web call ${call_id} missing agent_id, cannot charge credits`);
|
||||
return {
|
||||
success: false,
|
||||
message: `Web call ${call_id} missing agent_id, cannot charge credits`
|
||||
message: `Web call ${call_id} missing agent_id, cannot charge credits`,
|
||||
};
|
||||
}
|
||||
|
||||
const agent = await PrismaAgentRepository.findByProviderAgentId({
|
||||
const agentRepo = new PrismaAgentRepository(prisma);
|
||||
const agent = await agentRepo.findByProviderAgentId({
|
||||
providerAgentId: agent_id,
|
||||
});
|
||||
|
||||
@@ -158,17 +160,17 @@ async function handleCallAnalyzed(callData: any) {
|
||||
log.error(`No agent found for providerAgentId ${agent_id}, call ${call_id}`);
|
||||
return {
|
||||
success: false,
|
||||
message: `No agent found for providerAgentId ${agent_id}, call ${call_id}`
|
||||
message: `No agent found for providerAgentId ${agent_id}, call ${call_id}`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
userId = agent.userId ?? undefined;
|
||||
teamId = agent.teamId ?? undefined;
|
||||
|
||||
log.info(`Processing web call ${call_id} for agent ${agent_id}, user ${userId}, team ${teamId}`);
|
||||
} else {
|
||||
const phoneNumber = await PrismaPhoneNumberRepository.findByPhoneNumber({
|
||||
const phoneNumberRepo = new PrismaPhoneNumberRepository(prisma);
|
||||
const phoneNumber = await phoneNumberRepo.findByPhoneNumber({
|
||||
phoneNumber: from_number,
|
||||
});
|
||||
|
||||
|
||||
@@ -525,6 +525,11 @@
|
||||
<path d="M14.05 2a9 9 0 0 1 8 7.94" ></path>
|
||||
<path d="M14.05 6A5 5 0 0 1 18 10" ></path>
|
||||
</symbol>
|
||||
<symbol class="lucide lucide-phone-incoming" viewBox="0 0 24 24" fill="inherit" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" id="phone-incoming">
|
||||
<polyline points="16 2 16 8 22 8" ></polyline>
|
||||
<line x1="22" x2="16" y1="2" y2="8" ></line>
|
||||
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z" ></path>
|
||||
</symbol>
|
||||
<symbol class="lucide lucide-phone-off" viewBox="0 0 24 24" fill="inherit" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" id="phone-off">
|
||||
<path d="M10.68 13.31a16 16 0 0 0 3.41 2.6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7 2 2 0 0 1 1.72 2v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.42 19.42 0 0 1-3.33-2.67m-2.67-3.34a19.79 19.79 0 0 1-3.07-8.63A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91" ></path>
|
||||
<line x1="22" x2="2" y1="2" y2="22" ></line>
|
||||
|
||||
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 55 KiB |
@@ -310,6 +310,8 @@
|
||||
"register_now": "Register now",
|
||||
"register": "Register",
|
||||
"page_doesnt_exist": "This page does not exist.",
|
||||
"no_active_phone_number_available": "No active phone number available",
|
||||
"workflow_step_not_configured": "Workflow step not configured",
|
||||
"check_spelling_mistakes_or_go_back": "Check for spelling mistakes or go back to the previous page.",
|
||||
"404_page_not_found": "404: This page could not be found.",
|
||||
"booker_event_not_found": "We could not find the event you are trying to book.",
|
||||
@@ -837,6 +839,7 @@
|
||||
"please_enter_phone_number": "Please enter a phone number",
|
||||
"agent_updated_successfully": "Agent updated successfully",
|
||||
"agent_created_successfully": "Agent created successfully",
|
||||
"agent_event_type_updated_successfully": "Event type added successfully",
|
||||
"phone_number_unsubscribed_successfully": "Phone number unsubscribed successfully",
|
||||
"general_prompt_description": "This prompt defines the agent's role and primary objectives",
|
||||
"prompt": "Prompt",
|
||||
@@ -3756,5 +3759,19 @@
|
||||
"voice_id": "Voice ID",
|
||||
"use_voice": "Use Voice",
|
||||
"current_voice": "Current Voice",
|
||||
"setup_incoming_agent": "Set up incoming agent",
|
||||
"setup_incoming_agent_description": "Configure an AI agent to handle incoming calls on your phone number",
|
||||
"connect_a_phone_number_first": "Connect a Phone Number first",
|
||||
"setup_agent_for_incoming_calls": "Set up Agent for incoming calls",
|
||||
"configure_agent_to_handle_incoming_calls": "Configure agent to handle incoming calls",
|
||||
"incoming_calls": "Incoming Calls",
|
||||
"outgoing_calls": "Outgoing Calls",
|
||||
"inbound_agent_setup_success": "Inbound agent setup successful",
|
||||
"inbound_agent_configured": "Inbound agent configured",
|
||||
"setup_inbound_agent": "Set up Inbound Agent",
|
||||
"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.",
|
||||
"setup_inbound_agent_for_incoming_calls": "Set up inbound agent for incoming calls",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ describe("AIPhoneServiceRegistry", () => {
|
||||
updatePhoneNumberWithAgents: vi.fn().mockResolvedValue({ message: "test-message" }),
|
||||
listAgents: vi.fn().mockResolvedValue({ totalCount: 1, filtered: [] }),
|
||||
getAgentWithDetails: vi.fn().mockResolvedValue({ agent_id: "test-agent" }),
|
||||
createAgent: vi
|
||||
createOutboundAgent: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ id: "test-id", providerAgentId: "test-provider-id", message: "test-message" }),
|
||||
updateAgentConfiguration: vi.fn().mockResolvedValue({ message: "test-message" }),
|
||||
@@ -285,7 +285,7 @@ describe("createAIPhoneServiceProvider", () => {
|
||||
updatePhoneNumberWithAgents: vi.fn(),
|
||||
listAgents: vi.fn(),
|
||||
getAgentWithDetails: vi.fn(),
|
||||
createAgent: vi.fn(),
|
||||
createOutboundAgent: vi.fn(),
|
||||
updateAgentConfiguration: vi.fn(),
|
||||
deleteAgent: vi.fn(),
|
||||
createTestCall: vi.fn(),
|
||||
@@ -410,7 +410,7 @@ describe("createDefaultAIPhoneServiceProvider", () => {
|
||||
updatePhoneNumberWithAgents: vi.fn(),
|
||||
listAgents: vi.fn(),
|
||||
getAgentWithDetails: vi.fn(),
|
||||
createAgent: vi.fn(),
|
||||
createOutboundAgent: vi.fn(),
|
||||
updateAgentConfiguration: vi.fn(),
|
||||
deleteAgent: vi.fn(),
|
||||
createTestCall: vi.fn(),
|
||||
|
||||
@@ -285,7 +285,7 @@ export interface AIPhoneServiceProvider<T extends AIPhoneServiceProviderType = A
|
||||
/**
|
||||
* Create a new agent
|
||||
*/
|
||||
createAgent(params: {
|
||||
createOutboundAgent(params: {
|
||||
name?: string;
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
@@ -301,6 +301,22 @@ export interface AIPhoneServiceProvider<T extends AIPhoneServiceProviderType = A
|
||||
message: string;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Create a new inbound agent
|
||||
*/
|
||||
createInboundAgent(params: {
|
||||
name?: string;
|
||||
phoneNumber: string;
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
workflowStepId: number;
|
||||
userTimeZone: string;
|
||||
}): Promise<{
|
||||
id: string;
|
||||
providerAgentId: string;
|
||||
message: string;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Update agent configuration
|
||||
*/
|
||||
|
||||
@@ -53,7 +53,7 @@ export const DEFAULT_PROMPT_VALUE = `## You are helping user set up a call with
|
||||
- if availability exists, inform user about the availability range (do not repeat the detailed available slot) and ask user to choose from it. Make sure user chose a slot within detailed available slot.
|
||||
- if availability does not exist, ask user to select another time range for the appointment, repeat this step 3.
|
||||
5. Confirm the date and time selected by user: \"Just to confirm, you want to book the appointment at ...\".
|
||||
6. Once confirmed, you can use {{NUMBER_TO_CALL}} as phone number for creating booking and call function book_appointment_{{eventTypeId}} to book the appointment.
|
||||
6. Once confirmed, you can use {{user_number}} if it is not unknown else ask user for phone number in international format and use it for creating booking if it is a required field and call function book_appointment_{{eventTypeId}} to book the appointment.
|
||||
- if booking returned booking detail, it means booking is successful, proceed to step 7.
|
||||
- if booking returned error message, let user know why the booking was not successful, and maybe start over with step 3.
|
||||
7. Inform the user booking is successful, and ask if user have any questions. Answer them if there are any.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { PrismaAgentRepository } from "@calcom/lib/server/repository/PrismaAgentRepository";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
import type {
|
||||
AgentRepositoryInterface,
|
||||
AgentData,
|
||||
@@ -13,18 +13,21 @@ import type {
|
||||
*/
|
||||
export class PrismaAgentRepositoryAdapter implements AgentRepositoryInterface {
|
||||
async canManageTeamResources(params: { userId: number; teamId: number }): Promise<boolean> {
|
||||
return await PrismaAgentRepository.canManageTeamResources(params);
|
||||
const agentRepo = new PrismaAgentRepository(prisma);
|
||||
return await agentRepo.canManageTeamResources(params);
|
||||
}
|
||||
|
||||
async findByIdWithUserAccess(params: { agentId: string; userId: number }): Promise<AgentData | null> {
|
||||
return await PrismaAgentRepository.findByIdWithUserAccess(params);
|
||||
const agentRepo = new PrismaAgentRepository(prisma);
|
||||
return await agentRepo.findByIdWithUserAccess(params);
|
||||
}
|
||||
|
||||
async findByProviderAgentIdWithUserAccess(params: {
|
||||
providerAgentId: string;
|
||||
userId: number;
|
||||
}): Promise<AgentData | null> {
|
||||
return await PrismaAgentRepository.findByProviderAgentIdWithUserAccess(params);
|
||||
const agentRepo = new PrismaAgentRepository(prisma);
|
||||
return await agentRepo.findByProviderAgentIdWithUserAccess(params);
|
||||
}
|
||||
|
||||
async findManyWithUserAccess(params: {
|
||||
@@ -32,7 +35,8 @@ export class PrismaAgentRepositoryAdapter implements AgentRepositoryInterface {
|
||||
teamId?: number;
|
||||
scope?: "personal" | "team" | "all";
|
||||
}): Promise<AgentWithDetailsData[]> {
|
||||
return await PrismaAgentRepository.findManyWithUserAccess(params);
|
||||
const agentRepo = new PrismaAgentRepository(prisma);
|
||||
return await agentRepo.findManyWithUserAccess(params);
|
||||
}
|
||||
|
||||
async findByIdWithUserAccessAndDetails(params: {
|
||||
@@ -40,7 +44,8 @@ export class PrismaAgentRepositoryAdapter implements AgentRepositoryInterface {
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
}): Promise<AgentWithDetailsData | null> {
|
||||
return await PrismaAgentRepository.findByIdWithUserAccessAndDetails(params);
|
||||
const agentRepo = new PrismaAgentRepository(prisma);
|
||||
return await agentRepo.findByIdWithUserAccessAndDetails(params);
|
||||
}
|
||||
|
||||
async create(params: {
|
||||
@@ -49,7 +54,8 @@ export class PrismaAgentRepositoryAdapter implements AgentRepositoryInterface {
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
}): Promise<AgentData> {
|
||||
return await PrismaAgentRepository.create(params);
|
||||
const agentRepo = new PrismaAgentRepository(prisma);
|
||||
return await agentRepo.create(params);
|
||||
}
|
||||
|
||||
async findByIdWithAdminAccess(params: {
|
||||
@@ -57,21 +63,30 @@ export class PrismaAgentRepositoryAdapter implements AgentRepositoryInterface {
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
}): Promise<AgentData | null> {
|
||||
return await PrismaAgentRepository.findByIdWithAdminAccess(params);
|
||||
const agentRepo = new PrismaAgentRepository(prisma);
|
||||
return await agentRepo.findByIdWithAdminAccess(params);
|
||||
}
|
||||
|
||||
async findByIdWithCallAccess(params: {
|
||||
id: string;
|
||||
userId: number;
|
||||
}): Promise<AgentWithPhoneNumbersData | null> {
|
||||
return await PrismaAgentRepository.findByIdWithCallAccess(params);
|
||||
const agentRepo = new PrismaAgentRepository(prisma);
|
||||
return await agentRepo.findByIdWithCallAccess(params);
|
||||
}
|
||||
|
||||
async delete(params: { id: string }): Promise<void> {
|
||||
await PrismaAgentRepository.delete(params);
|
||||
const agentRepo = new PrismaAgentRepository(prisma);
|
||||
await agentRepo.delete(params);
|
||||
}
|
||||
|
||||
async linkToWorkflowStep(params: { workflowStepId: number; agentId: string }): Promise<void> {
|
||||
await PrismaAgentRepository.linkToWorkflowStep(params);
|
||||
async linkOutboundAgentToWorkflow(params: { workflowStepId: number; agentId: string }): Promise<void> {
|
||||
const agentRepo = new PrismaAgentRepository(prisma);
|
||||
await agentRepo.linkOutboundAgentToWorkflow(params);
|
||||
}
|
||||
|
||||
async linkInboundAgentToWorkflow(params: { workflowStepId: number; agentId: string }): Promise<void> {
|
||||
const agentRepo = new PrismaAgentRepository(prisma);
|
||||
await agentRepo.linkInboundAgentToWorkflow(params);
|
||||
}
|
||||
}
|
||||
|
||||
+30
-9
@@ -1,4 +1,5 @@
|
||||
import { PrismaPhoneNumberRepository } from "@calcom/lib/server/repository/PrismaPhoneNumberRepository";
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { PhoneNumberSubscriptionStatus } from "@calcom/prisma/enums";
|
||||
|
||||
import type {
|
||||
@@ -15,7 +16,8 @@ export class PrismaPhoneNumberRepositoryAdapter implements PhoneNumberRepository
|
||||
phoneNumber: string;
|
||||
userId: number;
|
||||
}): Promise<PhoneNumberData | null> {
|
||||
return await PrismaPhoneNumberRepository.findByPhoneNumberAndUserId(params);
|
||||
const phoneNumberRepo = new PrismaPhoneNumberRepository(prisma);
|
||||
return await phoneNumberRepo.findByPhoneNumberAndUserId(params);
|
||||
}
|
||||
|
||||
async findByPhoneNumberAndTeamId(params: {
|
||||
@@ -23,11 +25,13 @@ export class PrismaPhoneNumberRepositoryAdapter implements PhoneNumberRepository
|
||||
teamId: number;
|
||||
userId: number;
|
||||
}): Promise<PhoneNumberData | null> {
|
||||
return await PrismaPhoneNumberRepository.findByPhoneNumberAndTeamId(params);
|
||||
const phoneNumberRepo = new PrismaPhoneNumberRepository(prisma);
|
||||
return await phoneNumberRepo.findByPhoneNumberAndTeamId(params);
|
||||
}
|
||||
|
||||
async findByIdAndUserId(params: { id: number; userId: number }): Promise<PhoneNumberData | null> {
|
||||
return await PrismaPhoneNumberRepository.findByIdAndUserId(params);
|
||||
const phoneNumberRepo = new PrismaPhoneNumberRepository(prisma);
|
||||
return await phoneNumberRepo.findByIdAndUserId(params);
|
||||
}
|
||||
|
||||
async findByIdWithTeamAccess(params: {
|
||||
@@ -35,7 +39,8 @@ export class PrismaPhoneNumberRepositoryAdapter implements PhoneNumberRepository
|
||||
teamId: number;
|
||||
userId: number;
|
||||
}): Promise<PhoneNumberData | null> {
|
||||
return await PrismaPhoneNumberRepository.findByIdWithTeamAccess(params);
|
||||
const phoneNumberRepo = new PrismaPhoneNumberRepository(prisma);
|
||||
return await phoneNumberRepo.findByIdWithTeamAccess(params);
|
||||
}
|
||||
|
||||
async createPhoneNumber(params: {
|
||||
@@ -45,6 +50,7 @@ export class PrismaPhoneNumberRepositoryAdapter implements PhoneNumberRepository
|
||||
teamId?: number;
|
||||
outboundAgentId?: string | null;
|
||||
}): Promise<PhoneNumberData> {
|
||||
const phoneNumberRepo = new PrismaPhoneNumberRepository(prisma);
|
||||
const createParams = {
|
||||
phoneNumber: params.phoneNumber,
|
||||
provider: params.provider,
|
||||
@@ -52,19 +58,21 @@ export class PrismaPhoneNumberRepositoryAdapter implements PhoneNumberRepository
|
||||
teamId: params.teamId,
|
||||
};
|
||||
|
||||
return await PrismaPhoneNumberRepository.createPhoneNumber(createParams);
|
||||
return await phoneNumberRepo.createPhoneNumber(createParams);
|
||||
}
|
||||
|
||||
async deletePhoneNumber(params: { phoneNumber: string }): Promise<void> {
|
||||
await PrismaPhoneNumberRepository.deletePhoneNumber(params);
|
||||
const phoneNumberRepo = new PrismaPhoneNumberRepository(prisma);
|
||||
await phoneNumberRepo.deletePhoneNumber(params);
|
||||
}
|
||||
|
||||
async updateSubscriptionStatus(params: {
|
||||
id: number;
|
||||
subscriptionStatus: PhoneNumberSubscriptionStatus;
|
||||
disconnectOutboundAgent?: boolean;
|
||||
disconnectAgents?: boolean;
|
||||
}): Promise<void> {
|
||||
await PrismaPhoneNumberRepository.updateSubscriptionStatus(params);
|
||||
const phoneNumberRepo = new PrismaPhoneNumberRepository(prisma);
|
||||
await phoneNumberRepo.updateSubscriptionStatus(params);
|
||||
}
|
||||
|
||||
async updateAgents(params: {
|
||||
@@ -72,10 +80,23 @@ export class PrismaPhoneNumberRepositoryAdapter implements PhoneNumberRepository
|
||||
inboundProviderAgentId?: string | null;
|
||||
outboundProviderAgentId?: string | null;
|
||||
}): Promise<void> {
|
||||
await PrismaPhoneNumberRepository.updateAgents({
|
||||
const phoneNumberRepo = new PrismaPhoneNumberRepository(prisma);
|
||||
await phoneNumberRepo.updateAgents({
|
||||
id: params.id,
|
||||
inboundProviderAgentId: params.inboundProviderAgentId,
|
||||
outboundProviderAgentId: params.outboundProviderAgentId,
|
||||
});
|
||||
}
|
||||
|
||||
async updateInboundAgentId(params: { id: number; agentId: string }): Promise<{ count: number }> {
|
||||
const phoneNumberRepo = new PrismaPhoneNumberRepository(prisma);
|
||||
return await phoneNumberRepo.updateInboundAgentId(params);
|
||||
}
|
||||
|
||||
async findInboundAgentIdByPhoneNumberId(params: {
|
||||
phoneNumberId: number;
|
||||
}): Promise<{ inboundAgentId: string | null } | null> {
|
||||
const phoneNumberRepo = new PrismaPhoneNumberRepository(prisma);
|
||||
return await phoneNumberRepo.findInboundAgentIdByPhoneNumberId(params);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +68,12 @@ export interface AgentRepositoryInterface {
|
||||
/**
|
||||
* Link agent to workflow step
|
||||
*/
|
||||
linkToWorkflowStep(params: { workflowStepId: number; agentId: string }): Promise<void>;
|
||||
linkOutboundAgentToWorkflow(params: { workflowStepId: number; agentId: string }): Promise<void>;
|
||||
|
||||
/**
|
||||
* Link inbound agent to workflow
|
||||
*/
|
||||
linkInboundAgentToWorkflow(params: { workflowStepId: number; agentId: string }): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -81,6 +86,7 @@ export interface AgentData {
|
||||
enabled: boolean;
|
||||
userId: number | null;
|
||||
teamId: number | null;
|
||||
inboundEventTypeId?: number | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
+11
-1
@@ -63,7 +63,7 @@ export interface PhoneNumberRepositoryInterface {
|
||||
updateSubscriptionStatus(params: {
|
||||
id: number;
|
||||
subscriptionStatus: PhoneNumberSubscriptionStatus;
|
||||
disconnectOutboundAgent?: boolean;
|
||||
disconnectAgents?: boolean;
|
||||
}): Promise<void>;
|
||||
|
||||
/**
|
||||
@@ -74,6 +74,16 @@ export interface PhoneNumberRepositoryInterface {
|
||||
inboundProviderAgentId?: string | null;
|
||||
outboundProviderAgentId?: string | null;
|
||||
}): Promise<void>;
|
||||
|
||||
/**
|
||||
* Conditionally set inbound agent only if currently unset (atomic operation to prevent race conditions)
|
||||
*/
|
||||
updateInboundAgentId(params: { id: number; agentId: string }): Promise<{ count: number }>;
|
||||
|
||||
/**
|
||||
* Find inbound agent by phone number ID
|
||||
*/
|
||||
findInboundAgentIdByPhoneNumberId(params: { phoneNumberId: number }): Promise<{ inboundAgentId: string | null } | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+3
-3
@@ -42,7 +42,7 @@ function createMockRetellAIService(overrides: Partial<RetellAIService> = {}): Re
|
||||
updatePhoneNumberWithAgents: vi.fn(),
|
||||
listAgents: vi.fn(),
|
||||
getAgentWithDetails: vi.fn(),
|
||||
createAgent: vi.fn(),
|
||||
createOutboundAgent: vi.fn(),
|
||||
updateAgentConfiguration: vi.fn(),
|
||||
deleteAgent: vi.fn(),
|
||||
createTestCall: vi.fn(),
|
||||
@@ -67,7 +67,7 @@ describe("RetellAIPhoneServiceProvider", () => {
|
||||
deleteLLM: vi.fn().mockResolvedValue(undefined),
|
||||
|
||||
// Agent operations
|
||||
createAgent: vi.fn().mockResolvedValue({ agent_id: "test-agent-id" }),
|
||||
createOutboundAgent: vi.fn().mockResolvedValue({ agent_id: "test-agent-id" }),
|
||||
getAgent: vi.fn().mockResolvedValue({ agent_id: "test-agent-id", agent_name: "Test Agent" }),
|
||||
updateAgent: vi.fn().mockResolvedValue({ agent_id: "test-agent-id" }),
|
||||
deleteAgent: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -94,7 +94,7 @@ describe("RetellAIPhoneServiceProvider", () => {
|
||||
findByIdWithAdminAccess: vi.fn(),
|
||||
findByIdWithCallAccess: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
linkToWorkflowStep: vi.fn(),
|
||||
linkOutboundAgentToWorkflow: vi.fn(),
|
||||
} as unknown as AgentRepositoryInterface;
|
||||
|
||||
// Mock phone number repository
|
||||
|
||||
@@ -210,7 +210,7 @@ export class RetellAIPhoneServiceProvider
|
||||
return await this.service.getAgentWithDetails(params);
|
||||
}
|
||||
|
||||
async createAgent(params: {
|
||||
async createOutboundAgent(params: {
|
||||
name?: string;
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
@@ -225,7 +225,27 @@ export class RetellAIPhoneServiceProvider
|
||||
providerAgentId: string;
|
||||
message: string;
|
||||
}> {
|
||||
const result = await this.service.createAgent(params);
|
||||
const result = await this.service.createOutboundAgent(params);
|
||||
return {
|
||||
id: result.id,
|
||||
providerAgentId: result.providerAgentId,
|
||||
message: result.message,
|
||||
};
|
||||
}
|
||||
|
||||
async createInboundAgent(params: {
|
||||
name?: string;
|
||||
phoneNumber: string;
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
workflowStepId: number;
|
||||
userTimeZone: string;
|
||||
}): Promise<{
|
||||
id: string;
|
||||
providerAgentId: string;
|
||||
message: string;
|
||||
}> {
|
||||
const result = await this.service.createInboundAgent(params);
|
||||
return {
|
||||
id: result.id,
|
||||
providerAgentId: result.providerAgentId,
|
||||
|
||||
+2
-2
@@ -25,7 +25,7 @@ vi.mock("./RetellSDKClient", () => ({
|
||||
getLLM: vi.fn(),
|
||||
updateLLM: vi.fn(),
|
||||
deleteLLM: vi.fn(),
|
||||
createAgent: vi.fn(),
|
||||
createOutboundAgent: vi.fn(),
|
||||
getAgent: vi.fn(),
|
||||
updateAgent: vi.fn(),
|
||||
deleteAgent: vi.fn(),
|
||||
@@ -72,7 +72,7 @@ vi.mock("../adapters/PrismaAgentRepositoryAdapter", () => ({
|
||||
findByIdWithAdminAccess: vi.fn(),
|
||||
findByIdWithCallAccess: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
linkToWorkflowStep: vi.fn(),
|
||||
linkOutboundAgentToWorkflow: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
|
||||
@@ -11,30 +11,6 @@ import { RetellAIError } from "./errors";
|
||||
import { createMockDatabaseAgent } from "./services/__tests__/test-utils";
|
||||
import type { RetellAIRepository } from "./types";
|
||||
|
||||
vi.mock("@calcom/lib/server/repository/PrismaPhoneNumberRepository", () => ({
|
||||
PrismaPhoneNumberRepository: {
|
||||
createPhoneNumber: vi.fn(),
|
||||
findByPhoneNumberAndUserId: vi.fn(),
|
||||
deletePhoneNumber: vi.fn(),
|
||||
updateAgents: vi.fn(),
|
||||
updateSubscriptionStatus: vi.fn(),
|
||||
findByIdAndUserId: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock("@calcom/lib/server/repository/PrismaAgentRepository", () => ({
|
||||
PrismaAgentRepository: {
|
||||
findByIdWithUserAccess: vi.fn(),
|
||||
findByProviderAgentIdWithUserAccess: vi.fn(),
|
||||
findManyWithUserAccess: vi.fn(),
|
||||
findByIdWithUserAccessAndDetails: vi.fn(),
|
||||
canManageTeamResources: vi.fn(),
|
||||
create: vi.fn(),
|
||||
linkToWorkflowStep: vi.fn(),
|
||||
findByIdWithAdminAccess: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
findByIdWithCallAccess: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/app-store/stripepayment/lib/customer", () => ({
|
||||
getStripeCustomerIdFromUserId: vi.fn(),
|
||||
@@ -104,7 +80,7 @@ describe("RetellAIService", () => {
|
||||
getLLM: vi.fn(),
|
||||
updateLLM: vi.fn(),
|
||||
deleteLLM: vi.fn(),
|
||||
createAgent: vi.fn(),
|
||||
createOutboundAgent: vi.fn(),
|
||||
getAgent: vi.fn(),
|
||||
updateAgent: vi.fn(),
|
||||
deleteAgent: vi.fn(),
|
||||
@@ -128,7 +104,7 @@ describe("RetellAIService", () => {
|
||||
findByIdWithAdminAccess: vi.fn(),
|
||||
findByIdWithCallAccess: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
linkToWorkflowStep: vi.fn(),
|
||||
linkOutboundAgentToWorkflow: vi.fn(),
|
||||
};
|
||||
mockAgentRepository = agentRepository as unknown as AgentRepositoryInterface;
|
||||
|
||||
@@ -192,7 +168,7 @@ describe("RetellAIService", () => {
|
||||
const mockLLM = { llm_id: "llm-123" };
|
||||
const mockAgent = { agent_id: "agent-123" };
|
||||
mockRepository.createLLM.mockResolvedValue(mockLLM);
|
||||
mockRepository.createAgent.mockResolvedValue(mockAgent);
|
||||
mockRepository.createOutboundAgent.mockResolvedValue(mockAgent);
|
||||
|
||||
const result = await service.setupAIConfiguration({});
|
||||
|
||||
@@ -215,7 +191,7 @@ describe("RetellAIService", () => {
|
||||
const mockAgent = { agent_id: "agent-123" };
|
||||
|
||||
mockRepository.createLLM.mockResolvedValue(mockLLM);
|
||||
mockRepository.createAgent.mockResolvedValue(mockAgent);
|
||||
mockRepository.createOutboundAgent.mockResolvedValue(mockAgent);
|
||||
|
||||
await service.setupAIConfiguration({
|
||||
calApiKey: "cal-key",
|
||||
@@ -566,7 +542,7 @@ describe("RetellAIService", () => {
|
||||
expect(mockRepository.updateLLM).toHaveBeenCalledWith("llm-123", {
|
||||
general_prompt: "Updated prompt",
|
||||
begin_message: "Updated message",
|
||||
general_tools: null,
|
||||
general_tools: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -732,7 +708,7 @@ describe("RetellAIService", () => {
|
||||
expect(mockPhoneNumberRepository.updateSubscriptionStatus).toHaveBeenCalledWith({
|
||||
id: 1,
|
||||
subscriptionStatus: PhoneNumberSubscriptionStatus.CANCELLED,
|
||||
disconnectOutboundAgent: true,
|
||||
disconnectAgents: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -813,10 +789,10 @@ describe("RetellAIService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("createAgent", () => {
|
||||
describe("createOutboundAgent", () => {
|
||||
it("should create agent successfully", async () => {
|
||||
mockRepository.createLLM.mockResolvedValue({ llm_id: "llm-123" });
|
||||
mockRepository.createAgent.mockResolvedValue({ agent_id: "agent-123" });
|
||||
mockRepository.createOutboundAgent.mockResolvedValue({ agent_id: "agent-123" });
|
||||
mockAgentRepository.create.mockResolvedValue({
|
||||
id: "db-agent-123",
|
||||
name: "Test Agent",
|
||||
@@ -828,7 +804,7 @@ describe("RetellAIService", () => {
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
const result = await service.createAgent({
|
||||
const result = await service.createOutboundAgent({
|
||||
name: "Test Agent",
|
||||
userId: 1,
|
||||
userTimeZone: "America/New_York",
|
||||
@@ -996,7 +972,6 @@ describe("RetellAIService", () => {
|
||||
it("should throw error if agent not found", async () => {
|
||||
const { CreditService } = await import("@calcom/features/ee/billing/credit-service");
|
||||
const { checkRateLimitAndThrowError } = await import("@calcom/lib/checkRateLimitAndThrowError");
|
||||
const { PrismaAgentRepository } = await import("@calcom/lib/server/repository/PrismaAgentRepository");
|
||||
|
||||
// Mock sufficient credits
|
||||
const mockHasAvailableCredits = vi.fn().mockResolvedValue(true);
|
||||
@@ -1005,7 +980,7 @@ describe("RetellAIService", () => {
|
||||
}));
|
||||
|
||||
(checkRateLimitAndThrowError as any).mockResolvedValue(undefined);
|
||||
(PrismaAgentRepository.findByIdWithCallAccess as any).mockResolvedValue(null);
|
||||
mockAgentRepository.findByIdWithCallAccess.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.createTestCall({
|
||||
|
||||
@@ -41,17 +41,27 @@ export class RetellAIService {
|
||||
private phoneNumberRepository: PhoneNumberRepositoryInterface,
|
||||
private transactionManager: TransactionInterface
|
||||
) {
|
||||
this.aiConfigurationService = new AIConfigurationService(repository);
|
||||
this.agentService = new AgentService(repository, agentRepository);
|
||||
this.billingService = new BillingService(phoneNumberRepository, repository);
|
||||
this.callService = new CallService(repository, agentRepository);
|
||||
this.phoneNumberService = new PhoneNumberService(
|
||||
repository,
|
||||
this.aiConfigurationService = new AIConfigurationService({ retellRepository: repository });
|
||||
this.agentService = new AgentService({
|
||||
retellRepository: repository,
|
||||
agentRepository,
|
||||
phoneNumberRepository,
|
||||
transactionManager
|
||||
);
|
||||
this.voiceService = new VoiceService(repository);
|
||||
});
|
||||
this.billingService = new BillingService({
|
||||
phoneNumberRepository,
|
||||
retellRepository: repository,
|
||||
});
|
||||
this.callService = new CallService({
|
||||
retellRepository: repository,
|
||||
agentRepository,
|
||||
});
|
||||
this.phoneNumberService = new PhoneNumberService({
|
||||
retellRepository: repository,
|
||||
agentRepository,
|
||||
phoneNumberRepository,
|
||||
transactionManager,
|
||||
});
|
||||
this.voiceService = new VoiceService({ retellRepository: repository });
|
||||
|
||||
// Inject RetellAIService reference into CallService
|
||||
this.callService.setRetellAIService(this);
|
||||
@@ -141,7 +151,7 @@ export class RetellAIService {
|
||||
return this.agentService.getAgentWithDetails(params);
|
||||
}
|
||||
|
||||
async createAgent(params: {
|
||||
async createOutboundAgent(params: {
|
||||
name?: string;
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
@@ -151,7 +161,7 @@ export class RetellAIService {
|
||||
generalTools?: RetellLLMGeneralTools;
|
||||
userTimeZone: string;
|
||||
}) {
|
||||
return this.agentService.createAgent({
|
||||
return this.agentService.createOutboundAgent({
|
||||
...params,
|
||||
setupAIConfiguration: () =>
|
||||
this.setupAIConfiguration({
|
||||
@@ -165,6 +175,20 @@ export class RetellAIService {
|
||||
});
|
||||
}
|
||||
|
||||
async createInboundAgent(params: {
|
||||
name?: string;
|
||||
phoneNumber: string;
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
workflowStepId: number;
|
||||
userTimeZone: string;
|
||||
}) {
|
||||
return this.agentService.createInboundAgent({
|
||||
...params,
|
||||
aiConfigurationService: this.aiConfigurationService,
|
||||
});
|
||||
}
|
||||
|
||||
async updateAgentConfiguration(params: {
|
||||
id: string;
|
||||
userId: number;
|
||||
|
||||
@@ -102,7 +102,7 @@ export class RetellAIServiceMapper {
|
||||
return {
|
||||
general_prompt: data.general_prompt,
|
||||
begin_message: data.begin_message,
|
||||
general_tools: data.general_tools ?? null,
|
||||
general_tools: data.general_tools ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -173,6 +173,7 @@ export class RetellAIServiceMapper {
|
||||
enabled: agent.enabled,
|
||||
userId: agent.userId,
|
||||
teamId: agent.teamId,
|
||||
inboundEventTypeId: agent.inboundEventTypeId,
|
||||
outboundPhoneNumbers: agent.outboundPhoneNumbers,
|
||||
retellData: {
|
||||
agentId: retellAgent.agent_id,
|
||||
|
||||
@@ -218,7 +218,7 @@ describe("RetellSDKClient", () => {
|
||||
client = new RetellSDKClient(mockLogger);
|
||||
});
|
||||
|
||||
describe("createAgent", () => {
|
||||
describe("createOutboundAgent", () => {
|
||||
it("should create agent", async () => {
|
||||
const mockRequest: CreateAgentRequest = {
|
||||
agent_name: "Test Agent",
|
||||
@@ -229,7 +229,7 @@ describe("RetellSDKClient", () => {
|
||||
|
||||
mockRetellInstance.agent.create.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await client.createAgent(mockRequest);
|
||||
const result = await client.createOutboundAgent(mockRequest);
|
||||
|
||||
expect(mockRetellInstance.agent.create).toHaveBeenCalledWith(mockRequest);
|
||||
expect(result).toEqual(mockResponse);
|
||||
|
||||
@@ -95,7 +95,7 @@ export class RetellSDKClient implements RetellAIRepository {
|
||||
}
|
||||
}
|
||||
|
||||
async createAgent(data: CreateAgentRequest): Promise<RetellAgent> {
|
||||
async createOutboundAgent(data: CreateAgentRequest): Promise<RetellAgent> {
|
||||
this.logger.info("Creating agent via SDK", {
|
||||
agentName: data.agent_name,
|
||||
});
|
||||
|
||||
@@ -14,10 +14,13 @@ import type {
|
||||
DeletionResult,
|
||||
} from "../types";
|
||||
|
||||
type Dependencies = {
|
||||
retellRepository: RetellAIRepository;
|
||||
};
|
||||
|
||||
export class AIConfigurationService {
|
||||
private logger = logger.getSubLogger({ prefix: ["AIConfigurationService"] });
|
||||
|
||||
constructor(private retellRepository: RetellAIRepository) {}
|
||||
constructor(private deps: Dependencies) {}
|
||||
|
||||
async setupAIConfiguration(config: AIConfigurationSetup): Promise<{ llmId: string; agentId: string }> {
|
||||
let llmId: string | null = null;
|
||||
@@ -35,19 +38,19 @@ export class AIConfigurationService {
|
||||
);
|
||||
|
||||
// Step 1: Create LLM
|
||||
const llm = await this.retellRepository.createLLM(llmRequest);
|
||||
const llm = await this.deps.retellRepository.createLLM(llmRequest);
|
||||
llmId = llm.llm_id;
|
||||
|
||||
// Step 2: Create Agent
|
||||
const agentRequest = RetellAIServiceMapper.mapToCreateAgentRequest(llm.llm_id, config.eventTypeId);
|
||||
const agent = await this.retellRepository.createAgent(agentRequest);
|
||||
const agent = await this.deps.retellRepository.createOutboundAgent(agentRequest);
|
||||
|
||||
return { llmId: llm.llm_id, agentId: agent.agent_id };
|
||||
} catch (error) {
|
||||
// Compensation: Clean up LLM if agent creation failed
|
||||
if (llmId) {
|
||||
try {
|
||||
await this.retellRepository.deleteLLM(llmId);
|
||||
await this.deps.retellRepository.deleteLLM(llmId);
|
||||
this.logger.info(`Successfully cleaned up orphaned LLM ${llmId} after setup failure`);
|
||||
} catch (cleanupError) {
|
||||
const errorMessage = `Failed to cleanup LLM ${llmId} after AI configuration setup failure. This will cause billing charges.`;
|
||||
@@ -86,7 +89,7 @@ export class AIConfigurationService {
|
||||
if (config.agentId) {
|
||||
try {
|
||||
this.logger.info(`Attempting to delete agent: ${config.agentId}`);
|
||||
await this.retellRepository.deleteAgent(config.agentId);
|
||||
await this.deps.retellRepository.deleteAgent(config.agentId);
|
||||
result.deleted.agent = true;
|
||||
this.logger.info(`Successfully deleted agent: ${config.agentId}`);
|
||||
} catch (error) {
|
||||
@@ -108,7 +111,7 @@ export class AIConfigurationService {
|
||||
if (config.llmId) {
|
||||
try {
|
||||
this.logger.info(`Attempting to delete LLM: ${config.llmId}`);
|
||||
await this.retellRepository.deleteLLM(config.llmId);
|
||||
await this.deps.retellRepository.deleteLLM(config.llmId);
|
||||
result.deleted.llm = true;
|
||||
this.logger.info(`Successfully deleted LLM: ${config.llmId}`);
|
||||
} catch (error) {
|
||||
@@ -152,20 +155,25 @@ export class AIConfigurationService {
|
||||
|
||||
try {
|
||||
const updateRequest = RetellAIServiceMapper.mapToUpdateLLMRequest(data);
|
||||
return await this.retellRepository.updateLLM(llmId, updateRequest);
|
||||
|
||||
return await this.deps.retellRepository.updateLLM(llmId, updateRequest);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`Failed to update LLM configuration for ${llmId}: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
async setupInboundAIConfiguration(): Promise<{ llmId: string; agentId: string }> {
|
||||
return this.setupAIConfiguration({});
|
||||
}
|
||||
|
||||
async getLLMDetails(llmId: string): Promise<AIPhoneServiceModel<AIPhoneServiceProviderType.RETELL_AI>> {
|
||||
if (!llmId?.trim()) {
|
||||
throw new Error("LLM ID is required and cannot be empty");
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.retellRepository.getLLM(llmId);
|
||||
return await this.deps.retellRepository.getLLM(llmId);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to get LLM details for '${llmId}'`, {
|
||||
llmId,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { isValidPhoneNumber } from "libphonenumber-js";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import { replaceEventTypePlaceholders } from "@calcom/features/ee/workflows/components/agent-configuration/utils/promptUtils";
|
||||
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";
|
||||
@@ -14,17 +16,20 @@ import type {
|
||||
AIPhoneServiceTools,
|
||||
} from "../../../interfaces/AIPhoneService.interface";
|
||||
import type { AgentRepositoryInterface } from "../../interfaces/AgentRepositoryInterface";
|
||||
import type { PhoneNumberRepositoryInterface } from "../../interfaces/PhoneNumberRepositoryInterface";
|
||||
import { RetellAIServiceMapper } from "../RetellAIServiceMapper";
|
||||
import type { RetellAIRepository, Language } from "../types";
|
||||
import { getLlmId } from "../types";
|
||||
|
||||
type Dependencies = {
|
||||
retellRepository: RetellAIRepository;
|
||||
agentRepository: AgentRepositoryInterface;
|
||||
phoneNumberRepository: PhoneNumberRepositoryInterface;
|
||||
};
|
||||
|
||||
export class AgentService {
|
||||
private logger = logger.getSubLogger({ prefix: ["AgentService"] });
|
||||
|
||||
constructor(
|
||||
private retellRepository: RetellAIRepository,
|
||||
private agentRepository: AgentRepositoryInterface
|
||||
) {}
|
||||
constructor(private deps: Dependencies) {}
|
||||
|
||||
private async createApiKey({ userId, teamId }: { userId: number; teamId?: number }) {
|
||||
const apiKeyRepository = await PrismaApiKeyRepository.withGlobalPrisma();
|
||||
@@ -45,7 +50,7 @@ export class AgentService {
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.retellRepository.getAgent(agentId);
|
||||
return await this.deps.retellRepository.getAgent(agentId);
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to get agent from external AI service", {
|
||||
agentId,
|
||||
@@ -100,7 +105,7 @@ export class AgentService {
|
||||
message: "Agent does not have an LLM configured.",
|
||||
});
|
||||
}
|
||||
const llmDetails = await this.retellRepository.getLLM(llmId);
|
||||
const llmDetails = await this.deps.retellRepository.getLLM(llmId);
|
||||
|
||||
if (!llmDetails) {
|
||||
throw new HttpError({ statusCode: 404, message: "LLM details not found." });
|
||||
@@ -160,7 +165,7 @@ export class AgentService {
|
||||
|
||||
const updatedGeneralTools = [...existing, ...newEventTools];
|
||||
|
||||
await this.retellRepository.updateLLM(llmId, { general_tools: updatedGeneralTools });
|
||||
await this.deps.retellRepository.updateLLM(llmId, { general_tools: updatedGeneralTools });
|
||||
} catch (error) {
|
||||
if (error instanceof HttpError) {
|
||||
throw error;
|
||||
@@ -211,7 +216,7 @@ export class AgentService {
|
||||
});
|
||||
}
|
||||
|
||||
const llmDetails = await this.retellRepository.getLLM(llmId);
|
||||
const llmDetails = await this.deps.retellRepository.getLLM(llmId);
|
||||
|
||||
if (!llmDetails) {
|
||||
throw new HttpError({ statusCode: 404, message: "LLM details not found." });
|
||||
@@ -227,7 +232,7 @@ export class AgentService {
|
||||
const filteredTools = existing.filter((tool) => !toolNamesToRemove.includes(tool.name));
|
||||
|
||||
if (filteredTools.length !== existing.length) {
|
||||
await this.retellRepository.updateLLM(llmId, { general_tools: filteredTools });
|
||||
await this.deps.retellRepository.updateLLM(llmId, { general_tools: filteredTools });
|
||||
this.logger.info("Removed event-specific tools from agent", {
|
||||
agentId,
|
||||
llmId,
|
||||
@@ -281,7 +286,7 @@ export class AgentService {
|
||||
});
|
||||
}
|
||||
|
||||
const llmDetails = await this.retellRepository.getLLM(llmId);
|
||||
const llmDetails = await this.deps.retellRepository.getLLM(llmId);
|
||||
|
||||
if (!llmDetails) {
|
||||
throw new HttpError({ statusCode: 404, message: "LLM details not found." });
|
||||
@@ -306,7 +311,7 @@ export class AgentService {
|
||||
const toolNamesToRemove = toolsToRemove.map((tool) => tool.name);
|
||||
const filteredTools = existing.filter((tool) => !toolNamesToRemove.includes(tool.name));
|
||||
|
||||
await this.retellRepository.updateLLM(llmId, { general_tools: filteredTools });
|
||||
await this.deps.retellRepository.updateLLM(llmId, { general_tools: filteredTools });
|
||||
|
||||
this.logger.info("Cleaned up unused event-specific tools", {
|
||||
agentId,
|
||||
@@ -363,7 +368,7 @@ export class AgentService {
|
||||
|
||||
try {
|
||||
const updateRequest = RetellAIServiceMapper.mapToUpdateAgentRequest(data);
|
||||
return await this.retellRepository.updateAgent(agentId, updateRequest);
|
||||
return await this.deps.retellRepository.updateAgent(agentId, updateRequest);
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to update agent in external AI service", {
|
||||
agentId,
|
||||
@@ -386,7 +391,7 @@ export class AgentService {
|
||||
teamId?: number;
|
||||
scope?: "personal" | "team" | "all";
|
||||
}) {
|
||||
const agents = await this.agentRepository.findManyWithUserAccess({
|
||||
const agents = await this.deps.agentRepository.findManyWithUserAccess({
|
||||
userId,
|
||||
teamId,
|
||||
scope,
|
||||
@@ -401,7 +406,7 @@ export class AgentService {
|
||||
}
|
||||
|
||||
async getAgentWithDetails({ id, userId, teamId }: { id: string; userId: number; teamId?: number }) {
|
||||
const agent = await this.agentRepository.findByIdWithUserAccessAndDetails({
|
||||
const agent = await this.deps.agentRepository.findByIdWithUserAccessAndDetails({
|
||||
id,
|
||||
userId,
|
||||
teamId,
|
||||
@@ -425,7 +430,7 @@ export class AgentService {
|
||||
});
|
||||
}
|
||||
|
||||
const llmDetails = await this.retellRepository.getLLM(llmId);
|
||||
const llmDetails = await this.deps.retellRepository.getLLM(llmId);
|
||||
|
||||
return RetellAIServiceMapper.formatAgentDetails(agent, retellAgent, llmDetails);
|
||||
} catch (error) {
|
||||
@@ -447,7 +452,7 @@ export class AgentService {
|
||||
}
|
||||
}
|
||||
|
||||
async createAgent({
|
||||
async createOutboundAgent({
|
||||
name: _name,
|
||||
userId,
|
||||
teamId,
|
||||
@@ -463,7 +468,7 @@ export class AgentService {
|
||||
const agentName = _name || `Agent - ${userId} ${uuidv4()}`;
|
||||
|
||||
if (teamId) {
|
||||
const canManage = await this.agentRepository.canManageTeamResources({
|
||||
const canManage = await this.deps.agentRepository.canManageTeamResources({
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
@@ -477,7 +482,7 @@ export class AgentService {
|
||||
|
||||
const llmConfig = await setupAIConfiguration();
|
||||
|
||||
const agent = await this.agentRepository.create({
|
||||
const agent = await this.deps.agentRepository.create({
|
||||
name: agentName,
|
||||
providerAgentId: llmConfig.agentId,
|
||||
userId,
|
||||
@@ -485,7 +490,7 @@ export class AgentService {
|
||||
});
|
||||
|
||||
if (workflowStepId) {
|
||||
await this.agentRepository.linkToWorkflowStep({
|
||||
await this.deps.agentRepository.linkOutboundAgentToWorkflow({
|
||||
workflowStepId,
|
||||
agentId: agent.id,
|
||||
});
|
||||
@@ -498,6 +503,116 @@ export class AgentService {
|
||||
};
|
||||
}
|
||||
|
||||
async createInboundAgent({
|
||||
name,
|
||||
phoneNumber,
|
||||
userId,
|
||||
teamId,
|
||||
workflowStepId,
|
||||
aiConfigurationService,
|
||||
}: {
|
||||
name?: string;
|
||||
phoneNumber: string;
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
workflowStepId: number;
|
||||
aiConfigurationService: {
|
||||
setupInboundAIConfiguration: () => Promise<{ llmId: string; agentId: string }>;
|
||||
};
|
||||
}) {
|
||||
if (teamId) {
|
||||
const canManage = await this.deps.agentRepository.canManageTeamResources({
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
if (!canManage) {
|
||||
throw new HttpError({
|
||||
statusCode: 403,
|
||||
message: "You don't have permission to create agents for this team.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const isPhoneNumberValid = isValidPhoneNumber(phoneNumber);
|
||||
if (!isPhoneNumberValid) {
|
||||
throw new HttpError({
|
||||
statusCode: 400,
|
||||
message: "Invalid phone number",
|
||||
});
|
||||
}
|
||||
|
||||
let phoneNumberRecord;
|
||||
if (teamId) {
|
||||
phoneNumberRecord = await this.deps.phoneNumberRepository.findByPhoneNumberAndTeamId({
|
||||
phoneNumber,
|
||||
teamId,
|
||||
userId,
|
||||
});
|
||||
} else {
|
||||
phoneNumberRecord = await this.deps.phoneNumberRepository.findByPhoneNumberAndUserId({
|
||||
phoneNumber,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
if (!phoneNumberRecord) {
|
||||
throw new HttpError({
|
||||
statusCode: 404,
|
||||
message: "Phone number not found or you don't have access to it",
|
||||
});
|
||||
}
|
||||
|
||||
if (phoneNumberRecord.inboundAgentId) {
|
||||
throw new HttpError({
|
||||
statusCode: 400,
|
||||
message: "Inbound agent already configured for this phone number",
|
||||
});
|
||||
}
|
||||
|
||||
const agentName = name || `Inbound Agent - ${workflowStepId}`;
|
||||
|
||||
const llmConfig = await aiConfigurationService.setupInboundAIConfiguration();
|
||||
|
||||
const agent = await this.deps.agentRepository.create({
|
||||
name: agentName,
|
||||
providerAgentId: llmConfig.agentId,
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
await this.deps.agentRepository.linkInboundAgentToWorkflow({
|
||||
workflowStepId,
|
||||
agentId: agent.id,
|
||||
});
|
||||
|
||||
// Update the Retell phone number with the new inbound agent ID
|
||||
await this.deps.retellRepository.updatePhoneNumber(phoneNumber, {
|
||||
inbound_agent_id: llmConfig.agentId,
|
||||
});
|
||||
|
||||
const updateInboundAgentIdResult = await this.deps.phoneNumberRepository.updateInboundAgentId({
|
||||
id: phoneNumberRecord.id,
|
||||
agentId: agent.id,
|
||||
});
|
||||
|
||||
if (updateInboundAgentIdResult.count === 0) {
|
||||
const conflictingAgentId = await this.deps.phoneNumberRepository.findInboundAgentIdByPhoneNumberId({
|
||||
phoneNumberId: phoneNumberRecord.id,
|
||||
});
|
||||
|
||||
throw new HttpError({
|
||||
statusCode: 409,
|
||||
message: `Inbound agent was configured by another request. Conflicting agent: ${conflictingAgentId}`,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id: agent.id,
|
||||
providerAgentId: agent.providerAgentId,
|
||||
message: "Inbound agent created successfully",
|
||||
};
|
||||
}
|
||||
|
||||
async updateAgentConfiguration({
|
||||
id,
|
||||
userId,
|
||||
@@ -524,7 +639,7 @@ export class AgentService {
|
||||
data: AIPhoneServiceUpdateModelParams<AIPhoneServiceProviderType.RETELL_AI>
|
||||
) => Promise<AIPhoneServiceModel<AIPhoneServiceProviderType.RETELL_AI>>;
|
||||
}) {
|
||||
const agent = await this.agentRepository.findByIdWithAdminAccess({
|
||||
const agent = await this.deps.agentRepository.findByIdWithAdminAccess({
|
||||
id,
|
||||
userId,
|
||||
teamId,
|
||||
@@ -537,8 +652,13 @@ export class AgentService {
|
||||
});
|
||||
}
|
||||
|
||||
const updatedPrompt =
|
||||
agent.inboundEventTypeId && generalPrompt
|
||||
? replaceEventTypePlaceholders(generalPrompt, agent.inboundEventTypeId)
|
||||
: generalPrompt;
|
||||
|
||||
const hasRetellUpdates =
|
||||
generalPrompt !== undefined ||
|
||||
updatedPrompt !== undefined ||
|
||||
beginMessage !== undefined ||
|
||||
generalTools !== undefined ||
|
||||
voiceId !== undefined ||
|
||||
@@ -551,10 +671,10 @@ export class AgentService {
|
||||
|
||||
if (
|
||||
llmId &&
|
||||
(generalPrompt !== undefined || beginMessage !== undefined || generalTools !== undefined)
|
||||
(updatedPrompt !== undefined || beginMessage !== undefined || generalTools !== undefined)
|
||||
) {
|
||||
const llmUpdateData = RetellAIServiceMapper.extractLLMUpdateData(
|
||||
generalPrompt,
|
||||
updatedPrompt,
|
||||
beginMessage,
|
||||
generalTools
|
||||
);
|
||||
@@ -606,7 +726,7 @@ export class AgentService {
|
||||
teamId?: number;
|
||||
deleteAIConfiguration: (config: { agentId: string; llmId?: string }) => Promise<void>;
|
||||
}) {
|
||||
const agent = await this.agentRepository.findByIdWithAdminAccess({
|
||||
const agent = await this.deps.agentRepository.findByIdWithAdminAccess({
|
||||
id,
|
||||
userId,
|
||||
teamId,
|
||||
@@ -637,7 +757,7 @@ export class AgentService {
|
||||
});
|
||||
}
|
||||
|
||||
await this.agentRepository.delete({ id });
|
||||
await this.deps.agentRepository.delete({ id });
|
||||
|
||||
return { message: "Agent deleted successfully" };
|
||||
}
|
||||
|
||||
@@ -20,10 +20,11 @@ const stripeErrorSchema = z.object({
|
||||
|
||||
export class BillingService {
|
||||
private logger = logger.getSubLogger({ prefix: ["BillingService"] });
|
||||
|
||||
constructor(
|
||||
private phoneNumberRepository: PhoneNumberRepositoryInterface,
|
||||
private retellRepository: RetellAIRepository
|
||||
private deps: {
|
||||
phoneNumberRepository: PhoneNumberRepositoryInterface;
|
||||
retellRepository: RetellAIRepository;
|
||||
}
|
||||
) {}
|
||||
|
||||
async generatePhoneNumberCheckoutSession({
|
||||
@@ -112,12 +113,12 @@ export class BillingService {
|
||||
}) {
|
||||
// Find phone number with proper team authorization
|
||||
const phoneNumber = teamId
|
||||
? await this.phoneNumberRepository.findByIdWithTeamAccess({
|
||||
? await this.deps.phoneNumberRepository.findByIdWithTeamAccess({
|
||||
id: phoneNumberId,
|
||||
teamId,
|
||||
userId,
|
||||
})
|
||||
: await this.phoneNumberRepository.findByIdAndUserId({
|
||||
: await this.deps.phoneNumberRepository.findByIdAndUserId({
|
||||
id: phoneNumberId,
|
||||
userId,
|
||||
});
|
||||
@@ -137,10 +138,10 @@ export class BillingService {
|
||||
}
|
||||
|
||||
try {
|
||||
await this.phoneNumberRepository.updateSubscriptionStatus({
|
||||
await this.deps.phoneNumberRepository.updateSubscriptionStatus({
|
||||
id: phoneNumberId,
|
||||
subscriptionStatus: PhoneNumberSubscriptionStatus.CANCELLED,
|
||||
disconnectOutboundAgent: false,
|
||||
disconnectAgents: false,
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -154,7 +155,7 @@ export class BillingService {
|
||||
stripeMessage: "Subscription resource not found",
|
||||
});
|
||||
} else {
|
||||
await this.phoneNumberRepository.updateSubscriptionStatus({
|
||||
await this.deps.phoneNumberRepository.updateSubscriptionStatus({
|
||||
id: phoneNumberId,
|
||||
subscriptionStatus: PhoneNumberSubscriptionStatus.ACTIVE,
|
||||
});
|
||||
@@ -163,15 +164,15 @@ export class BillingService {
|
||||
}
|
||||
|
||||
// Disconnnect agent after cancelling from stripe
|
||||
await this.phoneNumberRepository.updateSubscriptionStatus({
|
||||
await this.deps.phoneNumberRepository.updateSubscriptionStatus({
|
||||
id: phoneNumberId,
|
||||
subscriptionStatus: PhoneNumberSubscriptionStatus.CANCELLED,
|
||||
disconnectOutboundAgent: true,
|
||||
disconnectAgents: true,
|
||||
});
|
||||
|
||||
// Delete the phone number from Retell, DB
|
||||
try {
|
||||
await this.retellRepository.deletePhoneNumber(phoneNumber.phoneNumber);
|
||||
await this.deps.retellRepository.deletePhoneNumber(phoneNumber.phoneNumber);
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to delete phone number from AI service, but subscription was cancelled:", {
|
||||
error,
|
||||
|
||||
@@ -16,14 +16,16 @@ interface RetellAIServiceInterface {
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
type Dependencies = {
|
||||
retellRepository: RetellAIRepository;
|
||||
agentRepository: AgentRepositoryInterface;
|
||||
};
|
||||
|
||||
export class CallService {
|
||||
private logger = logger.getSubLogger({ prefix: ["CallService"] });
|
||||
private retellAIService?: RetellAIServiceInterface;
|
||||
|
||||
constructor(
|
||||
private retellRepository: RetellAIRepository,
|
||||
private agentRepository: AgentRepositoryInterface
|
||||
) {}
|
||||
constructor(private deps: Dependencies) {}
|
||||
|
||||
setRetellAIService(service: RetellAIServiceInterface): void {
|
||||
this.retellAIService = service;
|
||||
@@ -51,7 +53,7 @@ export class CallService {
|
||||
const { fromNumber, toNumber, dynamicVariables } = data;
|
||||
|
||||
try {
|
||||
return await this.retellRepository.createPhoneCall({
|
||||
return await this.deps.retellRepository.createPhoneCall({
|
||||
fromNumber,
|
||||
toNumber,
|
||||
dynamicVariables,
|
||||
@@ -106,7 +108,7 @@ export class CallService {
|
||||
});
|
||||
}
|
||||
|
||||
const agent = await this.agentRepository.findByIdWithCallAccess({
|
||||
const agent = await this.deps.agentRepository.findByIdWithCallAccess({
|
||||
id: agentId,
|
||||
userId,
|
||||
});
|
||||
@@ -193,7 +195,7 @@ export class CallService {
|
||||
identifier: `web-call:${userId}`,
|
||||
});
|
||||
|
||||
const agent = await this.agentRepository.findByIdWithCallAccess({
|
||||
const agent = await this.deps.agentRepository.findByIdWithCallAccess({
|
||||
id: agentId,
|
||||
userId,
|
||||
});
|
||||
@@ -233,7 +235,7 @@ export class CallService {
|
||||
};
|
||||
|
||||
try {
|
||||
const webCall = await this.retellRepository.createWebCall({
|
||||
const webCall = await this.deps.retellRepository.createWebCall({
|
||||
agentId: agent.providerAgentId,
|
||||
dynamicVariables,
|
||||
});
|
||||
@@ -306,7 +308,7 @@ export class CallService {
|
||||
return [];
|
||||
}
|
||||
|
||||
const callsResponse = await this.retellRepository.listCalls({
|
||||
const callsResponse = await this.deps.retellRepository.listCalls({
|
||||
filter_criteria: {
|
||||
from_number: filters.fromNumber,
|
||||
...(filters?.toNumber && { to_number: filters.toNumber }),
|
||||
|
||||
@@ -14,15 +14,16 @@ import type { TransactionInterface } from "../../interfaces/TransactionInterface
|
||||
import { RetellAIServiceMapper } from "../RetellAIServiceMapper";
|
||||
import type { RetellAIRepository } from "../types";
|
||||
|
||||
type Dependencies = {
|
||||
retellRepository: RetellAIRepository;
|
||||
agentRepository: AgentRepositoryInterface;
|
||||
phoneNumberRepository: PhoneNumberRepositoryInterface;
|
||||
transactionManager: TransactionInterface;
|
||||
};
|
||||
|
||||
export class PhoneNumberService {
|
||||
private logger = logger.getSubLogger({ prefix: ["PhoneNumberService"] });
|
||||
|
||||
constructor(
|
||||
private retellRepository: RetellAIRepository,
|
||||
private agentRepository: AgentRepositoryInterface,
|
||||
private phoneNumberRepository: PhoneNumberRepositoryInterface,
|
||||
private transactionManager: TransactionInterface
|
||||
) {}
|
||||
constructor(private deps: Dependencies) {}
|
||||
|
||||
async importPhoneNumber(
|
||||
data: AIPhoneServiceImportPhoneNumberParamsExtended
|
||||
@@ -46,8 +47,8 @@ export class PhoneNumberService {
|
||||
};
|
||||
|
||||
try {
|
||||
return await this.transactionManager.executeInTransaction(async (txContext) => {
|
||||
transactionState.retellPhoneNumber = await this.retellRepository.importPhoneNumber({
|
||||
return await this.deps.transactionManager.executeInTransaction(async (txContext) => {
|
||||
transactionState.retellPhoneNumber = await this.deps.retellRepository.importPhoneNumber({
|
||||
phone_number: rest.phone_number,
|
||||
termination_uri: rest.termination_uri,
|
||||
sip_trunk_auth_username: rest.sip_trunk_auth_username,
|
||||
@@ -65,9 +66,12 @@ export class PhoneNumberService {
|
||||
transactionState.databaseRecordCreated = true;
|
||||
|
||||
if (agent) {
|
||||
await this.retellRepository.updatePhoneNumber(transactionState.retellPhoneNumber.phone_number, {
|
||||
outbound_agent_id: agent.providerAgentId,
|
||||
});
|
||||
await this.deps.retellRepository.updatePhoneNumber(
|
||||
transactionState.retellPhoneNumber.phone_number,
|
||||
{
|
||||
outbound_agent_id: agent.providerAgentId,
|
||||
}
|
||||
);
|
||||
transactionState.agentAssigned = true;
|
||||
}
|
||||
|
||||
@@ -83,7 +87,7 @@ export class PhoneNumberService {
|
||||
data: AIPhoneServiceCreatePhoneNumberParams
|
||||
): Promise<AIPhoneServicePhoneNumber<AIPhoneServiceProviderType.RETELL_AI> & { provider: string }> {
|
||||
try {
|
||||
const phoneNumber = await this.retellRepository.createPhoneNumber(data);
|
||||
const phoneNumber = await this.deps.retellRepository.createPhoneNumber(data);
|
||||
return {
|
||||
...phoneNumber,
|
||||
provider: AIPhoneServiceProviderType.RETELL_AI,
|
||||
@@ -119,12 +123,12 @@ export class PhoneNumberService {
|
||||
}
|
||||
|
||||
const phoneNumberToDelete = teamId
|
||||
? await this.phoneNumberRepository.findByPhoneNumberAndTeamId({
|
||||
? await this.deps.phoneNumberRepository.findByPhoneNumberAndTeamId({
|
||||
phoneNumber,
|
||||
teamId,
|
||||
userId,
|
||||
})
|
||||
: await this.phoneNumberRepository.findByPhoneNumberAndUserId({
|
||||
: await this.deps.phoneNumberRepository.findByPhoneNumberAndUserId({
|
||||
phoneNumber,
|
||||
userId,
|
||||
});
|
||||
@@ -150,7 +154,7 @@ export class PhoneNumberService {
|
||||
}
|
||||
|
||||
try {
|
||||
await this.retellRepository.updatePhoneNumber(phoneNumber, {
|
||||
await this.deps.retellRepository.updatePhoneNumber(phoneNumber, {
|
||||
inbound_agent_id: null,
|
||||
outbound_agent_id: null,
|
||||
});
|
||||
@@ -161,10 +165,10 @@ export class PhoneNumberService {
|
||||
});
|
||||
}
|
||||
|
||||
await this.retellRepository.deletePhoneNumber(phoneNumber);
|
||||
await this.deps.retellRepository.deletePhoneNumber(phoneNumber);
|
||||
|
||||
if (deleteFromDB) {
|
||||
await this.phoneNumberRepository.deletePhoneNumber({ phoneNumber });
|
||||
await this.deps.phoneNumberRepository.deletePhoneNumber({ phoneNumber });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,7 +183,7 @@ export class PhoneNumberService {
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.retellRepository.getPhoneNumber(phoneNumber);
|
||||
return await this.deps.retellRepository.getPhoneNumber(phoneNumber);
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to get phone number from external AI service", {
|
||||
phoneNumber,
|
||||
@@ -211,7 +215,7 @@ export class PhoneNumberService {
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.retellRepository.updatePhoneNumber(phoneNumber, data);
|
||||
return await this.deps.retellRepository.updatePhoneNumber(phoneNumber, data);
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to update phone number in external AI service", {
|
||||
phoneNumber,
|
||||
@@ -246,12 +250,12 @@ export class PhoneNumberService {
|
||||
}
|
||||
|
||||
const phoneNumberRecord = teamId
|
||||
? await this.phoneNumberRepository.findByPhoneNumberAndTeamId({
|
||||
? await this.deps.phoneNumberRepository.findByPhoneNumberAndTeamId({
|
||||
phoneNumber,
|
||||
teamId,
|
||||
userId,
|
||||
})
|
||||
: await this.phoneNumberRepository.findByPhoneNumberAndUserId({
|
||||
: await this.deps.phoneNumberRepository.findByPhoneNumberAndUserId({
|
||||
phoneNumber,
|
||||
userId,
|
||||
});
|
||||
@@ -285,7 +289,7 @@ export class PhoneNumberService {
|
||||
});
|
||||
}
|
||||
|
||||
await this.phoneNumberRepository.updateAgents({
|
||||
await this.deps.phoneNumberRepository.updateAgents({
|
||||
id: phoneNumberRecord.id,
|
||||
inboundProviderAgentId: inboundAgentId,
|
||||
outboundProviderAgentId: outboundAgentId,
|
||||
@@ -296,7 +300,7 @@ export class PhoneNumberService {
|
||||
|
||||
private async validateTeamPermissions(userId: number, teamId?: number) {
|
||||
if (teamId) {
|
||||
const canManage = await this.agentRepository.canManageTeamResources({
|
||||
const canManage = await this.deps.agentRepository.canManageTeamResources({
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
@@ -319,7 +323,7 @@ export class PhoneNumberService {
|
||||
|
||||
let agent = null;
|
||||
if (agentId) {
|
||||
agent = await this.agentRepository.findByIdWithUserAccess({
|
||||
agent = await this.deps.agentRepository.findByIdWithUserAccess({
|
||||
agentId,
|
||||
userId,
|
||||
});
|
||||
@@ -341,7 +345,7 @@ export class PhoneNumberService {
|
||||
type: "inbound" | "outbound"
|
||||
) {
|
||||
if (agentId) {
|
||||
const agent = await this.agentRepository.findByProviderAgentIdWithUserAccess({
|
||||
const agent = await this.deps.agentRepository.findByProviderAgentIdWithUserAccess({
|
||||
providerAgentId: agentId,
|
||||
userId,
|
||||
});
|
||||
@@ -376,7 +380,7 @@ export class PhoneNumberService {
|
||||
this.logger.warn("Attempting cleanup of Retell phone number due to transaction failure", {
|
||||
phoneNumber: transactionState.retellPhoneNumber.phone_number,
|
||||
});
|
||||
await this.retellRepository.deletePhoneNumber(transactionState.retellPhoneNumber.phone_number);
|
||||
await this.deps.retellRepository.deletePhoneNumber(transactionState.retellPhoneNumber.phone_number);
|
||||
this.logger.info("Successfully cleaned up Retell phone number", {
|
||||
phoneNumber: transactionState.retellPhoneNumber.phone_number,
|
||||
});
|
||||
|
||||
@@ -22,7 +22,7 @@ This directory contains the refactored RetellAI services that follow the Single
|
||||
|
||||
**Responsibility**: Agent CRUD operations
|
||||
|
||||
- `createAgent()` - Create new agents
|
||||
- `createOutboundAgent()` - Create new agents
|
||||
- `updateAgent()` - Update agent properties
|
||||
- `deleteAgent()` - Remove agents
|
||||
- `listAgents()` - Get agent lists
|
||||
@@ -83,7 +83,7 @@ const { llmId, agentId } = await aiConfigService.setupAIConfiguration({
|
||||
});
|
||||
|
||||
// Create agent
|
||||
const agent = await agentService.createAgent({
|
||||
const agent = await agentService.createOutboundAgent({
|
||||
name: 'Support Agent',
|
||||
userId: 1,
|
||||
userTimeZone: 'America/New_York',
|
||||
|
||||
@@ -3,19 +3,22 @@ import logger from "@calcom/lib/logger";
|
||||
|
||||
import type { RetellAIRepository, RetellVoice } from "../types";
|
||||
|
||||
type Dependencies = {
|
||||
retellRepository: RetellAIRepository;
|
||||
};
|
||||
|
||||
export class VoiceService {
|
||||
private logger = logger.getSubLogger({ prefix: ["VoiceService"] });
|
||||
|
||||
constructor(private retellRepository: RetellAIRepository) {}
|
||||
constructor(private deps: Dependencies) {}
|
||||
|
||||
async listVoices(): Promise<RetellVoice[]> {
|
||||
try {
|
||||
const voices = await this.retellRepository.listVoices();
|
||||
|
||||
const voices = await this.deps.retellRepository.listVoices();
|
||||
|
||||
this.logger.info("Retrieved voices successfully", {
|
||||
count: voices.length,
|
||||
});
|
||||
|
||||
|
||||
return voices;
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to list voices from external AI service", {
|
||||
@@ -27,4 +30,4 @@ export class VoiceService {
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+5
-3
@@ -11,7 +11,9 @@ describe("AIConfigurationService", () => {
|
||||
vi.clearAllMocks();
|
||||
mocks = setupBasicMocks();
|
||||
|
||||
service = new AIConfigurationService(mocks.mockRetellRepository);
|
||||
service = new AIConfigurationService({
|
||||
retellRepository: mocks.mockRetellRepository,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -30,7 +32,7 @@ describe("AIConfigurationService", () => {
|
||||
const mockAgent = createMockAgent();
|
||||
|
||||
mocks.mockRetellRepository.createLLM.mockResolvedValue(mockLLM);
|
||||
mocks.mockRetellRepository.createAgent.mockResolvedValue(mockAgent);
|
||||
mocks.mockRetellRepository.createOutboundAgent.mockResolvedValue(mockAgent);
|
||||
|
||||
const result = await service.setupAIConfiguration(validConfig);
|
||||
|
||||
@@ -40,7 +42,7 @@ describe("AIConfigurationService", () => {
|
||||
});
|
||||
|
||||
expect(mocks.mockRetellRepository.createLLM).toHaveBeenCalled();
|
||||
expect(mocks.mockRetellRepository.createAgent).toHaveBeenCalled();
|
||||
expect(mocks.mockRetellRepository.createOutboundAgent).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle LLM creation failure", async () => {
|
||||
|
||||
+10
-7
@@ -48,7 +48,10 @@ describe("BillingService", () => {
|
||||
});
|
||||
stripe.subscriptions.cancel.mockResolvedValue({});
|
||||
|
||||
service = new BillingService(mocks.mockPhoneNumberRepository, mocks.mockRetellRepository);
|
||||
service = new BillingService({
|
||||
phoneNumberRepository: mocks.mockPhoneNumberRepository,
|
||||
retellRepository: mocks.mockRetellRepository,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -166,12 +169,12 @@ describe("BillingService", () => {
|
||||
expect(mocks.mockPhoneNumberRepository.updateSubscriptionStatus).toHaveBeenNthCalledWith(1, {
|
||||
id: 1,
|
||||
subscriptionStatus: PhoneNumberSubscriptionStatus.CANCELLED,
|
||||
disconnectOutboundAgent: false,
|
||||
disconnectAgents: false,
|
||||
});
|
||||
expect(mocks.mockPhoneNumberRepository.updateSubscriptionStatus).toHaveBeenNthCalledWith(2, {
|
||||
id: 1,
|
||||
subscriptionStatus: PhoneNumberSubscriptionStatus.CANCELLED,
|
||||
disconnectOutboundAgent: true,
|
||||
disconnectAgents: true,
|
||||
});
|
||||
expect(mocks.mockRetellRepository.deletePhoneNumber).toHaveBeenCalledWith("+1234567890");
|
||||
});
|
||||
@@ -294,17 +297,17 @@ describe("BillingService", () => {
|
||||
|
||||
// Should attempt to cancel
|
||||
expect(stripe.subscriptions.cancel).toHaveBeenCalledWith("sub_123");
|
||||
// Should update database twice: first CANCELLED (disconnectOutboundAgent: false), then final CANCELLED (disconnectOutboundAgent: true)
|
||||
// Should update database twice: first CANCELLED (disconnectAgents: false), then final CANCELLED (disconnectAgents: true)
|
||||
expect(mocks.mockPhoneNumberRepository.updateSubscriptionStatus).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.mockPhoneNumberRepository.updateSubscriptionStatus).toHaveBeenNthCalledWith(1, {
|
||||
id: 1,
|
||||
subscriptionStatus: PhoneNumberSubscriptionStatus.CANCELLED,
|
||||
disconnectOutboundAgent: false,
|
||||
disconnectAgents: false,
|
||||
});
|
||||
expect(mocks.mockPhoneNumberRepository.updateSubscriptionStatus).toHaveBeenNthCalledWith(2, {
|
||||
id: 1,
|
||||
subscriptionStatus: PhoneNumberSubscriptionStatus.CANCELLED,
|
||||
disconnectOutboundAgent: true,
|
||||
disconnectAgents: true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -330,7 +333,7 @@ describe("BillingService", () => {
|
||||
expect(mocks.mockPhoneNumberRepository.updateSubscriptionStatus).toHaveBeenNthCalledWith(1, {
|
||||
id: 1,
|
||||
subscriptionStatus: PhoneNumberSubscriptionStatus.CANCELLED,
|
||||
disconnectOutboundAgent: false,
|
||||
disconnectAgents: false,
|
||||
});
|
||||
expect(mocks.mockPhoneNumberRepository.updateSubscriptionStatus).toHaveBeenNthCalledWith(2, {
|
||||
id: 1,
|
||||
|
||||
+4
-1
@@ -36,7 +36,10 @@ describe("CallService", () => {
|
||||
updateToolsFromAgentId: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
service = new CallService(mocks.mockRetellRepository, mocks.mockAgentRepository);
|
||||
service = new CallService({
|
||||
retellRepository: mocks.mockRetellRepository,
|
||||
agentRepository: mocks.mockAgentRepository,
|
||||
});
|
||||
service.setRetellAIService(mockRetellAIService);
|
||||
});
|
||||
|
||||
|
||||
+6
-6
@@ -20,12 +20,12 @@ describe("PhoneNumberService", () => {
|
||||
vi.clearAllMocks();
|
||||
mocks = setupBasicMocks();
|
||||
|
||||
service = new PhoneNumberService(
|
||||
mocks.mockRetellRepository,
|
||||
mocks.mockAgentRepository,
|
||||
mocks.mockPhoneNumberRepository,
|
||||
mocks.mockTransactionManager
|
||||
);
|
||||
service = new PhoneNumberService({
|
||||
retellRepository: mocks.mockRetellRepository,
|
||||
agentRepository: mocks.mockAgentRepository,
|
||||
phoneNumberRepository: mocks.mockPhoneNumberRepository,
|
||||
transactionManager: mocks.mockTransactionManager,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -91,7 +91,7 @@ export const createMockRetellRepository = (): RetellAIRepository & {
|
||||
getLLM: vi.fn(),
|
||||
updateLLM: vi.fn(),
|
||||
deleteLLM: vi.fn(),
|
||||
createAgent: vi.fn(),
|
||||
createOutboundAgent: vi.fn(),
|
||||
getAgent: vi.fn(),
|
||||
updateAgent: vi.fn(),
|
||||
deleteAgent: vi.fn(),
|
||||
@@ -117,7 +117,7 @@ export const createMockAgentRepository = (): AgentRepositoryInterface & {
|
||||
findByIdWithAdminAccess: vi.fn(),
|
||||
findByIdWithCallAccess: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
linkToWorkflowStep: vi.fn(),
|
||||
linkOutboundAgentToWorkflow: vi.fn(),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -103,7 +103,7 @@ export type CreateAgentRequest = Retell.AgentCreateParams;
|
||||
export type UpdateLLMRequest = Retell.LlmUpdateParams;
|
||||
export type UpdateAgentRequest = Retell.AgentUpdateParams;
|
||||
export type Agent = NonNullable<
|
||||
Awaited<ReturnType<typeof PrismaAgentRepository.findByIdWithUserAccessAndDetails>>
|
||||
Awaited<ReturnType<PrismaAgentRepository["findByIdWithUserAccessAndDetails"]>>
|
||||
>;
|
||||
|
||||
export type RetellAgentWithDetails = {
|
||||
@@ -113,6 +113,7 @@ export type RetellAgentWithDetails = {
|
||||
enabled: boolean;
|
||||
userId: number | null;
|
||||
teamId: number | null;
|
||||
inboundEventTypeId?: number | null;
|
||||
outboundPhoneNumbers: Array<{
|
||||
id: number;
|
||||
phoneNumber: string;
|
||||
@@ -167,7 +168,7 @@ export interface RetellAIRepository {
|
||||
deleteLLM(llmId: string): Promise<void>;
|
||||
|
||||
// Agent operations
|
||||
createAgent(data: CreateAgentRequest): Promise<RetellAgent>;
|
||||
createOutboundAgent(data: CreateAgentRequest): Promise<RetellAgent>;
|
||||
getAgent(agentId: string): Promise<RetellAgent>;
|
||||
updateAgent(agentId: string, data: UpdateAgentRequest): Promise<RetellAgent>;
|
||||
deleteAgent(agentId: string): Promise<void>;
|
||||
@@ -181,7 +182,7 @@ export interface RetellAIRepository {
|
||||
|
||||
// Call operations
|
||||
createPhoneCall(data: CreatePhoneCallParams): Promise<RetellCall>;
|
||||
|
||||
|
||||
listCalls(params: RetellCallListParams): Promise<RetellCallListResponse>;
|
||||
|
||||
createWebCall(
|
||||
|
||||
@@ -3,7 +3,7 @@ import stripe from "@calcom/features/ee/payments/server/stripe";
|
||||
import { PrismaAgentRepository } from "@calcom/lib/server/repository/PrismaAgentRepository";
|
||||
import { PrismaPhoneNumberRepository } from "@calcom/lib/server/repository/PrismaPhoneNumberRepository";
|
||||
import { CreditsRepository } from "@calcom/lib/server/repository/credits";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { PhoneNumberSubscriptionStatus } from "@calcom/prisma/enums";
|
||||
|
||||
import { CHECKOUT_SESSION_TYPES } from "../../constants";
|
||||
@@ -100,7 +100,8 @@ async function handleCalAIPhoneNumberSubscription(
|
||||
throw new HttpCode(400, "Missing agentId for phone number subscription");
|
||||
}
|
||||
|
||||
const agent = await PrismaAgentRepository.findByIdWithUserAccess({
|
||||
const agentRepo = new PrismaAgentRepository(prisma);
|
||||
const agent = await agentRepo.findByIdWithUserAccess({
|
||||
agentId,
|
||||
userId,
|
||||
teamId: teamId ?? undefined,
|
||||
@@ -130,7 +131,8 @@ async function handleCalAIPhoneNumberSubscription(
|
||||
throw new HttpCode(400, "Invalid subscription data");
|
||||
}
|
||||
|
||||
const newNumber = await PrismaPhoneNumberRepository.createPhoneNumber({
|
||||
const phoneNumberRepo = new PrismaPhoneNumberRepository(prisma);
|
||||
const newNumber = await phoneNumberRepo.createPhoneNumber({
|
||||
userId,
|
||||
teamId: teamId ?? undefined,
|
||||
phoneNumber: calAIPhoneNumber.phone_number,
|
||||
@@ -144,7 +146,7 @@ async function handleCalAIPhoneNumberSubscription(
|
||||
try {
|
||||
console.log("Attempting to link agent to phone number:", { agentId, phoneNumberId: newNumber.id });
|
||||
|
||||
const agent = await PrismaAgentRepository.findByIdWithUserAccess({
|
||||
const agent = await agentRepo.findByIdWithUserAccess({
|
||||
agentId,
|
||||
userId,
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createDefaultAIPhoneServiceProvider } from "@calcom/features/calAIPhone";
|
||||
import { PrismaPhoneNumberRepository } from "@calcom/lib/server/repository/PrismaPhoneNumberRepository";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import type { LazyModule, SWHMap } from "./__handler";
|
||||
import { HttpCode } from "./__handler";
|
||||
@@ -12,8 +13,9 @@ const STRIPE_TEAM_PRODUCT_ID = process.env.STRIPE_TEAM_PRODUCT_ID || "";
|
||||
|
||||
const stripeWebhookProductHandler = (handlers: Handlers) => async (data: Data) => {
|
||||
const subscription = data.object;
|
||||
const phoneNumberRepo = new PrismaPhoneNumberRepository(prisma);
|
||||
|
||||
const phoneNumber = await PrismaPhoneNumberRepository.findByStripeSubscriptionId({
|
||||
const phoneNumber = await phoneNumberRepo.findByStripeSubscriptionId({
|
||||
stripeSubscriptionId: subscription.id,
|
||||
});
|
||||
|
||||
@@ -62,7 +64,7 @@ const stripeWebhookProductHandler = (handlers: Handlers) => async (data: Data) =
|
||||
|
||||
async function handleCalAIPhoneNumberSubscriptionDeleted(
|
||||
subscription: Data["object"],
|
||||
phoneNumber: NonNullable<Awaited<ReturnType<typeof PrismaPhoneNumberRepository.findByStripeSubscriptionId>>>
|
||||
phoneNumber: NonNullable<Awaited<ReturnType<PrismaPhoneNumberRepository["findByStripeSubscriptionId"]>>>
|
||||
) {
|
||||
if (!subscription.id) {
|
||||
throw new HttpCode(400, "Subscription ID not found");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { PrismaPhoneNumberRepository } from "@calcom/lib/server/repository/PrismaPhoneNumberRepository";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { PhoneNumberSubscriptionStatus } from "@calcom/prisma/enums";
|
||||
|
||||
import type { SWHMap } from "./__handler";
|
||||
@@ -14,7 +14,8 @@ const handler = async (data: Data) => {
|
||||
throw new HttpCode(400, "Subscription ID not found");
|
||||
}
|
||||
|
||||
const phoneNumber = await PrismaPhoneNumberRepository.findByStripeSubscriptionId({
|
||||
const phoneNumberRepo = new PrismaPhoneNumberRepository(prisma);
|
||||
const phoneNumber = await phoneNumberRepo.findByStripeSubscriptionId({
|
||||
stripeSubscriptionId: subscription.id,
|
||||
});
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,7 @@
|
||||
import { getCoreRowModel, getSortedRowModel, useReactTable, type ColumnDef } from "@tanstack/react-table";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
DataTableProvider,
|
||||
DataTableWrapper,
|
||||
DataTableToolbar,
|
||||
DataTableFilters,
|
||||
useDataTable,
|
||||
} from "@calcom/features/data-table";
|
||||
import { DataTableProvider, DataTableWrapper } from "@calcom/features/data-table";
|
||||
import { useSegments } from "@calcom/features/data-table/hooks/useSegments";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
@@ -21,8 +15,8 @@ import { useVoicePreview } from "../hooks/useVoicePreview";
|
||||
type Voice = {
|
||||
voice_id: string;
|
||||
voice_name: string;
|
||||
provider: 'elevenlabs' | 'openai' | 'deepgram';
|
||||
gender: 'male' | 'female';
|
||||
provider: "elevenlabs" | "openai" | "deepgram";
|
||||
gender: "male" | "female";
|
||||
accent?: string;
|
||||
age?: string;
|
||||
preview_audio_url?: string;
|
||||
@@ -35,7 +29,13 @@ type VoiceSelectionDialogProps = {
|
||||
onVoiceSelect: (voiceId: string) => void;
|
||||
};
|
||||
|
||||
function VoiceSelectionTable({ selectedVoiceId, onVoiceSelect }: { selectedVoiceId?: string; onVoiceSelect: (voiceId: string) => void }) {
|
||||
function VoiceSelectionTable({
|
||||
selectedVoiceId,
|
||||
onVoiceSelect,
|
||||
}: {
|
||||
selectedVoiceId?: string;
|
||||
onVoiceSelect: (voiceId: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<DataTableProvider useSegments={useSegments} defaultPageSize={1000}>
|
||||
<VoiceSelectionContent selectedVoiceId={selectedVoiceId} onVoiceSelect={onVoiceSelect} />
|
||||
@@ -43,14 +43,19 @@ function VoiceSelectionTable({ selectedVoiceId, onVoiceSelect }: { selectedVoice
|
||||
);
|
||||
}
|
||||
|
||||
function VoiceSelectionContent({ selectedVoiceId, onVoiceSelect }: { selectedVoiceId?: string; onVoiceSelect: (voiceId: string) => void }) {
|
||||
function VoiceSelectionContent({
|
||||
selectedVoiceId,
|
||||
onVoiceSelect,
|
||||
}: {
|
||||
selectedVoiceId?: string;
|
||||
onVoiceSelect: (voiceId: string) => void;
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
const { playingVoiceId, handlePlayVoice } = useVoicePreview();
|
||||
const [rowSelection, setRowSelection] = useState({});
|
||||
|
||||
const { data: voices, isLoading } = trpc.viewer.aiVoiceAgent.listVoices.useQuery();
|
||||
|
||||
|
||||
const handleUseVoice = (voiceId: string) => {
|
||||
onVoiceSelect(voiceId);
|
||||
showToast("Voice selected successfully", "success");
|
||||
@@ -78,16 +83,16 @@ function VoiceSelectionContent({ selectedVoiceId, onVoiceSelect }: { selectedVoi
|
||||
onClick={() => handlePlayVoice(row.original.preview_audio_url, row.original.voice_id)}
|
||||
className="rounded-full">
|
||||
{playingVoiceId === row.original.voice_id ? (
|
||||
<Icon name="pause" className="h-3 w-3 text-default" />
|
||||
<Icon name="pause" className="text-default h-3 w-3" />
|
||||
) : (
|
||||
<Icon name="play" className="h-3 w-3 text-default" />
|
||||
<Icon name="play" className="text-default h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-10 h-10 bg-subtle rounded-full flex items-center justify-center">
|
||||
<Icon name="user" className="h-5 w-5 text-default" />
|
||||
<div className="bg-subtle flex h-10 w-10 items-center justify-center rounded-full">
|
||||
<Icon name="user" className="text-default h-5 w-5" />
|
||||
</div>
|
||||
<span className="font-medium text-emphasis">{row.original.voice_name}</span>
|
||||
<span className="text-emphasis font-medium">{row.original.voice_name}</span>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
@@ -100,24 +105,22 @@ function VoiceSelectionContent({ selectedVoiceId, onVoiceSelect }: { selectedVoi
|
||||
cell: ({ row }) => (
|
||||
<div className="flex gap-2 text-sm">
|
||||
{row.original.accent && (
|
||||
<span className="px-2 py-1 bg-subtle text-default rounded-md text-xs">
|
||||
<span className="bg-subtle text-default rounded-md px-2 py-1 text-xs">
|
||||
{row.original.accent}
|
||||
</span>
|
||||
)}
|
||||
{row.original.gender && (
|
||||
<span className="px-2 py-1 bg-subtle text-default rounded-md text-xs">
|
||||
<span className="bg-subtle text-default rounded-md px-2 py-1 text-xs">
|
||||
{row.original.gender}
|
||||
</span>
|
||||
)}
|
||||
{row.original.provider && (
|
||||
<span className="px-2 py-1 bg-subtle text-default rounded-md text-xs">
|
||||
<span className="bg-subtle text-default rounded-md px-2 py-1 text-xs">
|
||||
{row.original.provider}
|
||||
</span>
|
||||
)}
|
||||
{row.original.age && (
|
||||
<span className="px-2 py-1 bg-subtle text-default rounded-md text-xs">
|
||||
{row.original.age}
|
||||
</span>
|
||||
<span className="bg-subtle text-default rounded-md px-2 py-1 text-xs">{row.original.age}</span>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
@@ -127,9 +130,7 @@ function VoiceSelectionContent({ selectedVoiceId, onVoiceSelect }: { selectedVoi
|
||||
accessorKey: "voice_id",
|
||||
header: t("voice_id"),
|
||||
size: 200,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-muted font-mono">{row.original.voice_id}</span>
|
||||
),
|
||||
cell: ({ row }) => <span className="text-muted font-mono text-sm">{row.original.voice_id}</span>,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
@@ -142,13 +143,7 @@ function VoiceSelectionContent({ selectedVoiceId, onVoiceSelect }: { selectedVoi
|
||||
color={selectedVoiceId === row.original.voice_id ? "primary" : "secondary"}
|
||||
onClick={() => handleUseVoice(row.original.voice_id)}
|
||||
className="whitespace-nowrap">
|
||||
{selectedVoiceId === row.original.voice_id ? (
|
||||
<>
|
||||
{t("current_voice")}
|
||||
</>
|
||||
) : (
|
||||
t("use_voice")
|
||||
)}
|
||||
{selectedVoiceId === row.original.voice_id ? <>{t("current_voice")}</> : t("use_voice")}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
@@ -171,7 +166,7 @@ function VoiceSelectionContent({ selectedVoiceId, onVoiceSelect }: { selectedVoi
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="max-h-[500px] overflow-auto border border-subtle rounded-lg">
|
||||
<div className="border-subtle max-h-[500px] overflow-auto rounded-lg border">
|
||||
<DataTableWrapper<Voice>
|
||||
testId="voice-selection-data-table"
|
||||
table={table}
|
||||
@@ -199,17 +194,11 @@ export function VoiceSelectionDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent enableOverflow className="sm:max-w-7xl h-[80vh] flex flex-col p-6">
|
||||
<DialogHeader
|
||||
title={t("select_voice")}
|
||||
subtitle={t("choose_a_voice_for_your_agent")}
|
||||
/>
|
||||
<DialogContent enableOverflow className="flex h-[80vh] flex-col p-6 sm:max-w-7xl">
|
||||
<DialogHeader title={t("select_voice")} subtitle={t("choose_a_voice_for_your_agent")} />
|
||||
|
||||
<div className="flex-1 overflow-hidden min-h-0 mt-4">
|
||||
<VoiceSelectionTable
|
||||
selectedVoiceId={selectedVoiceId}
|
||||
onVoiceSelect={handleVoiceSelect}
|
||||
/>
|
||||
<div className="mt-4 min-h-0 flex-1 overflow-hidden">
|
||||
<VoiceSelectionTable selectedVoiceId={selectedVoiceId} onVoiceSelect={handleVoiceSelect} />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -454,8 +454,7 @@ export function WebCallDialog({
|
||||
type="button"
|
||||
onClick={handleStartCall}
|
||||
loading={createWebCallMutation.isPending || callStatus === "connecting"}
|
||||
disabled={!hasCredits || createWebCallMutation.isPending || callStatus === "connecting"}
|
||||
>
|
||||
disabled={!hasCredits || createWebCallMutation.isPending || callStatus === "connecting"}>
|
||||
<Icon name="phone" className="mr-2 h-4 w-4" />
|
||||
{callStatus === "connecting" ? t("connecting") : t("start_web_call")}
|
||||
</Button>
|
||||
|
||||
@@ -9,12 +9,12 @@ import type { WorkflowPermissions } from "@calcom/lib/server/repository/workflow
|
||||
import type { 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 { 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, isSMSAction } from "../lib/actionHelperFunctions";
|
||||
import type { FormValues } from "../pages/workflow";
|
||||
import { AddActionDialog } from "./AddActionDialog";
|
||||
@@ -58,8 +58,6 @@ export default function WorkflowDetailsPage(props: Props) {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [eventTypeId]);
|
||||
|
||||
const steps = form.getValues("steps") || [];
|
||||
|
||||
const addAction = (
|
||||
action: WorkflowActions,
|
||||
sendTo?: string,
|
||||
@@ -96,19 +94,14 @@ export default function WorkflowDetailsPage(props: Props) {
|
||||
includeCalendarEvent: false,
|
||||
verifiedAt: SCANNING_WORKFLOW_STEPS ? null : new Date(),
|
||||
agentId: null,
|
||||
inboundAgentId: null,
|
||||
};
|
||||
steps?.push(step);
|
||||
form.setValue("steps", steps);
|
||||
};
|
||||
|
||||
const agentQueriesTrpc = trpc.useQueries((t) =>
|
||||
steps.map((step, index) => {
|
||||
const watchedAgentId = form.watch(`steps.${index}.agentId`);
|
||||
const agentId = step?.agentId ?? watchedAgentId ?? null;
|
||||
|
||||
return t.viewer.aiVoiceAgent.get({ id: agentId ?? "" }, { enabled: !!agentId });
|
||||
})
|
||||
);
|
||||
const { outboundAgentQueries: agentQueriesTrpc, inboundAgentQueries: inboundAgentQueriesTrpc } =
|
||||
useAgentsData(form);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -145,6 +138,8 @@ export default function WorkflowDetailsPage(props: Props) {
|
||||
{form.getValues("steps")?.map((step, index) => {
|
||||
const agentData = agentQueriesTrpc[index]?.data;
|
||||
const isAgentLoading = agentQueriesTrpc[index]?.isPending;
|
||||
const inboundAgentData = inboundAgentQueriesTrpc[index]?.data;
|
||||
const isInboundAgentLoading = inboundAgentQueriesTrpc[index]?.isPending;
|
||||
|
||||
return (
|
||||
<div key={index}>
|
||||
@@ -207,6 +202,9 @@ export default function WorkflowDetailsPage(props: Props) {
|
||||
isDeleteStepDialogOpen={isDeleteStepDialogOpen}
|
||||
isAgentLoading={isAgentLoading}
|
||||
agentData={agentData}
|
||||
inboundAgentData={inboundAgentData}
|
||||
isInboundAgentLoading={isInboundAgentLoading}
|
||||
allOptions={allOptions}
|
||||
/>
|
||||
</FormCardBody>
|
||||
</FormCard>
|
||||
|
||||
@@ -67,10 +67,10 @@ import emailRatingTemplate from "../lib/reminders/templates/emailRatingTemplate"
|
||||
import emailReminderTemplate from "../lib/reminders/templates/emailReminderTemplate";
|
||||
import type { FormValues } from "../pages/workflow";
|
||||
import "../style/styles.css";
|
||||
import { AgentConfigurationSheet } from "./AgentConfigurationSheet";
|
||||
import { TestPhoneCallDialog } from "./TestPhoneCallDialog";
|
||||
import { TimeTimeUnitInput } from "./TimeTimeUnitInput";
|
||||
import { WebCallDialog } from "./WebCallDialog";
|
||||
import { AgentConfigurationSheet } from "./agent-configuration/AgentConfigurationSheet";
|
||||
|
||||
type User = RouterOutputs["viewer"]["me"]["get"];
|
||||
|
||||
@@ -92,6 +92,8 @@ type WorkflowStepProps = {
|
||||
isDeleteStepDialogOpen?: boolean;
|
||||
agentData?: RetellAgentWithDetails;
|
||||
isAgentLoading?: boolean;
|
||||
inboundAgentData?: RetellAgentWithDetails;
|
||||
isInboundAgentLoading?: boolean;
|
||||
};
|
||||
|
||||
const getTimeSectionText = (trigger: WorkflowTriggerEvents, t: TFunction) => {
|
||||
@@ -157,6 +159,8 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
|
||||
allOptions,
|
||||
agentData,
|
||||
isAgentLoading,
|
||||
inboundAgentData,
|
||||
isInboundAgentLoading: _isInboundAgentLoading,
|
||||
isDeleteStepDialogOpen,
|
||||
setIsDeleteStepDialogOpen,
|
||||
onSaveWorkflow,
|
||||
@@ -171,10 +175,10 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
|
||||
const { data: userTeams } = trpc.viewer.teams.list.useQuery({}, { enabled: !teamId });
|
||||
const [agentConfigurationSheet, setAgentConfigurationSheet] = useState<{
|
||||
open: boolean;
|
||||
activeTab?: "prompt" | "phoneNumber";
|
||||
activeTab?: "outgoingCalls" | "phoneNumber" | "incomingCalls";
|
||||
}>({
|
||||
open: false,
|
||||
activeTab: "prompt",
|
||||
activeTab: "outgoingCalls",
|
||||
});
|
||||
|
||||
const creditsTeamId = userTeams?.find(
|
||||
@@ -210,12 +214,15 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
|
||||
});
|
||||
|
||||
const stepAgentId = step?.agentId || form.watch(`steps.${step ? step.stepNumber - 1 : 0}.agentId`) || null;
|
||||
const stepInboundAgentId =
|
||||
step?.inboundAgentId || form.watch(`steps.${step ? step.stepNumber - 1 : 0}.inboundAgentId`) || null;
|
||||
|
||||
const updateAgentMutation = trpc.viewer.aiVoiceAgent.update.useMutation({
|
||||
onSuccess: async () => {
|
||||
showToast(t("agent_updated_successfully"), "success");
|
||||
if (stepAgentId) {
|
||||
utils.viewer.aiVoiceAgent.get.invalidate({ id: stepAgentId });
|
||||
const currentAgentId = stepAgentId || stepInboundAgentId;
|
||||
if (currentAgentId) {
|
||||
utils.viewer.aiVoiceAgent.get.invalidate({ id: currentAgentId });
|
||||
}
|
||||
},
|
||||
onError: (error: { message: string }) => {
|
||||
@@ -227,8 +234,9 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
|
||||
onSuccess: async () => {
|
||||
showToast(t("phone_number_unsubscribed_successfully"), "success");
|
||||
setIsUnsubscribeDialogOpen(false);
|
||||
if (stepAgentId) {
|
||||
utils.viewer.aiVoiceAgent.get.invalidate({ id: stepAgentId });
|
||||
const currentAgentId = stepAgentId || stepInboundAgentId;
|
||||
if (currentAgentId) {
|
||||
utils.viewer.aiVoiceAgent.get.invalidate({ id: currentAgentId });
|
||||
}
|
||||
},
|
||||
onError: (error: { message: string }) => {
|
||||
@@ -844,7 +852,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
|
||||
return;
|
||||
}
|
||||
if (!props.readOnly) {
|
||||
setAgentConfigurationSheet({ open: true, activeTab: "prompt" });
|
||||
setAgentConfigurationSheet({ open: true, activeTab: "outgoingCalls" });
|
||||
}
|
||||
}}>
|
||||
<div>
|
||||
@@ -962,7 +970,9 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
|
||||
<DropdownItem
|
||||
type="button"
|
||||
StartIcon="pencil"
|
||||
onClick={() => setAgentConfigurationSheet({ open: true, activeTab: "prompt" })}>
|
||||
onClick={() =>
|
||||
setAgentConfigurationSheet({ open: true, activeTab: "outgoingCalls" })
|
||||
}>
|
||||
{t("edit")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
@@ -1519,11 +1529,12 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
|
||||
activeTab={agentConfigurationSheet.activeTab}
|
||||
onOpenChange={(val) => setAgentConfigurationSheet((prev) => ({ ...prev, open: val }))}
|
||||
agentId={stepAgentId}
|
||||
inboundAgentId={stepInboundAgentId}
|
||||
agentData={agentData}
|
||||
inboundAgentData={inboundAgentData}
|
||||
onUpdate={(data) => {
|
||||
updateAgentMutation.mutate({
|
||||
//eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
id: stepAgentId!,
|
||||
id: data.id,
|
||||
teamId: teamId,
|
||||
generalPrompt: data.generalPrompt,
|
||||
beginMessage: data.beginMessage,
|
||||
@@ -1536,6 +1547,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
|
||||
workflowId={params?.workflow as string}
|
||||
workflowStepId={step?.id}
|
||||
form={form}
|
||||
eventTypeOptions={props.allOptions}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1543,7 +1555,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
|
||||
<TestPhoneCallDialog
|
||||
open={isTestAgentDialogOpen}
|
||||
onOpenChange={setIsTestAgentDialogOpen}
|
||||
agentId={stepAgentId}
|
||||
agentId={stepAgentId || ""}
|
||||
teamId={teamId}
|
||||
form={form}
|
||||
/>
|
||||
@@ -1553,7 +1565,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
|
||||
<WebCallDialog
|
||||
open={isWebCallDialogOpen}
|
||||
onOpenChange={setIsWebCallDialogOpen}
|
||||
agentId={stepAgentId}
|
||||
agentId={stepAgentId || ""}
|
||||
teamId={teamId}
|
||||
isOrganization={props.isOrganization}
|
||||
form={form}
|
||||
|
||||
+374
@@ -0,0 +1,374 @@
|
||||
import { useState } from "react";
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
|
||||
import type { Language } from "@calcom/features/calAIPhone/providers/retellAI/types";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@calcom/ui/components/dropdown";
|
||||
import type { MultiSelectCheckboxesOptionType as Option } from "@calcom/ui/components/form";
|
||||
import { ToggleGroup } from "@calcom/ui/components/form";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetBody,
|
||||
SheetFooter,
|
||||
} from "@calcom/ui/components/sheet";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
|
||||
import type { FormValues } from "../../pages/workflow";
|
||||
import { WebCallDialog } from "../WebCallDialog";
|
||||
import { IncomingCallsTab } from "./components/tabs/IncomingCallsTab";
|
||||
import { OutgoingCallsTab } from "./components/tabs/OutgoingCallsTab";
|
||||
import { PhoneNumberTab } from "./components/tabs/PhoneNumberTab";
|
||||
import { useAgentForms } from "./hooks/useAgentForms";
|
||||
import type { AgentFormValues } from "./types/schemas";
|
||||
import { restorePromptComplexity } from "./utils/promptUtils";
|
||||
|
||||
type AgentConfigurationSheetProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
agentId?: string | null;
|
||||
inboundAgentId?: string | null;
|
||||
agentData?: RouterOutputs["viewer"]["aiVoiceAgent"]["get"];
|
||||
inboundAgentData?: RouterOutputs["viewer"]["aiVoiceAgent"]["get"];
|
||||
onUpdate: (data: AgentFormValues & { id: string }) => void;
|
||||
readOnly?: boolean;
|
||||
teamId?: number;
|
||||
isOrganization?: boolean;
|
||||
workflowId?: string;
|
||||
workflowStepId?: number;
|
||||
activeTab?: "outgoingCalls" | "phoneNumber" | "incomingCalls";
|
||||
form: UseFormReturn<FormValues>;
|
||||
eventTypeOptions?: Option[];
|
||||
};
|
||||
|
||||
export function AgentConfigurationSheet({
|
||||
open,
|
||||
activeTab: _activeTab,
|
||||
onOpenChange,
|
||||
agentId,
|
||||
inboundAgentId,
|
||||
agentData,
|
||||
inboundAgentData,
|
||||
readOnly = false,
|
||||
teamId,
|
||||
isOrganization = false,
|
||||
workflowId,
|
||||
workflowStepId,
|
||||
form,
|
||||
eventTypeOptions = [],
|
||||
}: AgentConfigurationSheetProps) {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<"outgoingCalls" | "phoneNumber" | "incomingCalls">(
|
||||
_activeTab ?? "outgoingCalls"
|
||||
);
|
||||
const [isWebCallDialogOpen, setIsWebCallDialogOpen] = useState(false);
|
||||
|
||||
const activeAgentId = activeTab === "incomingCalls" ? inboundAgentId : agentId;
|
||||
|
||||
const { outboundAgentForm, inboundAgentForm } = useAgentForms({ agentData, inboundAgentData });
|
||||
|
||||
const updateAgentMutation = trpc.viewer.aiVoiceAgent.update.useMutation({
|
||||
onSuccess: async () => {
|
||||
if (activeAgentId) {
|
||||
await utils.viewer.aiVoiceAgent.get.invalidate({ id: activeAgentId });
|
||||
}
|
||||
},
|
||||
onError: (error: { message: string }) => {
|
||||
showToast(error.message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
const handleAgentUpdate = async (data: AgentFormValues) => {
|
||||
if (!agentId) return;
|
||||
|
||||
const updatePayload = {
|
||||
generalPrompt: restorePromptComplexity(data.generalPrompt),
|
||||
beginMessage: data.beginMessage,
|
||||
language: data.language as Language,
|
||||
voiceId: data.voiceId,
|
||||
};
|
||||
|
||||
await updateAgentMutation.mutateAsync({
|
||||
id: agentId,
|
||||
teamId: teamId,
|
||||
...updatePayload,
|
||||
});
|
||||
};
|
||||
|
||||
const handleInboundAgentUpdate = async (data: AgentFormValues) => {
|
||||
if (!inboundAgentId) return;
|
||||
|
||||
const updatePayload = {
|
||||
generalPrompt: restorePromptComplexity(data.generalPrompt),
|
||||
beginMessage: data.beginMessage,
|
||||
language: data.language as Language,
|
||||
voiceId: data.voiceId,
|
||||
};
|
||||
|
||||
await updateAgentMutation.mutateAsync({
|
||||
id: inboundAgentId,
|
||||
teamId: teamId,
|
||||
...updatePayload,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="sm:max-w-xl">
|
||||
<SheetHeader showCloseButton={false} className="w-full">
|
||||
<SheetTitle className="mb-6">{t("cal_ai_agent_configuration")}</SheetTitle>
|
||||
<ToggleGroup
|
||||
onValueChange={(val) => {
|
||||
setActiveTab((val || "outgoingCalls") as "outgoingCalls" | "phoneNumber" | "incomingCalls");
|
||||
}}
|
||||
value={activeTab}
|
||||
options={[
|
||||
{ value: "outgoingCalls", label: t("outgoing_calls") },
|
||||
{ value: "phoneNumber", label: t("phone_number") },
|
||||
{ value: "incomingCalls", label: t("incoming_calls") },
|
||||
]}
|
||||
isFullWidth={true}
|
||||
/>
|
||||
</SheetHeader>
|
||||
<SheetBody className="px-0">
|
||||
{activeTab === "outgoingCalls" && (
|
||||
<OutgoingCallsTab outboundAgentForm={outboundAgentForm} readOnly={readOnly} />
|
||||
)}
|
||||
|
||||
{activeTab === "phoneNumber" && (
|
||||
<PhoneNumberTab
|
||||
agentData={agentData}
|
||||
agentId={agentId}
|
||||
readOnly={readOnly}
|
||||
teamId={teamId}
|
||||
workflowId={workflowId}
|
||||
isOrganization={isOrganization}
|
||||
form={form}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === "incomingCalls" && (
|
||||
<IncomingCallsTab
|
||||
agentData={agentData}
|
||||
inboundAgentData={inboundAgentData}
|
||||
inboundAgentId={inboundAgentId}
|
||||
inboundAgentForm={inboundAgentForm}
|
||||
form={form}
|
||||
eventTypeOptions={eventTypeOptions}
|
||||
readOnly={readOnly}
|
||||
teamId={teamId}
|
||||
workflowStepId={workflowStepId}
|
||||
/>
|
||||
)}
|
||||
</SheetBody>
|
||||
<SheetFooter>
|
||||
<div className="mr-auto">
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
color="secondary"
|
||||
className="rounded-[10px]"
|
||||
disabled={readOnly}
|
||||
EndIcon="chevron-down">
|
||||
{t("test_agent")}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
StartIcon="monitor"
|
||||
onClick={() => {
|
||||
setIsWebCallDialogOpen(true);
|
||||
}}>
|
||||
{t("web_call")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
</div>
|
||||
<Button
|
||||
className="justify-center"
|
||||
type="button"
|
||||
color="secondary"
|
||||
onClick={() => onOpenChange(false)}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="justify-center"
|
||||
onClick={() => {
|
||||
if (activeTab === "incomingCalls" && inboundAgentId) {
|
||||
inboundAgentForm.handleSubmit(handleInboundAgentUpdate)();
|
||||
} else if (activeTab === "outgoingCalls" && agentId) {
|
||||
outboundAgentForm.handleSubmit(handleAgentUpdate)();
|
||||
}
|
||||
}}
|
||||
disabled={
|
||||
readOnly ||
|
||||
updateAgentMutation.isPending ||
|
||||
(activeTab === "outgoingCalls" && !outboundAgentForm.formState.isDirty) ||
|
||||
(activeTab === "incomingCalls" && !inboundAgentForm.formState.isDirty) ||
|
||||
(activeTab === "outgoingCalls" && !agentId) ||
|
||||
(activeTab === "incomingCalls" && !inboundAgentId)
|
||||
}
|
||||
loading={updateAgentMutation.isPending}>
|
||||
{t("save")}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
{activeAgentId && (
|
||||
<WebCallDialog
|
||||
open={isWebCallDialogOpen}
|
||||
onOpenChange={setIsWebCallDialogOpen}
|
||||
agentId={activeAgentId}
|
||||
teamId={teamId}
|
||||
isOrganization={isOrganization}
|
||||
form={form}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* <ToolsDialog
|
||||
open={toolDialogOpen}
|
||||
onOpenChange={setToolDialogOpen}
|
||||
toolDraft={toolDraft}
|
||||
setToolDraft={setToolDraft}
|
||||
onSave={handleToolDialogSave}
|
||||
isEditing={editingToolIndex !== null}
|
||||
/> */}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// const ToolsDialog = ({
|
||||
// open,
|
||||
// onOpenChange,
|
||||
// toolDraft,
|
||||
// setToolDraft,
|
||||
// onSave,
|
||||
// isEditing,
|
||||
// }: {
|
||||
// open: boolean;
|
||||
// onOpenChange: (open: boolean) => void;
|
||||
// toolDraft: ToolDraft | null;
|
||||
// setToolDraft: (draft: ToolDraft) => void;
|
||||
// onSave: () => void;
|
||||
// isEditing: boolean;
|
||||
// }) => {
|
||||
// const { t } = useLocale();
|
||||
|
||||
// const TOOL_TYPES = [
|
||||
// { label: t("Check Availability"), value: "check_availability_cal" },
|
||||
// { label: t("Book Appointment"), value: "book_appointment_cal" },
|
||||
// { label: t("End Call"), value: "end_call" },
|
||||
// ];
|
||||
|
||||
// return (
|
||||
// <Dialog open={open} onOpenChange={onOpenChange}>
|
||||
// <DialogContent
|
||||
// enableOverflow
|
||||
// title={isEditing ? t("Edit Function") : t("Add Function")}
|
||||
// type="creation">
|
||||
// <div className="flex flex-col gap-4">
|
||||
// <div>
|
||||
// <Label>{t("Type")}</Label>
|
||||
// <Select
|
||||
// options={TOOL_TYPES}
|
||||
// value={TOOL_TYPES.find((opt) => opt.value === toolDraft?.type) || null}
|
||||
// onChange={(option) => setToolDraft((d: ToolDraft) => ({ ...d, type: option?.value || "" }))}
|
||||
// placeholder={t("Select function type")}
|
||||
// />
|
||||
// </div>
|
||||
// <div>
|
||||
// <Label>{t("Name")}</Label>
|
||||
// <TextField
|
||||
// required
|
||||
// value={toolDraft?.name || ""}
|
||||
// onChange={(e) => setToolDraft((d: ToolDraft) => ({ ...d, name: e.target.value }))}
|
||||
// placeholder={t("Enter function name")}
|
||||
// />
|
||||
// </div>
|
||||
// <div>
|
||||
// <Label>{t("Description")}</Label>
|
||||
// <TextArea
|
||||
// value={toolDraft?.description || ""}
|
||||
// onChange={(e) => setToolDraft((d: ToolDraft) => ({ ...d, description: e.target.value }))}
|
||||
// placeholder={t("Enter description (optional)")}
|
||||
// />
|
||||
// </div>
|
||||
// {(toolDraft?.type === "check_availability_cal" || toolDraft?.type === "book_appointment_cal") && (
|
||||
// <>
|
||||
// <div>
|
||||
// <Label>
|
||||
// {t("API Key (Cal.com)")}
|
||||
// <span className="text-red-500"> *</span>
|
||||
// </Label>
|
||||
// <TextField
|
||||
// required
|
||||
// value={toolDraft?.cal_api_key || ""}
|
||||
// onChange={(e) => setToolDraft((d: ToolDraft) => ({ ...d, cal_api_key: e.target.value }))}
|
||||
// placeholder={t("Enter Cal.com API key")}
|
||||
// />
|
||||
// </div>
|
||||
// <div>
|
||||
// <Label>
|
||||
// {t("Event Type ID (Cal.com)")}
|
||||
// <span className="text-red-500"> *</span>
|
||||
// </Label>
|
||||
// <TextField
|
||||
// required
|
||||
// value={toolDraft?.event_type_id || ""}
|
||||
// onChange={(e) =>
|
||||
// setToolDraft((d: ToolDraft) => ({
|
||||
// ...d,
|
||||
// event_type_id: e.target.value ? Number(e.target.value) : null,
|
||||
// }))
|
||||
// }
|
||||
// placeholder={t("Enter Event Type ID")}
|
||||
// type="number"
|
||||
// />
|
||||
// </div>
|
||||
// <div>
|
||||
// <Label>
|
||||
// {t("Timezone")}
|
||||
// <span className="text-red-500"> *</span>
|
||||
// </Label>
|
||||
// <TextField
|
||||
// required
|
||||
// value={toolDraft?.timezone || ""}
|
||||
// onChange={(e) => setToolDraft((d: ToolDraft) => ({ ...d, timezone: e.target.value }))}
|
||||
// placeholder={t("America/Los_Angeles")}
|
||||
// />
|
||||
// <p className="mt-1 text-xs text-subtle">{t("Required for Cal.com calendar integration")}</p>
|
||||
// </div>
|
||||
// </>
|
||||
// )}
|
||||
// </div>
|
||||
// <DialogFooter showDivider>
|
||||
// <Button type="button" color="secondary" onClick={() => onOpenChange(false)}>
|
||||
// {t("Cancel")}
|
||||
// </Button>
|
||||
// <Button type="button" onClick={onSave}>
|
||||
// {t("Save")}
|
||||
// </Button>
|
||||
// </DialogFooter>
|
||||
// </DialogContent>
|
||||
// </Dialog>
|
||||
// );
|
||||
// };
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
import { CAL_AI_PHONE_NUMBER_MONTHLY_PRICE } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Alert } from "@calcom/ui/components/alert";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Dialog } from "@calcom/ui/components/dialog";
|
||||
import { DialogContent, DialogHeader, DialogFooter as BaseDialogFooter } from "@calcom/ui/components/dialog";
|
||||
import { Label } from "@calcom/ui/components/form";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
|
||||
interface BuyNumberDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
agentId?: string | null;
|
||||
workflowId?: string;
|
||||
teamId?: number;
|
||||
}
|
||||
|
||||
export function BuyNumberDialog({ open, onOpenChange, agentId, workflowId, teamId }: BuyNumberDialogProps) {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const buyNumberMutation = trpc.viewer.phoneNumber.buy.useMutation({
|
||||
onSuccess: async (data: { checkoutUrl?: string; message?: string; phoneNumber?: unknown }) => {
|
||||
if (data.checkoutUrl) {
|
||||
window.location.href = data.checkoutUrl;
|
||||
} else if (data.phoneNumber) {
|
||||
showToast(t("phone_number_purchased_successfully"), "success");
|
||||
await utils.viewer.me.get.invalidate();
|
||||
onOpenChange(false);
|
||||
if (agentId) {
|
||||
utils.viewer.aiVoiceAgent.get.invalidate({ id: agentId });
|
||||
}
|
||||
} else {
|
||||
showToast(data.message || t("something_went_wrong"), "error");
|
||||
}
|
||||
},
|
||||
onError: (error: { message: string }) => {
|
||||
showToast(error.message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent type="creation">
|
||||
<DialogHeader
|
||||
title={t("buy_a_new_number")}
|
||||
subtitle={t("buy_number_for_x_per_month", {
|
||||
priceInDollars: CAL_AI_PHONE_NUMBER_MONTHLY_PRICE,
|
||||
})}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<div>
|
||||
<div>
|
||||
<Alert
|
||||
className="mb-4"
|
||||
severity="info"
|
||||
message={t("only_us_phone_numbers_can_be_purchased_here")}
|
||||
/>
|
||||
<Label>{t("supported_call_destinations")}</Label>
|
||||
<div className="-mb-4 grid grid-cols-2 gap-x-8 gap-y-1 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xl">🇺🇸</span>
|
||||
<span className="text-default">United States</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xl">🇮🇳</span>
|
||||
<span className="text-default">India</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xl">🇦🇺</span>
|
||||
<span className="text-default">Australia</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xl">🇩🇪</span>
|
||||
<span className="text-default">Germany</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xl">🇪🇸</span>
|
||||
<span className="text-default">Spain</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xl">🇬🇧</span>
|
||||
<span className="text-default">United Kingdom</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xl">🇲🇽</span>
|
||||
<span className="text-default">Mexico</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xl">🇫🇷</span>
|
||||
<span className="text-default">France</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xl">🇯🇵</span>
|
||||
<span className="text-default">Japan</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xl">🇨🇦</span>
|
||||
<span className="text-default">Canada</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xl">🇮🇹</span>
|
||||
<span className="text-default">Italy</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<BaseDialogFooter showDivider className="relative">
|
||||
<Button onClick={() => onOpenChange(false)} color="secondary">
|
||||
{t("close")}
|
||||
</Button>
|
||||
<Button
|
||||
StartIcon="external-link"
|
||||
onClick={() => {
|
||||
if (!agentId || !workflowId) {
|
||||
return;
|
||||
}
|
||||
buyNumberMutation.mutate({
|
||||
agentId: agentId,
|
||||
workflowId: workflowId,
|
||||
teamId: teamId ?? undefined,
|
||||
});
|
||||
}}
|
||||
loading={buyNumberMutation.isPending}
|
||||
disabled={buyNumberMutation.isPending}>
|
||||
{t("buy_us_number_button", { priceInDollars: CAL_AI_PHONE_NUMBER_MONTHLY_PRICE })}
|
||||
</Button>
|
||||
</BaseDialogFooter>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { ConfirmationDialogContent } from "@calcom/ui/components/dialog";
|
||||
import { Dialog as UIDialog } from "@calcom/ui/components/dialog";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
|
||||
interface ConfirmationDialogsProps {
|
||||
cancellingNumberId: number | null;
|
||||
setCancellingNumberId: (id: number | null) => void;
|
||||
numberToDelete: string | null;
|
||||
setNumberToDelete: (number: string | null) => void;
|
||||
agentId?: string | null;
|
||||
}
|
||||
|
||||
export function ConfirmationDialogs({
|
||||
cancellingNumberId,
|
||||
setCancellingNumberId,
|
||||
numberToDelete,
|
||||
setNumberToDelete,
|
||||
agentId,
|
||||
}: ConfirmationDialogsProps) {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const cancelSubscriptionMutation = trpc.viewer.phoneNumber.cancel.useMutation({
|
||||
onSuccess: async (data: { message?: string }) => {
|
||||
showToast(data.message || t("phone_number_subscription_cancelled_successfully"), "success");
|
||||
setCancellingNumberId(null);
|
||||
|
||||
await utils.viewer.me.get.invalidate();
|
||||
if (agentId) {
|
||||
await utils.viewer.aiVoiceAgent.get.invalidate({ id: agentId });
|
||||
}
|
||||
},
|
||||
onError: (error: { message: string }) => {
|
||||
showToast(error.message, "error");
|
||||
setCancellingNumberId(null);
|
||||
},
|
||||
});
|
||||
|
||||
const deletePhoneNumberMutation = trpc.viewer.phoneNumber.delete.useMutation({
|
||||
onSuccess: async () => {
|
||||
showToast(t("phone_number_deleted_successfully"), "success");
|
||||
setNumberToDelete(null);
|
||||
|
||||
if (agentId) {
|
||||
await utils.viewer.aiVoiceAgent.get.invalidate({ id: agentId });
|
||||
}
|
||||
},
|
||||
onError: (error: { message: string }) => {
|
||||
showToast(error.message, "error");
|
||||
setNumberToDelete(null);
|
||||
},
|
||||
});
|
||||
|
||||
const confirmCancelSubscription = () => {
|
||||
if (cancellingNumberId) {
|
||||
cancelSubscriptionMutation.mutate({ phoneNumberId: cancellingNumberId });
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDeletePhoneNumber = () => {
|
||||
if (numberToDelete) {
|
||||
deletePhoneNumberMutation.mutate({ phoneNumber: numberToDelete });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<UIDialog open={cancellingNumberId !== null} onOpenChange={() => setCancellingNumberId(null)}>
|
||||
<ConfirmationDialogContent
|
||||
isPending={cancelSubscriptionMutation.isPending}
|
||||
variety="danger"
|
||||
title={t("cancel_phone_number_subscription")}
|
||||
confirmBtnText={t("yes_cancel_subscription")}
|
||||
onConfirm={confirmCancelSubscription}>
|
||||
{t("cancel_phone_number_subscription_confirmation")}
|
||||
</ConfirmationDialogContent>
|
||||
</UIDialog>
|
||||
|
||||
<UIDialog open={numberToDelete !== null} onOpenChange={() => setNumberToDelete(null)}>
|
||||
<ConfirmationDialogContent
|
||||
isPending={deletePhoneNumberMutation.isPending}
|
||||
variety="danger"
|
||||
title={t("delete_phone_number")}
|
||||
confirmBtnText={t("yes_delete_phone_number")}
|
||||
onConfirm={confirmDeletePhoneNumber}>
|
||||
{t("delete_phone_number_confirmation")}
|
||||
</ConfirmationDialogContent>
|
||||
</UIDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+252
@@ -0,0 +1,252 @@
|
||||
import { Controller } from "react-hook-form";
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Dialog } from "@calcom/ui/components/dialog";
|
||||
import { DialogContent, DialogHeader, DialogFooter as BaseDialogFooter } from "@calcom/ui/components/dialog";
|
||||
import { Form, Label, Switch, TextField } from "@calcom/ui/components/form";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
import { Tooltip } from "@calcom/ui/components/tooltip";
|
||||
|
||||
import type { PhoneNumberFormValues } from "../../types/schemas";
|
||||
|
||||
interface ImportNumberDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
phoneNumberForm: UseFormReturn<PhoneNumberFormValues>;
|
||||
showAdvancedFields: boolean;
|
||||
setShowAdvancedFields: (show: boolean) => void;
|
||||
agentId?: string | null;
|
||||
workflowId?: string;
|
||||
teamId?: number;
|
||||
}
|
||||
|
||||
export function ImportNumberDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
phoneNumberForm,
|
||||
showAdvancedFields,
|
||||
setShowAdvancedFields,
|
||||
agentId,
|
||||
workflowId,
|
||||
teamId,
|
||||
}: ImportNumberDialogProps) {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const importNumberMutation = trpc.viewer.phoneNumber.import.useMutation({
|
||||
onSuccess: async () => {
|
||||
showToast(t("phone_number_imported_successfully"), "success");
|
||||
onOpenChange(false);
|
||||
phoneNumberForm.reset();
|
||||
|
||||
await utils.viewer.me.get.invalidate();
|
||||
if (agentId) {
|
||||
await utils.viewer.aiVoiceAgent.get.invalidate({ id: agentId });
|
||||
}
|
||||
},
|
||||
onError: (error: { message: string }) => {
|
||||
showToast(error.message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
const handleImportPhoneNumber = (values: PhoneNumberFormValues) => {
|
||||
if (!agentId) {
|
||||
showToast(t("agent_required_for_import"), "error");
|
||||
return;
|
||||
}
|
||||
const mutationPayload = {
|
||||
...values,
|
||||
workflowId: workflowId,
|
||||
agentId: agentId,
|
||||
teamId: teamId,
|
||||
};
|
||||
importNumberMutation.mutate(mutationPayload);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent enableOverflow type="creation">
|
||||
<DialogHeader title={t("import_phone_number")} subtitle={t("import_phone_number_description")} />
|
||||
|
||||
<Form form={phoneNumberForm} handleSubmit={(values) => handleImportPhoneNumber(values)}>
|
||||
<div className="space-y-6">
|
||||
<Controller
|
||||
name="phoneNumber"
|
||||
control={phoneNumberForm.control}
|
||||
render={({ field: { value } }) => (
|
||||
<TextField
|
||||
name="phoneNumber"
|
||||
label={
|
||||
<span className="flex items-center gap-1">
|
||||
{t("phone_number")}
|
||||
<Tooltip content={t("phone_number_info_tooltip")}>
|
||||
<Icon name="info" className="text-muted h-4 w-4 cursor-pointer" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
value={value}
|
||||
required
|
||||
type="text"
|
||||
placeholder="eg:- +12088782105"
|
||||
onChange={(e) => {
|
||||
phoneNumberForm.setValue("phoneNumber", e?.target.value, { shouldDirty: true });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="terminationUri"
|
||||
control={phoneNumberForm.control}
|
||||
render={({ field: { value } }) => (
|
||||
<TextField
|
||||
name="terminationUri"
|
||||
label={
|
||||
<span className="flex items-center gap-1">
|
||||
{t("termination_uri")}
|
||||
<Tooltip content={t("termination_uri_info_tooltip")}>
|
||||
<Icon name="info" className="text-muted h-4 w-4 cursor-pointer" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
value={value}
|
||||
required
|
||||
type="text"
|
||||
placeholder="sip.example.co.pstn.twilio.com"
|
||||
onChange={(e) => {
|
||||
phoneNumberForm.setValue("terminationUri", e?.target.value, { shouldDirty: true });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="bg-muted rounded-xl p-1">
|
||||
<div className="flex items-center justify-between p-2">
|
||||
<Label className="text-emphasis mb-0 text-sm font-medium leading-none">
|
||||
{t("advanced")}
|
||||
</Label>
|
||||
<Switch size="sm" checked={showAdvancedFields} onCheckedChange={setShowAdvancedFields} />
|
||||
</div>
|
||||
|
||||
{showAdvancedFields && (
|
||||
<div className="bg-default space-y-5 rounded-lg p-4">
|
||||
<Controller
|
||||
name="sipTrunkAuthUsername"
|
||||
control={phoneNumberForm.control}
|
||||
render={({ field: { value } }) => (
|
||||
<TextField
|
||||
name="sipTrunkAuthUsername"
|
||||
label={
|
||||
<span className="flex items-center gap-1">
|
||||
{t("sip_trunk_username")}
|
||||
<Tooltip content={t("sip_trunk_username_info_tooltip")}>
|
||||
<Icon name="info" className="text-muted h-4 w-4 cursor-pointer" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
value={value}
|
||||
type="text"
|
||||
onChange={(e) => {
|
||||
phoneNumberForm.setValue("sipTrunkAuthUsername", e?.target.value, {
|
||||
shouldDirty: true,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="sipTrunkAuthPassword"
|
||||
control={phoneNumberForm.control}
|
||||
render={({ field: { value } }) => (
|
||||
<TextField
|
||||
name="sipTrunkAuthPassword"
|
||||
label={
|
||||
<span className="flex items-center gap-1">
|
||||
{t("sip_trunk_password")}
|
||||
<Tooltip content={t("sip_trunk_password_info_tooltip")}>
|
||||
<Icon name="info" className="text-muted h-4 w-4 cursor-pointer" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
value={value}
|
||||
type="password"
|
||||
onChange={(e) => {
|
||||
phoneNumberForm.setValue("sipTrunkAuthPassword", e?.target.value, {
|
||||
shouldDirty: true,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="nickname"
|
||||
control={phoneNumberForm.control}
|
||||
render={({ field: { value } }) => (
|
||||
<TextField
|
||||
name="nickname"
|
||||
label={
|
||||
<span className="flex items-center gap-1">
|
||||
{t("nickname")}
|
||||
<Tooltip content={t("nickname_info_tooltip")}>
|
||||
<Icon name="info" className="text-muted h-4 w-4 cursor-pointer" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
value={value}
|
||||
type="text"
|
||||
onChange={(e) => {
|
||||
phoneNumberForm.setValue("nickname", e?.target.value, { shouldDirty: true });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Having trouble importing section */}
|
||||
<div className="rounded-lg border p-2">
|
||||
<div className="flex items-start gap-3">
|
||||
<Icon name="info" className="text-subtle mt-0.5 h-5 w-5 flex-shrink-0" />
|
||||
<div className="flex-1">
|
||||
<p className="text-emphasis text-sm font-medium">{t("having_trouble_importing")}</p>
|
||||
<p className="text-subtle mt-1 text-sm leading-tight">
|
||||
{t("learn_how_to_get_your_terminator")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
color="secondary"
|
||||
size="base"
|
||||
EndIcon="external-link"
|
||||
href="https://cal.com/help/importing/import-numbers"
|
||||
target="_blank"
|
||||
className="text-emphasis my-auto">
|
||||
{t("learn")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BaseDialogFooter showDivider className="relative">
|
||||
<Button onClick={() => onOpenChange(false)} color="secondary">
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
loading={importNumberMutation.isPending}
|
||||
disabled={importNumberMutation.isPending}>
|
||||
{t("create")}
|
||||
</Button>
|
||||
</BaseDialogFooter>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
import { useRef } from "react";
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { AddVariablesDropdown } from "@calcom/ui/components/editor";
|
||||
import { Input, Label, TextArea } from "@calcom/ui/components/form";
|
||||
|
||||
import { DYNAMIC_TEXT_VARIABLES } from "../../../../lib/constants";
|
||||
import type { AgentFormValues } from "../../types/schemas";
|
||||
import { LanguageSelector } from "./LanguageSelector";
|
||||
import { VoiceSelector } from "./VoiceSelector";
|
||||
|
||||
interface AgentConfigFormProps {
|
||||
form: UseFormReturn<AgentFormValues>;
|
||||
readOnly?: boolean;
|
||||
selectedVoiceId?: string;
|
||||
onVoiceDialogOpen: () => void;
|
||||
}
|
||||
|
||||
export function AgentConfigForm({
|
||||
form,
|
||||
readOnly = false,
|
||||
selectedVoiceId,
|
||||
onVoiceDialogOpen,
|
||||
}: AgentConfigFormProps) {
|
||||
const { t } = useLocale();
|
||||
const generalPromptRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
const addVariableToGeneralPrompt = (variable: string) => {
|
||||
if (generalPromptRef.current) {
|
||||
const currentPrompt = generalPromptRef.current.value || "";
|
||||
const cursorPosition = generalPromptRef.current.selectionStart || currentPrompt.length;
|
||||
const variableName = `{${variable.toUpperCase().replace(/ /g, "_")}}`;
|
||||
const newPrompt = `${currentPrompt.substring(
|
||||
0,
|
||||
cursorPosition
|
||||
)}${variableName}${currentPrompt.substring(cursorPosition)}`;
|
||||
|
||||
form.setValue("generalPrompt", newPrompt, { shouldDirty: true, shouldTouch: true });
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (generalPromptRef.current) {
|
||||
generalPromptRef.current.focus();
|
||||
generalPromptRef.current.setSelectionRange(
|
||||
cursorPosition + variableName.length,
|
||||
cursorPosition + variableName.length
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<LanguageSelector control={form.control} name="language" disabled={readOnly} />
|
||||
|
||||
<VoiceSelector
|
||||
selectedVoiceId={selectedVoiceId}
|
||||
onVoiceDialogOpen={onVoiceDialogOpen}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Label className="text-emphasis mb-1 block text-sm font-medium">{t("initial_message")} *</Label>
|
||||
<p className="text-subtle mb-1.5 text-xs">{t("initial_message_description")}</p>
|
||||
<Input
|
||||
type="text"
|
||||
{...form.register("beginMessage")}
|
||||
placeholder={t("hi_how_are_you_doing")}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-1.5">
|
||||
<Label className="text-emphasis mb-1 block text-sm font-medium">{t("general_prompt")} *</Label>
|
||||
<p className="text-subtle text-xs">{t("general_prompt_description")}</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-t-lg border border-b-0 p-2">
|
||||
{!readOnly && (
|
||||
<AddVariablesDropdown
|
||||
addVariable={addVariableToGeneralPrompt}
|
||||
variables={[...DYNAMIC_TEXT_VARIABLES, "number_to_call"]}
|
||||
addVariableButtonClassName="border rounded-[10px] py-1 px-1"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<TextArea
|
||||
{...form.register("generalPrompt")}
|
||||
ref={(e) => {
|
||||
form.register("generalPrompt").ref(e);
|
||||
generalPromptRef.current = e;
|
||||
}}
|
||||
placeholder={t("enter_the_general_prompt_for_the_agent")}
|
||||
className="min-h-[500px] rounded-t-none"
|
||||
disabled={readOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Commented code for future tools implementation */}
|
||||
{/* <div className="p-4 rounded-lg border border-subtle bg-default">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-emphasis">{t("Functions")}</h4>
|
||||
<p className="text-xs text-subtle">
|
||||
{t(
|
||||
"Enable your agent with capabilities such as calendar bookings, call termination, etc."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={openAddToolDialog}
|
||||
color="secondary"
|
||||
size="sm"
|
||||
disabled={readOnly}>
|
||||
<Icon name="plus" className="mr-2 w-4 h-4" />
|
||||
{t("Add Function")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{toolFields.map((tool, idx) => (
|
||||
<div
|
||||
key={tool.id}
|
||||
className="flex justify-between items-center p-4 rounded-lg border transition-colors border-subtle bg-muted hover:bg-default">
|
||||
<div className="flex gap-3">
|
||||
<div className="flex justify-center items-center w-8 h-8 rounded-md border bg-default border-subtle">
|
||||
<Icon name="zap" className="w-4 h-4 text-emphasis" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-emphasis">{tool.name}</p>
|
||||
<p className="text-sm text-subtle">
|
||||
{tool.type === "check_availability_cal" && t("Check Availability")}
|
||||
{tool.type === "book_appointment_cal" && t("Book Appointment")}
|
||||
{tool.type === "end_call" && t("End Call")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
variant="icon"
|
||||
onClick={() => openEditToolDialog(idx)}
|
||||
disabled={readOnly}>
|
||||
<Icon name="pencil" className="w-4 h-4" />
|
||||
</Button>
|
||||
{tool.name !== "end_call" && (
|
||||
<Button
|
||||
type="button"
|
||||
color="destructive"
|
||||
variant="icon"
|
||||
size="sm"
|
||||
onClick={() => handleToolDelete(idx)}
|
||||
disabled={readOnly}>
|
||||
<Icon name="trash" className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{toolFields.length === 0 && (
|
||||
<div className="flex flex-col justify-center items-center p-6 text-center rounded-lg border border-subtle bg-muted">
|
||||
<Icon name="zap" className="w-6 h-6 text-subtle" />
|
||||
<p className="mt-2 text-sm text-subtle">{t("No functions configured yet")}</p>
|
||||
<p className="text-xs text-subtle">
|
||||
{t("Add functions to enable advanced capabilities")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div> */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
import { Controller } from "react-hook-form";
|
||||
import type { Control } from "react-hook-form";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Label, Select } from "@calcom/ui/components/form";
|
||||
|
||||
import type { AgentFormValues } from "../../types/schemas";
|
||||
import { LANGUAGE_OPTIONS } from "../../utils/constants";
|
||||
|
||||
interface LanguageSelectorProps {
|
||||
control: Control<AgentFormValues>;
|
||||
name: keyof AgentFormValues;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function LanguageSelector({ control, name, disabled = false }: LanguageSelectorProps) {
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Label className="text-emphasis mb-1 block text-sm font-medium">{t("language")}</Label>
|
||||
<Controller
|
||||
name={name}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
value={LANGUAGE_OPTIONS.find((option) => option.value === field.value)}
|
||||
onChange={(option) => field.onChange(option?.value)}
|
||||
options={LANGUAGE_OPTIONS}
|
||||
isDisabled={disabled}
|
||||
className="mb-4"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Label } from "@calcom/ui/components/form";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
|
||||
interface VoiceSelectorProps {
|
||||
selectedVoiceId?: string;
|
||||
onVoiceDialogOpen: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function VoiceSelector({ selectedVoiceId, onVoiceDialogOpen, disabled = false }: VoiceSelectorProps) {
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Label className="text-emphasis mb-1 block text-sm font-medium">{t("voice")}</Label>
|
||||
<p className="text-subtle mb-1.5 text-xs">{t("select_voice_for_agent")}</p>
|
||||
<Button
|
||||
type="button"
|
||||
color="secondary"
|
||||
onClick={onVoiceDialogOpen}
|
||||
disabled={disabled}
|
||||
className="w-full justify-start">
|
||||
<Icon name="user" className="mr-2 h-4 w-4" />
|
||||
{selectedVoiceId ? <span className="text-sm">{selectedVoiceId}</span> : t("select_voice")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
import { useState } from "react";
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { PhoneNumberSubscriptionStatus } from "@calcom/prisma/enums";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Label, Select } from "@calcom/ui/components/form";
|
||||
import type { MultiSelectCheckboxesOptionType as Option } from "@calcom/ui/components/form";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
|
||||
import type { FormValues } from "../../../../pages/workflow";
|
||||
import { VoiceSelectionDialog } from "../../../VoiceSelectionDialog";
|
||||
import type { AgentFormValues } from "../../types/schemas";
|
||||
import { AgentConfigForm } from "../forms/AgentConfigForm";
|
||||
|
||||
interface IncomingCallsTabProps {
|
||||
agentData?: RouterOutputs["viewer"]["aiVoiceAgent"]["get"];
|
||||
inboundAgentData?: RouterOutputs["viewer"]["aiVoiceAgent"]["get"];
|
||||
inboundAgentId?: string | null;
|
||||
inboundAgentForm: UseFormReturn<AgentFormValues>;
|
||||
form: UseFormReturn<FormValues>;
|
||||
eventTypeOptions: Option[];
|
||||
readOnly?: boolean;
|
||||
teamId?: number;
|
||||
workflowStepId?: number;
|
||||
}
|
||||
|
||||
export function IncomingCallsTab({
|
||||
agentData,
|
||||
inboundAgentData,
|
||||
inboundAgentId,
|
||||
inboundAgentForm,
|
||||
form,
|
||||
eventTypeOptions,
|
||||
readOnly = false,
|
||||
teamId,
|
||||
workflowStepId,
|
||||
}: IncomingCallsTabProps) {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useUtils();
|
||||
const [isVoiceDialogOpen, setIsVoiceDialogOpen] = useState(false);
|
||||
|
||||
const setupInboundAgentMutation = trpc.viewer.aiVoiceAgent.setupInboundAgent.useMutation({
|
||||
onSuccess: async (data) => {
|
||||
showToast(t("inbound_agent_setup_success"), "success");
|
||||
// Update the form with the new inbound agent ID
|
||||
if (data.agentId && form && workflowStepId) {
|
||||
const stepIndex = form.getValues("steps")?.findIndex((s) => s.id === workflowStepId);
|
||||
if (stepIndex !== undefined && stepIndex !== -1) {
|
||||
form.setValue(`steps.${stepIndex}.inboundAgentId`, data.agentId, { shouldDirty: true });
|
||||
}
|
||||
}
|
||||
|
||||
// Invalidate and refetch the agent data
|
||||
if (data.agentId) {
|
||||
await utils.viewer.aiVoiceAgent.get.invalidate({ id: data.agentId });
|
||||
}
|
||||
},
|
||||
onError: (error: { message: string }) => {
|
||||
showToast(error.message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
const updateInboundAgentEventTypeMutation =
|
||||
trpc.viewer.aiVoiceAgent.updateInboundAgentEventType.useMutation({
|
||||
onSuccess: async () => {
|
||||
showToast(t("agent_event_type_updated_successfully"), "success");
|
||||
if (inboundAgentId) {
|
||||
await utils.viewer.aiVoiceAgent.get.invalidate({ id: inboundAgentId });
|
||||
}
|
||||
},
|
||||
onError: (error: { message: string }) => {
|
||||
showToast(error.message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
const hasActivePhoneNumbers =
|
||||
agentData?.outboundPhoneNumbers &&
|
||||
agentData.outboundPhoneNumbers.filter(
|
||||
(phone) =>
|
||||
phone.subscriptionStatus === PhoneNumberSubscriptionStatus.ACTIVE || !phone.subscriptionStatus
|
||||
).length > 0;
|
||||
|
||||
if (!hasActivePhoneNumbers) {
|
||||
return (
|
||||
<div className="border-subtle rounded-xl border p-8">
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!inboundAgentId) {
|
||||
return (
|
||||
<div className="border-subtle rounded-xl border p-8">
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
const phoneNumber = agentData?.outboundPhoneNumbers?.filter(
|
||||
(phone) =>
|
||||
phone.subscriptionStatus === PhoneNumberSubscriptionStatus.ACTIVE ||
|
||||
!phone.subscriptionStatus
|
||||
)[0]?.phoneNumber;
|
||||
|
||||
if (!phoneNumber) {
|
||||
showToast(t("no_active_phone_number_available"), "error");
|
||||
return;
|
||||
}
|
||||
if (!workflowStepId) {
|
||||
showToast(t("workflow_step_not_configured"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
setupInboundAgentMutation.mutate({
|
||||
phoneNumber,
|
||||
teamId,
|
||||
workflowStepId: workflowStepId,
|
||||
});
|
||||
}}
|
||||
className="px-6"
|
||||
loading={setupInboundAgentMutation.isPending}
|
||||
disabled={setupInboundAgentMutation.isPending || !workflowStepId}>
|
||||
{t("setup_inbound_agent")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<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_inbound_calls")}</p>
|
||||
<Select
|
||||
isSearchable={false}
|
||||
innerClassNames={{ valueContainer: "font-medium" }}
|
||||
className="text-sm font-medium"
|
||||
isDisabled={readOnly || updateInboundAgentEventTypeMutation.isPending}
|
||||
isLoading={updateInboundAgentEventTypeMutation.isPending}
|
||||
onChange={(val) => {
|
||||
if (val && inboundAgentId) {
|
||||
updateInboundAgentEventTypeMutation.mutate({
|
||||
agentId: inboundAgentId,
|
||||
eventTypeId: parseInt(val.value),
|
||||
teamId,
|
||||
});
|
||||
}
|
||||
}}
|
||||
value={eventTypeOptions.find((opt) => opt.value === inboundAgentData?.inboundEventTypeId?.toString())}
|
||||
options={eventTypeOptions}
|
||||
placeholder={t("select_event_type")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AgentConfigForm
|
||||
form={inboundAgentForm}
|
||||
readOnly={readOnly}
|
||||
selectedVoiceId={inboundAgentForm.watch("voiceId")}
|
||||
onVoiceDialogOpen={() => setIsVoiceDialogOpen(true)}
|
||||
/>
|
||||
|
||||
<VoiceSelectionDialog
|
||||
open={isVoiceDialogOpen}
|
||||
onOpenChange={setIsVoiceDialogOpen}
|
||||
selectedVoiceId={inboundAgentForm.watch("voiceId")}
|
||||
onVoiceSelect={(voiceId: string) => {
|
||||
inboundAgentForm.setValue("voiceId", voiceId, { shouldDirty: true });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import { useState } from "react";
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
|
||||
import { VoiceSelectionDialog } from "../../../VoiceSelectionDialog";
|
||||
import type { AgentFormValues } from "../../types/schemas";
|
||||
import { AgentConfigForm } from "../forms/AgentConfigForm";
|
||||
|
||||
interface OutgoingCallsTabProps {
|
||||
outboundAgentForm: UseFormReturn<AgentFormValues>;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export function OutgoingCallsTab({ outboundAgentForm, readOnly = false }: OutgoingCallsTabProps) {
|
||||
const [isVoiceDialogOpen, setIsVoiceDialogOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AgentConfigForm
|
||||
form={outboundAgentForm}
|
||||
readOnly={readOnly}
|
||||
selectedVoiceId={outboundAgentForm.watch("voiceId")}
|
||||
onVoiceDialogOpen={() => setIsVoiceDialogOpen(true)}
|
||||
/>
|
||||
|
||||
<VoiceSelectionDialog
|
||||
open={isVoiceDialogOpen}
|
||||
onOpenChange={setIsVoiceDialogOpen}
|
||||
selectedVoiceId={outboundAgentForm.watch("voiceId")}
|
||||
onVoiceSelect={(voiceId: string) => {
|
||||
outboundAgentForm.setValue("voiceId", voiceId, { shouldDirty: true });
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+350
@@ -0,0 +1,350 @@
|
||||
import { useState } from "react";
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
|
||||
import { CAL_AI_PHONE_NUMBER_MONTHLY_PRICE } from "@calcom/lib/constants";
|
||||
import { formatPhoneNumber } from "@calcom/lib/formatPhoneNumber";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { PhoneNumberSubscriptionStatus } from "@calcom/prisma/enums";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Badge } from "@calcom/ui/components/badge";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@calcom/ui/components/dropdown";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
|
||||
import type { FormValues } from "../../../../pages/workflow";
|
||||
import { TestPhoneCallDialog } from "../../../TestPhoneCallDialog";
|
||||
import { WebCallDialog } from "../../../WebCallDialog";
|
||||
import { usePhoneNumberActions } from "../../hooks/usePhoneNumberActions";
|
||||
import { BuyNumberDialog } from "../dialogs/BuyNumberDialog";
|
||||
import { ConfirmationDialogs } from "../dialogs/ConfirmationDialogs";
|
||||
import { ImportNumberDialog } from "../dialogs/ImportNumberDialog";
|
||||
|
||||
interface PhoneNumberTabProps {
|
||||
agentData?: RouterOutputs["viewer"]["aiVoiceAgent"]["get"];
|
||||
readOnly?: boolean;
|
||||
agentId?: string | null;
|
||||
teamId?: number;
|
||||
workflowId?: string;
|
||||
isOrganization?: boolean;
|
||||
form?: UseFormReturn<FormValues>;
|
||||
}
|
||||
|
||||
export function PhoneNumberTab({
|
||||
agentData,
|
||||
readOnly = false,
|
||||
agentId,
|
||||
teamId,
|
||||
workflowId,
|
||||
isOrganization = false,
|
||||
form,
|
||||
}: PhoneNumberTabProps) {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const [isBuyDialogOpen, setIsBuyDialogOpen] = useState(false);
|
||||
const [isImportDialogOpen, setIsImportDialogOpen] = useState(false);
|
||||
const [showAdvancedFields, setShowAdvancedFields] = useState(false);
|
||||
const [isTestAgentDialogOpen, setIsTestAgentDialogOpen] = useState(false);
|
||||
const [isWebCallDialogOpen, setIsWebCallDialogOpen] = useState(false);
|
||||
|
||||
const phoneNumberActions = usePhoneNumberActions();
|
||||
|
||||
const buyNumberMutation = trpc.viewer.phoneNumber.buy.useMutation({
|
||||
onSuccess: async (data: { checkoutUrl?: string; message?: string; phoneNumber?: unknown }) => {
|
||||
if (data.checkoutUrl) {
|
||||
window.location.href = data.checkoutUrl;
|
||||
} else if (data.phoneNumber) {
|
||||
showToast(t("phone_number_purchased_successfully"), "success");
|
||||
await utils.viewer.me.get.invalidate();
|
||||
setIsBuyDialogOpen(false);
|
||||
if (agentId) {
|
||||
utils.viewer.aiVoiceAgent.get.invalidate({ id: agentId });
|
||||
}
|
||||
} else {
|
||||
showToast(data.message || t("something_went_wrong"), "error");
|
||||
}
|
||||
},
|
||||
onError: (error: { message: string }) => {
|
||||
showToast(error.message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
const importNumberMutation = trpc.viewer.phoneNumber.import.useMutation({
|
||||
onSuccess: async () => {
|
||||
showToast(t("phone_number_imported_successfully"), "success");
|
||||
setIsImportDialogOpen(false);
|
||||
|
||||
await utils.viewer.me.get.invalidate();
|
||||
if (agentId) {
|
||||
await utils.viewer.aiVoiceAgent.get.invalidate({ id: agentId });
|
||||
}
|
||||
},
|
||||
onError: (error: { message: string }) => {
|
||||
showToast(error.message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
const updateAgentMutation = trpc.viewer.aiVoiceAgent.update.useMutation({
|
||||
onSuccess: async () => {
|
||||
if (agentId) {
|
||||
await utils.viewer.aiVoiceAgent.get.invalidate({ id: agentId });
|
||||
}
|
||||
},
|
||||
onError: (error: { message: string }) => {
|
||||
showToast(error.message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
const hasActivePhoneNumbers =
|
||||
agentData?.outboundPhoneNumbers &&
|
||||
agentData.outboundPhoneNumbers.filter(
|
||||
(phone) =>
|
||||
phone.subscriptionStatus === PhoneNumberSubscriptionStatus.ACTIVE || !phone.subscriptionStatus
|
||||
).length > 0;
|
||||
|
||||
if (hasActivePhoneNumbers) {
|
||||
const activePhoneNumbers = agentData.outboundPhoneNumbers.filter(
|
||||
(phone) =>
|
||||
phone.subscriptionStatus === PhoneNumberSubscriptionStatus.ACTIVE || !phone.subscriptionStatus
|
||||
);
|
||||
const phoneNumber = activePhoneNumbers[0];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative space-y-2">
|
||||
{updateAgentMutation.isPending && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-lg bg-white/50">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||
<Icon name="loader" className="h-4 w-4 animate-spin" />
|
||||
{t("updating")}...
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="border-subtle rounded-lg border p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-emphasis text-sm">{formatPhoneNumber(phoneNumber.phoneNumber)}</span>
|
||||
<Badge variant="green" size="md" withDot>
|
||||
{t("active")}
|
||||
</Badge>
|
||||
{phoneNumber.subscriptionStatus && (
|
||||
<span className="text-muted text-xs">
|
||||
{t("phone_number_cost", {
|
||||
price: CAL_AI_PHONE_NUMBER_MONTHLY_PRICE,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
color="secondary"
|
||||
className="rounded-[10px]"
|
||||
disabled={readOnly}
|
||||
EndIcon="chevron-down">
|
||||
{t("test_agent")}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
StartIcon="phone"
|
||||
onClick={() => {
|
||||
setIsTestAgentDialogOpen(true);
|
||||
}}>
|
||||
{t("phone_call")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
StartIcon="monitor"
|
||||
onClick={() => {
|
||||
setIsWebCallDialogOpen(true);
|
||||
}}>
|
||||
{t("web_call")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
{!readOnly && (
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="button" color="secondary" variant="icon" StartIcon="ellipsis" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
StartIcon="trash"
|
||||
color="destructive"
|
||||
onClick={() => {
|
||||
if (readOnly) return;
|
||||
if (phoneNumber.subscriptionStatus === PhoneNumberSubscriptionStatus.ACTIVE) {
|
||||
phoneNumberActions.handleCancelSubscription(phoneNumber.id);
|
||||
} else {
|
||||
phoneNumberActions.handleDeletePhoneNumber(phoneNumber.phoneNumber);
|
||||
}
|
||||
}}>
|
||||
{phoneNumber.subscriptionStatus === PhoneNumberSubscriptionStatus.ACTIVE
|
||||
? t("unsubscribe")
|
||||
: t("delete")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BuyNumberDialog
|
||||
open={isBuyDialogOpen}
|
||||
onOpenChange={setIsBuyDialogOpen}
|
||||
agentId={agentId}
|
||||
workflowId={workflowId}
|
||||
teamId={teamId}
|
||||
/>
|
||||
|
||||
<ImportNumberDialog
|
||||
open={isImportDialogOpen}
|
||||
onOpenChange={setIsImportDialogOpen}
|
||||
phoneNumberForm={phoneNumberActions.phoneNumberForm}
|
||||
showAdvancedFields={showAdvancedFields}
|
||||
setShowAdvancedFields={setShowAdvancedFields}
|
||||
agentId={agentId}
|
||||
workflowId={workflowId}
|
||||
teamId={teamId}
|
||||
/>
|
||||
|
||||
{agentId && form && (
|
||||
<TestPhoneCallDialog
|
||||
open={isTestAgentDialogOpen}
|
||||
onOpenChange={setIsTestAgentDialogOpen}
|
||||
agentId={agentId}
|
||||
teamId={teamId}
|
||||
form={form}
|
||||
/>
|
||||
)}
|
||||
|
||||
{agentId && form && (
|
||||
<WebCallDialog
|
||||
open={isWebCallDialogOpen}
|
||||
onOpenChange={setIsWebCallDialogOpen}
|
||||
agentId={agentId}
|
||||
teamId={teamId}
|
||||
isOrganization={isOrganization}
|
||||
form={form}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ConfirmationDialogs
|
||||
cancellingNumberId={phoneNumberActions.cancellingNumberId}
|
||||
setCancellingNumberId={phoneNumberActions.setCancellingNumberId}
|
||||
numberToDelete={phoneNumberActions.numberToDelete}
|
||||
setNumberToDelete={phoneNumberActions.setNumberToDelete}
|
||||
agentId={agentId}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="border-subtle rounded-xl border p-8">
|
||||
<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" className="text-subtle h-8 w-8" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-emphasis text-lg font-semibold">{t("no_phone_numbers")}</h3>
|
||||
<p className="text-subtle text-sm">{t("buy_a_phone_number_or_import_one_you_already_have")}</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (readOnly) return;
|
||||
setIsBuyDialogOpen(true);
|
||||
}}
|
||||
StartIcon="external-link"
|
||||
className="px-6"
|
||||
disabled={readOnly || buyNumberMutation.isPending}>
|
||||
{t("buy")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (readOnly) return;
|
||||
setIsImportDialogOpen(true);
|
||||
}}
|
||||
color="secondary"
|
||||
className="px-6"
|
||||
disabled={readOnly || importNumberMutation.isPending}>
|
||||
{t("import")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BuyNumberDialog
|
||||
open={isBuyDialogOpen}
|
||||
onOpenChange={setIsBuyDialogOpen}
|
||||
agentId={agentId}
|
||||
workflowId={workflowId}
|
||||
teamId={teamId}
|
||||
/>
|
||||
|
||||
<ImportNumberDialog
|
||||
open={isImportDialogOpen}
|
||||
onOpenChange={setIsImportDialogOpen}
|
||||
phoneNumberForm={phoneNumberActions.phoneNumberForm}
|
||||
showAdvancedFields={showAdvancedFields}
|
||||
setShowAdvancedFields={setShowAdvancedFields}
|
||||
agentId={agentId}
|
||||
workflowId={workflowId}
|
||||
teamId={teamId}
|
||||
/>
|
||||
|
||||
{agentId && form && (
|
||||
<TestPhoneCallDialog
|
||||
open={isTestAgentDialogOpen}
|
||||
onOpenChange={setIsTestAgentDialogOpen}
|
||||
agentId={agentId}
|
||||
teamId={teamId}
|
||||
form={form}
|
||||
/>
|
||||
)}
|
||||
|
||||
{agentId && form && (
|
||||
<WebCallDialog
|
||||
open={isWebCallDialogOpen}
|
||||
onOpenChange={setIsWebCallDialogOpen}
|
||||
agentId={agentId}
|
||||
teamId={teamId}
|
||||
isOrganization={isOrganization}
|
||||
form={form}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ConfirmationDialogs
|
||||
cancellingNumberId={phoneNumberActions.cancellingNumberId}
|
||||
setCancellingNumberId={phoneNumberActions.setCancellingNumberId}
|
||||
numberToDelete={phoneNumberActions.numberToDelete}
|
||||
setNumberToDelete={phoneNumberActions.setNumberToDelete}
|
||||
agentId={agentId}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
|
||||
import type { AgentFormValues } from "../types/schemas";
|
||||
import { agentSchema } from "../types/schemas";
|
||||
import { cleanPromptForDisplay } from "../utils/promptUtils";
|
||||
|
||||
interface UseAgentFormsProps {
|
||||
agentData?: RouterOutputs["viewer"]["aiVoiceAgent"]["get"];
|
||||
inboundAgentData?: RouterOutputs["viewer"]["aiVoiceAgent"]["get"];
|
||||
}
|
||||
|
||||
export function useAgentForms({ agentData, inboundAgentData }: UseAgentFormsProps) {
|
||||
const outboundAgentForm = useForm<AgentFormValues>({
|
||||
resolver: zodResolver(agentSchema),
|
||||
defaultValues: {
|
||||
generalPrompt: "",
|
||||
beginMessage: "",
|
||||
numberToCall: "",
|
||||
language: "en-US",
|
||||
voiceId: "",
|
||||
generalTools: [],
|
||||
},
|
||||
});
|
||||
|
||||
const inboundAgentForm = useForm<AgentFormValues>({
|
||||
resolver: zodResolver(agentSchema),
|
||||
defaultValues: {
|
||||
generalPrompt: "",
|
||||
beginMessage: "",
|
||||
numberToCall: "",
|
||||
language: "en-US",
|
||||
voiceId: "",
|
||||
generalTools: [],
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (agentData?.retellData) {
|
||||
const retellData = agentData.retellData;
|
||||
outboundAgentForm.reset({
|
||||
generalPrompt: cleanPromptForDisplay(retellData.generalPrompt || ""),
|
||||
beginMessage: retellData.beginMessage || "",
|
||||
numberToCall: "",
|
||||
language: retellData.language || "en-US",
|
||||
voiceId: retellData.voiceId || "",
|
||||
generalTools: retellData.generalTools || [],
|
||||
});
|
||||
}
|
||||
}, [agentData, outboundAgentForm]);
|
||||
|
||||
useEffect(() => {
|
||||
if (inboundAgentData?.retellData) {
|
||||
const retellData = inboundAgentData.retellData;
|
||||
|
||||
inboundAgentForm.reset({
|
||||
generalPrompt: cleanPromptForDisplay(retellData.generalPrompt || ""),
|
||||
beginMessage: retellData.beginMessage || "",
|
||||
numberToCall: "",
|
||||
language: retellData.language || "en-US",
|
||||
voiceId: retellData.voiceId || "",
|
||||
generalTools: retellData.generalTools || [],
|
||||
});
|
||||
}
|
||||
}, [inboundAgentData, inboundAgentForm]);
|
||||
|
||||
return {
|
||||
outboundAgentForm,
|
||||
inboundAgentForm,
|
||||
};
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import type { PhoneNumberFormValues } from "../types/schemas";
|
||||
import { phoneNumberFormSchema } from "../types/schemas";
|
||||
|
||||
export function usePhoneNumberActions() {
|
||||
const [cancellingNumberId, setCancellingNumberId] = useState<number | null>(null);
|
||||
const [numberToDelete, setNumberToDelete] = useState<string | null>(null);
|
||||
|
||||
const phoneNumberForm = useForm<PhoneNumberFormValues>({
|
||||
resolver: zodResolver(phoneNumberFormSchema),
|
||||
defaultValues: {
|
||||
phoneNumber: "",
|
||||
terminationUri: "",
|
||||
sipTrunkAuthUsername: "",
|
||||
sipTrunkAuthPassword: "",
|
||||
nickname: "",
|
||||
},
|
||||
});
|
||||
|
||||
const handleCancelSubscription = (phoneNumberId: number) => {
|
||||
setCancellingNumberId(phoneNumberId);
|
||||
};
|
||||
|
||||
const handleDeletePhoneNumber = (phoneNumber: string) => {
|
||||
setNumberToDelete(phoneNumber);
|
||||
};
|
||||
|
||||
return {
|
||||
phoneNumberForm,
|
||||
cancellingNumberId,
|
||||
setCancellingNumberId,
|
||||
numberToDelete,
|
||||
setNumberToDelete,
|
||||
handleCancelSubscription,
|
||||
handleDeletePhoneNumber,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { isValidPhoneNumber } from "libphonenumber-js";
|
||||
import { z } from "zod";
|
||||
|
||||
export const agentSchema = z.object({
|
||||
generalPrompt: z.string().min(1, "General prompt is required"),
|
||||
beginMessage: z.string().min(1, "Begin message is required"),
|
||||
numberToCall: z.string().optional(),
|
||||
language: z.string().optional(),
|
||||
voiceId: z.string().optional(),
|
||||
generalTools: z
|
||||
.array(
|
||||
z.object({
|
||||
type: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string().nullish().default(null),
|
||||
cal_api_key: z.string().nullish().default(null),
|
||||
event_type_id: z.number().nullish().default(null),
|
||||
timezone: z.string().nullish().default(null),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const phoneNumberFormSchema = z.object({
|
||||
phoneNumber: z.string().refine((val) => isValidPhoneNumber(val)),
|
||||
terminationUri: z.string().min(1, "Termination URI is required"),
|
||||
sipTrunkAuthUsername: z.string().optional(),
|
||||
sipTrunkAuthPassword: z.string().optional(),
|
||||
nickname: z.string().optional(),
|
||||
});
|
||||
|
||||
export type AgentFormValues = z.infer<typeof agentSchema>;
|
||||
export type PhoneNumberFormValues = z.infer<typeof phoneNumberFormSchema>;
|
||||
|
||||
// Commented types for future implementation
|
||||
// type RetellData = RouterOutputs["viewer"]["ai"]["get"]["retellData"];
|
||||
|
||||
// type ToolDraft = {
|
||||
// type: string;
|
||||
// name: string;
|
||||
// description: string | null;
|
||||
// cal_api_key: string | null;
|
||||
// event_type_id: number | null;
|
||||
// timezone: string;
|
||||
// };
|
||||
@@ -0,0 +1,38 @@
|
||||
export const LANGUAGE_OPTIONS = [
|
||||
{ value: "en-US", label: "English (US)" },
|
||||
{ value: "en-IN", label: "English (India)" },
|
||||
{ value: "en-GB", label: "English (UK)" },
|
||||
{ value: "en-AU", label: "English (Australia)" },
|
||||
{ value: "en-NZ", label: "English (New Zealand)" },
|
||||
{ value: "de-DE", label: "German" },
|
||||
{ value: "es-ES", label: "Spanish (Spain)" },
|
||||
{ value: "es-419", label: "Spanish (Latin America)" },
|
||||
{ value: "hi-IN", label: "Hindi" },
|
||||
{ value: "fr-FR", label: "French (France)" },
|
||||
{ value: "fr-CA", label: "French (Canada)" },
|
||||
{ value: "ja-JP", label: "Japanese" },
|
||||
{ value: "pt-PT", label: "Portuguese (Portugal)" },
|
||||
{ value: "pt-BR", label: "Portuguese (Brazil)" },
|
||||
{ value: "zh-CN", label: "Chinese (Simplified)" },
|
||||
{ value: "ru-RU", label: "Russian" },
|
||||
{ value: "it-IT", label: "Italian" },
|
||||
{ value: "ko-KR", label: "Korean" },
|
||||
{ value: "nl-NL", label: "Dutch (Netherlands)" },
|
||||
{ value: "nl-BE", label: "Dutch (Belgium)" },
|
||||
{ value: "pl-PL", label: "Polish" },
|
||||
{ value: "tr-TR", label: "Turkish" },
|
||||
{ value: "th-TH", label: "Thai" },
|
||||
{ value: "vi-VN", label: "Vietnamese" },
|
||||
{ value: "ro-RO", label: "Romanian" },
|
||||
{ value: "bg-BG", label: "Bulgarian" },
|
||||
{ value: "ca-ES", label: "Catalan" },
|
||||
{ value: "da-DK", label: "Danish" },
|
||||
{ value: "fi-FI", label: "Finnish" },
|
||||
{ value: "el-GR", label: "Greek" },
|
||||
{ value: "hu-HU", label: "Hungarian" },
|
||||
{ value: "id-ID", label: "Indonesian" },
|
||||
{ value: "no-NO", label: "Norwegian" },
|
||||
{ value: "sk-SK", label: "Slovak" },
|
||||
{ value: "sv-SE", label: "Swedish" },
|
||||
{ value: "multi", label: "Multilingual" },
|
||||
];
|
||||
@@ -0,0 +1,40 @@
|
||||
// Utility functions for prompt display and restoration
|
||||
import { RETELL_AI_TEST_MODE, RETELL_AI_TEST_EVENT_TYPE_MAP } from "@calcom/lib/constants";
|
||||
|
||||
export const cleanPromptForDisplay = (prompt: string): string => {
|
||||
if (!prompt) return prompt;
|
||||
|
||||
const cleanedPrompt = prompt
|
||||
.replace(/check_availability_\{\{eventTypeId\}\}/g, "check_availability")
|
||||
.replace(/book_appointment_\{\{eventTypeId\}\}/g, "book_appointment")
|
||||
.replace(/check_availability_\d+/g, "check_availability")
|
||||
.replace(/book_appointment_\d+/g, "book_appointment");
|
||||
|
||||
return cleanedPrompt;
|
||||
};
|
||||
|
||||
export const restorePromptComplexity = (prompt: string): string => {
|
||||
if (!prompt) return prompt;
|
||||
|
||||
return prompt
|
||||
.replace(/\bcheck_availability\b/g, "check_availability_{{eventTypeId}}")
|
||||
.replace(/\bbook_appointment\b/g, "book_appointment_{{eventTypeId}}");
|
||||
};
|
||||
|
||||
/**
|
||||
* Replaces event type placeholders in a prompt with the actual event type ID
|
||||
* Handles both numeric placeholders (check_availability_123) and template placeholders (check_availability_{{eventTypeId}})
|
||||
*/
|
||||
export const replaceEventTypePlaceholders = (prompt: string, eventTypeId: number): string => {
|
||||
let eventTypeIdToUse = eventTypeId;
|
||||
if (RETELL_AI_TEST_MODE && RETELL_AI_TEST_EVENT_TYPE_MAP) {
|
||||
const mappedId = RETELL_AI_TEST_EVENT_TYPE_MAP[String(eventTypeId)];
|
||||
eventTypeIdToUse = mappedId ? Number(mappedId) : eventTypeId;
|
||||
}
|
||||
|
||||
return prompt
|
||||
.replace(/check_availability_\d+/g, `check_availability_${eventTypeIdToUse}`)
|
||||
.replace(/book_appointment_\d+/g, `book_appointment_${eventTypeIdToUse}`)
|
||||
.replace(/check_availability_\{\{eventTypeId\}\}/g, `check_availability_${eventTypeIdToUse}`)
|
||||
.replace(/book_appointment_\{\{eventTypeId\}\}/g, `book_appointment_${eventTypeIdToUse}`);
|
||||
};
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useMemo } from "react";
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
import { useWatch } from "react-hook-form";
|
||||
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
|
||||
import type { FormValues } from "../pages/workflow";
|
||||
|
||||
type AgentData = RouterOutputs["viewer"]["aiVoiceAgent"]["get"];
|
||||
|
||||
interface AgentQuery {
|
||||
data: AgentData | undefined;
|
||||
isPending: boolean;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
interface UseAgentsDataReturn {
|
||||
outboundAgentQueries: AgentQuery[];
|
||||
inboundAgentQueries: AgentQuery[];
|
||||
}
|
||||
|
||||
const emptyQuery: AgentQuery = { data: undefined, isPending: false, isLoading: false, error: null };
|
||||
|
||||
/**
|
||||
* Custom hook to fetch and manage agent data for workflow steps.
|
||||
*
|
||||
* @param form - React Hook Form instance for workflow form values
|
||||
* @returns Object containing outbound and inbound agent queries with proper typing
|
||||
*/
|
||||
export function useAgentsData(form: UseFormReturn<FormValues>): UseAgentsDataReturn {
|
||||
const watchedSteps = useWatch({
|
||||
control: form.control,
|
||||
name: "steps",
|
||||
});
|
||||
|
||||
const allAgentIds = useMemo(() => {
|
||||
const outboundAgentIds = (watchedSteps || []).map((step) => step?.agentId || null);
|
||||
const inboundAgentIds = (watchedSteps || []).map((step) => step?.inboundAgentId || null);
|
||||
return { outboundAgentIds, inboundAgentIds };
|
||||
}, [watchedSteps]);
|
||||
|
||||
const uniqueAgentIds = useMemo((): string[] => {
|
||||
const allIds = [...allAgentIds.outboundAgentIds, ...allAgentIds.inboundAgentIds];
|
||||
const validIds: string[] = [];
|
||||
|
||||
for (const id of allIds) {
|
||||
if (id && typeof id === "string") {
|
||||
validIds.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(new Set(validIds));
|
||||
}, [allAgentIds]);
|
||||
|
||||
const uniqueAgentQueries = trpc.useQueries((t) =>
|
||||
uniqueAgentIds.map((agentId) => {
|
||||
return t.viewer.aiVoiceAgent.get(
|
||||
{ id: agentId },
|
||||
{
|
||||
enabled: true,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
}
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
const agentDataMap = useMemo(() => {
|
||||
const map = new Map<string, AgentQuery>();
|
||||
uniqueAgentIds.forEach((agentId, index) => {
|
||||
const query = uniqueAgentQueries[index];
|
||||
if (query) {
|
||||
map.set(agentId, {
|
||||
data: query.data,
|
||||
isPending: query.isPending,
|
||||
isLoading: query.isLoading,
|
||||
error: query.error,
|
||||
});
|
||||
}
|
||||
});
|
||||
return map;
|
||||
}, [uniqueAgentIds, uniqueAgentQueries]);
|
||||
|
||||
const outboundAgentQueries = useMemo((): AgentQuery[] => {
|
||||
return allAgentIds.outboundAgentIds.map((agentId) => {
|
||||
if (!agentId) {
|
||||
return emptyQuery;
|
||||
}
|
||||
return agentDataMap.get(agentId) || emptyQuery;
|
||||
});
|
||||
}, [allAgentIds.outboundAgentIds, agentDataMap]);
|
||||
|
||||
const inboundAgentQueries = useMemo((): AgentQuery[] => {
|
||||
return allAgentIds.inboundAgentIds.map((agentId) => {
|
||||
if (!agentId) {
|
||||
return emptyQuery;
|
||||
}
|
||||
return agentDataMap.get(agentId) || emptyQuery;
|
||||
});
|
||||
}, [allAgentIds.inboundAgentIds, agentDataMap]);
|
||||
|
||||
return {
|
||||
outboundAgentQueries,
|
||||
inboundAgentQueries,
|
||||
};
|
||||
}
|
||||
@@ -38,7 +38,11 @@ import { getTranslatedText, translateVariablesToEnglish } from "../lib/variableT
|
||||
export type FormValues = {
|
||||
name: string;
|
||||
activeOn: Option[];
|
||||
steps: (WorkflowStep & { senderName: string | null; agentId?: string | null })[];
|
||||
steps: (WorkflowStep & {
|
||||
senderName: string | null;
|
||||
agentId?: string | null;
|
||||
inboundAgentId?: string | null;
|
||||
})[];
|
||||
trigger: WorkflowTriggerEvents;
|
||||
time?: number;
|
||||
timeUnit?: TimeUnit;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import prisma from "@calcom/prisma";
|
||||
import { Prisma } from "@calcom/prisma/client";
|
||||
import { Prisma, PrismaClient } from "@calcom/prisma/client";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
interface _AgentRawResult {
|
||||
@@ -9,6 +8,7 @@ interface _AgentRawResult {
|
||||
enabled: boolean;
|
||||
userId: number;
|
||||
teamId: number | null;
|
||||
inboundEventTypeId?: number | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
user_id?: number;
|
||||
@@ -29,8 +29,10 @@ interface _PhoneNumberRawResult {
|
||||
}
|
||||
|
||||
export class PrismaAgentRepository {
|
||||
private static async getUserAccessibleTeamIds(userId: number): Promise<number[]> {
|
||||
const memberships = await prisma.membership.findMany({
|
||||
constructor(private prismaClient: PrismaClient) {}
|
||||
|
||||
private async getUserAccessibleTeamIds(userId: number): Promise<number[]> {
|
||||
const memberships = await this.prismaClient.membership.findMany({
|
||||
where: {
|
||||
userId,
|
||||
accepted: true,
|
||||
@@ -43,8 +45,8 @@ export class PrismaAgentRepository {
|
||||
return memberships.map((membership) => membership.teamId);
|
||||
}
|
||||
|
||||
private static async getUserAdminTeamIds(userId: number): Promise<number[]> {
|
||||
const memberships = await prisma.membership.findMany({
|
||||
private async getUserAdminTeamIds(userId: number): Promise<number[]> {
|
||||
const memberships = await this.prismaClient.membership.findMany({
|
||||
where: {
|
||||
userId,
|
||||
accepted: true,
|
||||
@@ -60,7 +62,7 @@ export class PrismaAgentRepository {
|
||||
return memberships.map((membership) => membership.teamId);
|
||||
}
|
||||
|
||||
static async findByIdWithUserAccess({
|
||||
async findByIdWithUserAccess({
|
||||
agentId,
|
||||
userId,
|
||||
teamId,
|
||||
@@ -98,6 +100,7 @@ export class PrismaAgentRepository {
|
||||
enabled,
|
||||
"userId",
|
||||
"teamId",
|
||||
"inboundEventTypeId",
|
||||
"createdAt",
|
||||
"updatedAt"
|
||||
FROM "Agent"
|
||||
@@ -105,12 +108,12 @@ export class PrismaAgentRepository {
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
const agents = await prisma.$queryRaw<_AgentRawResult[]>(query);
|
||||
const agents = await this.prismaClient.$queryRaw<_AgentRawResult[]>(query);
|
||||
|
||||
return agents.length > 0 ? agents[0] : null;
|
||||
}
|
||||
|
||||
static async findByProviderAgentIdWithUserAccess({
|
||||
async findByProviderAgentIdWithUserAccess({
|
||||
providerAgentId,
|
||||
userId,
|
||||
}: {
|
||||
@@ -136,6 +139,7 @@ export class PrismaAgentRepository {
|
||||
enabled,
|
||||
"userId",
|
||||
"teamId",
|
||||
"inboundEventTypeId",
|
||||
"createdAt",
|
||||
"updatedAt"
|
||||
FROM "Agent"
|
||||
@@ -143,13 +147,13 @@ export class PrismaAgentRepository {
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
const agents = await prisma.$queryRaw<_AgentRawResult[]>(query);
|
||||
const agents = await this.prismaClient.$queryRaw<_AgentRawResult[]>(query);
|
||||
|
||||
return agents.length > 0 ? agents[0] : null;
|
||||
}
|
||||
|
||||
static async findById({ id }: { id: string }) {
|
||||
return await prisma.agent.findUnique({
|
||||
async findById({ id }: { id: string }) {
|
||||
return await this.prismaClient.agent.findUnique({
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
@@ -157,6 +161,7 @@ export class PrismaAgentRepository {
|
||||
enabled: true,
|
||||
userId: true,
|
||||
teamId: true,
|
||||
inboundEventTypeId: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
@@ -166,8 +171,8 @@ export class PrismaAgentRepository {
|
||||
});
|
||||
}
|
||||
|
||||
static async findByProviderAgentId({ providerAgentId }: { providerAgentId: string }) {
|
||||
return await prisma.agent.findUnique({
|
||||
async findByProviderAgentId({ providerAgentId }: { providerAgentId: string }) {
|
||||
return await this.prismaClient.agent.findUnique({
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
@@ -175,6 +180,7 @@ export class PrismaAgentRepository {
|
||||
enabled: true,
|
||||
userId: true,
|
||||
teamId: true,
|
||||
inboundEventTypeId: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
@@ -184,7 +190,7 @@ export class PrismaAgentRepository {
|
||||
});
|
||||
}
|
||||
|
||||
static async findManyWithUserAccess({
|
||||
async findManyWithUserAccess({
|
||||
userId,
|
||||
teamId,
|
||||
scope = "all",
|
||||
@@ -239,6 +245,7 @@ export class PrismaAgentRepository {
|
||||
a.enabled,
|
||||
a."userId",
|
||||
a."teamId",
|
||||
a."inboundEventTypeId",
|
||||
a."createdAt",
|
||||
a."updatedAt",
|
||||
u.id as user_id,
|
||||
@@ -255,13 +262,13 @@ export class PrismaAgentRepository {
|
||||
ORDER BY a."teamId" ASC, a."createdAt" DESC
|
||||
`;
|
||||
|
||||
const agents = await prisma.$queryRaw<_AgentRawResult[]>(query);
|
||||
const agents = await this.prismaClient.$queryRaw<_AgentRawResult[]>(query);
|
||||
|
||||
// Get phone numbers for each agent in a separate query to avoid N+1
|
||||
const agentIds = agents.map((agent) => agent.id);
|
||||
const phoneNumbers =
|
||||
agentIds.length > 0
|
||||
? await prisma.$queryRaw<_PhoneNumberRawResult[]>`
|
||||
? await this.prismaClient.$queryRaw<_PhoneNumberRawResult[]>`
|
||||
SELECT
|
||||
pn.id,
|
||||
pn."phoneNumber",
|
||||
@@ -298,6 +305,7 @@ export class PrismaAgentRepository {
|
||||
enabled: agent.enabled,
|
||||
userId: agent.userId,
|
||||
teamId: agent.teamId,
|
||||
inboundEventTypeId: agent.inboundEventTypeId,
|
||||
createdAt: agent.createdAt,
|
||||
updatedAt: agent.updatedAt,
|
||||
user: agent.user_id
|
||||
@@ -319,7 +327,7 @@ export class PrismaAgentRepository {
|
||||
}));
|
||||
}
|
||||
|
||||
static async findByIdWithUserAccessAndDetails({
|
||||
async findByIdWithUserAccessAndDetails({
|
||||
id,
|
||||
userId,
|
||||
teamId,
|
||||
@@ -357,6 +365,7 @@ export class PrismaAgentRepository {
|
||||
a.enabled,
|
||||
a."userId",
|
||||
a."teamId",
|
||||
a."inboundEventTypeId",
|
||||
a."createdAt",
|
||||
a."updatedAt",
|
||||
u.id as user_id,
|
||||
@@ -372,7 +381,7 @@ export class PrismaAgentRepository {
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
const agents = await prisma.$queryRaw<_AgentRawResult[]>(query);
|
||||
const agents = await this.prismaClient.$queryRaw<_AgentRawResult[]>(query);
|
||||
|
||||
if (agents.length === 0) {
|
||||
return null;
|
||||
@@ -380,7 +389,7 @@ export class PrismaAgentRepository {
|
||||
|
||||
const agent = agents[0];
|
||||
|
||||
const phoneNumbers = await prisma.$queryRaw<_PhoneNumberRawResult[]>`
|
||||
const phoneNumbers = await this.prismaClient.$queryRaw<_PhoneNumberRawResult[]>`
|
||||
SELECT
|
||||
pn.id,
|
||||
pn."phoneNumber",
|
||||
@@ -398,6 +407,7 @@ export class PrismaAgentRepository {
|
||||
enabled: agent.enabled,
|
||||
userId: agent.userId,
|
||||
teamId: agent.teamId,
|
||||
inboundEventTypeId: agent.inboundEventTypeId,
|
||||
createdAt: agent.createdAt,
|
||||
updatedAt: agent.updatedAt,
|
||||
user: agent.user_id
|
||||
@@ -423,7 +433,7 @@ export class PrismaAgentRepository {
|
||||
};
|
||||
}
|
||||
|
||||
static async create({
|
||||
async create({
|
||||
name,
|
||||
providerAgentId,
|
||||
userId,
|
||||
@@ -434,7 +444,7 @@ export class PrismaAgentRepository {
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
}) {
|
||||
return await prisma.agent.create({
|
||||
return await this.prismaClient.agent.create({
|
||||
data: {
|
||||
name,
|
||||
providerAgentId,
|
||||
@@ -444,7 +454,7 @@ export class PrismaAgentRepository {
|
||||
});
|
||||
}
|
||||
|
||||
static async findByIdWithAdminAccess({
|
||||
async findByIdWithAdminAccess({
|
||||
id,
|
||||
userId,
|
||||
teamId,
|
||||
@@ -481,18 +491,19 @@ export class PrismaAgentRepository {
|
||||
"userId",
|
||||
"teamId",
|
||||
"createdAt",
|
||||
"updatedAt"
|
||||
"updatedAt",
|
||||
"inboundEventTypeId"
|
||||
FROM "Agent"
|
||||
WHERE ${whereCondition}
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
const agents = await prisma.$queryRaw<_AgentRawResult[]>(query);
|
||||
const agents = await this.prismaClient.$queryRaw<_AgentRawResult[]>(query);
|
||||
|
||||
return agents.length > 0 ? agents[0] : null;
|
||||
}
|
||||
|
||||
static async findByIdWithCallAccess({ id, userId }: { id: string; userId: number }) {
|
||||
async findByIdWithCallAccess({ id, userId }: { id: string; userId: number }) {
|
||||
const accessibleTeamIds = await this.getUserAccessibleTeamIds(userId);
|
||||
|
||||
let whereCondition: Prisma.Sql;
|
||||
@@ -512,6 +523,7 @@ export class PrismaAgentRepository {
|
||||
a.enabled,
|
||||
a."userId",
|
||||
a."teamId",
|
||||
a."inboundEventTypeId",
|
||||
a."createdAt",
|
||||
a."updatedAt"
|
||||
FROM "Agent" a
|
||||
@@ -519,7 +531,7 @@ export class PrismaAgentRepository {
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
const agents = await prisma.$queryRaw<_AgentRawResult[]>(query);
|
||||
const agents = await this.prismaClient.$queryRaw<_AgentRawResult[]>(query);
|
||||
|
||||
if (agents.length === 0) {
|
||||
return null;
|
||||
@@ -527,7 +539,7 @@ export class PrismaAgentRepository {
|
||||
|
||||
const agent = agents[0];
|
||||
|
||||
const phoneNumbers = await prisma.$queryRaw<{ phoneNumber: string }[]>`
|
||||
const phoneNumbers = await this.prismaClient.$queryRaw<{ phoneNumber: string }[]>`
|
||||
SELECT "phoneNumber"
|
||||
FROM "CalAiPhoneNumber"
|
||||
WHERE "outboundAgentId" = ${agent.id}
|
||||
@@ -539,27 +551,61 @@ export class PrismaAgentRepository {
|
||||
};
|
||||
}
|
||||
|
||||
static async delete({ id }: { id: string }) {
|
||||
return await prisma.agent.delete({
|
||||
async delete({ id }: { id: string }) {
|
||||
return await this.prismaClient.agent.delete({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
|
||||
static async linkToWorkflowStep({ workflowStepId, agentId }: { workflowStepId: number; agentId: string }) {
|
||||
return await prisma.workflowStep.update({
|
||||
async linkOutboundAgentToWorkflow({
|
||||
workflowStepId,
|
||||
agentId,
|
||||
}: {
|
||||
workflowStepId: number;
|
||||
agentId: string;
|
||||
}) {
|
||||
return await this.prismaClient.workflowStep.update({
|
||||
where: { id: workflowStepId },
|
||||
data: { agentId },
|
||||
});
|
||||
}
|
||||
|
||||
static async canManageTeamResources({
|
||||
async linkInboundAgentToWorkflow({
|
||||
workflowStepId,
|
||||
agentId,
|
||||
}: {
|
||||
workflowStepId: number;
|
||||
agentId: string;
|
||||
}) {
|
||||
return await this.prismaClient.workflowStep.update({
|
||||
where: {
|
||||
id: workflowStepId,
|
||||
},
|
||||
data: {
|
||||
inboundAgentId: agentId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async updateEventTypeId({ agentId, eventTypeId }: { agentId: string; eventTypeId: number }) {
|
||||
return await this.prismaClient.agent.update({
|
||||
where: {
|
||||
id: agentId,
|
||||
},
|
||||
data: {
|
||||
inboundEventTypeId: eventTypeId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async canManageTeamResources({
|
||||
userId,
|
||||
teamId,
|
||||
}: {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
}): Promise<boolean> {
|
||||
const result = await prisma.$queryRaw<{ count: bigint }[]>`
|
||||
const result = await this.prismaClient.$queryRaw<{ count: bigint }[]>`
|
||||
SELECT COUNT(*) as count
|
||||
FROM "Membership"
|
||||
WHERE "userId" = ${userId}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { PrismaClient } from "@calcom/prisma";
|
||||
import { Prisma } from "@calcom/prisma/client";
|
||||
import { PhoneNumberSubscriptionStatus } from "@calcom/prisma/enums";
|
||||
|
||||
@@ -24,8 +24,10 @@ export interface AgentRawResult {
|
||||
}
|
||||
|
||||
export class PrismaPhoneNumberRepository {
|
||||
private static async getUserAccessibleTeamIds(userId: number): Promise<number[]> {
|
||||
const memberships = await prisma.membership.findMany({
|
||||
constructor(private prismaClient: PrismaClient) {}
|
||||
|
||||
private async getUserAccessibleTeamIds(userId: number): Promise<number[]> {
|
||||
const memberships = await this.prismaClient.membership.findMany({
|
||||
where: {
|
||||
userId,
|
||||
accepted: true,
|
||||
@@ -38,8 +40,8 @@ export class PrismaPhoneNumberRepository {
|
||||
return memberships.map((membership) => membership.teamId);
|
||||
}
|
||||
|
||||
static async findByPhoneNumberAndUserId({ phoneNumber, userId }: { phoneNumber: string; userId: number }) {
|
||||
return await prisma.calAiPhoneNumber.findFirstOrThrow({
|
||||
async findByPhoneNumberAndUserId({ phoneNumber, userId }: { phoneNumber: string; userId: number }) {
|
||||
return await this.prismaClient.calAiPhoneNumber.findFirstOrThrow({
|
||||
where: {
|
||||
phoneNumber,
|
||||
userId,
|
||||
@@ -61,8 +63,8 @@ export class PrismaPhoneNumberRepository {
|
||||
});
|
||||
}
|
||||
|
||||
static async findPhoneNumbersFromUserId({ userId }: { userId: number }) {
|
||||
const phoneNumbers = await prisma.$queryRaw<_PhoneNumberRawResult[]>`
|
||||
async findPhoneNumbersFromUserId({ userId }: { userId: number }) {
|
||||
const phoneNumbers = await this.prismaClient.$queryRaw<_PhoneNumberRawResult[]>`
|
||||
SELECT
|
||||
pn.id,
|
||||
pn."phoneNumber",
|
||||
@@ -84,7 +86,7 @@ export class PrismaPhoneNumberRepository {
|
||||
const phoneNumberIds = phoneNumbers.map((pn) => pn.id);
|
||||
const agents =
|
||||
phoneNumberIds.length > 0
|
||||
? await prisma.$queryRaw<(AgentRawResult & { phoneNumberId: number; agentType: string })[]>`
|
||||
? await this.prismaClient.$queryRaw<(AgentRawResult & { phoneNumberId: number; agentType: string })[]>`
|
||||
SELECT
|
||||
a.id,
|
||||
a.name,
|
||||
@@ -137,7 +139,7 @@ export class PrismaPhoneNumberRepository {
|
||||
}));
|
||||
}
|
||||
|
||||
static async createPhoneNumber({
|
||||
async createPhoneNumber({
|
||||
phoneNumber,
|
||||
provider,
|
||||
userId,
|
||||
@@ -156,7 +158,7 @@ export class PrismaPhoneNumberRepository {
|
||||
subscriptionStatus?: PhoneNumberSubscriptionStatus;
|
||||
providerPhoneNumberId?: string;
|
||||
}) {
|
||||
return await prisma.calAiPhoneNumber.create({
|
||||
return await this.prismaClient.calAiPhoneNumber.create({
|
||||
select: {
|
||||
id: true,
|
||||
phoneNumber: true,
|
||||
@@ -185,16 +187,16 @@ export class PrismaPhoneNumberRepository {
|
||||
});
|
||||
}
|
||||
|
||||
static async deletePhoneNumber({ phoneNumber }: { phoneNumber: string }) {
|
||||
return await prisma.calAiPhoneNumber.delete({
|
||||
async deletePhoneNumber({ phoneNumber }: { phoneNumber: string }) {
|
||||
return await this.prismaClient.calAiPhoneNumber.delete({
|
||||
where: {
|
||||
phoneNumber,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
static async findByStripeSubscriptionId({ stripeSubscriptionId }: { stripeSubscriptionId: string }) {
|
||||
return await prisma.calAiPhoneNumber.findFirst({
|
||||
async findByStripeSubscriptionId({ stripeSubscriptionId }: { stripeSubscriptionId: string }) {
|
||||
return await this.prismaClient.calAiPhoneNumber.findFirst({
|
||||
where: {
|
||||
stripeSubscriptionId,
|
||||
},
|
||||
@@ -211,8 +213,8 @@ export class PrismaPhoneNumberRepository {
|
||||
});
|
||||
}
|
||||
|
||||
static async findByIdAndUserId({ id, userId }: { id: number; userId: number }) {
|
||||
return await prisma.calAiPhoneNumber.findFirst({
|
||||
async findByIdAndUserId({ id, userId }: { id: number; userId: number }) {
|
||||
return await this.prismaClient.calAiPhoneNumber.findFirst({
|
||||
where: {
|
||||
id,
|
||||
userId,
|
||||
@@ -234,7 +236,7 @@ export class PrismaPhoneNumberRepository {
|
||||
});
|
||||
}
|
||||
|
||||
static async findByIdWithTeamAccess({
|
||||
async findByIdWithTeamAccess({
|
||||
id,
|
||||
teamId,
|
||||
userId,
|
||||
@@ -249,7 +251,7 @@ export class PrismaPhoneNumberRepository {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await prisma.calAiPhoneNumber.findFirst({
|
||||
return await this.prismaClient.calAiPhoneNumber.findFirst({
|
||||
where: {
|
||||
id,
|
||||
teamId,
|
||||
@@ -271,7 +273,7 @@ export class PrismaPhoneNumberRepository {
|
||||
});
|
||||
}
|
||||
|
||||
static async findByPhoneNumberAndTeamId({
|
||||
async findByPhoneNumberAndTeamId({
|
||||
phoneNumber,
|
||||
teamId,
|
||||
userId,
|
||||
@@ -286,7 +288,7 @@ export class PrismaPhoneNumberRepository {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await prisma.calAiPhoneNumber.findFirst({
|
||||
return await this.prismaClient.calAiPhoneNumber.findFirst({
|
||||
where: {
|
||||
phoneNumber,
|
||||
teamId,
|
||||
@@ -308,7 +310,7 @@ export class PrismaPhoneNumberRepository {
|
||||
});
|
||||
}
|
||||
|
||||
static async findManyWithUserAccess({
|
||||
async findManyWithUserAccess({
|
||||
userId,
|
||||
teamId,
|
||||
scope = "all",
|
||||
@@ -373,12 +375,12 @@ export class PrismaPhoneNumberRepository {
|
||||
ORDER BY pn."createdAt" DESC
|
||||
`;
|
||||
|
||||
const phoneNumbers = await prisma.$queryRaw<_PhoneNumberRawResult[]>(query);
|
||||
const phoneNumbers = await this.prismaClient.$queryRaw<_PhoneNumberRawResult[]>(query);
|
||||
|
||||
const phoneNumberIds = phoneNumbers.map((pn) => pn.id);
|
||||
const agents =
|
||||
phoneNumberIds.length > 0
|
||||
? await prisma.$queryRaw<(AgentRawResult & { phoneNumberId: number; agentType: string })[]>`
|
||||
? await this.prismaClient.$queryRaw<(AgentRawResult & { phoneNumberId: number; agentType: string })[]>`
|
||||
SELECT
|
||||
a.id,
|
||||
a.name,
|
||||
@@ -431,26 +433,29 @@ export class PrismaPhoneNumberRepository {
|
||||
}));
|
||||
}
|
||||
|
||||
static async updateSubscriptionStatus({
|
||||
async updateSubscriptionStatus({
|
||||
id,
|
||||
subscriptionStatus,
|
||||
disconnectOutboundAgent = false,
|
||||
disconnectAgents = false,
|
||||
}: {
|
||||
id: number;
|
||||
subscriptionStatus: PhoneNumberSubscriptionStatus;
|
||||
disconnectOutboundAgent?: boolean;
|
||||
disconnectAgents?: boolean;
|
||||
}) {
|
||||
const updateData: Prisma.CalAiPhoneNumberUpdateInput = {
|
||||
subscriptionStatus,
|
||||
};
|
||||
|
||||
if (disconnectOutboundAgent) {
|
||||
if (disconnectAgents) {
|
||||
updateData.outboundAgent = {
|
||||
disconnect: true,
|
||||
};
|
||||
updateData.inboundAgent = {
|
||||
disconnect: true,
|
||||
};
|
||||
}
|
||||
|
||||
return await prisma.calAiPhoneNumber.update({
|
||||
return await this.prismaClient.calAiPhoneNumber.update({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
@@ -458,7 +463,7 @@ export class PrismaPhoneNumberRepository {
|
||||
});
|
||||
}
|
||||
|
||||
static async updateAgents({
|
||||
async updateAgents({
|
||||
id,
|
||||
inboundProviderAgentId,
|
||||
outboundProviderAgentId,
|
||||
@@ -471,7 +476,7 @@ export class PrismaPhoneNumberRepository {
|
||||
|
||||
if (inboundProviderAgentId !== undefined) {
|
||||
if (inboundProviderAgentId) {
|
||||
const agent = await prisma.agent.findFirst({
|
||||
const agent = await this.prismaClient.agent.findFirst({
|
||||
where: {
|
||||
providerAgentId: inboundProviderAgentId,
|
||||
},
|
||||
@@ -491,7 +496,7 @@ export class PrismaPhoneNumberRepository {
|
||||
|
||||
if (outboundProviderAgentId !== undefined) {
|
||||
if (outboundProviderAgentId) {
|
||||
const agent = await prisma.agent.findFirst({
|
||||
const agent = await this.prismaClient.agent.findFirst({
|
||||
where: {
|
||||
providerAgentId: outboundProviderAgentId,
|
||||
},
|
||||
@@ -509,7 +514,7 @@ export class PrismaPhoneNumberRepository {
|
||||
}
|
||||
}
|
||||
|
||||
return await prisma.calAiPhoneNumber.update({
|
||||
return await this.prismaClient.calAiPhoneNumber.update({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
@@ -517,8 +522,32 @@ export class PrismaPhoneNumberRepository {
|
||||
});
|
||||
}
|
||||
|
||||
static async findByPhoneNumber({ phoneNumber }: { phoneNumber: string }) {
|
||||
return await prisma.calAiPhoneNumber.findFirst({
|
||||
async updateInboundAgentId({
|
||||
id,
|
||||
agentId,
|
||||
}: {
|
||||
id: number;
|
||||
agentId: string;
|
||||
}) {
|
||||
// Atomic update: only set if inboundAgentId is currently null
|
||||
return await this.prismaClient.calAiPhoneNumber.updateMany({
|
||||
where: {
|
||||
id,
|
||||
inboundAgentId: null,
|
||||
},
|
||||
data: { inboundAgentId: agentId },
|
||||
});
|
||||
}
|
||||
|
||||
async findInboundAgentIdByPhoneNumberId({ phoneNumberId }: { phoneNumberId: number }) {
|
||||
return await this.prismaClient.calAiPhoneNumber.findUnique({
|
||||
where: { id: phoneNumberId },
|
||||
select: { inboundAgentId: true },
|
||||
});
|
||||
}
|
||||
|
||||
async findByPhoneNumber({ phoneNumber }: { phoneNumber: string }) {
|
||||
return await this.prismaClient.calAiPhoneNumber.findFirst({
|
||||
where: {
|
||||
phoneNumber,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- A unique constraint covering the columns `[inboundAgentId]` on the table `WorkflowStep` will be added. If there are existing duplicate values, this will fail.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "Agent" ADD COLUMN "inboundEventTypeId" INTEGER;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "WorkflowStep" ADD COLUMN "inboundAgentId" TEXT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Agent_inboundEventTypeId_idx" ON "Agent"("inboundEventTypeId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "WorkflowStep_inboundAgentId_key" ON "WorkflowStep"("inboundAgentId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "WorkflowStep" ADD CONSTRAINT "WorkflowStep_inboundAgentId_fkey" FOREIGN KEY ("inboundAgentId") REFERENCES "Agent"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -247,6 +247,7 @@ model EventType {
|
||||
createdAt DateTime? @default(now())
|
||||
updatedAt DateTime? @updatedAt
|
||||
|
||||
|
||||
@@unique([userId, slug])
|
||||
@@unique([teamId, slug])
|
||||
@@unique([userId, parentId])
|
||||
@@ -1426,6 +1427,9 @@ model WorkflowStep {
|
||||
agentId String? @unique
|
||||
agent Agent? @relation(fields: [agentId], references: [id], onDelete: SetNull)
|
||||
|
||||
inboundAgentId String? @unique
|
||||
inboundAgent Agent? @relation("WorkflowStepInboundAgent", fields: [inboundAgentId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([workflowId])
|
||||
}
|
||||
|
||||
@@ -2538,6 +2542,7 @@ enum PhoneNumberSubscriptionStatus {
|
||||
UNPAID
|
||||
}
|
||||
|
||||
|
||||
// Cal AI Voice Agent
|
||||
model Agent {
|
||||
id String @id @default(uuid())
|
||||
@@ -2552,6 +2557,9 @@ model Agent {
|
||||
// Provider-specific agent ID (e.g., Retell AI agent ID)
|
||||
providerAgentId String @unique
|
||||
|
||||
// Event type association for inbound agents
|
||||
inboundEventTypeId Int?
|
||||
|
||||
enabled Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -2559,9 +2567,11 @@ model Agent {
|
||||
inboundPhoneNumbers CalAiPhoneNumber[] @relation("InboundAgent")
|
||||
outboundPhoneNumbers CalAiPhoneNumber[] @relation("OutboundAgent")
|
||||
workflowStep WorkflowStep?
|
||||
inboundWorkflowStep WorkflowStep? @relation("WorkflowStepInboundAgent")
|
||||
|
||||
@@index([userId])
|
||||
@@index([teamId])
|
||||
@@index([inboundEventTypeId])
|
||||
}
|
||||
|
||||
model CalAiPhoneNumber {
|
||||
|
||||
@@ -7,8 +7,10 @@ import { ZDeleteInputSchema } from "./delete.schema";
|
||||
import { ZGetInputSchema } from "./get.schema";
|
||||
import { ZListInputSchema } from "./list.schema";
|
||||
import { ZListCallsInputSchema } from "./listCalls.schema";
|
||||
import { ZSetupInboundAgentInputSchema } from "./setupInboundAgent.schema";
|
||||
import { ZTestCallInputSchema } from "./testCall.schema";
|
||||
import { ZUpdateInputSchema } from "./update.schema";
|
||||
import { ZUpdateInboundAgentEventTypeInputSchema } from "./updateInboundAgentEventType.schema";
|
||||
|
||||
export const aiVoiceAgentRouter = router({
|
||||
list: authedProcedure.input(ZListInputSchema).query(async ({ ctx, input }) => {
|
||||
@@ -72,7 +74,7 @@ export const aiVoiceAgentRouter = router({
|
||||
ctx,
|
||||
input,
|
||||
});
|
||||
}),
|
||||
}),
|
||||
|
||||
createWebCall: eventOwnerProcedure.input(ZCreateWebCallInputSchema).mutation(async ({ ctx, input }) => {
|
||||
const { createWebCallHandler } = await import("./createWebCall.handler");
|
||||
@@ -90,4 +92,24 @@ export const aiVoiceAgentRouter = router({
|
||||
ctx,
|
||||
});
|
||||
}),
|
||||
|
||||
setupInboundAgent: authedProcedure.input(ZSetupInboundAgentInputSchema).mutation(async ({ ctx, input }) => {
|
||||
const { setupInboundAgentHandler } = await import("./setupInboundAgent.handler");
|
||||
|
||||
return setupInboundAgentHandler({
|
||||
ctx,
|
||||
input,
|
||||
});
|
||||
}),
|
||||
|
||||
updateInboundAgentEventType: eventOwnerProcedure
|
||||
.input(ZUpdateInboundAgentEventTypeInputSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { updateInboundAgentEventTypeHandler } = await import("./updateInboundAgentEventType.handler");
|
||||
|
||||
return updateInboundAgentEventTypeHandler({
|
||||
ctx,
|
||||
input,
|
||||
});
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -22,7 +22,7 @@ export const createHandler = async ({ ctx, input }: CreateHandlerOptions) => {
|
||||
?.generalPrompt
|
||||
: undefined;
|
||||
|
||||
return await aiService.createAgent({
|
||||
return await aiService.createOutboundAgent({
|
||||
name,
|
||||
userId: ctx.user.id,
|
||||
teamId,
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { createDefaultAIPhoneServiceProvider } from "@calcom/features/calAIPhone";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import logger from "@calcom/lib/logger";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import type { TrpcSessionUser } from "../../../types";
|
||||
import type { TSetupInboundAgentInputSchema } from "./setupInboundAgent.schema";
|
||||
|
||||
type SetupInboundAgentHandlerOptions = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
};
|
||||
input: TSetupInboundAgentInputSchema;
|
||||
};
|
||||
|
||||
export const setupInboundAgentHandler = async ({ ctx, input }: SetupInboundAgentHandlerOptions) => {
|
||||
const log = logger.getSubLogger({ prefix: ["setupInboundAgentHandler"] });
|
||||
|
||||
try {
|
||||
const { phoneNumber, teamId, workflowStepId } = input;
|
||||
const userId = ctx.user.id;
|
||||
const userTimeZone = ctx.user.timeZone || "UTC";
|
||||
|
||||
const aiService = createDefaultAIPhoneServiceProvider();
|
||||
|
||||
const createAgentResult = await aiService.createInboundAgent({
|
||||
name: `Inbound Agent - ${phoneNumber}`,
|
||||
phoneNumber,
|
||||
userId,
|
||||
teamId,
|
||||
workflowStepId,
|
||||
userTimeZone,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
agentId: createAgentResult.id,
|
||||
message: "Inbound agent configured successfully",
|
||||
};
|
||||
} catch (error) {
|
||||
log.error("Failed to setup inbound agent", { error });
|
||||
|
||||
if (error instanceof HttpError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to setup inbound agent",
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ZSetupInboundAgentInputSchema = z.object({
|
||||
phoneNumber: z.string(),
|
||||
teamId: z.number().optional(),
|
||||
workflowStepId: z.number(),
|
||||
});
|
||||
|
||||
export type TSetupInboundAgentInputSchema = z.infer<typeof ZSetupInboundAgentInputSchema>;
|
||||
@@ -2,7 +2,7 @@ import { createDefaultAIPhoneServiceProvider } from "@calcom/features/calAIPhone
|
||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { PrismaAgentRepository } from "@calcom/lib/server/repository/PrismaAgentRepository";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
@@ -27,7 +27,8 @@ export const testCallHandler = async ({ ctx, input }: TestCallHandlerOptions) =>
|
||||
return;
|
||||
}
|
||||
|
||||
const agent = await PrismaAgentRepository.findByIdWithCallAccess({
|
||||
const agentRepo = new PrismaAgentRepository(prisma);
|
||||
const agent = await agentRepo.findByIdWithCallAccess({
|
||||
id: input.agentId,
|
||||
userId: ctx.user.id,
|
||||
});
|
||||
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
import { createDefaultAIPhoneServiceProvider } from "@calcom/features/calAIPhone";
|
||||
import { replaceEventTypePlaceholders } from "@calcom/features/ee/workflows/components/agent-configuration/utils/promptUtils";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { PrismaAgentRepository } from "@calcom/lib/server/repository/PrismaAgentRepository";
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { TrpcSessionUser } from "../../../types";
|
||||
import type { TUpdateInboundAgentEventTypeInputSchema } from "./updateInboundAgentEventType.schema";
|
||||
|
||||
type UpdateInboundAgentEventTypeHandlerOptions = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
};
|
||||
input: TUpdateInboundAgentEventTypeInputSchema;
|
||||
};
|
||||
|
||||
export const updateInboundAgentEventTypeHandler = async ({
|
||||
ctx,
|
||||
input,
|
||||
}: UpdateInboundAgentEventTypeHandlerOptions) => {
|
||||
const log = logger.getSubLogger({ prefix: ["updateInboundAgentEventTypeHandler"] });
|
||||
|
||||
try {
|
||||
const { agentId, eventTypeId, teamId } = input;
|
||||
const userId = ctx.user.id;
|
||||
const userTimeZone = ctx.user.timeZone || "UTC";
|
||||
|
||||
const aiService = createDefaultAIPhoneServiceProvider();
|
||||
|
||||
const agentDetails = await aiService.getAgentWithDetails({
|
||||
id: agentId,
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
if (!agentDetails.retellData.generalPrompt) {
|
||||
throw new Error("Agent configuration not found");
|
||||
}
|
||||
|
||||
// Replace placeholders in the prompt with the specific event type ID
|
||||
const updatedPrompt = replaceEventTypePlaceholders(agentDetails.retellData.generalPrompt, eventTypeId);
|
||||
|
||||
// Update tools and prompt using existing updateAgentConfiguration method
|
||||
await aiService.updateToolsFromAgentId(agentDetails.retellData.agentId, {
|
||||
eventTypeId,
|
||||
timeZone: userTimeZone,
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
const result = await aiService.updateAgentConfiguration({
|
||||
id: agentId,
|
||||
userId,
|
||||
teamId,
|
||||
generalPrompt: updatedPrompt,
|
||||
});
|
||||
|
||||
const agentRepo = new PrismaAgentRepository(prisma);
|
||||
await agentRepo.updateEventTypeId({
|
||||
agentId,
|
||||
eventTypeId,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: result.message,
|
||||
};
|
||||
} catch (error) {
|
||||
log.error("Failed to update inbound agent prompt", { error });
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ZUpdateInboundAgentEventTypeInputSchema = z.object({
|
||||
agentId: z.string(),
|
||||
eventTypeId: z.number(),
|
||||
teamId: z.number().optional(),
|
||||
});
|
||||
|
||||
export type TUpdateInboundAgentEventTypeInputSchema = z.infer<typeof ZUpdateInboundAgentEventTypeInputSchema>;
|
||||
@@ -1,4 +1,5 @@
|
||||
import { PrismaPhoneNumberRepository } from "@calcom/lib/server/repository/PrismaPhoneNumberRepository";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import type { TrpcSessionUser } from "../../../types";
|
||||
import type { TListInputSchema } from "./list.schema";
|
||||
@@ -11,7 +12,8 @@ type ListHandlerOptions = {
|
||||
};
|
||||
|
||||
export const listHandler = async ({ ctx, input }: ListHandlerOptions) => {
|
||||
return await PrismaPhoneNumberRepository.findManyWithUserAccess({
|
||||
const phoneNumberRepo = new PrismaPhoneNumberRepository(prisma);
|
||||
return await phoneNumberRepo.findManyWithUserAccess({
|
||||
userId: ctx.user.id,
|
||||
teamId: input?.teamId,
|
||||
scope: input?.scope || "all",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { WorkflowRepository } from "@calcom/lib/server/repository/workflow";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { WorkflowActions } from "@calcom/prisma/enums";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
|
||||
import logger from "@calcom/lib/logger";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
@@ -22,6 +23,7 @@ type DeleteOptions = {
|
||||
|
||||
export const deleteHandler = async ({ ctx, input }: DeleteOptions) => {
|
||||
const { id } = input;
|
||||
const log = logger.getSubLogger({ prefix: ["workflows/deleteHandler"] });
|
||||
|
||||
const workflowToDelete = await prisma.workflow.findUnique({
|
||||
where: {
|
||||
@@ -56,6 +58,11 @@ export const deleteHandler = async ({ ctx, input }: DeleteOptions) => {
|
||||
},
|
||||
},
|
||||
},
|
||||
inboundAgent: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
team: {
|
||||
@@ -100,7 +107,7 @@ export const deleteHandler = async ({ ctx, input }: DeleteOptions) => {
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to handle phone number ${phoneNumber.phoneNumber}:`, error);
|
||||
log.error(`Failed to handle phone number ${phoneNumber.phoneNumber}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -113,8 +120,19 @@ export const deleteHandler = async ({ ctx, input }: DeleteOptions) => {
|
||||
teamId: workflowToDelete.teamId ?? undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Failed to delete agent ${step.agent.id}:`, error);
|
||||
// Continue with deletion even if agent deletion fails
|
||||
log.error(`Failed to delete agent ${step.agent.id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
if (step.inboundAgent) {
|
||||
try {
|
||||
await aiPhoneService.deleteAgent({
|
||||
id: step.inboundAgent.id,
|
||||
userId: ctx.user.id,
|
||||
teamId: workflowToDelete.teamId ?? undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
log.error(`Failed to delete inbound agent ${step.inboundAgent.id}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -440,6 +440,22 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
|
||||
log.error(message);
|
||||
}
|
||||
|
||||
if (oldStep.inboundAgentId) {
|
||||
try {
|
||||
await aiPhoneService.deleteAgent({
|
||||
id: oldStep.inboundAgentId,
|
||||
userId: user.id,
|
||||
teamId: userWorkflow.teamId ?? undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = `Failed to delete inbound agent ${
|
||||
oldStep.inboundAgentId
|
||||
} from external service: ${error instanceof Error ? error.message : "Unknown error"}`;
|
||||
externalErrors.push(message);
|
||||
log.error(message);
|
||||
}
|
||||
}
|
||||
|
||||
// If there were external errors, we should log them for manual cleanup
|
||||
// but the operation is considered successful since DB is consistent
|
||||
if (externalErrors.length > 0) {
|
||||
@@ -459,6 +475,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
|
||||
...newStep,
|
||||
verifiedAt: oldStep.verifiedAt,
|
||||
agentId: newStep.agentId || null,
|
||||
inboundAgentId: newStep.inboundAgentId || null,
|
||||
})
|
||||
) {
|
||||
// check if step that require team plan already existed before
|
||||
|
||||
@@ -26,6 +26,7 @@ export const ZUpdateInputSchema = z.object({
|
||||
senderName: z.string().nullable(),
|
||||
includeCalendarEvent: z.boolean(),
|
||||
agentId: z.string().nullish(),
|
||||
inboundAgentId: z.string().nullish(),
|
||||
})
|
||||
.array(),
|
||||
trigger: z.enum(WORKFLOW_TRIGGER_EVENTS),
|
||||
|
||||
@@ -101,6 +101,7 @@ export const lucideIconList = new Set([
|
||||
"pause",
|
||||
"pencil",
|
||||
"phone-call",
|
||||
"phone-incoming",
|
||||
"phone-outgoing",
|
||||
"phone",
|
||||
"play",
|
||||
|
||||
@@ -108,6 +108,7 @@ export type IconName =
|
||||
| "pause"
|
||||
| "pencil"
|
||||
| "phone-call"
|
||||
| "phone-incoming"
|
||||
| "phone-off"
|
||||
| "phone-outgoing"
|
||||
| "phone"
|
||||
|
||||
Reference in New Issue
Block a user