mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 01:28:34 +08:00
Merge pull request #8 from IanCouto/chore/vercel-pro-crons
feat: add enviarTextoFixoPendente to followup-flow-worker cron job
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user