From 8a41b5d2b25058a241682ec467d32b34600f61e7 Mon Sep 17 00:00:00 2001 From: B2BCall Bootstrap Date: Thu, 27 Aug 2026 12:58:12 -0300 Subject: [PATCH] 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). --- TODO.md | 10 +- apps/api/package.json | 2 + apps/api/src/app.module.ts | 8 + .../extensions/dto/create-extension.dto.ts | 57 ++++ .../extensions/dto/update-extension.dto.ts | 8 + .../src/extensions/extensions.controller.ts | 88 ++++++ apps/api/src/extensions/extensions.module.ts | 9 + apps/api/src/extensions/extensions.service.ts | 230 +++++++++++++++ apps/api/src/health/health.controller.ts | 28 +- .../src/monitoring/extension-status.util.ts | 26 ++ .../src/monitoring/monitoring.controller.ts | 37 +++ apps/api/src/monitoring/monitoring.module.ts | 7 + .../src/telephony/pjsip-realtime.service.ts | 160 ++++++++++ apps/api/src/telephony/telephony.module.ts | 59 ++++ apps/api/src/trunks/dto/create-trunk.dto.ts | 111 +++++++ apps/api/src/trunks/dto/update-trunk.dto.ts | 8 + apps/api/src/trunks/trunks.controller.ts | 81 +++++ apps/api/src/trunks/trunks.module.ts | 9 + apps/api/src/trunks/trunks.service.ts | 279 ++++++++++++++++++ infrastructure/docker/api.Dockerfile | 3 + pnpm-lock.yaml | 6 + 21 files changed, 1221 insertions(+), 5 deletions(-) create mode 100644 apps/api/src/extensions/dto/create-extension.dto.ts create mode 100644 apps/api/src/extensions/dto/update-extension.dto.ts create mode 100644 apps/api/src/extensions/extensions.controller.ts create mode 100644 apps/api/src/extensions/extensions.module.ts create mode 100644 apps/api/src/extensions/extensions.service.ts create mode 100644 apps/api/src/monitoring/extension-status.util.ts create mode 100644 apps/api/src/monitoring/monitoring.controller.ts create mode 100644 apps/api/src/monitoring/monitoring.module.ts create mode 100644 apps/api/src/telephony/pjsip-realtime.service.ts create mode 100644 apps/api/src/telephony/telephony.module.ts create mode 100644 apps/api/src/trunks/dto/create-trunk.dto.ts create mode 100644 apps/api/src/trunks/dto/update-trunk.dto.ts create mode 100644 apps/api/src/trunks/trunks.controller.ts create mode 100644 apps/api/src/trunks/trunks.module.ts create mode 100644 apps/api/src/trunks/trunks.service.ts diff --git a/TODO.md b/TODO.md index 8993baa..4fe2bc9 100644 --- a/TODO.md +++ b/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 - [x] Schema Prisma: Trunk, Extension, ExtensionState + criptografia de segredos AES-256-GCM (packages/shared/secret-crypto.ts) -- [ ] CRUD Troncos (com CPS máximo, ACL, teste de status) -- [ ] CRUD Ramais (senha SIP gerada, reset, status tempo real) +- [x] CRUD Troncos (IP/AUTH/REGISTRATION, CPS máximo, ACL, secret cifrado + 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 (gateway WS) pendente; ExtensionState/pub-sub já prontos como base - [ ] Dialplan estruturado (versionado, modo Advanced, validação+rollback) diff --git a/apps/api/package.json b/apps/api/package.json index 5724fd1..5ce5659 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -22,6 +22,7 @@ "dependencies": { "@b2bcall/database": "workspace:*", "@b2bcall/shared": "workspace:*", + "@b2bcall/telephony": "workspace:*", "@fastify/cookie": "^11.0.2", "@fastify/helmet": "^13.0.1", "@fastify/static": "^8.0.4", @@ -29,6 +30,7 @@ "@nestjs/config": "^4.0.2", "@nestjs/core": "^11.0.1", "@nestjs/jwt": "^11.0.0", + "@nestjs/mapped-types": "^2.1.0", "@nestjs/platform-fastify": "^11.0.1", "@nestjs/swagger": "^11.2.0", "@nestjs/terminus": "^11.0.0", diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index d241f5d..ca30d6b 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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: [ diff --git a/apps/api/src/extensions/dto/create-extension.dto.ts b/apps/api/src/extensions/dto/create-extension.dto.ts new file mode 100644 index 0000000..596bc1c --- /dev/null +++ b/apps/api/src/extensions/dto/create-extension.dto.ts @@ -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; +} diff --git a/apps/api/src/extensions/dto/update-extension.dto.ts b/apps/api/src/extensions/dto/update-extension.dto.ts new file mode 100644 index 0000000..c512007 --- /dev/null +++ b/apps/api/src/extensions/dto/update-extension.dto.ts @@ -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), +) {} diff --git a/apps/api/src/extensions/extensions.controller.ts b/apps/api/src/extensions/extensions.controller.ts new file mode 100644 index 0000000..a3929fb --- /dev/null +++ b/apps/api/src/extensions/extensions.controller.ts @@ -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'], + }); + } +} diff --git a/apps/api/src/extensions/extensions.module.ts b/apps/api/src/extensions/extensions.module.ts new file mode 100644 index 0000000..090c9b3 --- /dev/null +++ b/apps/api/src/extensions/extensions.module.ts @@ -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 {} diff --git a/apps/api/src/extensions/extensions.service.ts b/apps/api/src/extensions/extensions.service.ts new file mode 100644 index 0000000..a88e81c --- /dev/null +++ b/apps/api/src/extensions/extensions.service.ts @@ -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('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, + }); + } +} diff --git a/apps/api/src/health/health.controller.ts b/apps/api/src/health/health.controller.ts index 6c7de4c..2e1adb4 100644 --- a/apps/api/src/health/health.controller.ts +++ b/apps/api/src/health/health.controller.ts @@ -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 => { + 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, + ]); } } diff --git a/apps/api/src/monitoring/extension-status.util.ts b/apps/api/src/monitoring/extension-status.util.ts new file mode 100644 index 0000000..ae1dabd --- /dev/null +++ b/apps/api/src/monitoring/extension-status.util.ts @@ -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'; +} diff --git a/apps/api/src/monitoring/monitoring.controller.ts b/apps/api/src/monitoring/monitoring.controller.ts new file mode 100644 index 0000000..0bae96e --- /dev/null +++ b/apps/api/src/monitoring/monitoring.controller.ts @@ -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, + }; + }); + } +} diff --git a/apps/api/src/monitoring/monitoring.module.ts b/apps/api/src/monitoring/monitoring.module.ts new file mode 100644 index 0000000..7a4582d --- /dev/null +++ b/apps/api/src/monitoring/monitoring.module.ts @@ -0,0 +1,7 @@ +import { Module } from '@nestjs/common'; +import { MonitoringController } from './monitoring.controller'; + +@Module({ + controllers: [MonitoringController], +}) +export class MonitoringModule {} diff --git a/apps/api/src/telephony/pjsip-realtime.service.ts b/apps/api/src/telephony/pjsip-realtime.service.ts new file mode 100644 index 0000000..674eb67 --- /dev/null +++ b/apps/api/src/telephony/pjsip-realtime.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + await this.prisma + .$executeRaw`DELETE FROM asterisk.ps_endpoint_id_ips WHERE endpoint = ${endpoint}`; + } + + async upsertRegistration(f: RegistrationFields): Promise { + 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 { + 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 { + 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[]); + } + + async getContacts( + endpoint: string, + ): Promise> { + return this.prisma.$queryRaw` + SELECT uri, via_addr FROM asterisk.ps_contacts WHERE endpoint = ${endpoint} + `; + } +} diff --git a/apps/api/src/telephony/telephony.module.ts b/apps/api/src/telephony/telephony.module.ts new file mode 100644 index 0000000..fa73a13 --- /dev/null +++ b/apps/api/src/telephony/telephony.module.ts @@ -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('ASTERISK_HOST'), + amiPort: Number(config.get('AMI_PORT', '5038')), + amiUsername: config.getOrThrow('AMI_USERNAME'), + amiSecret: config.getOrThrow('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(); + } +} diff --git a/apps/api/src/trunks/dto/create-trunk.dto.ts b/apps/api/src/trunks/dto/create-trunk.dto.ts new file mode 100644 index 0000000..358c640 --- /dev/null +++ b/apps/api/src/trunks/dto/create-trunk.dto.ts @@ -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; +} diff --git a/apps/api/src/trunks/dto/update-trunk.dto.ts b/apps/api/src/trunks/dto/update-trunk.dto.ts new file mode 100644 index 0000000..2ccc233 --- /dev/null +++ b/apps/api/src/trunks/dto/update-trunk.dto.ts @@ -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), +) {} diff --git a/apps/api/src/trunks/trunks.controller.ts b/apps/api/src/trunks/trunks.controller.ts new file mode 100644 index 0000000..e4dbb3d --- /dev/null +++ b/apps/api/src/trunks/trunks.controller.ts @@ -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'], + }); + } +} diff --git a/apps/api/src/trunks/trunks.module.ts b/apps/api/src/trunks/trunks.module.ts new file mode 100644 index 0000000..17ebd2d --- /dev/null +++ b/apps/api/src/trunks/trunks.module.ts @@ -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 {} diff --git a/apps/api/src/trunks/trunks.service.ts b/apps/api/src/trunks/trunks.service.ts new file mode 100644 index 0000000..6cc1378 --- /dev/null +++ b/apps/api/src/trunks/trunks.service.ts @@ -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('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 { + 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'; + } +} diff --git a/infrastructure/docker/api.Dockerfile b/infrastructure/docker/api.Dockerfile index 66fc2db..54434c6 100644 --- a/infrastructure/docker/api.Dockerfile +++ b/infrastructure/docker/api.Dockerfile @@ -14,14 +14,17 @@ COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./ COPY apps/api/package.json apps/api/package.json COPY packages/database/package.json packages/database/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 COPY packages/shared packages/shared +COPY packages/telephony packages/telephony COPY packages/database packages/database COPY apps/api apps/api RUN pnpm --filter @b2bcall/shared build \ + && pnpm --filter @b2bcall/telephony build \ && pnpm --filter @b2bcall/database build \ && pnpm --filter @b2bcall/api build diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 510c630..956488d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,6 +16,9 @@ importers: '@b2bcall/shared': specifier: workspace:* version: link:../../packages/shared + '@b2bcall/telephony': + specifier: workspace:* + version: link:../../packages/telephony '@fastify/cookie': specifier: ^11.0.2 version: 11.1.2 @@ -37,6 +40,9 @@ importers: '@nestjs/jwt': 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)) + '@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': 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))