feat: add trunks and extensions CRUD with realtime PJSIP provisioning
- apps/api/src/telephony: TelephonyModule (conexao AMI da API para comandos de controle) + PjsipRealtimeService (unico ponto de escrita nas tabelas realtime do schema 'asterisk' via Prisma $executeRaw parametrizado) - apps/api/src/trunks: CRUD completo (tipos IP/AUTH/REGISTRATION), secret cifrado em repouso (AES-256-GCM), endpoint de teste de status via PJSIPShowContacts/pjsip show registration - apps/api/src/extensions: CRUD completo, senha SIP gerada automaticamente, endpoint de reset, nunca reexibe a senha apos a criacao - apps/api/src/monitoring: GET /api/monitoring/extensions com prioridade de cor (agente.md secao 15) a partir do ExtensionState alimentado por apps/asterisk-events - health check agora reporta status real do Asterisk/AMI via heartbeat do asterisk-events no Redis, sem precisar de conexao AMI propria para isso Testado ponta a ponta contra o Asterisk real: ramal e tronco criados via API aparecem imediatamente via 'pjsip show endpoint' sem reload (realtime funcionando), secret nunca retornado pela API, exclusao em cascata confirmada (ps_endpoints/ps_auths/ps_aors/ps_contacts/ps_registrations).
This commit is contained in:
@@ -13,6 +13,10 @@ import { AuthModule } from './auth/auth.module';
|
||||
import { UsersModule } from './users/users.module';
|
||||
import { RolesModule } from './roles/roles.module';
|
||||
import { HealthModule } from './health/health.module';
|
||||
import { TelephonyModule } from './telephony/telephony.module';
|
||||
import { TrunksModule } from './trunks/trunks.module';
|
||||
import { ExtensionsModule } from './extensions/extensions.module';
|
||||
import { MonitoringModule } from './monitoring/monitoring.module';
|
||||
import { AuthGuard } from './common/guards/auth.guard';
|
||||
import { PermissionsGuard } from './common/guards/permissions.guard';
|
||||
import { GlobalExceptionFilter } from './common/filters/global-exception.filter';
|
||||
@@ -52,6 +56,10 @@ import { GlobalExceptionFilter } from './common/filters/global-exception.filter'
|
||||
UsersModule,
|
||||
RolesModule,
|
||||
HealthModule,
|
||||
TelephonyModule,
|
||||
TrunksModule,
|
||||
ExtensionsModule,
|
||||
MonitoringModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [
|
||||
|
||||
57
apps/api/src/extensions/dto/create-extension.dto.ts
Normal file
57
apps/api/src/extensions/dto/create-extension.dto.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateExtensionDto {
|
||||
@IsString()
|
||||
@Matches(/^\d{2,10}$/, {
|
||||
message: 'Número de ramal deve conter apenas dígitos (2 a 10).',
|
||||
})
|
||||
number!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(120)
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
callerId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
context?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
codecs?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
transport?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(10)
|
||||
maxContacts?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(10)
|
||||
qualifyFrequency?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
enabled?: boolean;
|
||||
}
|
||||
8
apps/api/src/extensions/dto/update-extension.dto.ts
Normal file
8
apps/api/src/extensions/dto/update-extension.dto.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { PartialType, OmitType } from '@nestjs/mapped-types';
|
||||
import { CreateExtensionDto } from './create-extension.dto';
|
||||
|
||||
// "number" não é editável — trocar o número de ramal é, na prática, criar
|
||||
// outro ramal (o identificador do objeto PJSIP é o próprio número).
|
||||
export class UpdateExtensionDto extends PartialType(
|
||||
OmitType(CreateExtensionDto, ['number'] as const),
|
||||
) {}
|
||||
88
apps/api/src/extensions/extensions.controller.ts
Normal file
88
apps/api/src/extensions/extensions.controller.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Req,
|
||||
} from '@nestjs/common';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import { RequirePermissions } from '../common/decorators/permissions.decorator';
|
||||
import { CurrentUser } from '../common/decorators/current-user.decorator';
|
||||
import type { AuthenticatedUser } from '../common/guards/auth.guard';
|
||||
import { ExtensionsService } from './extensions.service';
|
||||
import { CreateExtensionDto } from './dto/create-extension.dto';
|
||||
import { UpdateExtensionDto } from './dto/update-extension.dto';
|
||||
|
||||
@Controller('extensions')
|
||||
export class ExtensionsController {
|
||||
constructor(private readonly extensionsService: ExtensionsService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermissions('extensions.view')
|
||||
list() {
|
||||
return this.extensionsService.list();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermissions('extensions.view')
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.extensionsService.findByIdOrThrow(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePermissions('extensions.create')
|
||||
create(
|
||||
@Body() dto: CreateExtensionDto,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
@Req() request: FastifyRequest,
|
||||
) {
|
||||
return this.extensionsService.create(dto, actor, {
|
||||
ip: request.ip,
|
||||
userAgent: request.headers['user-agent'],
|
||||
});
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RequirePermissions('extensions.update')
|
||||
update(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateExtensionDto,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
@Req() request: FastifyRequest,
|
||||
) {
|
||||
return this.extensionsService.update(id, dto, actor, {
|
||||
ip: request.ip,
|
||||
userAgent: request.headers['user-agent'],
|
||||
});
|
||||
}
|
||||
|
||||
@Post(':id/reset-password')
|
||||
@RequirePermissions('extensions.update')
|
||||
resetPassword(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
@Req() request: FastifyRequest,
|
||||
) {
|
||||
return this.extensionsService.resetPassword(id, actor, {
|
||||
ip: request.ip,
|
||||
userAgent: request.headers['user-agent'],
|
||||
});
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RequirePermissions('extensions.delete')
|
||||
remove(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
@Req() request: FastifyRequest,
|
||||
) {
|
||||
return this.extensionsService.delete(id, actor, {
|
||||
ip: request.ip,
|
||||
userAgent: request.headers['user-agent'],
|
||||
});
|
||||
}
|
||||
}
|
||||
9
apps/api/src/extensions/extensions.module.ts
Normal file
9
apps/api/src/extensions/extensions.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ExtensionsController } from './extensions.controller';
|
||||
import { ExtensionsService } from './extensions.service';
|
||||
|
||||
@Module({
|
||||
controllers: [ExtensionsController],
|
||||
providers: [ExtensionsService],
|
||||
})
|
||||
export class ExtensionsModule {}
|
||||
230
apps/api/src/extensions/extensions.service.ts
Normal file
230
apps/api/src/extensions/extensions.service.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Extension } from '@b2bcall/database';
|
||||
import {
|
||||
decryptSecret,
|
||||
encryptSecret,
|
||||
generateStrongPassword,
|
||||
} from '@b2bcall/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import type { RequestContext } from '../auth/auth.service';
|
||||
import { PjsipRealtimeService } from '../telephony/pjsip-realtime.service';
|
||||
import { CreateExtensionDto } from './dto/create-extension.dto';
|
||||
import { UpdateExtensionDto } from './dto/update-extension.dto';
|
||||
|
||||
function toSafeExtension(ext: Extension) {
|
||||
return {
|
||||
id: ext.id,
|
||||
number: ext.number,
|
||||
name: ext.name,
|
||||
callerId: ext.callerId,
|
||||
context: ext.context,
|
||||
codecs: ext.codecs,
|
||||
transport: ext.transport,
|
||||
maxContacts: ext.maxContacts,
|
||||
qualifyFrequency: ext.qualifyFrequency,
|
||||
enabled: ext.enabled,
|
||||
createdAt: ext.createdAt,
|
||||
updatedAt: ext.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ExtensionsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly pjsip: PjsipRealtimeService,
|
||||
private readonly audit: AuditService,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
private get masterKey(): string {
|
||||
return this.config.getOrThrow<string>('SECRETS_MASTER_KEY');
|
||||
}
|
||||
|
||||
async list() {
|
||||
const extensions = await this.prisma.extension.findMany({
|
||||
orderBy: { number: 'asc' },
|
||||
});
|
||||
const states = await this.prisma.extensionState.findMany({
|
||||
where: { extension: { in: extensions.map((e) => e.number) } },
|
||||
});
|
||||
const stateByExtension = new Map(states.map((s) => [s.extension, s]));
|
||||
return extensions.map((e) => ({
|
||||
...toSafeExtension(e),
|
||||
state: stateByExtension.get(e.number) ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
async findByIdOrThrow(id: string) {
|
||||
const extension = await this.prisma.extension.findUnique({ where: { id } });
|
||||
if (!extension) throw new NotFoundException('Ramal não encontrado.');
|
||||
const state = await this.prisma.extensionState.findUnique({
|
||||
where: { extension: extension.number },
|
||||
});
|
||||
return { ...toSafeExtension(extension), state };
|
||||
}
|
||||
|
||||
private async provisionPjsip(extension: Extension, plainPassword: string) {
|
||||
const id = extension.number;
|
||||
await this.pjsip.upsertAuth({ id, username: id, password: plainPassword });
|
||||
await this.pjsip.upsertAor({
|
||||
id,
|
||||
maxContacts: extension.maxContacts,
|
||||
qualifyFrequency: extension.qualifyFrequency,
|
||||
});
|
||||
await this.pjsip.upsertEndpoint({
|
||||
id,
|
||||
aors: id,
|
||||
auth: id,
|
||||
context: extension.context,
|
||||
allowCodecs: extension.codecs,
|
||||
dtmfMode: 'rfc4733',
|
||||
callerId: extension.callerId ?? undefined,
|
||||
identifyBy: 'username',
|
||||
maxContacts: extension.maxContacts,
|
||||
});
|
||||
}
|
||||
|
||||
async create(
|
||||
dto: CreateExtensionDto,
|
||||
actor: { id: string },
|
||||
ctx: RequestContext,
|
||||
) {
|
||||
const existing = await this.prisma.extension.findUnique({
|
||||
where: { number: dto.number },
|
||||
});
|
||||
if (existing)
|
||||
throw new BadRequestException('Já existe um ramal com este número.');
|
||||
|
||||
const password = generateStrongPassword();
|
||||
const sipPasswordEncrypted = encryptSecret(password, this.masterKey);
|
||||
|
||||
const extension = await this.prisma.extension.create({
|
||||
data: {
|
||||
number: dto.number,
|
||||
name: dto.name,
|
||||
sipPasswordEncrypted,
|
||||
callerId: dto.callerId,
|
||||
context: dto.context ?? 'b2bcall-agents',
|
||||
codecs: dto.codecs ?? ['ulaw', 'alaw'],
|
||||
transport: dto.transport ?? 'udp',
|
||||
maxContacts: dto.maxContacts ?? 1,
|
||||
qualifyFrequency: dto.qualifyFrequency ?? 60,
|
||||
enabled: dto.enabled ?? true,
|
||||
},
|
||||
});
|
||||
|
||||
await this.provisionPjsip(extension, password);
|
||||
|
||||
await this.audit.log({
|
||||
userId: actor.id,
|
||||
action: 'extension_created',
|
||||
entityType: 'extension',
|
||||
entityId: extension.id,
|
||||
after: { number: extension.number, name: extension.name },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
|
||||
// A senha só é exibida nesta resposta — nunca mais recuperável (só reset).
|
||||
return { ...toSafeExtension(extension), sipPassword: password };
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
dto: UpdateExtensionDto,
|
||||
actor: { id: string },
|
||||
ctx: RequestContext,
|
||||
) {
|
||||
const before = await this.prisma.extension.findUnique({ where: { id } });
|
||||
if (!before) throw new NotFoundException('Ramal não encontrado.');
|
||||
|
||||
const extension = await this.prisma.extension.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: dto.name,
|
||||
callerId: dto.callerId,
|
||||
context: dto.context,
|
||||
codecs: dto.codecs,
|
||||
transport: dto.transport,
|
||||
maxContacts: dto.maxContacts,
|
||||
qualifyFrequency: dto.qualifyFrequency,
|
||||
enabled: dto.enabled,
|
||||
},
|
||||
});
|
||||
|
||||
// Não há troca de senha aqui — reaplica só endpoint/aor com a senha atual.
|
||||
const currentPassword = decryptSecret(
|
||||
extension.sipPasswordEncrypted,
|
||||
this.masterKey,
|
||||
);
|
||||
await this.provisionPjsip(extension, currentPassword);
|
||||
|
||||
await this.audit.log({
|
||||
userId: actor.id,
|
||||
action: 'extension_updated',
|
||||
entityType: 'extension',
|
||||
entityId: id,
|
||||
before: { name: before.name, enabled: before.enabled },
|
||||
after: { name: extension.name, enabled: extension.enabled },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
|
||||
return toSafeExtension(extension);
|
||||
}
|
||||
|
||||
async resetPassword(id: string, actor: { id: string }, ctx: RequestContext) {
|
||||
const extension = await this.prisma.extension.findUnique({ where: { id } });
|
||||
if (!extension) throw new NotFoundException('Ramal não encontrado.');
|
||||
|
||||
const password = generateStrongPassword();
|
||||
const sipPasswordEncrypted = encryptSecret(password, this.masterKey);
|
||||
|
||||
const updated = await this.prisma.extension.update({
|
||||
where: { id },
|
||||
data: { sipPasswordEncrypted },
|
||||
});
|
||||
await this.provisionPjsip(updated, password);
|
||||
|
||||
await this.audit.log({
|
||||
userId: actor.id,
|
||||
action: 'extension_password_reset',
|
||||
entityType: 'extension',
|
||||
entityId: id,
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
|
||||
return { ...toSafeExtension(updated), sipPassword: password };
|
||||
}
|
||||
|
||||
async delete(id: string, actor: { id: string }, ctx: RequestContext) {
|
||||
const extension = await this.prisma.extension.findUnique({ where: { id } });
|
||||
if (!extension) throw new NotFoundException('Ramal não encontrado.');
|
||||
|
||||
await this.pjsip.deleteObjectCascade(extension.number);
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.extensionState.deleteMany({
|
||||
where: { extension: extension.number },
|
||||
}),
|
||||
this.prisma.extension.delete({ where: { id } }),
|
||||
]);
|
||||
|
||||
await this.audit.log({
|
||||
userId: actor.id,
|
||||
action: 'extension_deleted',
|
||||
entityType: 'extension',
|
||||
entityId: id,
|
||||
before: { number: extension.number },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,21 @@ export class HealthController {
|
||||
return { redis: { status: 'up' } };
|
||||
};
|
||||
|
||||
// apps/asterisk-events grava um heartbeat a cada ~7,5s (TTL 15s) sempre
|
||||
// que sua conexão AMI está viva — evita a API precisar de sua própria
|
||||
// conexão AMI só para reportar status (agente.md seção 62).
|
||||
private asteriskIndicator: HealthIndicatorFunction =
|
||||
async (): Promise<HealthIndicatorResult> => {
|
||||
const heartbeat = await this.redis.get(
|
||||
'b2bcall:asterisk-events:heartbeat',
|
||||
);
|
||||
if (!heartbeat)
|
||||
throw new Error(
|
||||
'Sem heartbeat de apps/asterisk-events (AMI indisponível)',
|
||||
);
|
||||
return { asterisk: { status: 'up', lastHeartbeat: heartbeat } };
|
||||
};
|
||||
|
||||
// Liveness: o processo da API está de pé. Não depende de dependências
|
||||
// externas — usado por orquestradores para decidir se precisa reiniciar.
|
||||
@Public()
|
||||
@@ -41,18 +56,25 @@ export class HealthController {
|
||||
}
|
||||
|
||||
// Readiness: a API está pronta para tráfego real (dependências no ar).
|
||||
// Asterisk/AMI entram aqui quando apps/asterisk-events existir (Fase 4).
|
||||
@Public()
|
||||
@Get('ready')
|
||||
@HealthCheck()
|
||||
ready() {
|
||||
return this.health.check([this.postgresIndicator, this.redisIndicator]);
|
||||
return this.health.check([
|
||||
this.postgresIndicator,
|
||||
this.redisIndicator,
|
||||
this.asteriskIndicator,
|
||||
]);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get()
|
||||
@HealthCheck()
|
||||
check() {
|
||||
return this.health.check([this.postgresIndicator, this.redisIndicator]);
|
||||
return this.health.check([
|
||||
this.postgresIndicator,
|
||||
this.redisIndicator,
|
||||
this.asteriskIndicator,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
26
apps/api/src/monitoring/extension-status.util.ts
Normal file
26
apps/api/src/monitoring/extension-status.util.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
// Prioridade visual do painel de ramais (agente.md seção 15). As camadas de
|
||||
// PAUSA e AGENTE LOGADO dependem de dados da Fase 5 (agents/agent_sessions)
|
||||
// — por ora computamos apenas REGISTERED/AVAILABLE/BUSY/OFFLINE a partir do
|
||||
// estado real do dispositivo PJSIP.
|
||||
export type ExtensionVisualStatus = 'OFFLINE' | 'AVAILABLE' | 'BUSY';
|
||||
|
||||
const BUSY_DEVICE_STATES = new Set([
|
||||
'INUSE',
|
||||
'BUSY',
|
||||
'RINGING',
|
||||
'RINGINUSE',
|
||||
'ONHOLD',
|
||||
]);
|
||||
|
||||
export function computeExtensionVisualStatus(
|
||||
deviceState: string | null | undefined,
|
||||
contactStatus: string | null | undefined,
|
||||
): ExtensionVisualStatus {
|
||||
const isRegistered =
|
||||
contactStatus === 'Reachable' ||
|
||||
contactStatus === 'Created' ||
|
||||
contactStatus === 'Updated';
|
||||
if (!isRegistered) return 'OFFLINE';
|
||||
if (deviceState && BUSY_DEVICE_STATES.has(deviceState)) return 'BUSY';
|
||||
return 'AVAILABLE';
|
||||
}
|
||||
37
apps/api/src/monitoring/monitoring.controller.ts
Normal file
37
apps/api/src/monitoring/monitoring.controller.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { RequirePermissions } from '../common/decorators/permissions.decorator';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { computeExtensionVisualStatus } from './extension-status.util';
|
||||
|
||||
@Controller('monitoring')
|
||||
export class MonitoringController {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
@Get('extensions')
|
||||
@RequirePermissions('monitoring.view')
|
||||
async extensions() {
|
||||
const extensions = await this.prisma.extension.findMany({
|
||||
orderBy: { number: 'asc' },
|
||||
});
|
||||
const states = await this.prisma.extensionState.findMany({
|
||||
where: { extension: { in: extensions.map((e) => e.number) } },
|
||||
});
|
||||
const stateByExtension = new Map(states.map((s) => [s.extension, s]));
|
||||
|
||||
return extensions.map((ext) => {
|
||||
const state = stateByExtension.get(ext.number);
|
||||
return {
|
||||
number: ext.number,
|
||||
name: ext.name,
|
||||
enabled: ext.enabled,
|
||||
deviceState: state?.deviceState ?? null,
|
||||
contactStatus: state?.contactStatus ?? null,
|
||||
status: computeExtensionVisualStatus(
|
||||
state?.deviceState,
|
||||
state?.contactStatus,
|
||||
),
|
||||
updatedAt: state?.updatedAt ?? null,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
7
apps/api/src/monitoring/monitoring.module.ts
Normal file
7
apps/api/src/monitoring/monitoring.module.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MonitoringController } from './monitoring.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [MonitoringController],
|
||||
})
|
||||
export class MonitoringModule {}
|
||||
160
apps/api/src/telephony/pjsip-realtime.service.ts
Normal file
160
apps/api/src/telephony/pjsip-realtime.service.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@b2bcall/database';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
export interface EndpointFields {
|
||||
id: string;
|
||||
transport?: string;
|
||||
aors: string;
|
||||
auth?: string;
|
||||
outboundAuth?: string;
|
||||
context: string;
|
||||
allowCodecs: string[];
|
||||
dtmfMode: string;
|
||||
callerId?: string;
|
||||
identifyBy: 'username' | 'ip' | 'username,ip';
|
||||
maxContacts: number;
|
||||
}
|
||||
|
||||
export interface AuthFields {
|
||||
id: string;
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface AorFields {
|
||||
id: string;
|
||||
contact?: string;
|
||||
maxContacts: number;
|
||||
qualifyFrequency: number;
|
||||
}
|
||||
|
||||
export interface IdentifyFields {
|
||||
id: string;
|
||||
endpoint: string;
|
||||
match: string;
|
||||
}
|
||||
|
||||
export interface RegistrationFields {
|
||||
id: string;
|
||||
endpoint: string;
|
||||
clientUri: string;
|
||||
serverUri: string;
|
||||
outboundAuth?: string;
|
||||
transport?: string;
|
||||
retryInterval: number;
|
||||
maxRetries: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Único ponto de escrita nas tabelas de Realtime do Asterisk (schema
|
||||
* "asterisk" — nunca "public"). Nenhum outro serviço deve montar SQL contra
|
||||
* essas tabelas diretamente (agente.md seção 6: "não misture indiscriminadamente
|
||||
* tabelas da aplicação com tabelas internas do Asterisk").
|
||||
*/
|
||||
@Injectable()
|
||||
export class PjsipRealtimeService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async upsertEndpoint(f: EndpointFields): Promise<void> {
|
||||
await this.prisma.$executeRaw`
|
||||
INSERT INTO asterisk.ps_endpoints
|
||||
(id, transport, aors, auth, outbound_auth, context, disallow, allow,
|
||||
dtmf_mode, callerid, identify_by, max_contacts, rewrite_contact,
|
||||
rtp_symmetric, force_rport, type)
|
||||
VALUES
|
||||
(${f.id}, ${f.transport ?? null}, ${f.aors}, ${f.auth ?? null}, ${f.outboundAuth ?? null},
|
||||
${f.context}, 'all', ${f.allowCodecs.join(',')}, ${f.dtmfMode}, ${f.callerId ?? null},
|
||||
${f.identifyBy}, ${f.maxContacts}, 'yes', 'yes', 'yes', 'endpoint')
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
transport = EXCLUDED.transport,
|
||||
aors = EXCLUDED.aors,
|
||||
auth = EXCLUDED.auth,
|
||||
outbound_auth = EXCLUDED.outbound_auth,
|
||||
context = EXCLUDED.context,
|
||||
allow = EXCLUDED.allow,
|
||||
dtmf_mode = EXCLUDED.dtmf_mode,
|
||||
callerid = EXCLUDED.callerid,
|
||||
identify_by = EXCLUDED.identify_by,
|
||||
max_contacts = EXCLUDED.max_contacts
|
||||
`;
|
||||
}
|
||||
|
||||
async upsertAuth(f: AuthFields): Promise<void> {
|
||||
await this.prisma.$executeRaw`
|
||||
INSERT INTO asterisk.ps_auths (id, auth_type, username, password)
|
||||
VALUES (${f.id}, 'userpass', ${f.username}, ${f.password})
|
||||
ON CONFLICT (id) DO UPDATE SET username = EXCLUDED.username, password = EXCLUDED.password
|
||||
`;
|
||||
}
|
||||
|
||||
async upsertAor(f: AorFields): Promise<void> {
|
||||
await this.prisma.$executeRaw`
|
||||
INSERT INTO asterisk.ps_aors (id, contact, max_contacts, qualify_frequency, remove_existing)
|
||||
VALUES (${f.id}, ${f.contact ?? null}, ${f.maxContacts}, ${f.qualifyFrequency}, 'yes')
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
contact = EXCLUDED.contact,
|
||||
max_contacts = EXCLUDED.max_contacts,
|
||||
qualify_frequency = EXCLUDED.qualify_frequency
|
||||
`;
|
||||
}
|
||||
|
||||
async upsertIdentify(f: IdentifyFields): Promise<void> {
|
||||
await this.prisma.$executeRaw`
|
||||
INSERT INTO asterisk.ps_endpoint_id_ips (id, endpoint, match)
|
||||
VALUES (${f.id}, ${f.endpoint}, ${f.match})
|
||||
ON CONFLICT (id) DO UPDATE SET endpoint = EXCLUDED.endpoint, match = EXCLUDED.match
|
||||
`;
|
||||
}
|
||||
|
||||
async deleteIdentifyByEndpoint(endpoint: string): Promise<void> {
|
||||
await this.prisma
|
||||
.$executeRaw`DELETE FROM asterisk.ps_endpoint_id_ips WHERE endpoint = ${endpoint}`;
|
||||
}
|
||||
|
||||
async upsertRegistration(f: RegistrationFields): Promise<void> {
|
||||
await this.prisma.$executeRaw`
|
||||
INSERT INTO asterisk.ps_registrations
|
||||
(id, endpoint, client_uri, server_uri, outbound_auth, transport, retry_interval, max_retries)
|
||||
VALUES
|
||||
(${f.id}, ${f.endpoint}, ${f.clientUri}, ${f.serverUri}, ${f.outboundAuth ?? null},
|
||||
${f.transport ?? null}, ${f.retryInterval}, ${f.maxRetries})
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
client_uri = EXCLUDED.client_uri,
|
||||
server_uri = EXCLUDED.server_uri,
|
||||
outbound_auth = EXCLUDED.outbound_auth,
|
||||
transport = EXCLUDED.transport,
|
||||
retry_interval = EXCLUDED.retry_interval,
|
||||
max_retries = EXCLUDED.max_retries
|
||||
`;
|
||||
}
|
||||
|
||||
async deleteRegistration(id: string): Promise<void> {
|
||||
await this.prisma
|
||||
.$executeRaw`DELETE FROM asterisk.ps_registrations WHERE id = ${id}`;
|
||||
}
|
||||
|
||||
/** Remove um objeto (trunk ou ramal) e tudo que depende dele, em cascata manual. */
|
||||
async deleteObjectCascade(id: string): Promise<void> {
|
||||
await this.prisma.$transaction([
|
||||
this.prisma
|
||||
.$executeRaw`DELETE FROM asterisk.ps_contacts WHERE endpoint = ${id}`,
|
||||
this.prisma
|
||||
.$executeRaw`DELETE FROM asterisk.ps_registrations WHERE endpoint = ${id}`,
|
||||
this.prisma
|
||||
.$executeRaw`DELETE FROM asterisk.ps_endpoint_id_ips WHERE endpoint = ${id}`,
|
||||
this.prisma
|
||||
.$executeRaw`DELETE FROM asterisk.ps_endpoints WHERE id = ${id}`,
|
||||
this.prisma.$executeRaw`DELETE FROM asterisk.ps_auths WHERE id = ${id}`,
|
||||
this.prisma.$executeRaw`DELETE FROM asterisk.ps_aors WHERE id = ${id}`,
|
||||
] as Prisma.PrismaPromise<unknown>[]);
|
||||
}
|
||||
|
||||
async getContacts(
|
||||
endpoint: string,
|
||||
): Promise<Array<{ uri: string; via_addr: string | null }>> {
|
||||
return this.prisma.$queryRaw`
|
||||
SELECT uri, via_addr FROM asterisk.ps_contacts WHERE endpoint = ${endpoint}
|
||||
`;
|
||||
}
|
||||
}
|
||||
59
apps/api/src/telephony/telephony.module.ts
Normal file
59
apps/api/src/telephony/telephony.module.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
Global,
|
||||
Inject,
|
||||
Module,
|
||||
OnModuleDestroy,
|
||||
OnModuleInit,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { AsteriskTelephonyProvider } from '@b2bcall/telephony';
|
||||
import { PjsipRealtimeService } from './pjsip-realtime.service';
|
||||
|
||||
export const TELEPHONY_PROVIDER = 'TELEPHONY_PROVIDER';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: TELEPHONY_PROVIDER,
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) =>
|
||||
new AsteriskTelephonyProvider({
|
||||
host: config.getOrThrow<string>('ASTERISK_HOST'),
|
||||
amiPort: Number(config.get('AMI_PORT', '5038')),
|
||||
amiUsername: config.getOrThrow<string>('AMI_USERNAME'),
|
||||
amiSecret: config.getOrThrow<string>('AMI_SECRET'),
|
||||
reconnect: true,
|
||||
}),
|
||||
},
|
||||
PjsipRealtimeService,
|
||||
],
|
||||
exports: [TELEPHONY_PROVIDER, PjsipRealtimeService],
|
||||
})
|
||||
export class TelephonyModule implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(TelephonyModule.name);
|
||||
|
||||
constructor(
|
||||
@Inject(TELEPHONY_PROVIDER)
|
||||
private readonly provider: AsteriskTelephonyProvider,
|
||||
) {}
|
||||
|
||||
async onModuleInit() {
|
||||
try {
|
||||
await this.provider.connect();
|
||||
this.logger.log('Conectado ao AMI do Asterisk');
|
||||
} catch (err) {
|
||||
// Não derruba a API se o Asterisk estiver fora do ar no boot — o
|
||||
// client tem reconnect automático e tentará novamente. Endpoints que
|
||||
// dependem dele falharão individualmente até a conexão voltar.
|
||||
this.logger.error(
|
||||
`Falha ao conectar ao AMI (tentará reconectar): ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
onModuleDestroy() {
|
||||
this.provider.disconnect();
|
||||
}
|
||||
}
|
||||
111
apps/api/src/trunks/dto/create-trunk.dto.ts
Normal file
111
apps/api/src/trunks/dto/create-trunk.dto.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
import { TrunkType, DtmfMode } from '@b2bcall/database';
|
||||
|
||||
export class CreateTrunkDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(80)
|
||||
@Matches(/^[a-zA-Z0-9_-]+$/, {
|
||||
message: 'Use apenas letras, números, hífen e underscore.',
|
||||
})
|
||||
name!: string;
|
||||
|
||||
@IsEnum(TrunkType)
|
||||
type!: TrunkType;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
host!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(65535)
|
||||
port?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
transport?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
username?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
password?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
fromUser?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
fromDomain?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
contactUser?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
outboundProxy?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
context?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
callerId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
codecs?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(DtmfMode)
|
||||
dtmfMode?: DtmfMode;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(10)
|
||||
qualifyFrequency?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
maxChannels?: number;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(1000)
|
||||
maxCps!: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(50)
|
||||
@Matches(/^\d{1,3}(\.\d{1,3}){3}(\/\d{1,2})?$/, {
|
||||
each: true,
|
||||
message: 'Use IPv4 ou CIDR, ex.: 10.0.0.0/24',
|
||||
})
|
||||
allowedIps?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
enabled?: boolean;
|
||||
}
|
||||
8
apps/api/src/trunks/dto/update-trunk.dto.ts
Normal file
8
apps/api/src/trunks/dto/update-trunk.dto.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { PartialType, OmitType } from '@nestjs/mapped-types';
|
||||
import { CreateTrunkDto } from './create-trunk.dto';
|
||||
|
||||
// "name" e "type" não são editáveis após a criação — trocar exigiria
|
||||
// reprovisionar os objetos PJSIP do zero; mais simples desativar e recriar.
|
||||
export class UpdateTrunkDto extends PartialType(
|
||||
OmitType(CreateTrunkDto, ['name', 'type'] as const),
|
||||
) {}
|
||||
81
apps/api/src/trunks/trunks.controller.ts
Normal file
81
apps/api/src/trunks/trunks.controller.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Req,
|
||||
} from '@nestjs/common';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import { RequirePermissions } from '../common/decorators/permissions.decorator';
|
||||
import { CurrentUser } from '../common/decorators/current-user.decorator';
|
||||
import type { AuthenticatedUser } from '../common/guards/auth.guard';
|
||||
import { TrunksService } from './trunks.service';
|
||||
import { CreateTrunkDto } from './dto/create-trunk.dto';
|
||||
import { UpdateTrunkDto } from './dto/update-trunk.dto';
|
||||
|
||||
@Controller('trunks')
|
||||
export class TrunksController {
|
||||
constructor(private readonly trunksService: TrunksService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermissions('trunks.view')
|
||||
list() {
|
||||
return this.trunksService.list();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermissions('trunks.view')
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.trunksService.findByIdOrThrow(id);
|
||||
}
|
||||
|
||||
@Get(':id/status')
|
||||
@RequirePermissions('trunks.view')
|
||||
status(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.trunksService.testStatus(id).then((status) => ({ status }));
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePermissions('trunks.create')
|
||||
create(
|
||||
@Body() dto: CreateTrunkDto,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
@Req() request: FastifyRequest,
|
||||
) {
|
||||
return this.trunksService.create(dto, actor, {
|
||||
ip: request.ip,
|
||||
userAgent: request.headers['user-agent'],
|
||||
});
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RequirePermissions('trunks.update')
|
||||
update(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateTrunkDto,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
@Req() request: FastifyRequest,
|
||||
) {
|
||||
return this.trunksService.update(id, dto, actor, {
|
||||
ip: request.ip,
|
||||
userAgent: request.headers['user-agent'],
|
||||
});
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RequirePermissions('trunks.delete')
|
||||
remove(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
@Req() request: FastifyRequest,
|
||||
) {
|
||||
return this.trunksService.delete(id, actor, {
|
||||
ip: request.ip,
|
||||
userAgent: request.headers['user-agent'],
|
||||
});
|
||||
}
|
||||
}
|
||||
9
apps/api/src/trunks/trunks.module.ts
Normal file
9
apps/api/src/trunks/trunks.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TrunksController } from './trunks.controller';
|
||||
import { TrunksService } from './trunks.service';
|
||||
|
||||
@Module({
|
||||
controllers: [TrunksController],
|
||||
providers: [TrunksService],
|
||||
})
|
||||
export class TrunksModule {}
|
||||
279
apps/api/src/trunks/trunks.service.ts
Normal file
279
apps/api/src/trunks/trunks.service.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Inject,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Trunk, TrunkType } from '@b2bcall/database';
|
||||
import { encryptSecret } from '@b2bcall/shared';
|
||||
import type { TelephonyProvider } from '@b2bcall/telephony';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import type { RequestContext } from '../auth/auth.service';
|
||||
import { TELEPHONY_PROVIDER } from '../telephony/telephony.module';
|
||||
import { PjsipRealtimeService } from '../telephony/pjsip-realtime.service';
|
||||
import { CreateTrunkDto } from './dto/create-trunk.dto';
|
||||
import { UpdateTrunkDto } from './dto/update-trunk.dto';
|
||||
|
||||
export type TrunkStatus = 'ONLINE' | 'OFFLINE' | 'UNREACHABLE' | 'UNKNOWN';
|
||||
|
||||
function toSafeTrunk(trunk: Trunk) {
|
||||
const { secretEncrypted: _secretEncrypted, ...safe } = trunk;
|
||||
return { ...safe, hasSecret: Boolean(_secretEncrypted) };
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TrunksService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly pjsip: PjsipRealtimeService,
|
||||
private readonly audit: AuditService,
|
||||
private readonly config: ConfigService,
|
||||
@Inject(TELEPHONY_PROVIDER) private readonly telephony: TelephonyProvider,
|
||||
) {}
|
||||
|
||||
private get masterKey(): string {
|
||||
return this.config.getOrThrow<string>('SECRETS_MASTER_KEY');
|
||||
}
|
||||
|
||||
async list() {
|
||||
const trunks = await this.prisma.trunk.findMany({
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
return trunks.map(toSafeTrunk);
|
||||
}
|
||||
|
||||
async findByIdOrThrow(id: string) {
|
||||
const trunk = await this.prisma.trunk.findUnique({ where: { id } });
|
||||
if (!trunk) throw new NotFoundException('Tronco não encontrado.');
|
||||
return toSafeTrunk(trunk);
|
||||
}
|
||||
|
||||
// Provisiona os objetos PJSIP realtime correspondentes ao tipo do tronco
|
||||
// (agente.md seção 5: IP Authentication / Username-Password / Registration).
|
||||
private async provisionPjsip(trunk: Trunk, plainSecret?: string) {
|
||||
const id = trunk.name;
|
||||
|
||||
if (
|
||||
trunk.type === TrunkType.AUTH ||
|
||||
trunk.type === TrunkType.REGISTRATION
|
||||
) {
|
||||
if (!trunk.username || !plainSecret) {
|
||||
throw new BadRequestException(
|
||||
'Troncos do tipo AUTH/REGISTRATION exigem username e password.',
|
||||
);
|
||||
}
|
||||
await this.pjsip.upsertAuth({
|
||||
id,
|
||||
username: trunk.username,
|
||||
password: plainSecret,
|
||||
});
|
||||
}
|
||||
|
||||
await this.pjsip.upsertAor({
|
||||
id,
|
||||
contact:
|
||||
trunk.type !== TrunkType.REGISTRATION
|
||||
? `sip:${trunk.host}:${trunk.port}`
|
||||
: undefined,
|
||||
maxContacts: trunk.maxChannels ?? 10,
|
||||
qualifyFrequency: trunk.qualifyFrequency,
|
||||
});
|
||||
|
||||
await this.pjsip.upsertEndpoint({
|
||||
id,
|
||||
aors: id,
|
||||
auth: trunk.type === TrunkType.AUTH ? id : undefined,
|
||||
outboundAuth:
|
||||
trunk.type === TrunkType.AUTH || trunk.type === TrunkType.REGISTRATION
|
||||
? id
|
||||
: undefined,
|
||||
context: trunk.context,
|
||||
allowCodecs: trunk.codecs,
|
||||
dtmfMode: trunk.dtmfMode,
|
||||
callerId: trunk.callerId ?? undefined,
|
||||
identifyBy: trunk.type === TrunkType.IP ? 'ip' : 'username,ip',
|
||||
maxContacts: trunk.maxChannels ?? 10,
|
||||
});
|
||||
|
||||
await this.pjsip.deleteIdentifyByEndpoint(id);
|
||||
if (trunk.type === TrunkType.IP) {
|
||||
const ips = trunk.allowedIps.length > 0 ? trunk.allowedIps : [trunk.host];
|
||||
for (const [i, ip] of ips.entries()) {
|
||||
await this.pjsip.upsertIdentify({
|
||||
id: `${id}-ip-${i}`,
|
||||
endpoint: id,
|
||||
match: ip,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (trunk.type === TrunkType.REGISTRATION) {
|
||||
await this.pjsip.upsertRegistration({
|
||||
id,
|
||||
endpoint: id,
|
||||
clientUri: `sip:${trunk.contactUser ?? trunk.username}@${trunk.host}:${trunk.port}`,
|
||||
serverUri: `sip:${trunk.host}:${trunk.port}`,
|
||||
outboundAuth: id,
|
||||
transport: trunk.transport,
|
||||
retryInterval: 60,
|
||||
maxRetries: 10,
|
||||
});
|
||||
} else {
|
||||
await this.pjsip.deleteRegistration(id);
|
||||
}
|
||||
}
|
||||
|
||||
async create(
|
||||
dto: CreateTrunkDto,
|
||||
actor: { id: string },
|
||||
ctx: RequestContext,
|
||||
) {
|
||||
const existing = await this.prisma.trunk.findUnique({
|
||||
where: { name: dto.name },
|
||||
});
|
||||
if (existing)
|
||||
throw new BadRequestException('Já existe um tronco com este nome.');
|
||||
|
||||
const secretEncrypted = dto.password
|
||||
? encryptSecret(dto.password, this.masterKey)
|
||||
: null;
|
||||
|
||||
const trunk = await this.prisma.trunk.create({
|
||||
data: {
|
||||
name: dto.name,
|
||||
type: dto.type,
|
||||
host: dto.host,
|
||||
port: dto.port ?? 5060,
|
||||
transport: dto.transport ?? 'udp',
|
||||
username: dto.username,
|
||||
secretEncrypted,
|
||||
fromUser: dto.fromUser,
|
||||
fromDomain: dto.fromDomain,
|
||||
contactUser: dto.contactUser,
|
||||
outboundProxy: dto.outboundProxy,
|
||||
context: dto.context ?? 'outbound',
|
||||
callerId: dto.callerId,
|
||||
codecs: dto.codecs ?? ['ulaw', 'alaw'],
|
||||
dtmfMode: dto.dtmfMode,
|
||||
qualifyFrequency: dto.qualifyFrequency ?? 60,
|
||||
maxChannels: dto.maxChannels,
|
||||
maxCps: dto.maxCps,
|
||||
allowedIps: dto.allowedIps ?? [],
|
||||
enabled: dto.enabled ?? true,
|
||||
},
|
||||
});
|
||||
|
||||
await this.provisionPjsip(trunk, dto.password);
|
||||
|
||||
await this.audit.log({
|
||||
userId: actor.id,
|
||||
action: 'trunk_created',
|
||||
entityType: 'trunk',
|
||||
entityId: trunk.id,
|
||||
after: { ...dto, password: dto.password ? '[REDACTED]' : undefined },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
|
||||
return toSafeTrunk(trunk);
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
dto: UpdateTrunkDto,
|
||||
actor: { id: string },
|
||||
ctx: RequestContext,
|
||||
) {
|
||||
const before = await this.prisma.trunk.findUnique({ where: { id } });
|
||||
if (!before) throw new NotFoundException('Tronco não encontrado.');
|
||||
|
||||
const secretEncrypted = dto.password
|
||||
? encryptSecret(dto.password, this.masterKey)
|
||||
: undefined;
|
||||
|
||||
const trunk = await this.prisma.trunk.update({
|
||||
where: { id },
|
||||
data: {
|
||||
host: dto.host,
|
||||
port: dto.port,
|
||||
transport: dto.transport,
|
||||
username: dto.username,
|
||||
...(secretEncrypted !== undefined ? { secretEncrypted } : {}),
|
||||
fromUser: dto.fromUser,
|
||||
fromDomain: dto.fromDomain,
|
||||
contactUser: dto.contactUser,
|
||||
outboundProxy: dto.outboundProxy,
|
||||
context: dto.context,
|
||||
callerId: dto.callerId,
|
||||
codecs: dto.codecs,
|
||||
dtmfMode: dto.dtmfMode,
|
||||
qualifyFrequency: dto.qualifyFrequency,
|
||||
maxChannels: dto.maxChannels,
|
||||
maxCps: dto.maxCps,
|
||||
allowedIps: dto.allowedIps,
|
||||
enabled: dto.enabled,
|
||||
},
|
||||
});
|
||||
|
||||
await this.provisionPjsip(trunk, dto.password);
|
||||
|
||||
await this.audit.log({
|
||||
userId: actor.id,
|
||||
action: 'trunk_updated',
|
||||
entityType: 'trunk',
|
||||
entityId: id,
|
||||
before: {
|
||||
...before,
|
||||
secretEncrypted: before.secretEncrypted ? '[REDACTED]' : null,
|
||||
},
|
||||
after: { ...dto, password: dto.password ? '[REDACTED]' : undefined },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
|
||||
return toSafeTrunk(trunk);
|
||||
}
|
||||
|
||||
async delete(id: string, actor: { id: string }, ctx: RequestContext) {
|
||||
const trunk = await this.prisma.trunk.findUnique({ where: { id } });
|
||||
if (!trunk) throw new NotFoundException('Tronco não encontrado.');
|
||||
|
||||
await this.pjsip.deleteObjectCascade(trunk.name);
|
||||
await this.prisma.trunk.delete({ where: { id } });
|
||||
|
||||
await this.audit.log({
|
||||
userId: actor.id,
|
||||
action: 'trunk_deleted',
|
||||
entityType: 'trunk',
|
||||
entityId: id,
|
||||
before: { name: trunk.name },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
}
|
||||
|
||||
async testStatus(id: string): Promise<TrunkStatus> {
|
||||
const trunk = await this.prisma.trunk.findUnique({ where: { id } });
|
||||
if (!trunk) throw new NotFoundException('Tronco não encontrado.');
|
||||
|
||||
if (!this.telephony.isConnected()) return 'UNKNOWN';
|
||||
|
||||
if (trunk.type === TrunkType.REGISTRATION) {
|
||||
const output = await this.telephony.runCommand(
|
||||
`pjsip show registration ${trunk.name}`,
|
||||
);
|
||||
if (/Registered/i.test(output)) return 'ONLINE';
|
||||
if (/Rejected|Unregistered/i.test(output)) return 'OFFLINE';
|
||||
return 'UNKNOWN';
|
||||
}
|
||||
|
||||
const contacts = await this.telephony.pjsipShowContacts();
|
||||
const mine = contacts.filter((c) => c.endpointName === trunk.name);
|
||||
if (mine.length === 0) return 'UNKNOWN';
|
||||
if (mine.some((c) => c.status === 'Reachable')) return 'ONLINE';
|
||||
if (mine.every((c) => c.status === 'Unreachable')) return 'UNREACHABLE';
|
||||
return 'UNKNOWN';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user