feat(followup): enhance inbound message handling and tests

- Updated `inboundEhDestaPergunta` to utilize `enrollment.updated_at` for better message filtering.
- Modified `ultimoInboundDoContato` to return both message text and sent timestamp.
- Adjusted `aplicarTextoAosEnrollmentsEmEspera` to apply messages conditionally based on the new timestamp logic.
- Refactored tests to cover new behavior, ensuring proper handling of inbound messages without reusing old text.
- Improved test descriptions for clarity and added checks for message application logic.
This commit is contained in:
Ian Couto
2026-08-26 14:23:47 -03:00
parent 98adcf4afb
commit 0fda528f1d
2 changed files with 52 additions and 17 deletions
+16 -5
View File
@@ -29,10 +29,21 @@ describe("inboundEhDestaPergunta", () => {
});
});
describe("aplicarTextoNosFollowups — segunda passada", () => {
it("reaplica waiting_reply depois de avançar active (corrida cap_nome)", () => {
const fonte = readFileSync(join(process.cwd(), "lib/followup/aplicar-inbound.ts"), "utf8");
const chamadas = fonte.match(/await aplicarTextoAosEnrollmentsEmEspera\(/g) ?? [];
expect(chamadas.length).toBe(2);
describe("aplicarTextoNosFollowups — uma mensagem, uma pergunta", () => {
const fonte = () => readFileSync(join(process.cwd(), "lib/followup/aplicar-inbound.ts"), "utf8");
it("filtra waiting_reply com inboundEhDestaPergunta (não reaproveita texto velho)", () => {
const src = fonte();
expect(src).toMatch(/inboundEhDestaPergunta\(enviadaEm, enrollment\.updated_at\)/);
expect(src).toMatch(/if \(!enviadaEm \|\| !inboundEhDestaPergunta/);
});
it("não tem 2ª passada cega fora do loop (regressão: confirm + endereço no mesmo request)", () => {
const src = fonte();
// Uma chamada só, dentro do for — a 2ª passada solta reaplicava o texto
// que enfileirou a confirmação de nome como se fosse o SIM.
const chamadas = src.match(/await aplicarTextoAosEnrollmentsEmEspera\(/g) ?? [];
expect(chamadas.length).toBe(1);
expect(src).toMatch(/for \(let i = 0; i < 6; i\+\+\) \{[\s\S]*aplicarTextoAosEnrollmentsEmEspera/);
});
});
+36 -12
View File
@@ -49,19 +49,24 @@ function tickDepsDe(admin: SupabaseClient): TickDeps {
/**
* Uma mensagem do lead vale para UM `match_reply`. Reaplicar o mesmo texto
* no passo seguinte (endereço, motivo…) pula a pergunta.
*
* `esperaDesde` = `enrollment.updated_at` no momento em que o nó estacionou
* em `waiting_reply` (confirmação de nome, pergunta de endereço, etc.).
*/
export function inboundEhDestaPergunta(enviadaEm: string, esperaDesde: string): boolean {
return enviadaEm >= esperaDesde;
}
type InboundResolvido = { texto: string; enviadaEm: string | null };
async function ultimoInboundDoContato(
admin: SupabaseClient,
orgId: string,
contactIds: string[],
): Promise<string> {
): Promise<InboundResolvido> {
const { data, error } = await admin
.from("messages")
.select("body")
.select("body, sent_at")
.eq("organization_id", orgId)
.in("contact_id", contactIds)
.eq("direction", "inbound")
@@ -69,7 +74,9 @@ async function ultimoInboundDoContato(
.limit(1)
.maybeSingle();
if (error) throw new Error(error.message);
return typeof data?.body === "string" ? data.body.trim() : "";
const texto = typeof data?.body === "string" ? data.body.trim() : "";
const enviadaEm = typeof data?.sent_at === "string" ? data.sent_at : null;
return { texto, enviadaEm };
}
async function aplicarTextoAosEnrollmentsEmEspera(
@@ -77,8 +84,9 @@ async function aplicarTextoAosEnrollmentsEmEspera(
orgId: string,
contactIds: string[],
texto: string,
enviadaEm: string | null,
deps: TickDeps,
): Promise<void> {
): Promise<number> {
const { data, error } = await admin
.from("followup_enrollments")
.select("*")
@@ -86,9 +94,16 @@ async function aplicarTextoAosEnrollmentsEmEspera(
.in("contact_id", contactIds)
.eq("status", "waiting_reply");
if (error) throw new Error(error.message);
let aplicados = 0;
for (const row of data ?? []) {
await aplicarRespostaInbound(deps, row as EnrollmentRow, texto);
const enrollment = row as EnrollmentRow;
// Sem sent_at não dá pra saber se a mensagem é desta pergunta — fail-closed
// (igual ao relógio): não avança com texto velho.
if (!enviadaEm || !inboundEhDestaPergunta(enviadaEm, enrollment.updated_at)) continue;
await aplicarRespostaInbound(deps, enrollment, texto);
aplicados++;
}
return aplicados;
}
export async function aplicarTextoNosFollowups(
@@ -96,12 +111,24 @@ export async function aplicarTextoNosFollowups(
sinal: SinalDeInboundFollowup,
): Promise<void> {
const contactIds = await idsDoContatoEGemeos(admin, sinal.organizationId, sinal.contactId);
const texto = (sinal.texto?.trim() || (await ultimoInboundDoContato(admin, sinal.organizationId, contactIds))).trim();
const ultimo = await ultimoInboundDoContato(admin, sinal.organizationId, contactIds);
const texto = (sinal.texto?.trim() || ultimo.texto).trim();
if (!texto) return;
const enviadaEm = ultimo.enviadaEm;
const deps = tickDepsDe(admin);
// 1ª passada: resposta chegou com o enrollment já em waiting_reply.
await aplicarTextoAosEnrollmentsEmEspera(admin, sinal.organizationId, contactIds, texto, deps);
// Apply dentro do loop (não numa 2ª passada cega): a mensagem que ENFILEIROU
// a confirmação de nome não pode responder a essa confirmação no mesmo request.
// `inboundEhDestaPergunta` exige sent_at >= updated_at do waiting_reply.
for (let i = 0; i < 6; i++) {
const aplicados = await aplicarTextoAosEnrollmentsEmEspera(
admin,
sinal.organizationId,
contactIds,
texto,
enviadaEm,
deps,
);
const agora = new Date().toISOString();
const { data: vivos, error: vivosErr } = await admin
.from("followup_enrollments")
@@ -116,9 +143,6 @@ export async function aplicarTextoNosFollowups(
await avancarEnrollmentAtivo(deps, row as EnrollmentRow);
}
const enviados = await enviarTextoFixoPendente(admin, contactIds);
if (!(vivos?.length) && !enviados) break;
if (!aplicados && !(vivos?.length) && !enviados) break;
}
// 2ª passada: o SIM pode ter chegado enquanto o nó ainda era `active` (ex.:
// cap_nome enfileirando a pergunta de confirmação neste mesmo request).
await aplicarTextoAosEnrollmentsEmEspera(admin, sinal.organizationId, contactIds, texto, deps);
}