mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 09:34:46 +08:00
feat(followup): o passo manda modelo aprovado do WhatsApp e o silêncio respeita o retorno agendado
Três defeitos medidos numa instalação real (25/09), no mesmo remarketing:
- O modo `template` do passo de ação só lia `message_templates`. Um fluxo
apontado para um modelo APROVADO do canal (o único envio que passa com a
janela de 24 h fechada) era publicado e morria no primeiro disparo. Agora o
turno resolve o id também em `meta_templates`, pela definição da conexão da
conversa, e envia como modelo (`isTemplate` na cadeia, nome e idioma no
canal). Pendente ou com variável: o passo é pulado com o motivo.
- `fallback_template_id` da mensagem por IA era gravado, validado no publish
e nunca lido. Com a janela fechada, o modelo aprovado sai no lugar da IA;
aberta, a IA escreve como sempre.
- O fluxo de silêncio falava por cima do retorno que o agente combinou
("te escrevo no dia 30"). A varredura não inscreve quem tem retorno vivo, e a
inscrição que já andava fica segurada até um dia depois do retorno, com o
evento `held_by_return` legível no dossiê.
O construtor ganha o grupo "Aprovados no WhatsApp" no seletor de modelo
(rota `GET /api/v1/ai/followup-flows/modelos-aprovados`, viewer+), e o plano B
da IA só oferece modelo aprovado.
(cherry picked from commit b4b8e38f6fa3fcf71c6afe113648f7953a38ba09)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
---
|
||||
impacto: capacidade_nova
|
||||
secao: adicionado
|
||||
titulo: O follow-up manda modelo aprovado do WhatsApp e respeita o retorno combinado
|
||||
---
|
||||
|
||||
O passo de mensagem pronta de um follow-up passa a oferecer, além dos textos de
|
||||
Ajustes → Modelos, os modelos aprovados no WhatsApp — o único envio que chega
|
||||
ao cliente depois de 24 horas sem resposta. Antes, um passo apontado para um
|
||||
modelo aprovado era publicado sem erro e falhava no primeiro disparo. A
|
||||
mensagem escrita pela IA também passa a usar o modelo aprovado escolhido como
|
||||
plano B quando a janela de 24 horas já fechou; até aqui esse campo era salvo e
|
||||
nunca usado.
|
||||
|
||||
E quando o assistente combina com o cliente um retorno numa data ("te escrevo
|
||||
no dia 30"), o follow-up de silêncio não escreve por cima: o contato não entra
|
||||
no fluxo enquanto o retorno está agendado, e quem já estava nele fica em espera
|
||||
até um dia depois do retorno. O detalhe do follow-up mostra o motivo.
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* GET /api/v1/ai/followup-flows/modelos-aprovados — os modelos aprovados do canal
|
||||
* que um passo de fluxo consegue enviar sozinho (viewer+, como a leitura do fluxo).
|
||||
*
|
||||
* É a lista do seletor de modelo no construtor. A de `/api/v1/channels/templates`
|
||||
* não serve: pede `admin` (quem edita fluxo é `manager`), não devolve o `id` que o
|
||||
* passo grava e inclui o que o fluxo não consegue mandar. A regra do que entra
|
||||
* mora em `lib/followup/modelos-aprovados.ts`.
|
||||
*/
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import { ok, fail } from "@/lib/api/wrappers";
|
||||
import { requireRole } from "@/lib/auth/require-role";
|
||||
import { modelosQueOFluxoEnvia, type LinhaDeModeloDoCanal } from "@/lib/followup/modelos-aprovados";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(): Promise<Response> {
|
||||
const requestId = randomUUID();
|
||||
const authz = await requireRole("viewer", { requestId, resource: "followup_flows" });
|
||||
if (!authz.ok) return authz.response;
|
||||
|
||||
const supabase = await createClient();
|
||||
const { data, error } = await supabase
|
||||
.from("meta_templates")
|
||||
.select("id, name, language, status, parameter_format, components")
|
||||
.eq("organization_id", authz.org.orgId)
|
||||
.order("name");
|
||||
if (error) return fail("internal_error", error.message, 500, { requestId });
|
||||
|
||||
return ok(modelosQueOFluxoEnvia((data ?? []) as LinhaDeModeloDoCanal[]), { requestId });
|
||||
}
|
||||
@@ -6,7 +6,9 @@ import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
@@ -14,6 +16,7 @@ import { Textarea } from "@/components/ui/textarea";
|
||||
import { actionConfigSchema } from "@/lib/followup/graph-schema";
|
||||
import { MODOS_DA_ACAO, opcoes, type ModoDaAcao } from "@/lib/followup/vocabulario";
|
||||
import { useMessageTemplates } from "@/hooks/inbox/useMessageTemplates";
|
||||
import { useModelosAprovadosDoFluxo } from "@/hooks/followup/useModelosAprovadosDoFluxo";
|
||||
import { useT } from "@/hooks/i18n/useT";
|
||||
|
||||
import type { ConfigOf } from "./shared";
|
||||
@@ -23,55 +26,88 @@ import type { ConfigOf } from "./shared";
|
||||
* mão. Trata os três estados em vez de fingir que a lista sempre chega:
|
||||
* carregando, vazia e erro — porque um seletor vazio sem explicação é o mesmo
|
||||
* beco sem saída que o campo de UUID era, só que mais bonito.
|
||||
*
|
||||
* Duas origens, em grupos separados: os textos prontos (Ajustes → Modelos) e os
|
||||
* modelos APROVADOS no WhatsApp. A diferença não é cosmética: com a janela de
|
||||
* 24 h fechada, só o aprovado chega ao cliente — por isso o plano B da mensagem
|
||||
* por IA (`soAprovados`) só oferece esse grupo.
|
||||
*/
|
||||
function SeletorDeModelo({
|
||||
id,
|
||||
valor,
|
||||
onChange,
|
||||
permiteVazio,
|
||||
soAprovados,
|
||||
}: {
|
||||
id: string;
|
||||
valor: string;
|
||||
onChange: (templateId: string) => void;
|
||||
permiteVazio: boolean;
|
||||
soAprovados: boolean;
|
||||
}) {
|
||||
const t = useT();
|
||||
const { data: modelos, isLoading, isError } = useMessageTemplates();
|
||||
const textos = useMessageTemplates();
|
||||
const aprovados = useModelosAprovadosDoFluxo();
|
||||
const prontos = soAprovados ? [] : (textos.data ?? []);
|
||||
const doCanal = aprovados.data ?? [];
|
||||
|
||||
if (isLoading) return <p className="text-xs text-text-muted">{t("Carregando seus modelos…")}</p>;
|
||||
if (isError) {
|
||||
if ((!soAprovados && textos.isLoading) || aprovados.isLoading) {
|
||||
return <p className="text-xs text-text-muted">{t("Carregando seus modelos…")}</p>;
|
||||
}
|
||||
if ((soAprovados || textos.isError) && aprovados.isError) {
|
||||
return (
|
||||
<p className="text-xs text-error-fg">
|
||||
{t("Não consegui carregar seus modelos de mensagem. Recarregue a página.")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (!modelos?.length) {
|
||||
if (prontos.length === 0 && doCanal.length === 0) {
|
||||
return (
|
||||
<p className="text-xs text-text-muted">
|
||||
{t("Você ainda não tem modelos de mensagem. Crie um em Ajustes → Modelos e ele aparece aqui.")}
|
||||
{soAprovados
|
||||
? t("Nenhum modelo aprovado no WhatsApp ainda. Crie um em Conexões → Modelos e ele aparece aqui quando for aprovado.")
|
||||
: t("Você ainda não tem modelos de mensagem. Crie um em Ajustes → Modelos e ele aparece aqui.")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const SEM_MODELO = "__nenhum__";
|
||||
const escolhido = doCanal.find((m) => m.id === valor);
|
||||
return (
|
||||
<Select
|
||||
value={valor === "" ? SEM_MODELO : valor}
|
||||
onValueChange={(v) => onChange(v === SEM_MODELO ? "" : v)}
|
||||
>
|
||||
<SelectTrigger id={id}>
|
||||
<SelectValue placeholder={t("Escolha um modelo")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{permiteVazio && <SelectItem value={SEM_MODELO}>{t("Nenhum")}</SelectItem>}
|
||||
{modelos.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id}>
|
||||
{m.title}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="space-y-2">
|
||||
<Select
|
||||
value={valor === "" ? SEM_MODELO : valor}
|
||||
onValueChange={(v) => onChange(v === SEM_MODELO ? "" : v)}
|
||||
>
|
||||
<SelectTrigger id={id}>
|
||||
<SelectValue placeholder={t("Escolha um modelo")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{permiteVazio && <SelectItem value={SEM_MODELO}>{t("Nenhum")}</SelectItem>}
|
||||
{prontos.length > 0 && (
|
||||
<SelectGroup>
|
||||
<SelectLabel>{t("Textos prontos")}</SelectLabel>
|
||||
{prontos.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id}>
|
||||
{m.title}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
)}
|
||||
{doCanal.length > 0 && (
|
||||
<SelectGroup>
|
||||
<SelectLabel>{t("Aprovados no WhatsApp")}</SelectLabel>
|
||||
{doCanal.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id}>
|
||||
{m.name} ({m.language})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{escolhido && <p className="whitespace-pre-line text-xs text-text-muted">{escolhido.texto}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -176,11 +212,14 @@ export function ActionForm({
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="action-fallback">{t("Se a IA não conseguir escrever, mandar este modelo")}</Label>
|
||||
<Label htmlFor="action-fallback">
|
||||
{t("Se a janela de 24 horas já tiver fechado, mandar este modelo aprovado no lugar da IA")}
|
||||
</Label>
|
||||
<SeletorDeModelo
|
||||
id="action-fallback"
|
||||
valor={fallbackTemplateId}
|
||||
permiteVazio
|
||||
soAprovados
|
||||
onChange={(v) => {
|
||||
setFallbackTemplateId(v);
|
||||
commit({ mode, ...fields, fallbackTemplateId: v });
|
||||
@@ -195,11 +234,15 @@ export function ActionForm({
|
||||
id="action-template-id"
|
||||
valor={templateId}
|
||||
permiteVazio={false}
|
||||
soAprovados={false}
|
||||
onChange={(v) => {
|
||||
setTemplateId(v);
|
||||
commit({ mode, ...fields, templateId: v });
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-text-muted">
|
||||
{t("Depois de 24 horas sem resposta do cliente, só um modelo aprovado no WhatsApp chega até ele.")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{error && <p className="text-xs text-error-fg">{error}</p>}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import type { ModeloAprovadoDoFluxo } from "@/lib/followup/modelos-aprovados";
|
||||
|
||||
/**
|
||||
* Modelos aprovados do canal que um passo de fluxo consegue enviar sozinho — o
|
||||
* único envio que passa com a janela de 24 h fechada. Ver
|
||||
* `lib/followup/modelos-aprovados.ts` para o que entra na lista e por quê.
|
||||
*/
|
||||
export function useModelosAprovadosDoFluxo() {
|
||||
return useQuery({
|
||||
queryKey: ["followup-modelos-aprovados"],
|
||||
queryFn: async () =>
|
||||
apiClient.get<{ data: ModeloAprovadoDoFluxo[] }>("/api/v1/ai/followup-flows/modelos-aprovados"),
|
||||
staleTime: 60_000,
|
||||
select: (res) => res.data,
|
||||
});
|
||||
}
|
||||
@@ -28,6 +28,11 @@ import { getLeadContext, type LeadContext } from '../edge/crm/get-lead-context';
|
||||
import { WahaChannelAdapter } from '../edge/channel/waha-adapter';
|
||||
import { applySendOutcome } from '../edge/crm/send-message';
|
||||
import { runBeforeSend } from '../guardrails/before-send';
|
||||
import { definicaoNaConexao } from '@/lib/channels/linha-do-espelho';
|
||||
import { estadoDaJanela } from '@/lib/channels/janela';
|
||||
import { renderTemplateBody } from '@/lib/channels/meta/render-template';
|
||||
import { isStatusSendable } from '@/lib/channels/meta/template-binding';
|
||||
import { deriveTemplateContract } from '@/lib/channels/meta/template-contract';
|
||||
import { camadaLigada, lerCamadasDaOrg } from '../guardrails/camadas-da-org';
|
||||
import { classifyPromise } from '../guardrails/promise/semantic';
|
||||
import { scheduleCronJob } from '../cron/scheduler';
|
||||
@@ -82,8 +87,10 @@ export const followupTurnPayloadSchema = z
|
||||
prompt_hint: z.string().optional(),
|
||||
/** action mode `text` — enviado pela cadeia de guardrails, sem LLM. */
|
||||
fixed_body: z.string().min(1).max(4000).optional(),
|
||||
/** action mode `template` — corpo em `message_templates`. */
|
||||
/** action mode `template` — `message_templates` (texto) ou `meta_templates` (modelo aprovado do canal). */
|
||||
template_id: z.string().uuid().optional(),
|
||||
/** action mode `ai_message` — modelo aprovado que sai no lugar da IA com a janela de 24 h fechada. */
|
||||
fallback_template_id: z.string().uuid().optional(),
|
||||
volta_index: z.number().int().optional(),
|
||||
volta_total: z.number().int().optional(),
|
||||
classes: z.array(z.string()).optional(),
|
||||
@@ -337,6 +344,7 @@ export function createFollowupTurnHandler(deps: FollowupTurnDeps) {
|
||||
promptHint: payload.prompt_hint,
|
||||
fixedBody: payload.fixed_body,
|
||||
templateId: payload.template_id,
|
||||
fallbackTemplateId: payload.fallback_template_id,
|
||||
voltaIndex: payload.volta_index,
|
||||
voltaTotal: payload.volta_total,
|
||||
classes: payload.classes,
|
||||
@@ -414,6 +422,7 @@ async function runFlowDrivenTurn(
|
||||
promptHint: string | undefined;
|
||||
fixedBody: string | undefined;
|
||||
templateId: string | undefined;
|
||||
fallbackTemplateId: string | undefined;
|
||||
voltaIndex: number | undefined;
|
||||
voltaTotal: number | undefined;
|
||||
classes: string[] | undefined;
|
||||
@@ -434,10 +443,31 @@ async function runFlowDrivenTurn(
|
||||
const runLog = withFields(deps.log, { job_id: job.id, tenant_id: target.tenantId, lead_id: target.leadId, enrollment_id: enrollmentId });
|
||||
|
||||
if (input.purpose === 'send_message') {
|
||||
const body = await resolveFlowSendBody(pool, target.tenantId, input);
|
||||
if (body !== null) {
|
||||
let passo = await resolveFlowSendBody(pool, target.tenantId, target.channelSessionId, input);
|
||||
// O PLANO B DA MENSAGEM POR IA. Com a janela de 24 h fechada, o canal recusa
|
||||
// qualquer texto livre — o da IA inclusive —, e o passo terminava sem mandar
|
||||
// nada. A tela prometia "se a IA não conseguir escrever, mandar este modelo"
|
||||
// e o campo era gravado, validado e nunca lido. Só vale para modelo APROVADO
|
||||
// do canal: um texto de `message_templates` seria recusado pela mesma janela,
|
||||
// então nesse caso o turno segue para a IA como sempre seguiu.
|
||||
if (
|
||||
passo === null &&
|
||||
input.fallbackTemplateId !== undefined &&
|
||||
(await janelaFechada(pool, target, clock()))
|
||||
) {
|
||||
passo = await resolveModeloAprovado(pool, target.tenantId, target.channelSessionId, input.fallbackTemplateId);
|
||||
}
|
||||
if (passo !== null && passo.tipo === 'recusado') {
|
||||
runLog.info('passo do fluxo pulado — o modelo não pode sair', { motivo: passo.motivo });
|
||||
await complete(pool, { jobId: job.id, jobClaim: claimOfJob(job), organizationId: target.tenantId, enrollmentId, nodeId, result: { kind: 'skipped', reason: passo.motivo } });
|
||||
return;
|
||||
}
|
||||
if (passo !== null) {
|
||||
// Texto do operador: sem camada semântica (ver o cabeçalho de sendFixedOutbound).
|
||||
const desfecho = await sendFixedOutbound(deps, job, pool, ctx, clock, target, body, false);
|
||||
const desfecho = await sendFixedOutbound(
|
||||
deps, job, pool, ctx, clock, target, passo.body, false,
|
||||
passo.tipo === 'modelo_aprovado' ? passo.modelo : undefined,
|
||||
);
|
||||
// TODO OS TRÊS DESFECHOS VOLTAM PARA O ENROLLMENT. O adiado era o que não
|
||||
// voltava, e o silêncio dele custava o enrollment inteiro: o motor ficava
|
||||
// rechecando um turno que ninguém ia fechar e, esgotado o orçamento do
|
||||
@@ -568,18 +598,36 @@ function interpolarVoltaDoPayload(texto: string, index: number | undefined, tota
|
||||
return texto.replaceAll('{{volta}}', String(index)).replaceAll('{{voltas}}', String(total));
|
||||
}
|
||||
|
||||
/**
|
||||
* O que um passo de envio sem IA manda.
|
||||
*
|
||||
* `modelo_aprovado` carrega, além do corpo RENDERIZADO (é ele que os gates de
|
||||
* conteúdo avaliam), o nome e o idioma que o canal precisa para disparar o modelo.
|
||||
* `recusado` é configuração que não pode sair — o passo é pulado com o motivo, em
|
||||
* vez de a fila re-tentar até matar a inscrição por algo que tempo não conserta.
|
||||
*/
|
||||
type PassoSemIa =
|
||||
| { tipo: 'texto'; body: string }
|
||||
| {
|
||||
tipo: 'modelo_aprovado';
|
||||
body: string;
|
||||
modelo: { name: string; language: string; values: Record<string, string> };
|
||||
}
|
||||
| { tipo: 'recusado'; motivo: string };
|
||||
|
||||
async function resolveFlowSendBody(
|
||||
pool: pg.Pool,
|
||||
tenantId: string,
|
||||
channelSessionId: string,
|
||||
input: {
|
||||
fixedBody: string | undefined;
|
||||
templateId: string | undefined;
|
||||
voltaIndex: number | undefined;
|
||||
voltaTotal: number | undefined;
|
||||
},
|
||||
): Promise<string | null> {
|
||||
): Promise<PassoSemIa | null> {
|
||||
if (input.fixedBody !== undefined) {
|
||||
return interpolarVoltaDoPayload(input.fixedBody, input.voltaIndex, input.voltaTotal);
|
||||
return { tipo: 'texto', body: interpolarVoltaDoPayload(input.fixedBody, input.voltaIndex, input.voltaTotal) };
|
||||
}
|
||||
if (input.templateId === undefined) return null;
|
||||
const { rows } = await pool.query<{ body: string }>(
|
||||
@@ -587,10 +635,99 @@ async function resolveFlowSendBody(
|
||||
[tenantId, input.templateId],
|
||||
);
|
||||
const body = rows[0]?.body;
|
||||
if (body === undefined || body.length === 0) {
|
||||
if (body !== undefined && body.length > 0) {
|
||||
return { tipo: 'texto', body: interpolarVoltaDoPayload(body, input.voltaIndex, input.voltaTotal) };
|
||||
}
|
||||
// Não é texto pronto: pode ser um modelo APROVADO do canal. Até aqui o passo só
|
||||
// lia `message_templates`, e um fluxo apontado para um modelo aprovado — o único
|
||||
// envio que passa com a janela de 24 h fechada — morria neste `throw` no primeiro
|
||||
// disparo, depois de o editor ter aceitado e publicado o grafo.
|
||||
const aprovado = await resolveModeloAprovado(pool, tenantId, channelSessionId, input.templateId);
|
||||
if (aprovado === null) {
|
||||
throw new Error('followup_turn sem modelo de mensagem — o template_id do passo não existe nesta organização');
|
||||
}
|
||||
return interpolarVoltaDoPayload(body, input.voltaIndex, input.voltaTotal);
|
||||
return aprovado;
|
||||
}
|
||||
|
||||
/**
|
||||
* Um modelo aprovado do canal (`meta_templates.id`), pronto para sair NESTA
|
||||
* conexão. `null` = o id não é de modelo do canal nesta organização.
|
||||
*
|
||||
* O id aponta uma linha, mas quem vale é a definição da conexão da conversa
|
||||
* (`definicaoNaConexao`, a mesma regra do `send_template` do agente): dois números
|
||||
* podem espelhar o mesmo nome, e disparar a linha de outra conta é recusa certa.
|
||||
*
|
||||
* O fluxo não tem de onde tirar valor para variável, então modelo com `{{1}}` é
|
||||
* recusado com o motivo — mandar o marcador cru ao cliente seria pior.
|
||||
*/
|
||||
async function resolveModeloAprovado(
|
||||
pool: pg.Pool,
|
||||
tenantId: string,
|
||||
channelSessionId: string,
|
||||
metaTemplateId: string,
|
||||
): Promise<PassoSemIa | null> {
|
||||
const { rows } = await pool.query<{ name: string; language: string }>(
|
||||
`select name, language from meta_templates where organization_id = $1 and id = $2 limit 1`,
|
||||
[tenantId, metaTemplateId],
|
||||
);
|
||||
const alvo = rows[0];
|
||||
if (alvo === undefined) return null;
|
||||
const linha = await definicaoNaConexao<{ components: unknown; parameter_format: string; status: string }>(
|
||||
pool,
|
||||
['components', 'parameter_format', 'status'],
|
||||
{ organizationId: tenantId, name: alvo.name, language: alvo.language, channelSessionId },
|
||||
);
|
||||
if (linha === null) {
|
||||
return { tipo: 'recusado', motivo: `O modelo "${alvo.name}" não existe no número desta conversa.` };
|
||||
}
|
||||
if (!isStatusSendable(linha.status)) {
|
||||
return {
|
||||
tipo: 'recusado',
|
||||
motivo: `O modelo "${alvo.name}" está ${linha.status} na plataforma — só modelo aprovado pode ser enviado.`,
|
||||
};
|
||||
}
|
||||
const meta = { name: alvo.name, language: alvo.language, parameterFormat: linha.parameter_format };
|
||||
const contrato = deriveTemplateContract({
|
||||
name: alvo.name,
|
||||
language: alvo.language,
|
||||
parameter_format: linha.parameter_format,
|
||||
components: linha.components as never,
|
||||
});
|
||||
if (contrato.slots.length > 0) {
|
||||
return {
|
||||
tipo: 'recusado',
|
||||
motivo: `O modelo "${alvo.name}" tem variáveis, e o fluxo não tem de onde tirar os valores. Use um modelo sem variáveis.`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
tipo: 'modelo_aprovado',
|
||||
body: renderTemplateBody(linha.components, {}, meta),
|
||||
modelo: { name: alvo.name, language: alvo.language, values: {} },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A janela de 24 h desta conversa está fechada? Mesmo insumo do gate da cadeia
|
||||
* (`readLastInboundAt` em before-send.ts: a conversa do contato NESTE número) e a
|
||||
* mesma conta (`estadoDaJanela`) — decidir aqui com outra régua faria o plano B
|
||||
* sair quando a cadeia deixaria a IA passar, ou o contrário.
|
||||
*/
|
||||
async function janelaFechada(pool: pg.Pool, target: ReentrySendTarget, agora: Date): Promise<boolean> {
|
||||
const { rows } = await pool.query<{ provider: string | null; last_inbound_at: Date | null }>(
|
||||
`select s.provider,
|
||||
(select c.last_inbound_at from conversations c
|
||||
where c.organization_id = s.organization_id and c.contact_id = $3
|
||||
and c.channel_session_id = s.id
|
||||
order by c.last_inbound_at desc nulls last
|
||||
limit 1) as last_inbound_at
|
||||
from channel_sessions s
|
||||
where s.organization_id = $1 and s.id = $2`,
|
||||
[target.tenantId, target.channelSessionId, target.leadId],
|
||||
);
|
||||
const linha = rows[0];
|
||||
if (linha === undefined) return false;
|
||||
const ultimo = linha.last_inbound_at === null ? null : new Date(linha.last_inbound_at).toISOString();
|
||||
return estadoDaJanela(linha.provider, ultimo, agora).tipo === 'fechada';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -630,6 +767,12 @@ async function sendFixedOutbound(
|
||||
body: string,
|
||||
/** `true` só na re-entrada por template — ver o cabeçalho. */
|
||||
comCamadaSemantica: boolean,
|
||||
/**
|
||||
* Presente = `body` é um modelo APROVADO do canal, já renderizado. A cadeia inteira
|
||||
* continua valendo (stop, LGPD, horário); só o gate da janela de 24 h o deixa
|
||||
* passar, que é o que um modelo aprovado é — como no `send_template` do agente.
|
||||
*/
|
||||
modelo?: { name: string; language: string; values: Record<string, string> },
|
||||
): Promise<EnvioFixoDesfecho> {
|
||||
const { tenantId, leadId, channelSessionId, conversationId } = target;
|
||||
const runLog = withFields(deps.log, { job_id: job.id, tenant_id: tenantId, lead_id: leadId });
|
||||
@@ -674,6 +817,7 @@ async function sendFixedOutbound(
|
||||
now: clock(),
|
||||
sleep: deps.sleep,
|
||||
lgpd: context.lgpd,
|
||||
...(modelo !== undefined ? { isTemplate: true } : {}),
|
||||
...(deps.knobs.disclosureMode !== undefined ? { disclosureMode: deps.knobs.disclosureMode } : {}),
|
||||
...(camadaSemanticaLigada
|
||||
? {
|
||||
@@ -687,7 +831,11 @@ async function sendFixedOutbound(
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
send: (finalBody) => channel.send({ tenantId, leadId, jobId: job.id, jobClaim:claimOfJob(job), seq: 1, conversationId, body: finalBody }),
|
||||
send: (finalBody) =>
|
||||
channel.send({
|
||||
tenantId, leadId, jobId: job.id, jobClaim: claimOfJob(job), seq: 1, conversationId, body: finalBody,
|
||||
...(modelo !== undefined ? { template: modelo } : {}),
|
||||
}),
|
||||
});
|
||||
|
||||
if (chain.status === 'vetoed') {
|
||||
|
||||
+56
-2
@@ -49,6 +49,8 @@ import {
|
||||
type AvisoRecuperacaoEsgotada,
|
||||
} from "./no-show-recuperacao-esgotada";
|
||||
import { interpolarDestino, persistirRespostaFollowupSupabase } from "./persistir-resposta";
|
||||
import { quandoDoRetornoVivo, reavaliarDepoisDoRetorno } from "./retorno-segura-o-fluxo";
|
||||
import { triggerConfigSchema } from "./api-schemas";
|
||||
|
||||
const MAX_STEPS = 80;
|
||||
const CLAIM_LEASE_SECONDS = 120;
|
||||
@@ -82,9 +84,19 @@ export interface FollowupJobRequest {
|
||||
purpose: "send_message" | "classify" | "plan_timing";
|
||||
/** action (mode 'ai_message') — Task 5.1: repassado ao turno pra virar o bloco de orientação. */
|
||||
prompt_hint?: string;
|
||||
/**
|
||||
* action (mode 'ai_message') — modelo APROVADO do canal (`meta_templates.id`) que
|
||||
* sai no lugar da IA quando a janela de 24 h da conversa já fechou. Sem ele, fora
|
||||
* da janela o canal recusa o texto livre e o passo não manda nada.
|
||||
*/
|
||||
fallback_template_id?: string;
|
||||
/** action (mode 'text') — corpo pronto; o turno envia sem chamar o modelo. */
|
||||
fixed_body?: string;
|
||||
/** action (mode 'template') — id em `message_templates`; o turno carrega o corpo e envia sem modelo. */
|
||||
/**
|
||||
* action (mode 'template') — id em `message_templates` (texto pronto) OU em
|
||||
* `meta_templates` (modelo aprovado do canal, o único que sai com a janela de 24 h
|
||||
* fechada). O turno resolve qual dos dois é e envia sem modelo de IA.
|
||||
*/
|
||||
template_id?: string;
|
||||
volta_index?: number;
|
||||
volta_total?: number;
|
||||
@@ -102,6 +114,11 @@ export interface FollowupJobRequest {
|
||||
export interface AdminClient {
|
||||
assertServiceBoundary?(enrollment: EnrollmentRow): Promise<void>;
|
||||
assertAgenda?(enrollment:EnrollmentRow):Promise<void>;
|
||||
/**
|
||||
* Até quando um retorno agendado segura esta inscrição (ISO), ou `null` quando
|
||||
* nada a segura. Só fluxo de silêncio — ver `retorno-segura-o-fluxo.ts`.
|
||||
*/
|
||||
retornoQueSeguraOFluxo?(enrollment: EnrollmentRow): Promise<string | null>;
|
||||
claimDueEnrollments(limit: number, leaseSeconds: number): Promise<EnrollmentRow[]>;
|
||||
loadFlowGraph(orgId: string, versionId: string): Promise<FlowGraph | null>;
|
||||
loadLeadFacts(orgId: string, contactId: string): Promise<{
|
||||
@@ -244,7 +261,10 @@ function turnPayloadExtras(
|
||||
events: EnrollmentEventRef[] = [],
|
||||
): Partial<FollowupJobRequest["payload"]> {
|
||||
if (node.type === "action" && node.config.mode === "ai_message") {
|
||||
return { prompt_hint: interpolarVolta(node.config.prompt_hint, events) };
|
||||
return {
|
||||
prompt_hint: interpolarVolta(node.config.prompt_hint, events),
|
||||
...(node.config.fallback_template_id ? { fallback_template_id: node.config.fallback_template_id } : {}),
|
||||
};
|
||||
}
|
||||
if (node.type === "action" && node.config.mode === "text") {
|
||||
return { fixed_body: interpolarVolta(node.config.body, events) };
|
||||
@@ -577,6 +597,26 @@ async function processEnrollment(
|
||||
return;
|
||||
}
|
||||
|
||||
// O RETORNO AGENDADO FALA PRIMEIRO. Com um "te escrevo no dia 30" a caminho, o
|
||||
// fluxo de silêncio não insiste por cima: fica segurado até um dia depois do
|
||||
// retorno. Resposta do cliente passa (quem reage ao que ele disse não é
|
||||
// insistência) — e, no fluxo de silêncio, é o `cancel_on_reply` que decide.
|
||||
if (inboundBodyOverride === undefined) {
|
||||
const seguraAte = await db.retornoQueSeguraOFluxo?.(enrollment);
|
||||
if (seguraAte) {
|
||||
await db.insertEnrollmentEvent({
|
||||
organization_id: enrollment.organization_id,
|
||||
enrollment_id: enrollment.id,
|
||||
node_id: enrollment.current_node_id,
|
||||
event_type: "held_by_return",
|
||||
payload: { next_eval_at: seguraAte },
|
||||
idempotency_key: `${enrollment.current_node_id}:${enrollment.steps_taken}:retorno:${seguraAte}`,
|
||||
});
|
||||
await db.updateEnrollment(enrollment.id, enrollment.organization_id, { next_eval_at: seguraAte, claimed_until: null });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (enrollment.steps_taken > MAX_STEPS) {
|
||||
await markDead(db, clock, enrollment, "max_steps");
|
||||
@@ -781,6 +821,20 @@ export function createSupabaseAdminClient(admin: SupabaseClient): AdminClient {
|
||||
const revisions=new Map<string,number>();
|
||||
return {
|
||||
async assertServiceBoundary(enrollment) { if(!revisions.has(enrollment.id) && enrollment.revision!==undefined) revisions.set(enrollment.id,enrollment.revision); await assertServiceBoundarySupabase(admin, enrollment.service_boundary ?? null); },
|
||||
async retornoQueSeguraOFluxo(enrollment) {
|
||||
const quando = await quandoDoRetornoVivo(admin, enrollment.organization_id, enrollment.contact_id);
|
||||
if (quando === null) return null;
|
||||
const { data, error } = await admin
|
||||
.from("followup_flow_pointers")
|
||||
.select("trigger_config")
|
||||
.eq("organization_id", enrollment.organization_id)
|
||||
.eq("id", enrollment.pointer_id)
|
||||
.maybeSingle();
|
||||
if (error) throw new Error(`pointer_query_failed: ${error.message}`);
|
||||
const gatilho = triggerConfigSchema.safeParse((data as { trigger_config?: unknown } | null)?.trigger_config);
|
||||
if (!gatilho.success || gatilho.data.kind !== "silence") return null;
|
||||
return reavaliarDepoisDoRetorno(quando);
|
||||
},
|
||||
async assertAgenda(enrollment){await assertAgendaEffectSupabase(admin,{organizationId:enrollment.organization_id,contactId:enrollment.contact_id,enrollmentId:enrollment.id,nodeId:enrollment.current_node_id});},
|
||||
async claimDueEnrollments(limit, leaseSeconds) {
|
||||
const { data, error } = await admin.rpc("fn_claim_due_followup_enrollments", {
|
||||
|
||||
@@ -391,6 +391,17 @@ export function descreveEvento(
|
||||
...motor,
|
||||
};
|
||||
}
|
||||
case "held_by_return": {
|
||||
// Como o adiamento pela janela: segurar NÃO é falhar. Sem esta linha o
|
||||
// operador veria o fluxo parado por dias sem saber que ele está esperando
|
||||
// o retorno que o agente combinou com o cliente.
|
||||
const ate = quandoLegivel(p.next_eval_at, idioma);
|
||||
return {
|
||||
titulo: "Segurou o fluxo por causa de um retorno agendado",
|
||||
detalhe: ate ? `volta a andar em ${ate}, um dia depois do retorno` : null,
|
||||
...motor,
|
||||
};
|
||||
}
|
||||
case "action_sent":
|
||||
return { titulo: "Mensagem enviada", detalhe: null, ...motor };
|
||||
case "ai_classified":
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { modelosQueOFluxoEnvia, type LinhaDeModeloDoCanal } from "./modelos-aprovados";
|
||||
|
||||
function linha(id: string, status: string, texto: string): LinhaDeModeloDoCanal {
|
||||
return {
|
||||
id,
|
||||
name: `modelo_${id}`,
|
||||
language: "es",
|
||||
status,
|
||||
parameter_format: "POSITIONAL",
|
||||
components: [
|
||||
{ type: "BODY", text: texto },
|
||||
{ type: "BUTTONS", buttons: [{ type: "QUICK_REPLY", text: "Sí" }] },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe("modelos que um passo de fluxo consegue mandar sozinho", () => {
|
||||
it("⭐ só aprovado e sem variável — o que o turno do fluxo depois pula não é oferecido", () => {
|
||||
const lista = modelosQueOFluxoEnvia([
|
||||
linha("a", "APPROVED", "Sale ₲125.000. ¿Te lo reservamos?"),
|
||||
linha("b", "PENDING", "ainda em análise"),
|
||||
linha("c", "APPROVED", "Hola {{1}}, ¿seguís interesado?"),
|
||||
linha("d", "REJECTED", "recusado"),
|
||||
]);
|
||||
expect(lista.map((m) => m.id)).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("devolve o texto que o cliente lê, para escolher pelo conteúdo", () => {
|
||||
const [m] = modelosQueOFluxoEnvia([linha("a", "APPROVED", "Sale ₲125.000.")]);
|
||||
expect(m).toEqual({ id: "a", name: "modelo_a", language: "es", texto: "Sale ₲125.000." });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Os modelos APROVADOS do canal que um passo de fluxo consegue mandar sozinho.
|
||||
*
|
||||
* O construtor de fluxo só oferecia os textos prontos de `message_templates` — e
|
||||
* texto livre é justamente o que o canal oficial recusa quando a janela de 24 h
|
||||
* fechou, que é quando um fluxo de reengajamento mais precisa falar. Esta é a
|
||||
* lista que falta: o que a plataforma aprovou, NESTA organização.
|
||||
*
|
||||
* Duas exclusões, pelas mesmas razões que o turno do fluxo recusa o envio
|
||||
* (`resolveModeloAprovado` em `lib/agent-engine/agent/followup-turn.ts`) — a
|
||||
* tela não oferece o que o motor depois pula:
|
||||
* - status que não dispara (pendente, rejeitado, pausado);
|
||||
* - modelo com variável: o fluxo não tem de onde tirar o valor.
|
||||
*/
|
||||
import { renderTemplateBody } from "@/lib/channels/meta/render-template";
|
||||
import { isStatusSendable } from "@/lib/channels/meta/template-binding";
|
||||
import { deriveTemplateContract } from "@/lib/channels/meta/template-contract";
|
||||
|
||||
export interface LinhaDeModeloDoCanal {
|
||||
id: string;
|
||||
name: string;
|
||||
language: string;
|
||||
status: string;
|
||||
parameter_format: string | null;
|
||||
components: unknown;
|
||||
}
|
||||
|
||||
export interface ModeloAprovadoDoFluxo {
|
||||
/** `meta_templates.id` — o que o passo grava em `template_id`/`fallback_template_id`. */
|
||||
id: string;
|
||||
name: string;
|
||||
language: string;
|
||||
/** O texto que o cliente lê, para a pessoa escolher pelo conteúdo e não pelo nome técnico. */
|
||||
texto: string;
|
||||
}
|
||||
|
||||
export function modelosQueOFluxoEnvia(linhas: LinhaDeModeloDoCanal[]): ModeloAprovadoDoFluxo[] {
|
||||
const saida: ModeloAprovadoDoFluxo[] = [];
|
||||
for (const l of linhas) {
|
||||
if (!isStatusSendable(l.status)) continue;
|
||||
const contrato = deriveTemplateContract({
|
||||
name: l.name,
|
||||
language: l.language,
|
||||
...(l.parameter_format ? { parameter_format: l.parameter_format } : {}),
|
||||
components: l.components as never,
|
||||
});
|
||||
if (contrato.slots.length > 0) continue;
|
||||
saida.push({
|
||||
id: l.id,
|
||||
name: l.name,
|
||||
language: l.language,
|
||||
texto: renderTemplateBody(l.components, {}, {
|
||||
name: l.name,
|
||||
language: l.language,
|
||||
...(l.parameter_format ? { parameterFormat: l.parameter_format } : {}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
return saida;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* O RETORNO AGENDADO SEGURA O FLUXO DE SILÊNCIO.
|
||||
*
|
||||
* ─── O defeito ─────────────────────────────────────────────────────────────
|
||||
*
|
||||
* Medido numa instalação real (25/09/2026): a cliente disse "só recebo o
|
||||
* salário no dia 30", o agente agendou o retorno para o dia 30 com
|
||||
* `schedule_followup` e respondeu "te escrevo no dia 30, sem pressa". Uma hora
|
||||
* depois, o fluxo de silêncio — que não sabia de retorno nenhum — voltou a
|
||||
* escrever pedindo os dados de entrega; no dia seguinte repetiria a oferta que
|
||||
* ela já tinha aceitado, e dois dias depois a "última oportunidade". O sistema
|
||||
* desmentia, sozinho, a promessa que ele mesmo tinha feito.
|
||||
*
|
||||
* ─── A regra ───────────────────────────────────────────────────────────────
|
||||
*
|
||||
* Com um retorno VIVO para o contato, o fluxo de silêncio não fala por cima:
|
||||
* - a varredura não inscreve o contato (`contatosComRetornoVivo`);
|
||||
* - a inscrição que já estava andando fica segurada até `FOLGA_DEPOIS_DO_RETORNO_MS`
|
||||
* depois do retorno (`quandoDoRetornoVivo` + `reavaliarDepoisDoRetorno`).
|
||||
* Se o cliente responder ao retorno, o `cancel_on_reply` do fluxo o encerra
|
||||
* como sempre; se não responder, o fluxo retoma de onde estava.
|
||||
*
|
||||
* Só o gatilho de SILÊNCIO: é ele que insiste com quem parou de falar, e é ele
|
||||
* que contradiz "te escrevo no dia 30". Fluxo por etapa ou por caso responde a um
|
||||
* FATO novo (o pedido foi confirmado, o caso foi aberto) e não deve esperar.
|
||||
*
|
||||
* ─── O que conta como retorno ──────────────────────────────────────────────
|
||||
*
|
||||
* Uma linha de `cron_jobs` `kind='at'`, `job_kind='followup_turn'`, agendada
|
||||
* (`enabled`, sem `cancelled_at` — ver `situacaoDoRetorno` em `retorno.ts`) e que
|
||||
* NÃO é o turno de um passo de fluxo. O motor de fluxo enfileira os próprios
|
||||
* passos na mesma tabela e no mesmo `job_kind`, com `followup_enrollment_id` no
|
||||
* payload; contá-los faria cada fluxo segurar a si mesmo.
|
||||
*/
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
|
||||
/**
|
||||
* Quanto o fluxo espera DEPOIS do retorno antes de retomar. Um dia: o tempo de a
|
||||
* pessoa ler e responder ao que foi prometido. Menos que isso, o fluxo emendaria
|
||||
* uma mensagem na outra no mesmo dia — o bombardeio que a regra existe para evitar.
|
||||
*/
|
||||
export const FOLGA_DEPOIS_DO_RETORNO_MS = 24 * 3_600_000;
|
||||
|
||||
export function reavaliarDepoisDoRetorno(quandoDoRetorno: string): string {
|
||||
return new Date(Date.parse(quandoDoRetorno) + FOLGA_DEPOIS_DO_RETORNO_MS).toISOString();
|
||||
}
|
||||
|
||||
function retornosVivos(admin: SupabaseClient, orgId: string) {
|
||||
return admin
|
||||
.from("cron_jobs")
|
||||
.select("contact_id, next_run_at")
|
||||
.eq("organization_id", orgId)
|
||||
.eq("kind", "at")
|
||||
.eq("job_kind", "followup_turn")
|
||||
.eq("enabled", true)
|
||||
.is("cancelled_at", null)
|
||||
.is("payload->>followup_enrollment_id", null);
|
||||
}
|
||||
|
||||
/** Contatos da organização com retorno vivo — a varredura de silêncio os pula. */
|
||||
export async function contatosComRetornoVivo(admin: SupabaseClient, orgId: string): Promise<Set<string>> {
|
||||
const { data, error } = await retornosVivos(admin, orgId);
|
||||
if (error) throw new Error(`retorno_vivo_query_failed: ${error.message}`);
|
||||
const ids = new Set<string>();
|
||||
for (const row of (data ?? []) as Array<{ contact_id: string | null }>) {
|
||||
if (row.contact_id) ids.add(row.contact_id);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Quando dispara o retorno vivo MAIS TARDIO do contato, ou `null` sem retorno.
|
||||
* O mais tardio porque é depois dele que o fluxo pode voltar a falar.
|
||||
*/
|
||||
export async function quandoDoRetornoVivo(
|
||||
admin: SupabaseClient,
|
||||
orgId: string,
|
||||
contactId: string,
|
||||
): Promise<string | null> {
|
||||
const { data, error } = await retornosVivos(admin, orgId)
|
||||
.eq("contact_id", contactId)
|
||||
.order("next_run_at", { ascending: false })
|
||||
.limit(1);
|
||||
if (error) throw new Error(`retorno_vivo_query_failed: ${error.message}`);
|
||||
const row = (data ?? [])[0] as { next_run_at: string } | undefined;
|
||||
return row?.next_run_at ?? null;
|
||||
}
|
||||
@@ -79,6 +79,7 @@ describe("runSilenceSweep", () => {
|
||||
{ id: "p-bom", organization_id: "org-2", active_version_id: "v2", threshold_minutes: 60, segments: [] },
|
||||
],
|
||||
loadSilentContactIds: async () => ["contato"],
|
||||
loadContatosComRetornoVivo: async () => new Set<string>(),
|
||||
loadTriggerNode: async () => ({ id: "t", pedeAgente: false }),
|
||||
insertEnrollment: insert,
|
||||
};
|
||||
|
||||
@@ -57,6 +57,7 @@ import {
|
||||
type FollowupGateDb,
|
||||
type NoDeGatilho,
|
||||
} from "./agent-followup-gate";
|
||||
import { contatosComRetornoVivo } from "./retorno-segura-o-fluxo";
|
||||
|
||||
export interface SilencePointer {
|
||||
id: string;
|
||||
@@ -72,6 +73,11 @@ export interface SilenceSweepDb {
|
||||
loadActiveSilencePointers(): Promise<SilencePointer[]>;
|
||||
/** Contact ids da org sem inbound desde `cutoffIso` (inclusive); `segments` vazio = todos. */
|
||||
loadSilentContactIds(orgId: string, cutoffIso: string, segments: string[]): Promise<string[]>;
|
||||
/**
|
||||
* Contatos com RETORNO agendado vivo — quem tem um "te escrevo no dia 30" a
|
||||
* caminho não entra no fluxo de silêncio. Ver `retorno-segura-o-fluxo.ts`.
|
||||
*/
|
||||
loadContatosComRetornoVivo(orgId: string): Promise<Set<string>>;
|
||||
/** Nó `trigger` do grafo pinado + se o fluxo pede agente; `null` se version/nó não existir. */
|
||||
loadTriggerNode(orgId: string, versionId: string): Promise<NoDeGatilho | null>;
|
||||
/** Insere o enrollment nascendo no nó trigger; `inserted:false` = 23505 (já vivo nesse pointer) → skip. */
|
||||
@@ -91,6 +97,8 @@ export interface SilenceSweepSummary {
|
||||
pointers_gated_out: number;
|
||||
enrolled: number;
|
||||
skipped_existing: number;
|
||||
/** Silenciosos que ficaram de fora porque já têm um retorno agendado. */
|
||||
skipped_pending_return: number;
|
||||
/**
|
||||
* Pointers que FALHARAM nesta varredura (logados e pulados). Um pointer ruim
|
||||
* — de uma empresa só — não pode calar a varredura de todas as outras: antes,
|
||||
@@ -112,6 +120,7 @@ export async function runSilenceSweep(deps: SilenceSweepDeps): Promise<SilenceSw
|
||||
pointers_gated_out: 0,
|
||||
enrolled: 0,
|
||||
skipped_existing: 0,
|
||||
skipped_pending_return: 0,
|
||||
pointers_failed: 0,
|
||||
};
|
||||
|
||||
@@ -154,8 +163,14 @@ export async function runSilenceSweep(deps: SilenceSweepDeps): Promise<SilenceSw
|
||||
const cutoffIso = new Date(clock().getTime() - pointer.threshold_minutes * 60_000).toISOString();
|
||||
const contactIds = await db.loadSilentContactIds(pointer.organization_id, cutoffIso, pointer.segments);
|
||||
const nextEvalAt = clock().toISOString();
|
||||
const comRetorno =
|
||||
contactIds.length > 0 ? await db.loadContatosComRetornoVivo(pointer.organization_id) : new Set<string>();
|
||||
|
||||
for (const contactId of contactIds) {
|
||||
if (comRetorno.has(contactId)) {
|
||||
summary.skipped_pending_return++;
|
||||
continue;
|
||||
}
|
||||
const { inserted } = await db.insertEnrollment({
|
||||
organization_id: pointer.organization_id,
|
||||
pointer_id: pointer.id,
|
||||
@@ -319,6 +334,10 @@ export function createSupabaseSilenceSweepDb(admin: SupabaseClient): SilenceSwee
|
||||
return silentIds;
|
||||
},
|
||||
|
||||
loadContatosComRetornoVivo(orgId) {
|
||||
return contatosComRetornoVivo(admin, orgId);
|
||||
},
|
||||
|
||||
async loadTriggerNode(orgId, versionId) {
|
||||
const { data, error } = await admin
|
||||
.from("followup_flow_versions")
|
||||
|
||||
+14
-1
@@ -2063,7 +2063,17 @@ export const DICIONARIO: Traducoes = {
|
||||
"Mensagem escrita pela IA": { es: "Mensaje escrito por la IA" },
|
||||
"Modelo de mensagem pronto": { es: "Plantilla de mensaje predefinida" },
|
||||
"Instrução para a IA": { es: "Instrucción para la IA" },
|
||||
"Se a IA não conseguir escrever, mandar este modelo": { es: "Si la IA no puede redactar el mensaje, enviar esta plantilla" },
|
||||
"Se a janela de 24 horas já tiver fechado, mandar este modelo aprovado no lugar da IA": {
|
||||
es: "Si la ventana de 24 horas ya se cerró, enviar esta plantilla aprobada en lugar de la IA",
|
||||
},
|
||||
"Nenhum modelo aprovado no WhatsApp ainda. Crie um em Conexões → Modelos e ele aparece aqui quando for aprovado.": {
|
||||
es: "Todavía no hay plantillas aprobadas en WhatsApp. Crea una en Conexiones → Plantillas y aparecerá aquí cuando se apruebe.",
|
||||
},
|
||||
"Textos prontos": { es: "Textos predefinidos" },
|
||||
"Aprovados no WhatsApp": { es: "Aprobadas en WhatsApp" },
|
||||
"Depois de 24 horas sem resposta do cliente, só um modelo aprovado no WhatsApp chega até ele.": {
|
||||
es: "Después de 24 horas sin respuesta del cliente, solo le llega una plantilla aprobada en WhatsApp.",
|
||||
},
|
||||
"Modelo de mensagem": { es: "Plantilla de mensaje" },
|
||||
"Nota (opcional)": { es: "Nota (opcional)" },
|
||||
"Fluxo reprovado na validação — corrija os nós destacados.": {
|
||||
@@ -6827,6 +6837,9 @@ export const DICIONARIO: Traducoes = {
|
||||
"Pediu ao agente para interpretar a resposta": { es: "Le pidió al agente que interpretara la respuesta" },
|
||||
"Conferiu se a mensagem já tinha saído": { es: "Verificó si el mensaje ya había salido" },
|
||||
"Mensagem enviada": { es: "Mensaje enviado" },
|
||||
"Segurou o fluxo por causa de um retorno agendado": {
|
||||
es: "Frenó el flujo por un regreso programado",
|
||||
},
|
||||
"O agente interpretou a resposta": { es: "El agente interpretó la respuesta" },
|
||||
"Fluxo concluído": { es: "Flujo concluido" },
|
||||
"O fluxo parou de tentar": { es: "El flujo dejó de intentarlo" },
|
||||
|
||||
@@ -137,6 +137,18 @@ function silenceSweepDb(): SilenceSweepDb {
|
||||
.filter((r) => segments.length === 0 || segments.some((s) => r.tags.includes(s)))
|
||||
.map((r) => r.contact_id);
|
||||
},
|
||||
// Mesma régua de `lib/followup/retorno-segura-o-fluxo.ts`: retorno agendado,
|
||||
// que não é o turno de um passo de fluxo.
|
||||
async loadContatosComRetornoVivo(orgId) {
|
||||
const { rows } = await pool.query<{ contact_id: string }>(
|
||||
`select contact_id from cron_jobs
|
||||
where organization_id = $1 and kind = 'at' and job_kind = 'followup_turn'
|
||||
and enabled and cancelled_at is null and contact_id is not null
|
||||
and payload->>'followup_enrollment_id' is null`,
|
||||
[orgId],
|
||||
);
|
||||
return new Set(rows.map((r) => r.contact_id));
|
||||
},
|
||||
async loadTriggerNode(orgId, versionId) {
|
||||
const { rows } = await pool.query<{ graph: FlowGraph }>(
|
||||
`select graph from followup_flow_versions where organization_id = $1 and id = $2`,
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* O PASSO DE FLUXO MANDA O MODELO APROVADO DO CANAL.
|
||||
*
|
||||
* ## O defeito
|
||||
*
|
||||
* O modo `template` do passo de ação só lia `message_templates` — os textos
|
||||
* prontos de Ajustes → Modelos. Texto livre é exatamente o que o canal oficial
|
||||
* recusa quando a janela de 24 h fechou, e é aí que um fluxo de reengajamento
|
||||
* fala. Um fluxo apontado para um modelo APROVADO (`meta_templates`) passava na
|
||||
* validação, era publicado, e morria no primeiro disparo com "o template_id do
|
||||
* passo não existe nesta organização". Medido numa instalação real: o passo
|
||||
* "Última oportunidade (plantilla)" de um remarketing nunca teria saído.
|
||||
*
|
||||
* O irmão do defeito: `fallback_template_id` da mensagem por IA ("se a IA não
|
||||
* conseguir escrever, mandar este modelo") era gravado, validado no publish e
|
||||
* nunca lido por ninguém em runtime.
|
||||
*
|
||||
* ## O que este arquivo prende
|
||||
*
|
||||
* - modelo aprovado sai COMO MODELO: a cadeia recebe `isTemplate` (só o gate da
|
||||
* janela o deixa passar) e o canal recebe nome e idioma;
|
||||
* - modelo que não pode sair (pendente, com variável) pula o passo com o motivo,
|
||||
* sem tocar na cadeia — em vez de a fila re-tentar até matar a inscrição;
|
||||
* - o plano B da IA sai com a janela FECHADA e só com ela: aberta, a IA escreve;
|
||||
* - controle: texto pronto continua saindo como texto.
|
||||
*
|
||||
* ## O que NÃO prova
|
||||
*
|
||||
* Nada com Postgres real nem com a plataforma: que o canal aceita o modelo é do
|
||||
* adapter (`sendTemplate`), não deste turno.
|
||||
*/
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { JobRow } from "@/lib/agent-engine/queue/queue";
|
||||
|
||||
const runBeforeSend = vi.fn(async (args: Record<string, unknown>) => {
|
||||
await (args.send as (b: string) => Promise<unknown>)(args.body as string);
|
||||
return { status: "sent", outcome: { kind: "sent" }, trace: [] };
|
||||
});
|
||||
vi.mock("@/lib/agent-engine/guardrails/before-send", () => ({ runBeforeSend }));
|
||||
|
||||
vi.mock("@/lib/agent-engine/agent/human-handoff", () => ({ isLeadInHandoff: vi.fn(async () => false) }));
|
||||
|
||||
vi.mock("@/lib/agent-engine/edge/crm/get-lead-context", () => ({
|
||||
getLeadContext: vi.fn(async () => ({
|
||||
ok: true,
|
||||
context: { contact: { is_blocked: false } },
|
||||
lgpd: { isAnonymized: false, isProspecting: false, legalBasis: {} },
|
||||
})),
|
||||
}));
|
||||
|
||||
const runAgentTurn = vi.fn(async () => undefined);
|
||||
vi.mock("@/lib/agent-engine/agent/inbound-turn", async (original) => ({
|
||||
...(await original<typeof import("@/lib/agent-engine/agent/inbound-turn")>()),
|
||||
runAgentTurn,
|
||||
}));
|
||||
vi.mock("@/lib/agent-engine/edge/crm/send-ledger", () => ({
|
||||
resultadoDoEnvioDoFollowup: vi.fn(async () => ({ kind: "sent" })),
|
||||
}));
|
||||
|
||||
const ORG = "org-1";
|
||||
const LEAD = "lead-1";
|
||||
const CONVERSA = "conversa-1";
|
||||
const CANAL = "canal-1";
|
||||
const MODELO_ID = "22222222-2222-4222-8222-222222222222";
|
||||
const HORA = 3_600_000;
|
||||
|
||||
const boundary = { organization_id: ORG, contact_id: LEAD, conversation_id: CONVERSA, service_revision: 1, demanda_id: null, demanda_revision: null };
|
||||
|
||||
function job(payload: Record<string, unknown>): JobRow {
|
||||
return {
|
||||
id: "job-1",
|
||||
organization_id: ORG,
|
||||
contact_id: LEAD,
|
||||
kind: "followup_turn",
|
||||
source_event_id: null,
|
||||
payload: {
|
||||
followup_enrollment_id: "11111111-1111-4111-8111-111111111111",
|
||||
node_id: "passo",
|
||||
purpose: "send_message",
|
||||
...payload,
|
||||
service_boundary: boundary,
|
||||
},
|
||||
status: "running",
|
||||
priority: 0,
|
||||
run_after: new Date(),
|
||||
attempts: 1,
|
||||
max_attempts: 3,
|
||||
last_error: null,
|
||||
locked_by: "w1",
|
||||
locked_at: new Date(),
|
||||
created_at: new Date(),
|
||||
} as JobRow;
|
||||
}
|
||||
|
||||
const CORPO = "¡Hola! ¿Seguís pensando en el Pico? Sale ₲125.000. ¿Te lo reservamos?";
|
||||
|
||||
interface Cenario {
|
||||
/** corpo em `message_templates` (texto pronto) */
|
||||
texto?: string;
|
||||
/** a linha do modelo do canal, ou ausente */
|
||||
modelo?: { status: string; texto?: string };
|
||||
/** último inbound da conversa, em horas atrás (`null` = nunca escreveu) */
|
||||
ultimoInboundHa?: number | null;
|
||||
}
|
||||
|
||||
function fakePool(c: Cenario) {
|
||||
const query = vi.fn(async (sql: string): Promise<{ rows: Array<Record<string, unknown>> }> => {
|
||||
if (sql.includes("d.fechada_em::text")) return { rows: [{ ...boundary, status: "open", demanda_fechada_em: null }] };
|
||||
// A ordem importa: a definição da conexão e a janela citam outras tabelas no corpo.
|
||||
if (/from meta_templates t/.test(sql)) {
|
||||
return c.modelo
|
||||
? { rows: [{ components: [{ type: "BODY", text: c.modelo.texto ?? CORPO }], parameter_format: "POSITIONAL", status: c.modelo.status }] }
|
||||
: { rows: [] };
|
||||
}
|
||||
if (/select name, language from meta_templates/.test(sql)) {
|
||||
return c.modelo ? { rows: [{ name: "recordatorio_pico", language: "es" }] } : { rows: [] };
|
||||
}
|
||||
if (/from message_templates/.test(sql)) return { rows: c.texto ? [{ body: c.texto }] : [] };
|
||||
if (/from channel_sessions s/.test(sql)) {
|
||||
const ha = c.ultimoInboundHa;
|
||||
return {
|
||||
rows: [{ provider: "zernio", last_inbound_at: ha === null || ha === undefined ? null : new Date(Date.now() - ha * HORA) }],
|
||||
};
|
||||
}
|
||||
if (/from conversations/.test(sql)) return { rows: [{ id: CONVERSA, channel_session_id: CANAL, archived_at: null }] };
|
||||
return { rows: [] };
|
||||
});
|
||||
return { query } as never;
|
||||
}
|
||||
|
||||
function deps() {
|
||||
const send = vi.fn(async (_input: Record<string, unknown>) => ({ ok: true }));
|
||||
const completeFollowupTurn = vi.fn(async () => undefined);
|
||||
const d = {
|
||||
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
crmCfg: {},
|
||||
llmCfg: {},
|
||||
knobs: {},
|
||||
channel: () => ({ send }),
|
||||
completeFollowupTurn,
|
||||
} as never;
|
||||
return { d, send, completeFollowupTurn };
|
||||
}
|
||||
|
||||
function resultado(completeFollowupTurn: ReturnType<typeof vi.fn>): { kind: string; reason?: string } {
|
||||
const entrada = (completeFollowupTurn.mock.calls[0] as unknown[] | undefined)?.[1] as { result: { kind: string; reason?: string } } | undefined;
|
||||
return entrada?.result ?? { kind: "(não chamou)" };
|
||||
}
|
||||
|
||||
let criarHandler: typeof import("@/lib/agent-engine/agent/followup-turn").createFollowupTurnHandler;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ createFollowupTurnHandler: criarHandler } = await import("@/lib/agent-engine/agent/followup-turn"));
|
||||
}, 60_000);
|
||||
|
||||
beforeEach(() => {
|
||||
runBeforeSend.mockClear();
|
||||
runAgentTurn.mockClear();
|
||||
});
|
||||
|
||||
describe("passo `template` apontado para um modelo aprovado do canal", () => {
|
||||
it("⭐ sai como MODELO: a cadeia sabe que é modelo e o canal recebe nome e idioma", async () => {
|
||||
const { d, send, completeFollowupTurn } = deps();
|
||||
await criarHandler(d)(job({ template_id: MODELO_ID }), fakePool({ modelo: { status: "APPROVED" }, ultimoInboundHa: 72 }), { workerId: "w1" });
|
||||
|
||||
expect(runBeforeSend).toHaveBeenCalledTimes(1);
|
||||
const cadeia = runBeforeSend.mock.calls[0]![0];
|
||||
expect(cadeia.isTemplate).toBe(true);
|
||||
expect(cadeia.body).toBe(CORPO);
|
||||
expect(send.mock.calls[0]![0].template).toEqual({ name: "recordatorio_pico", language: "es", values: {} });
|
||||
expect(resultado(completeFollowupTurn).kind).toBe("sent");
|
||||
});
|
||||
|
||||
it("modelo ainda em análise: o passo é pulado com o motivo, sem tocar na cadeia", async () => {
|
||||
const { d, completeFollowupTurn } = deps();
|
||||
await criarHandler(d)(job({ template_id: MODELO_ID }), fakePool({ modelo: { status: "PENDING" } }), { workerId: "w1" });
|
||||
|
||||
expect(runBeforeSend).not.toHaveBeenCalled();
|
||||
const r = resultado(completeFollowupTurn);
|
||||
expect(r.kind).toBe("skipped");
|
||||
expect(r.reason).toContain("PENDING");
|
||||
});
|
||||
|
||||
it("modelo com variável: pulado — o fluxo não tem de onde tirar o {{1}}", async () => {
|
||||
const { d, completeFollowupTurn } = deps();
|
||||
await criarHandler(d)(
|
||||
job({ template_id: MODELO_ID }),
|
||||
fakePool({ modelo: { status: "APPROVED", texto: "Hola {{1}}, ¿seguís interesado?" } }),
|
||||
{ workerId: "w1" },
|
||||
);
|
||||
|
||||
expect(runBeforeSend).not.toHaveBeenCalled();
|
||||
expect(resultado(completeFollowupTurn).kind).toBe("skipped");
|
||||
});
|
||||
|
||||
it("controle: texto pronto de Ajustes → Modelos continua saindo como TEXTO", async () => {
|
||||
const { d, send } = deps();
|
||||
await criarHandler(d)(job({ template_id: MODELO_ID }), fakePool({ texto: "oi, tudo bem?" }), { workerId: "w1" });
|
||||
|
||||
expect(runBeforeSend).toHaveBeenCalledTimes(1);
|
||||
expect(runBeforeSend.mock.calls[0]![0].isTemplate).toBeUndefined();
|
||||
expect(send.mock.calls[0]![0].template).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("plano B da mensagem por IA (`fallback_template_id`)", () => {
|
||||
const PASSO_IA = { prompt_hint: "Retomá la charla", fallback_template_id: MODELO_ID };
|
||||
|
||||
it("⭐ janela de 24 h FECHADA: sai o modelo aprovado e a IA não é chamada", async () => {
|
||||
const { d, send, completeFollowupTurn } = deps();
|
||||
await criarHandler(d)(job(PASSO_IA), fakePool({ modelo: { status: "APPROVED" }, ultimoInboundHa: 30 }), { workerId: "w1" });
|
||||
|
||||
expect(runAgentTurn).not.toHaveBeenCalled();
|
||||
expect(runBeforeSend.mock.calls[0]![0].isTemplate).toBe(true);
|
||||
expect(send.mock.calls[0]![0].template).toEqual({ name: "recordatorio_pico", language: "es", values: {} });
|
||||
expect(resultado(completeFollowupTurn).kind).toBe("sent");
|
||||
});
|
||||
|
||||
it("⭐ janela ABERTA: a IA escreve, como sempre — o plano B não passa na frente", async () => {
|
||||
const { d } = deps();
|
||||
await criarHandler(d)(job(PASSO_IA), fakePool({ modelo: { status: "APPROVED" }, ultimoInboundHa: 2 }), { workerId: "w1" });
|
||||
|
||||
expect(runAgentTurn).toHaveBeenCalledTimes(1);
|
||||
expect(runBeforeSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("plano B apontado para texto pronto (legado): segue para a IA, que é o que sempre fez", async () => {
|
||||
// Texto livre seria recusado pela mesma janela fechada — trocá-lo pela IA
|
||||
// não perderia nada, e pular a IA por ele seria regressão.
|
||||
const { d } = deps();
|
||||
await criarHandler(d)(job(PASSO_IA), fakePool({ texto: "oi", ultimoInboundHa: 30 }), { workerId: "w1" });
|
||||
|
||||
expect(runAgentTurn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* O FLUXO DE SILÊNCIO NÃO FALA POR CIMA DE UM RETORNO AGENDADO.
|
||||
*
|
||||
* ## O defeito (medido numa instalação real, 25/09/2026)
|
||||
*
|
||||
* A cliente avisou que só compraria no dia 30, quando recebe o salário. O agente
|
||||
* agendou o retorno (`schedule_followup`) e respondeu "te escrevo no dia 30, sem
|
||||
* pressa". Uma hora depois o fluxo de silêncio a inscreveu de novo e mandou
|
||||
* pedido de dados; estavam na fila a oferta que ela já tinha aceitado (dia
|
||||
* seguinte) e a "última oportunidade" (dia 28). Nada ligava o retorno ao fluxo.
|
||||
*
|
||||
* ## O que este arquivo prende
|
||||
*
|
||||
* - a varredura de silêncio não inscreve quem tem retorno vivo (e inscreve os demais);
|
||||
* - a inscrição que já andava fica SEGURADA até a data que a regra devolve, com
|
||||
* um evento legível no dossiê — sem enfileirar o envio;
|
||||
* - controle: sem retorno, o mesmo passo enfileira o envio.
|
||||
*
|
||||
* ## O que NÃO prova
|
||||
*
|
||||
* As consultas reais (`retorno-segura-o-fluxo.ts` sobre o PostgREST) — a régua da
|
||||
* consulta foi conferida contra um banco de produção ao escrever o conserto, e o
|
||||
* espelho em SQL puro mora no invariante da varredura (`test:db`).
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { runFollowupTick, type AdminClient, type FollowupJobRequest } from "@/lib/followup/engine";
|
||||
import type { FlowGraph } from "@/lib/followup/graph-schema";
|
||||
import type { EnrollmentRow } from "@/lib/followup/node-handlers";
|
||||
import { FOLGA_DEPOIS_DO_RETORNO_MS, reavaliarDepoisDoRetorno } from "@/lib/followup/retorno-segura-o-fluxo";
|
||||
import { runSilenceSweep, type SilenceSweepDb } from "@/lib/followup/silence-sweep";
|
||||
|
||||
const AGORA = new Date("2026-09-25T15:00:00.000Z");
|
||||
const RETORNO = "2026-09-30T12:00:00.000Z";
|
||||
|
||||
describe("varredura de silêncio", () => {
|
||||
function sweepDb(comRetorno: Set<string>) {
|
||||
const insert = vi.fn(async () => ({ inserted: true }));
|
||||
const db: SilenceSweepDb = {
|
||||
loadActiveSilencePointers: async () => [
|
||||
{ id: "ptr", organization_id: "org", active_version_id: "v1", threshold_minutes: 60, segments: [] },
|
||||
],
|
||||
loadSilentContactIds: async () => ["clara", "outro"],
|
||||
loadContatosComRetornoVivo: async () => comRetorno,
|
||||
loadTriggerNode: async () => ({ id: "inicio", pedeAgente: false }),
|
||||
insertEnrollment: insert,
|
||||
};
|
||||
return { db, insert };
|
||||
}
|
||||
const gateDb = { loadEnabledPublishedFollowupAgents: async () => [] };
|
||||
|
||||
it("⭐ quem tem retorno agendado fica de fora; os demais silenciosos entram", async () => {
|
||||
const { db, insert } = sweepDb(new Set(["clara"]));
|
||||
const resumo = await runSilenceSweep({ db, gateDb, clock: () => AGORA });
|
||||
|
||||
expect(insert).toHaveBeenCalledTimes(1);
|
||||
expect(insert).toHaveBeenCalledWith(expect.objectContaining({ contact_id: "outro" }));
|
||||
expect(resumo.skipped_pending_return).toBe(1);
|
||||
expect(resumo.enrolled).toBe(1);
|
||||
});
|
||||
|
||||
it("controle: sem retorno nenhum, os dois entram", async () => {
|
||||
const { db, insert } = sweepDb(new Set());
|
||||
await runSilenceSweep({ db, gateDb, clock: () => AGORA });
|
||||
expect(insert).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("inscrição que já andava", () => {
|
||||
const GRAFO: FlowGraph = {
|
||||
nodes: [
|
||||
{ id: "oferta", type: "action", label: "Oferta", position: { x: 0, y: 0 }, config: { mode: "text", body: "oi" } },
|
||||
{ id: "fim", type: "end", label: "Fim", position: { x: 0, y: 0 }, config: { outcome: "exhausted" } },
|
||||
],
|
||||
edges: [{ id: "oferta-fim", source: "oferta", target: "fim", priority: 0, condition: { type: "always" } }],
|
||||
};
|
||||
|
||||
function loja(seguraAte: string | null) {
|
||||
const enrollment: EnrollmentRow = {
|
||||
id: "enr-1",
|
||||
organization_id: "org",
|
||||
pointer_id: "ptr",
|
||||
version_id: "v1",
|
||||
contact_id: "clara",
|
||||
conversation_id: null,
|
||||
current_node_id: "oferta",
|
||||
status: "active",
|
||||
next_eval_at: AGORA.toISOString(),
|
||||
claimed_until: null,
|
||||
attempts: 0,
|
||||
max_attempts: 5,
|
||||
last_error: null,
|
||||
steps_taken: 3,
|
||||
outcome: null,
|
||||
cancel_reason: null,
|
||||
started_at: AGORA.toISOString(),
|
||||
completed_at: null,
|
||||
updated_at: AGORA.toISOString(),
|
||||
};
|
||||
const eventos: Array<{ event_type: string; payload: Record<string, unknown> }> = [];
|
||||
const jobs: FollowupJobRequest[] = [];
|
||||
const db: AdminClient = {
|
||||
retornoQueSeguraOFluxo: async () => seguraAte,
|
||||
async claimDueEnrollments() {
|
||||
return enrollment.next_eval_at !== null && Date.parse(enrollment.next_eval_at) <= AGORA.getTime()
|
||||
? [{ ...enrollment }]
|
||||
: [];
|
||||
},
|
||||
loadFlowGraph: async () => GRAFO,
|
||||
loadLeadFacts: async () => ({ lead_stage: null, tags: [] }),
|
||||
loadEnrollmentEvents: async () => [],
|
||||
loadLastInboundBody: async () => null,
|
||||
async insertEnrollmentEvent(e) {
|
||||
eventos.push({ event_type: e.event_type, payload: e.payload });
|
||||
return { inserted: true };
|
||||
},
|
||||
async updateEnrollment(_id, _org, patch) {
|
||||
Object.assign(enrollment, patch);
|
||||
},
|
||||
loadFlowPointerName: async () => "Remarketing",
|
||||
insertDeadInboxItem: async () => undefined,
|
||||
persistirRespostaFollowup: async () => undefined,
|
||||
};
|
||||
const tick = () => runFollowupTick({ db, clock: () => AGORA, enqueueJob: async (j) => void jobs.push(j) });
|
||||
return { enrollment, eventos, jobs, tick };
|
||||
}
|
||||
|
||||
it("⭐ fica segurada até a data da regra: nada vai para a fila e o dossiê diz por quê", async () => {
|
||||
const ate = reavaliarDepoisDoRetorno(RETORNO);
|
||||
const l = loja(ate);
|
||||
await l.tick();
|
||||
|
||||
expect(l.jobs).toHaveLength(0);
|
||||
expect(l.enrollment.next_eval_at).toBe(ate);
|
||||
expect(l.enrollment.current_node_id).toBe("oferta");
|
||||
expect(l.eventos.map((e) => e.event_type)).toEqual(["held_by_return"]);
|
||||
});
|
||||
|
||||
it("controle: sem retorno, o mesmo passo enfileira o envio", async () => {
|
||||
const l = loja(null);
|
||||
await l.tick();
|
||||
expect(l.jobs).toHaveLength(1);
|
||||
expect(l.eventos.map((e) => e.event_type)).not.toContain("held_by_return");
|
||||
});
|
||||
|
||||
it("a regra devolve um dia depois do retorno — o tempo de a pessoa responder", () => {
|
||||
expect(Date.parse(reavaliarDepoisDoRetorno(RETORNO)) - Date.parse(RETORNO)).toBe(FOLGA_DEPOIS_DO_RETORNO_MS);
|
||||
expect(FOLGA_DEPOIS_DO_RETORNO_MS).toBe(24 * 3_600_000);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user