mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 09:34:46 +08:00
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:
co-authored by
Claude Fable 5
parent
04215bd560
commit
786d2f6112
@@ -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 },
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,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 },
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user