Merge pull request #8 from IanCouto/chore/vercel-pro-crons

feat: add enviarTextoFixoPendente to followup-flow-worker cron job
This commit is contained in:
Ian Couto
2026-09-13 11:00:02 -03:00
committed by GitHub
2 changed files with 43 additions and 3 deletions
+20 -3
View File
@@ -7,14 +7,20 @@
* follow-up. Trigger Postgres NUNCA faz HTTP; este cron TS é quem consome via
* admin client, no mesmo contrato dos demais crons.
*
* Depois do tick, roda `runSilenceSweep` (lib/followup/silence-sweep.ts) NO
* MESMO tick — gatilho TIME-DRIVEN (varredura periódica, não event-driven):
* acha pointers `trigger_config.kind='silence'` ativos, gateia via
* Depois do tick, `runSilenceSweep` (lib/followup/silence-sweep.ts) NO MESMO
* tick — gatilho TIME-DRIVEN (varredura periódica, não event-driven): acha
* pointers `trigger_config.kind='silence'` ativos, gateia via
* `isPointerEnabledForAutomaticTrigger` (só enrolla se algum agente publicado
* da org tem o pointer habilitado), acha contatos silenciosos e cria
* enrollment. Falha do sweep NUNCA aborta a resposta do tick (try/catch
* isolado, só loga) — o cron sempre devolve o resultado de `runFollowupTick`.
*
* No fim, drena texto fixo pendente (`enviarTextoFixoPendente`) — o mesmo
* atalho do relógio HTTP. Na Vercel não há `agent-worker`; sem isto o job
* `followup_turn` fica `pending` e o no_reply nunca vira mensagem. O ledger
* (job_id, seq) impede envio em dobro no self-host, onde o worker também
* consome a fila.
*
* Auth: Bearer INTERNAL_CRON_SECRET|INTERNAL_SECRET, fail-closed. Audit
* agregada por tick (`followup.worker_run` + `followup.silence_sweep_run`),
* sem organization_id (roda pra todas as orgs).
@@ -29,6 +35,7 @@ import { logger } from "@/lib/logger";
import { createAdminClient } from "@/lib/supabase/admin";
import { createSupabaseAdminClient, runFollowupTick, type FollowupJobRequest } from "@/lib/followup/engine";
import { createSupabaseFollowupGateDb } from "@/lib/followup/agent-followup-gate";
import { enviarTextoFixoPendente } from "@/lib/followup/enviar-texto-fixo";
import { createSupabaseSilenceSweepDb, runSilenceSweep } from "@/lib/followup/silence-sweep";
export const dynamic = "force-dynamic";
@@ -133,6 +140,16 @@ async function handle(req: NextRequest): Promise<Response> {
logger.error("[followup-flow-worker.cron] runSilenceSweep threw", { error: detail, requestId });
}
// ponytail: o cron nativo da Vercel não tem agent-worker. Sem este dreno o
// no_reply avança o grafo e a mensagem seguinte fica pending. Teto: jobs
// sem fixed_body (mode ai_message) continuam precisando do worker.
try {
await enviarTextoFixoPendente(admin);
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
logger.error("[followup-flow-worker.cron] enviarTextoFixoPendente threw", { error: detail, requestId });
}
return ok(summary, { requestId });
}
+23
View File
@@ -12,6 +12,7 @@ import { NextRequest } from "next/server";
import { audit } from "@/lib/audit";
import { createAdminClient } from "@/lib/supabase/admin";
import { runFollowupTick, createSupabaseAdminClient } from "@/lib/followup/engine";
import { enviarTextoFixoPendente } from "@/lib/followup/enviar-texto-fixo";
vi.mock("@/lib/env", () => ({ env: { INTERNAL_SECRET: "dev-secret", INTERNAL_CRON_SECRET: "" } }));
vi.mock("@/lib/audit", () => ({ audit: vi.fn(async () => undefined) }));
@@ -20,6 +21,9 @@ vi.mock("@/lib/followup/engine", () => ({
runFollowupTick: vi.fn(),
createSupabaseAdminClient: vi.fn(() => ({})),
}));
vi.mock("@/lib/followup/enviar-texto-fixo", () => ({
enviarTextoFixoPendente: vi.fn(async () => 0),
}));
function req(headers: Record<string, string> = {}): NextRequest {
return new NextRequest("http://localhost/api/v1/cron/followup-flow-worker", { headers });
@@ -37,6 +41,7 @@ describe("GET/POST /api/v1/cron/followup-flow-worker", () => {
const body = (await res.json()) as { error: { code: string } };
expect(body.error.code).toBe("forbidden");
expect(vi.mocked(runFollowupTick)).not.toHaveBeenCalled();
expect(vi.mocked(enviarTextoFixoPendente)).not.toHaveBeenCalled();
});
it("secret errado → 403", async () => {
@@ -44,6 +49,7 @@ describe("GET/POST /api/v1/cron/followup-flow-worker", () => {
const res = await POST(req({ authorization: "Bearer wrong-secret" }));
expect(res.status).toBe(403);
expect(vi.mocked(runFollowupTick)).not.toHaveBeenCalled();
expect(vi.mocked(enviarTextoFixoPendente)).not.toHaveBeenCalled();
});
it("secret correto → 200, chama runFollowupTick e audita followup.worker_run", async () => {
@@ -57,11 +63,27 @@ describe("GET/POST /api/v1/cron/followup-flow-worker", () => {
expect(body.data).toEqual(summary);
expect(vi.mocked(createSupabaseAdminClient)).toHaveBeenCalledWith(expect.anything());
expect(vi.mocked(createAdminClient)).toHaveBeenCalled();
expect(vi.mocked(enviarTextoFixoPendente)).toHaveBeenCalled();
expect(vi.mocked(audit)).toHaveBeenCalledWith(
expect.objectContaining({ action: "followup.worker_run", metadata: summary }),
);
});
it("tick vazio ainda drena texto fixo pendente — senão o no_reply enfileira e a mensagem nunca sai", async () => {
vi.mocked(runFollowupTick).mockResolvedValue({
claimed: 0,
advanced: 0,
scheduled: 0,
failed: 0,
dead: 0,
});
const { POST } = await import("@/app/api/v1/cron/followup-flow-worker/route");
const res = await POST(req({ authorization: "Bearer dev-secret" }));
expect(res.status).toBe(200);
expect(vi.mocked(enviarTextoFixoPendente)).toHaveBeenCalled();
expect(vi.mocked(audit)).not.toHaveBeenCalled();
});
it("runFollowupTick lança → 500 internal_error, sem audit", async () => {
vi.mocked(runFollowupTick).mockRejectedValue(new Error("db down"));
@@ -71,6 +93,7 @@ describe("GET/POST /api/v1/cron/followup-flow-worker", () => {
const body = (await res.json()) as { error: { code: string } };
expect(body.error.code).toBe("internal_error");
expect(vi.mocked(audit)).not.toHaveBeenCalled();
expect(vi.mocked(enviarTextoFixoPendente)).not.toHaveBeenCalled();
});
it("leitura da confirmação indisponível recusa o tick sem auditar sucesso",async()=>{
vi.mocked(createAdminClient).mockReturnValueOnce({rpc:vi.fn(async()=>({data:null,error:{message:"database down"}}))} as never);