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:
54
TODO.md
54
TODO.md
@@ -376,10 +376,58 @@
|
|||||||
agente (secao 89) — fora do escopo, ficam pra fase CDR
|
agente (secao 89) — fora do escopo, ficam pra fase CDR
|
||||||
- [ ] CPS a nível de trunk — só aplica quando o caminho PSTN real rodar
|
- [ ] 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
|
- [ ] 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,
|
## PHASE 17 — CDR (agente.md secao 152-160)
|
||||||
Billing, Frontend, Reports, Security, Tests)
|
- [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)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ import { RealtimeModule } from "./realtime/realtime.module";
|
|||||||
import { CampaignsModule } from "./campaigns/campaigns.module";
|
import { CampaignsModule } from "./campaigns/campaigns.module";
|
||||||
import { LeadsModule } from "./leads/leads.module";
|
import { LeadsModule } from "./leads/leads.module";
|
||||||
import { SuppressionModule } from "./suppression/suppression.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({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -26,6 +29,9 @@ import { SuppressionModule } from "./suppression/suppression.module";
|
|||||||
CampaignsModule,
|
CampaignsModule,
|
||||||
LeadsModule,
|
LeadsModule,
|
||||||
SuppressionModule,
|
SuppressionModule,
|
||||||
|
DispositionsModule,
|
||||||
|
CallsModule,
|
||||||
|
ReportsModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
125
apps/api/src/calls/calls.controller.ts
Normal file
125
apps/api/src/calls/calls.controller.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
7
apps/api/src/calls/calls.module.ts
Normal file
7
apps/api/src/calls/calls.module.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { CallsController } from "./calls.controller";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [CallsController],
|
||||||
|
})
|
||||||
|
export class CallsModule {}
|
||||||
6
apps/api/src/calls/dto/set-call-disposition.dto.ts
Normal file
6
apps/api/src/calls/dto/set-call-disposition.dto.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { IsUUID } from "class-validator";
|
||||||
|
|
||||||
|
export class SetCallDispositionDto {
|
||||||
|
@IsUUID()
|
||||||
|
dispositionId!: string;
|
||||||
|
}
|
||||||
68
apps/api/src/dispositions/dispositions.controller.ts
Normal file
68
apps/api/src/dispositions/dispositions.controller.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
7
apps/api/src/dispositions/dispositions.module.ts
Normal file
7
apps/api/src/dispositions/dispositions.module.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { DispositionsController } from "./dispositions.controller";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [DispositionsController],
|
||||||
|
})
|
||||||
|
export class DispositionsModule {}
|
||||||
11
apps/api/src/dispositions/dto/create-disposition.dto.ts
Normal file
11
apps/api/src/dispositions/dto/create-disposition.dto.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { IsString, MaxLength } from "class-validator";
|
||||||
|
|
||||||
|
export class CreateDispositionDto {
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(80)
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(40)
|
||||||
|
code!: string;
|
||||||
|
}
|
||||||
203
apps/api/src/reports/reports.controller.ts
Normal file
203
apps/api/src/reports/reports.controller.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
7
apps/api/src/reports/reports.module.ts
Normal file
7
apps/api/src/reports/reports.module.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { ReportsController } from "./reports.controller";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [ReportsController],
|
||||||
|
})
|
||||||
|
export class ReportsModule {}
|
||||||
172
apps/freeswitch-events/src/cdr.ts
Normal file
172
apps/freeswitch-events/src/cdr.ts
Normal file
@@ -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<NormalizedEvent["type"]>([
|
||||||
|
"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<void> {
|
||||||
|
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<void> {
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { FreeSwitchTelephonyProvider, normalizeEslEvent } from "@b2bcall/telepho
|
|||||||
import { createLogger } from "@b2bcall/shared";
|
import { createLogger } from "@b2bcall/shared";
|
||||||
import { updateTrunkStatusFromGatewayEvent } from "./trunk-status";
|
import { updateTrunkStatusFromGatewayEvent } from "./trunk-status";
|
||||||
import { resolveTenantIdForAgent, resolveTenantIdForQueue } from "./tenant-resolve";
|
import { resolveTenantIdForAgent, resolveTenantIdForQueue } from "./tenant-resolve";
|
||||||
|
import { persistCallEvent } from "./cdr";
|
||||||
|
|
||||||
const logger = createLogger("b2bcall-fs-events");
|
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) });
|
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}`, {
|
logger.info(`evento: ${normalized.type}`, {
|
||||||
callUuid: normalized.callUuid,
|
callUuid: normalized.callUuid,
|
||||||
tenantId: normalized.tenantId,
|
tenantId: normalized.tenantId,
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ delete numa campanha RUNNING -> 400 "Pare a campanha antes de apaga-la"
|
|||||||
Predictive Engine (ver docs/PREDICTIVE_DIALER.md).
|
Predictive Engine (ver docs/PREDICTIVE_DIALER.md).
|
||||||
- Wizard visual de importação (upload de arquivo de verdade) — fase
|
- Wizard visual de importação (upload de arquivo de verdade) — fase
|
||||||
Frontend.
|
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
|
- 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
|
(agente.md secao 56 não lista um); se vier a ser necessário, é uma
|
||||||
adição pequena ao Plan + `assertQuota`.
|
adição pequena ao Plan + `assertQuota`.
|
||||||
|
|||||||
152
docs/CDR.md
Normal file
152
docs/CDR.md
Normal file
@@ -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.
|
||||||
@@ -192,6 +192,5 @@ typecheck do workspace inteiro limpo.
|
|||||||
(contra ~30-55MB de fs-events/fs-config) — dentro do orçamento da VM,
|
(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
|
mas vale reavaliar se crescer mais rodando por mais tempo/mais
|
||||||
campanhas simultâneas.
|
campanhas simultâneas.
|
||||||
- Relatório de campanha (secao 160), TME/TMA/Service Level/Abandon Rate
|
- ~~Relatório de campanha, TME/TMA/Service Level/Abandon Rate~~ —
|
||||||
agregados de verdade — dependem de CDR (próxima fase da ordem do
|
implementado na fase CDR (ver docs/CDR.md).
|
||||||
agente.md secao 232).
|
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -52,6 +52,10 @@ model Tenant {
|
|||||||
suppressionEntries SuppressionEntry[]
|
suppressionEntries SuppressionEntry[]
|
||||||
callAttempts CallAttempt[]
|
callAttempts CallAttempt[]
|
||||||
campaignStats CampaignStats[]
|
campaignStats CampaignStats[]
|
||||||
|
dispositions Disposition[]
|
||||||
|
calls Call[]
|
||||||
|
callLegs CallLeg[]
|
||||||
|
callEvents CallEvent[]
|
||||||
|
|
||||||
@@map("tenants")
|
@@map("tenants")
|
||||||
}
|
}
|
||||||
@@ -265,6 +269,7 @@ model Extension {
|
|||||||
|
|
||||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||||
agents Agent[]
|
agents Agent[]
|
||||||
|
calls Call[]
|
||||||
|
|
||||||
@@unique([tenantId, number])
|
@@unique([tenantId, number])
|
||||||
@@index([tenantId])
|
@@index([tenantId])
|
||||||
@@ -359,6 +364,7 @@ model Trunk {
|
|||||||
|
|
||||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||||
campaigns Campaign[]
|
campaigns Campaign[]
|
||||||
|
calls Call[]
|
||||||
|
|
||||||
@@unique([tenantId, name])
|
@@unique([tenantId, name])
|
||||||
@@index([tenantId])
|
@@index([tenantId])
|
||||||
@@ -485,6 +491,7 @@ model Queue {
|
|||||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||||
tiers Tier[]
|
tiers Tier[]
|
||||||
campaigns Campaign[]
|
campaigns Campaign[]
|
||||||
|
calls Call[]
|
||||||
|
|
||||||
@@unique([tenantId, name])
|
@@unique([tenantId, name])
|
||||||
@@index([tenantId])
|
@@index([tenantId])
|
||||||
@@ -542,6 +549,7 @@ model Agent {
|
|||||||
stateEvents AgentStateEvent[]
|
stateEvents AgentStateEvent[]
|
||||||
pauseEvents AgentPauseEvent[]
|
pauseEvents AgentPauseEvent[]
|
||||||
callAttempts CallAttempt[]
|
callAttempts CallAttempt[]
|
||||||
|
calls Call[]
|
||||||
|
|
||||||
@@unique([tenantId, userId])
|
@@unique([tenantId, userId])
|
||||||
@@index([tenantId])
|
@@index([tenantId])
|
||||||
@@ -720,6 +728,7 @@ model Campaign {
|
|||||||
leads Lead[]
|
leads Lead[]
|
||||||
callAttempts CallAttempt[]
|
callAttempts CallAttempt[]
|
||||||
campaignStats CampaignStats[]
|
campaignStats CampaignStats[]
|
||||||
|
calls Call[]
|
||||||
|
|
||||||
@@unique([tenantId, name])
|
@@unique([tenantId, name])
|
||||||
@@index([tenantId])
|
@@index([tenantId])
|
||||||
@@ -784,6 +793,7 @@ model Lead {
|
|||||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||||
campaign Campaign @relation(fields: [campaignId], references: [id])
|
campaign Campaign @relation(fields: [campaignId], references: [id])
|
||||||
callAttempts CallAttempt[]
|
callAttempts CallAttempt[]
|
||||||
|
calls Call[]
|
||||||
|
|
||||||
@@unique([campaignId, phoneNormalized])
|
@@unique([campaignId, phoneNormalized])
|
||||||
@@index([tenantId])
|
@@index([tenantId])
|
||||||
@@ -865,6 +875,7 @@ model CallAttempt {
|
|||||||
campaign Campaign @relation(fields: [campaignId], references: [id])
|
campaign Campaign @relation(fields: [campaignId], references: [id])
|
||||||
lead Lead @relation(fields: [leadId], references: [id])
|
lead Lead @relation(fields: [leadId], references: [id])
|
||||||
agent Agent? @relation(fields: [agentId], references: [id])
|
agent Agent? @relation(fields: [agentId], references: [id])
|
||||||
|
calls Call[]
|
||||||
|
|
||||||
@@index([tenantId])
|
@@index([tenantId])
|
||||||
@@index([campaignId, status])
|
@@index([campaignId, status])
|
||||||
@@ -899,3 +910,153 @@ model CampaignStats {
|
|||||||
@@index([tenantId])
|
@@index([tenantId])
|
||||||
@@map("campaign_stats")
|
@@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")
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user