feat: Fase 9/10 — métricas, scripts operacionais, callback/wrap-up e aceite final

Fase 9 (segurança e produção):
- GET /api/metrics: endpoint Prometheus com métricas reais (chamadas,
  agentes, filas, CPS por campanha), protegido por permissão
- scripts/backup.sh, restore.sh, healthcheck.sh, install.sh, update.sh —
  testados contra o ambiente real (backup.sh e healthcheck.sh rodados de
  verdade; install.sh/update.sh validados por inspeção, ambiente atual já
  provisionado)
- POST /api/agent-console/dispose: aplica disposição de chamada de verdade
  (lacuna deixada aberta desde a Fase 6), com ações CALLBACK (agenda
  retorno) e DO_NOT_CALL (suprime automaticamente)
- apps/dialer-worker/src/callback-sweep.ts: reativa leads com callback
  vencido; GET /api/callbacks para consulta
- apps/dialer-worker/src/wrap-up-sweep.ts: transição automática
  WRAP_UP -> AVAILABLE + despausa real na fila do Asterisk. Exigiu corrigir
  main.ts para conectar ao AMI mesmo em DIALER_SIMULATION=true
  (DIALER_SIMULATION deve impedir só originação de chamada, não ações
  administrativas de fila)
- nftables revisado (sem alterações necessárias)
- Documentação completa: INSTALL, OPERATIONS, BACKUP_RESTORE, SECURITY,
  DATABASE, API, ASTERISK, OPENSIPS (não implementado, motivo
  documentado), TROUBLESHOOTING
- README.md e CHANGELOG.md reescritos

Fase 10 (testes e aceite):
- Quality gate completo executado: build/typecheck (7 workspaces), lint,
  46 testes unitários, docker compose config/ps, healthcheck — tudo verde
- Aceite de segurança (seção 92): 13 itens verificados ao vivo contra o
  sistema real, não só por inspeção de código
- Aceite Asterisk (seção 93): os 5 comandos executados e documentados,
  comunicação API->AMI->Asterisk validada
- docs/RELATORIO_FINAL.md: relatório final no formato da seção 96

Todos os fixtures de teste desta fase foram removidos/desativados ao
final. Credenciais de acesso entregues separadamente em CREDENCIAIS.txt
(fora do git, nunca versionado).
This commit is contained in:
2026-08-27 19:16:14 -03:00
parent 6273b32214
commit 80e72881b2
33 changed files with 2024 additions and 39 deletions

View File

@@ -44,6 +44,7 @@
"fastify": "^5.2.1",
"ioredis": "^5.4.2",
"ms": "^2.1.3",
"prom-client": "^15.1.3",
"nestjs-pino": "^4.4.0",
"pino-http": "^10.5.0",
"reflect-metadata": "^0.2.2",

View File

@@ -5,6 +5,7 @@ import type { AuthenticatedUser } from '../common/guards/auth.guard';
import { AgentConsoleService } from './agent-console.service';
import { AgentLoginDto } from './dto/agent-login.dto';
import { AgentPauseDto } from './dto/agent-pause.dto';
import { DisposeCallDto } from './dto/dispose-call.dto';
// Sem @RequirePermissions dedicada: qualquer usuário autenticado com um
// Agent associado pode operar sua própria tela de agente (agente.md seção
@@ -74,4 +75,16 @@ export class AgentConsoleController {
userAgent: request.headers['user-agent'],
});
}
@Post('dispose')
dispose(
@Body() dto: DisposeCallDto,
@CurrentUser() user: AuthenticatedUser,
@Req() request: FastifyRequest,
) {
return this.agentConsoleService.dispose(user.id, dto, {
ip: request.ip,
userAgent: request.headers['user-agent'],
});
}
}

View File

@@ -1,8 +1,10 @@
import { Module } from '@nestjs/common';
import { SuppressionModule } from '../suppression/suppression.module';
import { AgentConsoleController } from './agent-console.controller';
import { AgentConsoleService } from './agent-console.service';
@Module({
imports: [SuppressionModule],
controllers: [AgentConsoleController],
providers: [AgentConsoleService],
})

View File

