feat(cdr): call detail records, relatorios e disposicoes

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
This commit is contained in:
2026-08-28 14:10:16 -03:00
parent cb6d343b2e
commit 56499f4b99
17 changed files with 1150 additions and 7 deletions

View File

@@ -11,6 +11,9 @@ import { RealtimeModule } from "./realtime/realtime.module";
import { CampaignsModule } from "./campaigns/campaigns.module";
import { LeadsModule } from "./leads/leads.module";
import { SuppressionModule } from "./suppression/suppression.module";
import { DispositionsModule } from "./dispositions/dispositions.module";
import { CallsModule } from "./calls/calls.module";
import { ReportsModule } from "./reports/reports.module";
@Module({
imports: [
@@ -26,6 +29,9 @@ import { SuppressionModule } from "./suppression/suppression.module";
CampaignsModule,
LeadsModule,
SuppressionModule,
DispositionsModule,
CallsModule,
ReportsModule,
],
})
export class AppModule {}

View File

@@ -0,0 +1,125 @@
import { Body, Controller, ForbiddenException, Get, NotFoundException, Param, Patch, Query, UseGuards } from "@nestjs/common";
import { getPrismaClient, withTenantContext, type Prisma } from "@b2bcall/database";
import { recordAuditEvent, userHasPermission, 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 { SetCallDispositionDto } from "./dto/set-call-disposition.dto";
/**
* Relatório de chamadas (agente.md secao 157) — filtros por data/ramal/
* agente/fila/campanha/trunk/telefone/hangup cause/disposition. "Platform
* admin poderá filtrar tenant" (secao 157) ainda não existe — este
* endpoint é sempre escopado ao tenant do JWT, nunca um cross-tenant vindo
* do client (mesmo princípio de sempre, secao 31); um console cross-tenant
* de verdade fica pra quando existir uma UI de plataforma.
*/
@UseGuards(JwtAuthGuard)
@Controller("calls")
export class CallsController {
@UseGuards(PermissionGuard)
@RequirePermission("reports.view")
@Get()
async list(
@CurrentUser() user: AccessTokenClaims,
@Query("from") from?: string,
@Query("to") to?: string,
@Query("extensionId") extensionId?: string,
@Query("agentId") agentId?: string,
@Query("queueId") queueId?: string,
@Query("campaignId") campaignId?: string,
@Query("trunkId") trunkId?: string,
@Query("phone") phone?: string,
@Query("hangupCause") hangupCause?: string,
@Query("dispositionId") dispositionId?: string,
) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
const where: Prisma.CallWhereInput = { tenantId };
if (from || to) {
where.createdAt = {
...(from ? { gte: new Date(from) } : {}),
...(to ? { lte: new Date(to) } : {}),
};
}
if (extensionId) where.extensionId = extensionId;
if (agentId) where.agentId = agentId;
if (queueId) where.queueId = queueId;
if (campaignId) where.campaignId = campaignId;
if (trunkId) where.trunkId = trunkId;
if (hangupCause) where.hangupCause = hangupCause;
if (dispositionId) where.dispositionId = dispositionId;
if (phone) {
where.OR = [{ callerNumber: { contains: phone } }, { calledNumber: { contains: phone } }];
}
return withTenantContext(prisma, tenantId, (tx) =>
tx.call.findMany({ where, orderBy: { createdAt: "desc" }, take: 500 }),
);
}
@UseGuards(PermissionGuard)
@RequirePermission("reports.view")
@Get(":id")
async get(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
const call = await withTenantContext(prisma, tenantId, (tx) =>
tx.call.findFirst({ where: { id, tenantId }, include: { events: { orderBy: { occurredAt: "asc" } } } }),
);
if (!call) throw new NotFoundException();
return call;
}
/** Disposição (secao 89) — o próprio agente que atendeu pode marcar a
* disposição da chamada; um supervisor (agents.manage) pode fazer isso
* em nome de outro agente. Nunca aceita um agentId vindo do client pra
* decidir a permissão — só compara contra o Agent do próprio usuário. */
@Patch(":id/disposition")
async setDisposition(
@CurrentUser() user: AccessTokenClaims,
@Param("id") id: string,
@Body() dto: SetCallDispositionDto,
) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
const [call, myAgent] = await withTenantContext(prisma, tenantId, (tx) =>
Promise.all([
tx.call.findFirst({ where: { id, tenantId } }),
tx.agent.findFirst({ where: { tenantId, userId: user.sub, deletedAt: null } }),
]),
);
if (!call) throw new NotFoundException();
const isOwnCall = myAgent && call.agentId === myAgent.id;
if (!isOwnCall) {
const isSupervisor = await userHasPermission(user.sub, "agents.manage", tenantId);
if (!isSupervisor) {
throw new ForbiddenException("Só o agente que atendeu (ou um supervisor) pode marcar a disposição");
}
}
const disposition = await withTenantContext(prisma, tenantId, (tx) =>
tx.disposition.findFirst({ where: { id: dto.dispositionId, tenantId, enabled: true } }),
);
if (!disposition) throw new NotFoundException("Disposição não encontrada");
const updated = await withTenantContext(prisma, tenantId, (tx) =>
tx.call.update({ where: { id }, data: { dispositionId: disposition.id } }),
);
await recordAuditEvent(prisma, {
action: "CALL_DISPOSITION_SET",
tenantId,
userId: user.sub,
entityType: "call",
entityId: id,
after: { disposition: disposition.name },
});
return updated;
}
}

