Fecha agente.md secao 152-160. Registro duravel de chamadas — ate aqui o estado de uma chamada so' vivia transitoriamente no canal Redis b2bcall:events (pub/sub sem historico). ## Modelo calls/call_legs/call_events (secao 153, tenant-scoped, RLS) + dispositions (secao 89, "Call Center -> Disposicoes", personalizavel por tenant, mesmo padrao de PauseReason). dial_attempts da especificacao nao virou tabela nova — CallAttempt (fase Predictive Engine) ja cobre esse conceito; Call.attemptId liga um Call a' sua tentativa de discagem. Call.id = o proprio freeswitch_uuid da perna principal (sem suporte a transferencia entre uuids nesta fase). ## apps/freeswitch-events/src/cdr.ts Cada NormalizedEvent relevante faz upsert em Call + insere em call_events (a trilha bruta). CALL_ENDED calcula os agregados em segundos (secao 155-156): ringTime/waitTime/talkTime/durationSeconds/billableSeconds. ## Dois bugs reais achados e corrigidos testando esta fase - AGENT_OFFERED_CALL/AGENT_BRIDGE_FAILED disparam de uma thread interna do mod_callcenter (outbound_agent_thread_run), sem contexto de channel — nao tem header Unique-ID, entao callUuid ficava undefined e os dois eram descartados silenciosamente (Call.queueId/agentId nunca preenchidos mesmo com bridge/falha de bridge reais). Corrigido com fallback pro CC-Member-Session-UUID (data.memberSessionUuid), mesmo identificador ja usado pra correlacao equivalente no predictive-dialer. - Corrida entre CALL_CREATED/CALL_ANSWERED (persistCallEvent roda sem await, cada evento abre sua propria transacao) podia fazer answerAt aparecer antes de createdAt quando o upsert que criava a linha usava now() do momento errado (nao do occurredAt do evento real). Corrigido setando createdAt explicito a partir de normalized.occurredAt. ## Relatorios (apps/api/src/reports) GET /reports/queues (secao 159): recebidas/atendidas/abandonadas/TME/TMA/ Service Level/Abandon Rate por fila. GET /reports/agents (secao 158): tempo logado/pausado/por estado (AgentStateEvent pareado) + chamadas atendidas/TMA. GET /reports/campaigns (secao 160): leads/attempts/ answered/agent connected/busy/no answer/failed/callbacks/rates/TME/TMA — "Valor Telefonia"/"Valor IA" ficam null (dependem de Billing, fase propria). ## GET /calls e disposicao Secao 157: filtros por data/ramal/agente/fila/campanha/trunk/telefone/ hangup cause/disposicao, sempre escopado ao tenant do JWT. PATCH /calls/:id/disposition (secao 89): o proprio agente que atendeu marca (compara Call.agentId contra o Agent do usuario autenticado, nunca um agentId vindo do client), supervisor (agents.manage) pode marcar em nome de outro agente. Verificado ponta a ponta: campanha com 5 leads, 3 ANSWERED simulados entrando na fila real, Call.queueId/agentId/hangupCause corretos (confirmando a correcao da correlacao), createdAt<=answerAt em todos, durationSeconds batendo com discard_abandoned_after; os 3 relatorios com numeros internamente consistentes entre si e com os logs do discador (received:3/abandoned:3/abandonRate:1, leads:5/attempts:5/answered:3/ answerRate:0.6); disposicao gravada com ownership check correto; queue list do FreeSWITCH confirmou calls_abandoned=4 real ao final. typecheck do workspace inteiro limpo. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X1HxY46WGU4G1zmVDNKcWw
69 lines
2.5 KiB
TypeScript
69 lines
2.5 KiB
TypeScript
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, NotFoundException, Param, Post, UseGuards } from "@nestjs/common";
|
|
import { getPrismaClient, withTenantContext } from "@b2bcall/database";
|
|
import { recordAuditEvent, type AccessTokenClaims } from "@b2bcall/auth";
|
|
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
|
|
import { PermissionGuard } from "../common/guards/permission.guard";
|
|
import { RequirePermission } from "../common/decorators/require-permission.decorator";
|
|
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
|
import { CreateDispositionDto } from "./dto/create-disposition.dto";
|
|
|
|
// "Call Center -> Disposições" (agente.md secao 89) — personalizável por
|
|
// tenant, sem lista fixa hardcoded. Sem permission dedicada na secao 145;
|
|
// reusa agents.* (mesmo criterio de PauseReasonsController).
|
|
@UseGuards(JwtAuthGuard, PermissionGuard)
|
|
@Controller("dispositions")
|
|
export class DispositionsController {
|
|
@RequirePermission("agents.manage")
|
|
@Post()
|
|
async create(@CurrentUser() user: AccessTokenClaims, @Body() dto: CreateDispositionDto) {
|
|
const prisma = getPrismaClient();
|
|
const tenantId = user.tenantId!;
|
|
|
|
const disposition = await withTenantContext(prisma, tenantId, (tx) =>
|
|
tx.disposition.create({ data: { tenantId, name: dto.name, code: dto.code } }),
|
|
);
|
|
|
|
await recordAuditEvent(prisma, {
|
|
action: "DISPOSITION_CREATE",
|
|
tenantId,
|
|
userId: user.sub,
|
|
entityType: "disposition",
|
|
entityId: disposition.id,
|
|
after: { name: disposition.name, code: disposition.code },
|
|
});
|
|
|
|
return disposition;
|
|
}
|
|
|
|
@RequirePermission("agents.view")
|
|
@Get()
|
|
async list(@CurrentUser() user: AccessTokenClaims) {
|
|
const prisma = getPrismaClient();
|
|
const tenantId = user.tenantId!;
|
|
return withTenantContext(prisma, tenantId, (tx) =>
|
|
tx.disposition.findMany({ where: { enabled: true }, orderBy: { name: "asc" } }),
|
|
);
|
|
}
|
|
|
|
@RequirePermission("agents.manage")
|
|
@Delete(":id")
|
|
@HttpCode(HttpStatus.NO_CONTENT)
|
|
async remove(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string) {
|
|
const prisma = getPrismaClient();
|
|
const tenantId = user.tenantId!;
|
|
|
|
const result = await withTenantContext(prisma, tenantId, (tx) =>
|
|
tx.disposition.updateMany({ where: { id, tenantId }, data: { enabled: false } }),
|
|
);
|
|
if (result.count === 0) throw new NotFoundException();
|
|
|
|
await recordAuditEvent(prisma, {
|
|
action: "DISPOSITION_DELETE",
|
|
tenantId,
|
|
userId: user.sub,
|
|
entityType: "disposition",
|
|
entityId: id,
|
|
});
|
|
}
|
|
}
|