Files
calendar/packages/features/calAIPhone/providers/retellAI/services/PhoneNumberService.ts
T
aa48e72c12 feat: inbound calls in cal ai (#23890)
* feat: lang support

* fix: type errors

* feat: select voice agent

* refactor: address feedback

* refactor: address feedback

* refactor: missing import

* fix: types

* feat: add inbound calls

* chore: formatting

* chore

* feat: finish inbound call

* chore: formatting

* fix: update bug

* fix: types

* refactor: Agent Configuration Sheet (#23930)

* refactor: agent configuration sheet

* chore: use default phone numbre

* refactor: improvements

* refactor: improvements

* fix: types

* fix: feedback

* chore:

* fix: feedback

* fix: prompt

* fix: review

* fix: review

* refactor: class

* refactor: class

* refactor: rename

* Update apps/web/public/static/locales/en/common.json

* Update apps/web/public/static/locales/en/common.json

* chore: update set value

* fix: remove index

* fix: type error

* fix: update tetss

* fix: use logger

* refactor: don't use static

* fix: type

* fix: schema

* refactor:

---------

Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
2025-10-09 11:40:37 +01:00

413 lines
12 KiB
TypeScript

import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import { PhoneNumberSubscriptionStatus } from "@calcom/prisma/enums";
import { AIPhoneServiceProviderType } from "../../../interfaces/AIPhoneService.interface";
import type {
AIPhoneServiceCreatePhoneNumberParams,
AIPhoneServiceImportPhoneNumberParamsExtended,
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";
type Dependencies = {
retellRepository: RetellAIRepository;
agentRepository: AgentRepositoryInterface;
phoneNumberRepository: PhoneNumberRepositoryInterface;
transactionManager: TransactionInterface;
};
export class PhoneNumberService {
private logger = logger.getSubLogger({ prefix: ["PhoneNumberService"] });
constructor(private deps: Dependencies) {}
async importPhoneNumber(
data: AIPhoneServiceImportPhoneNumberParamsExtended
): Promise<AIPhoneServicePhoneNumber<AIPhoneServiceProviderType.RETELL_AI>> {
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);
const transactionState = {
retellPhoneNumber: null as AIPhoneServicePhoneNumber<AIPhoneServiceProviderType.RETELL_AI> | null,
databaseRecordCreated: false,
agentAssigned: false,
};
try {
return await this.deps.transactionManager.executeInTransaction(async (txContext) => {
transactionState.retellPhoneNumber = await this.deps.retellRepository.importPhoneNumber({
phone_number: rest.phone_number,
termination_uri: rest.termination_uri,
sip_trunk_auth_username: rest.sip_trunk_auth_username,
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.deps.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<AIPhoneServicePhoneNumber<AIPhoneServiceProviderType.RETELL_AI> & { provider: string }> {
try {
const phoneNumber = await this.deps.retellRepository.createPhoneNumber(data);
return {
...phoneNumber,
provider: AIPhoneServiceProviderType.RETELL_AI,
};
} 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<void> {
if (!phoneNumber?.trim()) {
throw new HttpError({
statusCode: 400,
message: "Phone number is required and cannot be empty",
});
}
const phoneNumberToDelete = teamId
? await this.deps.phoneNumberRepository.findByPhoneNumberAndTeamId({
phoneNumber,
teamId,
userId,
})
: await this.deps.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.deps.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.deps.retellRepository.deletePhoneNumber(phoneNumber);
if (deleteFromDB) {
await this.deps.phoneNumberRepository.deletePhoneNumber({ phoneNumber });
}
}
async getPhoneNumber(
phoneNumber: string
): Promise<AIPhoneServicePhoneNumber<AIPhoneServiceProviderType.RETELL_AI>> {
if (!phoneNumber?.trim()) {
throw new HttpError({
statusCode: 400,
message: "Phone number is required and cannot be empty",
});
}
try {
return await this.deps.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<AIPhoneServicePhoneNumber<AIPhoneServiceProviderType.RETELL_AI>> {
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.deps.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.deps.phoneNumberRepository.findByPhoneNumberAndTeamId({
phoneNumber,
teamId,
userId,
})
: await this.deps.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.deps.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.deps.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 | null) {
if (!agentId) {
throw new HttpError({
statusCode: 400,
message: "Agent ID is required and cannot be empty",
});
}
let agent = null;
if (agentId) {
agent = await this.deps.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.deps.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<AIPhoneServiceProviderType.RETELL_AI> | null;
databaseRecordCreated: boolean;
agentAssigned: boolean;
},
error: unknown,
context: { userId: number; teamId?: number; agentId?: string | null | undefined }
) {
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.deps.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,
});
}
}
}
}