import { randomUUID } from "node:crypto"; import type { CallAttempt, CallAttemptStatus, Prisma } from "@b2bcall/database"; import { computeNextAttemptAt } from "./retry-rules"; import { ewmaUpdate } from "./ewma"; export interface CreateAttemptParams { tenantId: string; campaignId: string; leadId: string; simulated: boolean; } export async function createCallAttempt( tx: Prisma.TransactionClient, params: CreateAttemptParams, ): Promise { return tx.callAttempt.create({ data: { tenantId: params.tenantId, campaignId: params.campaignId, leadId: params.leadId, simulated: params.simulated, status: "RESERVED", }, }); } export async function setAttemptStatus( tx: Prisma.TransactionClient, attemptId: string, status: CallAttemptStatus, extra: Partial> = {}, ): Promise { await tx.callAttempt.update({ where: { id: attemptId }, data: { status, ...extra } }); } export function newOriginationUuid(): string { return randomUUID(); } export type TerminalOutcome = "COMPLETED" | "BUSY" | "NO_ANSWER" | "FAILED" | "ABANDONED"; export interface CompleteAttemptParams { attemptId: string; tenantId: string; campaignId: string; leadId: string; outcome: TerminalOutcome; reachedQueue: boolean; answerDelaySeconds?: number; talkTimeSeconds?: number; hangupCause?: string; agentId?: string; } const OUTCOME_TO_STATUS: Record = { COMPLETED: "COMPLETED", BUSY: "BUSY", NO_ANSWER: "NO_ANSWER", FAILED: "FAILED", ABANDONED: "ABANDONED", }; /** * Fecha uma tentativa: grava o CallAttempt terminal, atualiza a EWMA da * campanha (agente.md secao 75) e decide o próximo passo do Lead — nunca * retry infinito (secao 86): `attemptCount >= maxAttempts` vira * MAX_ATTEMPTS, terminal, nunca mais selecionado. Leads que ainda vão * tentar de novo voltam pra READY (não BUSY/NO_ANSWER/etc como status * consultável — a razão especifica fica em `lastResult`; READY é o único * jeito da query de reserva achar o lead de novo depois de * `nextAttemptAt`). */ export async function completeAttempt( tx: Prisma.TransactionClient, params: CompleteAttemptParams, campaignMaxAttempts: number, ): Promise { const now = new Date(); await tx.callAttempt.update({ where: { id: params.attemptId }, data: { status: OUTCOME_TO_STATUS[params.outcome], talkTimeSeconds: params.talkTimeSeconds, hangupCause: params.hangupCause, agentId: params.agentId, endedAt: now, }, }); const stats = await tx.campaignStats.findUniqueOrThrow({ where: { campaignId: params.campaignId } }); const wasAnswered = params.outcome === "COMPLETED" || params.outcome === "ABANDONED"; const data: Prisma.CampaignStatsUpdateInput = { answerProbability: ewmaUpdate(stats.answerProbability, wasAnswered ? 1 : 0), }; if (wasAnswered && params.answerDelaySeconds != null) { data.averageAnswerDelay = ewmaUpdate(stats.averageAnswerDelay, params.answerDelaySeconds); } if (params.talkTimeSeconds != null) { data.averageTalkTime = ewmaUpdate(stats.averageTalkTime, params.talkTimeSeconds); } if (params.reachedQueue) { data.abandonRate = ewmaUpdate(stats.abandonRate, params.outcome === "ABANDONED" ? 1 : 0); } await tx.campaignStats.update({ where: { campaignId: params.campaignId }, data }); const lead = await tx.lead.findUniqueOrThrow({ where: { id: params.leadId } }); const attemptCount = lead.attemptCount + 1; if (params.outcome === "COMPLETED") { await tx.lead.update({ where: { id: params.leadId }, data: { status: "COMPLETED", attemptCount, lastAttemptAt: now, lastResult: params.outcome }, }); return; } if (attemptCount >= campaignMaxAttempts) { await tx.lead.update({ where: { id: params.leadId }, data: { status: "MAX_ATTEMPTS", attemptCount, lastAttemptAt: now, lastResult: params.outcome }, }); return; } await tx.lead.update({ where: { id: params.leadId }, data: { status: "READY", attemptCount, lastAttemptAt: now, lastResult: params.outcome, nextAttemptAt: computeNextAttemptAt(params.outcome, now), }, }); }