feat(radar): Radar de Risco — desilhamento C1 da doutrina do sistema vivo

Fecha o gap de visibilidade do follow-up: o anti-morte já vivia forte no
engine (schedule-followup/watchdog/reentry) mas era invisível ao humano.
O Radar de Risco (/app/radar) agrega as demandas ABERTAS que esfriaram,
classificadas em crítico / em risco / em voo (follow-up agendado = sistema
mantém viva), com link para a conversa (ação humana).

- lib/leads/risk-radar.ts: classificação pura + tests/unit/risk-radar.test.ts
- app/api/v1/leads/at-risk/route.ts: GET (agent), filtro org explícito, enriquece
  com cron_jobs (em voo) + conversations (assignee/deep-link)
- app/app/radar/ + hooks/leads/useAtRiskLeads.ts + item no Sidebar
- E2E tests/e2e/risk-radar.spec.ts (verde) + scripts/seed-e2e-radar.ts
- docs/doctrine/sistema-vivo.md: C1 marcado como desilhado

Sem migration: usa crm_leads.status/last_activity_at + cron_jobs existentes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BZVQh4NLFrVnS9XRHVUgZF
This commit is contained in:
Rafael Melgaço
2026-07-24 10:57:27 -03:00
co-authored by Claude Fable 5
parent 04215bd560
commit 786d2f6112
10 changed files with 677 additions and 3 deletions
+178
View File
@@ -0,0 +1,178 @@
/**
* Radar de Risco (desilhamento C1 — doutrina do sistema vivo). GET → demandas
* ABERTAS (`crm_leads.status='open'`) que esfriaram, para o humano ver o que está
* morrendo sem ninguém olhar. Enriquece com follow-up agendado (`cron_jobs` kind='at')
* — se a IA prometeu voltar, a demanda está "em voo", não abandonada. Ver
* docs/doctrine/sistema-vivo.md.
*
* Admin client bypassa RLS: TODA query filtra organization_id explicitamente
* (resolvido do JWT via requireRole, nunca do body).
*/
import { randomUUID } from "node:crypto";
import { type NextRequest } from "next/server";
import { z } from "zod";
import { ok, fail } from "@/lib/api/wrappers";
import { requireRole } from "@/lib/auth/require-role";
import {
classifyRisk,
compareRisk,
RISK_COLD_HOURS,
type RiskBucket,
} from "@/lib/leads/risk-radar";
import { createAdminClient } from "@/lib/supabase/admin";
export const dynamic = "force-dynamic";
// ponytail: teto do pool varrido (leads abertos mais frios primeiro). Escala do
// tenant-alvo (~centenas de leads abertos) cabe nisso; se um tenant estourar, vira
// query paginada com índice (organization_id, status, last_activity_at).
const SCAN_CAP = 500;
const querySchema = z.object({
limit: z.coerce.number().int().min(1).max(200).default(50),
min_hours: z.coerce.number().int().min(0).max(2000).default(RISK_COLD_HOURS),
});
export interface AtRiskLead {
id: string;
title: string;
contact_id: string | null;
contact_name: string | null;
owner_user_id: string | null;
assignee_kind: "user" | "ai" | null;
last_activity_at: string | null;
hours_since_activity: number;
risk: RiskBucket;
in_flight: boolean;
next_followup_at: string | null;
conversation_id: string | null;
pipeline_id: string;
}
export async function GET(req: NextRequest): Promise<Response> {
const requestId = randomUUID();
const authz = await requireRole("agent", { requestId, resource: "leads_at_risk" });
if (!authz.ok) return authz.response;
const { org } = authz;
const parsed = querySchema.safeParse(
Object.fromEntries(new URL(req.url).searchParams.entries()),
);
if (!parsed.success) {
return fail("validation_failed", "Query inválida.", 422, {
requestId,
details: parsed.error.flatten(),
});
}
const { limit, min_hours } = parsed.data;
const admin = createAdminClient();
const now = new Date();
const nowIso = now.toISOString();
const { data: leads, error: leadsErr } = await admin
.from("crm_leads")
.select("id, title, contact_id, owner_user_id, last_activity_at, created_at, pipeline_id")
.eq("organization_id", org.orgId)
.eq("status", "open")
.order("last_activity_at", { ascending: true, nullsFirst: true })
.limit(SCAN_CAP);
if (leadsErr) {
return fail("internal_error", "Falha ao carregar o radar.", 500, { requestId });
}
const rows = leads ?? [];
const contactIds = [...new Set(rows.map((l) => l.contact_id).filter((c): c is string => c !== null))];
// Follow-ups agendados no futuro por contato (mais próximo primeiro) — "em voo".
const followupByContact = new Map<string, string>();
// Uma conversa por contato (qualquer serve para o deep-link do inbox) + assignee.
const convByContact = new Map<string, { id: string; assignee_kind: "user" | "ai" | null }>();
const nameByContact = new Map<string, string | null>();
if (contactIds.length > 0) {
const [followups, convs, contacts] = await Promise.all([
admin
.from("cron_jobs")
.select("contact_id, next_run_at")
.eq("organization_id", org.orgId)
.eq("kind", "at")
.eq("enabled", true)
.gt("next_run_at", nowIso)
.in("contact_id", contactIds),
admin
.from("conversations")
.select("id, contact_id, assignee_kind")
.eq("organization_id", org.orgId)
.in("contact_id", contactIds),
admin
.from("contacts")
.select("id, name, display_name")
.eq("organization_id", org.orgId)
.in("id", contactIds),
]);
for (const f of followups.data ?? []) {
const prev = followupByContact.get(f.contact_id);
if (!prev || f.next_run_at < prev) followupByContact.set(f.contact_id, f.next_run_at);
}
for (const c of convs.data ?? []) {
if (!convByContact.has(c.contact_id)) {
convByContact.set(c.contact_id, { id: c.id, assignee_kind: c.assignee_kind ?? null });
}
}
for (const p of contacts.data ?? []) {
nameByContact.set(p.id, p.display_name ?? p.name ?? null);
}
}
const radar: AtRiskLead[] = [];
const counts: Record<RiskBucket, number> = { critico: 0, em_risco: 0, em_voo: 0, em_dia: 0 };
for (const l of rows) {
const lastActivity = l.last_activity_at ?? l.created_at;
if (!lastActivity) continue;
const nextFollowupAt = l.contact_id ? followupByContact.get(l.contact_id) ?? null : null;
const { bucket, hoursSinceActivity, onRadar } = classifyRisk({
lastActivityAt: new Date(lastActivity),
now,
inFlight: nextFollowupAt !== null,
});
if (!onRadar || hoursSinceActivity < min_hours) continue;
const conv = l.contact_id ? convByContact.get(l.contact_id) ?? null : null;
counts[bucket] += 1;
radar.push({
id: l.id,
title: l.title,
contact_id: l.contact_id,
contact_name: l.contact_id ? nameByContact.get(l.contact_id) ?? null : null,
owner_user_id: l.owner_user_id,
assignee_kind: conv?.assignee_kind ?? null,
last_activity_at: l.last_activity_at,
hours_since_activity: Math.round(hoursSinceActivity),
risk: bucket,
in_flight: nextFollowupAt !== null,
next_followup_at: nextFollowupAt,
conversation_id: conv?.id ?? null,
pipeline_id: l.pipeline_id,
});
}
radar.sort((a, b) =>
compareRisk(
{ bucket: a.risk, hoursSinceActivity: a.hours_since_activity },
{ bucket: b.risk, hoursSinceActivity: b.hours_since_activity },
),
);
const items = radar.slice(0, limit);
return ok(
{
items,
counts: { critico: counts.critico, em_risco: counts.em_risco, em_voo: counts.em_voo },
total: radar.length,
},
{ requestId },
);
}
+135
View File
@@ -0,0 +1,135 @@
"use client";
import Link from "next/link";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { useAtRiskLeads, type AtRiskLead } from "@/hooks/leads/useAtRiskLeads";
import type { RiskBucket } from "@/lib/leads/risk-radar";
import {
ArrowRight,
CheckCircle,
ClockCountdown,
PaperPlaneTilt,
Robot,
Warning,
} from "@/lib/ui/icons";
const RISK_META: Record<
Exclude<RiskBucket, "em_dia">,
{ label: string; variant: "error" | "warning" | "info" }
> = {
critico: { label: "Crítico", variant: "error" },
em_risco: { label: "Em risco", variant: "warning" },
em_voo: { label: "Em voo", variant: "info" },
};
function coldFor(hours: number): string {
if (hours < 48) return `parado há ${hours}h`;
return `parado há ${Math.round(hours / 24)}d`;
}
function followupWhen(iso: string): string {
const diffMs = new Date(iso).getTime() - Date.now();
if (diffMs <= 0) return "agora";
const hours = Math.round(diffMs / 3_600_000);
if (hours < 48) return `em ${Math.max(1, hours)}h`;
return `em ${Math.round(hours / 24)}d`;
}
export function RiskRadarList() {
const { data, isLoading } = useAtRiskLeads();
if (isLoading) {
return (
<div className="space-y-2">
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
</div>
);
}
if (!data || data.total === 0) {
return (
<div
className="flex flex-1 flex-col items-center justify-center gap-2 py-16 text-center"
data-testid="radar-empty"
>
<CheckCircle size={28} className="text-success-fg/70" aria-hidden />
<p className="text-sm font-medium">Nenhuma demanda em risco</p>
<p className="text-xs text-muted-foreground">
Toda demanda aberta teve atividade recente ou já tem um retorno agendado.
</p>
</div>
);
}
return (
<div className="flex min-h-0 flex-1 flex-col gap-4">
<div className="flex flex-wrap gap-2" data-testid="radar-counts">
<Badge variant="error">{data.counts.critico} crítico</Badge>
<Badge variant="warning">{data.counts.em_risco} em risco</Badge>
<Badge variant="info">{data.counts.em_voo} em voo</Badge>
</div>
<ul className="divide-y divide-border rounded-lg border border-border">
{data.items.map((lead) => (
<RadarRow key={lead.id} lead={lead} />
))}
</ul>
</div>
);
}
function RadarRow({ lead }: { lead: AtRiskLead }) {
const meta = RISK_META[lead.risk as Exclude<RiskBucket, "em_dia">] ?? RISK_META.em_risco;
const href = lead.conversation_id
? `/app/inbox?id=${lead.conversation_id}`
: `/app/pipelines/${lead.pipeline_id}`;
const assignee =
lead.assignee_kind === "ai"
? { icon: <Robot size={13} aria-hidden />, text: "Assistente" }
: lead.owner_user_id || lead.assignee_kind === "user"
? { icon: null, text: "Com atendente" }
: { icon: null, text: "Sem dono" };
return (
<li data-testid="radar-item" data-risk={lead.risk}>
<Link
href={href}
className="flex items-start gap-3 px-4 py-3 transition-colors hover:bg-accent/50"
>
<Badge variant={meta.variant} className="mt-0.5 shrink-0">
{meta.label}
</Badge>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{lead.title}</p>
<p className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
{lead.contact_name ? <span className="truncate">{lead.contact_name}</span> : null}
<span className="inline-flex items-center gap-1">
<ClockCountdown size={13} aria-hidden />
{coldFor(lead.hours_since_activity)}
</span>
<span className="inline-flex items-center gap-1">
{assignee.icon}
{assignee.text}
</span>
</p>
{lead.in_flight && lead.next_followup_at ? (
<p className="mt-1 inline-flex items-center gap-1 text-xs text-info-fg">
<PaperPlaneTilt size={13} aria-hidden />
Assistente retorna {followupWhen(lead.next_followup_at)}
</p>
) : (
<p className="mt-1 inline-flex items-center gap-1 text-xs text-warning-fg">
<Warning size={13} aria-hidden />
Sem próximo passo agendado
</p>
)}
</div>
<ArrowRight size={16} className="mt-1 shrink-0 text-muted-foreground" aria-hidden />
</Link>
</li>
);
}
+25
View File
@@ -0,0 +1,25 @@
import { redirect } from "next/navigation";
import { requireAuth, resolveActiveOrg } from "@/lib/auth/server";
import { RiskRadarList } from "./_components/RiskRadarList";
export const dynamic = "force-dynamic";
export default async function RadarPage() {
const user = await requireAuth();
const activeOrg = await resolveActiveOrg(user);
if (!activeOrg) redirect("/app");
return (
<div className="flex h-full flex-col gap-6 p-6">
<header>
<h1 className="text-2xl font-semibold tracking-tight">Radar de risco</h1>
<p className="text-sm text-muted-foreground">
Demandas abertas que esfriaram e precisam de você. Se o assistente já agendou um
retorno, aparece como “em voo”; sem próximo passo, é risco de perder o cliente.
</p>
</header>
<RiskRadarList />
</div>
);
}
+2 -1
View File
@@ -2,7 +2,7 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useTransition } from "react";
import { Kanban, Users, UsersThree, Gear, CaretDoubleLeft, CaretDoubleRight, Inbox, ScalesSimple, Robot, PlugsConnected, ChartBar, WebhooksLogo, FlowArrow, FileText } from "@/lib/ui/icons";
import { Kanban, Users, UsersThree, Gear, CaretDoubleLeft, CaretDoubleRight, Inbox, ScalesSimple, Robot, PlugsConnected, ChartBar, WebhooksLogo, FlowArrow, FileText, ClockCountdown } from "@/lib/ui/icons";
import type { Icon as PhosphorIcon } from "@phosphor-icons/react";
import { cn } from "@/lib/utils";
import { toggleSidebar } from "@/app/actions/shell/toggleSidebar";
@@ -19,6 +19,7 @@ interface NavItem {
const NAV_ITEMS: NavItem[] = [
{ href: "/app/inbox", label: "Inbox", icon: Inbox },
{ href: "/app/radar", label: "Radar", icon: ClockCountdown },
{ href: "/app/connections", label: "Conexões", icon: PlugsConnected, healthDot: true },
{ href: "/app/kanban", label: "Kanban", icon: Kanban },
{ href: "/app/contacts", label: "Contatos", icon: Users },
+2 -2
View File
@@ -93,9 +93,9 @@ Primeira passada ancorada no grafo (`graphify`) + `docs/architecture/`. A doutri
**Verificado em código (2026-07-24):**
1. ✅ **Timeline de `crm_lead_activities` renderizada** — VIVO. `components/inbox/CRMSidePanel.tsx` busca `crm_lead_activities` e renderiza como feed no inbox (API `app/api/v1/contacts/[id]/timeline/route.ts`). *Nuance:* o feed vive no inbox, não no card do pipeline — desilhar o card seria o próximo passo se o operador trabalhar direto do Kanban. (invariante 3)
2. ✅ **Payload de continuidade IA→humano** — VIVO. `buildHandoffSummary()` (`lib/agent-engine/agent/human-handoff.ts:252`) monta resumo contextual (rolling summary + compromissos + objeções + próxima ação) e vai ao inbox do humano, nunca a log. (invariante 2)
3. ⚠️ **Follow-up como invariante de lead — ILHA PARCIAL (gap confirmado).** O mecanismo anti-morte é forte no *engine* (`schedule-followup.ts`, `followup-turn.ts`, `cron/scheduler.ts`, `edge/crm/session-watchdog.ts`, re-entrada determinística), mas **todo o domínio vive só em `lib/agent-engine/**` e `workers/**` — zero UI.** Não há tela agregada onde o humano veja leads com follow-up pendente / em risco de morrer. Invariante 4 satisfeito para a IA, **invisível para o humano.** É o desilhamento prioritário: um painel/insight de "demandas em voo e em risco" fecha a alça de saída do engine de follow-up para o olho humano. (invariantes 4 + 5)
3. ✅ **Follow-up como invariante de lead — DESILHADO (2026-07-24).** Era ilha parcial: o anti-morte era forte no *engine* (`schedule-followup.ts`, `followup-turn.ts`, `cron/scheduler.ts`, `edge/crm/session-watchdog.ts`), mas invisível para o humano. Fechado com o **Radar de Risco** (`/app/radar`): tela que agrega as demandas abertas que esfriaram, classificadas em crítico / em risco / em voo (follow-up agendado = sistema mantém viva). Arestas: **entrada** = `crm_leads` (abertos, `last_activity_at`) + `cron_jobs` (follow-ups agendados); **saída** = link → inbox/conversa (ação humana). Código: `lib/leads/risk-radar.ts` (classificação pura + teste), `app/api/v1/leads/at-risk/route.ts`, `app/app/radar/`. Provado em tela (E2E `tests/e2e/risk-radar.spec.ts` verde + screenshot). (invariantes 4 + 5)
> Auditoria linha-a-linha via `graphify` + leitura de fontes. C1 é o backlog de desilhamento real; C2/C3 confirmados vivos.
> Auditoria linha-a-linha via `graphify` + leitura de fontes. C1 desilhado com o Radar de Risco; C2/C3 já eram vivos.
---
+37
View File
@@ -0,0 +1,37 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { apiClient } from "@/lib/api/client";
import type { RiskBucket } from "@/lib/leads/risk-radar";
export interface AtRiskLead {
id: string;
title: string;
contact_id: string | null;
contact_name: string | null;
owner_user_id: string | null;
assignee_kind: "user" | "ai" | null;
last_activity_at: string | null;
hours_since_activity: number;
risk: RiskBucket;
in_flight: boolean;
next_followup_at: string | null;
conversation_id: string | null;
pipeline_id: string;
}
export interface AtRiskData {
items: AtRiskLead[];
counts: { critico: number; em_risco: number; em_voo: number };
total: number;
}
/** Radar de risco (C1). Polling 60s — a atividade dos leads muda no worker/inbox. */
export function useAtRiskLeads() {
return useQuery({
queryKey: ["leads-at-risk"],
refetchInterval: 60_000,
queryFn: () =>
apiClient.get<{ data: AtRiskData }>(`/api/v1/leads/at-risk`).then((r) => r.data),
});
}
+65
View File
@@ -0,0 +1,65 @@
/**
* Radar de Risco (desilhamento C1 da doutrina do sistema vivo) — lógica pura de
* classificação. Uma demanda aberta que esfriou e não tem próximo passo garantido
* está morrendo sem ninguém ver; o radar a torna visível. Ver docs/doctrine/sistema-vivo.md.
*
* Regra: se a IA agendou um follow-up (in_flight), o sistema ainda a mantém viva —
* "em voo". Sem follow-up e fria = "em risco"; muito fria = "crítico". Fresca (< janela
* mínima) sai do radar: nada a fazer ainda.
*/
export type RiskBucket = "critico" | "em_risco" | "em_voo" | "em_dia";
// ponytail: janelas fixas; viram knob por pipeline (settings.fields) se um tenant pedir.
export const RISK_COLD_HOURS = 24;
export const RISK_CRITICAL_HOURS = 72;
export interface RiskInput {
/** last_activity_at do lead (ou created_at se nunca teve atividade). */
lastActivityAt: Date;
now: Date;
/** há follow-up agendado no futuro (cron_jobs kind='at' enabled) para o contato. */
inFlight: boolean;
}
export interface RiskResult {
bucket: RiskBucket;
hoursSinceActivity: number;
/** entra no radar? em_dia (fresca e sem risco) fica de fora. */
onRadar: boolean;
}
export function classifyRisk({ lastActivityAt, now, inFlight }: RiskInput): RiskResult {
const hoursSinceActivity = Math.max(
0,
(now.getTime() - lastActivityAt.getTime()) / 3_600_000,
);
let bucket: RiskBucket;
if (hoursSinceActivity < RISK_COLD_HOURS) {
bucket = "em_dia";
} else if (inFlight) {
bucket = "em_voo";
} else if (hoursSinceActivity >= RISK_CRITICAL_HOURS) {
bucket = "critico";
} else {
bucket = "em_risco";
}
return { bucket, hoursSinceActivity, onRadar: bucket !== "em_dia" };
}
/** Ordem de triagem: crítico primeiro, depois em risco, depois em voo. */
const BUCKET_RANK: Record<RiskBucket, number> = {
critico: 0,
em_risco: 1,
em_voo: 2,
em_dia: 3,
};
/** Ordena por bucket e, dentro do bucket, o mais frio primeiro. */
export function compareRisk(
a: { bucket: RiskBucket; hoursSinceActivity: number },
b: { bucket: RiskBucket; hoursSinceActivity: number },
): number {
const byBucket = BUCKET_RANK[a.bucket] - BUCKET_RANK[b.bucket];
return byBucket !== 0 ? byBucket : b.hoursSinceActivity - a.hoursSinceActivity;
}
+110
View File
@@ -0,0 +1,110 @@
/**
* Seed E2E do Radar de Risco (C1): um lead ABERTO cuja última atividade foi há 5
* dias e SEM follow-up agendado — o caso "crítico / sem próximo passo" que o radar
* existe para tornar visível. Alimenta tests/e2e/risk-radar.spec.ts.
*
* Idempotente: upsert por (organization_id, title). Depende de .e2e-creds.json
* (rode scripts/seed-e2e-credentials.ts antes). Grava o bloco `radar` em .e2e-creds.json.
*
* Run: npx tsx scripts/seed-e2e-radar.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 AT_RISK_TITLE = "Demanda E2E em risco (radar)";
interface Creds {
org_id: string;
radar?: unknown;
}
async function main(): Promise<void> {
const creds = JSON.parse(fs.readFileSync(CREDS_PATH, "utf8")) as Creds;
const orgId = creds.org_id;
const { data: pipeline, error: pErr } = await admin
.from("crm_pipelines")
.select("id")
.eq("organization_id", orgId)
.eq("is_default", true)
.maybeSingle();
if (pErr || !pipeline) throw new Error(`default pipeline not found: ${pErr?.message}`);
const pipelineId = (pipeline as { id: string }).id;
const { data: stage, error: sErr } = await admin
.from("crm_stages")
.select("id")
.eq("pipeline_id", pipelineId)
.eq("is_archived", false)
.order("position")
.limit(1)
.maybeSingle();
if (sErr || !stage) throw new Error(`stage not found: ${sErr?.message}`);
const stageId = (stage as { id: string }).id;
// 5 dias sem atividade → crítico; sem contato/follow-up → "sem próximo passo".
const coldAt = new Date(Date.now() - 5 * 24 * 3_600_000).toISOString();
const { data: existing } = await admin
.from("crm_leads")
.select("id")
.eq("organization_id", orgId)
.eq("title", AT_RISK_TITLE)
.maybeSingle();
let leadId: string;
if (existing) {
leadId = (existing as { id: string }).id;
await admin
.from("crm_leads")
.update({ stage_id: stageId, owner_user_id: null, last_activity_at: coldAt } as never)
.eq("id", leadId);
console.log(`[seed] radar lead existing: ${leadId}`);
} else {
const { data, error } = await admin
.from("crm_leads")
.insert({
organization_id: orgId,
pipeline_id: pipelineId,
stage_id: stageId,
title: AT_RISK_TITLE,
owner_user_id: null,
position_in_stage: 3000,
source: "manual",
last_activity_at: coldAt,
} as never)
.select("id")
.single();
if (error || !data) throw new Error(`insert radar lead: ${error?.message}`);
leadId = (data as { id: string }).id;
console.log(`[seed] radar lead created: ${leadId}`);
}
creds.radar = { pipeline_id: pipelineId, at_risk_lead_id: leadId, at_risk_title: AT_RISK_TITLE };
fs.writeFileSync(CREDS_PATH, JSON.stringify(creds, null, 2));
console.log(`\n✅ Radar seed completo. lead=${leadId} (frio desde ${coldAt})`);
}
main().catch((err) => {
console.error("❌ Radar seed falhou:", err);
process.exit(1);
});
+68
View File
@@ -0,0 +1,68 @@
/**
* Radar de Risco (C1 — desilhamento da doutrina do sistema vivo). Prova, na
* perspectiva do usuário real: o atendente entra no Radar e VÊ a demanda aberta
* que esfriou (5 dias sem atividade, sem próximo passo) — o que antes morria
* invisível no engine. Login como manager (sem MFA; vê todos os leads da org).
*
* Pré-requisito: seed de credenciais + seed do radar (rodados aqui se faltarem).
*/
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 }>;
radar?: { at_risk_title: string };
}
function loadCreds(): Creds {
const needsBase = (): boolean => {
if (!fs.existsSync(CREDS_PATH)) return true;
const c = JSON.parse(fs.readFileSync(CREDS_PATH, "utf8")) as Creds;
return !c.users?.manager;
};
if (needsBase()) {
execFileSync("npx", ["tsx", "scripts/seed-e2e-credentials.ts"], { stdio: "inherit" });
}
let c = JSON.parse(fs.readFileSync(CREDS_PATH, "utf8")) as Creds;
if (!c.radar?.at_risk_title) {
execFileSync("npx", ["tsx", "scripts/seed-e2e-radar.ts"], { stdio: "inherit" });
c = JSON.parse(fs.readFileSync(CREDS_PATH, "utf8")) as Creds;
}
return c;
}
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\//);
}
test("o atendente vê no Radar a demanda aberta que esfriou sem próximo passo", async ({ page }) => {
await login(page, creds.users.manager!.email);
// Navega pelo menu lateral — como um usuário real, não por deep-link.
await page.getByRole("link", { name: "Radar" }).click();
await page.waitForURL(/\/app\/radar/);
await expect(page.getByRole("heading", { name: "Radar de risco" })).toBeVisible();
// A demanda em risco seedada aparece como um item do radar.
const item = page.locator('[data-testid="radar-item"]', {
hasText: creds.radar!.at_risk_title,
});
await expect(item).toBeVisible();
// ...classificada como crítica e sinalizada como sem próximo passo agendado.
await expect(item).toHaveAttribute("data-risk", "critico");
await expect(item.getByText("Crítico")).toBeVisible();
await expect(item.getByText(/Sem próximo passo/)).toBeVisible();
});
+55
View File
@@ -0,0 +1,55 @@
import { describe, it, expect } from "vitest";
import {
classifyRisk,
compareRisk,
RISK_COLD_HOURS,
RISK_CRITICAL_HOURS,
} from "@/lib/leads/risk-radar";
const now = new Date("2026-07-24T12:00:00Z");
const hoursAgo = (h: number) => new Date(now.getTime() - h * 3_600_000);
describe("classifyRisk", () => {
it("demanda fresca fica fora do radar", () => {
const r = classifyRisk({ lastActivityAt: hoursAgo(2), now, inFlight: false });
expect(r.bucket).toBe("em_dia");
expect(r.onRadar).toBe(false);
});
it("fria sem follow-up entre 24h e 72h é em_risco", () => {
const r = classifyRisk({ lastActivityAt: hoursAgo(30), now, inFlight: false });
expect(r.bucket).toBe("em_risco");
expect(r.onRadar).toBe(true);
});
it("muito fria sem follow-up é crítico", () => {
const r = classifyRisk({ lastActivityAt: hoursAgo(100), now, inFlight: false });
expect(r.bucket).toBe("critico");
});
it("fria mas com follow-up agendado é em_voo (sistema mantém viva)", () => {
const r = classifyRisk({ lastActivityAt: hoursAgo(100), now, inFlight: true });
expect(r.bucket).toBe("em_voo");
expect(r.onRadar).toBe(true);
});
it("bordas das janelas", () => {
expect(classifyRisk({ lastActivityAt: hoursAgo(RISK_COLD_HOURS), now, inFlight: false }).bucket).toBe("em_risco");
expect(classifyRisk({ lastActivityAt: hoursAgo(RISK_COLD_HOURS - 0.1), now, inFlight: false }).bucket).toBe("em_dia");
expect(classifyRisk({ lastActivityAt: hoursAgo(RISK_CRITICAL_HOURS), now, inFlight: false }).bucket).toBe("critico");
});
});
describe("compareRisk", () => {
it("crítico vem antes de em_risco antes de em_voo; dentro, mais frio primeiro", () => {
const rows = [
{ bucket: "em_voo" as const, hoursSinceActivity: 200 },
{ bucket: "em_risco" as const, hoursSinceActivity: 25 },
{ bucket: "critico" as const, hoursSinceActivity: 80 },
{ bucket: "critico" as const, hoursSinceActivity: 500 },
];
const sorted = [...rows].sort(compareRisk).map((r) => `${r.bucket}:${r.hoursSinceActivity}`);
expect(sorted).toEqual(["critico:500", "critico:80", "em_risco:25", "em_voo:200"]);
});
});