@@ -3,15 +3,18 @@ import {
ForbiddenException,
Inject,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { AgentState } from '@b2bcall/database';
import { AgentState, DispositionAction } from '@b2bcall/database';
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 { SuppressionService } from '../suppression/suppression.service';
import { AgentLoginDto } from './dto/agent-login.dto';
import { AgentPauseDto } from './dto/agent-pause.dto';
import { DisposeCallDto } from './dto/dispose-call.dto';
function interfaceFor(extension: string): string {
return `PJSIP/${extension}`;
@@ -22,6 +25,7 @@ export class AgentConsoleService {
constructor(
private readonly prisma: PrismaService,
private readonly audit: AuditService,
private readonly suppression: SuppressionService,
@Inject(TELEPHONY_PROVIDER) private readonly telephony: TelephonyProvider,
) {}
@@ -259,4 +263,115 @@ export class AgentConsoleService {
return { ok: true };
}
// Disposição da chamada (agente.md seção 40). Uma disposição pode
// disparar uma ação: CALLBACK cria um agendamento (seção 41), DO_NOT_CALL
// adiciona o lead à lista de supressão automaticamente. Também abre o
// estado WRAP_UP do agente (seção 39) — a transição automática de volta
// para AVAILABLE é feita pela varredura periódica do dialer-worker
// (wrapUpSeconds da fila), nunca por um timer em memória (não sobrevive a
// restart).
async dispose(userId: string, dto: DisposeCallDto, ctx: RequestContext) {
const agent = await this.getAgentForUserOrThrow(userId);
const attempt = await this.prisma.dialAttempt.findUnique({
where: { id: dto.dialAttemptId },
include: { lead: true, campaign: true },
});
if (!attempt)
throw new NotFoundException('Tentativa de chamada não encontrada.');
if (attempt.agentId !== agent.id) {
throw new ForbiddenException('Esta chamada não pertence a este agente.');
}
if (attempt.dispositionId) {
throw new BadRequestException('Esta chamada já possui uma disposição.');
}
const disposition = await this.prisma.callDisposition.findUnique({
where: { id: dto.dispositionId },
});
if (!disposition || !disposition.active)
throw new BadRequestException('Disposição inválida.');
if (disposition.action === DispositionAction.CALLBACK && !dto.callbackAt) {
throw new BadRequestException(
'Esta disposição exige data/hora de retorno (callbackAt).',
);
}
await this.prisma.$transaction(async (tx) => {
await tx.dialAttempt.update({
where: { id: attempt.id },
data: { dispositionId: disposition.id, dispositionNotes: dto.notes },
});
if (disposition.action === DispositionAction.CALLBACK) {
await tx.callback.create({
data: {
leadId: attempt.leadId,
campaignId: attempt.campaignId,
preferredAgentId: dto.preferSameAgent ? agent.id : undefined,
scheduledAt: new Date(dto.callbackAt!),
notes: dto.notes,
},
});
await tx.lead.update({
where: { id: attempt.leadId },
data: { status: 'CALLBACK' },
});
} else if (disposition.action === DispositionAction.DO_NOT_CALL) {
await tx.lead.update({
where: { id: attempt.leadId },
data: { status: 'DO_NOT_CALL' },
});
}
});
if (disposition.action === DispositionAction.DO_NOT_CALL) {
// Fora da transação: SuppressionService já audita e normaliza por si,
// reaproveitado em vez de duplicar a lógica de normalização de telefone.
await this.suppression.add(
{
phone: attempt.lead.phone,
reason: `Disposição: ${disposition.name}`,
},
{ id: userId },
ctx,
);
}
await this.transition(agent.id, AgentState.WRAP_UP);
// Pausa o agente nas filas durante o wrap-up — sem isso o Asterisk
// poderia rotear uma nova chamada para ele antes de terminar o
// pós-atendimento (agente.md seção 39). Revertido pela varredura
// periódica do dialer-worker quando o wrap-up expira.
if (agent.currentExtension) {
const iface = `PJSIP/${agent.currentExtension}`;
for (const membership of agent.queues) {
try {
await this.telephony.queuePause({
interface: iface,
queue: membership.queue.name,
paused: true,
reason: 'wrap-up',
});
} catch {
// Inofensivo se já estiver pausado/não for membro.
}
}
}
await this.audit.log({
userId,
action: 'call_disposed',
entityType: 'dial_attempt',
entityId: attempt.id,
after: { dispositionId: disposition.id, action: disposition.action },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
});
return this.me(userId);
}
}

View File

@@ -0,0 +1,31 @@
import {
IsBoolean,
IsISO8601,
IsOptional,
IsString,
IsUUID,
MaxLength,
} from 'class-validator';
export class DisposeCallDto {
@IsUUID('4')
dialAttemptId!: string;
@IsUUID('4')
dispositionId!: string;
@IsOptional()
@IsString()
@MaxLength(2000)
notes?: string;
// Obrigatório apenas quando a disposição tiver action=CALLBACK (validado
// no service, que é quem conhece a disposição real).
@IsOptional()
@IsISO8601()
callbackAt?: string;
@IsOptional()
@IsBoolean()
preferSameAgent?: boolean;
}

View File

@@ -30,6 +30,8 @@ import { LeadsModule } from './leads/leads.module';
import { ReportsModule } from './reports/reports.module';
import { DashboardModule } from './dashboard/dashboard.module';
import { ComplianceModule } from './compliance/compliance.module';
import { MetricsModule } from './metrics/metrics.module';
import { CallbacksModule } from './callbacks/callbacks.module';
import { AuthGuard } from './common/guards/auth.guard';
import { PermissionsGuard } from './common/guards/permissions.guard';
import { GlobalExceptionFilter } from './common/filters/global-exception.filter';
@@ -73,6 +75,8 @@ import { GlobalExceptionFilter } from './common/filters/global-exception.filter'
TrunksModule,
ExtensionsModule,
MonitoringModule,
MetricsModule,
CallbacksModule,
DialplanModule,
AsteriskAdminModule,
PauseReasonsModule,

View File

@@ -0,0 +1,33 @@
import { Controller, Get, Query } from '@nestjs/common';
import { IsOptional, IsUUID } from 'class-validator';
import { RequirePermissions } from '../common/decorators/permissions.decorator';
import { PrismaService } from '../prisma/prisma.service';
class QueryCallbacksDto {
@IsOptional()
@IsUUID('4')
campaignId?: string;
}
// Consulta de callbacks agendados (agente.md seção 41/52). O agendamento em
// si acontece via POST /api/agent-console/dispose (disposição com
// action=CALLBACK); a movimentação automática do lead de volta para
// discagem no horário certo é feita pela varredura periódica do
// dialer-worker (callback-sweep.ts).
@Controller('callbacks')
export class CallbacksController {
constructor(private readonly prisma: PrismaService) {}
@Get()
@RequirePermissions('campaigns.view')
list(@Query() query: QueryCallbacksDto) {
return this.prisma.callback.findMany({
where: { campaignId: query.campaignId, completed: false },
orderBy: { scheduledAt: 'asc' },
include: {
lead: { select: { name: true, phone: true } },
campaign: { select: { name: true } },
},
});
}
}

View File

@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { CallbacksController } from './callbacks.controller';
@Module({
controllers: [CallbacksController],
})
export class CallbacksModule {}

View File

@@ -0,0 +1,146 @@
import { Controller, Get, Header, Inject } from '@nestjs/common';
import { Registry, Gauge } from 'prom-client';
import type { TelephonyProvider } from '@b2bcall/telephony';
import { RequirePermissions } from '../common/decorators/permissions.decorator';
import { PrismaService } from '../prisma/prisma.service';
import { TELEPHONY_PROVIDER } from '../telephony/telephony.module';
function startOfToday(): Date {
const d = new Date();
d.setHours(0, 0, 0, 0);
return d;
}
// Endpoint Prometheus (agente.md seção 89). Protegido como qualquer outra
// rota operacional (monitoring.view) — não é proxiado publicamente pelo
// Nginx (ver infrastructure/nginx/nginx.conf, só /api/ é exposto e exige
// sessão válida via o AuthGuard global). Todos os valores vêm de consultas
// reais ao Postgres/Redis/AMI no momento do scrape — nunca contadores
// acumulados em memória que poderiam dessincronizar após um restart
// (agente.md seção 72: nunca dado fake).
@Controller('metrics')
export class MetricsController {
private readonly registry = new Registry();
private readonly callsTotal = new Gauge({
name: 'b2bcall_calls_total',
help: 'Total de tentativas de discagem hoje',
registers: [this.registry],
});
private readonly callsAnsweredTotal = new Gauge({
name: 'b2bcall_calls_answered_total',
help: 'Total de chamadas completadas hoje',
registers: [this.registry],
});
private readonly callsAbandonedTotal = new Gauge({
name: 'b2bcall_calls_abandoned_total',
help: 'Total de chamadas abandonadas hoje (hangup_cause=ABANDONED)',
registers: [this.registry],
});
private readonly campaignCps = new Gauge({
name: 'b2bcall_campaign_cps',
help: 'CPS máximo configurado por campanha em execução',
labelNames: ['campaign'],
registers: [this.registry],
});
private readonly agentsAvailable = new Gauge({
name: 'b2bcall_agents_available',
help: 'Agentes disponíveis agora',
registers: [this.registry],
});
private readonly agentsBusy = new Gauge({
name: 'b2bcall_agents_busy',
help: 'Agentes em chamada agora',
registers: [this.registry],
});
private readonly agentsPaused = new Gauge({
name: 'b2bcall_agents_paused',
help: 'Agentes pausados agora',
registers: [this.registry],
});
private readonly queueWaiting = new Gauge({
name: 'b2bcall_queue_waiting',
help: 'Chamadas aguardando por fila',
labelNames: ['queue'],
registers: [this.registry],
});
private readonly dialerActiveCalls = new Gauge({
name: 'b2bcall_dialer_active_calls',
help: 'Chamadas ativas do discador (discando/tocando/na fila/com agente)',
registers: [this.registry],
});
constructor(
private readonly prisma: PrismaService,
@Inject(TELEPHONY_PROVIDER) private readonly telephony: TelephonyProvider,
) {}
@Get()
@RequirePermissions('monitoring.view')
@Header('Content-Type', 'text/plain; version=0.0.4')
async metrics(): Promise<string> {
const since = startOfToday();
const [
callsToday,
answeredToday,
abandonedToday,
activeCalls,
runningCampaigns,
openAgentStates,
] = await Promise.all([
this.prisma.dialAttempt.count({ where: { startedAt: { gte: since } } }),
this.prisma.dialAttempt.count({
where: { startedAt: { gte: since }, state: 'COMPLETED' },
}),
this.prisma.dialAttempt.count({
where: { startedAt: { gte: since }, hangupCause: 'ABANDONED' },
}),
this.prisma.dialAttempt.count({
where: {
state: {
in: ['ORIGINATING', 'RINGING', 'QUEUED', 'AGENT_CONNECTED'],
},
},
}),
this.prisma.campaign.findMany({
where: { status: 'RUNNING' },
select: { name: true, maxCps: true },
}),
this.prisma.agentStateEvent.findMany({
where: { endedAt: null },
select: { state: true },
}),
]);
this.callsTotal.set(callsToday);
this.callsAnsweredTotal.set(answeredToday);
this.callsAbandonedTotal.set(abandonedToday);
this.dialerActiveCalls.set(activeCalls);
this.campaignCps.reset();
for (const c of runningCampaigns)
this.campaignCps.set({ campaign: c.name }, c.maxCps);
this.agentsAvailable.set(
openAgentStates.filter((s) => s.state === 'AVAILABLE').length,
);
this.agentsBusy.set(
openAgentStates.filter(
(s) => s.state === 'IN_CALL' || s.state === 'RINGING',
).length,
);
this.agentsPaused.set(
openAgentStates.filter((s) => s.state === 'PAUSED').length,
);
this.queueWaiting.reset();
if (this.telephony.isConnected()) {
const liveQueues = await this.telephony.queueStatus();
for (const q of liveQueues)
this.queueWaiting.set({ queue: q.queue }, q.entries.length);
}
return this.registry.metrics();
}
}

View File

@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { MetricsController } from './metrics.controller';
@Module({
controllers: [MetricsController],
})
export class MetricsModule {}