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:
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 { 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,
|
||||
|
||||
Reference in New Issue
Block a user