From 56499f4b999e5186968bd283f09c5e10d82715bc Mon Sep 17 00:00:00 2001 From: Matheus Date: Fri, 28 Aug 2026 14:10:16 -0300 Subject: [PATCH] feat(cdr): call detail records, relatorios e disposicoes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01X1HxY46WGU4G1zmVDNKcWw --- TODO.md | 54 ++++- apps/api/src/app.module.ts | 6 + apps/api/src/calls/calls.controller.ts | 125 +++++++++++ apps/api/src/calls/calls.module.ts | 7 + .../src/calls/dto/set-call-disposition.dto.ts | 6 + .../dispositions/dispositions.controller.ts | 68 ++++++ .../src/dispositions/dispositions.module.ts | 7 + .../dto/create-disposition.dto.ts | 11 + apps/api/src/reports/reports.controller.ts | 203 ++++++++++++++++++ apps/api/src/reports/reports.module.ts | 7 + apps/freeswitch-events/src/cdr.ts | 172 +++++++++++++++ apps/freeswitch-events/src/main.ts | 5 + docs/CAMPAIGNS.md | 2 +- docs/CDR.md | 152 +++++++++++++ docs/PREDICTIVE_DIALER.md | 5 +- .../20260828164734_cdr/migration.sql | 166 ++++++++++++++ packages/database/prisma/schema.prisma | 161 ++++++++++++++ 17 files changed, 1150 insertions(+), 7 deletions(-) create mode 100644 apps/api/src/calls/calls.controller.ts create mode 100644 apps/api/src/calls/calls.module.ts create mode 100644 apps/api/src/calls/dto/set-call-disposition.dto.ts create mode 100644 apps/api/src/dispositions/dispositions.controller.ts create mode 100644 apps/api/src/dispositions/dispositions.module.ts create mode 100644 apps/api/src/dispositions/dto/create-disposition.dto.ts create mode 100644 apps/api/src/reports/reports.controller.ts create mode 100644 apps/api/src/reports/reports.module.ts create mode 100644 apps/freeswitch-events/src/cdr.ts create mode 100644 docs/CDR.md create mode 100644 packages/database/prisma/migrations/20260828164734_cdr/migration.sql diff --git a/TODO.md b/TODO.md index c8dc297..748f8f9 100644 --- a/TODO.md +++ b/TODO.md @@ -376,10 +376,58 @@ agente (secao 89) — fora do escopo, ficam pra fase CDR - [ ] CPS a nível de trunk — só aplica quando o caminho PSTN real rodar - [ ] Relatório de campanha (secao 160), TME/TMA/Service Level/Abandon - Rate agregados — dependem de CDR + Rate agregados — implementado na PHASE 17 -## PHASE 17+ — ver `agente.md` seções 87 em diante (CDR, Recordings, AI, -Billing, Frontend, Reports, Security, Tests) +## PHASE 17 — CDR (agente.md secao 152-160) +- [x] `calls`/`call_legs`/`call_events` (tenant-scoped, RLS) + + `dispositions` (secao 89). `dial_attempts` da especificação não + virou tabela nova — `CallAttempt` (fase Predictive Engine) já cobre + o conceito; `Call.attemptId` liga um Call à sua tentativa +- [x] `apps/freeswitch-events/src/cdr.ts`: cada NormalizedEvent relevante + faz upsert em Call + insere em call_events; CALL_ENDED calcula os + agregados em segundos (secao 155-156: ringTime/waitTime/talkTime/ + durationSeconds/billableSeconds) +- [x] **Bug real, achado no teste desta fase**: `AGENT_OFFERED_CALL`/ + `AGENT_BRIDGE_FAILED` disparam de uma thread interna do + mod_callcenter sem contexto de channel — não têm header Unique-ID, + então `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). +- [x] **Bug real, achado no teste desta fase**: corrida entre + CALL_CREATED/CALL_ANSWERED (persistCallEvent roda sem await, cada + evento abre sua própria transação) podia fazer `answerAt` aparecer + antes de `createdAt` quando o upsert que criava a linha usava + `now()` do momento errado. Corrigido setando `createdAt` explicito + a partir de `normalized.occurredAt` no branch de criação. +- [x] `GET /reports/queues` (secao 159): recebidas/atendidas/abandonadas/ + TME/TMA/Service Level/Abandon Rate por fila +- [x] `GET /reports/agents` (secao 158): tempo logado/pausado/por estado + (AgentStateEvent pareado) + chamadas atendidas/TMA +- [x] `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) +- [x] `GET /calls` (secao 157, filtros por data/ramal/agente/fila/ + campanha/trunk/telefone/hangup cause/disposição) + `PATCH /calls/ + :id/disposition` (secao 89, o próprio agente que atendeu marca, ou + supervisor com agents.manage) +- [x] Testado ponta a ponta: campanha com 5 leads, 3 ANSWERED simulados + entrando na fila real, Call.queueId/agentId/hangupCause corretos + (confirmando a correção da correlação), createdAt<=answerAt em + todos, durationSeconds batendo com discard_abandoned_after; os 3 + relatórios com números internamente consistentes entre si e com os + logs do discador; disposição gravada com ownership check correto +- [ ] extensionId/sipCallId/callerNumber/calledNumber — não populados + ainda (nenhum evento atual carrega esses dados de forma confiável) +- [ ] direction: só distingue OUTBOUND (tem campanha) de INTERNAL — + detecção de INBOUND de verdade não implementada +- [ ] call_legs — schema existe, nada escreve ainda (só faz sentido com + transferência entre uuids) +- [ ] Cross-tenant reports pra platform admin (secao 157) — sem console + de plataforma ainda + +## PHASE 18+ — ver `agente.md` seções 90 em diante (Recordings, AI, +Billing, Frontend, Security, Tests) --- diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index d75d5f3..943b91c 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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 {} diff --git a/apps/api/src/calls/calls.controller.ts b/apps/api/src/calls/calls.controller.ts new file mode 100644 index 0000000..69c7dc4 --- /dev/null +++ b/apps/api/src/calls/calls.controller.ts @@ -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; + } +} diff --git a/apps/api/src/calls/calls.module.ts b/apps/api/src/calls/calls.module.ts new file mode 100644 index 0000000..4c58189 --- /dev/null +++ b/apps/api/src/calls/calls.module.ts @@ -0,0 +1,7 @@ +import { Module } from "@nestjs/common"; +import { CallsController } from "./calls.controller"; + +@Module({ + controllers: [CallsController], +}) +export class CallsModule {} diff --git a/apps/api/src/calls/dto/set-call-disposition.dto.ts b/apps/api/src/calls/dto/set-call-disposition.dto.ts new file mode 100644 index 0000000..883e034 --- /dev/null +++ b/apps/api/src/calls/dto/set-call-disposition.dto.ts @@ -0,0 +1,6 @@ +import { IsUUID } from "class-validator"; + +export class SetCallDispositionDto { + @IsUUID() + dispositionId!: string; +} diff --git a/apps/api/src/dispositions/dispositions.controller.ts b/apps/api/src/dispositions/dispositions.controller.ts new file mode 100644 index 0000000..98df5b4 --- /dev/null +++ b/apps/api/src/dispositions/dispositions.controller.ts @@ -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, + }); + } +} diff --git a/apps/api/src/dispositions/dispositions.module.ts b/apps/api/src/dispositions/dispositions.module.ts new file mode 100644 index 0000000..e077cd5 --- /dev/null +++ b/apps/api/src/dispositions/dispositions.module.ts @@ -0,0 +1,7 @@ +import { Module } from "@nestjs/common"; +import { DispositionsController } from "./dispositions.controller"; + +@Module({ + controllers: [DispositionsController], +}) +export class DispositionsModule {} diff --git a/apps/api/src/dispositions/dto/create-disposition.dto.ts b/apps/api/src/dispositions/dto/create-disposition.dto.ts new file mode 100644 index 0000000..4da384a --- /dev/null +++ b/apps/api/src/dispositions/dto/create-disposition.dto.ts @@ -0,0 +1,11 @@ +import { IsString, MaxLength } from "class-validator"; + +export class CreateDispositionDto { + @IsString() + @MaxLength(80) + name!: string; + + @IsString() + @MaxLength(40) + code!: string; +} diff --git a/apps/api/src/reports/reports.controller.ts b/apps/api/src/reports/reports.controller.ts new file mode 100644 index 0000000..c7bc5d7 --- /dev/null +++ b/apps/api/src/reports/reports.controller.ts @@ -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(); + 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 = {}; + 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, + }; + }), + ); + } +} diff --git a/apps/api/src/reports/reports.module.ts b/apps/api/src/reports/reports.module.ts new file mode 100644 index 0000000..06ad5c8 --- /dev/null +++ b/apps/api/src/reports/reports.module.ts @@ -0,0 +1,7 @@ +import { Module } from "@nestjs/common"; +import { ReportsController } from "./reports.controller"; + +@Module({ + controllers: [ReportsController], +}) +export class ReportsModule {} diff --git a/apps/freeswitch-events/src/cdr.ts b/apps/freeswitch-events/src/cdr.ts new file mode 100644 index 0000000..f912672 --- /dev/null +++ b/apps/freeswitch-events/src/cdr.ts @@ -0,0 +1,172 @@ +import { getPrismaClient, withTenantContext, type Prisma } from "@b2bcall/database"; +import type { NormalizedEvent } from "@b2bcall/telephony"; +import { createLogger } from "@b2bcall/shared"; + +const logger = createLogger("b2bcall-fs-events"); + +function extractId(fsName: string | undefined): string | undefined { + return fsName?.split("@")[0]; +} + +function toDate(epochSeconds: unknown): Date | undefined { + const n = typeof epochSeconds === "string" ? Number(epochSeconds) : undefined; + return n && !Number.isNaN(n) ? new Date(n * 1000) : undefined; +} + +function seconds(a: Date | null | undefined, b: Date | null | undefined): number | undefined { + if (!a || !b) return undefined; + return Math.max(0, Math.round((b.getTime() - a.getTime()) / 1000)); +} + +/** Eventos sem relação 1:1 com uma chamada específica (estado de gateway, + * registro de ramal, snapshot de fila) não geram/atualizam `Call`. */ +const CALL_RELATED_TYPES = new Set([ + "CALL_CREATED", + "CALL_RINGING", + "CALL_ANSWERED", + "CALL_BRIDGED", + "CALL_UNBRIDGED", + "CALL_ENDED", + "AGENT_OFFERED_CALL", + "AGENT_BRIDGE_FAILED", + "QUEUE_MEMBER_LEFT", +]); + +/** + * Persiste o rastro de uma chamada em `calls`/`call_legs`/`call_events` + * (agente.md secao 152-154) — o canal Redis `b2bcall:events` é efêmero + * (pub/sub sem histórico), isso aqui é o registro durável por trás dos + * relatórios (secao 157-160). `Call.id` é o próprio `freeswitch_uuid` (sem + * suporte a transferência entre uuids nesta fase — ver docs/CDR.md). + * + * Só roda pra eventos com `tenantId` já resolvido (direto via channel + * variable, ou pelo fan-out de tenant-resolve.ts) — sem tenant não dá pra + * saber em qual RLS context escrever. + */ +export async function persistCallEvent(normalized: NormalizedEvent): Promise { + if (!normalized.tenantId || !CALL_RELATED_TYPES.has(normalized.type)) { + return; + } + + // Achado real: agent-offering/bridge-agent-fail disparam de uma thread + // interna do mod_callcenter (outbound_agent_thread_run), sem contexto de + // channel — não têm header Unique-ID, então `normalized.callUuid` fica + // undefined pra esses dois tipos (diferente de member-queue-end, que + // dispara no channel do member e tem Unique-ID normalmente). O + // `CC-Member-Session-UUID` (`data.memberSessionUuid`) é o mesmo uuid do + // channel member em todos os casos — fallback confiável. + const callId = normalized.callUuid ?? (normalized.data.memberSessionUuid as string | undefined); + if (!callId) return; + + const prisma = getPrismaClient(); + const tenantId = normalized.tenantId; + + try { + await withTenantContext(prisma, tenantId, async (tx) => { + const patch = buildPatch(normalized); + + await tx.call.upsert({ + where: { id: callId }, + create: { + id: callId, + tenantId, + freeswitchUuid: callId, + attemptId: normalized.b2bcallAttemptId, + campaignId: normalized.b2bcallCampaignId, + leadId: normalized.b2bcallLeadId, + direction: normalized.b2bcallCampaignId ? "OUTBOUND" : "INTERNAL", + createdAt: new Date(normalized.occurredAt), + ...patch, + }, + update: patch, + }); + + await tx.callEvent.create({ + data: { + tenantId, + callId, + type: normalized.type, + occurredAt: new Date(normalized.occurredAt), + data: normalized.data as Prisma.InputJsonValue, + }, + }); + + if (normalized.type === "CALL_ENDED") { + await finalizeCall(tx, callId); + } + }); + } catch (err) { + logger.error("falha ao persistir evento de chamada", { + error: String(err), + type: normalized.type, + callId, + }); + } +} + +type CallPatch = Partial<{ + progressAt: Date; + answerAt: Date; + bridgeAt: Date; + agentAnswerAt: Date; + queueEnterAt: Date; + queueId: string; + agentId: string; + endAt: Date; + hangupCause: string; +}>; + +function buildPatch(normalized: NormalizedEvent): CallPatch { + switch (normalized.type) { + case "CALL_RINGING": + return { progressAt: new Date(normalized.occurredAt) }; + + case "CALL_ANSWERED": + return { answerAt: new Date(normalized.occurredAt) }; + + case "CALL_BRIDGED": + // Simplificação: quando a chamada tem fila associada, o primeiro + // bridge É o agente atendendo — não distinguimos bridge pra IVR/ + // AVMD de bridge pro agente nesta fase (ver docs/CDR.md). + return { bridgeAt: new Date(normalized.occurredAt), agentAnswerAt: new Date(normalized.occurredAt) }; + + case "AGENT_OFFERED_CALL": { + const queueId = extractId(normalized.data.queue as string | undefined); + const agentId = extractId(normalized.data.agent as string | undefined); + return { queueId, agentId }; + } + + case "AGENT_BRIDGE_FAILED": + return { hangupCause: normalized.data.hangupCause as string | undefined }; + + case "QUEUE_MEMBER_LEFT": { + const joinedAt = toDate(normalized.data.joinedAt); + const queueId = extractId(normalized.data.queue as string | undefined); + return { queueEnterAt: joinedAt, queueId }; + } + + case "CALL_ENDED": + return { endAt: new Date(normalized.occurredAt), hangupCause: normalized.data.hangupCause as string | undefined }; + + default: + return {}; + } +} + +/** Calcula os agregados em segundos (secao 155-156) uma vez que a chamada + * terminou — nunca antes, pra não gravar valores parciais. */ +async function finalizeCall(tx: Prisma.TransactionClient, callId: string): Promise { + const call = await tx.call.findUniqueOrThrow({ where: { id: callId } }); + const talkTime = seconds(call.bridgeAt, call.endAt); + + await tx.call.update({ + where: { id: callId }, + data: { + ringTime: seconds(call.createdAt, call.answerAt), + waitTime: seconds(call.queueEnterAt, call.agentAnswerAt), + talkTime, + durationSeconds: seconds(call.createdAt, call.endAt), + billableSeconds: talkTime ?? 0, + }, + }); +} diff --git a/apps/freeswitch-events/src/main.ts b/apps/freeswitch-events/src/main.ts index 8bddf38..6c25fac 100644 --- a/apps/freeswitch-events/src/main.ts +++ b/apps/freeswitch-events/src/main.ts @@ -4,6 +4,7 @@ import { FreeSwitchTelephonyProvider, normalizeEslEvent } from "@b2bcall/telepho import { createLogger } from "@b2bcall/shared"; import { updateTrunkStatusFromGatewayEvent } from "./trunk-status"; import { resolveTenantIdForAgent, resolveTenantIdForQueue } from "./tenant-resolve"; +import { persistCallEvent } from "./cdr"; const logger = createLogger("b2bcall-fs-events"); @@ -138,6 +139,10 @@ async function main() { logger.error("falha ao publicar evento normalizado no Redis", { error: String(err) }); }); + persistCallEvent(normalized).catch((err) => { + logger.error("falha ao persistir CDR", { error: String(err), type: normalized.type }); + }); + logger.info(`evento: ${normalized.type}`, { callUuid: normalized.callUuid, tenantId: normalized.tenantId, diff --git a/docs/CAMPAIGNS.md b/docs/CAMPAIGNS.md index bb15cd2..3b8d27e 100644 --- a/docs/CAMPAIGNS.md +++ b/docs/CAMPAIGNS.md @@ -111,7 +111,7 @@ delete numa campanha RUNNING -> 400 "Pare a campanha antes de apaga-la" Predictive Engine (ver docs/PREDICTIVE_DIALER.md). - Wizard visual de importação (upload de arquivo de verdade) — fase Frontend. -- Relatório de campanha (secao 160) — depende de CDR. +- ~~Relatório de campanha~~ — implementado na fase CDR (ver docs/CDR.md). - Quota de leads por campanha — não existe campo `max_leads` no Plan (agente.md secao 56 não lista um); se vier a ser necessário, é uma adição pequena ao Plan + `assertQuota`. diff --git a/docs/CDR.md b/docs/CDR.md new file mode 100644 index 0000000..3677f21 --- /dev/null +++ b/docs/CDR.md @@ -0,0 +1,152 @@ +# CDR (Call Detail Records) + +Agente.md secao 152-160. Fecha o registro durável de chamadas e os +relatórios que dependem dele — até aqui, o estado de uma chamada só vivia +transitoriamente no canal Redis `b2bcall:events` (pub/sub sem histórico). + +## Modelo + +`calls`/`call_legs`/`call_events` (secao 153), tenant-scoped, RLS. +`dial_attempts` da especificação **não** virou uma tabela nova — +`CallAttempt` (fase Predictive Engine) já cobre exatamente esse conceito +(campanha, lead, timestamps, resultado); `Call.attemptId` liga um `Call` à +sua tentativa de discagem quando aplicável. + +`Call.id` = o próprio `freeswitch_uuid` da perna principal (sem suporte a +transferência entre uuids nesta fase — `call_id`/`freeswitch_uuid` +coincidem por enquanto, campos mantidos separados só pra já bater com o +schema da especificação quando isso mudar). + +`dispositions` (secao 89, "Call Center → Disposições"): personalizável por +tenant, mesmo padrão de `PauseReason`. `Call.dispositionId` aponta pra lá. + +## Quem escreve: `apps/freeswitch-events/src/cdr.ts` + +Cada `NormalizedEvent` relevante (`CALL_CREATED`, `CALL_RINGING`, +`CALL_ANSWERED`, `CALL_BRIDGED`, `CALL_ENDED`, `AGENT_OFFERED_CALL`, +`AGENT_BRIDGE_FAILED`, `QUEUE_MEMBER_LEFT`) faz um `upsert` em `Call` +(cria se for o primeiro evento visto pra aquele uuid, atualiza os campos +relevantes) e sempre insere uma linha em `call_events` — a trilha bruta. +`CALL_ENDED` também dispara o cálculo dos agregados em segundos (secao +155-156): `ringTime`/`waitTime`/`talkTime`/`durationSeconds`/ +`billableSeconds` (`billableSeconds = talkTime` por enquanto, sem regra de +tarifação ainda — fase Billing). + +**Simplificação deliberada**: quando a chamada tem fila associada, o +primeiro `CALL_BRIDGED` é tratado como o agente atendendo +(`agentAnswerAt = bridgeAt`) — não distingue bridge pro agente de um +eventual bridge intermediário (IVR, AVMD). Reavaliar se/quando esses +cenários existirem de verdade. + +## Achado real: `AGENT_OFFERED_CALL`/`AGENT_BRIDGE_FAILED` não têm `Unique-ID` + +Descoberto testando esta fase: esses dois `CC-Action` disparam de dentro +de uma thread interna do mod_callcenter (`outbound_agent_thread_run`), sem +contexto de channel — o header `Unique-ID` (de onde `normalizeEslEvent` +tira `callUuid`) simplesmente não existe nesses dois eventos (diferente de +`member-queue-end`, que dispara no channel do member e tem `Unique-ID` +normal). Sem correção, `cdr.ts` descartava os dois silenciosamente (guard +`if (!normalized.callUuid) return`) — `Call.queueId`/`agentId` nunca eram +preenchidos, mesmo com o bridge (ou a falha de bridge) acontecendo de +verdade. + +Corrigido usando `CC-Member-Session-UUID` (`data.memberSessionUuid`) como +fallback quando `callUuid` não vem — é o mesmo uuid do channel member em +todos os casos, já usado pra correlação equivalente em +`apps/predictive-dialer/src/event-listener.ts`. + +## Achado real: corrida entre `CALL_CREATED` e `CALL_ANSWERED` + +`persistCallEvent` é chamado sem `await` no loop de eventos (fire-and- +forget, pra não travar o processamento dos próximos eventos ESL) — cada +chamada abre sua própria transação. Pra uma perna `null/dummy` (auto- +atende quase instantaneamente, sem ring de verdade), `CALL_CREATED` e +`CALL_ANSWERED` podem chegar tão perto um do outro que suas transações +concorrentes commitam fora de ordem, e quem quer que "ganhe" a corrida do +`upsert` (criar a linha) usava `now()` daquele momento como `createdAt` — +resultando em `answerAt` aparentemente **antes** de `createdAt`. Corrigido +passando `createdAt: new Date(normalized.occurredAt)` explicitamente no +branch de criação do upsert, em vez de depender do default `@default(now())` +do schema (que reflete "quando a linha foi inserida", não "quando o +evento realmente aconteceu"). + +## Relatórios (`apps/api/src/reports`) + +- `GET /reports/queues` (secao 159): recebidas/atendidas/abandonadas/TME/ + TMA/Service Level/Abandon Rate por fila, a partir de `Call` agrupado por + `queueId`. Service Level usa um limiar configurável (`slThresholdSeconds`, + default 20s — a especificação não fixa um valor). +- `GET /reports/agents` (secao 158): tempo logado (`AgentSession`), tempo + pausado (`AgentPauseEvent`, timestamps explícitos), tempo em cada outro + estado (`AgentStateEvent`, pareando eventos consecutivos do mesmo agente + — sem uma tabela de "duração por estado" pronta, calculado on-the-fly), + chamadas atendidas + TMA (`Call`). +- `GET /reports/campaigns` (secao 160): leads/attempts/answered/agent + connected/busy/no answer/failed/callbacks/answer rate/contact rate/ + abandon rate a partir de `CallAttempt` + `Lead`; TME/TMA via `Call` + (join por `attemptId`) e `CallAttempt.talkTimeSeconds`. "Valor + Telefonia"/"Valor IA" ficam `null` — dependem de rating de uso, fase + Billing, ainda não existe. + +## `GET /calls` (secao 157) e disposição (secao 89) + +Filtros: data (`from`/`to`), ramal, agente, fila, campanha, trunk, +telefone (`contains` em `callerNumber`/`calledNumber`), hangup cause, +disposição. Sempre escopado ao tenant do JWT — "platform admin poderá +filtrar tenant" (secao 157) ainda não existe (não há console cross-tenant +ainda). + +`PATCH /calls/:id/disposition`: o próprio agente que atendeu marca a +disposição (compara `Call.agentId` contra o `Agent` do usuário +autenticado, nunca um `agentId` vindo do client); um supervisor +(`agents.manage`) pode marcar em nome de outro agente. + +## Verificado ponta a ponta + +``` +Campanha com 5 leads, 1 agente sem SIP registrado de verdade (mesma +limitação de sempre neste laboratório — USER_NOT_REGISTERED): + +3 leads deram ANSWERED (simulado) -> entraram na fila real: + Call.queueId preenchido (via AGENT_OFFERED_CALL ou QUEUE_MEMBER_LEFT) + Call.agentId preenchido num deles (via AGENT_OFFERED_CALL, fallback + memberSessionUuid) — confirma a correção da correlação + Call.hangupCause = USER_NOT_REGISTERED (via AGENT_BRIDGE_FAILED) até o + hangup agendado sobrescrever com NORMAL_CLEARING no fim + createdAt <= answerAt em todos (corrige a inversão da corrida) + durationSeconds calculado corretamente (~60s, bateu com + discard_abandoned_after) + +GET /reports/queues -> received:3, abandoned:3, abandonRate:1 +GET /reports/agents -> loggedInSeconds/availableSeconds corretos, + callsAnswered:0 (nenhum bridge de verdade aconteceu) +GET /reports/campaigns -> leads:5, attempts:5, answered:3, busy:1, + noAnswer:1, answerRate:0.6, abandonRate:1 — todos os números batendo + com o que os logs do discador mostraram + +POST /dispositions {"name":"Sem Interesse","code":"no_interest"} +PATCH /calls/:id/disposition -> disposição gravada (agente dono da + chamada, ownership check correto) + +`queue list` do FreeSWITCH mostra calls_abandoned=4 ao final, +confirmando o pipeline real por trás dos números agregados. +``` + +typecheck do workspace inteiro limpo. + +## O que falta + +- `extensionId`/`sipCallId`/`callerNumber`/`calledNumber` — não + populados ainda (nenhum evento atual carrega esses dados de forma + confiável; precisa de inspeção de headers adicionais do canal, ver + docs/REALTIME.md "O que falta" pro mesmo tipo de lacuna em ramais). +- `direction` só distingue OUTBOUND (tem campanha) de INTERNAL (resto) — + detecção de INBOUND de verdade precisa inspecionar `Call-Direction`/ + contexto do dialplan, não feito ainda. +- `call_legs` — schema existe, mas nada escreve nele ainda (só faz + sentido de verdade com transferência entre uuids, que também não + existe). +- Cross-tenant reports pra platform admin (secao 157) — sem console de + plataforma ainda. +- Billing (`billableSeconds` sem tarifação, "Valor Telefonia"/"Valor IA" + sempre null) — fase própria, ainda não iniciada. diff --git a/docs/PREDICTIVE_DIALER.md b/docs/PREDICTIVE_DIALER.md index 8cbfc11..b9635c3 100644 --- a/docs/PREDICTIVE_DIALER.md +++ b/docs/PREDICTIVE_DIALER.md @@ -192,6 +192,5 @@ typecheck do workspace inteiro limpo. (contra ~30-55MB de fs-events/fs-config) — dentro do orçamento da VM, mas vale reavaliar se crescer mais rodando por mais tempo/mais campanhas simultâneas. -- Relatório de campanha (secao 160), TME/TMA/Service Level/Abandon Rate - agregados de verdade — dependem de CDR (próxima fase da ordem do - agente.md secao 232). +- ~~Relatório de campanha, TME/TMA/Service Level/Abandon Rate~~ — + implementado na fase CDR (ver docs/CDR.md). diff --git a/packages/database/prisma/migrations/20260828164734_cdr/migration.sql b/packages/database/prisma/migrations/20260828164734_cdr/migration.sql new file mode 100644 index 0000000..226b40e --- /dev/null +++ b/packages/database/prisma/migrations/20260828164734_cdr/migration.sql @@ -0,0 +1,166 @@ +-- CreateEnum +CREATE TYPE "call_direction" AS ENUM ('INBOUND', 'OUTBOUND', 'INTERNAL'); + +-- CreateTable +CREATE TABLE "dispositions" ( + "id" UUID NOT NULL, + "tenant_id" UUID NOT NULL, + "name" TEXT NOT NULL, + "code" TEXT NOT NULL, + "enabled" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "dispositions_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "calls" ( + "id" UUID NOT NULL, + "tenant_id" UUID NOT NULL, + "attempt_id" UUID, + "freeswitch_uuid" UUID NOT NULL, + "sip_call_id" TEXT, + "direction" "call_direction" NOT NULL DEFAULT 'INTERNAL', + "campaign_id" UUID, + "lead_id" UUID, + "queue_id" UUID, + "agent_id" UUID, + "extension_id" UUID, + "trunk_id" UUID, + "caller_number" TEXT, + "called_number" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "progress_at" TIMESTAMP(3), + "answer_at" TIMESTAMP(3), + "queue_enter_at" TIMESTAMP(3), + "agent_answer_at" TIMESTAMP(3), + "bridge_at" TIMESTAMP(3), + "end_at" TIMESTAMP(3), + "ring_time" INTEGER, + "wait_time" INTEGER, + "talk_time" INTEGER, + "duration_seconds" INTEGER, + "billable_seconds" INTEGER, + "hangup_cause" TEXT, + "disposition_id" UUID, + + CONSTRAINT "calls_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "call_legs" ( + "id" UUID NOT NULL, + "tenant_id" UUID NOT NULL, + "call_id" UUID NOT NULL, + "freeswitch_uuid" UUID NOT NULL, + "role" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "answered_at" TIMESTAMP(3), + "ended_at" TIMESTAMP(3), + "hangup_cause" TEXT, + + CONSTRAINT "call_legs_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "call_events" ( + "id" UUID NOT NULL, + "tenant_id" UUID NOT NULL, + "call_id" UUID NOT NULL, + "type" TEXT NOT NULL, + "occurred_at" TIMESTAMP(3) NOT NULL, + "data" JSONB NOT NULL DEFAULT '{}', + + CONSTRAINT "call_events_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "dispositions_tenant_id_idx" ON "dispositions"("tenant_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "dispositions_tenant_id_code_key" ON "dispositions"("tenant_id", "code"); + +-- CreateIndex +CREATE INDEX "calls_tenant_id_created_at_idx" ON "calls"("tenant_id", "created_at"); + +-- CreateIndex +CREATE INDEX "calls_tenant_id_queue_id_idx" ON "calls"("tenant_id", "queue_id"); + +-- CreateIndex +CREATE INDEX "calls_tenant_id_agent_id_idx" ON "calls"("tenant_id", "agent_id"); + +-- CreateIndex +CREATE INDEX "calls_tenant_id_campaign_id_idx" ON "calls"("tenant_id", "campaign_id"); + +-- CreateIndex +CREATE INDEX "call_legs_tenant_id_call_id_idx" ON "call_legs"("tenant_id", "call_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "call_legs_freeswitch_uuid_key" ON "call_legs"("freeswitch_uuid"); + +-- CreateIndex +CREATE INDEX "call_events_tenant_id_call_id_occurred_at_idx" ON "call_events"("tenant_id", "call_id", "occurred_at"); + +-- AddForeignKey +ALTER TABLE "dispositions" ADD CONSTRAINT "dispositions_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "calls" ADD CONSTRAINT "calls_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "calls" ADD CONSTRAINT "calls_attempt_id_fkey" FOREIGN KEY ("attempt_id") REFERENCES "call_attempts"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "calls" ADD CONSTRAINT "calls_campaign_id_fkey" FOREIGN KEY ("campaign_id") REFERENCES "campaigns"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "calls" ADD CONSTRAINT "calls_lead_id_fkey" FOREIGN KEY ("lead_id") REFERENCES "leads"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "calls" ADD CONSTRAINT "calls_queue_id_fkey" FOREIGN KEY ("queue_id") REFERENCES "queues"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "calls" ADD CONSTRAINT "calls_agent_id_fkey" FOREIGN KEY ("agent_id") REFERENCES "agents"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "calls" ADD CONSTRAINT "calls_extension_id_fkey" FOREIGN KEY ("extension_id") REFERENCES "extensions"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "calls" ADD CONSTRAINT "calls_trunk_id_fkey" FOREIGN KEY ("trunk_id") REFERENCES "trunks"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "calls" ADD CONSTRAINT "calls_disposition_id_fkey" FOREIGN KEY ("disposition_id") REFERENCES "dispositions"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "call_legs" ADD CONSTRAINT "call_legs_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "call_legs" ADD CONSTRAINT "call_legs_call_id_fkey" FOREIGN KEY ("call_id") REFERENCES "calls"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "call_events" ADD CONSTRAINT "call_events_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "call_events" ADD CONSTRAINT "call_events_call_id_fkey" FOREIGN KEY ("call_id") REFERENCES "calls"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- Tabelas de negocio tenant-scoped: RLS obrigatorio em todas (ver docs/TENANT_ISOLATION.md). +ALTER TABLE "dispositions" ENABLE ROW LEVEL SECURITY; +ALTER TABLE "dispositions" FORCE ROW LEVEL SECURITY; +CREATE POLICY "tenant_isolation" ON "dispositions" + USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid); + +ALTER TABLE "calls" ENABLE ROW LEVEL SECURITY; +ALTER TABLE "calls" FORCE ROW LEVEL SECURITY; +CREATE POLICY "tenant_isolation" ON "calls" + USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid); + +ALTER TABLE "call_legs" ENABLE ROW LEVEL SECURITY; +ALTER TABLE "call_legs" FORCE ROW LEVEL SECURITY; +CREATE POLICY "tenant_isolation" ON "call_legs" + USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid); + +ALTER TABLE "call_events" ENABLE ROW LEVEL SECURITY; +ALTER TABLE "call_events" FORCE ROW LEVEL SECURITY; +CREATE POLICY "tenant_isolation" ON "call_events" + USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid); diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index d7e391c..ff27a51 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -52,6 +52,10 @@ model Tenant { suppressionEntries SuppressionEntry[] callAttempts CallAttempt[] campaignStats CampaignStats[] + dispositions Disposition[] + calls Call[] + callLegs CallLeg[] + callEvents CallEvent[] @@map("tenants") } @@ -265,6 +269,7 @@ model Extension { tenant Tenant @relation(fields: [tenantId], references: [id]) agents Agent[] + calls Call[] @@unique([tenantId, number]) @@index([tenantId]) @@ -359,6 +364,7 @@ model Trunk { tenant Tenant @relation(fields: [tenantId], references: [id]) campaigns Campaign[] + calls Call[] @@unique([tenantId, name]) @@index([tenantId]) @@ -485,6 +491,7 @@ model Queue { tenant Tenant @relation(fields: [tenantId], references: [id]) tiers Tier[] campaigns Campaign[] + calls Call[] @@unique([tenantId, name]) @@index([tenantId]) @@ -542,6 +549,7 @@ model Agent { stateEvents AgentStateEvent[] pauseEvents AgentPauseEvent[] callAttempts CallAttempt[] + calls Call[] @@unique([tenantId, userId]) @@index([tenantId]) @@ -720,6 +728,7 @@ model Campaign { leads Lead[] callAttempts CallAttempt[] campaignStats CampaignStats[] + calls Call[] @@unique([tenantId, name]) @@index([tenantId]) @@ -784,6 +793,7 @@ model Lead { tenant Tenant @relation(fields: [tenantId], references: [id]) campaign Campaign @relation(fields: [campaignId], references: [id]) callAttempts CallAttempt[] + calls Call[] @@unique([campaignId, phoneNormalized]) @@index([tenantId]) @@ -865,6 +875,7 @@ model CallAttempt { campaign Campaign @relation(fields: [campaignId], references: [id]) lead Lead @relation(fields: [leadId], references: [id]) agent Agent? @relation(fields: [agentId], references: [id]) + calls Call[] @@index([tenantId]) @@index([campaignId, status]) @@ -899,3 +910,153 @@ model CampaignStats { @@index([tenantId]) @@map("campaign_stats") } + +enum CallDirection { + INBOUND + OUTBOUND + INTERNAL + + @@map("call_direction") +} + +// Disposição escolhida pelo agente ao fim de uma chamada (agente.md secao +// 89: "Call Center -> Disposições", personalizável por tenant — mesmo +// padrão de PauseReason, não uma lista fixa hardcoded). +model Disposition { + id String @id @default(uuid()) @db.Uuid + tenantId String @map("tenant_id") @db.Uuid + + name String + code String + + enabled Boolean @default(true) + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + tenant Tenant @relation(fields: [tenantId], references: [id]) + calls Call[] + + @@unique([tenantId, code]) + @@index([tenantId]) + @@map("dispositions") +} + +// "calls" (agente.md secao 153-154) — um registro por chamada lógica +// (`id` = freeswitch_uuid da perna principal; sem suporte a transferência +// entre uuids nesta fase, então `call_id`/`freeswitch_uuid` coincidem por +// enquanto, mantidos como campos separados pra já bater com o schema da +// especificação quando isso mudar). Alimentado por +// apps/freeswitch-events (nunca escrito manualmente pela API) — ver +// docs/CDR.md. +model Call { + id String @id @db.Uuid + tenantId String @map("tenant_id") @db.Uuid + + // agente.md secao 154: attempt_id só existe pra chamadas originadas pelo + // PredictiveDialerEngine — CallAttempt já cobre o conceito de + // "dial_attempts" da secao 153, sem tabela duplicada. + attemptId String? @map("attempt_id") @db.Uuid + + freeswitchUuid String @map("freeswitch_uuid") @db.Uuid + sipCallId String? @map("sip_call_id") + + direction CallDirection @default(INTERNAL) + + campaignId String? @map("campaign_id") @db.Uuid + leadId String? @map("lead_id") @db.Uuid + queueId String? @map("queue_id") @db.Uuid + agentId String? @map("agent_id") @db.Uuid + extensionId String? @map("extension_id") @db.Uuid + trunkId String? @map("trunk_id") @db.Uuid + + callerNumber String? @map("caller_number") + calledNumber String? @map("called_number") + + createdAt DateTime @default(now()) @map("created_at") + progressAt DateTime? @map("progress_at") + answerAt DateTime? @map("answer_at") + queueEnterAt DateTime? @map("queue_enter_at") + agentAnswerAt DateTime? @map("agent_answer_at") + bridgeAt DateTime? @map("bridge_at") + endAt DateTime? @map("end_at") + + // Segundos, calculados quando a chamada termina (secao 155-156): + // ringTime = answerAt-createdAt, waitTime = agentAnswerAt-queueEnterAt + // (TME de uma chamada individual), talkTime = endAt-bridgeAt, + // durationSeconds = endAt-createdAt, billableSeconds = talkTime por + // enquanto (sem regra de arredondamento/tarifação ainda, fase Billing). + ringTime Int? @map("ring_time") + waitTime Int? @map("wait_time") + talkTime Int? @map("talk_time") + durationSeconds Int? @map("duration_seconds") + billableSeconds Int? @map("billable_seconds") + + hangupCause String? @map("hangup_cause") + + dispositionId String? @map("disposition_id") @db.Uuid + + tenant Tenant @relation(fields: [tenantId], references: [id]) + attempt CallAttempt? @relation(fields: [attemptId], references: [id]) + campaign Campaign? @relation(fields: [campaignId], references: [id]) + lead Lead? @relation(fields: [leadId], references: [id]) + queue Queue? @relation(fields: [queueId], references: [id]) + agent Agent? @relation(fields: [agentId], references: [id]) + extension Extension? @relation(fields: [extensionId], references: [id]) + trunk Trunk? @relation(fields: [trunkId], references: [id]) + disposition Disposition? @relation(fields: [dispositionId], references: [id]) + legs CallLeg[] + events CallEvent[] + + @@index([tenantId, createdAt]) + @@index([tenantId, queueId]) + @@index([tenantId, agentId]) + @@index([tenantId, campaignId]) + @@map("calls") +} + +// "call_legs" (secao 153) — um por channel/uuid FreeSWITCH envolvido na +// chamada (hoje sempre 1, a própria perna principal; ganha sentido quando +// existir bridge de 2+ pernas rastreadas separadamente, ex.: transferência). +model CallLeg { + id String @id @default(uuid()) @db.Uuid + tenantId String @map("tenant_id") @db.Uuid + callId String @map("call_id") @db.Uuid + freeswitchUuid String @map("freeswitch_uuid") @db.Uuid + + role String // "caller" | "callee" | "agent" — livre, sem enum fechado ainda + + createdAt DateTime @default(now()) @map("created_at") + answeredAt DateTime? @map("answered_at") + endedAt DateTime? @map("ended_at") + + hangupCause String? @map("hangup_cause") + + tenant Tenant @relation(fields: [tenantId], references: [id]) + call Call @relation(fields: [callId], references: [id]) + + @@unique([freeswitchUuid]) + @@index([tenantId, callId]) + @@map("call_legs") +} + +// "call_events" (secao 153) — trilha bruta dos NormalizedEvent que +// alimentaram a chamada, persistida (o canal Redis b2bcall:events é +// efêmero, pub/sub sem histórico). Nunca deletado — é o material bruto por +// trás de qualquer relatório futuro mais granular que os campos agregados +// de `Call` não cobrirem. +model CallEvent { + id String @id @default(uuid()) @db.Uuid + tenantId String @map("tenant_id") @db.Uuid + callId String @map("call_id") @db.Uuid + + type String + occurredAt DateTime @map("occurred_at") + data Json @default("{}") + + tenant Tenant @relation(fields: [tenantId], references: [id]) + call Call @relation(fields: [callId], references: [id]) + + @@index([tenantId, callId, occurredAt]) + @@map("call_events") +}