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:
10
TODO.md
10
TODO.md
@@ -105,8 +105,14 @@ mestre original (`agente.md`, seções 90-93).
|
|||||||
heartbeat para health check — testado ponta a ponta com chamada real
|
heartbeat para health check — testado ponta a ponta com chamada real
|
||||||
- [x] Schema Prisma: Trunk, Extension, ExtensionState + criptografia de
|
- [x] Schema Prisma: Trunk, Extension, ExtensionState + criptografia de
|
||||||
segredos AES-256-GCM (packages/shared/secret-crypto.ts)
|
segredos AES-256-GCM (packages/shared/secret-crypto.ts)
|
||||||
- [ ] CRUD Troncos (com CPS máximo, ACL, teste de status)
|
- [x] CRUD Troncos (IP/AUTH/REGISTRATION, CPS máximo, ACL, secret cifrado
|
||||||
- [ ] CRUD Ramais (senha SIP gerada, reset, status tempo real)
|
AES-256-GCM, teste de status) — testado: criado via API, verificado
|
||||||
|
que o Asterisk enxerga o endpoint via realtime SEM reload, status
|
||||||
|
consultado via PJSIPShowContacts, exclusão em cascata confirmada
|
||||||
|
- [x] CRUD Ramais (senha SIP gerada automaticamente, nunca reexibida exceto
|
||||||
|
na criação/reset, reset endpoint) — testado igual aos troncos
|
||||||
|
- [x] GET /api/monitoring/extensions — status com prioridade de cor
|
||||||
|
(offline/available/busy; pausa e agente logado chegam na Fase 5)
|
||||||
- [ ] Painel visual de Ramais (WebSocket, prioridade de cores) — backend
|
- [ ] Painel visual de Ramais (WebSocket, prioridade de cores) — backend
|
||||||
(gateway WS) pendente; ExtensionState/pub-sub já prontos como base
|
(gateway WS) pendente; ExtensionState/pub-sub já prontos como base
|
||||||
- [ ] Dialplan estruturado (versionado, modo Advanced, validação+rollback)
|
- [ ] Dialplan estruturado (versionado, modo Advanced, validação+rollback)
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@b2bcall/database": "workspace:*",
|
"@b2bcall/database": "workspace:*",
|
||||||
"@b2bcall/shared": "workspace:*",
|
"@b2bcall/shared": "workspace:*",
|
||||||
|
"@b2bcall/telephony": "workspace:*",
|
||||||
"@fastify/cookie": "^11.0.2",
|
"@fastify/cookie": "^11.0.2",
|
||||||
"@fastify/helmet": "^13.0.1",
|
"@fastify/helmet": "^13.0.1",
|
||||||
"@fastify/static": "^8.0.4",
|
"@fastify/static": "^8.0.4",
|
||||||
@@ -29,6 +30,7 @@
|
|||||||
"@nestjs/config": "^4.0.2",
|
"@nestjs/config": "^4.0.2",
|
||||||
"@nestjs/core": "^11.0.1",
|
"@nestjs/core": "^11.0.1",
|
||||||
"@nestjs/jwt": "^11.0.0",
|
"@nestjs/jwt": "^11.0.0",
|
||||||
|
"@nestjs/mapped-types": "^2.1.0",
|
||||||
"@nestjs/platform-fastify": "^11.0.1",
|
"@nestjs/platform-fastify": "^11.0.1",
|
||||||
"@nestjs/swagger": "^11.2.0",
|
"@nestjs/swagger": "^11.2.0",
|
||||||
"@nestjs/terminus": "^11.0.0",
|
"@nestjs/terminus": "^11.0.0",
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ import { AuthModule } from './auth/auth.module';
|
|||||||
import { UsersModule } from './users/users.module';
|
import { UsersModule } from './users/users.module';
|
||||||
import { RolesModule } from './roles/roles.module';
|
import { RolesModule } from './roles/roles.module';
|
||||||
import { HealthModule } from './health/health.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 { AuthGuard } from './common/guards/auth.guard';
|
||||||
import { PermissionsGuard } from './common/guards/permissions.guard';
|
import { PermissionsGuard } from './common/guards/permissions.guard';
|
||||||
import { GlobalExceptionFilter } from './common/filters/global-exception.filter';
|
import { GlobalExceptionFilter } from './common/filters/global-exception.filter';
|
||||||
@@ -52,6 +56,10 @@ import { GlobalExceptionFilter } from './common/filters/global-exception.filter'
|
|||||||
UsersModule,
|
UsersModule,
|
||||||
RolesModule,
|
RolesModule,
|
||||||
HealthModule,
|
HealthModule,
|
||||||
|
TelephonyModule,
|
||||||
|
TrunksModule,
|
||||||
|
ExtensionsModule,
|
||||||
|
MonitoringModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [
|
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' } };
|
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
|
// Liveness: o processo da API está de pé. Não depende de dependências
|
||||||
// externas — usado por orquestradores para decidir se precisa reiniciar.
|
// externas — usado por orquestradores para decidir se precisa reiniciar.
|
||||||
@Public()
|
@Public()
|
||||||
@@ -41,18 +56,25 @@ export class HealthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Readiness: a API está pronta para tráfego real (dependências no ar).
|
// 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()
|
@Public()
|
||||||
@Get('ready')
|
@Get('ready')
|
||||||
@HealthCheck()
|
@HealthCheck()
|
||||||
ready() {
|
ready() {
|
||||||
return this.health.check([this.postgresIndicator, this.redisIndicator]);
|
return this.health.check([
|
||||||
|
this.postgresIndicator,
|
||||||
|
this.redisIndicator,
|
||||||
|
this.asteriskIndicator,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@Get()
|
@Get()
|
||||||
@HealthCheck()
|
@HealthCheck()
|
||||||
check() {
|
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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,14 +14,17 @@ COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
|
|||||||
COPY apps/api/package.json apps/api/package.json
|
COPY apps/api/package.json apps/api/package.json
|
||||||
COPY packages/database/package.json packages/database/package.json
|
COPY packages/database/package.json packages/database/package.json
|
||||||
COPY packages/shared/package.json packages/shared/package.json
|
COPY packages/shared/package.json packages/shared/package.json
|
||||||
|
COPY packages/telephony/package.json packages/telephony/package.json
|
||||||
|
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
COPY packages/shared packages/shared
|
COPY packages/shared packages/shared
|
||||||
|
COPY packages/telephony packages/telephony
|
||||||
COPY packages/database packages/database
|
COPY packages/database packages/database
|
||||||
COPY apps/api apps/api
|
COPY apps/api apps/api
|
||||||
|
|
||||||
RUN pnpm --filter @b2bcall/shared build \
|
RUN pnpm --filter @b2bcall/shared build \
|
||||||
|
&& pnpm --filter @b2bcall/telephony build \
|
||||||
&& pnpm --filter @b2bcall/database build \
|
&& pnpm --filter @b2bcall/database build \
|
||||||
&& pnpm --filter @b2bcall/api build
|
&& pnpm --filter @b2bcall/api build
|
||||||
|
|
||||||
|
|||||||
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
@@ -16,6 +16,9 @@ importers:
|
|||||||
'@b2bcall/shared':
|
'@b2bcall/shared':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/shared
|
version: link:../../packages/shared
|
||||||
|
'@b2bcall/telephony':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/telephony
|
||||||
'@fastify/cookie':
|
'@fastify/cookie':
|
||||||
specifier: ^11.0.2
|
specifier: ^11.0.2
|
||||||
version: 11.1.2
|
version: 11.1.2
|
||||||
@@ -37,6 +40,9 @@ importers:
|
|||||||
'@nestjs/jwt':
|
'@nestjs/jwt':
|
||||||
specifier: ^11.0.0
|
specifier: ^11.0.0
|
||||||
version: 11.0.2(@nestjs/common@11.2.3(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))
|
version: 11.0.2(@nestjs/common@11.2.3(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))
|
||||||
|
'@nestjs/mapped-types':
|
||||||
|
specifier: ^2.1.0
|
||||||
|
version: 2.1.1(@nestjs/common@11.2.3(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)
|
||||||
'@nestjs/platform-fastify':
|
'@nestjs/platform-fastify':
|
||||||
specifier: ^11.0.1
|
specifier: ^11.0.1
|
||||||
version: 11.2.3(@fastify/static@8.3.0)(@nestjs/common@11.2.3(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.2.3(@nestjs/common@11.2.3(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(reflect-metadata@0.2.2)(rxjs@7.8.2))
|
version: 11.2.3(@fastify/static@8.3.0)(@nestjs/common@11.2.3(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.2.3(@nestjs/common@11.2.3(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(reflect-metadata@0.2.2)(rxjs@7.8.2))
|
||||||
|
|||||||
Reference in New Issue
Block a user