View File

@@ -0,0 +1,7 @@
import { Module } from "@nestjs/common";
import { CallsController } from "./calls.controller";
@Module({
controllers: [CallsController],
})
export class CallsModule {}

View File

@@ -0,0 +1,6 @@
import { IsUUID } from "class-validator";
export class SetCallDispositionDto {
@IsUUID()
dispositionId!: string;
}

View File

@@ -0,0 +1,68 @@
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,
});
}
}

View File

@@ -0,0 +1,7 @@
import { Module } from "@nestjs/common";
import { DispositionsController } from "./dispositions.controller";
@Module({
controllers: [DispositionsController],
})
export class DispositionsModule {}

View File

@@ -0,0 +1,11 @@
import { IsString, MaxLength } from "class-validator";
export class CreateDispositionDto {
@IsString()
@MaxLength(80)
name!: string;
@IsString()
@MaxLength(40)
code!: string;
}

View File

@@ -0,0 +1,203 @@
import { Controller, Get, Query, UseGuards } from "@nestjs/common";
import { getPrismaClient, withTenantContext, type Prisma } from "@b2bcall/database";
import 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";
function dateRange(from?: string, to?: string): { gte?: Date; lte?: Date } {
const range: { gte?: Date; lte?: Date } = {};
if (from) range.gte = new Date(from);
if (to) range.lte = new Date(to);
return range;
}
function average(values: number[]): number | null {
if (values.length === 0) return null;
return values.reduce((sum, v) => sum + v, 0) / values.length;
}
@UseGuards(JwtAuthGuard, PermissionGuard)
@Controller("reports")
export class ReportsController {
/** Relatório de filas (secao 159): recebidas/atendidas/abandonadas/TME/
* TMA/Service Level/Abandon Rate, agrupado por Queue. Service Level usa
* um limiar configurável via query (`slThresholdSeconds`, default 20s —
* a especificação não define um valor fixo). */
@RequirePermission("reports.view")
@Get("queues")
async queues(
@CurrentUser() user: AccessTokenClaims,
@Query("from") from?: string,
@Query("to") to?: string,
@Query("slThresholdSeconds") slThresholdSecondsRaw?: string,
) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
const slThresholdSeconds = slThresholdSecondsRaw ? Number(slThresholdSecondsRaw) : 20;
const calls = await withTenantContext(prisma, tenantId, (tx) =>
tx.call.findMany({
where: { tenantId, queueId: { not: null }, createdAt: dateRange(from, to) },
select: { queueId: true, queueEnterAt: true, agentAnswerAt: true, endAt: true, waitTime: true, talkTime: true },
}),
);
const byQueue = new Map<string, typeof calls>();
for (const call of calls) {
const list = byQueue.get(call.queueId!) ?? [];
list.push(call);
byQueue.set(call.queueId!, list);
}
return Array.from(byQueue.entries()).map(([queueId, rows]) => {
const answered = rows.filter((r) => r.agentAnswerAt);
const abandoned = rows.filter((r) => !r.agentAnswerAt && r.endAt);
const waitTimes = answered.map((r) => r.waitTime).filter((v): v is number => v != null);
const talkTimes = answered.map((r) => r.talkTime).filter((v): v is number => v != null);
const withinSl = waitTimes.filter((w) => w <= slThresholdSeconds).length;
return {
queueId,
received: rows.length,
answered: answered.length,
abandoned: abandoned.length,
tme: average(waitTimes),
tma: average(talkTimes),
serviceLevel: waitTimes.length > 0 ? withinSl / waitTimes.length : null,
abandonRate: rows.length > 0 ? abandoned.length / rows.length : null,
};
});
}
/** Relatório de agentes (secao 158): tempo em cada estado (a partir de
* agent_state_events, pareando eventos consecutivos), chamadas atendidas
* e TMA (a partir de calls). */
@RequirePermission("reports.view")
@Get("agents")
async agents(@CurrentUser() user: AccessTokenClaims, @Query("from") from?: string, @Query("to") to?: string) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
const range = dateRange(from, to);
const now = new Date();
const agentsList = await withTenantContext(prisma, tenantId, (tx) =>
tx.agent.findMany({ where: { tenantId, deletedAt: null }, select: { id: true, name: true } }),
);
const [stateEvents, sessions, pauseEvents, calls] = await withTenantContext(prisma, tenantId, (tx) =>
Promise.all([
tx.agentStateEvent.findMany({
where: { tenantId, occurredAt: range },
orderBy: [{ agentId: "asc" }, { occurredAt: "asc" }],
}),
tx.agentSession.findMany({ where: { tenantId, startedAt: range } }),
tx.agentPauseEvent.findMany({ where: { tenantId, startedAt: range } }),
tx.call.findMany({
where: { tenantId, agentId: { not: null }, agentAnswerAt: { not: null }, createdAt: range },
select: { agentId: true, talkTime: true },
}),
]),
);
const secondsBetween = (a: Date, b: Date) => Math.max(0, (b.getTime() - a.getTime()) / 1000);
return agentsList.map((agent) => {
const events = stateEvents.filter((e) => e.agentId === agent.id);
const stateSeconds: Record<string, number> = {};
for (let i = 0; i < events.length; i++) {
const end = events[i + 1]?.occurredAt ?? now;
stateSeconds[events[i].state] = (stateSeconds[events[i].state] ?? 0) + secondsBetween(events[i].occurredAt, end);
}
const loggedInSeconds = sessions
.filter((s) => s.agentId === agent.id)
.reduce((sum, s) => sum + secondsBetween(s.startedAt, s.endedAt ?? now), 0);
const pausedSeconds = pauseEvents
.filter((p) => p.agentId === agent.id)
.reduce((sum, p) => sum + secondsBetween(p.startedAt, p.endedAt ?? now), 0);
const agentCalls = calls.filter((c) => c.agentId === agent.id);
const talkTimes = agentCalls.map((c) => c.talkTime).filter((v): v is number => v != null);
return {
agentId: agent.id,
name: agent.name,
loggedInSeconds,
availableSeconds: stateSeconds.AVAILABLE ?? 0,
reservedSeconds: stateSeconds.RESERVED ?? 0,
ringingSeconds: stateSeconds.RINGING ?? 0,
inCallSeconds: stateSeconds.IN_CALL ?? 0,
wrapUpSeconds: stateSeconds.WRAP_UP ?? 0,
pausedSeconds,
callsAnswered: agentCalls.length,
tma: average(talkTimes),
};
});
}
/** Relatório de campanha (secao 160). "Valor Telefonia"/"Valor IA" ficam
* null — dependem da fase Billing, ainda não existe rating de uso. */
@RequirePermission("reports.view")
@Get("campaigns")
async campaigns(@CurrentUser() user: AccessTokenClaims, @Query("campaignId") campaignId?: string) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
const where: Prisma.CampaignWhereInput = { tenantId, deletedAt: null };
if (campaignId) where.id = campaignId;
const campaignsList = await withTenantContext(prisma, tenantId, (tx) => tx.campaign.findMany({ where }));
return Promise.all(
campaignsList.map(async (campaign) => {
const [leadCount, attempts, callsForAttempts] = await withTenantContext(prisma, tenantId, (tx) =>
Promise.all([
tx.lead.count({ where: { tenantId, campaignId: campaign.id } }),
tx.callAttempt.findMany({ where: { tenantId, campaignId: campaign.id } }),
tx.call.findMany({
where: { tenantId, campaignId: campaign.id, attemptId: { not: null } },
select: { attemptId: true, waitTime: true },
}),
]),
);
const answered = attempts.filter((a) =>
["ANSWERED", "QUEUEING", "AGENT_CONNECTED", "COMPLETED", "ABANDONED"].includes(a.status),
);
const agentConnected = attempts.filter((a) => a.status === "COMPLETED" || a.status === "AGENT_CONNECTED");
const busy = attempts.filter((a) => a.status === "BUSY").length;
const noAnswer = attempts.filter((a) => a.status === "NO_ANSWER").length;
const failed = attempts.filter((a) => a.status === "FAILED").length;
const abandoned = attempts.filter((a) => a.status === "ABANDONED").length;
const callbacks = await withTenantContext(prisma, tenantId, (tx) =>
tx.lead.count({ where: { tenantId, campaignId: campaign.id, status: "CALLBACK" } }),
);
const talkTimes = attempts.map((a) => a.talkTimeSeconds).filter((v): v is number => v != null);
const waitTimes = callsForAttempts.map((c) => c.waitTime).filter((v): v is number => v != null);
return {
campaignId: campaign.id,
name: campaign.name,
leads: leadCount,
attempts: attempts.length,
answered: answered.length,
agentConnected: agentConnected.length,
busy,
noAnswer,
failed,
callbacks,
answerRate: attempts.length > 0 ? answered.length / attempts.length : null,
contactRate: leadCount > 0 ? answered.length / leadCount : null,
abandonRate: answered.length > 0 ? abandoned / answered.length : null,
tme: average(waitTimes),
tma: average(talkTimes),
telefoniaValor: null,
iaValor: null,
};
}),
);
}
}

View File

@@ -0,0 +1,7 @@
import { Module } from "@nestjs/common";
import { ReportsController } from "./reports.controller";
@Module({
controllers: [ReportsController],
})
export class ReportsModule {}