feat(followup): fila unificada (enrollments + promessas) + cancelar + aba [onda 7]

GET /api/v1/ai/followups/queue (viewer+) une followup_enrollments + cron_jobs
(promessas schedule_followup) numa lista só, com cursor pagination correto sobre
a união (k-way merge lookahead, limit+1 por fonte). POST enrollments/:id/cancel
(manager+) encerra um enrollment vivo com 409 em terminal. UI: aba Fila em
app/app/ai/followups com filtros status/fluxo/busca, badges e AlertDialog de
cancelamento. Prova end-to-end via Playwright (tests/e2e/followup-queue.spec.ts,
2/2 verde) contra dados reais, incluindo a promessa seedada via
scripts/seed-e2e-followup-promise.ts (mesmo padrão de seed-e2e-queue.ts).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0143T3gYRomX8kFxK3KhJK1U
This commit is contained in:
Rafael Melgaço
2026-07-22 16:02:11 -03:00
co-authored by Claude Fable 5
parent 3307bb9ba6
commit 6546271b31
9 changed files with 1024 additions and 6 deletions
+3 -1
View File
@@ -23,7 +23,9 @@
## Estado atual
- **Onda:** 5 ✅ COMPLETA. Onda 6 EM ANDAMENTO (UI Builder React Flow): Task 6.1 ✅ + Task 6.2 ✅ + Task 6.3 ✅ (editor de condição de aresta — desbloqueia `ai_classify`/`condition` no builder). Próxima: Onda 7 (UI Fila + seletor no agente).
- **Onda:** 5 ✅ COMPLETA. Onda 6 ✅ COMPLETA (UI Builder React Flow: Task 6.1 + 6.2 + 6.3). Onda 7 EM ANDAMENTO: Task 7.1 ✅ (endpoint fila + aba + cancelar). Próxima: seletor no agente (resto da Onda 7).
- **Task 7.1 ✅ — endpoint fila + aba + cancelar.** `GET /api/v1/ai/followups/queue` (viewer+): UNION app-side de `followup_enrollments` + `cron_jobs` (kind='at'+job_kind='followup_turn'), ordenado `(next_fire_at asc nulls last, id asc)`, cursor seek `{next_fire_at,id}` aplicado às 2 fontes, janela `limit+1` por fonte + merge (k-way lookahead — sem pular/duplicar entre páginas). `status`/`pointer_id` pulam a fonte de promessas (não têm esses campos); `q` resolve pra `contact_id`s primeiro, aplicado nas 2 fontes. `POST /api/v1/ai/followups/enrollments/:id/cancel` (manager+): 409 `already_terminal` se já encerrado, senão cancela + evento `cancelled_manual` + audit `followup_enrollment.cancelled` (ação nova). `app/app/ai/followups/page.tsx` virou `Tabs` (Fluxos/Fila) — o gate de página que exigia manager+ pra ver QUALQUER coisa foi relaxado pra só exigir org ativa (Fila é viewer+; Fluxos manteve seu próprio `canWrite`). `QueueTab.tsx` + `useFollowupQueue.ts` (useInfiniteQuery, mesmo padrão de `useAdminIncidents`) — filtros status/fluxo/busca (debounce 250ms), relativo+absoluto via `date-fns`/`ptBR` (reuso, sem dep nova), `AlertDialog` de cancelar.
**PROVA AO VIVO (`tests/e2e/followup-queue.spec.ts`, 2/2 verde, `E2E_PORT=3010`):** setup 100% via API real (fluxo publicado, contato, enrollment) + `scripts/seed-e2e-followup-promise.ts` novo (mesmo padrão de `seed-e2e-queue.ts`, service role — não existe rota pública pra criar `cron_jobs`, só a tool do agente em runtime). Os 4 casos de aceite cobertos SEM DEFERIR NENHUM: (1) linha do enrollment com contato/fluxo/nó/próximo-disparo/status; (2) filtro status esconde e filtro por fluxo estreita pra exatamente 1 linha; (3) cancelar via dialog → some do filtro Ativo + toast + prova via API (`GET queue?status=cancelled` contém o id cancelado) + RBAC extra verificada ad-hoc (viewer faz POST direto em `/cancel` → 403 `forbidden_role` server-side, não só botão escondido); (4) busca por nome da promessa seedada estreita a fila INTEIRA da org pra exatamente 1 linha (`flow_name="Promessa"`, motivo, "Agendada", "em 3 dias", sem botão Cancelar). Screenshots em `test-results/followup-7.1-0{1..6}-*.png` — a 01 mostra dado 100% real incluindo 3 promessas de uso genuíno da IA já existentes no dev DB ("Prova Multimodal"). `npm run typecheck` 0, `npm run lint` 0 novo (2 erros pré-existentes intocados da Task 2.1), `npm run test:unit` 547/547 sem regressão. **Sem migration** (só leitura de tabelas já existentes; `cancelled_manual` é `event_type text` livre, sem CHECK a alterar). Detalhe completo em `.superpowers/sdd/task-7.1-report.md`.
- **Task 6.3 ✅ — editor de condição de aresta.** `lib/followup/edge-condition-options.ts` (novo, puro): `edgeConditionOptions(sourceNode)` — `ai_classify` → `always` + 1 `class_match` por classe declarada + `class_match:no_reply`; `condition` → `always` + `cond_result:true/false`; qualquer outro tipo → só `always`. Mais `conditionKey`/`conditionLabel` (pt-br: classe crua, `no_reply`→"Sem resposta", `true`→"Sim", `false`→"Não", `always`→"Sempre") — 9 testes unit. `app/app/ai/followups/[id]/_components/EdgeConfigPanel.tsx` (novo): painel docado (mesmo estilo do NodeConfigPanel), mostra origem→destino e um Select com as opções exatas do nó de origem. `FlowCanvas.tsx`: `onEdgeClick` seleciona a aresta (fecha o painel de nó, e vice-versa — mutuamente exclusivos), `edgesForRender` (memo) injeta `label` derivado de `data.condition` em TODA aresta pro fio mostrar "positivo"/"Sem resposta"/"Sempre"/"Sim"/"Não" (React Flow's built-in edge label, sem edge customizado — confirmado no dist instalado que `BaseEdge`/`EdgeText` já suportam `label` nos 4 tipos default). `graph-mappers.ts`: só um `export` adicionado em `toFlowNode` (já existia, não exportada) — reuso, ZERO mudança de comportamento/round-trip.
**PROVA (Playwright, `tests/e2e/followup-builder.spec.ts`, describe "Task 6.3"):** fluxo trigger→ai_classify(2 classes: positivo/objecao)→2 ações→2 fins (2 fins são necessários, não decorativos — `addEdge` do React Flow recusa uma 2ª aresta entre o mesmo par origem/destino, então `no_reply` e o fallback `always` do classify não podem mirar o MESMO nó "fim"). **Checagem negativa primeiro:** publica o grafo com TODAS as 4 arestas de saída do classify ainda `always` (estado pré-6.3) → 422 real, `Fluxo reprovado na validação`, nó do classify com anel vermelho + mensagem `"não tem edge class_match para a classe 'positivo'"`, exatamente 1 nó com erro (screenshot `followup-6.3-02-publish-422-all-always.png`). Depois abre cada aresta de saída do classify via o painel novo, seleciona `positivo`/`objecao`/`Sem resposta` (a `always` de fallback já vem certa, confirmada explicitamente no painel) → Publicar → SUCESSO: badge "Ativo", toast "Fluxo publicado.", zero erros no nó classify, e as 4 arestas mostram seus labels no fio (screenshot `followup-6.3-04-published-branching.png`) — o MESMO grafo que falhou acima agora publica porque o editor existe. Descoberta de implementação: o grid de posição default da paleta (220px de espaçamento < 224px de largura do card) sobrepõe cards com >4 nós e gera bezier loopy que esconde o label de aresta atrás de um card vizinho — o teste reposiciona os 6 nós manualmente (drag pelo header, fora dos handles Top/Bottom) num layout de árvore de verdade antes de conectar; e `fitView` do React Flow reajusta zoom (até 2x) a cada nó recém-medido, então o teste estabiliza via zoom-out ANTES de calcular posições-alvo (mesma cautela já documentada no teste 6.2). **PROVA:** 9/9 unit novos (`edge-condition-options.test.ts`), suíte unit completa 547/547, typecheck 0, lint 0 novo (os 2 erros pré-existentes em `graph-schema.test.ts` da Task 2.1 continuam intocados), `npm run build` OK, e2e `followup-builder.spec.ts` completo 7/7 (6.1+6.2+6.3, sem regressão). Sem migration nesta task.
- **RACE do classify lento — RESOLVIDA na 5.2.** `processNode` (node-handlers.ts) ganhou `wokeEarly?: boolean`: quando `waitElapsed=true` E `wokeEarly=true`, reenfileira classify em vez de rotear `no_reply`. `engine.ts` computa `wokeEarly` checando o marker `${node}:${steps}:wake` nos eventos do enrollment — gravado por `lib/followup/reactivity.ts` quando um inbound chega durante `waiting_reply` sem `cancel_on_reply`. Distinto do idempotency_key de passo que `waitElapsed` já checava (Task 5.1), então os dois sinais nunca se confundem. Provado em `tests/invariants/followup-reactivity.test.ts` (o TESTE CRÍTICO: 1º tick enfileira classify, reactivity empurra `next_eval_at` pra agora ANTES do grace vencer, 2º tick do engine real reenfileira classify — não roteia `no_reply`).
@@ -0,0 +1,93 @@
/**
* POST /api/v1/ai/followups/enrollments/:id/cancel (manager+) — encerra um
* enrollment VIVO (active|waiting_reply|paused_handoff) manualmente pela
* fila. 409 `already_terminal` se já tiver encerrado (completed/cancelled/
* dead) — cancelar 2x não é erro operacional, mas também não deve reescrever
* o desfecho de um enrollment que já fechou por conta própria.
*
* Promessas (`cron_jobs`) NÃO são canceláveis por aqui — fora de escopo desta
* task; só enrollments do motor.
*/
import { randomUUID } from "node:crypto";
import type { NextRequest } from "next/server";
import { ok, fail } from "@/lib/api/wrappers";
import { audit } from "@/lib/audit";
import { requireRole } from "@/lib/auth/require-role";
import { createClient } from "@/lib/supabase/server";
export const dynamic = "force-dynamic";
const UUID_RX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const LIVE_STATUSES = ["active", "waiting_reply", "paused_handoff"];
type RouteCtx = { params: Promise<{ id: string }> };
export async function POST(_req: NextRequest, ctx: RouteCtx): Promise<Response> {
const requestId = randomUUID();
const { id } = await ctx.params;
if (!UUID_RX.test(id)) {
return fail("invalid_request", "id inválido.", 400, { requestId });
}
const authz = await requireRole("manager", { requestId, resource: "followup_enrollments" });
if (!authz.ok) return authz.response;
const { user, org: activeOrg } = authz;
const supabase = await createClient();
const { data: existing, error: fetchErr } = await supabase
.from("followup_enrollments")
.select("id, status, current_node_id")
.eq("id", id)
.eq("organization_id", activeOrg.orgId)
.maybeSingle();
if (fetchErr) return fail("internal_error", fetchErr.message, 500, { requestId });
if (!existing) return fail("not_found", "Enrollment não encontrado.", 404, { requestId });
if (!LIVE_STATUSES.includes(existing.status)) {
return fail("already_terminal", "Enrollment já está encerrado.", 409, { requestId });
}
const { data: updated, error: updErr } = await supabase
.from("followup_enrollments")
.update({
status: "cancelled",
cancel_reason: "manual",
next_eval_at: null,
outcome: null,
completed_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
})
.eq("id", id)
.eq("organization_id", activeOrg.orgId)
.select("id, status, cancel_reason, updated_at")
.single();
if (updErr || !updated) {
return fail("internal_error", updErr?.message ?? "followup_enrollment_cancel_failed", 500, {
requestId,
});
}
const { error: eventErr } = await supabase.from("followup_enrollment_events").insert({
organization_id: activeOrg.orgId,
enrollment_id: id,
node_id: existing.current_node_id,
event_type: "cancelled_manual",
payload: { actor_user_id: user.id },
});
if (eventErr) {
console.error("[followup.enrollment.cancel] event insert failed", eventErr.message);
}
void audit({
action: "followup_enrollment.cancelled",
actorUserId: user.id,
organizationId: activeOrg.orgId,
resourceType: "followup_enrollment",
resourceId: id,
requestId,
metadata: { previous_status: existing.status },
});
return ok(updated, { requestId });
}
+233
View File
@@ -0,0 +1,233 @@
/**
* GET /api/v1/ai/followups/queue — fila unificada (any member): UNION de
* `followup_enrollments` (estado vivo do motor) + `cron_jobs` (promessas
* `schedule_followup`, kind='at' + job_kind='followup_turn'). Union feita em
* app-code (2 queries + merge), não SQL nativo — schema-simples o bastante
* pra não justificar uma function nova; ver nota de ordenação/cursor abaixo.
*
* Ordenação: (next_fire_at asc, nulls last, id asc) — terminal/disparado
* (next_eval_at ou next_run_at null) vai pro fim. Cursor = a chave de
* ordenação da última linha emitida (`{next_fire_at, id}`), aplicada a
* AMBAS as fontes na próxima página (seek pagination).
*
* Correção da paginação sobre união: cada página busca `limit+1` linhas de
* CADA fonte (já filtradas pelo cursor), concatena e reordena — o clássico
* "k-way merge lookahead": a (limit+1)-ésima menor linha GLOBAL está
* garantida dentro do top-(limit+1) de pelo menos uma das janelas
* individuais, então essa janela é suficiente pra decidir `has_more` e o
* próximo cursor sem pular nem duplicar linha entre páginas.
*
* `status`/`pointer_id` só existem pra enrollment — quando setados, a fonte
* de promessas é pulada (nenhuma promessa tem esses atributos, então
* nenhuma promessa poderia bater no filtro mesmo se buscada).
*/
import { randomUUID } from "node:crypto";
import type { NextRequest } from "next/server";
import { ok, fail } from "@/lib/api/wrappers";
import { requireRole } from "@/lib/auth/require-role";
import { createClient } from "@/lib/supabase/server";
export const dynamic = "force-dynamic";
const ENROLLMENT_STATUSES = [
"active",
"waiting_reply",
"paused_handoff",
"completed",
"cancelled",
"dead",
] as const;
interface CursorPayload {
next_fire_at: string | null;
id: string;
}
function encodeCursor(p: CursorPayload): string {
return Buffer.from(JSON.stringify(p), "utf8").toString("base64url");
}
function decodeCursor(raw: string): CursorPayload | null {
try {
const parsed = JSON.parse(Buffer.from(raw, "base64url").toString("utf8")) as Partial<CursorPayload>;
if (typeof parsed.id !== "string") return null;
if (parsed.next_fire_at !== null && typeof parsed.next_fire_at !== "string") return null;
return { next_fire_at: parsed.next_fire_at ?? null, id: parsed.id };
} catch {
return null;
}
}
interface ContactRow {
id: string;
name: string | null;
display_name: string | null;
phone_number: string | null;
}
function resolveContactName(c: ContactRow | null): string {
if (!c) return "Contato removido";
return c.display_name?.trim() || c.name?.trim() || c.phone_number || "Contato sem nome";
}
function embedded<T>(v: T | T[] | null): T | null {
return Array.isArray(v) ? (v[0] ?? null) : v;
}
export interface QueueRow {
source: "enrollment" | "promise";
id: string;
contact: { id: string; name: string };
flow_name: string | null;
node_or_reason: string;
next_fire_at: string | null;
status: string;
detail: string | null;
}
/** Chave de ordenação/seek: null (infinito) sempre vem por último. */
function sortCompare(a: { next_fire_at: string | null; id: string }, b: { next_fire_at: string | null; id: string }): number {
if (a.next_fire_at === null && b.next_fire_at === null) return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
if (a.next_fire_at === null) return 1;
if (b.next_fire_at === null) return -1;
const ta = Date.parse(a.next_fire_at);
const tb = Date.parse(b.next_fire_at);
if (ta !== tb) return ta - tb;
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
}
export async function GET(req: NextRequest): Promise<Response> {
const requestId = randomUUID();
const authz = await requireRole("viewer", { requestId, resource: "followup_queue" });
if (!authz.ok) return authz.response;
const { org: activeOrg } = authz;
const sp = req.nextUrl.searchParams;
const status = sp.get("status");
if (status !== null && !ENROLLMENT_STATUSES.includes(status as (typeof ENROLLMENT_STATUSES)[number])) {
return fail("invalid_request", "status inválido.", 400, { requestId });
}
const pointerId = sp.get("pointer_id");
const UUID_RX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (pointerId !== null && !UUID_RX.test(pointerId)) {
return fail("invalid_request", "pointer_id inválido.", 400, { requestId });
}
const q = sp.get("q")?.trim() || null;
const cursorRaw = sp.get("cursor");
const cursor = cursorRaw ? decodeCursor(cursorRaw) : null;
if (cursorRaw && !cursor) {
return fail("invalid_request", "cursor inválido.", 400, { requestId });
}
const limitParam = Number(sp.get("limit") ?? "20");
const limit = Number.isFinite(limitParam) ? Math.min(100, Math.max(1, Math.trunc(limitParam))) : 20;
const supabase = await createClient();
// `q` resolve pra um conjunto de contact_ids ANTES das 2 queries (mesma
// filtragem aplicada às duas fontes). Sem match → resultado vazio direto.
let contactIds: string[] | null = null;
if (q) {
const { data: matches, error: cErr } = await supabase
.from("contacts")
.select("id")
.eq("organization_id", activeOrg.orgId)
.or(`name.ilike.%${q}%,display_name.ilike.%${q}%,phone_number.ilike.%${q}%`)
.limit(500); // ponytail: fila é escala MVP; sobe se virar hot path
if (cErr) return fail("internal_error", cErr.message, 500, { requestId });
contactIds = (matches ?? []).map((m) => m.id);
if (contactIds.length === 0) {
return ok<QueueRow[]>([], { requestId, meta: { cursor: null, has_more: false } });
}
}
const skipPromises = status !== null || pointerId !== null;
// --- fonte 1: enrollments ---
let enrollQuery = supabase
.from("followup_enrollments")
.select(
`id, pointer_id, contact_id, status, current_node_id, next_eval_at, outcome, updated_at,
contacts:contact_id(id, name, display_name, phone_number),
followup_flow_pointers:pointer_id(name)`,
)
.eq("organization_id", activeOrg.orgId)
.order("next_eval_at", { ascending: true, nullsFirst: false })
.order("id", { ascending: true })
.limit(limit + 1);
if (status !== null) enrollQuery = enrollQuery.eq("status", status);
if (pointerId !== null) enrollQuery = enrollQuery.eq("pointer_id", pointerId);
if (contactIds) enrollQuery = enrollQuery.in("contact_id", contactIds);
if (cursor) {
enrollQuery =
cursor.next_fire_at !== null
? enrollQuery.or(
`next_eval_at.gt.${cursor.next_fire_at},and(next_eval_at.eq.${cursor.next_fire_at},id.gt.${cursor.id}),next_eval_at.is.null`,
)
: enrollQuery.is("next_eval_at", null).gt("id", cursor.id);
}
// --- fonte 2: promessas (cron_jobs kind='at' + job_kind='followup_turn') ---
let promiseQuery = supabase
.from("cron_jobs")
.select("id, contact_id, next_run_at, enabled, payload, contacts:contact_id(id, name, display_name, phone_number)")
.eq("organization_id", activeOrg.orgId)
.eq("kind", "at")
.eq("job_kind", "followup_turn")
.order("next_run_at", { ascending: true, nullsFirst: false })
.order("id", { ascending: true })
.limit(limit + 1);
if (contactIds) promiseQuery = promiseQuery.in("contact_id", contactIds);
if (cursor) {
promiseQuery =
cursor.next_fire_at !== null
? promiseQuery.or(
`next_run_at.gt.${cursor.next_fire_at},and(next_run_at.eq.${cursor.next_fire_at},id.gt.${cursor.id}),next_run_at.is.null`,
)
: promiseQuery.is("next_run_at", null).gt("id", cursor.id);
}
const [enrollRes, promiseRes] = await Promise.all([
enrollQuery,
skipPromises ? Promise.resolve({ data: [], error: null }) : promiseQuery,
]);
if (enrollRes.error) return fail("internal_error", enrollRes.error.message, 500, { requestId });
if (promiseRes.error) return fail("internal_error", promiseRes.error.message, 500, { requestId });
const enrollRows: QueueRow[] = (enrollRes.data ?? []).map((e) => {
const contact = embedded(e.contacts as ContactRow | ContactRow[] | null);
const pointer = embedded(e.followup_flow_pointers as { name: string } | { name: string }[] | null);
return {
source: "enrollment",
id: e.id,
contact: { id: e.contact_id, name: resolveContactName(contact) },
flow_name: pointer?.name ?? null,
node_or_reason: e.current_node_id,
next_fire_at: e.next_eval_at,
status: e.status,
detail: e.outcome,
};
});
const promiseRows: QueueRow[] = (promiseRes.data ?? []).map((j) => {
const contact = embedded(j.contacts as ContactRow | ContactRow[] | null);
const payload = (j.payload ?? {}) as { reason?: string; promise?: string };
return {
source: "promise",
id: j.id,
contact: { id: j.contact_id, name: resolveContactName(contact) },
flow_name: null,
node_or_reason: payload.reason ?? "—",
next_fire_at: j.next_run_at,
status: j.enabled ? "agendada" : "concluída",
detail: payload.promise ?? null,
};
});
const merged = [...enrollRows, ...promiseRows].sort(sortCompare);
const hasMore = merged.length > limit;
const page = merged.slice(0, limit);
const last = page[page.length - 1];
const nextCursor = hasMore && last ? encodeCursor({ next_fire_at: last.next_fire_at, id: last.id }) : null;
return ok<QueueRow[]>(page, { requestId, meta: { cursor: nextCursor, has_more: hasMore } });
}
@@ -0,0 +1,267 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { format, formatDistanceToNowStrict } from "date-fns";
import { ptBR } from "date-fns/locale";
import { Badge } from "@/components/ui/badge";
import { Button, buttonVariants } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Clock, MagnifyingGlass, Trash } from "@/lib/ui/icons";
import { useFollowupFlows } from "@/hooks/followup/useFollowupFlows";
import {
useCancelFollowupEnrollment,
useFollowupQueue,
type FollowupEnrollmentStatus,
type FollowupQueueRow,
} from "@/hooks/followup/useFollowupQueue";
interface Props {
canWrite: boolean;
}
const STATUS_OPTIONS: { value: FollowupEnrollmentStatus; label: string }[] = [
{ value: "active", label: "Ativo" },
{ value: "waiting_reply", label: "Aguardando resposta" },
{ value: "paused_handoff", label: "Pausado (handoff)" },
{ value: "completed", label: "Concluído" },
{ value: "cancelled", label: "Cancelado" },
{ value: "dead", label: "Morto" },
];
const STATUS_LABEL: Record<string, string> = {
active: "Ativo",
waiting_reply: "Aguardando resposta",
paused_handoff: "Pausado",
completed: "Concluído",
cancelled: "Cancelado",
dead: "Morto",
agendada: "Agendada",
"concluída": "Concluída",
};
const STATUS_VARIANT: Record<string, "neutral" | "success" | "warning" | "error" | "info"> = {
active: "success",
waiting_reply: "info",
paused_handoff: "warning",
completed: "neutral",
cancelled: "neutral",
dead: "error",
agendada: "info",
"concluída": "neutral",
};
const LIVE_ENROLLMENT_STATUSES = new Set(["active", "waiting_reply", "paused_handoff"]);
function QueueStatusBadge({ status }: { status: string }) {
return (
<Badge variant={STATUS_VARIANT[status] ?? "neutral"} aria-label={`status: ${STATUS_LABEL[status] ?? status}`}>
{STATUS_LABEL[status] ?? status}
</Badge>
);
}
function NextFireCell({ iso }: { iso: string | null }) {
if (!iso) return <span className="text-text-muted">—</span>;
const d = new Date(iso);
const relative = formatDistanceToNowStrict(d, { addSuffix: true, locale: ptBR });
const absolute = format(d, "dd/MM/yyyy HH:mm", { locale: ptBR });
return (
<div title={absolute} className="flex flex-col">
<span className="text-sm">{relative}</span>
<span className="text-xs text-text-muted">{absolute}</span>
</div>
);
}
export function QueueTab({ canWrite }: Props) {
const [status, setStatus] = useState<FollowupEnrollmentStatus | "all">("all");
const [pointerId, setPointerId] = useState<string>("all");
const [searchInput, setSearchInput] = useState("");
const [q, setQ] = useState("");
const [pendingCancelId, setPendingCancelId] = useState<string | null>(null);
useEffect(() => {
const t = setTimeout(() => setQ(searchInput.trim()), 250);
return () => clearTimeout(t);
}, [searchInput]);
const { data: flows } = useFollowupFlows();
const filters = useMemo(
() => ({
status: status === "all" ? undefined : status,
pointer_id: pointerId === "all" ? undefined : pointerId,
q: q || undefined,
}),
[status, pointerId, q],
);
const { data, isLoading, hasNextPage, isFetchingNextPage, fetchNextPage } = useFollowupQueue(filters);
const cancelMutation = useCancelFollowupEnrollment();
const rows: FollowupQueueRow[] = data?.pages.flatMap((p) => p.data) ?? [];
return (
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-center gap-2">
<div className="relative">
<MagnifyingGlass
size={14}
className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-text-muted"
aria-hidden
/>
<Input
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="Buscar contato…"
className="h-9 w-56 pl-8 text-sm"
aria-label="Buscar contato"
/>
</div>
<Select value={status} onValueChange={(v) => setStatus(v as FollowupEnrollmentStatus | "all")}>
<SelectTrigger className="h-9 w-48 text-sm" aria-label="Filtrar por status">
<SelectValue placeholder="Todos os status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todos os status</SelectItem>
{STATUS_OPTIONS.map((s) => (
<SelectItem key={s.value} value={s.value}>
{s.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={pointerId} onValueChange={setPointerId}>
<SelectTrigger className="h-9 w-48 text-sm" aria-label="Filtrar por fluxo">
<SelectValue placeholder="Todos os fluxos" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todos os fluxos</SelectItem>
{(flows ?? []).map((f) => (
<SelectItem key={f.id} value={f.id}>
{f.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{!isLoading && rows.length === 0 ? (
<div className="flex flex-col items-center gap-3 rounded-md border border-border py-16 text-center">
<Clock size={36} className="text-text-muted" aria-hidden />
<h2 className="font-medium">Nenhum item na fila</h2>
<p className="max-w-sm text-sm text-text-muted">
Enrollments ativos e promessas de retorno agendadas pela IA aparecem aqui.
</p>
</div>
) : (
<div className="rounded-md border border-border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Contato</TableHead>
<TableHead>Fluxo / Promessa</TableHead>
<TableHead>Nó atual / Motivo</TableHead>
<TableHead>Próximo disparo</TableHead>
<TableHead>Status</TableHead>
{canWrite && <TableHead className="w-[100px]" />}
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => {
const canCancel = canWrite && row.source === "enrollment" && LIVE_ENROLLMENT_STATUSES.has(row.status);
return (
<TableRow key={`${row.source}:${row.id}`} data-testid="queue-row">
<TableCell className="font-medium">{row.contact.name}</TableCell>
<TableCell>
{row.flow_name ?? <span className="text-text-muted">Promessa</span>}
</TableCell>
<TableCell className="max-w-[280px] truncate text-sm" title={row.node_or_reason}>
{row.node_or_reason}
</TableCell>
<TableCell>
<NextFireCell iso={row.next_fire_at} />
</TableCell>
<TableCell>
<QueueStatusBadge status={row.status} />
</TableCell>
{canWrite && (
<TableCell>
{canCancel && (
<Button
variant="ghost"
size="sm"
aria-label="Cancelar follow-up"
onClick={() => setPendingCancelId(row.id)}
>
<Trash size={14} aria-hidden className="mr-1 text-error" /> Cancelar
</Button>
)}
</TableCell>
)}
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
{hasNextPage && (
<div className="flex justify-center">
<Button variant="outline" size="sm" onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>
{isFetchingNextPage ? "Carregando..." : "Carregar mais"}
</Button>
</div>
)}
<AlertDialog open={pendingCancelId !== null} onOpenChange={(open) => !open && setPendingCancelId(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Cancelar este follow-up?</AlertDialogTitle>
<AlertDialogDescription>
O lead não receberá mais mensagens deste fluxo. Essa ação não pode ser desfeita.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Voltar</AlertDialogCancel>
<AlertDialogAction
className={buttonVariants({ variant: "destructive" })}
onClick={() => {
if (pendingCancelId) cancelMutation.mutate(pendingCancelId);
setPendingCancelId(null);
}}
>
Cancelar follow-up
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
+16 -4
View File
@@ -4,7 +4,9 @@ import { requireAuth, resolveActiveOrg } from "@/lib/auth/server";
import { ROLE_RANK } from "@/lib/auth/types";
import { createClient } from "@/lib/supabase/server";
import type { FollowupFlowPointerRow } from "@/hooks/followup/useFollowupFlows";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { FlowsList } from "./_components/FlowsList";
import { QueueTab } from "./_components/QueueTab";
export const dynamic = "force-dynamic";
@@ -14,9 +16,8 @@ export default async function FollowupFlowsPage() {
const user = await requireAuth();
const activeOrg = await resolveActiveOrg(user);
if (!activeOrg) redirect("/app");
if (ROLE_RANK[activeOrg.role] < ROLE_RANK.manager) {
redirect("/403");
}
// Fluxos (edição) segue exigindo manager+; a Fila (leitura) é de qualquer
// member — o gate por tela fica dentro das abas (canWrite), não na rota.
const supabase = await createClient();
const { data } = await supabase
@@ -39,7 +40,18 @@ export default async function FollowupFlowsPage() {
</p>
</div>
</header>
<FlowsList initialData={flows} canWrite={canWrite} />
<Tabs defaultValue="fluxos" className="flex flex-1 flex-col">
<TabsList>
<TabsTrigger value="fluxos">Fluxos</TabsTrigger>
<TabsTrigger value="fila">Fila</TabsTrigger>
</TabsList>
<TabsContent value="fluxos">
<FlowsList initialData={flows} canWrite={canWrite} />
</TabsContent>
<TabsContent value="fila">
<QueueTab canWrite={canWrite} />
</TabsContent>
</Tabs>
</div>
);
}
+84
View File
@@ -0,0 +1,84 @@
"use client";
import { useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { apiClient } from "@/lib/api/client";
import { showApiError } from "@/components/feedback/ApiErrorToast";
export type FollowupEnrollmentStatus =
| "active"
| "waiting_reply"
| "paused_handoff"
| "completed"
| "cancelled"
| "dead";
export interface FollowupQueueRow {
source: "enrollment" | "promise";
id: string;
contact: { id: string; name: string };
flow_name: string | null;
node_or_reason: string;
next_fire_at: string | null;
status: string;
detail: string | null;
}
export interface FollowupQueueFilters {
status?: FollowupEnrollmentStatus;
pointer_id?: string;
q?: string;
}
interface ListResponse {
data: FollowupQueueRow[];
meta?: { cursor?: string | null; has_more?: boolean };
}
const QUEUE_LIMIT = 20;
export const followupQueueQueryKey = (filters: FollowupQueueFilters) =>
["followup", "queue", filters] as const;
export function useFollowupQueue(filters: FollowupQueueFilters = {}) {
return useInfiniteQuery({
queryKey: followupQueueQueryKey(filters),
initialPageParam: undefined as string | undefined,
queryFn: async ({ pageParam }) => {
const qs = new URLSearchParams();
if (filters.status) qs.set("status", filters.status);
if (filters.pointer_id) qs.set("pointer_id", filters.pointer_id);
if (filters.q) qs.set("q", filters.q);
if (pageParam) qs.set("cursor", pageParam);
qs.set("limit", String(QUEUE_LIMIT));
try {
return await apiClient.get<ListResponse>(`/api/v1/ai/followups/queue?${qs.toString()}`);
} catch (err) {
showApiError(err);
throw err;
}
},
getNextPageParam: (last) => (last.meta?.has_more && last.meta.cursor ? last.meta.cursor : undefined),
staleTime: 15_000,
});
}
export function useCancelFollowupEnrollment() {
const qc = useQueryClient();
return useMutation({
mutationKey: ["followup", "enrollments", "cancel"],
mutationFn: async (enrollmentId: string) => {
const res = await apiClient.post<{ data: { id: string; status: string } }>(
`/api/v1/ai/followups/enrollments/${enrollmentId}/cancel`,
{},
);
return res.data;
},
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ["followup", "queue"] });
toast.success("Follow-up cancelado.");
},
onError: (err) => {
showApiError(err);
},
});
}
+2 -1
View File
@@ -147,4 +147,5 @@ export type AuditAction =
| "followup_flow.disabled"
| "followup_flow.rolled_back"
| "followup.worker_run"
| "followup_enrollment.created";
| "followup_enrollment.created"
| "followup_enrollment.cancelled";
+116
View File
@@ -0,0 +1,116 @@
/**
* Seed E2E de uma PROMESSA de follow-up (Task 7.1): 1 contato + 1 `cron_jobs`
* row (kind='at', job_kind='followup_turn') com o payload que a tool
* `schedule_followup` grava — sem passar pelo agente de IA, direto via
* service role (mesmo padrão de scripts/seed-e2e-queue.ts). Não existe rota
* pública que crie esse tipo de linha (só a tool do agente, em runtime), então
* o seed de teste replica o shape exato do payload em vez de inventar um
* endpoint novo só pra isto.
*
* Idempotente: apaga as linhas de teste anteriores do mesmo contato antes de
* inserir a nova, então cada run deixa exatamente 1 promessa viva. Depende de
* .e2e-creds.json (rode scripts/seed-e2e-credentials.ts antes). Grava o bloco
* `followup_promise`.
*
* Run: npx tsx scripts/seed-e2e-followup-promise.ts
*/
import { createClient } from "@supabase/supabase-js";
import * as fs from "node:fs";
import * as path from "node:path";
const envFile = fs.readFileSync(path.join(process.cwd(), ".env.local"), "utf8");
const env: Record<string, string> = {};
for (const line of envFile.split("\n")) {
const m = line.match(/^([A-Z_]+)=(.*)$/);
if (m) env[m[1]!] = m[2]!.replace(/^"(.*)"$/, "$1");
}
const SUPABASE_URL = env.NEXT_PUBLIC_SUPABASE_URL!;
const SERVICE_ROLE = env.SUPABASE_SERVICE_ROLE_KEY!;
if (!SUPABASE_URL || !SERVICE_ROLE) {
throw new Error("Missing NEXT_PUBLIC_SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY in .env.local");
}
const admin = createClient(SUPABASE_URL, SERVICE_ROLE, {
auth: { autoRefreshToken: false, persistSession: false },
});
const CREDS_PATH = path.join(process.cwd(), ".e2e-creds.json");
const CONTACT_NAME = "Cliente Promessa E2E";
interface Creds {
org_id: string;
followup_promise?: unknown;
}
async function ensureContact(orgId: string): Promise<string> {
const { data: existing } = await admin
.from("contacts")
.select("id")
.eq("organization_id", orgId)
.eq("display_name", CONTACT_NAME)
.maybeSingle();
if (existing) return (existing as { id: string }).id;
const { data, error } = await admin
.from("contacts")
.insert({ organization_id: orgId, display_name: CONTACT_NAME } as never)
.select("id")
.single();
if (error || !data) throw new Error(`insert contact: ${error?.message}`);
return (data as { id: string }).id;
}
async function main(): Promise<void> {
const creds = JSON.parse(fs.readFileSync(CREDS_PATH, "utf8")) as Creds;
const orgId = creds.org_id;
const contactId = await ensureContact(orgId);
// Idempotência: remove promessas de teste anteriores deste contato antes de recriar.
await admin
.from("cron_jobs")
.delete()
.eq("organization_id", orgId)
.eq("contact_id", contactId)
.eq("job_kind", "followup_turn");
const promisedAt = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000); // em 3 dias
const payload = {
reason: "Cliente pediu para retornar depois do feriado",
promise: "Vamos avisar assim que o produto voltar ao estoque",
promised_at: promisedAt.toISOString(),
context_snapshot: null,
};
const { data, error } = await admin
.from("cron_jobs")
.insert({
organization_id: orgId,
contact_id: contactId,
kind: "at",
job_kind: "followup_turn",
next_run_at: promisedAt.toISOString(),
enabled: true,
payload,
} as never)
.select("id")
.single();
if (error || !data) throw new Error(`insert cron_jobs: ${error?.message}`);
const cronJobId = (data as { id: string }).id;
creds.followup_promise = {
cron_job_id: cronJobId,
contact_id: contactId,
contact_name: CONTACT_NAME,
reason: payload.reason,
promise: payload.promise,
};
fs.writeFileSync(CREDS_PATH, JSON.stringify(creds, null, 2));
console.log(`\n✅ Follow-up promise seed completo. cron_job=${cronJobId} contact=${contactId}`);
}
main().catch((err) => {
console.error("❌ Follow-up promise seed falhou:", err);
process.exit(1);
});
+210
View File
@@ -0,0 +1,210 @@
/**
* Task 7.1 — fila unificada (enrollments + promessas) + cancelar + aba.
*
* Setup via API (não UI) como manager: publica um fluxo mínimo trigger→end,
* cria um contato e um enrollment manual — aparece na fila imediatamente
* (next_eval_at=now no trigger). A promessa (cron_jobs kind='at' +
* job_kind='followup_turn') não tem rota pública de criação — só a tool do
* agente de IA em runtime cria esse tipo de linha — então
* scripts/seed-e2e-followup-promise.ts a semeia direto via service role
* (mesmo padrão de scripts/seed-e2e-queue.ts), rodado 1x em beforeAll.
*
* Cada run usa nome de fluxo/contato com timestamp único — não colide entre
* execuções; os registros de teste se acumulam no banco (mesma doutrina do
* followup-builder.spec.ts), sweep manual fora do escopo desta task.
*/
import { execFileSync } from "node:child_process";
import * as fs from "node:fs";
import * as path from "node:path";
import { test, expect, type Page } from "@playwright/test";
const CREDS_PATH = path.join(process.cwd(), ".e2e-creds.json");
interface Creds {
password: string;
users: Record<string, { email: string }>;
followup_promise?: { contact_name: string; reason: string; promise: string };
}
function loadCreds(): Creds {
const needsSeed = (): boolean => {
if (!fs.existsSync(CREDS_PATH)) return true;
const c = JSON.parse(fs.readFileSync(CREDS_PATH, "utf8")) as Creds;
return !c.users?.manager;
};
if (needsSeed()) {
execFileSync("npx", ["tsx", "scripts/seed-e2e-credentials.ts"], { stdio: "inherit" });
}
return JSON.parse(fs.readFileSync(CREDS_PATH, "utf8")) as Creds;
}
const creds = loadCreds();
async function login(page: Page, email: string): Promise<void> {
await page.goto("/login");
await page.locator("#email").fill(email);
await page.locator("#password").fill(creds.password);
await page.getByRole("button", { name: /entrar/i }).click();
await page.waitForURL(/\/app\//);
}
interface ApiOk<T> {
data: T;
}
test.describe("followup queue — fila unificada (Task 7.1)", () => {
test.beforeAll(() => {
execFileSync("npx", ["tsx", "scripts/seed-e2e-followup-promise.ts"], { stdio: "inherit" });
});
test("manager vê enrollment na fila, filtra por status/fluxo, cancela, e a promessa seedada aparece", async ({
page,
}) => {
await login(page, creds.users.manager!.email);
const stamp = Date.now();
const flowName = `E2E Fila ${stamp}`;
const contactName = `Cliente Fila E2E ${stamp}`;
// --- setup via API: fluxo mínimo trigger->end, publicado ---
const createFlowRes = await page.request.post("/api/v1/ai/followup-flows", {
data: { name: flowName },
});
expect(createFlowRes.status()).toBe(201);
const { data: flow } = (await createFlowRes.json()) as ApiOk<{ id: string }>;
const graph = {
nodes: [
{ id: "trigger-1", type: "trigger", label: "Início", position: { x: 0, y: 0 }, config: {} },
{
id: "end-1",
type: "end",
label: "Fim",
position: { x: 0, y: 200 },
config: { outcome: "exhausted" },
},
],
edges: [
{ id: "edge-1", source: "trigger-1", target: "end-1", priority: 0, condition: { type: "always" } },
],
};
const patchRes = await page.request.patch(`/api/v1/ai/followup-flows/${flow.id}`, {
data: { draft_graph: graph },
});
expect(patchRes.status()).toBe(200);
const publishRes = await page.request.post(`/api/v1/ai/followup-flows/${flow.id}/publish`, { data: {} });
expect(publishRes.status()).toBe(200);
const createContactRes = await page.request.post("/api/v1/contacts", {
data: { display_name: contactName },
});
expect(createContactRes.status()).toBe(201);
const { data: contactResult } = (await createContactRes.json()) as ApiOk<{ contact: { id: string } }>;
const contact = contactResult.contact;
const createEnrollmentRes = await page.request.post("/api/v1/ai/followups/enrollments", {
data: { pointer_id: flow.id, contact_id: contact.id },
});
expect(createEnrollmentRes.status()).toBe(201);
const { data: enrollment } = (await createEnrollmentRes.json()) as ApiOk<{ id: string }>;
// --- 1. a aba Fila lista o enrollment real ---
await page.goto("/app/ai/followups");
await expect(page.getByRole("heading", { name: "Follow-ups" })).toBeVisible();
await page.getByRole("tab", { name: "Fila" }).click();
const row = page.locator('[data-testid="queue-row"]', { hasText: contactName });
await expect(row).toBeVisible();
await expect(row).toContainText(flowName);
await expect(row).toContainText("trigger-1");
await expect(row.getByText("Ativo", { exact: true })).toBeVisible();
// next-fire: relativo visível + absoluto (dd/mm/yyyy) como texto secundário.
await expect(row.locator("td").nth(3)).toContainText(/\d{2}\/\d{2}\/\d{4}/);
await page.screenshot({ path: "test-results/followup-7.1-01-queue-populated.png", fullPage: true });
// --- 2. filtro por status e por fluxo estreitam a lista ---
await page.getByLabel("Filtrar por status").click();
await page.getByRole("option", { name: "Ativo", exact: true }).click();
await expect(row).toBeVisible();
await page.getByLabel("Filtrar por status").click();
await page.getByRole("option", { name: "Concluído", exact: true }).click();
await expect(row).toHaveCount(0);
await page.screenshot({ path: "test-results/followup-7.1-02-filtered-status-empty.png", fullPage: true });
await page.getByLabel("Filtrar por status").click();
await page.getByRole("option", { name: "Todos os status", exact: true }).click();
await expect(row).toBeVisible();
await page.getByLabel("Filtrar por fluxo").click();
await page.getByRole("option", { name: flowName, exact: true }).click();
await expect(row).toBeVisible();
// filtro por ESTE fluxo recém-criado — só esta 1 linha pode bater (nenhum
// outro teste usou este nome com timestamp único).
await expect(page.locator('[data-testid="queue-row"]')).toHaveCount(1);
await page.screenshot({ path: "test-results/followup-7.1-03-filtered-by-flow.png", fullPage: true });
// --- 3. cancelar via AlertDialog ---
await row.getByRole("button", { name: "Cancelar follow-up" }).click();
const dialog = page.getByRole("alertdialog");
await expect(dialog).toBeVisible();
await expect(dialog).toContainText("O lead não receberá mais mensagens deste fluxo");
await page.screenshot({ path: "test-results/followup-7.1-04-cancel-dialog.png", fullPage: true });
await dialog.getByRole("button", { name: "Cancelar follow-up" }).click();
await expect(page.getByText("Follow-up cancelado.")).toBeVisible();
// sai do filtro "ativos" (aqui, o fluxo específico + status Ativo).
await page.getByLabel("Filtrar por status").click();
await page.getByRole("option", { name: "Ativo", exact: true }).click();
await expect(row).toHaveCount(0);
await page.screenshot({ path: "test-results/followup-7.1-05-cancelled-left-active.png", fullPage: true });
// Prova via API (não só UI): GET queue com status=cancelled mostra o
// enrollment cancelado para este pointer.
const cancelledQueueRes = await page.request.get(
`/api/v1/ai/followups/queue?status=cancelled&pointer_id=${flow.id}`,
);
expect(cancelledQueueRes.status()).toBe(200);
const { data: cancelledRows } = (await cancelledQueueRes.json()) as ApiOk<
Array<{ id: string; status: string }>
>;
expect(cancelledRows.some((r) => r.id === enrollment.id && r.status === "cancelled")).toBe(true);
// --- 4. a promessa seedada (cron_jobs) aparece como linha "Promessa" ---
const promiseInfo = creds.followup_promise;
if (!promiseInfo) throw new Error("followup_promise ausente em .e2e-creds.json — seed falhou");
await page.getByLabel("Filtrar por status").click();
await page.getByRole("option", { name: "Todos os status", exact: true }).click();
await page.getByLabel("Filtrar por fluxo").click();
await page.getByRole("option", { name: "Todos os fluxos", exact: true }).click();
await page.getByLabel("Buscar contato").fill(promiseInfo.contact_name);
// `q` de fato ESTREITA a busca no servidor (não é coincidência a linha
// aparecer): o seed é upsert-like (1 contato, 1 cron_job por run), então
// esta busca só pode bater em exatamente 1 linha na fila inteira da org.
await expect(page.locator('[data-testid="queue-row"]')).toHaveCount(1);
const promiseRow = page.locator('[data-testid="queue-row"]', { hasText: promiseInfo.contact_name });
await expect(promiseRow).toBeVisible();
await expect(promiseRow).toContainText("Promessa");
await expect(promiseRow).toContainText(promiseInfo.reason);
await expect(promiseRow.getByText("Agendada", { exact: true })).toBeVisible();
await expect(promiseRow.locator("td").nth(3)).toContainText(/\d{2}\/\d{2}\/\d{4}/);
// Promessa não tem botão Cancelar (fora de escopo desta task).
await expect(promiseRow.getByRole("button", { name: "Cancelar follow-up" })).toHaveCount(0);
await page.screenshot({ path: "test-results/followup-7.1-06-promise-row.png", fullPage: true });
});
test("viewer vê a fila (leitura) mas sem botão Cancelar (RBAC)", async ({ page }) => {
await login(page, creds.users.viewer!.email);
await page.goto("/app/ai/followups");
await expect(page.getByRole("heading", { name: "Follow-ups" })).toBeVisible();
await page.getByRole("tab", { name: "Fila" }).click();
// any member consegue ver a fila (GET queue é viewer+) — só não há coluna de ação.
await expect(page.getByRole("button", { name: "Cancelar follow-up" })).toHaveCount(0);
});
});