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, tenantId); } }); } 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, tenantId: string): Promise { const call = await tx.call.findUniqueOrThrow({ where: { id: callId } }); const talkTime = seconds(call.bridgeAt, call.endAt); const billableSeconds = talkTime ?? 0; 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, }, }); // "Chamada faturável" (agente.md secao 133) gera o UsageEvent que o // RatingEngine (packages/billing) consome no fechamento do período — // nunca calcula o valor aqui, só registra o fato bruto (segundos // faturáveis). Chamada sem talk time (nunca bridgeou) não gera evento — // nada a cobrar. if (billableSeconds > 0) { await tx.usageEvent.create({ data: { tenantId, callId, meter: "CALL_SECONDS", quantity: billableSeconds, unit: "seconds", sourceType: "call", sourceId: callId, occurredAt: call.endAt ?? new Date(), }, }); } }