From 02c86d0268668903d5174376e8c31e6f8de2eb44 Mon Sep 17 00:00:00 2001 From: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> Date: Fri, 8 Aug 2025 21:27:13 +0530 Subject: [PATCH] feat: cal ai self serve architecture #1 (#22919) * feat: cal ai self serve architecture * chore: add package * chore: update evnet controller * refactor: improvements * chore: rename * chore: type error and naming * chore: just set it to nul * chore: just set it to nul * chore: some more improvements * chore: packate version * fix: API v2 * chore: change name of files * chore: add select * chore: add missing teamId * chore: save progress * refactor: split into multiple services * refactor: make schema provider agonistic * chore: improvements * chore: * chore: remove duplicate files * chore: semicolon * chore: formatting * refactor: logging and error handling * chore: rename variable * refactor: use trpc error * chore: replace with HttpError * chore: remove from option * We need the enum and not just the type --------- Co-authored-by: Hariom Balhara --- .../workflows/inputs/workflow-step.input.ts | 4 + apps/web/package.json | 1 + .../api/get-inbound-dynamic-variables.ts | 4 +- .../app-store/stripepayment/lib/constants.ts | 1 + packages/app-store/stripepayment/lib/utils.ts | 6 + .../calAIPhone/AIPhoneServiceRegistry.test.ts | 457 ++++++++ .../calAIPhone/AIPhoneServiceRegistry.ts | 189 +++ packages/features/calAIPhone/README.md | 359 ++++++ .../getTemplateFieldsSchema.ts | 0 packages/features/calAIPhone/index.ts | 101 ++ .../features/calAIPhone/initializeRegistry.ts | 44 + .../interfaces/AIPhoneService.interface.ts | 343 ++++++ .../promptTemplates.ts | 0 .../adapters/PrismaAgentRepositoryAdapter.ts | 77 ++ .../PrismaPhoneNumberRepositoryAdapter.ts | 82 ++ .../adapters/PrismaTransactionAdapter.ts | 37 + .../interfaces/AgentRepositoryInterface.ts | 118 ++ .../PhoneNumberRepositoryInterface.ts | 91 ++ .../interfaces/TransactionInterface.ts | 38 + .../RetellAIPhoneServiceProvider.test.ts | 669 +++++++++++ .../retellAI/RetellAIPhoneServiceProvider.ts | 266 +++++ ...etellAIPhoneServiceProviderFactory.test.ts | 166 +++ .../RetellAIPhoneServiceProviderFactory.ts | 58 + .../retellAI/RetellAIService.test.ts | 1027 +++++++++++++++++ .../providers/retellAI/RetellAIService.ts | 209 ++++ .../retellAI/RetellAIServiceMapper.ts | 209 ++++ .../retellAI/RetellSDKClient.test.ts | 428 +++++++ .../providers/retellAI/RetellSDKClient.ts | 241 ++++ .../calAIPhone/providers/retellAI/errors.ts | 6 + .../calAIPhone/providers/retellAI/index.ts | 137 +++ .../services/AIConfigurationService.ts | 179 +++ .../retellAI/services/AgentService.ts | 336 ++++++ .../retellAI/services/BillingService.ts | 157 +++ .../retellAI/services/CallService.ts | 161 +++ .../retellAI/services/PhoneNumberService.ts | 397 +++++++ .../providers/retellAI/services/README.md | 143 +++ .../__tests__/AIConfigurationService.test.ts | 103 ++ .../services/__tests__/BillingService.test.ts | 258 +++++ .../services/__tests__/CallService.test.ts | 269 +++++ .../__tests__/PhoneNumberService.test.ts | 316 +++++ .../retellAI/services/__tests__/test-utils.ts | 206 ++++ .../calAIPhone/providers/retellAI/types.ts | 165 +++ .../retellAIService.ts | 0 .../template-fields-map.ts | 2 +- .../cal-ai-phone => calAIPhone}/zod-utils.ts | 26 + .../components/WorkflowDetailsPage.tsx | 1 + .../features/ee/workflows/lib/constants.ts | 1 + .../features/ee/workflows/lib/getOptions.ts | 4 +- .../components/tabs/ai/AIEventController.tsx | 8 +- packages/features/handleCreatePhoneCall.ts | 8 +- .../repository/PrismaAgentRepository.ts | 536 +++++++++ .../repository/PrismaPhoneNumberRepository.ts | 482 ++++++++ .../event-types/hooks/useEventTypeForm.ts | 4 +- .../migration.sql | 112 ++ packages/prisma/schema.prisma | 78 ++ .../server/routers/viewer/eventTypes/types.ts | 2 +- .../routers/viewer/organizations/_router.tsx | 2 +- .../organizations/createPhoneCall.handler.ts | 2 +- .../viewer/workflows/update.handler.ts | 3 +- yarn.lock | 42 +- 60 files changed, 9351 insertions(+), 20 deletions(-) create mode 100644 packages/features/calAIPhone/AIPhoneServiceRegistry.test.ts create mode 100644 packages/features/calAIPhone/AIPhoneServiceRegistry.ts create mode 100644 packages/features/calAIPhone/README.md rename packages/features/{ee/cal-ai-phone => calAIPhone}/getTemplateFieldsSchema.ts (100%) create mode 100644 packages/features/calAIPhone/index.ts create mode 100644 packages/features/calAIPhone/initializeRegistry.ts create mode 100644 packages/features/calAIPhone/interfaces/AIPhoneService.interface.ts rename packages/features/{ee/cal-ai-phone => calAIPhone}/promptTemplates.ts (100%) create mode 100644 packages/features/calAIPhone/providers/adapters/PrismaAgentRepositoryAdapter.ts create mode 100644 packages/features/calAIPhone/providers/adapters/PrismaPhoneNumberRepositoryAdapter.ts create mode 100644 packages/features/calAIPhone/providers/adapters/PrismaTransactionAdapter.ts create mode 100644 packages/features/calAIPhone/providers/interfaces/AgentRepositoryInterface.ts create mode 100644 packages/features/calAIPhone/providers/interfaces/PhoneNumberRepositoryInterface.ts create mode 100644 packages/features/calAIPhone/providers/interfaces/TransactionInterface.ts create mode 100644 packages/features/calAIPhone/providers/retellAI/RetellAIPhoneServiceProvider.test.ts create mode 100644 packages/features/calAIPhone/providers/retellAI/RetellAIPhoneServiceProvider.ts create mode 100644 packages/features/calAIPhone/providers/retellAI/RetellAIPhoneServiceProviderFactory.test.ts create mode 100644 packages/features/calAIPhone/providers/retellAI/RetellAIPhoneServiceProviderFactory.ts create mode 100644 packages/features/calAIPhone/providers/retellAI/RetellAIService.test.ts create mode 100644 packages/features/calAIPhone/providers/retellAI/RetellAIService.ts create mode 100644 packages/features/calAIPhone/providers/retellAI/RetellAIServiceMapper.ts create mode 100644 packages/features/calAIPhone/providers/retellAI/RetellSDKClient.test.ts create mode 100644 packages/features/calAIPhone/providers/retellAI/RetellSDKClient.ts create mode 100644 packages/features/calAIPhone/providers/retellAI/errors.ts create mode 100644 packages/features/calAIPhone/providers/retellAI/index.ts create mode 100644 packages/features/calAIPhone/providers/retellAI/services/AIConfigurationService.ts create mode 100644 packages/features/calAIPhone/providers/retellAI/services/AgentService.ts create mode 100644 packages/features/calAIPhone/providers/retellAI/services/BillingService.ts create mode 100644 packages/features/calAIPhone/providers/retellAI/services/CallService.ts create mode 100644 packages/features/calAIPhone/providers/retellAI/services/PhoneNumberService.ts create mode 100644 packages/features/calAIPhone/providers/retellAI/services/README.md create mode 100644 packages/features/calAIPhone/providers/retellAI/services/__tests__/AIConfigurationService.test.ts create mode 100644 packages/features/calAIPhone/providers/retellAI/services/__tests__/BillingService.test.ts create mode 100644 packages/features/calAIPhone/providers/retellAI/services/__tests__/CallService.test.ts create mode 100644 packages/features/calAIPhone/providers/retellAI/services/__tests__/PhoneNumberService.test.ts create mode 100644 packages/features/calAIPhone/providers/retellAI/services/__tests__/test-utils.ts create mode 100644 packages/features/calAIPhone/providers/retellAI/types.ts rename packages/features/{ee/cal-ai-phone => calAIPhone}/retellAIService.ts (100%) rename packages/features/{ee/cal-ai-phone => calAIPhone}/template-fields-map.ts (95%) rename packages/features/{ee/cal-ai-phone => calAIPhone}/zod-utils.ts (80%) create mode 100644 packages/lib/server/repository/PrismaAgentRepository.ts create mode 100644 packages/lib/server/repository/PrismaPhoneNumberRepository.ts create mode 100644 packages/prisma/migrations/20250807134931_add_cal_ai_self_serve/migration.sql diff --git a/apps/api/v2/src/modules/workflows/inputs/workflow-step.input.ts b/apps/api/v2/src/modules/workflows/inputs/workflow-step.input.ts index 9630700bb3..49988697cf 100644 --- a/apps/api/v2/src/modules/workflows/inputs/workflow-step.input.ts +++ b/apps/api/v2/src/modules/workflows/inputs/workflow-step.input.ts @@ -10,6 +10,7 @@ export const SMS_ATTENDEE = "sms_attendee"; export const SMS_NUMBER = "sms_number"; export const WHATSAPP_ATTENDEE = "whatsapp_attendee"; export const WHATSAPP_NUMBER = "whatsapp_number"; +export const CAL_AI_PHONE_CALL = "cal_ai_phone_call"; export const STEP_ACTIONS = [ EMAIL_HOST, @@ -19,6 +20,7 @@ export const STEP_ACTIONS = [ SMS_NUMBER, WHATSAPP_ATTENDEE, WHATSAPP_NUMBER, + CAL_AI_PHONE_CALL, ] as const; export const STEP_ACTIONS_TO_ENUM = { @@ -29,6 +31,7 @@ export const STEP_ACTIONS_TO_ENUM = { [WHATSAPP_ATTENDEE]: WorkflowActions.WHATSAPP_ATTENDEE, [WHATSAPP_NUMBER]: WorkflowActions.WHATSAPP_NUMBER, [SMS_NUMBER]: WorkflowActions.SMS_NUMBER, + [CAL_AI_PHONE_CALL]: WorkflowActions.CAL_AI_PHONE_CALL, } as const; export const ENUM_TO_STEP_ACTIONS = { @@ -39,6 +42,7 @@ export const ENUM_TO_STEP_ACTIONS = { [WorkflowActions.WHATSAPP_ATTENDEE]: WHATSAPP_ATTENDEE, [WorkflowActions.WHATSAPP_NUMBER]: WHATSAPP_NUMBER, [WorkflowActions.SMS_NUMBER]: SMS_NUMBER, + [WorkflowActions.CAL_AI_PHONE_CALL]: CAL_AI_PHONE_CALL, } as const; export type StepAction = (typeof STEP_ACTIONS)[number]; diff --git a/apps/web/package.json b/apps/web/package.json index 99986d67e4..26153ae777 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -138,6 +138,7 @@ "react-use-intercom": "1.5.1", "recoil": "^0.7.7", "remove-markdown": "^0.5.0", + "retell-sdk": "^4.40.0", "rrule": "^2.7.1", "sanitize-html": "^2.10.0", "schema-dts": "^1.1.0", diff --git a/apps/web/pages/api/get-inbound-dynamic-variables.ts b/apps/web/pages/api/get-inbound-dynamic-variables.ts index b9b3a9bfec..088a13a099 100644 --- a/apps/web/pages/api/get-inbound-dynamic-variables.ts +++ b/apps/web/pages/api/get-inbound-dynamic-variables.ts @@ -3,8 +3,8 @@ import type { NextApiRequest, NextApiResponse } from "next"; import { z } from "zod"; import dayjs from "@calcom/dayjs"; -import { ZGetRetellLLMSchema } from "@calcom/features/ee/cal-ai-phone/zod-utils"; -import type { TGetRetellLLMSchema } from "@calcom/features/ee/cal-ai-phone/zod-utils"; +import { ZGetRetellLLMSchema } from "@calcom/features/calAIPhone/zod-utils"; +import type { TGetRetellLLMSchema } from "@calcom/features/calAIPhone/zod-utils"; import { getAvailableSlotsService } from "@calcom/lib/di/containers/available-slots"; import { fetcher } from "@calcom/lib/retellAIFetcher"; import { defaultHandler } from "@calcom/lib/server/defaultHandler"; diff --git a/packages/app-store/stripepayment/lib/constants.ts b/packages/app-store/stripepayment/lib/constants.ts index 5499427359..1f92b1b4e9 100644 --- a/packages/app-store/stripepayment/lib/constants.ts +++ b/packages/app-store/stripepayment/lib/constants.ts @@ -1,6 +1,7 @@ export const PREMIUM_MONTHLY_PLAN_PRICE = process.env.NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE_MONTHLY || ""; export const PREMIUM_PLAN_PRODUCT_ID = process.env.NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRODUCT_ID || ""; export const STRIPE_TEAM_MONTHLY_PRICE_ID = process.env.NEXT_PUBLIC_STRIPE_TEAM_MONTHLY_PRICE_ID || ""; +export const STRIPE_PHONE_NUMBER_MONTHLY_PRICE_ID = process.env.STRIPE_PHONE_NUMBER_MONTHLY_PRICE_ID || ""; export const paymentOptions = [ { diff --git a/packages/app-store/stripepayment/lib/utils.ts b/packages/app-store/stripepayment/lib/utils.ts index 3fc19527ce..df29b5d1cc 100644 --- a/packages/app-store/stripepayment/lib/utils.ts +++ b/packages/app-store/stripepayment/lib/utils.ts @@ -3,6 +3,7 @@ import { PREMIUM_PLAN_PRODUCT_ID, STRIPE_TEAM_MONTHLY_PRICE_ID, PREMIUM_MONTHLY_PLAN_PRICE, + STRIPE_PHONE_NUMBER_MONTHLY_PRICE_ID } from "./constants"; export const getPremiumMonthlyPlanPriceId = (): string => { @@ -20,3 +21,8 @@ export function getPerSeatPlanPrice(): string { export function getPremiumPlanPriceValue() { return "$29/month"; } + + +export function getPhoneNumberMonthlyPriceId(){ + return STRIPE_PHONE_NUMBER_MONTHLY_PRICE_ID; +} diff --git a/packages/features/calAIPhone/AIPhoneServiceRegistry.test.ts b/packages/features/calAIPhone/AIPhoneServiceRegistry.test.ts new file mode 100644 index 0000000000..19af9695e0 --- /dev/null +++ b/packages/features/calAIPhone/AIPhoneServiceRegistry.test.ts @@ -0,0 +1,457 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +import { + AIPhoneServiceRegistry, + createAIPhoneServiceProvider, + createDefaultAIPhoneServiceProvider, +} from "./AIPhoneServiceRegistry"; +import type { + AIPhoneServiceProvider, + AIPhoneServiceProviderFactory, + AIPhoneServiceProviderConfig, +} from "./interfaces/AIPhoneService.interface"; +import { AIPhoneServiceProviderType } from "./interfaces/AIPhoneService.interface"; + +// Mock environment variables +vi.stubEnv("RETELL_AI_KEY", "test-api-key"); + +vi.mock("./providers/retellAI", () => ({ + RetellAIPhoneServiceProviderFactory: vi.fn().mockImplementation(() => ({ + create: vi.fn().mockReturnValue({ + setupConfiguration: vi.fn(), + createPhoneCall: vi.fn(), + createPhoneNumber: vi.fn(), + }), + })), +})); + +describe("AIPhoneServiceRegistry", () => { + let mockFactory: AIPhoneServiceProviderFactory; + let mockProvider: AIPhoneServiceProvider; + + beforeEach(() => { + // Clear all factories before each test + AIPhoneServiceRegistry.clearProviders(); + + // Create mock factory and provider + mockProvider = { + setupConfiguration: vi.fn().mockResolvedValue({ llmId: "test-llm", agentId: "test-agent" }), + deleteConfiguration: vi + .fn() + .mockResolvedValue({ success: true, errors: [], deleted: { llm: true, agent: true } }), + updateModelConfiguration: vi.fn().mockResolvedValue({ llm_id: "test-llm" }), + getModelDetails: vi.fn().mockResolvedValue({ llm_id: "test-llm" }), + getAgent: vi.fn().mockResolvedValue({ agent_id: "test-agent" }), + updateAgent: vi.fn().mockResolvedValue({ agent_id: "test-agent" }), + createPhoneCall: vi.fn().mockResolvedValue({ call_id: "test-call" }), + createPhoneNumber: vi.fn().mockResolvedValue({ phone_number: "+1234567890" }), + deletePhoneNumber: vi.fn().mockResolvedValue(undefined), + getPhoneNumber: vi.fn().mockResolvedValue({ phone_number: "+1234567890" }), + updatePhoneNumber: vi.fn().mockResolvedValue({ phone_number: "+1234567890" }), + importPhoneNumber: vi.fn().mockResolvedValue({ phone_number: "+1234567890" }), + generatePhoneNumberCheckoutSession: vi + .fn() + .mockResolvedValue({ url: "test-url", message: "test-message" }), + cancelPhoneNumberSubscription: vi.fn().mockResolvedValue({ success: true, message: "test-message" }), + updatePhoneNumberWithAgents: vi.fn().mockResolvedValue({ message: "test-message" }), + listAgents: vi.fn().mockResolvedValue({ totalCount: 1, filtered: [] }), + getAgentWithDetails: vi.fn().mockResolvedValue({ agent_id: "test-agent" }), + createAgent: vi + .fn() + .mockResolvedValue({ id: "test-id", providerAgentId: "test-provider-id", message: "test-message" }), + updateAgentConfiguration: vi.fn().mockResolvedValue({ message: "test-message" }), + deleteAgent: vi.fn().mockResolvedValue({ message: "test-message" }), + createTestCall: vi + .fn() + .mockResolvedValue({ callId: "test-call-id", status: "test-status", message: "test-message" }), + }; + + mockFactory = { + create: vi.fn().mockReturnValue(mockProvider), + }; + }); + + describe("initialize", () => { + it("should initialize with provided configuration", () => { + AIPhoneServiceRegistry.initialize({ + defaultProvider: "test-provider", + providers: [ + { type: "test-provider", factory: mockFactory }, + { type: "another-provider", factory: mockFactory }, + ], + }); + + expect(AIPhoneServiceRegistry.isInitialized()).toBe(true); + expect(AIPhoneServiceRegistry.getDefaultProvider()).toBe("test-provider"); + expect(AIPhoneServiceRegistry.getAvailableProviders()).toContain("test-provider"); + expect(AIPhoneServiceRegistry.getAvailableProviders()).toContain("another-provider"); + }); + + it("should set first provider as default if defaultProvider not specified", () => { + AIPhoneServiceRegistry.initialize({ + providers: [ + { type: "first-provider", factory: mockFactory }, + { type: "second-provider", factory: mockFactory }, + ], + }); + + expect(AIPhoneServiceRegistry.getDefaultProvider()).toBe("first-provider"); + }); + + it("should allow initialization without providers", () => { + AIPhoneServiceRegistry.initialize(); + + expect(AIPhoneServiceRegistry.isInitialized()).toBe(true); + expect(AIPhoneServiceRegistry.getDefaultProvider()).toBeNull(); + }); + }); + + describe("registerProvider", () => { + it("should register a provider factory", () => { + AIPhoneServiceRegistry.registerProvider("test-provider", mockFactory); + + expect(AIPhoneServiceRegistry.isProviderRegistered("test-provider")).toBe(true); + expect(AIPhoneServiceRegistry.getAvailableProviders()).toContain("test-provider"); + }); + + it("should set first registered provider as default", () => { + AIPhoneServiceRegistry.registerProvider("test-provider", mockFactory); + + expect(AIPhoneServiceRegistry.getDefaultProvider()).toBe("test-provider"); + }); + + it("should allow overriding existing provider", () => { + const newMockFactory: AIPhoneServiceProviderFactory = { + create: vi.fn().mockReturnValue(mockProvider), + }; + + AIPhoneServiceRegistry.registerProvider("test-provider", mockFactory); + AIPhoneServiceRegistry.registerProvider("test-provider", newMockFactory); + + const retrievedFactory = AIPhoneServiceRegistry.getProviderFactory("test-provider"); + expect(retrievedFactory).toBe(newMockFactory); + }); + }); + + describe("getProviderFactory", () => { + it("should return registered factory", () => { + AIPhoneServiceRegistry.registerProvider("test-provider", mockFactory); + + const retrievedFactory = AIPhoneServiceRegistry.getProviderFactory("test-provider"); + expect(retrievedFactory).toBe(mockFactory); + }); + + it("should return undefined for unregistered provider", () => { + const retrievedFactory = AIPhoneServiceRegistry.getProviderFactory("nonexistent"); + expect(retrievedFactory).toBeUndefined(); + }); + }); + + describe("createProvider", () => { + it("should create provider instance using registered factory", () => { + const config: AIPhoneServiceProviderConfig = { apiKey: "test-key" }; + AIPhoneServiceRegistry.registerProvider("test-provider", mockFactory); + + const provider = AIPhoneServiceRegistry.createProvider("test-provider", config); + + expect(mockFactory.create).toHaveBeenCalledWith(config); + expect(provider).toBe(mockProvider); + }); + + it("should throw error for unregistered provider", () => { + const config: AIPhoneServiceProviderConfig = { apiKey: "test-key" }; + + expect(() => { + AIPhoneServiceRegistry.createProvider("nonexistent", config); + }).toThrow("AI phone service provider 'nonexistent' not found. Available providers: "); + }); + + it("should include available providers in error message when factory not found", () => { + AIPhoneServiceRegistry.registerProvider("provider1", mockFactory); + AIPhoneServiceRegistry.registerProvider("provider2", mockFactory); + + expect(() => { + AIPhoneServiceRegistry.createProvider("nonexistent", {}); + }).toThrow("Available providers: provider1, provider2"); + }); + }); + + describe("createDefaultProvider", () => { + it("should create provider using default provider type", () => { + const config: AIPhoneServiceProviderConfig = { apiKey: "test-key" }; + AIPhoneServiceRegistry.initialize({ + defaultProvider: "test-provider", + providers: [{ type: "test-provider", factory: mockFactory }], + }); + + const provider = AIPhoneServiceRegistry.createDefaultProvider(config); + + expect(mockFactory.create).toHaveBeenCalledWith(config); + expect(provider).toBe(mockProvider); + }); + + it("should throw error when no default provider is set", () => { + const config: AIPhoneServiceProviderConfig = { apiKey: "test-key" }; + + expect(() => { + AIPhoneServiceRegistry.createDefaultProvider(config); + }).toThrow("No default provider set"); + }); + }); + + describe("setDefaultProvider", () => { + it("should set default provider when provider is registered", () => { + AIPhoneServiceRegistry.registerProvider("new-default", mockFactory); + + AIPhoneServiceRegistry.setDefaultProvider("new-default"); + + expect(AIPhoneServiceRegistry.getDefaultProvider()).toBe("new-default"); + }); + + it("should throw error when setting unregistered provider as default", () => { + expect(() => { + AIPhoneServiceRegistry.setDefaultProvider("nonexistent"); + }).toThrow("Cannot set default provider to 'nonexistent' - provider not registered"); + }); + }); + + describe("getDefaultProvider", () => { + it("should return current default provider", () => { + AIPhoneServiceRegistry.initialize({ + defaultProvider: "test-provider", + providers: [{ type: "test-provider", factory: mockFactory }], + }); + + expect(AIPhoneServiceRegistry.getDefaultProvider()).toBe("test-provider"); + }); + + it("should return null when no default provider is set", () => { + expect(AIPhoneServiceRegistry.getDefaultProvider()).toBeNull(); + }); + }); + + describe("getAvailableProviders", () => { + it("should return empty array when no providers registered", () => { + expect(AIPhoneServiceRegistry.getAvailableProviders()).toEqual([]); + }); + + it("should return array of registered provider names", () => { + AIPhoneServiceRegistry.registerProvider("provider1", mockFactory); + AIPhoneServiceRegistry.registerProvider("provider2", mockFactory); + + const providers = AIPhoneServiceRegistry.getAvailableProviders(); + expect(providers).toContain("provider1"); + expect(providers).toContain("provider2"); + expect(providers).toHaveLength(2); + }); + }); + + describe("isProviderRegistered", () => { + it("should return true for registered provider", () => { + AIPhoneServiceRegistry.registerProvider("test-provider", mockFactory); + + expect(AIPhoneServiceRegistry.isProviderRegistered("test-provider")).toBe(true); + }); + + it("should return false for unregistered provider", () => { + expect(AIPhoneServiceRegistry.isProviderRegistered("nonexistent")).toBe(false); + }); + }); +}); + +describe("createAIPhoneServiceProvider", () => { + let mockFactory: AIPhoneServiceProviderFactory; + let mockProvider: AIPhoneServiceProvider; + + beforeEach(() => { + // Clear all factories before each test + AIPhoneServiceRegistry.clearProviders(); + + mockProvider = { + setupConfiguration: vi.fn(), + deleteConfiguration: vi.fn(), + updateModelConfiguration: vi.fn(), + getModelDetails: vi.fn(), + getAgent: vi.fn(), + updateAgent: vi.fn(), + createPhoneCall: vi.fn(), + createPhoneNumber: vi.fn(), + deletePhoneNumber: vi.fn(), + getPhoneNumber: vi.fn(), + updatePhoneNumber: vi.fn(), + importPhoneNumber: vi.fn(), + generatePhoneNumberCheckoutSession: vi.fn(), + cancelPhoneNumberSubscription: vi.fn(), + updatePhoneNumberWithAgents: vi.fn(), + listAgents: vi.fn(), + getAgentWithDetails: vi.fn(), + createAgent: vi.fn(), + updateAgentConfiguration: vi.fn(), + deleteAgent: vi.fn(), + createTestCall: vi.fn(), + }; + + mockFactory = { + create: vi.fn().mockReturnValue(mockProvider), + }; + }); + + it("should throw error when registry is not initialized", () => { + // Registry starts uninitialized + expect(AIPhoneServiceRegistry.isInitialized()).toBe(false); + + // Should throw error for uninitialized registry + expect(() => { + createAIPhoneServiceProvider({ + config: { apiKey: "test-key" }, + }); + }).toThrow("AIPhoneServiceRegistry not initialized"); + + // Registry should still be uninitialized + expect(AIPhoneServiceRegistry.isInitialized()).toBe(false); + }); + + it("should create provider with specified type and config", () => { + AIPhoneServiceRegistry.initialize({ + providers: [{ type: "custom-provider", factory: mockFactory }], + }); + + // For custom providers, we must provide API key in config since there's no env var mapping + const provider = createAIPhoneServiceProvider({ + providerType: "custom-provider", + config: { apiKey: "custom-key", enableLogging: false }, + }); + + expect(mockFactory.create).toHaveBeenCalledWith({ + apiKey: "custom-key", + enableLogging: false, + }); + expect(provider).toBe(mockProvider); + }); + + it("should use default provider when type not specified", () => { + AIPhoneServiceRegistry.initialize({ + defaultProvider: AIPhoneServiceProviderType.RETELL_AI, // Use RETELL_AI as default since we have its API key + providers: [{ type: AIPhoneServiceProviderType.RETELL_AI, factory: mockFactory }], + }); + + const provider = createAIPhoneServiceProvider(); + + expect(mockFactory.create).toHaveBeenCalledWith({ + apiKey: "test-api-key", + enableLogging: true, // Should be true when NODE_ENV is not production + }); + expect(provider).toBe(mockProvider); + }); + + it("should merge provided config with defaults", () => { + AIPhoneServiceRegistry.initialize({ + providers: [{ type: AIPhoneServiceProviderType.RETELL_AI, factory: mockFactory }], + }); + + createAIPhoneServiceProvider({ + providerType: AIPhoneServiceProviderType.RETELL_AI, + config: { enableLogging: false }, + }); + + expect(mockFactory.create).toHaveBeenCalledWith({ + apiKey: "test-api-key", + enableLogging: false, + }); + }); + + it("should override default apiKey with provided config", () => { + AIPhoneServiceRegistry.initialize({ + providers: [{ type: AIPhoneServiceProviderType.RETELL_AI, factory: mockFactory }], + }); + + createAIPhoneServiceProvider({ + providerType: AIPhoneServiceProviderType.RETELL_AI, + config: { apiKey: "override-key" }, + }); + + expect(mockFactory.create).toHaveBeenCalledWith({ + apiKey: "override-key", + enableLogging: true, + }); + }); + + it("should throw error when no provider type and no default provider", () => { + AIPhoneServiceRegistry.initialize(); // Initialize without providers + + expect(() => { + createAIPhoneServiceProvider(); + }).toThrow("No provider type specified and no default provider configured"); + }); +}); + +describe("createDefaultAIPhoneServiceProvider", () => { + let mockFactory: AIPhoneServiceProviderFactory; + let mockProvider: AIPhoneServiceProvider; + + beforeEach(() => { + AIPhoneServiceRegistry.clearProviders(); + + mockProvider = { + setupConfiguration: vi.fn(), + deleteConfiguration: vi.fn(), + updateModelConfiguration: vi.fn(), + getModelDetails: vi.fn(), + getAgent: vi.fn(), + updateAgent: vi.fn(), + createPhoneCall: vi.fn(), + createPhoneNumber: vi.fn(), + deletePhoneNumber: vi.fn(), + getPhoneNumber: vi.fn(), + updatePhoneNumber: vi.fn(), + importPhoneNumber: vi.fn(), + generatePhoneNumberCheckoutSession: vi.fn(), + cancelPhoneNumberSubscription: vi.fn(), + updatePhoneNumberWithAgents: vi.fn(), + listAgents: vi.fn(), + getAgentWithDetails: vi.fn(), + createAgent: vi.fn(), + updateAgentConfiguration: vi.fn(), + deleteAgent: vi.fn(), + createTestCall: vi.fn(), + }; + + mockFactory = { + create: vi.fn().mockReturnValue(mockProvider), + }; + + AIPhoneServiceRegistry.initialize({ + defaultProvider: AIPhoneServiceProviderType.RETELL_AI, + providers: [{ type: AIPhoneServiceProviderType.RETELL_AI, factory: mockFactory }], + }); + }); + + it("should create provider using default configuration", () => { + const provider = createDefaultAIPhoneServiceProvider(); + + expect(mockFactory.create).toHaveBeenCalledWith({ + apiKey: "test-api-key", + enableLogging: true, + }); + expect(provider).toBe(mockProvider); + }); + + it("should merge provided config with defaults", () => { + const customConfig = { enableLogging: false, apiKey: "custom-key" }; + const provider = createDefaultAIPhoneServiceProvider(customConfig); + + expect(mockFactory.create).toHaveBeenCalledWith({ + apiKey: "custom-key", + enableLogging: false, + }); + expect(provider).toBe(mockProvider); + }); + + it("should use default provider type internally", () => { + // This test ensures createDefaultAIPhoneServiceProvider calls createAIPhoneServiceProvider correctly + const provider = createDefaultAIPhoneServiceProvider(); + + expect(provider).toBe(mockProvider); + expect(mockFactory.create).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/features/calAIPhone/AIPhoneServiceRegistry.ts b/packages/features/calAIPhone/AIPhoneServiceRegistry.ts new file mode 100644 index 0000000000..e7239bd18b --- /dev/null +++ b/packages/features/calAIPhone/AIPhoneServiceRegistry.ts @@ -0,0 +1,189 @@ +import { AIPhoneServiceProviderType } from "./interfaces/AIPhoneService.interface"; +import type { + AIPhoneServiceProvider, + AIPhoneServiceProviderFactory, + AIPhoneServiceProviderConfig, +} from "./interfaces/AIPhoneService.interface"; + +/** + * Configuration for the registry + */ +interface RegistryConfiguration { + defaultProvider?: string; + providers?: Array<{ + type: string; + factory: AIPhoneServiceProviderFactory; + }>; +} + +/** + * Registry for AI phone service providers + * Allows registering and creating different AI service providers + */ +export class AIPhoneServiceRegistry { + private static factories: Map = new Map(); + private static defaultProvider: string | null = null; + private static initialized = false; + + /** + * Initialize the registry with configuration + * This should be called during application startup + */ + static initialize(config?: RegistryConfiguration): void { + // Register providers if provided + if (config?.providers) { + config.providers.forEach(({ type, factory }) => { + this.registerProvider(type, factory); + }); + } + + // Set default provider if provided, otherwise use the first registered provider + if (config?.defaultProvider) { + this.setDefaultProvider(config.defaultProvider); + } else if (this.factories.size > 0) { + this.defaultProvider = Array.from(this.factories.keys())[0]; + } + + this.initialized = true; + } + + static registerProvider(type: string, factory: AIPhoneServiceProviderFactory): void { + this.factories.set(type, factory); + + // If no default provider is set and this is the first provider, make it default + if (!this.defaultProvider) { + this.defaultProvider = type; + } + } + + static getProviderFactory(type: string): AIPhoneServiceProviderFactory | undefined { + return this.factories.get(type); + } + + static createProvider(type: string, config: AIPhoneServiceProviderConfig): AIPhoneServiceProvider { + const factory = this.getProviderFactory(type); + if (!factory) { + throw new Error( + `AI phone service provider '${type}' not found. Available providers: ${Array.from( + this.factories.keys() + ).join(", ")}` + ); + } + return factory.create(config); + } + + /** + * Create a provider instance using the default provider + */ + static createDefaultProvider(config: AIPhoneServiceProviderConfig): AIPhoneServiceProvider { + if (!this.defaultProvider) { + throw new Error( + "No default provider set. Please initialize the registry with a default provider or register at least one provider." + ); + } + return this.createProvider(this.defaultProvider, config); + } + + static setDefaultProvider(type: string): void { + if (!this.factories.has(type)) { + throw new Error(`Cannot set default provider to '${type}' - provider not registered`); + } + this.defaultProvider = type; + } + + static getDefaultProvider(): string | null { + return this.defaultProvider; + } + + static getAvailableProviders(): string[] { + return Array.from(this.factories.keys()); + } + + static isProviderRegistered(type: string): boolean { + return this.factories.has(type); + } + + /** + * Clear all registered providers (mainly for testing purposes) + */ + static clearProviders(): void { + this.factories.clear(); + this.defaultProvider = null; + this.initialized = false; + } + + static isInitialized(): boolean { + return this.initialized; + } +} + +/** + * Provider-specific API key resolution + * This function determines the appropriate API key based on the provider type + */ +function resolveApiKey(providerType: string, config?: Partial): string { + // First check if API key is provided in config + if (config?.apiKey) { + return config.apiKey; + } + + // Then check environment variables based on provider type + const envVarMap: Record = { + [AIPhoneServiceProviderType.RETELL_AI]: process.env.RETELL_AI_KEY, + // Add more providers here as they are implemented + }; + + const apiKey = envVarMap[providerType]; + + if (!apiKey) { + throw new Error( + `API key not configured for provider type: ${providerType}. ` + + `Please set the API key in the config or provide the appropriate environment variable.` + ); + } + + return apiKey; +} + +/** + * Options for creating an AI phone service provider + */ +interface CreateProviderOptions { + /** Provider type to use. If not specified, uses the default provider */ + providerType?: string; + /** Configuration options for the provider */ + config?: Partial; +} + +export function createAIPhoneServiceProvider(options: CreateProviderOptions = {}): AIPhoneServiceProvider { + // Require explicit initialization + if (!AIPhoneServiceRegistry.isInitialized()) { + throw new Error( + "AIPhoneServiceRegistry not initialized. Please call initializeAIPhoneServiceRegistry() or AIPhoneServiceRegistry.initialize() during application startup." + ); + } + + const { providerType, config } = options; + + // Use provided type or fall back to default provider + const type = providerType || AIPhoneServiceRegistry.getDefaultProvider(); + if (!type) { + throw new Error("No provider type specified and no default provider configured"); + } + + const apiKey = resolveApiKey(type, config); + + const providerConfig: AIPhoneServiceProviderConfig = { + apiKey, + enableLogging: process.env.NODE_ENV !== "production", + ...config, + }; + + return AIPhoneServiceRegistry.createProvider(type, providerConfig); +} + +export function createDefaultAIPhoneServiceProvider( + config?: Partial +): AIPhoneServiceProvider { + return createAIPhoneServiceProvider({ config }); +} diff --git a/packages/features/calAIPhone/README.md b/packages/features/calAIPhone/README.md new file mode 100644 index 0000000000..3d12867c81 --- /dev/null +++ b/packages/features/calAIPhone/README.md @@ -0,0 +1,359 @@ +# Cal.com AI Phone Service Architecture + +This package provides a comprehensive, provider-agnostic architecture for AI phone services in Cal.com, offering easy integration with different AI service providers, template management, and self-service UI components. + +## Architecture Overview + +The architecture implements multiple design patterns to create a maintainable, scalable, and flexible system: + +### Layered Architecture + +``` +┌─────────────────────────────────────────────┐ +│ Frontend (React/Next.js) │ +│ - UI Components (Agent/Phone Management) │ +├─────────────────────────────────────────────┤ +│ TRPC API Layer │ +│ - Type-safe RPC endpoints │ +│ - Request/Response validation │ +├─────────────────────────────────────────────┤ +│ Service Layer │ +│ - Business logic orchestration │ +│ - Provider abstraction │ +├─────────────────────────────────────────────┤ +│ Repository Layer │ +│ - Data access abstraction │ +│ - Query optimization │ +├─────────────────────────────────────────────┤ +│ Prisma ORM / Database │ +│ - PostgreSQL with optimized queries │ +└─────────────────────────────────────────────┘ +``` + +### Design Patterns Implemented + +1. **Repository Pattern**: Encapsulates data access logic +2. **Factory Pattern**: Creates provider instances dynamically +3. **Strategy Pattern**: Allows switching between AI providers +4. **Service Pattern**: Orchestrates business logic +5. **Registry Pattern**: Manages provider registration +6. **Template Pattern**: Provides reusable conversation templates +7. **Mapper Pattern**: Transforms data between layers + +## Key Components + +### 1. Generic Interfaces (`interfaces/AIPhoneService.interface.ts`) + +- `AIPhoneServiceProvider` - Main interface that all providers must implement +- `AIPhoneServiceConfiguration` - Configuration for setting up AI services +- `AIPhoneServiceProviderFactory` - Factory interface for creating providers +- Common data types (`AIPhoneServiceModel`, `AIPhoneServiceCall`, `AIPhoneServiceAgent`, etc.) +- Phone number management interfaces +- Agent and LLM management interfaces + +### 2. Provider Registry (`AIPhoneServiceRegistry.ts`) + +- `AIPhoneServiceRegistry` - Central registry for managing providers +- `createAIPhoneServiceProvider()` - Helper function to create providers +- `createDefaultAIPhoneServiceProvider()` - Convenience function for default provider +- Provider registration and management system + +### 3. Template System + +- **Template Types**: `CHECK_IN_APPOINTMENT` and `CUSTOM_TEMPLATE` +- **Prompt Templates** (`promptTemplates.ts`): Pre-defined conversation templates with placeholders +- **Field Mapping** (`template-fields-map.ts`): Dynamic field definitions for different template types +- **Schema Validation** (`zod-utils.ts`): Comprehensive validation schemas for all data types +- **Template Field Schema** (`getTemplateFieldsSchema.ts`): Dynamic schema generation based on template type + +### 4. Provider Implementations (`providers/`) + +- `retellAI/` - Complete Retell AI implementation with: + - Provider class with full interface implementation + - Factory for provider creation + - SDK client for API communication + - Service layer for business logic + - Error handling and type definitions +- `example-future-provider/` - Example showing how to add new providers + +### 5. UI Components (`components/`) + +- `CreateAgentStep.tsx` - Step-by-step agent creation wizard +- `CreateWorkflowAgent.tsx` - Workflow-based agent setup +- `AgentsListPage.tsx` - Agent management and listing +- `SkeletonLoaderList.tsx` - Loading states for better UX + +### 6. Pages (`pages/`) + +- `agent.tsx` - Agent management page +- `index.tsx` - Main AI phone service dashboard + +### 7. Data Layer Components + +- **Repository Classes** (`/packages/lib/server/repository/`) + - `PrismaAgentRepository` - Manages agent data access + - `PrismaPhoneNumberRepository` - Handles phone number operations +- **Service Classes** (`providers/retellAI/RetellAIService.ts`) + - Business logic orchestration + - External API integration +- **Mapper Functions** + - Transform between database models and DTOs + - Handle data serialization/deserialization + +## Architecture Patterns in Detail + +### Repository Pattern + +The repository pattern provides an abstraction layer over data access, making the system database-agnostic and testable. + +**Benefits:** + +- **Separation of Concerns**: Business logic separated from data access +- **Testability**: Easy to mock for unit tests +- **Flexibility**: Can switch between Prisma ORM and raw SQL +- **Reusability**: Common queries centralized in one place + +### Factory Pattern + +The factory pattern enables dynamic creation of provider instances without exposing instantiation logic. + +**Benefits:** + +- **Loose Coupling**: Client code doesn't depend on concrete classes +- **Extensibility**: Easy to add new providers +- **Configuration Management**: Centralized provider setup + +### Service Pattern + +Service layer orchestrates business operations and coordinates between multiple repositories and external services. + +**Benefits:** + +- **Business Logic Encapsulation**: Complex operations in one place +- **Transaction Management**: Coordinates multiple operations +- **Reusability**: Common workflows available to all consumers + +### Registry Pattern + +The registry pattern manages provider registration and lookup, acting as a service locator. + +**Benefits:** + +- **Dynamic Provider Management**: Add/remove providers at runtime +- **Centralized Configuration**: Single point for provider management +- **Dependency Injection**: Supports IoC principles + +### Mapper Pattern + +Mappers transform data between different representations (database models, DTOs, API responses). + +**Benefits:** + +- **Data Transformation**: Clean separation between layers +- **Flexibility**: Different representations for different contexts +- **Maintainability**: Changes in one layer don't affect others + +### Strategy Pattern + +The strategy pattern allows switching between different AI providers seamlessly. + +**Benefits:** + +- **Provider Independence**: Switch providers without changing client code +- **Extensibility**: Add new providers easily +- **Testing**: Mock providers for testing + +## Integration with Cal.com Workflows + +The AI Phone system is designed specifically for Cal.com's workflow automation, enabling AI-powered phone calls as part of scheduling workflows. + +### Workflow Integration Architecture + +``` +┌─────────────────────────────────────────────┐ +│ Cal.com Workflow Engine │ +├─────────────────────────────────────────────┤ +│ Workflow Step: AI Phone Call │ +├─────────────────────────────────────────────┤ +│ AI Phone Service (via Registry) │ +├─────────────────────────────────────────────┤ +│ Agent + Phone Number Setup │ +└─────────────────────────────────────────────┘ +``` + +### Workflow Step Configuration + +AI Phone calls are configured as workflow steps with specific triggers. + +### Supported Workflow Triggers + +AI Phone calls can be triggered by various workflow events: + +- **BEFORE_EVENT**: Call attendees before appointments +- **AFTER_EVENT**: Follow up after meetings +- **NEW_EVENT**: Welcome calls for new bookings +- **RESCHEDULE_EVENT**: Notify about schedule changes +- **CANCELLED_EVENT**: Handle cancellation calls + +### Workflow Execution Flow + +``` +1. Workflow Trigger (e.g., 24 hours before event) + ↓ +2. Workflow Engine schedules AI Phone Call task + ↓ +3. Task Runner (executeAIPhoneCall) executes + ↓ +4. AI Phone Service creates call with dynamic data + ↓ +5. Call connects and AI handles conversation + ↓ +6. Call results logged back to workflow +``` + +### Rate Limiting for Workflows + +To prevent abuse, workflow AI calls are rate-limited. + +## Data Flow Architecture + +### Workflow-Specific Data Flow + +``` +1. Workflow Trigger Event + ↓ +2. Workflow Step Configuration Retrieved + ↓ +3. Booking & Attendee Data Fetched + ↓ +4. Dynamic Variables Prepared + ↓ +5. AI Agent & Phone Number Selected + ↓ +6. Phone Call Initiated + ↓ +7. Results Logged to Workflow +``` + +## Usage Examples + +For complete usage examples, please refer to: + +- **Basic Usage**: See commented examples in [`index.ts`](./index.ts) +- **Provider Setup**: Check provider factories in [`providers/`](./providers/) +- **Workflow Integration**: Review [`packages/features/handleCreatePhoneCall.ts`](../handleCreatePhoneCall.ts) + +### Field Types + +Supported field types include: + +- `text`, `textarea`, `number`, `email`, `phone` +- `address`, `multiemail`, `select`, `multiselect` +- `checkbox`, `radio`, `radioInput`, `boolean` + +### Template Variables + +Templates support dynamic variables: + +- `{{scheduler_name}}` - Name of the scheduler +- `{{name}}` - Guest name +- `{{email}}` - Guest email +- `{{company}}` - Guest company +- `{{current_time}}` - Current timestamp +- And many more + +## Adding New Providers + +For detailed instructions on adding new providers, see the [example provider implementation](./providers/example-future-provider/). + +## Environment Variables + +- `RETELL_AI_KEY` - API key for Retell AI (required when using Retell AI provider) +- `STRIPE_PHONE_NUMBER_MONTHLY_PRICE_ID` - Stripe price ID for monthly phone number subscriptions (required for billing) +- `NODE_ENV` - Controls logging behavior (logging disabled in production) + +### Stripe Integration + +The AI Phone system integrates with Stripe for phone number billing. When users purchase phone numbers through the workflow system, they're redirected to Stripe checkout. + +The `STRIPE_PHONE_NUMBER_MONTHLY_PRICE_ID` should be set to your Stripe price ID that represents the monthly subscription cost for AI phone numbers. This price ID is created in your Stripe dashboard and determines how much customers are charged monthly for each phone number. + +## Credit System and Billing + +### Webhook-Based Credit Deduction + +The AI Phone system automatically deducts credits from user or team accounts when calls are completed. This is handled by the webhook at `apps/web/app/api/webhooks/retellAI/route.ts`. + +#### Credit Deduction Flow + +``` +1. AI Phone Call Completes + ↓ +2. Provider sends "call_analyzed" webhook + ↓ +3. Webhook verifies signature for security + ↓ +4. System looks up phone number owner (user/team) + ↓ +5. Calculates credits based on usage factors + ↓ +6. Verifies sufficient credits available + ↓ +7. Deducts credits from account + ↓ +8. Logs transaction for audit +``` + +#### Credit Calculation + +Credits are deducted based on multiple factors including: + +- **Call Duration**: Longer calls consume more credits +- **Prompt Complexity**: Advanced prompts and conversation templates affect cost +- **Voice Configuration**: Different voice models and settings impact pricing +- **Features Used**: Additional AI capabilities and tools increase credit usage + +The system calculates the total cost and converts it to credits for deduction from the user or team account. + +#### Webhook Setup + +The webhook requires proper configuration: + +1. **Webhook URL**: Add the webhook endpoint to your AI provider dashboard +2. **Environment Variables**: Set appropriate API keys for webhook verification +3. **Security**: Webhook signatures are verified to ensure authenticity + +#### Credit Service Integration + +The webhook integrates with Cal.com's credit system to check and deduct credits appropriately. + +#### Error Handling + +The webhook includes comprehensive error handling: + +- **Invalid Signatures**: Returns 401 Unauthorized +- **Missing Phone Number**: Logs error, cannot deduct credits +- **Insufficient Credits**: Logs warning but doesn't block (call already completed) +- **Missing Cost Data**: Logs error and skips credit deduction + +#### Supported Events + +The webhook processes call completion events that contain the final usage data and cost information. Other events are acknowledged but not processed for billing purposes. + +## Provider-Specific Documentation + +- [Retell AI Provider](./providers/retellAI/README.md) - Complete Retell AI integration +- [Example Provider](./providers/example-future-provider/README.md) - Template for new providers + +## Future Enhancements + +- Multi-language template support +- Advanced workflow builder UI +- Provider health checks and automatic failover +- Real-time call monitoring and analytics +- Integration with Cal.com's webhook system +- Custom field type plugins +- Template marketplace and sharing +- Advanced agent training and optimization tools +- Integration with more AI service providers +- Voice cloning and customization options diff --git a/packages/features/ee/cal-ai-phone/getTemplateFieldsSchema.ts b/packages/features/calAIPhone/getTemplateFieldsSchema.ts similarity index 100% rename from packages/features/ee/cal-ai-phone/getTemplateFieldsSchema.ts rename to packages/features/calAIPhone/getTemplateFieldsSchema.ts diff --git a/packages/features/calAIPhone/index.ts b/packages/features/calAIPhone/index.ts new file mode 100644 index 0000000000..d438998a81 --- /dev/null +++ b/packages/features/calAIPhone/index.ts @@ -0,0 +1,101 @@ +// Generic AI Phone Service Interfaces +export type { + AIPhoneServiceProvider, + AIPhoneServiceProviderFactory, + AIPhoneServiceProviderConfig, + AIPhoneServiceConfiguration, + AIPhoneServiceDeletion, + AIPhoneServiceDeletionResult, + AIPhoneServiceCallData, + AIPhoneServiceModel, + AIPhoneServiceAgent, + AIPhoneServiceCall, + AIPhoneServicePhoneNumber, +} from "./interfaces/AIPhoneService.interface"; + +export { AIPhoneServiceProviderType } from "./interfaces/AIPhoneService.interface"; + +// Registry System +export { + AIPhoneServiceRegistry, + createAIPhoneServiceProvider, + createDefaultAIPhoneServiceProvider, +} from "./AIPhoneServiceRegistry"; + +// Registry Initialization +export { + initializeAIPhoneServiceRegistry, + ensureAIPhoneServiceRegistryInitialized, +} from "./initializeRegistry"; + +// Provider Implementations +export { + RetellAIPhoneServiceProvider, + RetellAIPhoneServiceProviderFactory, + RetellAIService, + RetellSDKClient, + RetellAIError, +} from "./providers/retellAI"; + +export type { + RetellAIRepository, + CreateLLMRequest, + CreateAgentRequest, + UpdateLLMRequest, + AIConfigurationSetup, + AIConfigurationDeletion, + DeletionResult, +} from "./providers/retellAI"; + +// Legacy exports for backward compatibility +export { RetellAIService as LegacyRetellAIService } from "./retellAIService"; + +// Other exports +export { DEFAULT_PROMPT_VALUE, DEFAULT_BEGIN_MESSAGE, PROMPT_TEMPLATES } from "./promptTemplates"; +export { getTemplateFieldsSchema } from "./getTemplateFieldsSchema"; +export { templateFieldsMap } from "./template-fields-map"; + +// Re-export zod schemas +export * from "./zod-utils"; + +// ===== USAGE EXAMPLES ===== +/* +// Recommended usage with provider abstraction +import { createDefaultAIPhoneServiceProvider } from "@calcom/features/calAIPhone"; + +const aiPhoneService = createDefaultAIPhoneServiceProvider(); + +// Setup AI configuration +const { modelId, agentId } = await aiPhoneService.setupConfiguration({ + calApiKey: "cal_live_123...", + timeZone: "America/New_York", + eventTypeId: 12345, +}); + +// Create phone call +const call = await aiPhoneService.createPhoneCall({ + fromNumber: "+1234567890", + toNumber: "+0987654321", + dynamicVariables: { + name: "John Doe", + company: "Acme Corp", + email: "john@acme.com", + }, +}); + +// Using a specific provider +import { createAIPhoneServiceProvider, AIPhoneServiceProviderType } from "@calcom/features/calAIPhone"; + +const retellAIService = createAIPhoneServiceProvider({ + providerType: AIPhoneServiceProviderType.RETELL_AI, + config: { + apiKey: "your-retell-ai-key", + enableLogging: true, + } +}); + +// Legacy usage (still supported) +import { RetellAIPhoneServiceProviderFactory } from "@calcom/features/calAIPhone"; + +const legacyService = RetellAIPhoneServiceProviderFactory.createWithConfig(); +*/ diff --git a/packages/features/calAIPhone/initializeRegistry.ts b/packages/features/calAIPhone/initializeRegistry.ts new file mode 100644 index 0000000000..cea62e0d36 --- /dev/null +++ b/packages/features/calAIPhone/initializeRegistry.ts @@ -0,0 +1,44 @@ +import { AIPhoneServiceRegistry } from "./AIPhoneServiceRegistry"; +import { AIPhoneServiceProviderType } from "./interfaces/AIPhoneService.interface"; +import { RetellAIPhoneServiceProviderFactory } from "./providers/retellAI"; + +/** + * Initialize the AI Phone Service Registry + * This function should be called during application startup + */ +export function initializeAIPhoneServiceRegistry(): void { + // Use Retell AI as the default provider + const defaultProvider = AIPhoneServiceProviderType.RETELL_AI; + + // Build providers configuration - only Retell AI for now + const providers = [ + { + type: AIPhoneServiceProviderType.RETELL_AI, + factory: new RetellAIPhoneServiceProviderFactory(), + }, + ]; + + // Initialize the registry + AIPhoneServiceRegistry.initialize({ + defaultProvider, + providers, + }); + + // Log initialization details in development + if (process.env.NODE_ENV !== "production") { + console.log("AI Phone Service Registry initialized:", { + defaultProvider: AIPhoneServiceRegistry.getDefaultProvider(), + availableProviders: AIPhoneServiceRegistry.getAvailableProviders(), + }); + } +} + +/** + * Check if the registry needs initialization and initialize if needed + * This is useful for ensuring the registry is ready before use + */ +export function ensureAIPhoneServiceRegistryInitialized(): void { + if (!AIPhoneServiceRegistry.isInitialized()) { + initializeAIPhoneServiceRegistry(); + } +} diff --git a/packages/features/calAIPhone/interfaces/AIPhoneService.interface.ts b/packages/features/calAIPhone/interfaces/AIPhoneService.interface.ts new file mode 100644 index 0000000000..ed72ab6bac --- /dev/null +++ b/packages/features/calAIPhone/interfaces/AIPhoneService.interface.ts @@ -0,0 +1,343 @@ +import type { Logger } from "tslog"; + +import type { RetellAIPhoneServiceProviderTypeMap } from "../providers/retellAI"; + +/** + * Enum for supported AI phone service providers + */ +export enum AIPhoneServiceProviderType { + RETELL_AI = "retellAI", + // Add other providers here as needed +} + +/** + * Generic type map for provider-specific types + * This allows the interface layer to remain provider-agnostic while supporting type safety + * + * Usage examples: + * - Retell AI provider: AIPhoneServiceProvider + * - Generic usage: AIPhoneServiceProvider (defaults to union of all providers) + * + * Providers implement this by exporting a consolidated type map from their module: + * ```typescript + * // In providers/retellAI/index.ts + * export interface RetellAIPhoneServiceProviderTypeMap { + * Configuration: RetellAIConfigurationSetup; + * Agent: RetellAgent; + * AgentWithDetails: RetellAgentWithDetails; + * // ... other types + * } + * + * // In interfaces/AIPhoneService.interface.ts + * import type { RetellAIPhoneServiceProviderTypeMap } from "../providers/retellAI"; + * + * export interface AIPhoneServiceProviderTypeMap { + * [AIPhoneServiceProviderType.RETELL_AI]: RetellAIPhoneServiceProviderTypeMap; + * } + * ``` + */ +export interface AIPhoneServiceProviderTypeMap { + [AIPhoneServiceProviderType.RETELL_AI]: RetellAIPhoneServiceProviderTypeMap; +} + +/** + * Generic types that resolve to provider-specific types based on the provider type parameter + */ +export type AIPhoneServiceConfiguration = + AIPhoneServiceProviderTypeMap[T]["Configuration"]; + +export type AIPhoneServiceUpdateModelParams< + T extends AIPhoneServiceProviderType = AIPhoneServiceProviderType +> = AIPhoneServiceProviderTypeMap[T]["UpdateModelParams"]; + +export type AIPhoneServiceModel = + AIPhoneServiceProviderTypeMap[T]["Model"]; + +export type AIPhoneServiceAgent = + AIPhoneServiceProviderTypeMap[T]["Agent"]; + +export type AIPhoneServiceCall = + AIPhoneServiceProviderTypeMap[T]["Call"]; + +export type AIPhoneServicePhoneNumber = + AIPhoneServiceProviderTypeMap[T]["PhoneNumber"]; + +export type AIPhoneServiceUpdatePhoneNumberParams< + T extends AIPhoneServiceProviderType = AIPhoneServiceProviderType +> = AIPhoneServiceProviderTypeMap[T]["UpdatePhoneNumberParams"]; + +export type AIPhoneServiceCreatePhoneNumberParams< + T extends AIPhoneServiceProviderType = AIPhoneServiceProviderType +> = AIPhoneServiceProviderTypeMap[T]["CreatePhoneNumberParams"]; + +export type AIPhoneServiceImportPhoneNumberParams< + T extends AIPhoneServiceProviderType = AIPhoneServiceProviderType +> = AIPhoneServiceProviderTypeMap[T]["ImportPhoneNumberParams"]; + +export type AIPhoneServiceUpdateAgentParams< + T extends AIPhoneServiceProviderType = AIPhoneServiceProviderType +> = AIPhoneServiceProviderTypeMap[T]["UpdateAgentParams"]; + +export type AIPhoneServiceTools = + AIPhoneServiceProviderTypeMap[T]["Tools"]; + +export type AIPhoneServiceCreatePhoneCallParams< + T extends AIPhoneServiceProviderType = AIPhoneServiceProviderType +> = AIPhoneServiceProviderTypeMap[T]["CreatePhoneCallParams"]; + +export type AIPhoneServiceAgentWithDetails< + T extends AIPhoneServiceProviderType = AIPhoneServiceProviderType +> = AIPhoneServiceProviderTypeMap[T]["AgentWithDetails"]; + +export interface AIPhoneServiceDeletion { + modelId?: string; + agentId?: string; +} + +export interface AIPhoneServiceDeletionResult { + success: boolean; + errors: string[]; + deleted: { + model: boolean; + agent: boolean; + }; +} + +export type AIPhoneServiceCallData = + AIPhoneServiceCreatePhoneCallParams; + +// Extended import phone number params with additional fields +export type AIPhoneServiceImportPhoneNumberParamsExtended< + T extends AIPhoneServiceProviderType = AIPhoneServiceProviderType +> = AIPhoneServiceImportPhoneNumberParams & { + userId: number; + teamId?: number; + agentId?: string | null; +}; + +export interface AIPhoneServiceAgentListItem { + id: string; + name: string; + providerAgentId: string; + enabled: boolean; + userId: number; + teamId: number | null; + createdAt: Date; + updatedAt: Date; + outboundPhoneNumbers: { + id: number; + phoneNumber: string; + subscriptionStatus: string | null; + provider: string | null; + }[]; + team: { + id: number; + name: string | null | undefined; + slug: string | null | undefined; + } | null; + user: { + id: number; + name: string | null | undefined; + email: string | undefined; + } | null; +} + +/** + * Generic interface for AI phone service providers + * This interface abstracts away provider-specific details using generics + */ +export interface AIPhoneServiceProvider { + /** + * Setup AI configuration + */ + setupConfiguration(config: AIPhoneServiceConfiguration): Promise<{ + modelId: string; + agentId: string; + }>; + + /** + * Delete AI configuration + */ + deleteConfiguration(config: AIPhoneServiceDeletion): Promise; + + /** + * Update model configuration + */ + updateModelConfiguration( + modelId: string, + data: AIPhoneServiceUpdateModelParams + ): Promise>; + + /** + * Get model details + */ + getModelDetails(modelId: string): Promise>; + + /** + * Get agent details + */ + getAgent(agentId: string): Promise>; + + /** + * Update agent configuration + */ + updateAgent(agentId: string, data: AIPhoneServiceUpdateAgentParams): Promise>; + + /** + * Create a phone call + */ + createPhoneCall(data: AIPhoneServiceCallData): Promise>; + + /** + * Create a phone number + */ + createPhoneNumber(data: AIPhoneServiceCreatePhoneNumberParams): Promise>; + + /** + * Delete a phone number + */ + deletePhoneNumber(params: { + phoneNumber: string; + userId: number; + teamId?: number; + deleteFromDB: boolean; + }): Promise; + + /** + * Get phone number details + */ + getPhoneNumber(phoneNumber: string): Promise>; + + /** + * Update phone number configuration + */ + updatePhoneNumber( + phoneNumber: string, + data: AIPhoneServiceUpdatePhoneNumberParams + ): Promise>; + + /** + * Import a phone number + */ + importPhoneNumber( + data: AIPhoneServiceImportPhoneNumberParamsExtended + ): Promise>; + + /** + * Generate a checkout session for phone number subscription + */ + generatePhoneNumberCheckoutSession(params: { + userId: number; + teamId?: number; + agentId?: string | null; + workflowId?: string; + }): Promise<{ url: string; message: string }>; + + /** + * Cancel a phone number subscription + */ + cancelPhoneNumberSubscription(params: { + phoneNumberId: number; + userId: number; + teamId?: number; + }): Promise<{ success: boolean; message: string }>; + + /** + * Update phone number with agent assignments + */ + updatePhoneNumberWithAgents(params: { + phoneNumber: string; + userId: number; + teamId?: number; + inboundAgentId?: string | null; + outboundAgentId?: string | null; + }): Promise<{ message: string }>; + + /** + * List agents with user access + */ + listAgents(params: { userId: number; teamId?: number; scope?: "personal" | "team" | "all" }): Promise<{ + totalCount: number; + filtered: AIPhoneServiceAgentListItem[]; + }>; + + /** + * Get agent with detailed information + */ + getAgentWithDetails(params: { + id: string; + userId: number; + teamId?: number; + }): Promise>; + + /** + * Create a new agent + */ + createAgent(params: { + name?: string; + userId: number; + teamId?: number; + workflowStepId?: number; + generalPrompt?: string; + beginMessage?: string; + generalTools?: AIPhoneServiceTools; + voiceId?: string; + userTimeZone: string; + }): Promise<{ + id: string; + providerAgentId: string; + message: string; + }>; + + /** + * Update agent configuration + */ + updateAgentConfiguration(params: { + id: string; + userId: number; + name?: string; + enabled?: boolean; + generalPrompt?: string | null; + beginMessage?: string | null; + generalTools?: AIPhoneServiceTools; + voiceId?: string; + }): Promise<{ message: string }>; + + /** + * Delete an agent + */ + deleteAgent(params: { id: string; userId: number; teamId?: number }): Promise<{ message: string }>; + + /** + * Create a test call + */ + createTestCall(params: { + agentId: string; + phoneNumber?: string; + userId: number; + teamId?: number; + }): Promise<{ + callId: string; + status: string; + message: string; + }>; +} + +/** + * Configuration for AI phone service providers + */ +export interface AIPhoneServiceProviderConfig { + apiKey?: string; + baseUrl?: string; + enableLogging?: boolean; + logger?: Logger; +} + +/** + * Factory interface for creating AI phone service providers + */ +export interface AIPhoneServiceProviderFactory< + T extends AIPhoneServiceProviderType = AIPhoneServiceProviderType +> { + create(config: AIPhoneServiceProviderConfig): AIPhoneServiceProvider; +} diff --git a/packages/features/ee/cal-ai-phone/promptTemplates.ts b/packages/features/calAIPhone/promptTemplates.ts similarity index 100% rename from packages/features/ee/cal-ai-phone/promptTemplates.ts rename to packages/features/calAIPhone/promptTemplates.ts diff --git a/packages/features/calAIPhone/providers/adapters/PrismaAgentRepositoryAdapter.ts b/packages/features/calAIPhone/providers/adapters/PrismaAgentRepositoryAdapter.ts new file mode 100644 index 0000000000..7022fafed5 --- /dev/null +++ b/packages/features/calAIPhone/providers/adapters/PrismaAgentRepositoryAdapter.ts @@ -0,0 +1,77 @@ +import { PrismaAgentRepository } from "@calcom/lib/server/repository/PrismaAgentRepository"; + +import type { + AgentRepositoryInterface, + AgentData, + AgentWithDetailsData, + AgentWithPhoneNumbersData, +} from "../interfaces/AgentRepositoryInterface"; + +/** + * Adapter that bridges the provider interface to the Prisma implementation + * This adapter provides a clean abstraction layer between provider and application + */ +export class PrismaAgentRepositoryAdapter implements AgentRepositoryInterface { + async canManageTeamResources(params: { userId: number; teamId: number }): Promise { + return await PrismaAgentRepository.canManageTeamResources(params); + } + + async findByIdWithUserAccess(params: { agentId: string; userId: number }): Promise { + return await PrismaAgentRepository.findByIdWithUserAccess(params); + } + + async findByProviderAgentIdWithUserAccess(params: { + providerAgentId: string; + userId: number; + }): Promise { + return await PrismaAgentRepository.findByProviderAgentIdWithUserAccess(params); + } + + async findManyWithUserAccess(params: { + userId: number; + teamId?: number; + scope?: "personal" | "team" | "all"; + }): Promise { + return await PrismaAgentRepository.findManyWithUserAccess(params); + } + + async findByIdWithUserAccessAndDetails(params: { + id: string; + userId: number; + teamId?: number; + }): Promise { + return await PrismaAgentRepository.findByIdWithUserAccessAndDetails(params); + } + + async create(params: { + name: string; + providerAgentId: string; + userId: number; + teamId?: number; + }): Promise { + return await PrismaAgentRepository.create(params); + } + + async findByIdWithAdminAccess(params: { + id: string; + userId: number; + teamId?: number; + }): Promise { + return await PrismaAgentRepository.findByIdWithAdminAccess(params); + } + + async findByIdWithCallAccess(params: { + id: string; + userId: number; + }): Promise { + return await PrismaAgentRepository.findByIdWithCallAccess(params); + } + + async delete(params: { id: string }): Promise { + await PrismaAgentRepository.delete(params); + } + + async linkToWorkflowStep(params: { workflowStepId: number; agentId: string }): Promise { + await PrismaAgentRepository.linkToWorkflowStep(params); + } +} diff --git a/packages/features/calAIPhone/providers/adapters/PrismaPhoneNumberRepositoryAdapter.ts b/packages/features/calAIPhone/providers/adapters/PrismaPhoneNumberRepositoryAdapter.ts new file mode 100644 index 0000000000..dd1eedba5b --- /dev/null +++ b/packages/features/calAIPhone/providers/adapters/PrismaPhoneNumberRepositoryAdapter.ts @@ -0,0 +1,82 @@ +import { PrismaPhoneNumberRepository } from "@calcom/lib/server/repository/PrismaPhoneNumberRepository"; +import prisma from "@calcom/prisma"; +import { PhoneNumberSubscriptionStatus } from "@calcom/prisma/enums"; + +import type { + PhoneNumberRepositoryInterface, + PhoneNumberData, +} from "../interfaces/PhoneNumberRepositoryInterface"; + +/** + * Adapter that bridges the provider interface to the Prisma implementation + * This adapter provides a clean abstraction layer between provider and application + */ +export class PrismaPhoneNumberRepositoryAdapter implements PhoneNumberRepositoryInterface { + async findByPhoneNumberAndUserId(params: { + phoneNumber: string; + userId: number; + }): Promise { + return await PrismaPhoneNumberRepository.findByPhoneNumberAndUserId(params); + } + + async findByPhoneNumberAndTeamId(params: { + phoneNumber: string; + teamId: number; + userId: number; + }): Promise { + return await PrismaPhoneNumberRepository.findByPhoneNumberAndTeamId(params); + } + + async findByIdAndUserId(params: { id: number; userId: number }): Promise { + return await PrismaPhoneNumberRepository.findByIdAndUserId(params); + } + + async findByIdWithTeamAccess(params: { + id: number; + teamId: number; + userId: number; + }): Promise { + return await PrismaPhoneNumberRepository.findByIdWithTeamAccess(params); + } + + async createPhoneNumber(params: { + phoneNumber: string; + provider?: string; + userId: number; + teamId?: number; + outboundAgentId?: string | null; + }): Promise { + const createParams = { + phoneNumber: params.phoneNumber, + provider: params.provider, + userId: params.userId, + teamId: params.teamId, + }; + + return await PrismaPhoneNumberRepository.createPhoneNumber(createParams); + } + + async deletePhoneNumber(params: { phoneNumber: string }): Promise { + await PrismaPhoneNumberRepository.deletePhoneNumber(params); + } + + async updateSubscriptionStatus(params: { + id: number; + subscriptionStatus: PhoneNumberSubscriptionStatus; + disconnectOutboundAgent?: boolean; + }): Promise { + await PrismaPhoneNumberRepository.updateSubscriptionStatus(params); + } + + async updateAgents(params: { + id: number; + inboundProviderAgentId?: string | null; + outboundProviderAgentId?: string | null; + }): Promise { + await PrismaPhoneNumberRepository.updateAgents({ + id: params.id, + inboundProviderAgentId: params.inboundProviderAgentId, + outboundProviderAgentId: params.outboundProviderAgentId, + }); + } +} diff --git a/packages/features/calAIPhone/providers/adapters/PrismaTransactionAdapter.ts b/packages/features/calAIPhone/providers/adapters/PrismaTransactionAdapter.ts new file mode 100644 index 0000000000..fb43dd3bb7 --- /dev/null +++ b/packages/features/calAIPhone/providers/adapters/PrismaTransactionAdapter.ts @@ -0,0 +1,37 @@ +import prisma from "@calcom/prisma"; + +import type { + TransactionInterface, + TransactionContext, + TransactionalPhoneNumberRepository, +} from "../interfaces/TransactionInterface"; + +/** + * Prisma implementation of the transaction interface + * This adapter handles Prisma-specific transaction logic while keeping the provider decoupled + */ +export class PrismaTransactionAdapter implements TransactionInterface { + async executeInTransaction(operations: (context: TransactionContext) => Promise): Promise { + return await prisma.$transaction(async (tx) => { + const transactionalPhoneNumberRepo: TransactionalPhoneNumberRepository = { + async createPhoneNumber(params) { + await tx.calAiPhoneNumber.create({ + data: { + phoneNumber: params.phoneNumber, + userId: params.userId, + provider: params.provider, + teamId: params.teamId, + outboundAgentId: params.outboundAgentId, + }, + }); + }, + }; + + const context: TransactionContext = { + phoneNumberRepository: transactionalPhoneNumberRepo, + }; + + return await operations(context); + }); + } +} diff --git a/packages/features/calAIPhone/providers/interfaces/AgentRepositoryInterface.ts b/packages/features/calAIPhone/providers/interfaces/AgentRepositoryInterface.ts new file mode 100644 index 0000000000..df777206ed --- /dev/null +++ b/packages/features/calAIPhone/providers/interfaces/AgentRepositoryInterface.ts @@ -0,0 +1,118 @@ +/** + * Abstract interface for Agent repository operations + * This interface defines only the operations that the provider layer needs + * Allows for loose coupling between provider and application layers + */ +export interface AgentRepositoryInterface { + /** + * Check if user can manage team resources + */ + canManageTeamResources(params: { userId: number; teamId: number }): Promise; + + /** + * Find agent by ID with user access validation + */ + findByIdWithUserAccess(params: { agentId: string; userId: number }): Promise; + + /** + * Find agent by provider agent ID with user access validation + */ + findByProviderAgentIdWithUserAccess(params: { + providerAgentId: string; + userId: number; + }): Promise; + + /** + * Find many agents with user access validation + */ + findManyWithUserAccess(params: { + userId: number; + teamId?: number; + scope?: "personal" | "team" | "all"; + }): Promise; + + /** + * Find agent by ID with user access and additional details + */ + findByIdWithUserAccessAndDetails(params: { + id: string; + userId: number; + teamId?: number; + }): Promise; + + /** + * Create a new agent + */ + create(params: { + name: string; + providerAgentId: string; + userId: number; + teamId?: number; + }): Promise; + + /** + * Find agent by ID with admin access validation + */ + findByIdWithAdminAccess(params: { id: string; userId: number; teamId?: number }): Promise; + + /** + * Find agent by ID with call access validation + */ + findByIdWithCallAccess(params: { id: string; userId: number }): Promise; + + /** + * Delete agent by ID + */ + delete(params: { id: string }): Promise; + + /** + * Link agent to workflow step + */ + linkToWorkflowStep(params: { workflowStepId: number; agentId: string }): Promise; +} + +/** + * Basic agent data structure + */ +export interface AgentData { + id: string; + name: string; + providerAgentId: string; + enabled: boolean; + userId: number; + teamId: number | null; + createdAt: Date; + updatedAt: Date; +} + +/** + * Agent data with additional details + */ +export interface AgentWithDetailsData extends AgentData { + user: { + id: number; + name: string | null; + email: string | null; + } | null; + team: { + id: number; + name: string | null; + slug: string | null; + logoUrl?: string | null; + } | null; + outboundPhoneNumbers: Array<{ + id: number; + phoneNumber: string; + subscriptionStatus: string | null; + provider: string | null; + }>; +} + +/** + * Agent data with phone numbers for call operations + */ +export interface AgentWithPhoneNumbersData extends AgentData { + outboundPhoneNumbers: Array<{ + phoneNumber: string; + }>; +} diff --git a/packages/features/calAIPhone/providers/interfaces/PhoneNumberRepositoryInterface.ts b/packages/features/calAIPhone/providers/interfaces/PhoneNumberRepositoryInterface.ts new file mode 100644 index 0000000000..c09d7d3d8a --- /dev/null +++ b/packages/features/calAIPhone/providers/interfaces/PhoneNumberRepositoryInterface.ts @@ -0,0 +1,91 @@ +import { PhoneNumberSubscriptionStatus } from "@calcom/prisma/enums"; + +/** + * Abstract interface for Phone Number repository operations + * This interface defines only the operations that the provider layer needs + * Allows for loose coupling between provider and application layers + */ +export interface PhoneNumberRepositoryInterface { + /** + * Find phone number by phone number and user ID + */ + findByPhoneNumberAndUserId(params: { + phoneNumber: string; + userId: number; + }): Promise; + + /** + * Find phone number by phone number and team ID with user access validation + */ + findByPhoneNumberAndTeamId(params: { + phoneNumber: string; + teamId: number; + userId: number; + }): Promise; + + /** + * Find phone number by ID and user ID + */ + findByIdAndUserId(params: { id: number; userId: number }): Promise; + + /** + * Find phone number by ID with team access validation + */ + findByIdWithTeamAccess(params: { + id: number; + teamId: number; + userId: number; + }): Promise; + + /** + * Create a new phone number record + */ + createPhoneNumber(params: { + phoneNumber: string; + provider?: string; + userId: number; + teamId?: number; + outboundAgentId?: string | null; + }): Promise; + + /** + * Delete phone number by phone number + */ + deletePhoneNumber(params: { phoneNumber: string }): Promise; + + /** + * Update subscription status + */ + updateSubscriptionStatus(params: { + id: number; + subscriptionStatus: PhoneNumberSubscriptionStatus; + disconnectOutboundAgent?: boolean; + }): Promise; + + /** + * Update agents for a phone number + */ + updateAgents(params: { + id: number; + inboundProviderAgentId?: string | null; + outboundProviderAgentId?: string | null; + }): Promise; +} + +/** + * Phone Number data structure + */ +export interface PhoneNumberData { + id: number; + phoneNumber: string; + userId: number | null; + teamId: number | null; + subscriptionStatus: string | null; + stripeSubscriptionId: string | null; + stripeCustomerId: string | null; + provider: string | null; + inboundAgentId: string | null; + outboundAgentId: string | null; + createdAt: Date; + updatedAt: Date; +} diff --git a/packages/features/calAIPhone/providers/interfaces/TransactionInterface.ts b/packages/features/calAIPhone/providers/interfaces/TransactionInterface.ts new file mode 100644 index 0000000000..8d81878093 --- /dev/null +++ b/packages/features/calAIPhone/providers/interfaces/TransactionInterface.ts @@ -0,0 +1,38 @@ +/** + * Abstract transaction interface that allows providers to request transactional operations + * without being coupled to any specific database implementation + */ +export interface TransactionInterface { + /** + * Execute operations within a transaction + * @param operations - A function that performs the operations within the transaction context + * @returns Promise that resolves when the transaction completes + */ + executeInTransaction(operations: (context: TransactionContext) => Promise): Promise; +} + +/** + * Transaction context that provides access to transactional repository operations + */ +export interface TransactionContext { + /** + * Phone number repository operations within this transaction + */ + phoneNumberRepository: TransactionalPhoneNumberRepository; +} + +/** + * Phone number repository operations that can be performed within a transaction + */ +export interface TransactionalPhoneNumberRepository { + /** + * Create a new phone number record within the transaction + */ + createPhoneNumber(params: { + phoneNumber: string; + userId: number; + provider: string; + teamId?: number; + outboundAgentId?: string | null; + }): Promise; +} diff --git a/packages/features/calAIPhone/providers/retellAI/RetellAIPhoneServiceProvider.test.ts b/packages/features/calAIPhone/providers/retellAI/RetellAIPhoneServiceProvider.test.ts new file mode 100644 index 0000000000..3cb76f923d --- /dev/null +++ b/packages/features/calAIPhone/providers/retellAI/RetellAIPhoneServiceProvider.test.ts @@ -0,0 +1,669 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +import type { + AIPhoneServiceConfiguration, + AIPhoneServiceDeletion, + AIPhoneServiceCallData, + AIPhoneServiceCreatePhoneNumberParams, + AIPhoneServiceImportPhoneNumberParams, + AIPhoneServiceUpdatePhoneNumberParams, + AIPhoneServiceUpdateAgentParams, + AIPhoneServiceUpdateModelParams, +} from "../../interfaces/AIPhoneService.interface"; +import type { AgentRepositoryInterface } from "../interfaces/AgentRepositoryInterface"; +import type { PhoneNumberRepositoryInterface } from "../interfaces/PhoneNumberRepositoryInterface"; +import type { TransactionInterface } from "../interfaces/TransactionInterface"; +import { RetellAIPhoneServiceProvider } from "./RetellAIPhoneServiceProvider"; +import type { RetellAIService } from "./RetellAIService"; +import type { RetellAIRepository } from "./types"; + +function createMockRetellAIService(overrides: Partial = {}): RetellAIService { + const defaultMocks = { + setupAIConfiguration: vi.fn(), + deleteAIConfiguration: vi.fn(), + updateLLMConfiguration: vi.fn(), + getLLMDetails: vi.fn(), + getAgent: vi.fn(), + updateAgent: vi.fn(), + createPhoneCall: vi.fn(), + createPhoneNumber: vi.fn(), + importPhoneNumber: vi.fn(), + deletePhoneNumber: vi.fn(), + getPhoneNumber: vi.fn(), + updatePhoneNumber: vi.fn(), + generatePhoneNumberCheckoutSession: vi.fn(), + cancelPhoneNumberSubscription: vi.fn(), + updatePhoneNumberWithAgents: vi.fn(), + listAgents: vi.fn(), + getAgentWithDetails: vi.fn(), + createAgent: vi.fn(), + updateAgentConfiguration: vi.fn(), + deleteAgent: vi.fn(), + createTestCall: vi.fn(), + }; + + return { ...defaultMocks, ...overrides } as unknown as RetellAIService; +} + +describe("RetellAIPhoneServiceProvider", () => { + let mockRepository: RetellAIRepository; + let mockAgentRepository: AgentRepositoryInterface; + let mockPhoneNumberRepository: PhoneNumberRepositoryInterface; + let mockTransactionManager: TransactionInterface; + let provider: RetellAIPhoneServiceProvider; + let mockService: RetellAIService; + + beforeEach(() => { + mockRepository = { + createLLM: vi.fn().mockResolvedValue({ llm_id: "test-llm-id" }), + getLLM: vi.fn().mockResolvedValue({ llm_id: "test-llm-id", general_prompt: "test prompt" }), + updateLLM: vi.fn().mockResolvedValue({ llm_id: "test-llm-id" }), + deleteLLM: vi.fn().mockResolvedValue(undefined), + + // Agent operations + createAgent: 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), + + // Phone number operations + createPhoneNumber: vi.fn().mockResolvedValue({ phone_number: "+1234567890" }), + importPhoneNumber: vi.fn().mockResolvedValue({ phone_number: "+1234567890" }), + deletePhoneNumber: vi.fn().mockResolvedValue(undefined), + getPhoneNumber: vi.fn().mockResolvedValue({ phone_number: "+1234567890" }), + updatePhoneNumber: vi.fn().mockResolvedValue({ phone_number: "+1234567890" }), + + // Call operations + createPhoneCall: vi.fn().mockResolvedValue({ call_id: "test-call-id" }), + }; + + // Mock agent repository + mockAgentRepository = { + canManageTeamResources: vi.fn(), + findByIdWithUserAccess: vi.fn(), + findByProviderAgentIdWithUserAccess: vi.fn(), + findManyWithUserAccess: vi.fn(), + findByIdWithUserAccessAndDetails: vi.fn(), + create: vi.fn(), + findByIdWithAdminAccess: vi.fn(), + findByIdWithCallAccess: vi.fn(), + delete: vi.fn(), + linkToWorkflowStep: vi.fn(), + } as unknown as AgentRepositoryInterface; + + // Mock phone number repository + mockPhoneNumberRepository = { + findByPhoneNumberAndUserId: vi.fn(), + findByPhoneNumberAndTeamId: vi.fn(), + findByIdAndUserId: vi.fn(), + findByIdWithTeamAccess: vi.fn(), + createPhoneNumber: vi.fn(), + deletePhoneNumber: vi.fn(), + updateSubscriptionStatus: vi.fn(), + updateAgents: vi.fn(), + } as unknown as PhoneNumberRepositoryInterface; + + // Mock transaction manager + mockTransactionManager = { + executeInTransaction: vi.fn(), + } as unknown as TransactionInterface; + + // Create a comprehensive mock service using helper function + mockService = createMockRetellAIService(); + + // Pass the mock service as the 5th parameter to ensure it's used instead of creating a new one + provider = new RetellAIPhoneServiceProvider( + mockRepository, + mockAgentRepository, + mockPhoneNumberRepository, + mockTransactionManager, + mockService + ); + }); + + describe("setupConfiguration", () => { + beforeEach(() => { + mockService.setupAIConfiguration = vi.fn().mockResolvedValue({ + llmId: "test-llm-id", + agentId: "test-agent-id", + }); + }); + + it("should setup AI configuration and return llmId and agentId", async () => { + const config: AIPhoneServiceConfiguration = { + calApiKey: "test-cal-api-key", + timeZone: "America/New_York", + eventTypeId: 123, + generalPrompt: "Test prompt", + beginMessage: "Hello!", + generalTools: [ + { + type: "check_availability_cal", + name: "check_availability", + cal_api_key: "test-key", + event_type_id: 123, + timezone: "America/New_York", + }, + ], + }; + + const result = await provider.setupConfiguration(config); + + expect(mockService.setupAIConfiguration).toHaveBeenCalledWith({ + calApiKey: config.calApiKey, + timeZone: config.timeZone, + eventTypeId: config.eventTypeId, + generalPrompt: config.generalPrompt, + beginMessage: config.beginMessage, + generalTools: config.generalTools, + }); + + expect(result).toEqual({ + modelId: "test-llm-id", + agentId: "test-agent-id", + }); + }); + + it("should handle missing optional fields in configuration", async () => { + const minimalConfig: AIPhoneServiceConfiguration = { + generalPrompt: "Minimal prompt", + }; + + await provider.setupConfiguration(minimalConfig); + + expect(mockService.setupAIConfiguration).toHaveBeenCalledWith({ + calApiKey: undefined, + timeZone: undefined, + eventTypeId: undefined, + generalPrompt: "Minimal prompt", + beginMessage: undefined, + generalTools: undefined, + }); + }); + }); + + describe("deleteConfiguration", () => { + beforeEach(() => { + mockService.deleteAIConfiguration = vi.fn().mockResolvedValue({ + success: true, + errors: [], + deleted: { llm: true, agent: true }, + }); + }); + + it("should delete AI configuration and return result", async () => { + const config: AIPhoneServiceDeletion = { + modelId: "test-llm-id", + agentId: "test-agent-id", + }; + + const result = await provider.deleteConfiguration(config); + + expect(mockService.deleteAIConfiguration).toHaveBeenCalledWith({ + llmId: "test-llm-id", + agentId: "test-agent-id", + }); + + expect(result).toEqual({ + success: true, + errors: [], + deleted: { model: true, agent: true }, + }); + }); + + it("should handle partial deletion results", async () => { + mockService.deleteAIConfiguration = vi.fn().mockResolvedValue({ + success: false, + errors: ["Failed to delete LLM"], + deleted: { llm: false, agent: true }, + }); + + const config: AIPhoneServiceDeletion = { + modelId: "test-llm-id", + agentId: "test-agent-id", + }; + + const result = await provider.deleteConfiguration(config); + + expect(result).toEqual({ + success: false, + errors: ["Failed to delete LLM"], + deleted: { model: false, agent: true }, + }); + }); + }); + + describe("updateModelConfiguration", () => { + let updateLLMConfigurationMock: ReturnType; + + beforeEach(() => { + updateLLMConfigurationMock = vi.fn().mockResolvedValue({ llm_id: "test-llm-id" }); + + mockService = createMockRetellAIService({ + updateLLMConfiguration: updateLLMConfigurationMock, + }); + + provider = new RetellAIPhoneServiceProvider( + mockRepository, + mockAgentRepository, + mockPhoneNumberRepository, + mockTransactionManager, + mockService + ); + }); + + it("should update model configuration", async () => { + const modelId = "test-llm-id"; + const updateData: AIPhoneServiceUpdateModelParams = { + general_prompt: "Updated prompt", + begin_message: "Updated begin message", + }; + + const result = await provider.updateModelConfiguration(modelId, updateData); + + expect(updateLLMConfigurationMock).toHaveBeenCalledWith(modelId, updateData); + expect(result).toEqual({ llm_id: "test-llm-id" }); + }); + }); + + describe("getModelDetails", () => { + let getLLMDetailsMock: ReturnType; + + beforeEach(() => { + getLLMDetailsMock = vi.fn().mockResolvedValue({ llm_id: "test-llm-id", general_prompt: "Test prompt" }); + + mockService = createMockRetellAIService({ + getLLMDetails: getLLMDetailsMock, + }); + + provider = new RetellAIPhoneServiceProvider( + mockRepository, + mockAgentRepository, + mockPhoneNumberRepository, + mockTransactionManager, + mockService + ); + }); + + it("should get model details", async () => { + const modelId = "test-llm-id"; + + const result = await provider.getModelDetails(modelId); + + expect(getLLMDetailsMock).toHaveBeenCalledWith(modelId); + expect(result).toEqual({ llm_id: "test-llm-id", general_prompt: "Test prompt" }); + }); + }); + + describe("getAgent", () => { + let getAgentMock: ReturnType; + + beforeEach(() => { + getAgentMock = vi.fn().mockResolvedValue({ agent_id: "test-agent-id", agent_name: "Test Agent" }); + + mockService = createMockRetellAIService({ + getAgent: getAgentMock, + }); + + provider = new RetellAIPhoneServiceProvider( + mockRepository, + mockAgentRepository, + mockPhoneNumberRepository, + mockTransactionManager, + mockService + ); + }); + + it("should get agent details", async () => { + const agentId = "test-agent-id"; + + const result = await provider.getAgent(agentId); + + expect(getAgentMock).toHaveBeenCalledWith(agentId); + expect(result).toEqual({ agent_id: "test-agent-id", agent_name: "Test Agent" }); + }); + }); + + describe("updateAgent", () => { + let updateAgentMock: ReturnType; + + beforeEach(() => { + updateAgentMock = vi.fn().mockResolvedValue({ agent_id: "test-agent-id" }); + + mockService = createMockRetellAIService({ + updateAgent: updateAgentMock, + }); + + provider = new RetellAIPhoneServiceProvider( + mockRepository, + mockAgentRepository, + mockPhoneNumberRepository, + mockTransactionManager, + mockService + ); + }); + + it("should update agent", async () => { + const agentId = "test-agent-id"; + const updateData: AIPhoneServiceUpdateAgentParams = { + agent_name: "Updated Agent Name", + }; + + const result = await provider.updateAgent(agentId, updateData); + + expect(updateAgentMock).toHaveBeenCalledWith(agentId, updateData); + expect(result).toEqual({ agent_id: "test-agent-id" }); + }); + }); + + describe("createPhoneCall", () => { + let mockService: RetellAIService; + let testProvider: RetellAIPhoneServiceProvider; + let createPhoneCallMock: ReturnType; + + beforeEach(() => { + createPhoneCallMock = vi.fn().mockResolvedValue({ call_id: "test-call-id" }); + + mockService = createMockRetellAIService({ + createPhoneCall: createPhoneCallMock, + }); + + testProvider = new RetellAIPhoneServiceProvider( + mockRepository, + mockAgentRepository, + mockPhoneNumberRepository, + mockTransactionManager, + mockService + ); + }); + + it("should create phone call", async () => { + const callData: AIPhoneServiceCallData = { + from_number: "+1234567890", + to_number: "+0987654321", + retell_llm_dynamic_variables: { + name: "John Doe", + email: "john@example.com", + }, + }; + + const result = await testProvider.createPhoneCall(callData); + + expect(createPhoneCallMock).toHaveBeenCalledWith(callData); + expect(result).toEqual({ call_id: "test-call-id" }); + }); + + it("should handle call data without dynamic variables", async () => { + const callData: AIPhoneServiceCallData = { + from_number: "+1234567890", + to_number: "+0987654321", + }; + + await testProvider.createPhoneCall(callData); + + expect(createPhoneCallMock).toHaveBeenCalledWith(callData); + }); + }); + + describe("createPhoneNumber", () => { + let mockService: RetellAIService; + let testProvider: RetellAIPhoneServiceProvider; + let createPhoneNumberMock: ReturnType; + + beforeEach(() => { + createPhoneNumberMock = vi.fn().mockResolvedValue({ phone_number: "+1234567890" }); + + mockService = createMockRetellAIService({ + createPhoneNumber: createPhoneNumberMock, + }); + + testProvider = new RetellAIPhoneServiceProvider( + mockRepository, + mockAgentRepository, + mockPhoneNumberRepository, + mockTransactionManager, + mockService + ); + }); + + it("should create phone number with proper parameter mapping", async () => { + const phoneNumberData: AIPhoneServiceCreatePhoneNumberParams = { + area_code: 415, + nickname: "Test Phone", + inbound_agent_id: "inbound-agent-id", + outbound_agent_id: "outbound-agent-id", + }; + + const result = await testProvider.createPhoneNumber(phoneNumberData); + + expect(createPhoneNumberMock).toHaveBeenCalledWith({ + area_code: 415, + nickname: "Test Phone", + inbound_agent_id: "inbound-agent-id", + outbound_agent_id: "outbound-agent-id", + }); + expect(result).toEqual({ phone_number: "+1234567890" }); + }); + + it("should handle optional parameters in phone number creation", async () => { + const minimalData: AIPhoneServiceCreatePhoneNumberParams = { + area_code: 415, + }; + + await testProvider.createPhoneNumber(minimalData); + + expect(createPhoneNumberMock).toHaveBeenCalledWith({ + area_code: 415, + nickname: undefined, + inbound_agent_id: undefined, + outbound_agent_id: undefined, + }); + }); + }); + + describe("importPhoneNumber", () => { + let mockService: RetellAIService; + let testProvider: RetellAIPhoneServiceProvider; + let importPhoneNumberMock: ReturnType; + + beforeEach(() => { + importPhoneNumberMock = vi.fn().mockResolvedValue({ phone_number: "+1234567890" }); + + mockService = createMockRetellAIService({ + importPhoneNumber: importPhoneNumberMock, + }); + + testProvider = new RetellAIPhoneServiceProvider( + mockRepository, + mockAgentRepository, + mockPhoneNumberRepository, + mockTransactionManager, + mockService + ); + }); + + it("should import phone number", async () => { + const importData: AIPhoneServiceImportPhoneNumberParams = { + phone_number: "+1234567890", + termination_uri: "https://example.com/webhook", + sip_trunk_auth_username: "username", + sip_trunk_auth_password: "password", + nickname: "Imported Phone", + userId: 123, + }; + + const result = await testProvider.importPhoneNumber(importData); + + expect(importPhoneNumberMock).toHaveBeenCalledWith(importData); + expect(result).toEqual({ phone_number: "+1234567890" }); + }); + }); + + describe("deletePhoneNumber", () => { + let mockService: RetellAIService; + let testProvider: RetellAIPhoneServiceProvider; + let deletePhoneNumberMock: ReturnType; + + beforeEach(() => { + deletePhoneNumberMock = vi.fn().mockResolvedValue(undefined); + + mockService = createMockRetellAIService({ + deletePhoneNumber: deletePhoneNumberMock, + }); + + testProvider = new RetellAIPhoneServiceProvider( + mockRepository, + mockAgentRepository, + mockPhoneNumberRepository, + mockTransactionManager, + mockService + ); + }); + + it("should delete phone number", async () => { + const deleteParams = { + phoneNumber: "+1234567890", + userId: 123, + deleteFromDB: true, + }; + + await testProvider.deletePhoneNumber(deleteParams); + + expect(deletePhoneNumberMock).toHaveBeenCalledWith(deleteParams); + }); + + it("should handle deletion without database cleanup", async () => { + const deleteParams = { + phoneNumber: "+1234567890", + userId: 123, + deleteFromDB: false, + }; + + await testProvider.deletePhoneNumber(deleteParams); + + expect(deletePhoneNumberMock).toHaveBeenCalledWith(deleteParams); + }); + }); + + describe("getPhoneNumber", () => { + let mockService: RetellAIService; + let testProvider: RetellAIPhoneServiceProvider; + let getPhoneNumberMock: ReturnType; + + beforeEach(() => { + getPhoneNumberMock = vi.fn().mockResolvedValue({ phone_number: "+1234567890" }); + + mockService = createMockRetellAIService({ + getPhoneNumber: getPhoneNumberMock, + }); + + testProvider = new RetellAIPhoneServiceProvider( + mockRepository, + mockAgentRepository, + mockPhoneNumberRepository, + mockTransactionManager, + mockService + ); + }); + + it("should get phone number details", async () => { + const phoneNumber = "+1234567890"; + + const result = await testProvider.getPhoneNumber(phoneNumber); + + expect(getPhoneNumberMock).toHaveBeenCalledWith(phoneNumber); + expect(result).toEqual({ phone_number: "+1234567890" }); + }); + }); + + describe("updatePhoneNumber", () => { + let mockService: RetellAIService; + let testProvider: RetellAIPhoneServiceProvider; + let updatePhoneNumberMock: ReturnType; + + beforeEach(() => { + updatePhoneNumberMock = vi.fn().mockResolvedValue({ phone_number: "+1234567890" }); + + mockService = createMockRetellAIService({ + updatePhoneNumber: updatePhoneNumberMock, + }); + + testProvider = new RetellAIPhoneServiceProvider( + mockRepository, + mockAgentRepository, + mockPhoneNumberRepository, + mockTransactionManager, + mockService + ); + }); + + it("should update phone number with proper parameter mapping", async () => { + const phoneNumber = "+1234567890"; + const updateData: AIPhoneServiceUpdatePhoneNumberParams = { + inbound_agent_id: "new-inbound-agent", + outbound_agent_id: "new-outbound-agent", + }; + + const result = await testProvider.updatePhoneNumber(phoneNumber, updateData); + + expect(updatePhoneNumberMock).toHaveBeenCalledWith(phoneNumber, { + inbound_agent_id: "new-inbound-agent", + outbound_agent_id: "new-outbound-agent", + }); + expect(result).toEqual({ phone_number: "+1234567890" }); + }); + + it("should handle partial updates", async () => { + const phoneNumber = "+1234567890"; + const updateData: AIPhoneServiceUpdatePhoneNumberParams = { + inbound_agent_id: "new-inbound-agent", + }; + + await testProvider.updatePhoneNumber(phoneNumber, updateData); + + expect(updatePhoneNumberMock).toHaveBeenCalledWith(phoneNumber, { + inbound_agent_id: "new-inbound-agent", + outbound_agent_id: undefined, + }); + }); + + it("should handle null values in update data", async () => { + const phoneNumber = "+1234567890"; + const updateData: AIPhoneServiceUpdatePhoneNumberParams = { + inbound_agent_id: null, + outbound_agent_id: "new-outbound-agent", + }; + + await testProvider.updatePhoneNumber(phoneNumber, updateData); + + expect(updatePhoneNumberMock).toHaveBeenCalledWith(phoneNumber, { + inbound_agent_id: null, + outbound_agent_id: "new-outbound-agent", + }); + }); + }); + + describe("error handling", () => { + it("should propagate errors from service layer", async () => { + const setupAIConfigurationMock = vi.fn().mockRejectedValue(new Error("Service error")); + + const mockService = createMockRetellAIService({ + setupAIConfiguration: setupAIConfigurationMock, + }); + + const testProvider = new RetellAIPhoneServiceProvider( + mockRepository, + mockAgentRepository, + mockPhoneNumberRepository, + mockTransactionManager, + mockService + ); + + const config: AIPhoneServiceConfiguration = { + generalPrompt: "Test prompt", + }; + + await expect(testProvider.setupConfiguration(config)).rejects.toThrow("Service error"); + }); + }); +}); diff --git a/packages/features/calAIPhone/providers/retellAI/RetellAIPhoneServiceProvider.ts b/packages/features/calAIPhone/providers/retellAI/RetellAIPhoneServiceProvider.ts new file mode 100644 index 0000000000..1e16b0c7b3 --- /dev/null +++ b/packages/features/calAIPhone/providers/retellAI/RetellAIPhoneServiceProvider.ts @@ -0,0 +1,266 @@ +import type { + AIPhoneServiceProvider, + AIPhoneServiceProviderType, + AIPhoneServiceConfiguration, + AIPhoneServiceDeletion, + AIPhoneServiceDeletionResult, + AIPhoneServiceCallData, + AIPhoneServiceModel, + AIPhoneServiceCall, + AIPhoneServicePhoneNumber, + AIPhoneServiceAgent, + AIPhoneServiceUpdateModelParams, + AIPhoneServiceUpdateAgentParams, + AIPhoneServiceCreatePhoneNumberParams, + AIPhoneServiceImportPhoneNumberParams, + AIPhoneServiceUpdatePhoneNumberParams, + AIPhoneServiceAgentListItem, + AIPhoneServiceImportPhoneNumberParamsExtended, + AIPhoneServiceAgentWithDetails, + AIPhoneServiceTools, +} from "../../interfaces/AIPhoneService.interface"; +import type { AgentRepositoryInterface } from "../interfaces/AgentRepositoryInterface"; +import type { PhoneNumberRepositoryInterface } from "../interfaces/PhoneNumberRepositoryInterface"; +import type { TransactionInterface } from "../interfaces/TransactionInterface"; +import { RetellAIService } from "./RetellAIService"; +import type { RetellAIRepository, RetellAgentWithDetails, RetellLLMGeneralTools } from "./types"; + +export class RetellAIPhoneServiceProvider + implements AIPhoneServiceProvider +{ + private service: RetellAIService; + + constructor( + repository: RetellAIRepository, + agentRepository: AgentRepositoryInterface, + phoneNumberRepository: PhoneNumberRepositoryInterface, + transactionManager: TransactionInterface, + service?: RetellAIService + ) { + this.service = + service || new RetellAIService(repository, agentRepository, phoneNumberRepository, transactionManager); + } + + async setupConfiguration( + config: AIPhoneServiceConfiguration + ): Promise<{ + modelId: string; + agentId: string; + }> { + const result = await this.service.setupAIConfiguration({ + calApiKey: config.calApiKey, + timeZone: config.timeZone, + eventTypeId: config.eventTypeId, + generalPrompt: config.generalPrompt, + beginMessage: config.beginMessage, + generalTools: config.generalTools, + }); + + return { + modelId: result.llmId, + agentId: result.agentId, + }; + } + + async deleteConfiguration(config: AIPhoneServiceDeletion): Promise { + const result = await this.service.deleteAIConfiguration({ + llmId: config.modelId, + agentId: config.agentId, + }); + + return { + success: result.success, + errors: result.errors, + deleted: { + model: result.deleted.llm, + agent: result.deleted.agent, + }, + }; + } + + async updateModelConfiguration( + modelId: string, + data: AIPhoneServiceUpdateModelParams + ): Promise> { + const result = await this.service.updateLLMConfiguration(modelId, data); + return result; + } + + async getModelDetails(modelId: string): Promise> { + const result = await this.service.getLLMDetails(modelId); + return result; + } + + async getAgent(agentId: string): Promise> { + const agent = await this.service.getAgent(agentId); + return agent; + } + + async updateAgent( + agentId: string, + data: AIPhoneServiceUpdateAgentParams + ): Promise> { + const agent = await this.service.updateAgent(agentId, data); + return agent; + } + + async createPhoneCall( + data: AIPhoneServiceCallData + ): Promise> { + const result = await this.service.createPhoneCall(data); + + return result; + } + + async createPhoneNumber( + data: AIPhoneServiceCreatePhoneNumberParams + ): Promise> { + const result = await this.service.createPhoneNumber({ + area_code: data.area_code, + nickname: data.nickname, + inbound_agent_id: data.inbound_agent_id, + outbound_agent_id: data.outbound_agent_id, + }); + + return result; + } + + async importPhoneNumber( + data: AIPhoneServiceImportPhoneNumberParamsExtended + ): Promise> { + const result = await this.service.importPhoneNumber(data); + return result; + } + + async deletePhoneNumber({ + phoneNumber, + userId, + deleteFromDB, + teamId, + }: { + phoneNumber: string; + userId: number; + deleteFromDB: boolean; + teamId?: number; + }): Promise { + await this.service.deletePhoneNumber({ phoneNumber, userId, deleteFromDB, teamId }); + } + + async getPhoneNumber( + phoneNumber: string + ): Promise> { + const result = await this.service.getPhoneNumber(phoneNumber); + return result; + } + + async updatePhoneNumber( + phoneNumber: string, + data: AIPhoneServiceUpdatePhoneNumberParams + ): Promise> { + const result = await this.service.updatePhoneNumber(phoneNumber, { + inbound_agent_id: data.inbound_agent_id, + outbound_agent_id: data.outbound_agent_id, + }); + + return result; + } + + async generatePhoneNumberCheckoutSession(params: { + userId: number; + teamId?: number; + agentId?: string | null; + workflowId?: string; + }): Promise<{ url: string; message: string }> { + return await this.service.generatePhoneNumberCheckoutSession(params); + } + + async cancelPhoneNumberSubscription(params: { + phoneNumberId: number; + userId: number; + teamId?: number; + }): Promise<{ success: boolean; message: string }> { + return await this.service.cancelPhoneNumberSubscription(params); + } + + async updatePhoneNumberWithAgents(params: { + phoneNumber: string; + userId: number; + teamId?: number; + inboundAgentId?: string | null; + outboundAgentId?: string | null; + }): Promise<{ message: string }> { + return await this.service.updatePhoneNumberWithAgents(params); + } + + async listAgents(params: { + userId: number; + teamId?: number; + scope?: "personal" | "team" | "all"; + }): Promise<{ + totalCount: number; + filtered: AIPhoneServiceAgentListItem[]; + }> { + return await this.service.listAgents(params); + } + + async getAgentWithDetails(params: { + id: string; + userId: number; + teamId?: number; + }): Promise> { + return await this.service.getAgentWithDetails(params); + } + + async createAgent(params: { + name?: string; + userId: number; + teamId?: number; + workflowStepId?: number; + generalPrompt?: string; + beginMessage?: string; + generalTools?: AIPhoneServiceTools; + voiceId?: string; + userTimeZone: string; + }): Promise<{ + id: string; + providerAgentId: string; + message: string; + }> { + const result = await this.service.createAgent(params); + return { + id: result.id, + providerAgentId: result.providerAgentId, + message: result.message, + }; + } + + async updateAgentConfiguration(params: { + id: string; + userId: number; + name?: string; + enabled?: boolean; + generalPrompt?: string | null; + beginMessage?: string | null; + generalTools?: AIPhoneServiceTools; + voiceId?: string; + }): Promise<{ message: string }> { + return await this.service.updateAgentConfiguration(params); + } + + async deleteAgent(params: { id: string; userId: number; teamId?: number }): Promise<{ message: string }> { + return await this.service.deleteAgent(params); + } + + async createTestCall(params: { + agentId: string; + phoneNumber?: string; + userId: number; + teamId?: number; + }): Promise<{ + callId: string; + status: string; + message: string; + }> { + return await this.service.createTestCall(params); + } +} diff --git a/packages/features/calAIPhone/providers/retellAI/RetellAIPhoneServiceProviderFactory.test.ts b/packages/features/calAIPhone/providers/retellAI/RetellAIPhoneServiceProviderFactory.test.ts new file mode 100644 index 0000000000..9988a520e2 --- /dev/null +++ b/packages/features/calAIPhone/providers/retellAI/RetellAIPhoneServiceProviderFactory.test.ts @@ -0,0 +1,166 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +import logger from "@calcom/lib/logger"; + +import type { AIPhoneServiceProviderConfig } from "../../interfaces/AIPhoneService.interface"; +import { RetellAIPhoneServiceProvider } from "./RetellAIPhoneServiceProvider"; +import { RetellAIPhoneServiceProviderFactory } from "./RetellAIPhoneServiceProviderFactory"; +import { RetellSDKClient } from "./RetellSDKClient"; + +vi.mock("@calcom/lib/logger", () => ({ + default: { + getSubLogger: vi.fn().mockReturnValue({ + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + }), + }, +})); + +vi.mock("./RetellSDKClient", () => ({ + RetellSDKClient: vi.fn().mockImplementation((logger) => ({ + logger, + createLLM: vi.fn(), + getLLM: vi.fn(), + updateLLM: vi.fn(), + deleteLLM: vi.fn(), + createAgent: vi.fn(), + getAgent: vi.fn(), + updateAgent: vi.fn(), + deleteAgent: vi.fn(), + createPhoneNumber: vi.fn(), + importPhoneNumber: vi.fn(), + deletePhoneNumber: vi.fn(), + getPhoneNumber: vi.fn(), + updatePhoneNumber: vi.fn(), + createPhoneCall: vi.fn(), + })), +})); + +vi.mock("./RetellAIPhoneServiceProvider", () => ({ + RetellAIPhoneServiceProvider: vi + .fn() + .mockImplementation((repository, agentRepository, phoneNumberRepository, transactionManager) => ({ + repository, + agentRepository, + phoneNumberRepository, + transactionManager, + setupConfiguration: vi.fn(), + deleteConfiguration: vi.fn(), + updateLLMConfiguration: vi.fn(), + getLLMDetails: vi.fn(), + getAgent: vi.fn(), + updateAgent: vi.fn(), + createPhoneCall: vi.fn(), + createPhoneNumber: vi.fn(), + deletePhoneNumber: vi.fn(), + getPhoneNumber: vi.fn(), + updatePhoneNumber: vi.fn(), + importPhoneNumber: vi.fn(), + })), +})); + +vi.mock("../adapters/PrismaAgentRepositoryAdapter", () => ({ + PrismaAgentRepositoryAdapter: vi.fn().mockImplementation(() => ({ + canManageTeamResources: vi.fn(), + findByIdWithUserAccess: vi.fn(), + findByProviderAgentIdWithUserAccess: vi.fn(), + findManyWithUserAccess: vi.fn(), + findByIdWithUserAccessAndDetails: vi.fn(), + create: vi.fn(), + findByIdWithAdminAccess: vi.fn(), + findByIdWithCallAccess: vi.fn(), + delete: vi.fn(), + linkToWorkflowStep: vi.fn(), + })), +})); + +vi.mock("../adapters/PrismaPhoneNumberRepositoryAdapter", () => ({ + PrismaPhoneNumberRepositoryAdapter: vi.fn().mockImplementation(() => ({ + findByPhoneNumberAndUserId: vi.fn(), + findByPhoneNumberAndTeamId: vi.fn(), + findByIdAndUserId: vi.fn(), + findByIdWithTeamAccess: vi.fn(), + createPhoneNumber: vi.fn(), + deletePhoneNumber: vi.fn(), + updateSubscriptionStatus: vi.fn(), + updateAgents: vi.fn(), + })), +})); + +vi.mock("../adapters/PrismaTransactionAdapter", () => ({ + PrismaTransactionAdapter: vi.fn().mockImplementation(() => ({ + executeInTransaction: vi.fn(), + })), +})); + +describe("RetellAIPhoneServiceProviderFactory", () => { + let factory: RetellAIPhoneServiceProviderFactory; + + beforeEach(() => { + vi.clearAllMocks(); + factory = new RetellAIPhoneServiceProviderFactory(); + }); + + describe("create", () => { + it("should create provider with logger when logging enabled", () => { + const config: AIPhoneServiceProviderConfig = { + enableLogging: true, + }; + + const provider = factory.create(config); + + expect(logger.getSubLogger).toHaveBeenCalledWith({ prefix: ["RetellAIPhoneServiceProvider:"] }); + const mockLoggerInstance = (logger.getSubLogger as any).mock.results[0].value; + expect(RetellSDKClient).toHaveBeenCalledWith(mockLoggerInstance); + expect(provider).toBeDefined(); + }); + + it("should create provider without logger when logging disabled", () => { + const config: AIPhoneServiceProviderConfig = { + enableLogging: false, + }; + + const provider = factory.create(config); + + expect(logger.getSubLogger).not.toHaveBeenCalled(); + expect(RetellSDKClient).toHaveBeenCalledWith(undefined); + expect(provider).toBeDefined(); + }); + }); + + describe("createWithConfig static method", () => { + it("should use custom logger when provided", () => { + const customLogger = { + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + } as unknown as ReturnType; + + const provider = RetellAIPhoneServiceProviderFactory.createWithConfig({ + enableLogging: true, + logger: customLogger, + }); + + expect(logger.getSubLogger).not.toHaveBeenCalled(); + expect(RetellSDKClient).toHaveBeenCalledWith(customLogger); + expect(provider).toBeDefined(); + }); + }); + + describe("dependency injection", () => { + it("should inject SDK client into provider", () => { + factory.create({}); + + const clientInstance = (RetellSDKClient as any).mock.results[0].value; + expect(RetellAIPhoneServiceProvider).toHaveBeenCalledWith( + clientInstance, + expect.any(Object), // PrismaAgentRepositoryAdapter + expect.any(Object), // PrismaPhoneNumberRepositoryAdapter + expect.any(Object) // PrismaTransactionAdapter + ); + }); + }); +}); diff --git a/packages/features/calAIPhone/providers/retellAI/RetellAIPhoneServiceProviderFactory.ts b/packages/features/calAIPhone/providers/retellAI/RetellAIPhoneServiceProviderFactory.ts new file mode 100644 index 0000000000..de96255eaf --- /dev/null +++ b/packages/features/calAIPhone/providers/retellAI/RetellAIPhoneServiceProviderFactory.ts @@ -0,0 +1,58 @@ +import logger from "@calcom/lib/logger"; + +import type { + AIPhoneServiceProvider, + AIPhoneServiceProviderFactory, + AIPhoneServiceProviderConfig, + AIPhoneServiceProviderType, +} from "../../interfaces/AIPhoneService.interface"; +import { PrismaAgentRepositoryAdapter } from "../adapters/PrismaAgentRepositoryAdapter"; +import { PrismaPhoneNumberRepositoryAdapter } from "../adapters/PrismaPhoneNumberRepositoryAdapter"; +import { PrismaTransactionAdapter } from "../adapters/PrismaTransactionAdapter"; +import { RetellAIPhoneServiceProvider } from "./RetellAIPhoneServiceProvider"; +import { RetellSDKClient } from "./RetellSDKClient"; + +export class RetellAIPhoneServiceProviderFactory + implements AIPhoneServiceProviderFactory +{ + create(config: AIPhoneServiceProviderConfig): AIPhoneServiceProvider { + const log = + config.enableLogging !== false + ? logger.getSubLogger({ prefix: ["RetellAIPhoneServiceProvider:"] }) + : undefined; + + const sdkClient = new RetellSDKClient(log); + const agentRepository = new PrismaAgentRepositoryAdapter(); + const phoneNumberRepository = new PrismaPhoneNumberRepositoryAdapter(); + const transactionManager = new PrismaTransactionAdapter(); + + return new RetellAIPhoneServiceProvider( + sdkClient, + agentRepository, + phoneNumberRepository, + transactionManager + ); + } + + static createWithConfig(config?: { + enableLogging?: boolean; + logger?: ReturnType; + }): AIPhoneServiceProvider { + const enableLogging = config?.enableLogging ?? true; + const log = enableLogging + ? config?.logger || logger.getSubLogger({ prefix: ["RetellAIPhoneServiceProvider:"] }) + : undefined; + + const sdkClient = new RetellSDKClient(log); + const agentRepository = new PrismaAgentRepositoryAdapter(); + const phoneNumberRepository = new PrismaPhoneNumberRepositoryAdapter(); + const transactionManager = new PrismaTransactionAdapter(); + + return new RetellAIPhoneServiceProvider( + sdkClient, + agentRepository, + phoneNumberRepository, + transactionManager + ); + } +} diff --git a/packages/features/calAIPhone/providers/retellAI/RetellAIService.test.ts b/packages/features/calAIPhone/providers/retellAI/RetellAIService.test.ts new file mode 100644 index 0000000000..68554f5a29 --- /dev/null +++ b/packages/features/calAIPhone/providers/retellAI/RetellAIService.test.ts @@ -0,0 +1,1027 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +import { PhoneNumberSubscriptionStatus } from "@calcom/prisma/enums"; + +import type { AgentRepositoryInterface } from "../interfaces/AgentRepositoryInterface"; +import type { PhoneNumberRepositoryInterface } from "../interfaces/PhoneNumberRepositoryInterface"; +import type { TransactionInterface } from "../interfaces/TransactionInterface"; +import { RetellAIService } from "./RetellAIService"; +import { RetellAIError } from "./errors"; +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(), +})); + +vi.mock("@calcom/app-store/stripepayment/lib/utils", () => ({ + getPhoneNumberMonthlyPriceId: vi.fn(), +})); + +vi.mock("@calcom/features/ee/payments/server/stripe", () => ({ + default: { + checkout: { + sessions: { + create: vi.fn(), + }, + }, + subscriptions: { + cancel: vi.fn(), + }, + }, +})); + +vi.mock("@calcom/features/ee/billing/credit-service", () => ({ + CreditService: vi.fn().mockImplementation(() => ({ + getAllCredits: vi.fn(), + })), +})); + +vi.mock("@calcom/lib/checkRateLimitAndThrowError", () => ({ + checkRateLimitAndThrowError: vi.fn(), +})); + +// Mock Prisma client with transaction support +vi.mock("@calcom/prisma", () => ({ + default: { + $transaction: vi.fn(), + calAiPhoneNumber: { + create: vi.fn(), + }, + }, +})); + +describe("RetellAIService", () => { + let service: RetellAIService; + let mockRepository: RetellAIRepository & { [K in keyof RetellAIRepository]: vi.Mock }; + let mockAgentRepository: AgentRepositoryInterface; + let mockPhoneNumberRepository: PhoneNumberRepositoryInterface; + let mockTransactionManager: TransactionInterface; + let mockTransaction: vi.Mock; + + beforeEach(async () => { + vi.clearAllMocks(); + const repository = { + createLLM: vi.fn(), + getLLM: vi.fn(), + updateLLM: vi.fn(), + deleteLLM: vi.fn(), + createAgent: vi.fn(), + getAgent: vi.fn(), + updateAgent: vi.fn(), + deleteAgent: vi.fn(), + createPhoneNumber: vi.fn(), + importPhoneNumber: vi.fn(), + deletePhoneNumber: vi.fn(), + getPhoneNumber: vi.fn(), + updatePhoneNumber: vi.fn(), + createPhoneCall: vi.fn(), + }; + mockRepository = repository as unknown as RetellAIRepository; + + // Mock agent repository + const agentRepository = { + canManageTeamResources: vi.fn(), + findByIdWithUserAccess: vi.fn(), + findByProviderAgentIdWithUserAccess: vi.fn(), + findManyWithUserAccess: vi.fn(), + findByIdWithUserAccessAndDetails: vi.fn(), + create: vi.fn(), + findByIdWithAdminAccess: vi.fn(), + findByIdWithCallAccess: vi.fn(), + delete: vi.fn(), + linkToWorkflowStep: vi.fn(), + }; + mockAgentRepository = agentRepository as unknown as AgentRepositoryInterface; + + // Mock phone number repository + const phoneNumberRepository = { + findByPhoneNumberAndUserId: vi.fn(), + findByPhoneNumberAndTeamId: vi.fn(), + findByIdAndUserId: vi.fn(), + findByIdWithTeamAccess: vi.fn(), + createPhoneNumber: vi.fn(), + deletePhoneNumber: vi.fn(), + updateSubscriptionStatus: vi.fn(), + updateAgents: vi.fn(), + }; + mockPhoneNumberRepository = phoneNumberRepository as unknown as PhoneNumberRepositoryInterface; + + // Mock transaction manager + const transactionManager = { + executeInTransaction: vi.fn(), + }; + mockTransactionManager = transactionManager as unknown as TransactionInterface; + + // Get reference to the mocked prisma and its transaction method + const prisma = (await import("@calcom/prisma")).default; + mockTransaction = prisma.$transaction as vi.Mock; + + // Reset transaction mock to simulate successful transaction by default + mockTransaction.mockImplementation(async (callback) => { + const mockTx = { + calAiPhoneNumber: { + create: vi.fn().mockResolvedValue({}), + }, + }; + return callback(mockTx); + }); + + // Mock transaction manager to call the callback directly + mockTransactionManager.executeInTransaction.mockImplementation(async (callback) => { + const mockContext = { + phoneNumberRepository: { + createPhoneNumber: vi.fn().mockResolvedValue({}), + }, + }; + return await callback(mockContext); + }); + + service = new RetellAIService( + mockRepository, + mockAgentRepository, + mockPhoneNumberRepository, + mockTransactionManager + ); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe("setupAIConfiguration", () => { + it("should create LLM and agent with minimal configuration", async () => { + const mockLLM = { llm_id: "llm-123" }; + const mockAgent = { agent_id: "agent-123" }; + mockRepository.createLLM.mockResolvedValue(mockLLM); + mockRepository.createAgent.mockResolvedValue(mockAgent); + + const result = await service.setupAIConfiguration({}); + + expect(result).toEqual({ llmId: "llm-123", agentId: "agent-123" }); + expect(mockRepository.createLLM).toHaveBeenCalledWith( + expect.objectContaining({ + general_tools: expect.arrayContaining([ + { + type: "end_call", + name: "end_call", + description: expect.any(String), + }, + ]), + }) + ); + }); + + it("should include Cal.com tools when API key and eventTypeId are provided", async () => { + const mockLLM = { llm_id: "llm-123" }; + const mockAgent = { agent_id: "agent-123" }; + + mockRepository.createLLM.mockResolvedValue(mockLLM); + mockRepository.createAgent.mockResolvedValue(mockAgent); + + await service.setupAIConfiguration({ + calApiKey: "cal-key", + eventTypeId: 123, + timeZone: "UTC", + }); + + expect(mockRepository.createLLM).toHaveBeenCalledWith( + expect.objectContaining({ + general_tools: expect.arrayContaining([ + expect.objectContaining({ type: "check_availability_cal" }), + expect.objectContaining({ type: "book_appointment_cal" }), + ]), + }) + ); + }); + }); + + describe("deleteAIConfiguration", () => { + it("should handle successful deletion of both LLM and agent", async () => { + mockRepository.deleteAgent.mockResolvedValue(undefined); + mockRepository.deleteLLM.mockResolvedValue(undefined); + + const result = await service.deleteAIConfiguration({ + llmId: "llm-123", + agentId: "agent-123", + }); + + expect(result).toEqual({ + success: true, + errors: [], + deleted: { llm: true, agent: true }, + }); + }); + + it("should handle 404 errors gracefully", async () => { + mockRepository.deleteAgent.mockRejectedValue(new RetellAIError("Agent not found", "deleteAgent")); + mockRepository.deleteLLM.mockRejectedValue(new RetellAIError("LLM not found", "deleteLLM")); + + const result = await service.deleteAIConfiguration({ + llmId: "llm-123", + agentId: "agent-123", + }); + + expect(result).toEqual({ + success: false, + errors: ["Agent not found", "LLM not found"], + deleted: { llm: false, agent: false }, + }); + }); + + it("should handle partial deletion failure", async () => { + mockRepository.deleteAgent.mockResolvedValue(undefined); + mockRepository.deleteLLM.mockRejectedValue(new Error("Network error")); + + const result = await service.deleteAIConfiguration({ + llmId: "llm-123", + agentId: "agent-123", + }); + + expect(result).toEqual({ + success: false, + errors: ["Network error"], + deleted: { llm: false, agent: true }, + }); + }); + }); + + describe("deletePhoneNumber", () => { + it("should throw error if phone number is active", async () => { + mockPhoneNumberRepository.findByPhoneNumberAndUserId.mockResolvedValue({ + id: 1, + phoneNumber: "+1234567890", + subscriptionStatus: PhoneNumberSubscriptionStatus.ACTIVE, + stripeSubscriptionId: null, + stripeCustomerId: null, + userId: 1, + teamId: null, + provider: null, + inboundAgentId: null, + outboundAgentId: null, + createdAt: new Date(), + updatedAt: new Date(), + }); + + await expect( + service.deletePhoneNumber({ + phoneNumber: "+1234567890", + userId: 1, + deleteFromDB: true, + }) + ).rejects.toThrow("Phone number is still active"); + }); + + it("should throw error if phone number is cancelled", async () => { + mockPhoneNumberRepository.findByPhoneNumberAndUserId.mockResolvedValue({ + id: 1, + phoneNumber: "+1234567890", + subscriptionStatus: PhoneNumberSubscriptionStatus.CANCELLED, + stripeSubscriptionId: null, + stripeCustomerId: null, + userId: 1, + teamId: null, + provider: null, + inboundAgentId: null, + outboundAgentId: null, + createdAt: new Date(), + updatedAt: new Date(), + }); + + await expect( + service.deletePhoneNumber({ + phoneNumber: "+1234567890", + userId: 1, + deleteFromDB: true, + }) + ).rejects.toThrow("Phone number is already cancelled"); + }); + + it("should delete from both DB and provider when deleteFromDB is true", async () => { + mockPhoneNumberRepository.findByPhoneNumberAndUserId.mockResolvedValue({ + id: 1, + phoneNumber: "+1234567890", + subscriptionStatus: PhoneNumberSubscriptionStatus.INCOMPLETE, + stripeSubscriptionId: null, + stripeCustomerId: null, + userId: 1, + teamId: null, + provider: null, + inboundAgentId: null, + outboundAgentId: null, + createdAt: new Date(), + updatedAt: new Date(), + }); + + await service.deletePhoneNumber({ + phoneNumber: "+1234567890", + userId: 1, + deleteFromDB: true, + }); + + expect(mockPhoneNumberRepository.deletePhoneNumber).toHaveBeenCalledWith({ + phoneNumber: "+1234567890", + }); + expect(mockRepository.deletePhoneNumber).toHaveBeenCalledWith("+1234567890"); + }); + }); + + describe("importPhoneNumber", () => { + it("should import phone number and create DB record using transaction", async () => { + const mockImportedNumber = { phone_number: "+1234567890" }; + mockRepository.importPhoneNumber.mockResolvedValue(mockImportedNumber); + + const result = await service.importPhoneNumber({ + phone_number: "+1234567890", + termination_uri: "https://example.com", + sip_trunk_auth_username: "user", + sip_trunk_auth_password: "pass", + userId: 1, + }); + + expect(result).toEqual(mockImportedNumber); + expect(mockTransactionManager.executeInTransaction).toHaveBeenCalled(); + expect(mockRepository.importPhoneNumber).toHaveBeenCalledWith({ + phone_number: "+1234567890", + termination_uri: "https://example.com", + sip_trunk_auth_username: "user", + sip_trunk_auth_password: "pass", + nickname: undefined, + }); + }); + + it("should import phone number and assign to agent if agentId provided", async () => { + const mockImportedNumber = { phone_number: "+1234567890" }; + mockRepository.importPhoneNumber.mockResolvedValue(mockImportedNumber); + + mockAgentRepository.findByIdWithUserAccess.mockResolvedValue({ + id: "agent-123", + name: "Test Agent", + providerAgentId: "retell-agent-456", + enabled: true, + userId: 1, + teamId: null, + createdAt: new Date(), + updatedAt: new Date(), + }); + + const result = await service.importPhoneNumber({ + phone_number: "+1234567890", + termination_uri: "https://example.com", + sip_trunk_auth_username: "user", + sip_trunk_auth_password: "pass", + userId: 1, + agentId: "agent-123", + }); + + expect(result).toEqual(mockImportedNumber); + expect(mockAgentRepository.findByIdWithUserAccess).toHaveBeenCalledWith({ + agentId: "agent-123", + userId: 1, + }); + expect(mockTransactionManager.executeInTransaction).toHaveBeenCalled(); + expect(mockRepository.updatePhoneNumber).toHaveBeenCalledWith("+1234567890", { + outbound_agent_id: "retell-agent-456", + }); + }); + + it("should throw error when agent not found during import", async () => { + mockAgentRepository.findByIdWithUserAccess.mockResolvedValue(null); + + await expect( + service.importPhoneNumber({ + phone_number: "+1234567890", + termination_uri: "https://example.com", + userId: 1, + agentId: "invalid-agent", + }) + ).rejects.toThrow("You don't have permission to use the selected agent."); + + // Verify that the agent permission check was called + expect(mockAgentRepository.findByIdWithUserAccess).toHaveBeenCalledWith({ + agentId: "invalid-agent", + userId: 1, + }); + + // Verify that no repository operations were called after the error + expect(mockRepository.importPhoneNumber).not.toHaveBeenCalled(); + expect(mockTransactionManager.executeInTransaction).not.toHaveBeenCalled(); + }); + + it("should handle transaction rollback when database creation fails with successful cleanup", async () => { + const mockImportedNumber = { phone_number: "+1234567890" }; + mockRepository.importPhoneNumber.mockResolvedValue(mockImportedNumber); + mockRepository.deletePhoneNumber.mockResolvedValue(undefined); + + // Mock transaction manager to simulate database creation failure + mockTransactionManager.executeInTransaction.mockImplementation(async (callback) => { + const mockContext = { + phoneNumberRepository: { + createPhoneNumber: vi.fn().mockRejectedValue(new Error("Database connection failed")), + }, + }; + return await callback(mockContext); + }); + + await expect( + service.importPhoneNumber({ + phone_number: "+1234567890", + termination_uri: "https://example.com", + sip_trunk_auth_username: "user", + sip_trunk_auth_password: "pass", + userId: 1, + }) + ).rejects.toThrow("Database connection failed"); + + // Verify that the phone number was imported from Retell + expect(mockRepository.importPhoneNumber).toHaveBeenCalled(); + + // Verify that cleanup was attempted and succeeded + expect(mockRepository.deletePhoneNumber).toHaveBeenCalledWith("+1234567890"); + }); + + it("should handle compensation failure and throw critical error", async () => { + const mockImportedNumber = { phone_number: "+1234567890" }; + mockRepository.importPhoneNumber.mockResolvedValue(mockImportedNumber); + + // Mock compensation failure + mockRepository.deletePhoneNumber.mockRejectedValue(new Error("Retell API unavailable")); + + // Mock transaction manager to simulate database creation failure + mockTransactionManager.executeInTransaction.mockImplementation(async (callback) => { + const mockContext = { + phoneNumberRepository: { + createPhoneNumber: vi.fn().mockRejectedValue(new Error("Database connection failed")), + }, + }; + return await callback(mockContext); + }); + + await expect( + service.importPhoneNumber({ + phone_number: "+1234567890", + termination_uri: "https://example.com", + sip_trunk_auth_username: "user", + sip_trunk_auth_password: "pass", + userId: 1, + }) + ).rejects.toThrow( + "Failed to cleanup Retell phone number +1234567890 after transaction failure. Manual cleanup required." + ); + + // Verify both operations were attempted + expect(mockRepository.importPhoneNumber).toHaveBeenCalled(); + expect(mockRepository.deletePhoneNumber).toHaveBeenCalledWith("+1234567890"); + }); + }); + + describe("createPhoneCall", () => { + it("should create phone call with dynamic variables", async () => { + const mockCall = { call_id: "call-123" }; + mockRepository.createPhoneCall.mockResolvedValue(mockCall); + + const result = await service.createPhoneCall({ + from_number: "+1234567890", + to_number: "+0987654321", + retell_llm_dynamic_variables: { + name: "John", + email: "john@example.com", + }, + }); + + expect(result).toEqual(mockCall); + expect(mockRepository.createPhoneCall).toHaveBeenCalledWith({ + from_number: "+1234567890", + to_number: "+0987654321", + retell_llm_dynamic_variables: { + name: "John", + email: "john@example.com", + }, + }); + }); + }); + + describe("updateLLMConfiguration", () => { + it("should update LLM configuration", async () => { + const mockUpdatedLLM = { llm_id: "llm-123", general_prompt: "Updated prompt" }; + mockRepository.updateLLM.mockResolvedValue(mockUpdatedLLM); + + const result = await service.updateLLMConfiguration("llm-123", { + general_prompt: "Updated prompt", + begin_message: "Updated message", + }); + + expect(result).toEqual(mockUpdatedLLM); + expect(mockRepository.updateLLM).toHaveBeenCalledWith("llm-123", { + general_prompt: "Updated prompt", + begin_message: "Updated message", + general_tools: null, + }); + }); + }); + + describe("getLLMDetails", () => { + it("should get LLM details", async () => { + const mockLLM = { llm_id: "llm-123", general_prompt: "Test prompt" }; + mockRepository.getLLM.mockResolvedValue(mockLLM); + + const result = await service.getLLMDetails("llm-123"); + + expect(result).toEqual(mockLLM); + expect(mockRepository.getLLM).toHaveBeenCalledWith("llm-123"); + }); + }); + + describe("getAgent", () => { + it("should get agent details", async () => { + const mockAgent = { agent_id: "agent-123", agent_name: "Test Agent" }; + mockRepository.getAgent.mockResolvedValue(mockAgent); + + const result = await service.getAgent("agent-123"); + + expect(result).toEqual(mockAgent); + expect(mockRepository.getAgent).toHaveBeenCalledWith("agent-123"); + }); + }); + + describe("updateAgent", () => { + it("should update agent", async () => { + const mockUpdatedAgent = { agent_id: "agent-123", agent_name: "Updated Agent" }; + mockRepository.updateAgent.mockResolvedValue(mockUpdatedAgent); + + const result = await service.updateAgent("agent-123", { + agent_name: "Updated Agent", + voice_id: "new-voice", + }); + + expect(result).toEqual(mockUpdatedAgent); + expect(mockRepository.updateAgent).toHaveBeenCalledWith("agent-123", { + agent_name: "Updated Agent", + voice_id: "new-voice", + }); + }); + }); + + describe("createPhoneNumber", () => { + it("should create phone number", async () => { + const mockPhoneNumber = { phone_number: "+14155551234" }; + mockRepository.createPhoneNumber.mockResolvedValue(mockPhoneNumber); + + const result = await service.createPhoneNumber({ + area_code: 415, + nickname: "Test Phone", + }); + + expect(result).toEqual(mockPhoneNumber); + expect(mockRepository.createPhoneNumber).toHaveBeenCalledWith({ + area_code: 415, + nickname: "Test Phone", + }); + }); + }); + + describe("getPhoneNumber", () => { + it("should get phone number", async () => { + const mockPhoneNumber = { phone_number: "+14155551234" }; + mockRepository.getPhoneNumber.mockResolvedValue(mockPhoneNumber); + + const result = await service.getPhoneNumber("+14155551234"); + + expect(result).toEqual(mockPhoneNumber); + expect(mockRepository.getPhoneNumber).toHaveBeenCalledWith("+14155551234"); + }); + }); + + describe("updatePhoneNumber", () => { + it("should update phone number", async () => { + const mockUpdatedNumber = { phone_number: "+14155551234" }; + mockRepository.updatePhoneNumber.mockResolvedValue(mockUpdatedNumber); + + const result = await service.updatePhoneNumber("+14155551234", { + inbound_agent_id: "inbound-123", + outbound_agent_id: "outbound-123", + }); + + expect(result).toEqual(mockUpdatedNumber); + expect(mockRepository.updatePhoneNumber).toHaveBeenCalledWith("+14155551234", { + inbound_agent_id: "inbound-123", + outbound_agent_id: "outbound-123", + }); + }); + }); + + describe("generatePhoneNumberCheckoutSession", () => { + it("should generate checkout session successfully", async () => { + const { getStripeCustomerIdFromUserId } = await import("@calcom/app-store/stripepayment/lib/customer"); + const { getPhoneNumberMonthlyPriceId } = await import("@calcom/app-store/stripepayment/lib/utils"); + const stripe = (await import("@calcom/features/ee/payments/server/stripe")).default; + + (getPhoneNumberMonthlyPriceId as any).mockReturnValue("price_123"); + (getStripeCustomerIdFromUserId as any).mockResolvedValue("cus_123"); + (stripe.checkout.sessions.create as any).mockResolvedValue({ + url: "https://checkout.stripe.com/session", + }); + + const result = await service.generatePhoneNumberCheckoutSession({ + userId: 1, + teamId: 2, + agentId: "agent-123", + }); + + expect(result).toEqual({ + url: "https://checkout.stripe.com/session", + message: "Payment required to purchase phone number", + }); + }); + + it("should throw error if price ID not configured", async () => { + const { getPhoneNumberMonthlyPriceId } = await import("@calcom/app-store/stripepayment/lib/utils"); + (getPhoneNumberMonthlyPriceId as any).mockReturnValue(null); + + await expect( + service.generatePhoneNumberCheckoutSession({ + userId: 1, + }) + ).rejects.toThrow("Phone number price ID not configured"); + }); + }); + + describe("cancelPhoneNumberSubscription", () => { + it("should cancel subscription successfully", async () => { + const stripe = (await import("@calcom/features/ee/payments/server/stripe")).default; + + mockPhoneNumberRepository.findByIdAndUserId.mockResolvedValue({ + id: 1, + phoneNumber: "+14155551234", + stripeSubscriptionId: "sub_123", + stripeCustomerId: null, + subscriptionStatus: null, + userId: 1, + teamId: null, + provider: null, + inboundAgentId: null, + outboundAgentId: null, + createdAt: new Date(), + updatedAt: new Date(), + }); + (stripe.subscriptions.cancel as any).mockResolvedValue({}); + + const result = await service.cancelPhoneNumberSubscription({ + phoneNumberId: 1, + userId: 1, + }); + + expect(result).toEqual({ + success: true, + message: "Phone number subscription cancelled successfully.", + }); + expect(mockPhoneNumberRepository.updateSubscriptionStatus).toHaveBeenCalledWith({ + id: 1, + subscriptionStatus: PhoneNumberSubscriptionStatus.CANCELLED, + disconnectOutboundAgent: true, + }); + }); + }); + + describe("updatePhoneNumberWithAgents", () => { + it("should update phone number with agents", async () => { + mockPhoneNumberRepository.findByPhoneNumberAndUserId.mockResolvedValue({ + id: 1, + phoneNumber: "+14155551234", + stripeSubscriptionId: null, + stripeCustomerId: null, + subscriptionStatus: null, + userId: 1, + teamId: null, + provider: null, + inboundAgentId: null, + outboundAgentId: null, + createdAt: new Date(), + updatedAt: new Date(), + }); + mockAgentRepository.findByProviderAgentIdWithUserAccess.mockResolvedValue({ + id: "agent-123", + name: "Test Agent", + providerAgentId: "retell-agent-456", + enabled: true, + userId: 1, + teamId: null, + createdAt: new Date(), + updatedAt: new Date(), + }); + mockRepository.getPhoneNumber.mockResolvedValue({ phone_number: "+14155551234" }); + + const result = await service.updatePhoneNumberWithAgents({ + phoneNumber: "+14155551234", + userId: 1, + inboundAgentId: "inbound-123", + outboundAgentId: "outbound-123", + }); + + expect(result).toEqual({ message: "Phone number updated successfully" }); + expect(mockRepository.updatePhoneNumber).toHaveBeenCalled(); + expect(mockPhoneNumberRepository.updateAgents).toHaveBeenCalled(); + }); + }); + + describe("listAgents", () => { + it("should list agents with user access", async () => { + const mockAgents = [ + { + id: "1", + name: "Agent 1", + providerAgentId: "retell-1", + enabled: true, + userId: 1, + teamId: null, + createdAt: new Date(), + updatedAt: new Date(), + user: { id: 1, name: "Test User", email: "test@example.com" }, + team: null, + outboundPhoneNumbers: [], + }, + ]; + + mockAgentRepository.findManyWithUserAccess.mockResolvedValue(mockAgents); + + const result = await service.listAgents({ + userId: 1, + scope: "all", + }); + + expect(result.totalCount).toBe(1); + expect(result.filtered).toHaveLength(1); + expect(mockAgentRepository.findManyWithUserAccess).toHaveBeenCalledWith({ + userId: 1, + teamId: undefined, + scope: "all", + }); + }); + }); + + describe("createAgent", () => { + it("should create agent successfully", async () => { + mockRepository.createLLM.mockResolvedValue({ llm_id: "llm-123" }); + mockRepository.createAgent.mockResolvedValue({ agent_id: "agent-123" }); + mockAgentRepository.create.mockResolvedValue({ + id: "db-agent-123", + name: "Test Agent", + providerAgentId: "agent-123", + enabled: true, + userId: 1, + teamId: null, + createdAt: new Date(), + updatedAt: new Date(), + }); + + const result = await service.createAgent({ + name: "Test Agent", + userId: 1, + userTimeZone: "America/New_York", + }); + + expect(result).toEqual({ + id: "db-agent-123", + providerAgentId: "agent-123", + message: "Agent created successfully", + }); + }); + }); + + describe("deleteAgent", () => { + it("should delete agent successfully", async () => { + mockAgentRepository.findByIdWithAdminAccess.mockResolvedValue({ + id: "1", + name: "Test Agent", + providerAgentId: "agent-123", + enabled: true, + userId: 1, + teamId: null, + createdAt: new Date(), + updatedAt: new Date(), + }); + mockRepository.getAgent.mockResolvedValue({ + agent_id: "agent-123", + response_engine: { type: "retell-llm", llm_id: "llm-123" }, + }); + + const result = await service.deleteAgent({ + id: "1", + userId: 1, + }); + + expect(result).toEqual({ message: "Agent deleted successfully" }); + expect(mockAgentRepository.delete).toHaveBeenCalledWith({ id: "1" }); + }); + }); + + describe("createTestCall", () => { + it("should create test call successfully with sufficient credits", async () => { + const { CreditService } = await import("@calcom/features/ee/billing/credit-service"); + const { checkRateLimitAndThrowError } = await import("@calcom/lib/checkRateLimitAndThrowError"); + + // Mock credit service to return sufficient credits + const mockGetAllCredits = vi.fn().mockResolvedValue({ + totalRemainingMonthlyCredits: 10, + additionalCredits: 5, + }); + (CreditService as any).mockImplementation(() => ({ + getAllCredits: mockGetAllCredits, + })); + + (checkRateLimitAndThrowError as any).mockResolvedValue(undefined); + mockAgentRepository.findByIdWithCallAccess.mockResolvedValue({ + id: "1", + name: "Test Agent", + providerAgentId: "agent-123", + enabled: true, + userId: 1, + teamId: null, + createdAt: new Date(), + updatedAt: new Date(), + outboundPhoneNumbers: [{ phoneNumber: "+14155551234" }], + }); + mockRepository.createPhoneCall.mockResolvedValue({ + call_id: "call-123", + call_status: "initiated", + }); + + const result = await service.createTestCall({ + agentId: "1", + phoneNumber: "+14155555678", + userId: 1, + teamId: 2, + }); + + expect(mockGetAllCredits).toHaveBeenCalledWith({ + userId: 1, + teamId: 2, + }); + expect(checkRateLimitAndThrowError).toHaveBeenCalledWith({ + rateLimitingType: "core", + identifier: "test-call:1", + }); + expect(result).toEqual({ + callId: "call-123", + status: "initiated", + message: "Call initiated to +14155555678 with call_id call-123", + }); + }); + + it("should throw error if insufficient credits", async () => { + const { CreditService } = await import("@calcom/features/ee/billing/credit-service"); + + // Mock credit service to return insufficient credits + const mockGetAllCredits = vi.fn().mockResolvedValue({ + totalRemainingMonthlyCredits: 2, + additionalCredits: 1, + }); + (CreditService as any).mockImplementation(() => ({ + getAllCredits: mockGetAllCredits, + })); + + await expect( + service.createTestCall({ + agentId: "1", + phoneNumber: "+14155555678", + userId: 1, + }) + ).rejects.toThrow( + "Insufficient credits to make test call. Need 5 credits, have 3. Please purchase more credits." + ); + + expect(mockGetAllCredits).toHaveBeenCalledWith({ + userId: 1, + teamId: undefined, + }); + }); + + it("should handle null/undefined credits gracefully", async () => { + const { CreditService } = await import("@calcom/features/ee/billing/credit-service"); + + // Mock credit service to return null credits + const mockGetAllCredits = vi.fn().mockResolvedValue(null); + (CreditService as any).mockImplementation(() => ({ + getAllCredits: mockGetAllCredits, + })); + + await expect( + service.createTestCall({ + agentId: "1", + phoneNumber: "+14155555678", + userId: 1, + }) + ).rejects.toThrow( + "Insufficient credits to make test call. Need 5 credits, have 0. Please purchase more credits." + ); + }); + + it("should throw error if no phone number provided", async () => { + const { CreditService } = await import("@calcom/features/ee/billing/credit-service"); + const { checkRateLimitAndThrowError } = await import("@calcom/lib/checkRateLimitAndThrowError"); + + // Mock sufficient credits to get past credit check + const mockGetAllCredits = vi.fn().mockResolvedValue({ + totalRemainingMonthlyCredits: 10, + additionalCredits: 0, + }); + (CreditService as any).mockImplementation(() => ({ + getAllCredits: mockGetAllCredits, + })); + + (checkRateLimitAndThrowError as any).mockResolvedValue(undefined); + + await expect( + service.createTestCall({ + agentId: "1", + userId: 1, + }) + ).rejects.toThrow("Phone number is required for test call"); + }); + + 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 mockGetAllCredits = vi.fn().mockResolvedValue({ + totalRemainingMonthlyCredits: 10, + additionalCredits: 0, + }); + (CreditService as any).mockImplementation(() => ({ + getAllCredits: mockGetAllCredits, + })); + + (checkRateLimitAndThrowError as any).mockResolvedValue(undefined); + (PrismaAgentRepository.findByIdWithCallAccess as any).mockResolvedValue(null); + + await expect( + service.createTestCall({ + agentId: "1", + phoneNumber: "+14155555678", + userId: 1, + }) + ).rejects.toThrow("Agent not found or you don't have permission to use it."); + }); + + it("should throw error if agent has no phone numbers", async () => { + const { CreditService } = await import("@calcom/features/ee/billing/credit-service"); + const { checkRateLimitAndThrowError } = await import("@calcom/lib/checkRateLimitAndThrowError"); + + // Mock sufficient credits + const mockGetAllCredits = vi.fn().mockResolvedValue({ + totalRemainingMonthlyCredits: 10, + additionalCredits: 0, + }); + (CreditService as any).mockImplementation(() => ({ + getAllCredits: mockGetAllCredits, + })); + + (checkRateLimitAndThrowError as any).mockResolvedValue(undefined); + mockAgentRepository.findByIdWithCallAccess.mockResolvedValue({ + id: "1", + name: "Test Agent", + providerAgentId: "agent-123", + enabled: true, + userId: 1, + teamId: null, + createdAt: new Date(), + updatedAt: new Date(), + outboundPhoneNumbers: [], + }); + + await expect( + service.createTestCall({ + agentId: "1", + phoneNumber: "+14155555678", + userId: 1, + }) + ).rejects.toThrow("Agent must have a phone number assigned to make calls."); + }); + }); +}); diff --git a/packages/features/calAIPhone/providers/retellAI/RetellAIService.ts b/packages/features/calAIPhone/providers/retellAI/RetellAIService.ts new file mode 100644 index 0000000000..66b3b35c9d --- /dev/null +++ b/packages/features/calAIPhone/providers/retellAI/RetellAIService.ts @@ -0,0 +1,209 @@ +import type { + AIPhoneServiceUpdateModelParams, + AIPhoneServiceCreatePhoneNumberParams, + AIPhoneServiceImportPhoneNumberParamsExtended, + AIPhoneServiceProviderType, +} from "../../interfaces/AIPhoneService.interface"; +import type { AgentRepositoryInterface } from "../interfaces/AgentRepositoryInterface"; +import type { PhoneNumberRepositoryInterface } from "../interfaces/PhoneNumberRepositoryInterface"; +import type { TransactionInterface } from "../interfaces/TransactionInterface"; +import { AIConfigurationService } from "./services/AIConfigurationService"; +import { AgentService } from "./services/AgentService"; +import { BillingService } from "./services/BillingService"; +import { CallService } from "./services/CallService"; +import { PhoneNumberService } from "./services/PhoneNumberService"; +import type { + RetellLLM, + RetellCall, + RetellAgent, + RetellPhoneNumber, + RetellDynamicVariables, + AIConfigurationSetup, + AIConfigurationDeletion, + DeletionResult, + RetellAIRepository, + RetellLLMGeneralTools, + Language, +} from "./types"; + +export class RetellAIService { + private aiConfigurationService: AIConfigurationService; + private agentService: AgentService; + private billingService: BillingService; + private callService: CallService; + private phoneNumberService: PhoneNumberService; + + constructor( + private repository: RetellAIRepository, + private agentRepository: AgentRepositoryInterface, + 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, + agentRepository, + phoneNumberRepository, + transactionManager + ); + } + + async setupAIConfiguration(config: AIConfigurationSetup): Promise<{ llmId: string; agentId: string }> { + return this.aiConfigurationService.setupAIConfiguration(config); + } + + async deleteAIConfiguration(config: AIConfigurationDeletion): Promise { + return this.aiConfigurationService.deleteAIConfiguration(config); + } + + async updateLLMConfiguration( + llmId: string, + data: AIPhoneServiceUpdateModelParams + ): Promise { + return this.aiConfigurationService.updateLLMConfiguration(llmId, data); + } + + async getLLMDetails(llmId: string): Promise { + return this.aiConfigurationService.getLLMDetails(llmId); + } + + async importPhoneNumber(data: AIPhoneServiceImportPhoneNumberParamsExtended): Promise { + return this.phoneNumberService.importPhoneNumber(data); + } + + async createPhoneNumber(data: AIPhoneServiceCreatePhoneNumberParams): Promise { + return this.phoneNumberService.createPhoneNumber(data); + } + + async deletePhoneNumber(params: { + phoneNumber: string; + userId: number; + teamId?: number; + deleteFromDB: boolean; + }): Promise { + return this.phoneNumberService.deletePhoneNumber(params); + } + + async getPhoneNumber(phoneNumber: string): Promise { + return this.phoneNumberService.getPhoneNumber(phoneNumber); + } + + async updatePhoneNumber( + phoneNumber: string, + data: { inbound_agent_id?: string | null; outbound_agent_id?: string | null } + ): Promise { + return this.phoneNumberService.updatePhoneNumber(phoneNumber, data); + } + + async updatePhoneNumberWithAgents(params: { + phoneNumber: string; + userId: number; + teamId?: number; + inboundAgentId?: string | null; + outboundAgentId?: string | null; + }) { + return this.phoneNumberService.updatePhoneNumberWithAgents(params); + } + + async getAgent(agentId: string): Promise { + return this.agentService.getAgent(agentId); + } + + async updateAgent( + agentId: string, + data: { + agent_name?: string | null; + voice_id?: string; + language?: Language; + responsiveness?: number; + interruption_sensitivity?: number; + } + ): Promise { + return this.agentService.updateAgent(agentId, data); + } + + async listAgents(params: { userId: number; teamId?: number; scope?: "personal" | "team" | "all" }) { + return this.agentService.listAgents(params); + } + + async getAgentWithDetails(params: { id: string; userId: number; teamId?: number }) { + return this.agentService.getAgentWithDetails(params); + } + + async createAgent(params: { + name?: string; + userId: number; + teamId?: number; + workflowStepId?: number; + generalPrompt?: string; + beginMessage?: string; + generalTools?: RetellLLMGeneralTools; + userTimeZone: string; + }) { + return this.agentService.createAgent({ + ...params, + setupAIConfiguration: () => + this.setupAIConfiguration({ + calApiKey: undefined, + timeZone: params.userTimeZone, + eventTypeId: undefined, + generalPrompt: params.generalPrompt, + beginMessage: params.beginMessage, + generalTools: params.generalTools, + }), + }); + } + + async updateAgentConfiguration(params: { + id: string; + userId: number; + name?: string; + generalPrompt?: string | null; + beginMessage?: string | null; + generalTools?: RetellLLMGeneralTools; + voiceId?: string; + }) { + return this.agentService.updateAgentConfiguration({ + ...params, + updateLLMConfiguration: ( + llmId: string, + data: AIPhoneServiceUpdateModelParams + ) => this.updateLLMConfiguration(llmId, data), + }); + } + + async deleteAgent(params: { id: string; userId: number; teamId?: number }) { + return this.agentService.deleteAgent({ + ...params, + deleteAIConfiguration: (config) => this.deleteAIConfiguration(config), + }); + } + + async createPhoneCall(data: { + from_number: string; + to_number: string; + retell_llm_dynamic_variables?: RetellDynamicVariables; + }): Promise { + return this.callService.createPhoneCall(data); + } + + async createTestCall(params: { agentId: string; phoneNumber?: string; userId: number; teamId?: number }) { + return this.callService.createTestCall(params); + } + + async generatePhoneNumberCheckoutSession(params: { + userId: number; + teamId?: number; + agentId?: string | null; + workflowId?: string; + }) { + return this.billingService.generatePhoneNumberCheckoutSession(params); + } + + async cancelPhoneNumberSubscription(params: { phoneNumberId: number; userId: number; teamId?: number }) { + return this.billingService.cancelPhoneNumberSubscription(params); + } +} diff --git a/packages/features/calAIPhone/providers/retellAI/RetellAIServiceMapper.ts b/packages/features/calAIPhone/providers/retellAI/RetellAIServiceMapper.ts new file mode 100644 index 0000000000..24ddb200a5 --- /dev/null +++ b/packages/features/calAIPhone/providers/retellAI/RetellAIServiceMapper.ts @@ -0,0 +1,209 @@ +/** + * Service layer mappers for RetellAI + * Maps between service/interface types and internal Retell types + */ +import type { + AIPhoneServiceConfiguration, + AIPhoneServiceUpdateModelParams, + AIPhoneServiceTools, + AIPhoneServiceProviderType, +} from "../../interfaces/AIPhoneService.interface"; +import type { + RetellLLMGeneralTools, + UpdateLLMRequest, + CreateLLMRequest, + CreateAgentRequest, + UpdateAgentRequest, + RetellAgentWithDetails, + Language, + Agent, + RetellAgent, + RetellLLM, +} from "./types"; + +export class RetellAIServiceMapper { + /** + * Maps AI configuration to Retell tools format + */ + static buildGeneralTools( + config: AIPhoneServiceConfiguration + ): NonNullable { + const tools: NonNullable = [ + { + type: "end_call", + name: "end_call", + description: "Hang up the call, triggered only after appointment successfully scheduled.", + }, + ]; + + // Add calendar tools if configured + if (config.calApiKey && config.eventTypeId && config.timeZone) { + tools.push( + { + type: "check_availability_cal" as const, + name: "check_availability", + cal_api_key: config.calApiKey, + event_type_id: config.eventTypeId, + timezone: config.timeZone, + }, + { + type: "book_appointment_cal" as const, + name: "book_appointment", + cal_api_key: config.calApiKey, + event_type_id: config.eventTypeId, + timezone: config.timeZone, + } + ); + } + + // Add any additional tools from config + if (config.generalTools) { + tools.push(...config.generalTools); + } + + return tools; + } + + /** + * Maps AI configuration to CreateLLMRequest + */ + static mapToCreateLLMRequest( + config: AIPhoneServiceConfiguration, + generalTools: RetellLLMGeneralTools + ): CreateLLMRequest { + return { + general_prompt: config.generalPrompt, + begin_message: config.beginMessage, + general_tools: generalTools, + }; + } + + /** + * Maps AI configuration to CreateAgentRequest + */ + static mapToCreateAgentRequest( + llmId: string, + eventTypeId?: number, + voiceId = "11labs-Adrian" + ): CreateAgentRequest { + return { + response_engine: { llm_id: llmId, type: "retell-llm" }, + agent_name: `agent-${eventTypeId || "default"}-${Date.now()}`, + voice_id: voiceId, + }; + } + + /** + * Maps update model params to Retell format + */ + static mapToUpdateLLMRequest( + data: AIPhoneServiceUpdateModelParams + ): UpdateLLMRequest { + return { + general_prompt: data.general_prompt, + begin_message: data.begin_message, + general_tools: data.general_tools ?? null, + }; + } + + /** + * Maps agent update data to Retell format + */ + static mapToUpdateAgentRequest(data: { + agent_name?: string | null; + voice_id?: string; + language?: Language; + responsiveness?: number; + interruption_sensitivity?: number; + }): UpdateAgentRequest { + return data; + } + + /** + * Maps phone number update data + */ + static mapPhoneNumberUpdateData( + inboundAgentId?: string | null, + outboundAgentId?: string | null + ): { inbound_agent_id?: string | null; outbound_agent_id?: string | null } { + const updateData: { inbound_agent_id?: string | null; outbound_agent_id?: string | null } = {}; + + if (inboundAgentId !== undefined) { + updateData.inbound_agent_id = inboundAgentId; + } + + if (outboundAgentId !== undefined) { + updateData.outbound_agent_id = outboundAgentId; + } + + return updateData; + } + + /** + * Format agent for listing response + */ + static formatAgentForList(agent: Agent) { + return { + id: agent.id, + name: agent.name, + providerAgentId: agent.providerAgentId, + enabled: agent.enabled, + userId: agent.userId, + teamId: agent.teamId, + createdAt: agent.createdAt, + updatedAt: agent.updatedAt, + outboundPhoneNumbers: agent.outboundPhoneNumbers, + team: agent.team, + user: agent.user, + }; + } + + /** + * Format agent details response + */ + static formatAgentDetails( + agent: Agent, + retellAgent: RetellAgent, + llmDetails: RetellLLM + ): RetellAgentWithDetails { + return { + id: agent.id, + name: agent.name, + providerAgentId: agent.providerAgentId, + enabled: agent.enabled, + userId: agent.userId, + teamId: agent.teamId, + outboundPhoneNumbers: agent.outboundPhoneNumbers, + retellData: { + agentId: retellAgent.agent_id, + agentName: retellAgent.agent_name, + voiceId: retellAgent.voice_id, + responseEngine: retellAgent.response_engine, + language: retellAgent.language, + responsiveness: retellAgent.responsiveness, + interruptionSensitivity: retellAgent.interruption_sensitivity, + generalPrompt: llmDetails.general_prompt, + beginMessage: llmDetails.begin_message, + generalTools: llmDetails.general_tools, + llmId: llmDetails.llm_id, + }, + createdAt: agent.createdAt, + updatedAt: agent.updatedAt, + }; + } + + /** + * Extract LLM update data from params + */ + static extractLLMUpdateData( + generalPrompt?: string | null, + beginMessage?: string | null, + generalTools?: AIPhoneServiceTools + ): UpdateLLMRequest { + return { + general_prompt: generalPrompt, + begin_message: beginMessage, + general_tools: generalTools, + }; + } +} diff --git a/packages/features/calAIPhone/providers/retellAI/RetellSDKClient.test.ts b/packages/features/calAIPhone/providers/retellAI/RetellSDKClient.test.ts new file mode 100644 index 0000000000..8d2d13647f --- /dev/null +++ b/packages/features/calAIPhone/providers/retellAI/RetellSDKClient.test.ts @@ -0,0 +1,428 @@ +import { Retell } from "retell-sdk"; +import { describe, it, expect, beforeEach, vi, beforeAll } from "vitest"; + +import logger from "@calcom/lib/logger"; + +import type { + CreateLLMRequest, + UpdateLLMRequest, + CreateAgentRequest, + UpdateAgentRequest, + CreatePhoneNumberParams, + ImportPhoneNumberParams, + RetellDynamicVariables, +} from "./types"; + +let RetellSDKClient: typeof import("./RetellSDKClient").RetellSDKClient; + +vi.mock("retell-sdk", () => ({ + Retell: vi.fn().mockImplementation(() => ({ + llm: { + create: vi.fn(), + retrieve: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }, + agent: { + create: vi.fn(), + retrieve: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }, + phoneNumber: { + create: vi.fn(), + import: vi.fn(), + delete: vi.fn(), + retrieve: vi.fn(), + update: vi.fn(), + }, + call: { + createPhoneCall: vi.fn(), + }, + })), +})); + +vi.mock("@calcom/lib/logger", () => ({ + default: { + getSubLogger: vi.fn().mockReturnValue({ + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + }), + }, +})); + +const TEST_API_KEY = "test-retell-api-key"; + +describe("RetellSDKClient", () => { + beforeAll(() => { + const originalEnv = process.env.RETELL_AI_KEY; + vi.stubEnv("RETELL_AI_KEY", TEST_API_KEY); + + return () => { + if (originalEnv !== undefined) { + vi.stubEnv("RETELL_AI_KEY", originalEnv); + } else { + vi.unstubAllEnvs(); + } + }; + }); + let client: RetellSDKClient; + let mockRetellInstance: any; + let mockLogger: any; + + beforeEach(() => { + vi.clearAllMocks(); + + mockLogger = { + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + }; + + mockRetellInstance = { + llm: { + create: vi.fn(), + retrieve: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }, + agent: { + create: vi.fn(), + retrieve: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }, + phoneNumber: { + create: vi.fn(), + import: vi.fn(), + delete: vi.fn(), + retrieve: vi.fn(), + update: vi.fn(), + }, + call: { + createPhoneCall: vi.fn(), + }, + }; + + (Retell as any).mockImplementation(() => mockRetellInstance); + }); + + describe("constructor", () => { + beforeAll(async () => { + // Reset modules and reimport with mocked env var + vi.resetModules(); + const module = await import("./RetellSDKClient"); + RetellSDKClient = module.RetellSDKClient; + }); + + it("should create client with default logger when no custom logger provided", () => { + client = new RetellSDKClient(); + + expect(logger.getSubLogger).toHaveBeenCalledWith({ prefix: ["retellSDKClient:"] }); + expect(Retell).toHaveBeenCalledWith({ apiKey: TEST_API_KEY }); + }); + + it("should create client with custom logger", () => { + client = new RetellSDKClient(mockLogger); + + expect(logger.getSubLogger).not.toHaveBeenCalled(); + expect(Retell).toHaveBeenCalledWith({ apiKey: TEST_API_KEY }); + }); + + it("should throw error when RETELL_AI_KEY is not configured", async () => { + vi.unstubAllEnvs(); + vi.stubEnv("RETELL_AI_KEY", ""); + + vi.resetModules(); + const { RetellSDKClient: TestRetellSDKClient } = await import("./RetellSDKClient"); + + expect(() => { + new TestRetellSDKClient(); + }).toThrow("RETELL_API_KEY is not configured"); + + // Restore the env var + vi.stubEnv("RETELL_AI_KEY", TEST_API_KEY); + }); + }); + + describe("LLM operations", () => { + beforeEach(() => { + client = new RetellSDKClient(mockLogger); + }); + + describe("createLLM", () => { + it("should create and return LLM", async () => { + const mockRequest: CreateLLMRequest = { + general_prompt: "Test prompt", + }; + const mockResponse = { llm_id: "test-llm-id" }; + + mockRetellInstance.llm.create.mockResolvedValue(mockResponse); + + const result = await client.createLLM(mockRequest); + + expect(mockRetellInstance.llm.create).toHaveBeenCalledWith(mockRequest); + expect(result).toEqual(mockResponse); + }); + }); + + describe("getLLM", () => { + it("should get LLM", async () => { + const llmId = "test-llm-id"; + const mockResponse = { llm_id: llmId, general_prompt: "Test prompt" }; + + mockRetellInstance.llm.retrieve.mockResolvedValue(mockResponse); + + const result = await client.getLLM(llmId); + + expect(mockRetellInstance.llm.retrieve).toHaveBeenCalledWith(llmId); + expect(result).toEqual(mockResponse); + }); + }); + + describe("updateLLM", () => { + it("should update LLM", async () => { + const llmId = "test-llm-id"; + const updateData: UpdateLLMRequest = { + general_prompt: "Updated prompt", + }; + const mockResponse = { llm_id: llmId }; + + mockRetellInstance.llm.update.mockResolvedValue(mockResponse); + + const result = await client.updateLLM(llmId, updateData); + + expect(mockRetellInstance.llm.update).toHaveBeenCalledWith(llmId, updateData); + expect(result).toEqual(mockResponse); + }); + }); + + describe("deleteLLM", () => { + it("should delete LLM", async () => { + const llmId = "test-llm-id"; + + mockRetellInstance.llm.delete.mockResolvedValue(undefined); + + await client.deleteLLM(llmId); + + expect(mockRetellInstance.llm.delete).toHaveBeenCalledWith(llmId); + }); + }); + }); + + describe("Agent operations", () => { + beforeEach(() => { + client = new RetellSDKClient(mockLogger); + }); + + describe("createAgent", () => { + it("should create agent", async () => { + const mockRequest: CreateAgentRequest = { + agent_name: "Test Agent", + response_engine: { type: "retell-llm", llm_id: "test-llm-id" }, + voice_id: "test-voice-id", + }; + const mockResponse = { agent_id: "test-agent-id", agent_name: "Test Agent" }; + + mockRetellInstance.agent.create.mockResolvedValue(mockResponse); + + const result = await client.createAgent(mockRequest); + + expect(mockRetellInstance.agent.create).toHaveBeenCalledWith(mockRequest); + expect(result).toEqual(mockResponse); + }); + }); + + describe("getAgent", () => { + it("should get agent", async () => { + const agentId = "test-agent-id"; + const mockResponse = { agent_id: agentId, agent_name: "Test Agent" }; + + mockRetellInstance.agent.retrieve.mockResolvedValue(mockResponse); + + const result = await client.getAgent(agentId); + + expect(mockRetellInstance.agent.retrieve).toHaveBeenCalledWith(agentId); + expect(result).toEqual(mockResponse); + }); + }); + + describe("updateAgent", () => { + it("should update agent", async () => { + const agentId = "test-agent-id"; + const updateData: UpdateAgentRequest = { + agent_name: "Updated Agent", + }; + const mockResponse = { agent_id: agentId, agent_name: "Updated Agent" }; + + mockRetellInstance.agent.update.mockResolvedValue(mockResponse); + + const result = await client.updateAgent(agentId, updateData); + + expect(mockRetellInstance.agent.update).toHaveBeenCalledWith(agentId, updateData); + expect(result).toEqual(mockResponse); + }); + }); + + describe("deleteAgent", () => { + it("should delete agent", async () => { + const agentId = "test-agent-id"; + + mockRetellInstance.agent.delete.mockResolvedValue(undefined); + + await client.deleteAgent(agentId); + + expect(mockRetellInstance.agent.delete).toHaveBeenCalledWith(agentId); + }); + }); + }); + + describe("Phone number operations", () => { + beforeEach(() => { + client = new RetellSDKClient(mockLogger); + }); + + describe("createPhoneNumber", () => { + it("should create phone number", async () => { + const phoneData: CreatePhoneNumberParams = { + area_code: 415, + nickname: "Test Phone", + }; + const mockResponse = { phone_number: "+14155551234" }; + + mockRetellInstance.phoneNumber.create.mockResolvedValue(mockResponse); + + const result = await client.createPhoneNumber(phoneData); + + expect(mockRetellInstance.phoneNumber.create).toHaveBeenCalledWith(phoneData); + expect(result).toEqual(mockResponse); + }); + }); + + describe("importPhoneNumber", () => { + it("should import phone number", async () => { + const importData: ImportPhoneNumberParams = { + phone_number: "+14155551234", + termination_uri: "https://example.com/webhook", + sip_trunk_auth_username: "username", + sip_trunk_auth_password: "password", + }; + const mockResponse = { phone_number: "+14155551234" }; + + mockRetellInstance.phoneNumber.import.mockResolvedValue(mockResponse); + + const result = await client.importPhoneNumber(importData); + + expect(mockRetellInstance.phoneNumber.import).toHaveBeenCalledWith(importData); + expect(result).toEqual(mockResponse); + }); + }); + + describe("deletePhoneNumber", () => { + it("should delete phone number", async () => { + const phoneNumber = "+14155551234"; + + mockRetellInstance.phoneNumber.delete.mockResolvedValue(undefined); + + await client.deletePhoneNumber(phoneNumber); + + expect(mockRetellInstance.phoneNumber.delete).toHaveBeenCalledWith(phoneNumber); + }); + }); + + describe("getPhoneNumber", () => { + it("should get phone number", async () => { + const phoneNumber = "+14155551234"; + const mockResponse = { phone_number: phoneNumber }; + + mockRetellInstance.phoneNumber.retrieve.mockResolvedValue(mockResponse); + + const result = await client.getPhoneNumber(phoneNumber); + + expect(mockRetellInstance.phoneNumber.retrieve).toHaveBeenCalledWith(phoneNumber); + expect(result).toEqual(mockResponse); + }); + }); + + describe("updatePhoneNumber", () => { + it("should update phone number", async () => { + const phoneNumber = "+14155551234"; + const updateData = { + inbound_agent_id: "new-inbound", + }; + const mockResponse = { phone_number: phoneNumber }; + + mockRetellInstance.phoneNumber.update.mockResolvedValue(mockResponse); + + const result = await client.updatePhoneNumber(phoneNumber, updateData); + + expect(mockRetellInstance.phoneNumber.update).toHaveBeenCalledWith(phoneNumber, updateData); + expect(result).toEqual(mockResponse); + }); + }); + }); + + describe("Call operations", () => { + beforeEach(() => { + client = new RetellSDKClient(mockLogger); + }); + + describe("createPhoneCall", () => { + it("should create phone call", async () => { + const callData = { + from_number: "+14155551234", + to_number: "+14155555678", + retell_llm_dynamic_variables: { + name: "John Doe", + email: "john@example.com", + } as RetellDynamicVariables, + }; + const mockResponse = { call_id: "test-call-id" }; + + mockRetellInstance.call.createPhoneCall.mockResolvedValue(mockResponse); + + const result = await client.createPhoneCall(callData); + + expect(mockRetellInstance.call.createPhoneCall).toHaveBeenCalledWith(callData); + expect(result).toEqual(mockResponse); + }); + + it("should handle undefined dynamic variables", async () => { + const callData = { + from_number: "+14155551234", + to_number: "+14155555678", + retell_llm_dynamic_variables: undefined, + }; + const mockResponse = { call_id: "test-call-id" }; + + mockRetellInstance.call.createPhoneCall.mockResolvedValue(mockResponse); + + const result = await client.createPhoneCall(callData); + + expect(mockRetellInstance.call.createPhoneCall).toHaveBeenCalledWith(callData); + expect(result).toEqual(mockResponse); + }); + }); + }); + + describe("Edge cases", () => { + beforeEach(() => { + client = new RetellSDKClient(mockLogger); + }); + + it("should handle special characters in phone numbers", async () => { + const phoneNumber = "+1 (415) 555-1234"; + const mockResponse = { phone_number: phoneNumber }; + + mockRetellInstance.phoneNumber.retrieve.mockResolvedValue(mockResponse); + + const result = await client.getPhoneNumber(phoneNumber); + + expect(mockRetellInstance.phoneNumber.retrieve).toHaveBeenCalledWith(phoneNumber); + expect(result).toEqual(mockResponse); + }); + }); +}); diff --git a/packages/features/calAIPhone/providers/retellAI/RetellSDKClient.ts b/packages/features/calAIPhone/providers/retellAI/RetellSDKClient.ts new file mode 100644 index 0000000000..15d665498e --- /dev/null +++ b/packages/features/calAIPhone/providers/retellAI/RetellSDKClient.ts @@ -0,0 +1,241 @@ +import { Retell } from "retell-sdk"; + +import { HttpError } from "@calcom/lib/http-error"; +import logger from "@calcom/lib/logger"; + +import type { + RetellAIRepository, + CreateLLMRequest, + UpdateLLMRequest, + CreateAgentRequest, + UpdateAgentRequest, + RetellAgent, + CreatePhoneNumberParams, + RetellDynamicVariables, + ImportPhoneNumberParams, +} from "./types"; + +const RETELL_API_KEY = process.env.RETELL_AI_KEY; + +export class RetellSDKClient implements RetellAIRepository { + private client: Retell; + private logger: ReturnType; + + constructor(customLogger?: ReturnType) { + this.logger = customLogger || logger.getSubLogger({ prefix: ["retellSDKClient:"] }); + + if (!RETELL_API_KEY) { + throw new HttpError({ + statusCode: 500, + message: "RETELL_API_KEY is not configured", + }); + } + + this.client = new Retell({ + apiKey: RETELL_API_KEY, + }); + } + + async createLLM(data: CreateLLMRequest) { + this.logger.info("Creating LLM via SDK", { + eventTypeId: data.general_tools?.find((t) => "event_type_id" in t)?.event_type_id, + }); + + try { + const response = await this.client.llm.create(data); + + this.logger.info("LLM created successfully", { llmId: response.llm_id }); + return response; + } catch (error) { + this.logger.error("Failed to create LLM", { error }); + throw error; + } + } + + async getLLM(llmId: string) { + this.logger.info("Getting LLM via SDK", { llmId }); + + try { + const response = await this.client.llm.retrieve(llmId); + this.logger.info("LLM retrieved successfully", { llmId }); + return response; + } catch (error) { + this.logger.error("Failed to get LLM", { error, llmId }); + throw error; + } + } + + async updateLLM(llmId: string, data: UpdateLLMRequest) { + try { + this.logger.info("Updating LLM via SDK", { llmId }); + + const response = await this.client.llm.update(llmId, data); + + this.logger.info("LLM updated successfully", { llmId }); + return response; + } catch (error) { + this.logger.error("Failed to update LLM", { error, llmId }); + throw error; + } + } + + async deleteLLM(llmId: string) { + this.logger.info("Deleting LLM via SDK", { llmId }); + + try { + await this.client.llm.delete(llmId); + this.logger.info("LLM deleted successfully", { llmId }); + } catch (error) { + this.logger.error("Failed to delete LLM", { error, llmId }); + throw error; + } + } + + async createAgent(data: CreateAgentRequest): Promise { + this.logger.info("Creating agent via SDK", { + agentName: data.agent_name, + }); + + try { + const response = await this.client.agent.create(data); + + this.logger.info("Agent created successfully", { + agentId: response.agent_id, + agentName: data.agent_name, + }); + + return response; + } catch (error) { + this.logger.error("Failed to create agent", { + error, + agentName: data.agent_name, + }); + throw error; + } + } + + async getAgent(agentId: string): Promise { + this.logger.info("Getting agent via SDK", { agentId }); + + try { + const response = await this.client.agent.retrieve(agentId); + this.logger.info("Agent retrieved successfully", { agentId }); + + return response; + } catch (error) { + this.logger.error("Failed to get agent", { error, agentId }); + throw error; + } + } + + async updateAgent(agentId: string, data: UpdateAgentRequest): Promise { + this.logger.info("Updating agent via SDK", { agentId }); + + try { + const response = await this.client.agent.update(agentId, data); + this.logger.info("Agent updated successfully", { agentId }); + + return response; + } catch (error) { + this.logger.error("Failed to update agent", { error, agentId }); + throw error; + } + } + + async deleteAgent(agentId: string) { + this.logger.info("Deleting agent via SDK", { agentId }); + + try { + await this.client.agent.delete(agentId); + this.logger.info("Agent deleted successfully", { agentId }); + } catch (error) { + this.logger.error("Failed to delete agent", { error, agentId }); + throw error; + } + } + + async createPhoneNumber(data: CreatePhoneNumberParams) { + try { + const response = await this.client.phoneNumber.create({ + area_code: data.area_code, + inbound_agent_id: data.inbound_agent_id, + outbound_agent_id: data.outbound_agent_id, + nickname: data.nickname, + }); + return response; + } catch (error) { + this.logger.error("Failed to create phone number", { error }); + throw error; + } + } + + async importPhoneNumber(data: ImportPhoneNumberParams) { + try { + const response = await this.client.phoneNumber.import({ + phone_number: data.phone_number, + termination_uri: data.termination_uri, + sip_trunk_auth_username: data.sip_trunk_auth_username, + sip_trunk_auth_password: data.sip_trunk_auth_password, + nickname: data.nickname, + }); + return response; + } catch (error) { + this.logger.error("Failed to import phone number", { error }); + throw error; + } + } + + async deletePhoneNumber(phoneNumber: string) { + try { + this.logger.info("Deleting phone number via SDK", { phoneNumber }); + await this.client.phoneNumber.delete(phoneNumber); + } catch (error) { + this.logger.error("Failed to delete phone number", { error, phoneNumber }); + throw error; + } + } + + async getPhoneNumber(phoneNumber: string) { + try { + const response = await this.client.phoneNumber.retrieve(phoneNumber); + return response; + } catch (error) { + this.logger.error("Failed to get phone number", { error, phoneNumber }); + throw error; + } + } + + async updatePhoneNumber( + phoneNumber: string, + data: { inbound_agent_id?: string | null; outbound_agent_id?: string | null } + ) { + try { + const response = await this.client.phoneNumber.update(phoneNumber, { + inbound_agent_id: data.inbound_agent_id, + outbound_agent_id: data.outbound_agent_id, + }); + return response; + } catch (error) { + this.logger.error("Failed to update phone number", { error, phoneNumber }); + throw error; + } + } + + async createPhoneCall(data: { + from_number: string; + to_number: string; + retell_llm_dynamic_variables?: RetellDynamicVariables; + }) { + try { + const response = await this.client.call.createPhoneCall({ + from_number: data.from_number, + to_number: data.to_number, + retell_llm_dynamic_variables: data.retell_llm_dynamic_variables, + }); + return response; + } catch (error) { + this.logger.error("Failed to create phone call", { error }); + throw error; + } + } +} diff --git a/packages/features/calAIPhone/providers/retellAI/errors.ts b/packages/features/calAIPhone/providers/retellAI/errors.ts new file mode 100644 index 0000000000..8e9ad61294 --- /dev/null +++ b/packages/features/calAIPhone/providers/retellAI/errors.ts @@ -0,0 +1,6 @@ +export class RetellAIError extends Error { + constructor(message: string, public readonly operation: string, public readonly originalError?: unknown) { + super(message); + this.name = "RetellAIError"; + } +} diff --git a/packages/features/calAIPhone/providers/retellAI/index.ts b/packages/features/calAIPhone/providers/retellAI/index.ts new file mode 100644 index 0000000000..36ec28f338 --- /dev/null +++ b/packages/features/calAIPhone/providers/retellAI/index.ts @@ -0,0 +1,137 @@ +export { RetellAIService } from "./RetellAIService"; + +export { AIConfigurationService } from "./services/AIConfigurationService"; +export { AgentService } from "./services/AgentService"; +export { BillingService } from "./services/BillingService"; +export { CallService } from "./services/CallService"; +export { PhoneNumberService } from "./services/PhoneNumberService"; + +export { RetellAIPhoneServiceProvider } from "./RetellAIPhoneServiceProvider"; +export { RetellAIPhoneServiceProviderFactory } from "./RetellAIPhoneServiceProviderFactory"; +export { RetellSDKClient } from "./RetellSDKClient"; +export { RetellAIError } from "./errors"; +export { PrismaAgentRepositoryAdapter } from "../adapters/PrismaAgentRepositoryAdapter"; +export { PrismaPhoneNumberRepositoryAdapter } from "../adapters/PrismaPhoneNumberRepositoryAdapter"; +export { PrismaTransactionAdapter } from "../adapters/PrismaTransactionAdapter"; +export type { AgentRepositoryInterface } from "../interfaces/AgentRepositoryInterface"; +export type { PhoneNumberRepositoryInterface } from "../interfaces/PhoneNumberRepositoryInterface"; +export type { TransactionInterface } from "../interfaces/TransactionInterface"; + +export type { + RetellAIRepository, + CreateLLMRequest, + CreateAgentRequest, + UpdateLLMRequest, + AIConfigurationSetup, + AIConfigurationDeletion, + DeletionResult, + RetellLLM, + RetellAgent, + RetellCall, + CreatePhoneCallParams, + RetellPhoneNumber, + UpdatePhoneNumberParams, + CreatePhoneNumberParams, + ImportPhoneNumberParams, + UpdateAgentRequest, + RetellLLMGeneralTools, + RetellAgentWithDetails, + Language, + RetellDynamicVariables, +} from "./types"; + +export interface RetellAIPhoneServiceProviderTypeMap { + Configuration: AIConfigurationSetup; + UpdateModelParams: UpdateLLMRequest; + Model: RetellLLM; + Agent: RetellAgent; + Call: RetellCall; + PhoneNumber: RetellPhoneNumber; + UpdatePhoneNumberParams: UpdatePhoneNumberParams; + CreatePhoneNumberParams: CreatePhoneNumberParams; + ImportPhoneNumberParams: ImportPhoneNumberParams; + UpdateAgentParams: UpdateAgentRequest; + Tools: RetellLLMGeneralTools; + CreatePhoneCallParams: CreatePhoneCallParams; + AgentWithDetails: RetellAgentWithDetails; +} + +// ===== USAGE EXAMPLES ===== +/* +// REFACTORED ARCHITECTURE - Multiple usage patterns available: + +// 1. BACKWARD COMPATIBLE - Existing code works unchanged +import { RetellAIService } from '@calcom/features/calAIPhone/providers/retellAI'; + +const service = new RetellAIService( + repository, + agentRepository, + phoneNumberRepository, + transactionManager +); + +// Same API as before - internally uses focused services +const { llmId, agentId } = await service.setupAIConfiguration({ + calApiKey: "cal_live_123...", + timeZone: "America/New_York", + eventTypeId: 12345, +}); + +const phoneNumber = await service.importPhoneNumber({ + phone_number: "+1234567890", + termination_uri: "https://example.com/webhook", + userId: 1, +}); + +// 2. FOCUSED SERVICE USAGE - Recommended for new modular code +import { RetellAIService } from '@calcom/features/calAIPhone/providers/retellAI'; + +const service = new RetellAIService( + repository, + agentRepository, + phoneNumberRepository, + transactionManager +); + +// Main service now directly uses focused services internally +const result = await service.importPhoneNumber(phoneNumberData); + +// 3. INDIVIDUAL SERVICES - Advanced usage for specific needs +import { + AIConfigurationService, + PhoneNumberService, + BillingService +} from '@calcom/features/calAIPhone/providers/retellAI'; + +// Use only what you need +const aiConfigService = new AIConfigurationService(repository); +const phoneService = new PhoneNumberService( + repository, + agentRepository, + phoneNumberRepository, + transactionManager +); + +const { llmId, agentId } = await aiConfigService.setupAIConfiguration(config); +const phoneNumber = await phoneService.importPhoneNumber(phoneData); + +// 4. PROVIDER ABSTRACTION - Highest level interface +const factory = new RetellAIProviderFactory(); +const provider = factory.create({ apiKey: "your-api-key" }); + +const { modelId, agentId } = await provider.setupConfiguration({ + calApiKey: "cal_live_123...", + timeZone: "America/New_York", +}); + +// Benefits of the refactored architecture: +// ✅ Single Responsibility - Each service has one clear purpose +// ✅ Easier Testing - Test services in isolation +// ✅ Better Maintainability - Changes to billing don't affect phone operations +// ✅ Reusability - Compose services differently for different use cases +// ✅ Code Navigation - ~150 lines per service vs 849 lines in god class +// ✅ Backward Compatibility - All existing code continues to work + +// BEFORE: 849-line god class mixing all responsibilities +// AFTER: 5 focused services composed in main service = Clean architecture +*/ diff --git a/packages/features/calAIPhone/providers/retellAI/services/AIConfigurationService.ts b/packages/features/calAIPhone/providers/retellAI/services/AIConfigurationService.ts new file mode 100644 index 0000000000..5405fef00e --- /dev/null +++ b/packages/features/calAIPhone/providers/retellAI/services/AIConfigurationService.ts @@ -0,0 +1,179 @@ +import logger from "@calcom/lib/logger"; + +import type { + AIPhoneServiceUpdateModelParams, + AIPhoneServiceProviderType, + AIPhoneServiceModel, +} from "../../../interfaces/AIPhoneService.interface"; +import { DEFAULT_BEGIN_MESSAGE, DEFAULT_PROMPT_VALUE } from "../../../promptTemplates"; +import { RetellAIServiceMapper } from "../RetellAIServiceMapper"; +import type { + RetellAIRepository, + AIConfigurationSetup, + AIConfigurationDeletion, + DeletionResult, +} from "../types"; + +export class AIConfigurationService { + private logger = logger.getSubLogger({ prefix: ["AIConfigurationService"] }); + + constructor(private retellRepository: RetellAIRepository) {} + + async setupAIConfiguration(config: AIConfigurationSetup): Promise<{ llmId: string; agentId: string }> { + let llmId: string | null = null; + + try { + const generalTools = RetellAIServiceMapper.buildGeneralTools(config); + + const llmRequest = RetellAIServiceMapper.mapToCreateLLMRequest( + { + ...config, + generalPrompt: config.generalPrompt || DEFAULT_PROMPT_VALUE, + beginMessage: config.beginMessage || DEFAULT_BEGIN_MESSAGE, + }, + generalTools + ); + + // Step 1: Create LLM + const llm = await this.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); + + 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); + 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.`; + this.logger.error(errorMessage, { + llmId, + originalError: error instanceof Error ? error.message : String(error), + cleanupError: cleanupError instanceof Error ? cleanupError.message : String(cleanupError), + timestamp: new Date().toISOString(), + requiresManualCleanup: true, + }); + } + } + + const errorMessage = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to setup AI configuration: ${errorMessage}`); + } + } + + async deleteAIConfiguration(config: AIConfigurationDeletion): Promise { + this.logger.info("Starting AI configuration deletion", { + agentId: config.agentId, + llmId: config.llmId, + timestamp: new Date().toISOString(), + }); + + const result: DeletionResult = { + success: true, + errors: [], + deleted: { + llm: false, + agent: false, + }, + }; + + // Delete Agent + if (config.agentId) { + try { + this.logger.info(`Attempting to delete agent: ${config.agentId}`); + await this.retellRepository.deleteAgent(config.agentId); + result.deleted.agent = true; + this.logger.info(`Successfully deleted agent: ${config.agentId}`); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : `Failed to delete agent: ${error}`; + result.errors.push(errorMessage); + result.success = false; + this.logger.error("Failed to delete agent", { + agentId: config.agentId, + error: errorMessage, + timestamp: new Date().toISOString(), + }); + } + } else { + result.deleted.agent = true; + this.logger.info("No agent ID provided, skipping agent deletion"); + } + + // Delete LLM + if (config.llmId) { + try { + this.logger.info(`Attempting to delete LLM: ${config.llmId}`); + await this.retellRepository.deleteLLM(config.llmId); + result.deleted.llm = true; + this.logger.info(`Successfully deleted LLM: ${config.llmId}`); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : `Failed to delete LLM: ${error}`; + result.errors.push(errorMessage); + result.success = false; + this.logger.error("Failed to delete LLM", { + llmId: config.llmId, + error: errorMessage, + timestamp: new Date().toISOString(), + }); + } + } else { + result.deleted.llm = true; + this.logger.info("No LLM ID provided, skipping LLM deletion"); + } + + this.logger.info("AI configuration deletion completed", { + success: result.success, + deletedAgent: result.deleted.agent, + deletedLLM: result.deleted.llm, + errorCount: result.errors.length, + errors: result.errors, + timestamp: new Date().toISOString(), + }); + + return result; + } + + async updateLLMConfiguration( + llmId: string, + data: AIPhoneServiceUpdateModelParams + ): Promise> { + if (!llmId?.trim()) { + throw new Error("LLM ID is required and cannot be empty"); + } + + if (!data || Object.keys(data).length === 0) { + throw new Error("Update data is required and cannot be empty"); + } + + try { + const updateRequest = RetellAIServiceMapper.mapToUpdateLLMRequest(data); + return await this.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 getLLMDetails(llmId: string): Promise> { + if (!llmId?.trim()) { + throw new Error("LLM ID is required and cannot be empty"); + } + + try { + return await this.retellRepository.getLLM(llmId); + } catch (error) { + this.logger.error(`Failed to get LLM details for '${llmId}'`, { + llmId, + error, + timestamp: new Date().toISOString(), + }); + + throw new Error(`Failed to get LLM details for '${llmId}'`); + } + } +} diff --git a/packages/features/calAIPhone/providers/retellAI/services/AgentService.ts b/packages/features/calAIPhone/providers/retellAI/services/AgentService.ts new file mode 100644 index 0000000000..85dbe7c858 --- /dev/null +++ b/packages/features/calAIPhone/providers/retellAI/services/AgentService.ts @@ -0,0 +1,336 @@ +import { v4 as uuidv4 } from "uuid"; + +import { HttpError } from "@calcom/lib/http-error"; +import logger from "@calcom/lib/logger"; + +import type { + AIPhoneServiceUpdateModelParams, + AIPhoneServiceProviderType, + AIPhoneServiceAgent, + AIPhoneServiceModel, + AIPhoneServiceTools, +} from "../../interfaces/AIPhoneService.interface"; +import type { AgentRepositoryInterface } from "../../interfaces/AgentRepositoryInterface"; +import { RetellAIServiceMapper } from "../RetellAIServiceMapper"; +import type { RetellAIRepository } from "../types"; +import { getLlmId, Language } from "../types"; + +export class AgentService { + private logger = logger.getSubLogger({ prefix: ["AgentService"] }); + + constructor( + private retellRepository: RetellAIRepository, + private agentRepository: AgentRepositoryInterface + ) {} + + async getAgent(agentId: string): Promise> { + if (!agentId?.trim()) { + throw new HttpError({ + statusCode: 400, + message: "Agent ID is required and cannot be empty", + }); + } + + try { + return await this.retellRepository.getAgent(agentId); + } catch (error) { + this.logger.error("Failed to get agent from external AI service", { + agentId, + error, + }); + throw new HttpError({ + statusCode: 500, + message: `Failed to get agent ${agentId}`, + }); + } + } + + async updateAgent( + agentId: string, + data: { + agent_name?: string | null; + voice_id?: string; + language?: Language; + responsiveness?: number; + interruption_sensitivity?: number; + } + ): Promise> { + if (!agentId?.trim()) { + throw new Error("Agent ID is required and cannot be empty"); + } + + if (!data || Object.keys(data).length === 0) { + throw new Error("Update data is required"); + } + + try { + const updateRequest = RetellAIServiceMapper.mapToUpdateAgentRequest(data); + return await this.retellRepository.updateAgent(agentId, updateRequest); + } catch (error) { + this.logger.error("Failed to update agent in external AI service", { + agentId, + data, + error, + }); + throw new Error(`Failed to update agent ${agentId}`); + } + } + + async listAgents({ + userId, + teamId, + scope = "all", + }: { + userId: number; + teamId?: number; + scope?: "personal" | "team" | "all"; + }) { + const agents = await this.agentRepository.findManyWithUserAccess({ + userId, + teamId, + scope, + }); + + const formattedAgents = agents.map((agent) => RetellAIServiceMapper.formatAgentForList(agent)); + + return { + totalCount: formattedAgents.length, + filtered: formattedAgents, + }; + } + + async getAgentWithDetails({ id, userId, teamId }: { id: string; userId: number; teamId?: number }) { + const agent = await this.agentRepository.findByIdWithUserAccessAndDetails({ + id, + userId, + teamId, + }); + + if (!agent) { + throw new HttpError({ + statusCode: 404, + message: "Agent not found or you don't have permission to view it.", + }); + } + + try { + const retellAgent = await this.getAgent(agent.providerAgentId); + const llmId = getLlmId(retellAgent); + + if (!llmId) { + throw new HttpError({ + statusCode: 404, + message: "Agent does not have an LLM configured.", + }); + } + + const llmDetails = await this.retellRepository.getLLM(llmId); + + return RetellAIServiceMapper.formatAgentDetails(agent, retellAgent, llmDetails); + } catch (error) { + if (error instanceof HttpError) { + throw error; + } + + this.logger.error("Failed to get agent details from external AI service", { + agentId: id, + providerAgentId: agent.providerAgentId, + userId, + teamId, + error, + }); + throw new HttpError({ + statusCode: 500, + message: "Unable to fetch agent details. Please try again.", + }); + } + } + + async createAgent({ + name: _name, + userId, + teamId, + workflowStepId, + setupAIConfiguration, + }: { + name?: string; + userId: number; + teamId?: number; + workflowStepId?: number; + setupAIConfiguration: () => Promise<{ llmId: string; agentId: string }>; + }) { + const agentName = _name || `Agent - ${userId} ${uuidv4()}`; + + if (teamId) { + const canManage = await this.agentRepository.canManageTeamResources({ + userId, + teamId, + }); + if (!canManage) { + throw new HttpError({ + statusCode: 403, + message: "You don't have permission to create agents for this team.", + }); + } + } + + const llmConfig = await setupAIConfiguration(); + + const agent = await this.agentRepository.create({ + name: agentName, + providerAgentId: llmConfig.agentId, + userId, + teamId, + }); + + if (workflowStepId) { + await this.agentRepository.linkToWorkflowStep({ + workflowStepId, + agentId: agent.id, + }); + } + + return { + id: agent.id, + providerAgentId: agent.providerAgentId, + message: "Agent created successfully", + }; + } + + async updateAgentConfiguration({ + id, + userId, + name, + generalPrompt, + beginMessage, + generalTools, + voiceId, + updateLLMConfiguration, + }: { + id: string; + userId: number; + name?: string; + generalPrompt?: string | null; + beginMessage?: string | null; + generalTools?: AIPhoneServiceTools; + voiceId?: string; + updateLLMConfiguration: ( + llmId: string, + data: AIPhoneServiceUpdateModelParams + ) => Promise>; + }) { + const agent = await this.agentRepository.findByIdWithAdminAccess({ + id, + userId, + }); + + if (!agent) { + throw new HttpError({ + statusCode: 404, + message: "Agent not found or you don't have permission to update it.", + }); + } + + const hasRetellUpdates = + generalPrompt !== undefined || + beginMessage !== undefined || + generalTools !== undefined || + voiceId !== undefined; + + if (hasRetellUpdates) { + try { + const retellAgent = await this.getAgent(agent.providerAgentId); + const llmId = getLlmId(retellAgent); + + if ( + llmId && + (generalPrompt !== undefined || beginMessage !== undefined || generalTools !== undefined) + ) { + const llmUpdateData = RetellAIServiceMapper.extractLLMUpdateData( + generalPrompt, + beginMessage, + generalTools + ); + await updateLLMConfiguration(llmId, llmUpdateData); + } + + if (voiceId) { + await this.updateAgent(agent.providerAgentId, { + voice_id: voiceId, + }); + } + } catch (error) { + this.logger.error("Failed to update agent configuration in external AI service", { + agentId: id, + providerAgentId: agent.providerAgentId, + userId, + updates: { + generalPrompt: !!generalPrompt, + beginMessage: !!beginMessage, + generalTools: !!generalTools, + voiceId: !!voiceId, + }, + error, + }); + + if (error instanceof HttpError) { + throw error; + } + + throw new HttpError({ + statusCode: 500, + message: "Unable to update agent configuration. Please try again.", + }); + } + } + + return { message: "Agent updated successfully" }; + } + + async deleteAgent({ + id, + userId, + teamId, + deleteAIConfiguration, + }: { + id: string; + userId: number; + teamId?: number; + deleteAIConfiguration: (config: { agentId: string; llmId?: string }) => Promise; + }) { + const agent = await this.agentRepository.findByIdWithAdminAccess({ + id, + userId, + teamId, + }); + + if (!agent) { + throw new HttpError({ + statusCode: 404, + message: "Agent not found or you don't have permission to delete it.", + }); + } + + try { + const retellAgent = await this.getAgent(agent.providerAgentId); + const llmId = getLlmId(retellAgent); + + await deleteAIConfiguration({ + agentId: agent.providerAgentId, + llmId: llmId || undefined, + }); + } catch (error) { + this.logger.error("Failed to delete agent from external AI service", { + agentId: id, + providerAgentId: agent.providerAgentId, + userId, + teamId, + error, + }); + } + + await this.agentRepository.delete({ id }); + + return { message: "Agent deleted successfully" }; + } +} diff --git a/packages/features/calAIPhone/providers/retellAI/services/BillingService.ts b/packages/features/calAIPhone/providers/retellAI/services/BillingService.ts new file mode 100644 index 0000000000..f86cfa4a66 --- /dev/null +++ b/packages/features/calAIPhone/providers/retellAI/services/BillingService.ts @@ -0,0 +1,157 @@ +import { getStripeCustomerIdFromUserId } from "@calcom/app-store/stripepayment/lib/customer"; +import { getPhoneNumberMonthlyPriceId } from "@calcom/app-store/stripepayment/lib/utils"; +import stripe from "@calcom/features/ee/payments/server/stripe"; +import { WEBAPP_URL, IS_PRODUCTION } from "@calcom/lib/constants"; +import { HttpError } from "@calcom/lib/http-error"; +import logger from "@calcom/lib/logger"; +import { PhoneNumberSubscriptionStatus } from "@calcom/prisma/enums"; + +import type { PhoneNumberRepositoryInterface } from "../../interfaces/PhoneNumberRepositoryInterface"; +import type { RetellAIRepository } from "../types"; + +export class BillingService { + private logger = logger.getSubLogger({ prefix: ["BillingService"] }); + + constructor( + private phoneNumberRepository: PhoneNumberRepositoryInterface, + private retellRepository: RetellAIRepository + ) {} + + async generatePhoneNumberCheckoutSession({ + userId, + teamId, + agentId, + workflowId, + }: { + userId: number; + teamId?: number; + agentId?: string | null; + workflowId?: string; + }) { + const phoneNumberPriceId = getPhoneNumberMonthlyPriceId(); + + if (!phoneNumberPriceId) { + throw new HttpError({ + statusCode: 500, + message: "Phone number price ID not configured. Please contact support.", + }); + } + + const stripeCustomerId = await getStripeCustomerIdFromUserId(userId); + if (!stripeCustomerId) { + throw new HttpError({ + statusCode: 500, + message: "Failed to create Stripe customer.", + }); + } + + // Create Stripe checkout session for phone number subscription + const checkoutSession = await stripe.checkout.sessions.create({ + customer: stripeCustomerId, + mode: "subscription", + line_items: [ + { + price: phoneNumberPriceId, + quantity: 1, + }, + ], + success_url: `${WEBAPP_URL}/api/phone-numbers/subscription/success?session_id={CHECKOUT_SESSION_ID}`, + cancel_url: `${WEBAPP_URL}/workflows/${workflowId}`, + allow_promotion_codes: true, + customer_update: { + address: "auto", + }, + automatic_tax: { + enabled: IS_PRODUCTION, + }, + metadata: { + userId: userId.toString(), + teamId: teamId?.toString() || "", + agentId: agentId || "", + workflowId: workflowId || "", + type: "phone_number_subscription", + }, + subscription_data: { + metadata: { + userId: userId.toString(), + teamId: teamId?.toString() || "", + agentId: agentId || "", + workflowId: workflowId || "", + type: "phone_number_subscription", + }, + }, + }); + + if (!checkoutSession.url) { + throw new HttpError({ + statusCode: 500, + message: "Failed to create checkout session.", + }); + } + + return { url: checkoutSession.url, message: "Payment required to purchase phone number" }; + } + + async cancelPhoneNumberSubscription({ + phoneNumberId, + userId, + teamId, + }: { + phoneNumberId: number; + userId: number; + teamId?: number; + }) { + // Find phone number with proper team authorization + const phoneNumber = teamId + ? await this.phoneNumberRepository.findByIdWithTeamAccess({ + id: phoneNumberId, + teamId, + userId, + }) + : await this.phoneNumberRepository.findByIdAndUserId({ + id: phoneNumberId, + userId, + }); + + if (!phoneNumber) { + throw new HttpError({ + statusCode: 404, + message: "Phone number not found or you don't have permission to cancel it.", + }); + } + + if (!phoneNumber.stripeSubscriptionId) { + throw new HttpError({ + statusCode: 400, + message: "Phone number doesn't have an active subscription.", + }); + } + + try { + await stripe.subscriptions.cancel(phoneNumber.stripeSubscriptionId); + + await this.phoneNumberRepository.updateSubscriptionStatus({ + id: phoneNumberId, + subscriptionStatus: PhoneNumberSubscriptionStatus.CANCELLED, + disconnectOutboundAgent: true, + }); + + // Delete the phone number from Retell, DB + try { + await this.retellRepository.deletePhoneNumber(phoneNumber.phoneNumber); + } catch (error) { + this.logger.error("Failed to delete phone number from AI service, but subscription was cancelled:", { + error, + }); + } + + return { success: true, message: "Phone number subscription cancelled successfully." }; + } catch (error) { + this.logger.error("Error cancelling phone number subscription:", { error }); + throw new HttpError({ + statusCode: 500, + message: "Failed to cancel subscription. Please try again or contact support.", + }); + } + } +} diff --git a/packages/features/calAIPhone/providers/retellAI/services/CallService.ts b/packages/features/calAIPhone/providers/retellAI/services/CallService.ts new file mode 100644 index 0000000000..ace6334733 --- /dev/null +++ b/packages/features/calAIPhone/providers/retellAI/services/CallService.ts @@ -0,0 +1,161 @@ +import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError"; +import { HttpError } from "@calcom/lib/http-error"; +import logger from "@calcom/lib/logger"; + +import type { + AIPhoneServiceProviderType, + AIPhoneServiceCall, +} from "../../interfaces/AIPhoneService.interface"; +import type { AgentRepositoryInterface } from "../../interfaces/AgentRepositoryInterface"; +import type { RetellAIRepository, RetellDynamicVariables } from "../types"; + +const MIN_CREDIT_REQUIRED_FOR_TEST_CALL = 5; + +export class CallService { + private logger = logger.getSubLogger({ prefix: ["CallService"] }); + + constructor( + private retellRepository: RetellAIRepository, + private agentRepository: AgentRepositoryInterface + ) {} + + async createPhoneCall(data: { + from_number: string; + to_number: string; + retell_llm_dynamic_variables?: RetellDynamicVariables; + }): Promise> { + if (!data.from_number?.trim()) { + throw new HttpError({ + statusCode: 400, + message: "From phone number is required and cannot be empty", + }); + } + + if (!data.to_number?.trim()) { + throw new HttpError({ + statusCode: 400, + message: "To phone number is required and cannot be empty", + }); + } + + try { + return await this.retellRepository.createPhoneCall({ + from_number: data.from_number, + to_number: data.to_number, + retell_llm_dynamic_variables: data.retell_llm_dynamic_variables, + }); + } catch (error) { + this.logger.error("Failed to create phone call in external AI service", { + fromNumber: data.from_number, + toNumber: data.to_number, + error, + }); + throw new HttpError({ + statusCode: 500, + message: `Failed to create phone call from ${data.from_number} to ${data.to_number}`, + }); + } + } + + async createTestCall({ + agentId, + phoneNumber, + userId, + teamId, + }: { + agentId: string; + phoneNumber?: string; + userId: number; + teamId?: number; + }) { + if (!agentId?.trim()) { + throw new HttpError({ + statusCode: 400, + message: "Agent ID is required and cannot be empty", + }); + } + + await this.validateCreditsForTestCall({ userId, teamId }); + + await checkRateLimitAndThrowError({ + rateLimitingType: "core", + identifier: `test-call:${userId}`, + }); + + const toNumber = phoneNumber?.trim(); + if (!toNumber) { + throw new HttpError({ + statusCode: 400, + message: "Phone number is required for test call", + }); + } + + const agent = await this.agentRepository.findByIdWithCallAccess({ + id: agentId, + userId, + }); + + if (!agent) { + throw new HttpError({ + statusCode: 404, + message: "Agent not found or you don't have permission to use it.", + }); + } + + const agentPhoneNumber = agent.outboundPhoneNumbers?.[0]?.phoneNumber; + + if (!agentPhoneNumber) { + throw new HttpError({ + statusCode: 400, + message: "Agent must have a phone number assigned to make calls.", + }); + } + + const call = await this.createPhoneCall({ + from_number: agentPhoneNumber, + to_number: toNumber, + }); + + return { + callId: call.call_id, + status: call.call_status, + message: `Call initiated to ${toNumber} with call_id ${call.call_id}`, + }; + } + + private async validateCreditsForTestCall({ userId, teamId }: { userId: number; teamId?: number }) { + try { + const { CreditService } = await import("@calcom/features/ee/billing/credit-service"); + const creditService = new CreditService(); + const credits = await creditService.getAllCredits({ + userId, + teamId, + }); + + const availableCredits = + (credits?.totalRemainingMonthlyCredits || 0) + (credits?.additionalCredits || 0); + + if (availableCredits < MIN_CREDIT_REQUIRED_FOR_TEST_CALL) { + throw new HttpError({ + statusCode: 403, + message: `Insufficient credits to make test call. Need ${MIN_CREDIT_REQUIRED_FOR_TEST_CALL} credits, have ${availableCredits}. Please purchase more credits.`, + }); + } + } catch (error) { + // Re-throw HTTP errors (like insufficient credits) as-is + if (error instanceof HttpError) { + throw error; + } + + this.logger.error("Failed to validate credits for test call", { + userId, + teamId, + error, + }); + throw new HttpError({ + statusCode: 500, + message: "Unable to validate credits. Please try again.", + }); + } + } +} diff --git a/packages/features/calAIPhone/providers/retellAI/services/PhoneNumberService.ts b/packages/features/calAIPhone/providers/retellAI/services/PhoneNumberService.ts new file mode 100644 index 0000000000..db7df561fa --- /dev/null +++ b/packages/features/calAIPhone/providers/retellAI/services/PhoneNumberService.ts @@ -0,0 +1,397 @@ +import { HttpError } from "@calcom/lib/http-error"; +import logger from "@calcom/lib/logger"; +import { PhoneNumberSubscriptionStatus } from "@calcom/prisma/enums"; + +import type { + AIPhoneServiceCreatePhoneNumberParams, + AIPhoneServiceImportPhoneNumberParamsExtended, + AIPhoneServiceProviderType, + AIPhoneServicePhoneNumber, +} from "../../../interfaces/AIPhoneService.interface"; +import type { AgentRepositoryInterface } from "../../interfaces/AgentRepositoryInterface"; +import type { PhoneNumberRepositoryInterface } from "../../interfaces/PhoneNumberRepositoryInterface"; +import type { TransactionInterface } from "../../interfaces/TransactionInterface"; +import { RetellAIServiceMapper } from "../RetellAIServiceMapper"; +import type { RetellAIRepository } from "../types"; + +export class PhoneNumberService { + private logger = logger.getSubLogger({ prefix: ["PhoneNumberService"] }); + + constructor( + private retellRepository: RetellAIRepository, + private agentRepository: AgentRepositoryInterface, + private phoneNumberRepository: PhoneNumberRepositoryInterface, + private transactionManager: TransactionInterface + ) {} + + async importPhoneNumber( + data: AIPhoneServiceImportPhoneNumberParamsExtended + ): Promise> { + if (!data || !data.phone_number?.trim()) { + throw new HttpError({ + statusCode: 400, + message: "Phone number is required and cannot be empty", + }); + } + + const { userId, agentId, teamId, ...rest } = data; + + await this.validateTeamPermissions(userId, teamId); + const agent = await this.validateAgentPermissions(userId, agentId); + + let transactionState = { + retellPhoneNumber: null as AIPhoneServicePhoneNumber | null, + databaseRecordCreated: false, + agentAssigned: false, + }; + + try { + return await this.transactionManager.executeInTransaction(async (txContext) => { + transactionState.retellPhoneNumber = await this.retellRepository.importPhoneNumber({ + phone_number: rest.phone_number, + termination_uri: rest.termination_uri, + sip_trunk_auth_username: rest.sip_trunk_auth_username, + sip_trunk_auth_password: rest.sip_trunk_auth_password, + nickname: rest.nickname, + }); + + await txContext.phoneNumberRepository.createPhoneNumber({ + phoneNumber: transactionState.retellPhoneNumber.phone_number, + userId, + provider: "custom-telephony", + teamId, + outboundAgentId: agent?.id || null, + }); + transactionState.databaseRecordCreated = true; + + if (agent) { + await this.retellRepository.updatePhoneNumber(transactionState.retellPhoneNumber.phone_number, { + outbound_agent_id: agent.providerAgentId, + }); + transactionState.agentAssigned = true; + } + + return transactionState.retellPhoneNumber; + }); + } catch (error) { + await this.handleCompensatingTransaction(transactionState, error, { userId, teamId, agentId }); + throw error; + } + } + + async createPhoneNumber( + data: AIPhoneServiceCreatePhoneNumberParams + ): Promise> { + try { + return await this.retellRepository.createPhoneNumber(data); + } catch (error) { + this.logger.error("Failed to create phone number in external AI service", { + data, + error, + }); + throw new HttpError({ + statusCode: 500, + message: "Failed to create phone number", + }); + } + } + + async deletePhoneNumber({ + phoneNumber, + userId, + teamId, + deleteFromDB = false, + }: { + phoneNumber: string; + userId: number; + teamId?: number; + deleteFromDB: boolean; + }): Promise { + if (!phoneNumber?.trim()) { + throw new HttpError({ + statusCode: 400, + message: "Phone number is required and cannot be empty", + }); + } + + const phoneNumberToDelete = teamId + ? await this.phoneNumberRepository.findByPhoneNumberAndTeamId({ + phoneNumber, + teamId, + userId, + }) + : await this.phoneNumberRepository.findByPhoneNumberAndUserId({ + phoneNumber, + userId, + }); + + if (!phoneNumberToDelete) { + throw new HttpError({ + statusCode: 404, + message: "Phone number not found or you don't have permission to delete it.", + }); + } + + if (phoneNumberToDelete.subscriptionStatus === PhoneNumberSubscriptionStatus.ACTIVE) { + throw new HttpError({ + statusCode: 400, + message: "Phone number is still active", + }); + } + if (phoneNumberToDelete.subscriptionStatus === PhoneNumberSubscriptionStatus.CANCELLED) { + throw new HttpError({ + statusCode: 400, + message: "Phone number is already cancelled", + }); + } + + try { + await this.retellRepository.updatePhoneNumber(phoneNumber, { + inbound_agent_id: null, + outbound_agent_id: null, + }); + } catch (error) { + this.logger.error("Failed to remove agents from phone number in Retell", { + phoneNumber, + error, + }); + } + + await this.retellRepository.deletePhoneNumber(phoneNumber); + + if (deleteFromDB) { + await this.phoneNumberRepository.deletePhoneNumber({ phoneNumber }); + } + } + + async getPhoneNumber( + phoneNumber: string + ): Promise> { + if (!phoneNumber?.trim()) { + throw new HttpError({ + statusCode: 400, + message: "Phone number is required and cannot be empty", + }); + } + + try { + return await this.retellRepository.getPhoneNumber(phoneNumber); + } catch (error) { + this.logger.error("Failed to get phone number from external AI service", { + phoneNumber, + error, + }); + throw new HttpError({ + statusCode: 500, + message: `Failed to get phone number '${phoneNumber}'`, + }); + } + } + + async updatePhoneNumber( + phoneNumber: string, + data: { inbound_agent_id?: string | null; outbound_agent_id?: string | null } + ): Promise> { + if (!phoneNumber?.trim()) { + throw new HttpError({ + statusCode: 400, + message: "Phone number is required and cannot be empty", + }); + } + + if (!data || Object.keys(data).length === 0) { + throw new HttpError({ + statusCode: 400, + message: "Update data is required and cannot be empty", + }); + } + + try { + return await this.retellRepository.updatePhoneNumber(phoneNumber, data); + } catch (error) { + this.logger.error("Failed to update phone number in external AI service", { + phoneNumber, + data, + error, + }); + throw new HttpError({ + statusCode: 500, + message: `Failed to update phone number '${phoneNumber}'`, + }); + } + } + + async updatePhoneNumberWithAgents({ + phoneNumber, + userId, + teamId, + inboundAgentId, + outboundAgentId, + }: { + phoneNumber: string; + userId: number; + teamId?: number; + inboundAgentId?: string | null; + outboundAgentId?: string | null; + }) { + if (!phoneNumber?.trim()) { + throw new HttpError({ + statusCode: 400, + message: "Phone number is required and cannot be empty", + }); + } + + const phoneNumberRecord = teamId + ? await this.phoneNumberRepository.findByPhoneNumberAndTeamId({ + phoneNumber, + teamId, + userId, + }) + : await this.phoneNumberRepository.findByPhoneNumberAndUserId({ + phoneNumber, + userId, + }); + + if (!phoneNumberRecord) { + throw new HttpError({ + statusCode: 404, + message: "Phone number not found or you don't have permission to update it.", + }); + } + + await this.validateAgentAccess(userId, teamId, inboundAgentId, "inbound"); + await this.validateAgentAccess(userId, teamId, outboundAgentId, "outbound"); + + try { + await this.getPhoneNumber(phoneNumber); + + const retellUpdateData = RetellAIServiceMapper.mapPhoneNumberUpdateData( + inboundAgentId, + outboundAgentId + ); + + if (Object.keys(retellUpdateData).length > 0) { + await this.updatePhoneNumber(phoneNumber, retellUpdateData); + } + } catch (error: unknown) { + this.logger.error("Failed to update phone number in external AI service", { + phoneNumber, + error, + note: "Continuing with local database update only", + }); + } + + await this.phoneNumberRepository.updateAgents({ + id: phoneNumberRecord.id, + inboundProviderAgentId: inboundAgentId, + outboundProviderAgentId: outboundAgentId, + }); + + return { message: "Phone number updated successfully" }; + } + + private async validateTeamPermissions(userId: number, teamId?: number) { + if (teamId) { + const canManage = await this.agentRepository.canManageTeamResources({ + userId, + teamId, + }); + if (!canManage) { + throw new HttpError({ + statusCode: 403, + message: "You don't have permission to import phone numbers for this team.", + }); + } + } + } + + private async validateAgentPermissions(userId: number, agentId?: string) { + let agent = null; + if (agentId) { + agent = await this.agentRepository.findByIdWithUserAccess({ + agentId, + userId, + }); + + if (!agent) { + throw new HttpError({ + statusCode: 403, + message: "You don't have permission to use the selected agent.", + }); + } + } + return agent; + } + + private async validateAgentAccess( + userId: number, + teamId: number | undefined, + agentId: string | null | undefined, + type: "inbound" | "outbound" + ) { + if (agentId) { + const agent = await this.agentRepository.findByProviderAgentIdWithUserAccess({ + providerAgentId: agentId, + userId, + }); + + if (!agent) { + throw new HttpError({ + statusCode: 403, + message: `You don't have permission to use the selected ${type} agent.`, + }); + } + + if (teamId && agent.teamId !== teamId) { + throw new HttpError({ + statusCode: 403, + message: `Selected ${type} agent does not belong to the specified team.`, + }); + } + } + } + + private async handleCompensatingTransaction( + transactionState: { + retellPhoneNumber: AIPhoneServicePhoneNumber | null; + databaseRecordCreated: boolean; + agentAssigned: boolean; + }, + error: unknown, + context: { userId: number; teamId?: number; agentId?: string } + ) { + if (transactionState.retellPhoneNumber?.phone_number) { + try { + 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); + this.logger.info("Successfully cleaned up Retell phone number", { + phoneNumber: transactionState.retellPhoneNumber.phone_number, + }); + } catch (cleanupError) { + const compensationFailureMessage = `Failed to cleanup Retell phone number ${transactionState.retellPhoneNumber.phone_number} after transaction failure. Manual cleanup required.`; + + this.logger.error(compensationFailureMessage, { + phoneNumber: transactionState.retellPhoneNumber.phone_number, + userId: context.userId, + teamId: context.teamId, + agentId: context.agentId, + originalError: error, + cleanupError: cleanupError, + transactionState: { + retellCreated: !!transactionState.retellPhoneNumber, + databaseCreated: transactionState.databaseRecordCreated, + agentAssigned: transactionState.agentAssigned, + }, + requiresManualCleanup: true, + }); + + throw new HttpError({ + statusCode: 500, + message: compensationFailureMessage, + }); + } + } + } +} diff --git a/packages/features/calAIPhone/providers/retellAI/services/README.md b/packages/features/calAIPhone/providers/retellAI/services/README.md new file mode 100644 index 0000000000..e09a5a20a0 --- /dev/null +++ b/packages/features/calAIPhone/providers/retellAI/services/README.md @@ -0,0 +1,143 @@ +# RetellAI Services Architecture + +This directory contains the refactored RetellAI services that follow the Single Responsibility Principle (SRP) to fix the previous GOD CLASS violation. + +## Architecture Overview + +**BEFORE**: Single `RetellAIService` class (849 lines) handling all responsibilities +**AFTER**: Focused services composed together (~150 lines each) + +## Services + +### 1. AIConfigurationService + +**Responsibility**: LLM and Agent configuration management + +- `setupAIConfiguration()` - Create LLM and Agent +- `deleteAIConfiguration()` - Clean up AI resources +- `updateLLMConfiguration()` - Update LLM settings +- `getLLMDetails()` - Retrieve LLM information + +### 2. AgentService + +**Responsibility**: Agent CRUD operations + +- `createAgent()` - Create new agents +- `updateAgent()` - Update agent properties +- `deleteAgent()` - Remove agents +- `listAgents()` - Get agent lists +- `getAgentWithDetails()` - Get detailed agent info + +### 3. BillingService + +**Responsibility**: Stripe payments and subscriptions + +- `generatePhoneNumberCheckoutSession()` - Create Stripe checkout +- `cancelPhoneNumberSubscription()` - Cancel subscriptions + +### 4. CallService + +**Responsibility**: Phone call operations + +- `createPhoneCall()` - Initiate calls +- `createTestCall()` - Create test calls with credit validation + +### 5. PhoneNumberService + +**Responsibility**: Phone number management with transaction integrity + +- `importPhoneNumber()` - Import with compensating transactions +- `deletePhoneNumber()` - Remove phone numbers +- `updatePhoneNumberWithAgents()` - Assign agents +- Includes billing leak protection and compensation failure handling + +## Usage Examples + +### Direct Service Usage (Advanced) + +```typescript +import { + AIConfigurationService, + AgentService, + BillingService, + CallService, + PhoneNumberService +} from './services'; + +// Initialize dependencies +const repository = new RetellSDKClient({ apiKey: 'your-key' }); +const agentRepository = new PrismaAgentRepositoryAdapter(); +const phoneNumberRepository = new PrismaPhoneNumberRepositoryAdapter(); +const transactionManager = new PrismaTransactionAdapter(); + +// Use individual services +const aiConfigService = new AIConfigurationService(repository); +const agentService = new AgentService(repository, agentRepository); +const billingService = new BillingService(phoneNumberRepository, repository); + +// Setup AI configuration +const { llmId, agentId } = await aiConfigService.setupAIConfiguration({ + calApiKey: 'cal_live_123...', + timeZone: 'America/New_York', + eventTypeId: 12345, +}); + +// Create agent +const agent = await agentService.createAgent({ + name: 'Support Agent', + userId: 1, + userTimeZone: 'America/New_York', + setupAIConfiguration: () => aiConfigService.setupAIConfiguration({...}) +}); +``` + +### Main Service Usage (Recommended) + +```typescript +import { RetellAIService } from "../RetellAIService"; + +// Initialize main service +const service = new RetellAIService(repository, agentRepository, phoneNumberRepository, transactionManager); + +// Use composed interface - same as before but internally organized +const { llmId, agentId } = await service.setupAIConfiguration({ + calApiKey: "cal_live_123...", + timeZone: "America/New_York", +}); + +const phoneNumber = await service.importPhoneNumber({ + phone_number: "+1234567890", + termination_uri: "https://example.com/webhook", + userId: 1, + agentId: "agent-123", +}); +``` + +## Benefits + +✅ **Single Responsibility** - Each service has one clear purpose +✅ **Easier Testing** - Test services in isolation with focused mocks +✅ **Better Maintainability** - Changes to billing don't affect phone operations +✅ **Reusability** - Compose services differently for different use cases +✅ **Code Navigation** - ~150 lines per service vs 849 lines +✅ **Backward Compatibility** - Existing code continues to work + +## Testing + +Each service can be tested independently: + +```typescript +// Test individual service +const aiConfigService = new AIConfigurationService(mockRepository); +await aiConfigService.setupAIConfiguration(config); + +// Test main service with all composed services +const service = new RetellAIService(mockRepository, mockAgentRepo, mockPhoneRepo, mockTransaction); +``` + +## Migration Guide + +**For new code**: Use `RetellAIService` or individual services for specific needs +**For existing code**: No changes needed - `RetellAIService` maintains same API + +The refactoring eliminates the GOD CLASS antipattern while preserving all functionality and improving code organization. diff --git a/packages/features/calAIPhone/providers/retellAI/services/__tests__/AIConfigurationService.test.ts b/packages/features/calAIPhone/providers/retellAI/services/__tests__/AIConfigurationService.test.ts new file mode 100644 index 0000000000..25d0bdca42 --- /dev/null +++ b/packages/features/calAIPhone/providers/retellAI/services/__tests__/AIConfigurationService.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +import { AIConfigurationService } from "../AIConfigurationService"; +import { setupBasicMocks, createMockLLM, createMockAgent, TestError } from "./test-utils"; + +describe("AIConfigurationService", () => { + let service: AIConfigurationService; + let mocks: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + mocks = setupBasicMocks(); + + service = new AIConfigurationService(mocks.mockRetellRepository); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe("setupAIConfiguration", () => { + const validConfig = { + calApiKey: "cal_live_123", + timeZone: "America/New_York", + eventTypeId: 12345, + }; + + it("should successfully setup AI configuration", async () => { + const mockLLM = createMockLLM(); + const mockAgent = createMockAgent(); + + mocks.mockRetellRepository.createLLM.mockResolvedValue(mockLLM); + mocks.mockRetellRepository.createAgent.mockResolvedValue(mockAgent); + + const result = await service.setupAIConfiguration(validConfig); + + expect(result).toEqual({ + llmId: "llm-123", + agentId: "agent-123", + }); + + expect(mocks.mockRetellRepository.createLLM).toHaveBeenCalled(); + expect(mocks.mockRetellRepository.createAgent).toHaveBeenCalled(); + }); + + it("should handle LLM creation failure", async () => { + mocks.mockRetellRepository.createLLM.mockRejectedValue(new TestError("LLM creation failed")); + + await expect(service.setupAIConfiguration(validConfig)).rejects.toThrow("LLM creation failed"); + }); + }); + + describe("deleteAIConfiguration", () => { + it("should successfully delete both agent and LLM", async () => { + const result = await service.deleteAIConfiguration({ + agentId: "agent-123", + llmId: "llm-123", + }); + + expect(result).toEqual({ + success: true, + errors: [], + deleted: { + agent: true, + llm: true, + }, + }); + + expect(mocks.mockRetellRepository.deleteAgent).toHaveBeenCalledWith("agent-123"); + expect(mocks.mockRetellRepository.deleteLLM).toHaveBeenCalledWith("llm-123"); + }); + + it("should handle partial failures gracefully", async () => { + mocks.mockRetellRepository.deleteAgent.mockRejectedValue(new TestError("Agent delete failed")); + + const result = await service.deleteAIConfiguration({ + agentId: "agent-123", + llmId: "llm-123", + }); + + expect(result).toEqual({ + success: false, + errors: ["Agent delete failed"], + deleted: { + agent: false, + llm: true, + }, + }); + }); + }); + + describe("getLLMDetails", () => { + it("should return LLM details", async () => { + const mockLLM = createMockLLM(); + mocks.mockRetellRepository.getLLM.mockResolvedValue(mockLLM); + + const result = await service.getLLMDetails("llm-123"); + + expect(result).toEqual(mockLLM); + expect(mocks.mockRetellRepository.getLLM).toHaveBeenCalledWith("llm-123"); + }); + }); +}); diff --git a/packages/features/calAIPhone/providers/retellAI/services/__tests__/BillingService.test.ts b/packages/features/calAIPhone/providers/retellAI/services/__tests__/BillingService.test.ts new file mode 100644 index 0000000000..eb8753ae5c --- /dev/null +++ b/packages/features/calAIPhone/providers/retellAI/services/__tests__/BillingService.test.ts @@ -0,0 +1,258 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +import { HttpError } from "@calcom/lib/http-error"; +import { PhoneNumberSubscriptionStatus } from "@calcom/prisma/enums"; + +import { BillingService } from "../BillingService"; +import { setupBasicMocks, createMockPhoneNumberRecord, TestError } from "./test-utils"; + +vi.mock("@calcom/lib/constants", () => ({ + WEBAPP_URL: "https://app.cal.com", + IS_PRODUCTION: false, +})); + +vi.mock("@calcom/features/ee/payments/server/stripe", () => ({ + default: { + checkout: { + sessions: { + create: vi.fn().mockResolvedValue({ + url: "https://checkout.stripe.com/session-123", + }), + }, + }, + subscriptions: { + cancel: vi.fn().mockResolvedValue({}), + }, + }, +})); + +vi.mock("@calcom/app-store/stripepayment/lib/customer", () => ({ + getStripeCustomerIdFromUserId: vi.fn().mockResolvedValue("cus_123"), +})); + +vi.mock("@calcom/app-store/stripepayment/lib/utils", () => ({ + getPhoneNumberMonthlyPriceId: vi.fn().mockReturnValue("price_123"), +})); + +describe("BillingService", () => { + let service: BillingService; + let mocks: ReturnType; + + beforeEach(async () => { + vi.clearAllMocks(); + mocks = setupBasicMocks(); + + const stripe = (await import("@calcom/features/ee/payments/server/stripe")).default; + stripe.checkout.sessions.create.mockResolvedValue({ + url: "https://checkout.stripe.com/session-123", + }); + stripe.subscriptions.cancel.mockResolvedValue({}); + + service = new BillingService(mocks.mockPhoneNumberRepository, mocks.mockRetellRepository); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe("generatePhoneNumberCheckoutSession", () => { + const validCheckoutData = { + userId: 1, + workflowId: "workflow-123", + }; + + it("should create successful checkout session", async () => { + const result = await service.generatePhoneNumberCheckoutSession(validCheckoutData); + + expect(result).toEqual({ + url: "https://checkout.stripe.com/session-123", + message: "Payment required to purchase phone number", + }); + + const stripe = (await import("@calcom/features/ee/payments/server/stripe")).default; + expect(stripe.checkout.sessions.create).toHaveBeenCalledWith( + expect.objectContaining({ + mode: "subscription", + success_url: + "https://app.cal.com/api/phone-numbers/subscription/success?session_id={CHECKOUT_SESSION_ID}", + cancel_url: "https://app.cal.com/workflows/workflow-123", + metadata: expect.objectContaining({ + userId: "1", + workflowId: "workflow-123", + type: "phone_number_subscription", + }), + }) + ); + }); + + it("should handle team billing", async () => { + await service.generatePhoneNumberCheckoutSession({ + ...validCheckoutData, + teamId: 5, + agentId: "agent-123", + }); + + const stripe = (await import("@calcom/features/ee/payments/server/stripe")).default; + expect(stripe.checkout.sessions.create).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ + userId: "1", + teamId: "5", + agentId: "agent-123", + workflowId: "workflow-123", + }), + }) + ); + }); + + it("should throw error if checkout session creation fails", async () => { + const stripe = (await import("@calcom/features/ee/payments/server/stripe")).default; + stripe.checkout.sessions.create.mockResolvedValue({ url: null }); + + await expect(service.generatePhoneNumberCheckoutSession(validCheckoutData)).rejects.toThrow( + "Failed to create checkout session" + ); + }); + + it("should throw error if price ID not configured", async () => { + const { getPhoneNumberMonthlyPriceId } = await import("@calcom/app-store/stripepayment/lib/utils"); + vi.mocked(getPhoneNumberMonthlyPriceId).mockReturnValue(null); + + await expect(service.generatePhoneNumberCheckoutSession(validCheckoutData)).rejects.toThrow( + "Phone number price ID not configured" + ); + }); + + it("should throw error if customer creation fails", async () => { + const { getPhoneNumberMonthlyPriceId } = await import("@calcom/app-store/stripepayment/lib/utils"); + vi.mocked(getPhoneNumberMonthlyPriceId).mockReturnValue("price_123"); + + const { getStripeCustomerIdFromUserId } = await import("@calcom/app-store/stripepayment/lib/customer"); + vi.mocked(getStripeCustomerIdFromUserId).mockResolvedValue(null); + + await expect(service.generatePhoneNumberCheckoutSession(validCheckoutData)).rejects.toThrow( + "Failed to create Stripe customer" + ); + }); + }); + + describe("cancelPhoneNumberSubscription", () => { + const validCancelData = { + phoneNumberId: 1, + userId: 1, + }; + + it("should successfully cancel subscription", async () => { + const mockPhoneNumber = createMockPhoneNumberRecord({ + id: 1, + stripeSubscriptionId: "sub_123", + subscriptionStatus: PhoneNumberSubscriptionStatus.ACTIVE, + }); + + mocks.mockPhoneNumberRepository.findByIdAndUserId.mockResolvedValue(mockPhoneNumber); + mocks.mockPhoneNumberRepository.updateSubscriptionStatus.mockResolvedValue(undefined); + mocks.mockRetellRepository.deletePhoneNumber.mockResolvedValue(undefined); + + const result = await service.cancelPhoneNumberSubscription(validCancelData); + + expect(result).toEqual({ + success: true, + message: "Phone number subscription cancelled successfully.", + }); + + const stripe = (await import("@calcom/features/ee/payments/server/stripe")).default; + expect(stripe.subscriptions.cancel).toHaveBeenCalledWith("sub_123"); + expect(mocks.mockPhoneNumberRepository.updateSubscriptionStatus).toHaveBeenCalledWith({ + id: 1, + subscriptionStatus: PhoneNumberSubscriptionStatus.CANCELLED, + disconnectOutboundAgent: true, + }); + expect(mocks.mockRetellRepository.deletePhoneNumber).toHaveBeenCalledWith("+1234567890"); + }); + + it("should handle team-scoped cancellation", async () => { + const mockPhoneNumber = createMockPhoneNumberRecord({ + stripeSubscriptionId: "sub_123", + }); + + mocks.mockPhoneNumberRepository.findByIdWithTeamAccess.mockResolvedValue(mockPhoneNumber); + + await service.cancelPhoneNumberSubscription({ + ...validCancelData, + teamId: 5, + }); + + expect(mocks.mockPhoneNumberRepository.findByIdWithTeamAccess).toHaveBeenCalledWith({ + id: 1, + teamId: 5, + userId: 1, + }); + }); + + it("should throw error if phone number not found", async () => { + mocks.mockPhoneNumberRepository.findByIdAndUserId.mockResolvedValue(null); + + await expect(service.cancelPhoneNumberSubscription(validCancelData)).rejects.toThrow( + "Phone number not found or you don't have permission to cancel it" + ); + }); + + it("should throw error if no active subscription", async () => { + const mockPhoneNumber = createMockPhoneNumberRecord({ + stripeSubscriptionId: null, + }); + + mocks.mockPhoneNumberRepository.findByIdAndUserId.mockResolvedValue(mockPhoneNumber); + + await expect(service.cancelPhoneNumberSubscription(validCancelData)).rejects.toThrow( + "Phone number doesn't have an active subscription" + ); + }); + + it("should handle Stripe cancellation failure", async () => { + const mockPhoneNumber = createMockPhoneNumberRecord({ + stripeSubscriptionId: "sub_123", + }); + + mocks.mockPhoneNumberRepository.findByIdAndUserId.mockResolvedValue(mockPhoneNumber); + const stripe = (await import("@calcom/features/ee/payments/server/stripe")).default; + stripe.subscriptions.cancel.mockRejectedValue(new TestError("Stripe API error")); + + await expect(service.cancelPhoneNumberSubscription(validCancelData)).rejects.toThrow( + "Failed to cancel subscription" + ); + }); + + it("should handle Retell deletion failure gracefully", async () => { + const mockPhoneNumber = createMockPhoneNumberRecord({ + stripeSubscriptionId: "sub_123", + }); + + mocks.mockPhoneNumberRepository.findByIdAndUserId.mockResolvedValue(mockPhoneNumber); + mocks.mockPhoneNumberRepository.updateSubscriptionStatus.mockResolvedValue(undefined); + mocks.mockRetellRepository.deletePhoneNumber.mockRejectedValue(new TestError("Retell API error")); + + const result = await service.cancelPhoneNumberSubscription(validCancelData); + + expect(result.success).toBe(true); + const stripe = (await import("@calcom/features/ee/payments/server/stripe")).default; + expect(stripe.subscriptions.cancel).toHaveBeenCalled(); + expect(mocks.mockPhoneNumberRepository.updateSubscriptionStatus).toHaveBeenCalled(); + }); + + it("should handle database update failure", async () => { + const mockPhoneNumber = createMockPhoneNumberRecord({ + stripeSubscriptionId: "sub_123", + }); + + mocks.mockPhoneNumberRepository.findByIdAndUserId.mockResolvedValue(mockPhoneNumber); + mocks.mockPhoneNumberRepository.updateSubscriptionStatus.mockRejectedValue( + new TestError("Database error") + ); + + await expect(service.cancelPhoneNumberSubscription(validCancelData)).rejects.toThrow( + "Failed to cancel subscription" + ); + }); + }); +}); diff --git a/packages/features/calAIPhone/providers/retellAI/services/__tests__/CallService.test.ts b/packages/features/calAIPhone/providers/retellAI/services/__tests__/CallService.test.ts new file mode 100644 index 0000000000..3838f0190a --- /dev/null +++ b/packages/features/calAIPhone/providers/retellAI/services/__tests__/CallService.test.ts @@ -0,0 +1,269 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError"; +import { HttpError } from "@calcom/lib/http-error"; + +import { CallService } from "../CallService"; +import { setupBasicMocks, createMockCall, createMockDatabaseAgent, TestError } from "./test-utils"; + +vi.mock("@calcom/features/ee/billing/credit-service", () => ({ + CreditService: vi.fn(), +})); + +vi.mock("@calcom/lib/checkRateLimitAndThrowError", () => ({ + checkRateLimitAndThrowError: vi.fn(), +})); + +describe("CallService", () => { + let service: CallService; + let mocks: ReturnType; + + beforeEach(async () => { + vi.clearAllMocks(); + + mocks = setupBasicMocks(); + + const { CreditService } = await import("@calcom/features/ee/billing/credit-service"); + const { checkRateLimitAndThrowError } = await import("@calcom/lib/checkRateLimitAndThrowError"); + + vi.mocked(CreditService).mockImplementation(() => ({ + getAllCredits: vi.fn().mockResolvedValue({ + totalRemainingMonthlyCredits: 100, + additionalCredits: 50, + }), + })); + + vi.mocked(checkRateLimitAndThrowError).mockResolvedValue(undefined); + + service = new CallService(mocks.mockRetellRepository, mocks.mockAgentRepository); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe("createPhoneCall", () => { + const validCallData = { + from_number: "+1234567890", + to_number: "+0987654321", + }; + + it("should successfully create phone call", async () => { + const mockCall = createMockCall(); + mocks.mockRetellRepository.createPhoneCall.mockResolvedValue(mockCall); + + const result = await service.createPhoneCall(validCallData); + + expect(result).toEqual(mockCall); + expect(mocks.mockRetellRepository.createPhoneCall).toHaveBeenCalledWith(validCallData); + }); + + it("should create phone call with dynamic variables", async () => { + const mockCall = createMockCall(); + mocks.mockRetellRepository.createPhoneCall.mockResolvedValue(mockCall); + + const callDataWithVariables = { + ...validCallData, + retell_llm_dynamic_variables: { + name: "John Doe", + company: "Acme Corp", + }, + }; + + await service.createPhoneCall(callDataWithVariables); + + expect(mocks.mockRetellRepository.createPhoneCall).toHaveBeenCalledWith(callDataWithVariables); + }); + + it("should handle Retell API errors", async () => { + mocks.mockRetellRepository.createPhoneCall.mockRejectedValue(new TestError("Retell API error")); + + await expect(service.createPhoneCall(validCallData)).rejects.toThrow( + "Failed to create phone call from +1234567890 to +0987654321" + ); + }); + }); + + describe("createTestCall", () => { + const validTestCallData = { + agentId: "agent-123", + phoneNumber: "+0987654321", + userId: 1, + }; + + const mockAgentWithPhoneNumber = createMockDatabaseAgent({ + outboundPhoneNumbers: [ + { + phoneNumber: "+1234567890", + }, + ], + }); + + it("should successfully create test call", async () => { + const mockCall = createMockCall(); + + mocks.mockAgentRepository.findByIdWithCallAccess.mockResolvedValue(mockAgentWithPhoneNumber); + mocks.mockRetellRepository.createPhoneCall.mockResolvedValue(mockCall); + + const result = await service.createTestCall(validTestCallData); + + expect(result).toEqual({ + callId: "call-123", + status: "registered", + message: "Call initiated to +0987654321 with call_id call-123", + }); + + expect(mocks.mockRetellRepository.createPhoneCall).toHaveBeenCalledWith({ + from_number: "+1234567890", + to_number: "+0987654321", + }); + }); + + it("should handle team-based credit validation", async () => { + mocks.mockAgentRepository.findByIdWithCallAccess.mockResolvedValue(mockAgentWithPhoneNumber); + mocks.mockRetellRepository.createPhoneCall.mockResolvedValue(createMockCall()); + + const result = await service.createTestCall({ + ...validTestCallData, + teamId: 5, + }); + + expect(result).toEqual({ + callId: "call-123", + status: "registered", + message: "Call initiated to +0987654321 with call_id call-123", + }); + }); + + it("should throw error if insufficient credits", async () => { + const { CreditService } = await import("@calcom/features/ee/billing/credit-service"); + vi.mocked(CreditService).mockImplementation(() => ({ + getAllCredits: vi.fn().mockResolvedValue({ + totalRemainingMonthlyCredits: 2, + additionalCredits: 1, + }), + })); + + await expect(service.createTestCall(validTestCallData)).rejects.toThrow( + "Insufficient credits to make test call. Need 5 credits, have 3" + ); + }); + + it("should throw error if no phone number provided", async () => { + await expect( + service.createTestCall({ + agentId: "agent-123", + userId: 1, + }) + ).rejects.toThrow("Phone number is required for test call"); + }); + + it("should throw error if agent not found", async () => { + mocks.mockAgentRepository.findByIdWithCallAccess.mockResolvedValue(null); + + await expect(service.createTestCall(validTestCallData)).rejects.toThrow( + "Agent not found or you don't have permission to use it" + ); + }); + + it("should throw error if agent has no phone number", async () => { + const agentWithoutPhoneNumber = createMockDatabaseAgent({ + outboundPhoneNumbers: [], + }); + + mocks.mockAgentRepository.findByIdWithCallAccess.mockResolvedValue(agentWithoutPhoneNumber); + + await expect(service.createTestCall(validTestCallData)).rejects.toThrow( + "Agent must have a phone number assigned to make calls" + ); + }); + + it("should handle call creation failure", async () => { + mocks.mockAgentRepository.findByIdWithCallAccess.mockResolvedValue(mockAgentWithPhoneNumber); + mocks.mockRetellRepository.createPhoneCall.mockRejectedValue(new TestError("Call creation failed")); + + await expect(service.createTestCall(validTestCallData)).rejects.toThrow( + "Failed to create phone call from +1234567890 to +0987654321" + ); + }); + + it("should handle rate limiting errors", async () => { + mocks.mockAgentRepository.findByIdWithCallAccess.mockResolvedValue(mockAgentWithPhoneNumber); + mocks.mockRetellRepository.createPhoneCall.mockResolvedValue(createMockCall()); + + vi.mocked(checkRateLimitAndThrowError).mockRejectedValue(new Error("Rate limit exceeded")); + + await expect(service.createTestCall(validTestCallData)).rejects.toThrow("Rate limit exceeded"); + + expect(checkRateLimitAndThrowError).toHaveBeenCalledWith({ + rateLimitingType: "core", + identifier: "test-call:1", + }); + }); + + it("should call rate limiting with correct identifier for different users", async () => { + mocks.mockAgentRepository.findByIdWithCallAccess.mockResolvedValue(mockAgentWithPhoneNumber); + mocks.mockRetellRepository.createPhoneCall.mockResolvedValue(createMockCall()); + + vi.mocked(checkRateLimitAndThrowError).mockResolvedValue(undefined); + + await service.createTestCall({ + ...validTestCallData, + userId: 42, + }); + + expect(checkRateLimitAndThrowError).toHaveBeenCalledWith({ + rateLimitingType: "core", + identifier: "test-call:42", + }); + }); + + it("should handle credit service errors", async () => { + mocks.mockAgentRepository.findByIdWithCallAccess.mockResolvedValue(mockAgentWithPhoneNumber); + mocks.mockRetellRepository.createPhoneCall.mockResolvedValue(createMockCall()); + + const { CreditService } = await import("@calcom/features/ee/billing/credit-service"); + vi.mocked(CreditService).mockImplementation(() => ({ + getAllCredits: vi.fn().mockRejectedValue(new TestError("Credit service unavailable")), + })); + + await expect(service.createTestCall(validTestCallData)).rejects.toThrow( + "Unable to validate credits. Please try again." + ); + }); + + it("should handle insufficient credits properly", async () => { + mocks.mockAgentRepository.findByIdWithCallAccess.mockResolvedValue(mockAgentWithPhoneNumber); + mocks.mockRetellRepository.createPhoneCall.mockResolvedValue(createMockCall()); + + const { CreditService } = await import("@calcom/features/ee/billing/credit-service"); + vi.mocked(CreditService).mockImplementation(() => ({ + getAllCredits: vi.fn().mockResolvedValue({ + totalRemainingMonthlyCredits: 2, + additionalCredits: 1, + }), + })); + + await expect(service.createTestCall(validTestCallData)).rejects.toThrow( + "Insufficient credits to make test call. Need 5 credits, have 3" + ); + }); + + it("should handle null credit values gracefully", async () => { + mocks.mockAgentRepository.findByIdWithCallAccess.mockResolvedValue(mockAgentWithPhoneNumber); + mocks.mockRetellRepository.createPhoneCall.mockResolvedValue(createMockCall()); + + const { CreditService } = await import("@calcom/features/ee/billing/credit-service"); + vi.mocked(CreditService).mockImplementation(() => ({ + getAllCredits: vi.fn().mockResolvedValue({ + totalRemainingMonthlyCredits: null, + additionalCredits: null, + }), + })); + + await expect(service.createTestCall(validTestCallData)).rejects.toThrow( + "Insufficient credits to make test call. Need 5 credits, have 0" + ); + }); + }); +}); diff --git a/packages/features/calAIPhone/providers/retellAI/services/__tests__/PhoneNumberService.test.ts b/packages/features/calAIPhone/providers/retellAI/services/__tests__/PhoneNumberService.test.ts new file mode 100644 index 0000000000..493f1a71f5 --- /dev/null +++ b/packages/features/calAIPhone/providers/retellAI/services/__tests__/PhoneNumberService.test.ts @@ -0,0 +1,316 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +import { HttpError } from "@calcom/lib/http-error"; +import { PhoneNumberSubscriptionStatus } from "@calcom/prisma/enums"; + +import { PhoneNumberService } from "../PhoneNumberService"; +import { + setupBasicMocks, + createMockPhoneNumber, + createMockDatabaseAgent, + createMockPhoneNumberRecord, + TestError, +} from "./test-utils"; + +describe("PhoneNumberService", () => { + let service: PhoneNumberService; + let mocks: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + mocks = setupBasicMocks(); + + service = new PhoneNumberService( + mocks.mockRetellRepository, + mocks.mockAgentRepository, + mocks.mockPhoneNumberRepository, + mocks.mockTransactionManager + ); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe("importPhoneNumber", () => { + const validImportData = { + phone_number: "+1234567890", + termination_uri: "https://example.com/webhook", + sip_trunk_auth_username: "user", + sip_trunk_auth_password: "pass", + userId: 1, + nickname: "Test Number", + }; + + it("should successfully import phone number without agent", async () => { + const mockPhoneNumber = createMockPhoneNumber(); + mocks.mockRetellRepository.importPhoneNumber.mockResolvedValue(mockPhoneNumber); + + const result = await service.importPhoneNumber(validImportData); + + expect(result).toEqual(mockPhoneNumber); + expect(mocks.mockRetellRepository.importPhoneNumber).toHaveBeenCalledWith({ + phone_number: "+1234567890", + termination_uri: "https://example.com/webhook", + sip_trunk_auth_username: "user", + sip_trunk_auth_password: "pass", + nickname: "Test Number", + }); + expect(mocks.mockTransactionManager.executeInTransaction).toHaveBeenCalled(); + }); + + it("should successfully import phone number with agent", async () => { + const mockPhoneNumber = createMockPhoneNumber(); + const mockAgent = createMockDatabaseAgent(); + + mocks.mockRetellRepository.importPhoneNumber.mockResolvedValue(mockPhoneNumber); + mocks.mockAgentRepository.findByIdWithUserAccess.mockResolvedValue(mockAgent); + mocks.mockRetellRepository.updatePhoneNumber.mockResolvedValue(mockPhoneNumber); + + const result = await service.importPhoneNumber({ + ...validImportData, + agentId: "agent-123", + }); + + expect(result).toEqual(mockPhoneNumber); + expect(mocks.mockRetellRepository.updatePhoneNumber).toHaveBeenCalledWith("+1234567890", { + outbound_agent_id: mockAgent.providerAgentId, + }); + }); + + it("should validate team permissions when teamId provided", async () => { + mocks.mockAgentRepository.canManageTeamResources.mockResolvedValue(false); + + await expect( + service.importPhoneNumber({ + ...validImportData, + teamId: 1, + }) + ).rejects.toThrow(HttpError); + + expect(mocks.mockAgentRepository.canManageTeamResources).toHaveBeenCalledWith({ + userId: 1, + teamId: 1, + }); + }); + + it("should validate agent permissions when agentId provided", async () => { + mocks.mockAgentRepository.findByIdWithUserAccess.mockResolvedValue(null); + + await expect( + service.importPhoneNumber({ + ...validImportData, + agentId: "invalid-agent", + }) + ).rejects.toThrow(HttpError); + + expect(mocks.mockAgentRepository.findByIdWithUserAccess).toHaveBeenCalledWith({ + agentId: "invalid-agent", + userId: 1, + }); + }); + + it("should handle transaction rollback with successful cleanup", async () => { + const mockPhoneNumber = createMockPhoneNumber(); + mocks.mockRetellRepository.importPhoneNumber.mockResolvedValue(mockPhoneNumber); + mocks.mockRetellRepository.deletePhoneNumber.mockResolvedValue(undefined); + + // Mock database failure + mocks.mockTransactionManager.executeInTransaction.mockImplementation(async (callback) => { + const mockContext = { + phoneNumberRepository: { + createPhoneNumber: vi.fn().mockRejectedValue(new TestError("Database connection failed")), + }, + }; + return await callback(mockContext); + }); + + await expect(service.importPhoneNumber(validImportData)).rejects.toThrow("Database connection failed"); + + // Verify cleanup was attempted + expect(mocks.mockRetellRepository.deletePhoneNumber).toHaveBeenCalledWith("+1234567890"); + }); + + it("should handle compensation failure and throw critical error", async () => { + const mockPhoneNumber = createMockPhoneNumber(); + mocks.mockRetellRepository.importPhoneNumber.mockResolvedValue(mockPhoneNumber); + + // Mock both database failure and cleanup failure + mocks.mockTransactionManager.executeInTransaction.mockImplementation(async (callback) => { + const mockContext = { + phoneNumberRepository: { + createPhoneNumber: vi.fn().mockRejectedValue(new TestError("Database connection failed")), + }, + }; + return await callback(mockContext); + }); + mocks.mockRetellRepository.deletePhoneNumber.mockRejectedValue(new TestError("Retell API unavailable")); + + await expect(service.importPhoneNumber(validImportData)).rejects.toThrow( + "Failed to cleanup Retell phone number +1234567890 after transaction failure. Manual cleanup required." + ); + + expect(mocks.mockRetellRepository.deletePhoneNumber).toHaveBeenCalledWith("+1234567890"); + }); + }); + + describe("deletePhoneNumber", () => { + it("should successfully delete phone number", async () => { + const mockRecord = createMockPhoneNumberRecord({ + subscriptionStatus: PhoneNumberSubscriptionStatus.PENDING, + }); + mocks.mockPhoneNumberRepository.findByPhoneNumberAndUserId.mockResolvedValue(mockRecord); + + await service.deletePhoneNumber({ + phoneNumber: "+1234567890", + userId: 1, + deleteFromDB: false, + }); + + expect(mocks.mockRetellRepository.updatePhoneNumber).toHaveBeenCalledWith("+1234567890", { + inbound_agent_id: null, + outbound_agent_id: null, + }); + expect(mocks.mockRetellRepository.deletePhoneNumber).toHaveBeenCalledWith("+1234567890"); + }); + + it("should throw error if phone number not found", async () => { + mocks.mockPhoneNumberRepository.findByPhoneNumberAndUserId.mockResolvedValue(null); + + await expect( + service.deletePhoneNumber({ + phoneNumber: "+1234567890", + userId: 1, + deleteFromDB: false, + }) + ).rejects.toThrow(HttpError); + }); + + it("should throw error if phone number is still active", async () => { + const mockRecord = createMockPhoneNumberRecord({ + subscriptionStatus: PhoneNumberSubscriptionStatus.ACTIVE, + }); + mocks.mockPhoneNumberRepository.findByPhoneNumberAndUserId.mockResolvedValue(mockRecord); + + await expect( + service.deletePhoneNumber({ + phoneNumber: "+1234567890", + userId: 1, + deleteFromDB: false, + }) + ).rejects.toThrow("Phone number is still active"); + }); + + it("should handle team-scoped deletion", async () => { + const mockRecord = createMockPhoneNumberRecord({ + subscriptionStatus: PhoneNumberSubscriptionStatus.PENDING, + }); + mocks.mockPhoneNumberRepository.findByPhoneNumberAndTeamId.mockResolvedValue(mockRecord); + + await service.deletePhoneNumber({ + phoneNumber: "+1234567890", + userId: 1, + teamId: 1, + deleteFromDB: true, + }); + + expect(mocks.mockPhoneNumberRepository.findByPhoneNumberAndTeamId).toHaveBeenCalledWith({ + phoneNumber: "+1234567890", + teamId: 1, + userId: 1, + }); + expect(mocks.mockPhoneNumberRepository.deletePhoneNumber).toHaveBeenCalledWith({ + phoneNumber: "+1234567890", + }); + }); + }); + + describe("updatePhoneNumberWithAgents", () => { + it("should successfully update phone number with agents", async () => { + const mockRecord = createMockPhoneNumberRecord(); + const mockAgent = createMockDatabaseAgent(); + + mocks.mockPhoneNumberRepository.findByPhoneNumberAndUserId.mockResolvedValue(mockRecord); + mocks.mockAgentRepository.findByProviderAgentIdWithUserAccess.mockResolvedValue(mockAgent); + mocks.mockRetellRepository.getPhoneNumber.mockResolvedValue(createMockPhoneNumber()); + + const result = await service.updatePhoneNumberWithAgents({ + phoneNumber: "+1234567890", + userId: 1, + inboundAgentId: "inbound-agent-123", + outboundAgentId: "outbound-agent-123", + }); + + expect(result.message).toBe("Phone number updated successfully"); + expect(mocks.mockPhoneNumberRepository.updateAgents).toHaveBeenCalledWith({ + id: 1, + inboundProviderAgentId: "inbound-agent-123", + outboundProviderAgentId: "outbound-agent-123", + }); + }); + + it("should validate agent permissions", async () => { + const mockRecord = createMockPhoneNumberRecord(); + + mocks.mockPhoneNumberRepository.findByPhoneNumberAndUserId.mockResolvedValue(mockRecord); + mocks.mockAgentRepository.findByProviderAgentIdWithUserAccess.mockResolvedValue(null); + + await expect( + service.updatePhoneNumberWithAgents({ + phoneNumber: "+1234567890", + userId: 1, + inboundAgentId: "invalid-agent", + }) + ).rejects.toThrow("You don't have permission to use the selected inbound agent"); + }); + + it("should handle phone number not found in Retell gracefully", async () => { + const mockRecord = createMockPhoneNumberRecord(); + const mockAgent = createMockDatabaseAgent(); + + mocks.mockPhoneNumberRepository.findByPhoneNumberAndUserId.mockResolvedValue(mockRecord); + mocks.mockAgentRepository.findByProviderAgentIdWithUserAccess.mockResolvedValue(mockAgent); + mocks.mockRetellRepository.getPhoneNumber.mockRejectedValue(new TestError("404 Not Found")); + + // Should not throw - should handle gracefully + const result = await service.updatePhoneNumberWithAgents({ + phoneNumber: "+1234567890", + userId: 1, + inboundAgentId: "agent-123", + }); + + expect(result.message).toBe("Phone number updated successfully"); + expect(mocks.mockPhoneNumberRepository.updateAgents).toHaveBeenCalled(); + }); + }); + + describe("getPhoneNumber", () => { + it("should return phone number from Retell", async () => { + const mockPhoneNumber = createMockPhoneNumber(); + mocks.mockRetellRepository.getPhoneNumber.mockResolvedValue(mockPhoneNumber); + + const result = await service.getPhoneNumber("+1234567890"); + + expect(result).toEqual(mockPhoneNumber); + expect(mocks.mockRetellRepository.getPhoneNumber).toHaveBeenCalledWith("+1234567890"); + }); + }); + + describe("updatePhoneNumber", () => { + it("should update phone number in Retell", async () => { + const mockPhoneNumber = createMockPhoneNumber(); + mocks.mockRetellRepository.updatePhoneNumber.mockResolvedValue(mockPhoneNumber); + + const result = await service.updatePhoneNumber("+1234567890", { + inbound_agent_id: "inbound-123", + outbound_agent_id: "outbound-123", + }); + + expect(result).toEqual(mockPhoneNumber); + expect(mocks.mockRetellRepository.updatePhoneNumber).toHaveBeenCalledWith("+1234567890", { + inbound_agent_id: "inbound-123", + outbound_agent_id: "outbound-123", + }); + }); + }); +}); diff --git a/packages/features/calAIPhone/providers/retellAI/services/__tests__/test-utils.ts b/packages/features/calAIPhone/providers/retellAI/services/__tests__/test-utils.ts new file mode 100644 index 0000000000..9cdc7262c5 --- /dev/null +++ b/packages/features/calAIPhone/providers/retellAI/services/__tests__/test-utils.ts @@ -0,0 +1,206 @@ +import { vi } from "vitest"; + +import type { AgentRepositoryInterface } from "../../interfaces/AgentRepositoryInterface"; +import type { PhoneNumberRepositoryInterface } from "../../interfaces/PhoneNumberRepositoryInterface"; +import type { TransactionInterface } from "../../interfaces/TransactionInterface"; +import type { RetellAIRepository, RetellAgent, RetellLLM, RetellPhoneNumber, RetellCall } from "../types"; + +/** + * Shared testing utilities and mock factories for service tests + * Reduces boilerplate and ensures consistent test setup + */ + +// Mock Factories for Domain Objects +export const createMockAgent = (overrides: Partial = {}): RetellAgent => ({ + agent_id: "agent-123", + agent_name: "Test Agent", + voice_id: "voice-123", + language: "en", + responsiveness: 1.0, + interruption_sensitivity: 1.0, + llm_websocket_url: "wss://example.com", + ...overrides, +}); + +export const createMockLLM = (overrides: Partial = {}): RetellLLM => ({ + llm_id: "llm-123", + general_prompt: "You are a helpful assistant", + general_tools: [], + states: [], + starting_state: "default", + inbound_dynamic_variables_webhook_url: null, + outbound_dynamic_variables_webhook_url: null, + ...overrides, +}); + +export const createMockPhoneNumber = (overrides: Partial = {}): RetellPhoneNumber => ({ + phone_number: "+1234567890", + phone_number_pretty: "(123) 456-7890", + inbound_agent_id: null, + outbound_agent_id: null, + ...overrides, +}); + +export const createMockCall = (overrides: Partial = {}): RetellCall => ({ + call_id: "call-123", + call_status: "registered", + agent_id: "agent-123", + from_number: "+1234567890", + to_number: "+0987654321", + direction: "outbound", + ...overrides, +}); + +export const createMockDatabaseAgent = (overrides: any = {}) => ({ + id: "db-agent-123", + name: "Test Agent", + providerAgentId: "agent-123", + userId: 1, + teamId: null, + enabled: true, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, +}); + +export const createMockPhoneNumberRecord = (overrides: any = {}) => ({ + id: 1, + phoneNumber: "+1234567890", + userId: 1, + teamId: null, + provider: "retell", // Default to retell provider for regular phone numbers + stripeSubscriptionId: "sub_123", + subscriptionStatus: "ACTIVE", + outboundAgentId: null, + inboundAgentId: null, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, +}); + +// Helper for creating custom telephony phone number records +export const createMockCustomTelephonyPhoneNumberRecord = (overrides: any = {}) => + createMockPhoneNumberRecord({ provider: "custom-telephony", ...overrides }); + +// Repository Mock Factories +export const createMockRetellRepository = (): RetellAIRepository & { + [K in keyof RetellAIRepository]: vi.Mock; +} => { + return { + createLLM: vi.fn(), + getLLM: vi.fn(), + updateLLM: vi.fn(), + deleteLLM: vi.fn(), + createAgent: vi.fn(), + getAgent: vi.fn(), + updateAgent: vi.fn(), + deleteAgent: vi.fn(), + createPhoneNumber: vi.fn(), + importPhoneNumber: vi.fn(), + deletePhoneNumber: vi.fn(), + getPhoneNumber: vi.fn(), + updatePhoneNumber: vi.fn(), + createPhoneCall: vi.fn(), + }; +}; + +export const createMockAgentRepository = (): AgentRepositoryInterface & { + [K in keyof AgentRepositoryInterface]: vi.Mock; +} => { + return { + canManageTeamResources: vi.fn(), + findByIdWithUserAccess: vi.fn(), + findByProviderAgentIdWithUserAccess: vi.fn(), + findManyWithUserAccess: vi.fn(), + findByIdWithUserAccessAndDetails: vi.fn(), + create: vi.fn(), + findByIdWithAdminAccess: vi.fn(), + findByIdWithCallAccess: vi.fn(), + delete: vi.fn(), + linkToWorkflowStep: vi.fn(), + }; +}; + +export const createMockPhoneNumberRepository = (): PhoneNumberRepositoryInterface & { + [K in keyof PhoneNumberRepositoryInterface]: vi.Mock; +} => { + return { + createPhoneNumber: vi.fn(), + findByPhoneNumberAndUserId: vi.fn(), + findByPhoneNumberAndTeamId: vi.fn(), + deletePhoneNumber: vi.fn(), + updateAgents: vi.fn(), + updateSubscriptionStatus: vi.fn(), + findByIdAndUserId: vi.fn(), + findByIdWithTeamAccess: vi.fn(), + }; +}; + +export const createMockTransactionManager = (): TransactionInterface & { + [K in keyof TransactionInterface]: vi.Mock; +} => { + return { + executeInTransaction: vi.fn().mockImplementation(async (callback) => { + // Default successful transaction simulation + const mockContext = { + phoneNumberRepository: { + createPhoneNumber: vi.fn().mockResolvedValue({}), + }, + }; + return await callback(mockContext); + }), + }; +}; + +// Common Test Setup Functions +export const setupBasicMocks = () => { + const mockRetellRepository = createMockRetellRepository(); + const mockAgentRepository = createMockAgentRepository(); + const mockPhoneNumberRepository = createMockPhoneNumberRepository(); + const mockTransactionManager = createMockTransactionManager(); + + return { + mockRetellRepository, + mockAgentRepository, + mockPhoneNumberRepository, + mockTransactionManager, + }; +}; + +// Credit Service Mocks +export const setupCreditMocks = () => { + const mockCreditService = { + getAllCredits: vi.fn().mockResolvedValue({ + totalRemainingMonthlyCredits: 100, + additionalCredits: 50, + }), + }; + + vi.mock("@calcom/features/ee/billing/credit-service", () => ({ + CreditService: vi.fn().mockImplementation(() => mockCreditService), + })); + + return mockCreditService; +}; + +// Rate Limiting Mocks +export const setupRateLimitMocks = () => { + vi.mock("@calcom/lib/checkRateLimitAndThrowError", () => ({ + checkRateLimitAndThrowError: vi.fn().mockResolvedValue(undefined), + })); +}; + +// Test Error Classes +export class TestError extends Error { + constructor(message: string, public code?: string) { + super(message); + this.name = "TestError"; + } +} + +export class TestRetellAPIError extends Error { + constructor(message: string, public statusCode: number = 500) { + super(message); + this.name = "TestRetellAPIError"; + } +} diff --git a/packages/features/calAIPhone/providers/retellAI/types.ts b/packages/features/calAIPhone/providers/retellAI/types.ts new file mode 100644 index 0000000000..bd5ab6377d --- /dev/null +++ b/packages/features/calAIPhone/providers/retellAI/types.ts @@ -0,0 +1,165 @@ +import type { Retell } from "retell-sdk"; + +import type { PrismaAgentRepository } from "@calcom/lib/server/repository/PrismaAgentRepository"; + +export type RetellLLM = Retell.LlmResponse; +export type RetellPhoneNumber = Retell.PhoneNumberResponse; +export type RetellCall = Retell.PhoneCallResponse; +export type RetellDynamicVariables = { [key: string]: unknown }; + +export type RetellAgent = Retell.AgentResponse; + +export type RetellAgentWithRetellLm = Retell.AgentResponse & { + response_engine: Retell.AgentResponse.ResponseEngineRetellLm; +}; + +export type RetellAgentWithCustomLm = Retell.AgentResponse & { + response_engine: Retell.AgentResponse.ResponseEngineCustomLm; +}; + +export type RetellAgentWithConversationFlow = Retell.AgentResponse & { + response_engine: Retell.AgentResponse.ResponseEngineConversationFlow; +}; + +// Type guards for safe access to response engine properties +export function isRetellLmAgent(agent: RetellAgent): agent is RetellAgentWithRetellLm { + return agent.response_engine.type === "retell-llm"; +} + +export function isCustomLmAgent(agent: RetellAgent): agent is RetellAgentWithCustomLm { + return agent.response_engine.type === "custom-llm"; +} + +export function isConversationFlowAgent(agent: RetellAgent): agent is RetellAgentWithConversationFlow { + return agent.response_engine.type === "conversation-flow"; +} + +export function getLlmId(agent: RetellAgent): string | null { + if (isRetellLmAgent(agent)) { + return agent.response_engine.llm_id; + } + return null; +} + +export type Language = + | "en-US" + | "en-IN" + | "en-GB" + | "en-AU" + | "en-NZ" + | "de-DE" + | "es-ES" + | "es-419" + | "hi-IN" + | "fr-FR" + | "fr-CA" + | "ja-JP" + | "pt-PT" + | "pt-BR" + | "zh-CN" + | "ru-RU" + | "it-IT" + | "ko-KR" + | "nl-NL" + | "nl-BE" + | "pl-PL" + | "tr-TR" + | "th-TH" + | "vi-VN" + | "ro-RO" + | "bg-BG" + | "ca-ES" + | "da-DK" + | "fi-FI" + | "el-GR" + | "hu-HU" + | "id-ID" + | "no-NO" + | "sk-SK" + | "sv-SE" + | "multi"; +// Request/response types +export type CreateLLMRequest = Retell.LlmCreateParams; +export type CreatePhoneNumberParams = Retell.PhoneNumberCreateParams; +export type CreatePhoneCallParams = Retell.CallCreatePhoneCallParams; +export type UpdatePhoneNumberParams = Retell.PhoneNumberUpdateParams; +export type ImportPhoneNumberParams = Retell.PhoneNumberImportParams; +export type RetellLLMGeneralTools = Retell.LlmCreateParams["general_tools"]; +export type CreateAgentRequest = Retell.AgentCreateParams; +export type UpdateLLMRequest = Retell.LlmUpdateParams; +export type UpdateAgentRequest = Retell.AgentUpdateParams; +export type Agent = NonNullable< + Awaited> +>; + +export type RetellAgentWithDetails = { + id: string; + name: string; + providerAgentId: string; + enabled: boolean; + userId: number; + teamId: number | null; + outboundPhoneNumbers: Agent["outboundPhoneNumbers"]; + retellData: { + agentId: RetellAgent["agent_id"]; + agentName: RetellAgent["agent_name"]; + voiceId: RetellAgent["voice_id"]; + responseEngine: RetellAgent["response_engine"]; + language: RetellAgent["language"]; + responsiveness: RetellAgent["responsiveness"]; + interruptionSensitivity: RetellAgent["interruption_sensitivity"]; + generalPrompt: RetellLLM["general_prompt"]; + beginMessage: RetellLLM["begin_message"]; + generalTools: RetellLLM["general_tools"]; + llmId: RetellLLM["llm_id"]; + }; + createdAt: Date; + updatedAt: Date; +}; + +export interface AIConfigurationSetup { + calApiKey?: string; + timeZone?: string; + eventTypeId?: number; + generalPrompt?: string; + beginMessage?: string; + generalTools?: Retell.LlmCreateParams["general_tools"]; +} + +export interface AIConfigurationDeletion { + llmId?: string; + agentId?: string; +} + +export interface DeletionResult { + success: boolean; + errors: string[]; + deleted: { + llm: boolean; + agent: boolean; + }; +} + +export interface RetellAIRepository { + // LLM operations + createLLM(data: CreateLLMRequest): Promise; + getLLM(llmId: string): Promise; + updateLLM(llmId: string, data: UpdateLLMRequest): Promise; + deleteLLM(llmId: string): Promise; + + // Agent operations + createAgent(data: CreateAgentRequest): Promise; + getAgent(agentId: string): Promise; + updateAgent(agentId: string, data: UpdateAgentRequest): Promise; + deleteAgent(agentId: string): Promise; + + // Phone number operations + createPhoneNumber(data: CreatePhoneNumberParams): Promise; + importPhoneNumber(data: ImportPhoneNumberParams): Promise; + deletePhoneNumber(phoneNumber: string): Promise; + getPhoneNumber(phoneNumber: string): Promise; + updatePhoneNumber(phoneNumber: string, data: UpdatePhoneNumberParams): Promise; + + // Call operations + createPhoneCall(data: CreatePhoneCallParams): Promise; +} diff --git a/packages/features/ee/cal-ai-phone/retellAIService.ts b/packages/features/calAIPhone/retellAIService.ts similarity index 100% rename from packages/features/ee/cal-ai-phone/retellAIService.ts rename to packages/features/calAIPhone/retellAIService.ts diff --git a/packages/features/ee/cal-ai-phone/template-fields-map.ts b/packages/features/calAIPhone/template-fields-map.ts similarity index 95% rename from packages/features/ee/cal-ai-phone/template-fields-map.ts rename to packages/features/calAIPhone/template-fields-map.ts index 0790a42451..9ac2985afd 100644 --- a/packages/features/ee/cal-ai-phone/template-fields-map.ts +++ b/packages/features/calAIPhone/template-fields-map.ts @@ -1,7 +1,7 @@ import type { TemplateType, Fields } from "./zod-utils"; import { fieldNameEnum } from "./zod-utils"; -export const TEMPLATES_FIELDS: Record = { +export const templateFieldsMap: Record = { CHECK_IN_APPOINTMENT: [ { type: "text", diff --git a/packages/features/ee/cal-ai-phone/zod-utils.ts b/packages/features/calAIPhone/zod-utils.ts similarity index 80% rename from packages/features/ee/cal-ai-phone/zod-utils.ts rename to packages/features/calAIPhone/zod-utils.ts index 571b984002..531569a47f 100644 --- a/packages/features/ee/cal-ai-phone/zod-utils.ts +++ b/packages/features/calAIPhone/zod-utils.ts @@ -44,6 +44,7 @@ export type TCreatePhoneCallSchema = z.infer; export const ZGetPhoneNumberSchema = z .object({ + phone_number: z.string(), agent_id: z.string().optional(), nickname: z.string(), inbound_agent_id: z.string(), @@ -172,3 +173,28 @@ export const ZGetRetellLLMSchema = z .passthrough(); export type TGetRetellLLMSchema = z.infer; + +export const ZCreatePhoneNumberResponseSchema = z.object({ + phone_number: z.string(), + phone_number_type: z.string(), + phone_number_pretty: z.string(), + inbound_agent_id: z.string().optional().nullable(), + outbound_agent_id: z.string().optional().nullable(), + inbound_agent_version: z.number().optional().nullable(), + outbound_agent_version: z.number().optional().nullable(), + area_code: z.number().optional().nullable(), + nickname: z.string(), + inbound_webhook_url: z.string().url().optional().nullable(), + last_modification_timestamp: z.number(), +}); +export type TCreatePhoneNumberResponseSchema = z.infer; + +export const ZCreateAgentResponseSchema = z.object({ + agent_id: z.string(), + agent_name: z.string(), +}); + +export type TCreateAgentResponseSchema = z.infer; + +export const ZUpdatePhoneNumberResponseSchema = ZCreatePhoneNumberResponseSchema; +export type TUpdatePhoneNumberResponseSchema = TCreatePhoneNumberResponseSchema; diff --git a/packages/features/ee/workflows/components/WorkflowDetailsPage.tsx b/packages/features/ee/workflows/components/WorkflowDetailsPage.tsx index f078c3897f..601edfecc1 100644 --- a/packages/features/ee/workflows/components/WorkflowDetailsPage.tsx +++ b/packages/features/ee/workflows/components/WorkflowDetailsPage.tsx @@ -119,6 +119,7 @@ export default function WorkflowDetailsPage(props: Props) { numberVerificationPending: false, includeCalendarEvent: false, verifiedAt: SCANNING_WORKFLOW_STEPS ? null : new Date(), + agentId: null }; steps?.push(step); form.setValue("steps", steps); diff --git a/packages/features/ee/workflows/lib/constants.ts b/packages/features/ee/workflows/lib/constants.ts index c288e37ff2..2114975de7 100644 --- a/packages/features/ee/workflows/lib/constants.ts +++ b/packages/features/ee/workflows/lib/constants.ts @@ -18,6 +18,7 @@ export const WORKFLOW_ACTIONS = [ WorkflowActions.SMS_NUMBER, WorkflowActions.WHATSAPP_ATTENDEE, WorkflowActions.WHATSAPP_NUMBER, + WorkflowActions.CAL_AI_PHONE_CALL, ] as const; export const TIME_UNIT = [TimeUnit.DAY, TimeUnit.HOUR, TimeUnit.MINUTE] as const; diff --git a/packages/features/ee/workflows/lib/getOptions.ts b/packages/features/ee/workflows/lib/getOptions.ts index 01e3644d73..dcfea6891e 100644 --- a/packages/features/ee/workflows/lib/getOptions.ts +++ b/packages/features/ee/workflows/lib/getOptions.ts @@ -1,6 +1,6 @@ import type { TFunction } from "i18next"; -import type { WorkflowActions } from "@calcom/prisma/enums"; +import { WorkflowActions } from "@calcom/prisma/enums"; import { WorkflowTemplates, WorkflowTriggerEvents } from "@calcom/prisma/enums"; import { isSMSOrWhatsappAction, isWhatsappAction, isEmailToAttendeeAction } from "./actionHelperFunctions"; @@ -13,7 +13,7 @@ import { } from "./constants"; export function getWorkflowActionOptions(t: TFunction, isOrgsPlan?: boolean) { - return WORKFLOW_ACTIONS.map((action) => { + return WORKFLOW_ACTIONS.filter((action) => action !== WorkflowActions.CAL_AI_PHONE_CALL).map((action) => { const actionString = t(`${action.toLowerCase()}_action`); return { diff --git a/packages/features/eventtypes/components/tabs/ai/AIEventController.tsx b/packages/features/eventtypes/components/tabs/ai/AIEventController.tsx index e03b6de78d..3c6596f91a 100644 --- a/packages/features/eventtypes/components/tabs/ai/AIEventController.tsx +++ b/packages/features/eventtypes/components/tabs/ai/AIEventController.tsx @@ -5,9 +5,9 @@ import { useFormContext, Controller } from "react-hook-form"; import { z } from "zod"; import PhoneInput from "@calcom/features/components/phone-input"; -import { getTemplateFieldsSchema } from "@calcom/features/ee/cal-ai-phone/getTemplateFieldsSchema"; -import { TEMPLATES_FIELDS } from "@calcom/features/ee/cal-ai-phone/template-fields-map"; -import type { TemplateType } from "@calcom/features/ee/cal-ai-phone/zod-utils"; +import { getTemplateFieldsSchema } from "@calcom/features/calAIPhone/getTemplateFieldsSchema"; +import { templateFieldsMap } from "@calcom/features/calAIPhone/template-fields-map"; +import type { TemplateType } from "@calcom/features/calAIPhone/zod-utils"; import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired"; import type { EventTypeSetup, FormValues } from "@calcom/features/eventtypes/lib/types"; import { ComponentForField } from "@calcom/features/form-builder/FormBuilderField"; @@ -102,7 +102,7 @@ const TemplateFields = () => { const { control, watch } = formMethods; const templateType = watch("aiPhoneCallConfig.templateType"); - const fields = TEMPLATES_FIELDS[templateType as TemplateType]; + const fields = templateFieldsMap[templateType as TemplateType]; return (
diff --git a/packages/features/handleCreatePhoneCall.ts b/packages/features/handleCreatePhoneCall.ts index c9b084fd61..813286460d 100644 --- a/packages/features/handleCreatePhoneCall.ts +++ b/packages/features/handleCreatePhoneCall.ts @@ -1,7 +1,7 @@ -import { PROMPT_TEMPLATES } from "@calcom/features/ee/cal-ai-phone/promptTemplates"; -import { RetellAIService, validatePhoneNumber } from "@calcom/features/ee/cal-ai-phone/retellAIService"; -import { templateTypeEnum } from "@calcom/features/ee/cal-ai-phone/zod-utils"; -import type { TCreatePhoneCallSchema } from "@calcom/features/ee/cal-ai-phone/zod-utils"; +import { PROMPT_TEMPLATES } from "@calcom/features/calAIPhone/promptTemplates"; +import { RetellAIService, validatePhoneNumber } from "@calcom/features/calAIPhone/retellAIService"; +import { templateTypeEnum } from "@calcom/features/calAIPhone/zod-utils"; +import type { TCreatePhoneCallSchema } from "@calcom/features/calAIPhone/zod-utils"; import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError"; import logger from "@calcom/lib/logger"; import prisma from "@calcom/prisma"; diff --git a/packages/lib/server/repository/PrismaAgentRepository.ts b/packages/lib/server/repository/PrismaAgentRepository.ts new file mode 100644 index 0000000000..15c8d627f9 --- /dev/null +++ b/packages/lib/server/repository/PrismaAgentRepository.ts @@ -0,0 +1,536 @@ +import { Prisma } from "@prisma/client"; + +import prisma from "@calcom/prisma"; +import { MembershipRole } from "@calcom/prisma/enums"; + +interface _AgentRawResult { + id: string; + name: string; + providerAgentId: string; + enabled: boolean; + userId: number; + teamId: number | null; + createdAt: Date; + updatedAt: Date; + user_id?: number; + user_name?: string; + user_email?: string; + team_id?: number; + team_name?: string; + team_slug?: string; + team_logo_url?: string; +} + +interface _PhoneNumberRawResult { + id: number; + phoneNumber: string; + subscriptionStatus: string; + provider: string; + outboundAgentId?: string; +} + +export class PrismaAgentRepository { + private static async getUserAccessibleTeamIds(userId: number): Promise { + const memberships = await prisma.membership.findMany({ + where: { + userId, + accepted: true, + }, + select: { + teamId: true, + }, + }); + + return memberships.map((membership) => membership.teamId); + } + + private static async getUserAdminTeamIds(userId: number): Promise { + const memberships = await prisma.membership.findMany({ + where: { + userId, + accepted: true, + role: { + in: [MembershipRole.ADMIN, MembershipRole.OWNER], + }, + }, + select: { + teamId: true, + }, + }); + + return memberships.map((membership) => membership.teamId); + } + + static async findByIdWithUserAccess({ agentId, userId }: { agentId: string; userId: number }) { + const accessibleTeamIds = await this.getUserAccessibleTeamIds(userId); + + let whereCondition: Prisma.Sql; + if (accessibleTeamIds.length > 0) { + whereCondition = Prisma.sql`id = ${agentId} AND ("userId" = ${userId} OR "teamId" IN (${Prisma.join( + accessibleTeamIds + )}))`; + } else { + whereCondition = Prisma.sql`id = ${agentId} AND "userId" = ${userId}`; + } + + const agents = await prisma.$queryRaw<_AgentRawResult[]>` + SELECT + id, + name, + "providerAgentId", + enabled, + "userId", + "teamId", + "createdAt", + "updatedAt" + FROM "Agent" + WHERE ${whereCondition} + LIMIT 1 + `; + + return agents.length > 0 ? agents[0] : null; + } + + static async findByProviderAgentIdWithUserAccess({ + providerAgentId, + userId, + }: { + providerAgentId: string; + userId: number; + }) { + const accessibleTeamIds = await this.getUserAccessibleTeamIds(userId); + + let whereCondition: Prisma.Sql; + if (accessibleTeamIds.length > 0) { + whereCondition = Prisma.sql`"providerAgentId" = ${providerAgentId} AND ("userId" = ${userId} OR "teamId" IN (${Prisma.join( + accessibleTeamIds + )}))`; + } else { + whereCondition = Prisma.sql`"providerAgentId" = ${providerAgentId} AND "userId" = ${userId}`; + } + + const agents = await prisma.$queryRaw<_AgentRawResult[]>` + SELECT + id, + name, + "providerAgentId", + enabled, + "userId", + "teamId", + "createdAt", + "updatedAt" + FROM "Agent" + WHERE ${whereCondition} + LIMIT 1 + `; + + return agents.length > 0 ? agents[0] : null; + } + + static async findById({ id }: { id: string }) { + return await prisma.agent.findUnique({ + select: { + id: true, + name: true, + providerAgentId: true, + enabled: true, + userId: true, + teamId: true, + createdAt: true, + updatedAt: true, + }, + where: { + id, + }, + }); + } + + static async findByProviderAgentId({ providerAgentId }: { providerAgentId: string }) { + return await prisma.agent.findUnique({ + select: { + id: true, + name: true, + providerAgentId: true, + enabled: true, + userId: true, + teamId: true, + createdAt: true, + updatedAt: true, + }, + where: { + providerAgentId, + }, + }); + } + + static async findManyWithUserAccess({ + userId, + teamId, + scope = "all", + }: { + userId: number; + teamId?: number; + scope?: "personal" | "team" | "all"; + }) { + let whereCondition: Prisma.Sql; + + if (scope === "personal") { + whereCondition = Prisma.sql`a."userId" = ${userId}`; + } else if (scope === "team") { + const accessibleTeamIds = await this.getUserAccessibleTeamIds(userId); + + if (accessibleTeamIds.length === 0) { + return []; + } + + if (teamId) { + // Check if user has access to the specific team + if (!accessibleTeamIds.includes(teamId)) { + return []; + } + whereCondition = Prisma.sql`a."teamId" = ${teamId}`; + } else { + whereCondition = Prisma.sql`a."teamId" IN (${Prisma.join(accessibleTeamIds)})`; + } + } else { + const accessibleTeamIds = await this.getUserAccessibleTeamIds(userId); + + if (teamId) { + if (accessibleTeamIds.includes(teamId)) { + whereCondition = Prisma.sql`(a."userId" = ${userId} OR a."teamId" = ${teamId})`; + } else { + whereCondition = Prisma.sql`a."userId" = ${userId}`; + } + } else if (accessibleTeamIds.length > 0) { + whereCondition = Prisma.sql`(a."userId" = ${userId} OR a."teamId" IN (${Prisma.join( + accessibleTeamIds + )}))`; + } else { + whereCondition = Prisma.sql`a."userId" = ${userId}`; + } + } + + const agents = await prisma.$queryRaw<_AgentRawResult[]>` + SELECT + a.id, + a.name, + a."providerAgentId", + a.enabled, + a."userId", + a."teamId", + a."createdAt", + a."updatedAt", + u.id as user_id, + u.name as user_name, + u.email as user_email, + t.id as team_id, + t.name as team_name, + t.slug as team_slug, + t."logoUrl" as team_logo_url + FROM "Agent" a + LEFT JOIN "users" u ON a."userId" = u.id + LEFT JOIN "Team" t ON a."teamId" = t.id + WHERE ${whereCondition} + ORDER BY a."teamId" ASC, a."createdAt" DESC + `; + + // 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[]>` + SELECT + pn.id, + pn."phoneNumber", + pn."subscriptionStatus", + pn.provider, + pn."outboundAgentId" + FROM "CalAiPhoneNumber" pn + WHERE pn."outboundAgentId" IN (${Prisma.join(agentIds)}) + ` + : []; + + // Map phone numbers to agents + const phoneNumbersByAgent = phoneNumbers.reduce((acc, pn) => { + const agentId = pn.outboundAgentId; + if (agentId) { + if (!acc[agentId]) { + acc[agentId] = []; + } + acc[agentId].push({ + id: pn.id, + phoneNumber: pn.phoneNumber, + subscriptionStatus: pn.subscriptionStatus, + provider: pn.provider, + }); + } + return acc; + }, {} as Record); + + // Transform results to match expected format + return agents.map((agent) => ({ + id: agent.id, + name: agent.name, + providerAgentId: agent.providerAgentId, + enabled: agent.enabled, + userId: agent.userId, + teamId: agent.teamId, + createdAt: agent.createdAt, + updatedAt: agent.updatedAt, + user: agent.user_id + ? { + id: agent.user_id, + name: agent.user_name, + email: agent.user_email, + } + : null, + team: agent.team_id + ? { + id: agent.team_id, + name: agent.team_name, + slug: agent.team_slug, + logoUrl: agent.team_logo_url, + } + : null, + outboundPhoneNumbers: phoneNumbersByAgent[agent.id] || [], + })); + } + + static async findByIdWithUserAccessAndDetails({ + id, + userId, + teamId, + }: { + id: string; + userId: number; + teamId?: number; + }) { + const accessibleTeamIds = await this.getUserAccessibleTeamIds(userId); + + let whereCondition: Prisma.Sql; + if (teamId) { + // If teamId is provided, check that the user has access to that specific team + if (accessibleTeamIds.includes(teamId)) { + whereCondition = Prisma.sql`a.id = ${id} AND a."teamId" = ${teamId}`; + } else { + // If user doesn't have access to the team, only check for personal agents + whereCondition = Prisma.sql`a.id = ${id} AND a."userId" = ${userId}`; + } + } else if (accessibleTeamIds.length > 0) { + // No specific teamId provided, check both personal and team agents + whereCondition = Prisma.sql`a.id = ${id} AND (a."userId" = ${userId} OR a."teamId" IN (${Prisma.join( + accessibleTeamIds + )}))`; + } else { + // User has no team access, only check personal agents + whereCondition = Prisma.sql`a.id = ${id} AND a."userId" = ${userId}`; + } + + const agents = await prisma.$queryRaw<_AgentRawResult[]>` + SELECT + a.id, + a.name, + a."providerAgentId", + a.enabled, + a."userId", + a."teamId", + a."createdAt", + a."updatedAt", + u.id as user_id, + u.name as user_name, + u.email as user_email, + t.id as team_id, + t.name as team_name, + t.slug as team_slug + FROM "Agent" a + LEFT JOIN "users" u ON a."userId" = u.id + LEFT JOIN "Team" t ON a."teamId" = t.id + WHERE ${whereCondition} + LIMIT 1 + `; + + if (agents.length === 0) { + return null; + } + + const agent = agents[0]; + + const phoneNumbers = await prisma.$queryRaw<_PhoneNumberRawResult[]>` + SELECT + pn.id, + pn."phoneNumber", + pn."subscriptionStatus", + pn.provider + FROM "CalAiPhoneNumber" pn + WHERE pn."outboundAgentId" = ${agent.id} + `; + + // Transform result to match expected format + return { + id: agent.id, + name: agent.name, + providerAgentId: agent.providerAgentId, + enabled: agent.enabled, + userId: agent.userId, + teamId: agent.teamId, + createdAt: agent.createdAt, + updatedAt: agent.updatedAt, + user: agent.user_id + ? { + id: agent.user_id, + name: agent.user_name, + email: agent.user_email, + } + : null, + team: agent.team_id + ? { + id: agent.team_id, + name: agent.team_name, + slug: agent.team_slug, + } + : null, + outboundPhoneNumbers: phoneNumbers.map((pn) => ({ + id: pn.id, + phoneNumber: pn.phoneNumber, + subscriptionStatus: pn.subscriptionStatus, + provider: pn.provider, + })), + }; + } + + static async create({ + name, + providerAgentId, + userId, + teamId, + }: { + name: string; + providerAgentId: string; + userId: number; + teamId?: number; + }) { + return await prisma.agent.create({ + data: { + name, + providerAgentId, + userId, + teamId, + }, + }); + } + + static async findByIdWithAdminAccess({ id, userId, teamId }: { id: string; userId: number; teamId?: number }) { + const adminTeamIds = await this.getUserAdminTeamIds(userId); + + let whereCondition: Prisma.Sql; + if (teamId) { + // If teamId is specified, check that user has admin access to that specific team + if (adminTeamIds.includes(teamId)) { + whereCondition = Prisma.sql`id = ${id} AND "teamId" = ${teamId}`; + } else { + // If user doesn't have admin access to the team, only check for personal agents + whereCondition = Prisma.sql`id = ${id} AND "userId" = ${userId}`; + } + } else if (adminTeamIds.length > 0) { + whereCondition = Prisma.sql`id = ${id} AND ("userId" = ${userId} OR "teamId" IN (${Prisma.join( + adminTeamIds + )}))`; + } else { + whereCondition = Prisma.sql`id = ${id} AND "userId" = ${userId}`; + } + + const agents = await prisma.$queryRaw<_AgentRawResult[]>` + SELECT + id, + name, + "providerAgentId", + enabled, + "userId", + "teamId", + "createdAt", + "updatedAt" + FROM "Agent" + WHERE ${whereCondition} + LIMIT 1 + `; + + return agents.length > 0 ? agents[0] : null; + } + + static async findByIdWithCallAccess({ id, userId }: { id: string; userId: number }) { + const accessibleTeamIds = await this.getUserAccessibleTeamIds(userId); + + let whereCondition: Prisma.Sql; + if (accessibleTeamIds.length > 0) { + whereCondition = Prisma.sql`a.id = ${id} AND (a."userId" = ${userId} OR a."teamId" IN (${Prisma.join( + accessibleTeamIds + )}))`; + } else { + whereCondition = Prisma.sql`a.id = ${id} AND a."userId" = ${userId}`; + } + + const agents = await prisma.$queryRaw<_AgentRawResult[]>` + SELECT + a.id, + a.name, + a."providerAgentId", + a.enabled, + a."userId", + a."teamId", + a."createdAt", + a."updatedAt" + FROM "Agent" a + WHERE ${whereCondition} + LIMIT 1 + `; + + if (agents.length === 0) { + return null; + } + + const agent = agents[0]; + + const phoneNumbers = await prisma.$queryRaw<{ phoneNumber: string }[]>` + SELECT "phoneNumber" + FROM "CalAiPhoneNumber" + WHERE "outboundAgentId" = ${agent.id} + `; + + return { + ...agent, + outboundPhoneNumbers: phoneNumbers, + }; + } + + static async delete({ id }: { id: string }) { + return await prisma.agent.delete({ + where: { id }, + }); + } + + static async linkToWorkflowStep({ workflowStepId, agentId }: { workflowStepId: number; agentId: string }) { + return await prisma.workflowStep.update({ + where: { id: workflowStepId }, + data: { agentId }, + }); + } + + static async canManageTeamResources({ + userId, + teamId, + }: { + userId: number; + teamId: number; + }): Promise { + const result = await prisma.$queryRaw<{ count: bigint }[]>` + SELECT COUNT(*) as count + FROM "Membership" + WHERE "userId" = ${userId} + AND "teamId" = ${teamId} + AND accepted = true + AND role IN ('ADMIN', 'OWNER') + `; + + return Number(result[0].count) > 0; + } +} \ No newline at end of file diff --git a/packages/lib/server/repository/PrismaPhoneNumberRepository.ts b/packages/lib/server/repository/PrismaPhoneNumberRepository.ts new file mode 100644 index 0000000000..7bd6f771a1 --- /dev/null +++ b/packages/lib/server/repository/PrismaPhoneNumberRepository.ts @@ -0,0 +1,482 @@ +import { Prisma } from "@prisma/client"; + +import prisma from "@calcom/prisma"; +import { PhoneNumberSubscriptionStatus } from "@calcom/prisma/enums"; + +interface _PhoneNumberRawResult { + id: number; + phoneNumber: string; + provider: string | null; + userId: number | null; + teamId: number | null; + subscriptionStatus: string | null; + createdAt: Date; + updatedAt: Date; + inboundAgentId: string | null; + outboundAgentId: string | null; + stripeCustomerId: string | null; + stripeSubscriptionId: string | null; +} + +export interface AgentRawResult { + id: string; + name: string; + providerAgentId: string; +} + +export class PrismaPhoneNumberRepository { + private static async getUserAccessibleTeamIds(userId: number): Promise { + const memberships = await prisma.membership.findMany({ + where: { + userId, + accepted: true, + }, + select: { + teamId: true, + }, + }); + + return memberships.map((membership) => membership.teamId); + } + + static async findByPhoneNumberAndUserId({ phoneNumber, userId }: { phoneNumber: string; userId: number }) { + return await prisma.calAiPhoneNumber.findFirstOrThrow({ + where: { + phoneNumber, + userId, + }, + select: { + id: true, + phoneNumber: true, + userId: true, + teamId: true, + subscriptionStatus: true, + stripeSubscriptionId: true, + stripeCustomerId: true, + provider: true, + inboundAgentId: true, + outboundAgentId: true, + createdAt: true, + updatedAt: true, + }, + }); + } + + static async findPhoneNumbersFromUserId({ userId }: { userId: number }) { + const phoneNumbers = await prisma.$queryRaw<_PhoneNumberRawResult[]>` + SELECT + pn.id, + pn."phoneNumber", + pn.provider, + pn."userId", + pn."teamId", + pn."subscriptionStatus", + pn."createdAt", + pn."updatedAt", + pn."inboundAgentId", + pn."outboundAgentId", + pn."stripeCustomerId", + pn."stripeSubscriptionId" + FROM "CalAiPhoneNumber" pn + WHERE pn."userId" = ${userId} + AND (pn."subscriptionStatus" = ${PhoneNumberSubscriptionStatus.ACTIVE} OR pn."subscriptionStatus" IS NULL) + `; + + const phoneNumberIds = phoneNumbers.map((pn) => pn.id); + const agents = + phoneNumberIds.length > 0 + ? await prisma.$queryRaw<(AgentRawResult & { phoneNumberId: number; agentType: string })[]>` + SELECT + a.id, + a.name, + a."providerAgentId", + pn.id as "phoneNumberId", + CASE + WHEN pn."inboundAgentId" = a.id THEN 'inbound' + WHEN pn."outboundAgentId" = a.id THEN 'outbound' + END as "agentType" + FROM "Agent" a + INNER JOIN "CalAiPhoneNumber" pn ON (pn."inboundAgentId" = a.id OR pn."outboundAgentId" = a.id) + WHERE pn.id IN (${Prisma.join(phoneNumberIds)}) + ` + : []; + + const agentsByPhoneNumber = agents.reduce((acc, agent) => { + const phoneNumberId = agent.phoneNumberId; + if (!acc[phoneNumberId]) { + acc[phoneNumberId] = { inbound: null, outbound: null }; + } + + const agentData = { + id: agent.id, + name: agent.name, + providerAgentId: agent.providerAgentId, + }; + + if (agent.agentType === "inbound") { + acc[phoneNumberId].inbound = agentData; + } else if (agent.agentType === "outbound") { + acc[phoneNumberId].outbound = agentData; + } + + return acc; + }, {} as Record); + + return phoneNumbers.map((pn) => ({ + id: pn.id, + phoneNumber: pn.phoneNumber, + provider: pn.provider, + userId: pn.userId, + teamId: pn.teamId, + subscriptionStatus: pn.subscriptionStatus, + createdAt: pn.createdAt, + updatedAt: pn.updatedAt, + stripeCustomerId: pn.stripeCustomerId, + stripeSubscriptionId: pn.stripeSubscriptionId, + inboundAgent: agentsByPhoneNumber[pn.id]?.inbound || null, + outboundAgent: agentsByPhoneNumber[pn.id]?.outbound || null, + })); + } + + static async createPhoneNumber({ + phoneNumber, + provider, + userId, + teamId, + }: { + phoneNumber: string; + provider?: string; + userId: number; + teamId?: number; + }) { + return await prisma.calAiPhoneNumber.create({ + select: { + id: true, + phoneNumber: true, + provider: true, + userId: true, + teamId: true, + subscriptionStatus: true, + createdAt: true, + updatedAt: true, + }, + data: { + provider, + userId, + teamId, + phoneNumber, + }, + }); + } + + static async deletePhoneNumber({ phoneNumber }: { phoneNumber: string }) { + return await prisma.calAiPhoneNumber.delete({ + where: { + phoneNumber, + }, + }); + } + + static async findByIdAndUserId({ id, userId }: { id: number; userId: number }) { + return await prisma.calAiPhoneNumber.findFirst({ + where: { + id, + userId, + }, + select: { + id: true, + phoneNumber: true, + userId: true, + teamId: true, + subscriptionStatus: true, + stripeSubscriptionId: true, + stripeCustomerId: true, + provider: true, + inboundAgentId: true, + outboundAgentId: true, + createdAt: true, + updatedAt: true, + }, + }); + } + + static async findByIdWithTeamAccess({ + id, + teamId, + userId, + }: { + id: number; + teamId: number; + userId: number; + }) { + const accessibleTeamIds = await this.getUserAccessibleTeamIds(userId); + + if (!accessibleTeamIds.includes(teamId)) { + return null; + } + + return await prisma.calAiPhoneNumber.findFirst({ + where: { + id, + teamId, + }, + select: { + id: true, + phoneNumber: true, + userId: true, + teamId: true, + subscriptionStatus: true, + stripeSubscriptionId: true, + stripeCustomerId: true, + provider: true, + inboundAgentId: true, + outboundAgentId: true, + createdAt: true, + updatedAt: true, + }, + }); + } + + static async findByPhoneNumberAndTeamId({ + phoneNumber, + teamId, + userId, + }: { + phoneNumber: string; + teamId: number; + userId: number; + }) { + const accessibleTeamIds = await this.getUserAccessibleTeamIds(userId); + + if (!accessibleTeamIds.includes(teamId)) { + return null; + } + + return await prisma.calAiPhoneNumber.findFirst({ + where: { + phoneNumber, + teamId, + }, + select: { + id: true, + phoneNumber: true, + userId: true, + teamId: true, + subscriptionStatus: true, + stripeCustomerId: true, + provider: true, + inboundAgentId: true, + outboundAgentId: true, + createdAt: true, + updatedAt: true, + }, + }); + } + + static async findManyWithUserAccess({ + userId, + teamId, + scope = "all", + }: { + userId: number; + teamId?: number; + scope?: "personal" | "team" | "all"; + }) { + let whereCondition: Prisma.Sql; + + if (scope === "personal") { + whereCondition = Prisma.sql`pn."userId" = ${userId}`; + } else if (scope === "team") { + const accessibleTeamIds = await this.getUserAccessibleTeamIds(userId); + + if (accessibleTeamIds.length === 0) { + return []; + } + + if (teamId) { + if (!accessibleTeamIds.includes(teamId)) { + return []; + } + whereCondition = Prisma.sql`pn."teamId" = ${teamId}`; + } else { + whereCondition = Prisma.sql`pn."teamId" IN (${Prisma.join(accessibleTeamIds)})`; + } + } else { + const accessibleTeamIds = await this.getUserAccessibleTeamIds(userId); + + if (teamId) { + if (accessibleTeamIds.includes(teamId)) { + whereCondition = Prisma.sql`(pn."userId" = ${userId} OR pn."teamId" = ${teamId})`; + } else { + whereCondition = Prisma.sql`pn."userId" = ${userId}`; + } + } else if (accessibleTeamIds.length > 0) { + whereCondition = Prisma.sql`(pn."userId" = ${userId} OR pn."teamId" IN (${Prisma.join( + accessibleTeamIds + )}))`; + } else { + whereCondition = Prisma.sql`pn."userId" = ${userId}`; + } + } + + const phoneNumbers = await prisma.$queryRaw<_PhoneNumberRawResult[]>` + SELECT + pn.id, + pn."phoneNumber", + pn.provider, + pn."userId", + pn."teamId", + pn."subscriptionStatus", + pn."createdAt", + pn."updatedAt", + pn."inboundAgentId", + pn."outboundAgentId", + pn."stripeCustomerId", + pn."stripeSubscriptionId" + FROM "CalAiPhoneNumber" pn + WHERE ${whereCondition} + ORDER BY pn."createdAt" DESC + `; + + const phoneNumberIds = phoneNumbers.map((pn) => pn.id); + const agents = + phoneNumberIds.length > 0 + ? await prisma.$queryRaw<(AgentRawResult & { phoneNumberId: number; agentType: string })[]>` + SELECT + a.id, + a.name, + a."providerAgentId", + pn.id as "phoneNumberId", + CASE + WHEN pn."inboundAgentId" = a.id THEN 'inbound' + WHEN pn."outboundAgentId" = a.id THEN 'outbound' + END as "agentType" + FROM "Agent" a + INNER JOIN "CalAiPhoneNumber" pn ON (pn."inboundAgentId" = a.id OR pn."outboundAgentId" = a.id) + WHERE pn.id IN (${Prisma.join(phoneNumberIds)}) + ` + : []; + + const agentsByPhoneNumber = agents.reduce((acc, agent) => { + const phoneNumberId = agent.phoneNumberId; + if (!acc[phoneNumberId]) { + acc[phoneNumberId] = { inbound: null, outbound: null }; + } + + const agentData = { + id: agent.id, + name: agent.name, + providerAgentId: agent.providerAgentId, + }; + + if (agent.agentType === "inbound") { + acc[phoneNumberId].inbound = agentData; + } else if (agent.agentType === "outbound") { + acc[phoneNumberId].outbound = agentData; + } + + return acc; + }, {} as Record); + + return phoneNumbers.map((pn) => ({ + id: pn.id, + phoneNumber: pn.phoneNumber, + provider: pn.provider, + userId: pn.userId, + teamId: pn.teamId, + subscriptionStatus: pn.subscriptionStatus, + createdAt: pn.createdAt, + updatedAt: pn.updatedAt, + stripeCustomerId: pn.stripeCustomerId, + stripeSubscriptionId: pn.stripeSubscriptionId, + inboundAgent: agentsByPhoneNumber[pn.id]?.inbound || null, + outboundAgent: agentsByPhoneNumber[pn.id]?.outbound || null, + })); + } + + static async updateSubscriptionStatus({ + id, + subscriptionStatus, + disconnectOutboundAgent = false, + }: { + id: number; + subscriptionStatus: PhoneNumberSubscriptionStatus; + disconnectOutboundAgent?: boolean; + }) { + const updateData: Prisma.CalAiPhoneNumberUpdateInput = { + subscriptionStatus, + }; + + if (disconnectOutboundAgent) { + updateData.outboundAgent = { + disconnect: true, + }; + } + + return await prisma.calAiPhoneNumber.update({ + where: { + id, + }, + data: updateData, + }); + } + + static async updateAgents({ + id, + inboundProviderAgentId, + outboundProviderAgentId, + }: { + id: number; + inboundProviderAgentId?: string | null; + outboundProviderAgentId?: string | null; + }) { + const updateData: Prisma.CalAiPhoneNumberUpdateInput = {}; + + if (inboundProviderAgentId !== undefined) { + if (inboundProviderAgentId) { + const agent = await prisma.agent.findFirst({ + where: { + providerAgentId: inboundProviderAgentId, + }, + }); + + if (agent) { + updateData.inboundAgent = { + connect: { id: agent.id }, + }; + } else { + updateData.inboundAgent = { disconnect: true }; + } + } else { + updateData.inboundAgent = { disconnect: true }; + } + } + + if (outboundProviderAgentId !== undefined) { + if (outboundProviderAgentId) { + const agent = await prisma.agent.findFirst({ + where: { + providerAgentId: outboundProviderAgentId, + }, + }); + + if (agent) { + updateData.outboundAgent = { + connect: { id: agent.id }, + }; + } else { + updateData.outboundAgent = { disconnect: true }; + } + } else { + updateData.outboundAgent = { disconnect: true }; + } + } + + return await prisma.calAiPhoneNumber.update({ + where: { + id, + }, + data: updateData, + }); + } +} \ No newline at end of file diff --git a/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts b/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts index 3134d221c1..cd63b4ddde 100644 --- a/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts +++ b/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts @@ -7,8 +7,8 @@ import checkForMultiplePaymentApps from "@calcom/app-store/_utils/payments/check import { DEFAULT_PROMPT_VALUE, DEFAULT_BEGIN_MESSAGE, -} from "@calcom/features/ee/cal-ai-phone/promptTemplates"; -import type { TemplateType } from "@calcom/features/ee/cal-ai-phone/zod-utils"; +} from "@calcom/features/calAIPhone/promptTemplates"; +import type { TemplateType } from "@calcom/features/calAIPhone/zod-utils"; import { sortHosts } from "@calcom/features/eventtypes/components/HostEditDialogs"; import type { FormValues, diff --git a/packages/prisma/migrations/20250807134931_add_cal_ai_self_serve/migration.sql b/packages/prisma/migrations/20250807134931_add_cal_ai_self_serve/migration.sql new file mode 100644 index 0000000000..1954119b23 --- /dev/null +++ b/packages/prisma/migrations/20250807134931_add_cal_ai_self_serve/migration.sql @@ -0,0 +1,112 @@ +/* + Warnings: + + - A unique constraint covering the columns `[agentId]` on the table `WorkflowStep` will be added. If there are existing duplicate values, this will fail. + +*/ +-- CreateEnum +CREATE TYPE "PhoneNumberSubscriptionStatus" AS ENUM ('ACTIVE', 'PAST_DUE', 'CANCELLED', 'INCOMPLETE', 'INCOMPLETE_EXPIRED', 'TRIALING', 'UNPAID'); + +-- AlterEnum +ALTER TYPE "WorkflowActions" ADD VALUE 'CAL_AI_PHONE_CALL'; + +-- AlterEnum +ALTER TYPE "WorkflowMethods" ADD VALUE 'AI_PHONE_CALL'; + +-- AlterTable +ALTER TABLE "WorkflowStep" ADD COLUMN "agentId" TEXT; + +-- CreateTable +CREATE TABLE "Agent" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "userId" INTEGER, + "teamId" INTEGER, + "providerAgentId" TEXT NOT NULL, + "enabled" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Agent_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "CalAiPhoneNumber" ( + "id" SERIAL NOT NULL, + "userId" INTEGER, + "teamId" INTEGER, + "phoneNumber" TEXT NOT NULL, + "provider" TEXT NOT NULL, + "providerPhoneNumberId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "stripeCustomerId" TEXT, + "stripeSubscriptionId" TEXT, + "subscriptionStatus" "PhoneNumberSubscriptionStatus", + "inboundAgentId" TEXT, + "outboundAgentId" TEXT, + + CONSTRAINT "CalAiPhoneNumber_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Agent_providerAgentId_key" ON "Agent"("providerAgentId"); + +-- CreateIndex +CREATE INDEX "Agent_userId_idx" ON "Agent"("userId"); + +-- CreateIndex +CREATE INDEX "Agent_teamId_idx" ON "Agent"("teamId"); + +-- CreateIndex +CREATE UNIQUE INDEX "CalAiPhoneNumber_phoneNumber_key" ON "CalAiPhoneNumber"("phoneNumber"); + +-- CreateIndex +CREATE UNIQUE INDEX "CalAiPhoneNumber_providerPhoneNumberId_key" ON "CalAiPhoneNumber"("providerPhoneNumberId"); + +-- CreateIndex +CREATE UNIQUE INDEX "CalAiPhoneNumber_stripeSubscriptionId_key" ON "CalAiPhoneNumber"("stripeSubscriptionId"); + +-- CreateIndex +CREATE INDEX "CalAiPhoneNumber_userId_idx" ON "CalAiPhoneNumber"("userId"); + +-- CreateIndex +CREATE INDEX "CalAiPhoneNumber_teamId_idx" ON "CalAiPhoneNumber"("teamId"); + +-- CreateIndex +CREATE INDEX "CalAiPhoneNumber_inboundAgentId_idx" ON "CalAiPhoneNumber"("inboundAgentId"); + +-- CreateIndex +CREATE INDEX "CalAiPhoneNumber_outboundAgentId_idx" ON "CalAiPhoneNumber"("outboundAgentId"); + +-- CreateIndex +CREATE UNIQUE INDEX "WorkflowStep_agentId_key" ON "WorkflowStep"("agentId"); + +-- AddForeignKey +ALTER TABLE "WorkflowStep" ADD CONSTRAINT "WorkflowStep_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Agent" ADD CONSTRAINT "Agent_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Agent" ADD CONSTRAINT "Agent_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "CalAiPhoneNumber" ADD CONSTRAINT "CalAiPhoneNumber_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "CalAiPhoneNumber" ADD CONSTRAINT "CalAiPhoneNumber_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "CalAiPhoneNumber" ADD CONSTRAINT "CalAiPhoneNumber_inboundAgentId_fkey" FOREIGN KEY ("inboundAgentId") REFERENCES "Agent"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "CalAiPhoneNumber" ADD CONSTRAINT "CalAiPhoneNumber_outboundAgentId_fkey" FOREIGN KEY ("outboundAgentId") REFERENCES "Agent"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- Add check constraint for Agent table to ensure at least one owner +ALTER TABLE "Agent" ADD CONSTRAINT "Agent_owner_check" + CHECK ("userId" IS NOT NULL OR "teamId" IS NOT NULL); + +-- Add check constraint for CalAiPhoneNumber table to ensure at least one owner +ALTER TABLE "CalAiPhoneNumber" ADD CONSTRAINT "CalAiPhoneNumber_owner_check" + CHECK ("userId" IS NOT NULL OR "teamId" IS NOT NULL); diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index c434d9c272..813922e52c 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -441,6 +441,8 @@ model User { filterSegmentPreferences UserFilterSegmentPreference[] creditBalance CreditBalance? whitelistWorkflows Boolean @default(false) + calAiPhoneNumbers CalAiPhoneNumber[] + agents Agent[] @@unique([email]) @@unique([email, username]) @@ -551,6 +553,8 @@ model Team { delegationCredentials DelegationCredential[] domainWideDelegations DomainWideDelegation[] roles Role[] // Added for Role relation + calAiPhoneNumbers CalAiPhoneNumber[] + agents Agent[] features TeamFeatures[] @@ -1340,6 +1344,7 @@ enum WorkflowActions { EMAIL_ADDRESS WHATSAPP_ATTENDEE WHATSAPP_NUMBER + CAL_AI_PHONE_CALL } model WorkflowStep { @@ -1358,6 +1363,8 @@ model WorkflowStep { numberVerificationPending Boolean @default(true) includeCalendarEvent Boolean @default(false) verifiedAt DateTime? + agentId String? @unique + agent Agent? @relation(fields: [agentId], references: [id], onDelete: SetNull) @@index([workflowId]) } @@ -1495,6 +1502,7 @@ enum WorkflowMethods { EMAIL SMS WHATSAPP + AI_PHONE_CALL } model BookingSeat { @@ -2433,3 +2441,73 @@ model RolePermission { // TODO: come back to this with indexs. @@index([action]) } + +enum PhoneNumberSubscriptionStatus { + ACTIVE + PAST_DUE + CANCELLED + INCOMPLETE + INCOMPLETE_EXPIRED + TRIALING + UNPAID +} + +// Cal AI Voice Agent +model Agent { + id String @id @default(uuid()) + name String + + // Either user-owned OR team-owned + userId Int? + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + teamId Int? + team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade) + + // Provider-specific agent ID (e.g., Retell AI agent ID) + providerAgentId String @unique + + enabled Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + inboundPhoneNumbers CalAiPhoneNumber[] @relation("InboundAgent") + outboundPhoneNumbers CalAiPhoneNumber[] @relation("OutboundAgent") + workflowStep WorkflowStep? + + @@index([userId]) + @@index([teamId]) +} + +model CalAiPhoneNumber { + id Int @id @default(autoincrement()) + + // Either user-owned OR team-owned + userId Int? + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + teamId Int? + team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade) + + phoneNumber String @unique + provider String + + // Provider-specific phone number ID (e.g., Retell phone number ID) + providerPhoneNumberId String? @unique + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + stripeCustomerId String? + stripeSubscriptionId String? @unique + subscriptionStatus PhoneNumberSubscriptionStatus? + + // Agent associations + inboundAgentId String? + inboundAgent Agent? @relation("InboundAgent", fields: [inboundAgentId], references: [id], onDelete: SetNull) + outboundAgentId String? + outboundAgent Agent? @relation("OutboundAgent", fields: [outboundAgentId], references: [id], onDelete: SetNull) + + @@index([userId]) + @@index([teamId]) + @@index([inboundAgentId]) + @@index([outboundAgentId]) +} diff --git a/packages/trpc/server/routers/viewer/eventTypes/types.ts b/packages/trpc/server/routers/viewer/eventTypes/types.ts index 3e11a20a5c..5f7528acc6 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/types.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/types.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import { templateTypeEnum } from "@calcom/features/ee/cal-ai-phone/zod-utils"; +import { templateTypeEnum } from "@calcom/features/calAIPhone/zod-utils"; import { MAX_SEATS_PER_TIME_SLOT } from "@calcom/lib/constants"; import { _DestinationCalendarModel, _EventTypeModel } from "@calcom/prisma/zod"; import { diff --git a/packages/trpc/server/routers/viewer/organizations/_router.tsx b/packages/trpc/server/routers/viewer/organizations/_router.tsx index 2b366a6cd4..87f29de4ff 100644 --- a/packages/trpc/server/routers/viewer/organizations/_router.tsx +++ b/packages/trpc/server/routers/viewer/organizations/_router.tsx @@ -1,4 +1,4 @@ -import { createPhoneCallSchema } from "@calcom/features/ee/cal-ai-phone/zod-utils"; +import { createPhoneCallSchema } from "@calcom/features/calAIPhone/zod-utils"; import { ZVerifyCodeInputSchema } from "@calcom/prisma/zod-utils"; import authedProcedure, { diff --git a/packages/trpc/server/routers/viewer/organizations/createPhoneCall.handler.ts b/packages/trpc/server/routers/viewer/organizations/createPhoneCall.handler.ts index 168a045ee5..40296119ea 100644 --- a/packages/trpc/server/routers/viewer/organizations/createPhoneCall.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/createPhoneCall.handler.ts @@ -1,6 +1,6 @@ import type { z } from "zod"; -import type { createPhoneCallSchema } from "@calcom/features/ee/cal-ai-phone/zod-utils"; +import type { createPhoneCallSchema } from "@calcom/features/calAIPhone/zod-utils"; import { handleCreatePhoneCall } from "@calcom/features/handleCreatePhoneCall"; import type { PrismaClient } from "@calcom/prisma"; import type { TrpcSessionUser } from "@calcom/trpc/server/types"; diff --git a/packages/trpc/server/routers/viewer/workflows/update.handler.ts b/packages/trpc/server/routers/viewer/workflows/update.handler.ts index a371cd4d3a..5530ef7b81 100755 --- a/packages/trpc/server/routers/viewer/workflows/update.handler.ts +++ b/packages/trpc/server/routers/viewer/workflows/update.handler.ts @@ -343,7 +343,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { id: oldStep.id, }, }); - } else if (isStepEdited(oldStep, { ...newStep, verifiedAt: oldStep.verifiedAt })) { + } else if (isStepEdited(oldStep, { ...newStep, verifiedAt: oldStep.verifiedAt, agentId: null })) { // check if step that require team plan already existed before if (!hasPaidPlan && isEmailAction(newStep.action)) { const isChangingToCustomTemplate = @@ -400,6 +400,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { numberVerificationPending: false, includeCalendarEvent: newStep.includeCalendarEvent, verifiedAt: !SCANNING_WORKFLOW_STEPS ? new Date() : didBodyChange ? null : oldStep.verifiedAt, + agentId: null, }, }); diff --git a/yarn.lock b/yarn.lock index e8a90751b0..55eea1c57b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4149,6 +4149,7 @@ __metadata: react-use-intercom: 1.5.1 recoil: ^0.7.7 remove-markdown: ^0.5.0 + retell-sdk: ^4.40.0 rrule: ^2.7.1 sanitize-html: ^2.10.0 schema-dts: ^1.1.0 @@ -27547,6 +27548,13 @@ __metadata: languageName: node linkType: hard +"form-data-encoder@npm:1.7.2": + version: 1.7.2 + resolution: "form-data-encoder@npm:1.7.2" + checksum: aeebd87a1cb009e13cbb5e4e4008e6202ed5f6551eb6d9582ba8a062005178907b90f4887899d3c993de879159b6c0c940af8196725b428b4248cec5af3acf5f + languageName: node + linkType: hard + "form-data@npm:^2.5.0": version: 2.5.1 resolution: "form-data@npm:2.5.1" @@ -27591,6 +27599,16 @@ __metadata: languageName: node linkType: hard +"formdata-node@npm:^4.3.2": + version: 4.4.1 + resolution: "formdata-node@npm:4.4.1" + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 4.0.0-beta.3 + checksum: d91d4f667cfed74827fc281594102c0dabddd03c9f8b426fc97123eedbf73f5060ee43205d89284d6854e2fc5827e030cd352ef68b93beda8decc2d72128c576 + languageName: node + linkType: hard + "formdata-polyfill@npm:^4.0.10": version: 4.0.10 resolution: "formdata-polyfill@npm:4.0.10" @@ -35918,7 +35936,7 @@ __metadata: languageName: node linkType: hard -"node-domexception@npm:^1.0.0": +"node-domexception@npm:1.0.0, node-domexception@npm:^1.0.0": version: 1.0.0 resolution: "node-domexception@npm:1.0.0" checksum: ee1d37dd2a4eb26a8a92cd6b64dfc29caec72bff5e1ed9aba80c294f57a31ba4895a60fd48347cf17dd6e766da0ae87d75657dfd1f384ebfa60462c2283f5c7f @@ -40890,6 +40908,21 @@ __metadata: languageName: node linkType: hard +"retell-sdk@npm:^4.40.0": + version: 4.41.0 + resolution: "retell-sdk@npm:4.41.0" + dependencies: + "@types/node": ^18.11.18 + "@types/node-fetch": ^2.6.4 + abort-controller: ^3.0.0 + agentkeepalive: ^4.2.1 + form-data-encoder: 1.7.2 + formdata-node: ^4.3.2 + node-fetch: ^2.6.7 + checksum: 7313095a25f958d9dce73b7041e9461ed9a393a0a30b50e3a31ce967c2f21285793a61ad790f8a1093658cff6454165d2d8b49de18edab38bfc3a1af08e246b4 + languageName: node + linkType: hard + "retry@npm:^0.12.0": version: 0.12.0 resolution: "retry@npm:0.12.0" @@ -46719,6 +46752,13 @@ __metadata: languageName: node linkType: hard +"web-streams-polyfill@npm:4.0.0-beta.3": + version: 4.0.0-beta.3 + resolution: "web-streams-polyfill@npm:4.0.0-beta.3" + checksum: dfec1fbf52b9140e4183a941e380487b6c3d5d3838dd1259be81506c1c9f2abfcf5aeb670aeeecfd9dff4271a6d8fef931b193c7bedfb42542a3b05ff36c0d16 + languageName: node + linkType: hard + "web-streams-polyfill@npm:^3.0.3": version: 3.3.3 resolution: "web-streams-polyfill@npm:3.3.3"