Files
calendar/packages/features/calAIPhone/providers/retellAI/services/PhoneNumberService.ts
T
d4bff9d6b1 feat: Cal.ai Self Serve #2 (#22995)
* feat: Cal.ai Self Serve #2

* chore: fix import and remove logs

* fix: update checkout session

* fix: type errors and test

* fix: imports

* fix: type err

* fix: type error

* fix: tests

* chore: save progress

* fix: workflow flow

* fix: workflow update bug

* tests: add unit tests for retell ai webhoo

* fix: status code

* fix: test and delete bug

* fix: add dynamic variables

* fix: type err

* chore: update unit test

* fix: type error

* chore: update default prompt

* fix: type errors

* fix: workflow permissions

* fix: workflow page

* fix: translations

* feat: add call duration

* chore: add booking uid

* fix: button positioning

* chore: update tests

* chore: improvements

* chore: some more improvements

* refactor: improvements

* refactor: code feedback

* refactor: improvements

* feat: enable credits for orgs (#23077)

* Show credits UI for orgs

* fix stripe callback url when buying credits

* give orgs 20% credits

* add test for calulating credits

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>

* fix: types

* fix: types

* chore: error

* fix: type error

* fix: type error

* chore: mock env

* feat: add idempotency key to prevent double charging

* chore: add userId and teamId

* fix: skip inbound calls

* chore: update tests

* feat: add feature flag for voice agent

* feat: finish test call and other improvements

* chore: add alert

* chore: update .env.example

* chore: improvements

* fix: update tests

* refactor: remove un necessary

* feat: add setup badge

* chore: improvements

* fix: use referene id

* chore: improvements

* fix: type error

* fix: type

* refactor: change pricing logic

* refactor: update tests

* fix: conflicts

* fix: billing link for orgs

* fix: types

* refactor: move feature flag up

* fix: alert and test call credit check

* fix: update unit tests

* fix: feedback

* refactor: improvements

* refactor: move handlers to separate files

* fix: types

* fix: missing import

* fix: type

* refactor: change general tools functions handling

* refactor: use repository

* refactor: improvements

* fix: types

* fix: type errorr

* fix: auth check

* feat: add creditFor

* fix: update defualt prompt

* fix: throw error on frontend

* fix: update unit tests

* fix: use deleteAllWorkflowReminders

* refactor: add connect phone number

* refactor: improvements

* chore: translation

* chore: update message

* chore: translation

* design improvements buy number dialog

* add translation for error message

* use translation key in error message

* refactor: improve connect phone number tab

* feat: support un saved workflow to tests

* chore: remove un used

* fix: remove un used

* fix: remove un used

* refactor: similify billing

---------

Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2025-08-29 05:04:05 +01:00

409 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";
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<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.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<AIPhoneServicePhoneNumber<AIPhoneServiceProviderType.RETELL_AI> & { provider: string }> {
try {
const phoneNumber = await this.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.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<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.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.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 | 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.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<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.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,
});
}
}
}
}