@danhachuel/leadmaster 0.1.20 → 0.1.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/backend/__pycache__/main.cpython-311.pyc +0 -0
  3. package/backend/main.py +64 -3
  4. package/backend/services/__pycache__/__init__.cpython-311.pyc +0 -0
  5. package/backend/services/__pycache__/agents.cpython-311.pyc +0 -0
  6. package/backend/services/__pycache__/crm_agent.cpython-311.pyc +0 -0
  7. package/backend/services/__pycache__/extractors.cpython-311.pyc +0 -0
  8. package/backend/services/__pycache__/flags.cpython-311.pyc +0 -0
  9. package/backend/services/__pycache__/llm.cpython-311.pyc +0 -0
  10. package/backend/services/__pycache__/storage.cpython-311.pyc +0 -0
  11. package/backend/services/agents.py +1 -0
  12. package/backend/services/storage.py +11 -6
  13. package/backend/tests/__pycache__/conftest.cpython-311-pytest-8.4.2.pyc +0 -0
  14. package/backend/tests/__pycache__/test_agents_routes.cpython-311-pytest-8.4.2.pyc +0 -0
  15. package/backend/tests/__pycache__/test_campaigns_routes.cpython-311-pytest-8.4.2.pyc +0 -0
  16. package/backend/tests/__pycache__/test_cnpj_extractor.cpython-311-pytest-8.4.2.pyc +0 -0
  17. package/backend/tests/__pycache__/test_config.cpython-311-pytest-8.4.2.pyc +0 -0
  18. package/backend/tests/__pycache__/test_crm_routes.cpython-311-pytest-8.4.2.pyc +0 -0
  19. package/backend/tests/__pycache__/test_funnel_routes.cpython-311-pytest-8.4.2.pyc +0 -0
  20. package/backend/tests/__pycache__/test_instagram_extractor.cpython-311-pytest-8.4.2.pyc +0 -0
  21. package/backend/tests/__pycache__/test_launcher_protections.cpython-311-pytest-8.4.2.pyc +0 -0
  22. package/backend/tests/__pycache__/test_maps_extractor.cpython-311-pytest-8.4.2.pyc +0 -0
  23. package/backend/tests/__pycache__/test_metrics.cpython-311-pytest-8.4.2.pyc +0 -0
  24. package/backend/tests/__pycache__/test_process_flags.cpython-311-pytest-8.4.2.pyc +0 -0
  25. package/backend/tests/__pycache__/test_webhook_routes.cpython-311-pytest-8.4.2.pyc +0 -0
  26. package/backend/tests/test_funnel_routes.py +65 -0
  27. package/backend/tests/test_webhook_routes.py +83 -0
  28. package/frontend/dist/assets/index-CX_h90mG.js +90 -0
  29. package/frontend/dist/assets/{index-BBxljeDe.css → index-ViDiQGJH.css} +1 -1
  30. package/frontend/dist/index.html +2 -2
  31. package/package.json +1 -1
  32. package/frontend/dist/assets/index-ErHj9z-h.js +0 -90
package/CHANGELOG.md CHANGED
@@ -37,6 +37,52 @@ normaliza para a forma canônica (ex.: `0.1.0`).
37
37
  falha se `process.stdin.resume();` desaparecer, se `.unref()` aparecer, se o
38
38
  ciclo de vida vinculado for removido ou se os handlers globais sumirem.
39
39
 
40
+ ## [0.1.22] — 2026-11-09
41
+
42
+ ### Adicionado
43
+
44
+ - **Funil de Prospecção (visão macro de conversão):**
45
+ - Rota `GET /api/funnel`: conta os leads por etapa do CRM (`Leads → Em
46
+ Contato → Proposta → Fechado`), calcula a taxa de conversão de cada
47
+ transição (`para/de × 100`, 1 decimal) e a queda percentual, e identifica
48
+ o **gargalo** — a etapa *origem* da maior queda (onde os leads param).
49
+ Resposta: `{etapas, conversoes, gargalo}`.
50
+ - Página `frontend/src/pages/Funil.tsx` (rota `/funil`, link **"Funil de
51
+ Prospecção"** no menu): **funil em CSS puro** com fatias de largura
52
+ decrescente (nome + quantidade por etapa), **cards de conversão** por
53
+ transição (verde ≥50%, âmbar ≥25%, vermelho abaixo), **alerta de gargalo**
54
+ em vermelho/laranja com "⚠️ Gargalo: muitos leads param em 'X'" e fatia
55
+ do gargalo destacada (von Restorff), zero filtros na tela (Lei de Hick) e
56
+ funil vazio com barra zerada + "Comece a prospectar para ver seu funil"
57
+ (Zeigarnik). Acesso aos dados via `api.funil()` no cliente padronizado.
58
+ - **4 novos testes pytest** (`backend/tests/test_funnel_routes.py`):
59
+ conversões + gargalo com distribuição [10,5,2,1] (taxas 50/40/50 → gargalo
60
+ "Em Contato"), gargalo na primeira etapa, funil vazio (taxas `null`,
61
+ gargalo `null`) e funil sem gargalo (quedas zero).
62
+
63
+ ## [0.1.21] — 2026-11-09
64
+
65
+ ### Adicionado
66
+
67
+ - **Módulo de Webhooks revisado conforme especificação:**
68
+ - `GET /api/webhook` agora retorna também **`ultimo_teste`** (registrado a cada
69
+ teste bem-sucedido no `config.json`).
70
+ - `PUT /api/webhook` e `POST /api/webhook/test` **validam a URL**: só
71
+ `http://`/`https://` são aceitos (400 com mensagem acionável).
72
+ - Novo evento **`campanha.iniciada`** — disparado quando uma campanha começa a
73
+ rodar; migração aditiva garante que ele apareça nas configurações existentes.
74
+ - Frontend `frontend/src/pages/Webhook.tsx` reformulado: eventos exibidos
75
+ diretamente e limpos (**Lead Criado**, **Campanha Iniciada**, **Campanha
76
+ Finalizada** — Lei de Hick), **prevenção de erros** (Nielsen): URL fora do
77
+ padrão http(s) bloqueia o salvamento e marca o input com **borda vermelha**,
78
+ botão **"Salvar Configurações"** em gradiente (Fitts) e **"Testar Webhook"**
79
+ em outline contrastante (von Restorff), toast de sucesso/erro no teste e card
80
+ **"Último teste realizado em: [data/hora]"**.
81
+ - **7 novos testes pytest** (`backend/tests/test_webhook_routes.py`): leitura do
82
+ padrão, salvamento de URL/eventos com persistência, URL inválida (400), teste
83
+ com sucesso (mock do `httpx.post`, registra `ultimo_teste`), teste com falha de
84
+ conexão sem quebrar (mock de `httpx.ConnectError`) e URL de teste inválida.
85
+
40
86
  ## [0.1.20] — 2026-11-09
41
87
 
42
88
  ### Adicionado
package/backend/main.py CHANGED
@@ -602,6 +602,41 @@ def salvar_prompt_agente(agent_id: str, dados: dict):
602
602
  return {"ok": True, "agent_id": agent_id, "prompt": agente["prompt"]}
603
603
 
604
604
 
605
+ # ---------------------------------------------------------------------------
606
+ # Funil de Prospecção (métricas de conversão do CRM)
607
+ # ---------------------------------------------------------------------------
608
+
609
+ ETAPAS_FUNIL = ["Leads", "Em Contato", "Proposta", "Fechado"]
610
+
611
+
612
+ @app.get("/api/funnel")
613
+ def funil_prospeccao():
614
+ """Métricas de conversão do funil, calculadas a partir do leads.json."""
615
+ leads = storage.list_leads()
616
+ quantidades = [
617
+ sum(1 for l in leads if l.get("etapa") == etapa)
618
+ for etapa in ("leads", "em_contato", "proposta", "fechado")
619
+ ]
620
+ etapas = [{"nome": nome, "quantidade": qtd} for nome, qtd in zip(ETAPAS_FUNIL, quantidades)]
621
+
622
+ conversoes = []
623
+ for i in range(len(ETAPAS_FUNIL) - 1):
624
+ de, para = ETAPAS_FUNIL[i], ETAPAS_FUNIL[i + 1]
625
+ if quantidades[i] > 0:
626
+ taxa = round(quantidades[i + 1] / quantidades[i] * 100, 1)
627
+ queda = round(100 - taxa, 1)
628
+ else:
629
+ taxa, queda = None, None
630
+ conversoes.append({"de": de, "para": para, "taxa": taxa, "queda": queda})
631
+
632
+ # Gargalo: etapa ORIGEM da maior queda entre etapas consecutivas — é onde os
633
+ # leads param (ex.: muitos entram em "Em Contato" e não seguem).
634
+ quedas = [c for c in conversoes if c["queda"] is not None and c["queda"] > 0]
635
+ gargalo = max(quedas, key=lambda c: c["queda"])["de"] if quedas else None
636
+
637
+ return {"etapas": etapas, "conversoes": conversoes, "gargalo": gargalo}
638
+
639
+
605
640
  # ---------------------------------------------------------------------------
606
641
  # Configuração e integrações
607
642
  # ---------------------------------------------------------------------------
@@ -712,21 +747,47 @@ def atendimento_chat(dados: ChatIn):
712
747
 
713
748
  @app.get("/api/webhook")
714
749
  def obter_webhook():
715
- return storage.get_config().get("webhook", {})
750
+ webhook = storage.get_config().get("webhook", {})
751
+ return {
752
+ "url": webhook.get("url", ""),
753
+ "eventos": webhook.get("eventos", []),
754
+ "ultimo_teste": webhook.get("ultimo_teste"),
755
+ }
756
+
757
+
758
+ def _url_valida(url: str) -> bool:
759
+ return isinstance(url, str) and (url.startswith("http://") or url.startswith("https://"))
716
760
 
717
761
 
718
762
  @app.put("/api/webhook")
719
763
  def salvar_webhook(patch: dict):
720
764
  config = storage.get_config()
721
765
  webhook = config.get("webhook", {})
766
+ url = patch.get("url", webhook.get("url", ""))
767
+ if url and not _url_valida(url):
768
+ raise HTTPException(400, "URL inválida: deve começar com http:// ou https://.")
722
769
  webhook.update({k: v for k, v in patch.items() if k in ("url", "eventos")})
723
770
  storage.update_config({"webhook": webhook})
724
- return webhook
771
+ return {
772
+ "url": webhook.get("url", ""),
773
+ "eventos": webhook.get("eventos", []),
774
+ "ultimo_teste": webhook.get("ultimo_teste"),
775
+ }
725
776
 
726
777
 
727
778
  @app.post("/api/webhook/test")
728
779
  def testar_webhook(dados: WebhookTestIn):
729
- return agents.testar_webhook(dados.url)
780
+ if dados.url and not _url_valida(dados.url):
781
+ raise HTTPException(400, "URL inválida: deve começar com http:// ou https://.")
782
+ resultado = agents.testar_webhook(dados.url)
783
+ if resultado.get("ok"):
784
+ # Registra o último teste bem-sucedido no config.json.
785
+ config = storage.get_config()
786
+ webhook = config.get("webhook", {})
787
+ webhook["ultimo_teste"] = storage.now_iso()
788
+ storage.update_config({"webhook": webhook})
789
+ resultado["ultimo_teste"] = webhook["ultimo_teste"]
790
+ return resultado
730
791
 
731
792
 
732
793
  # ---------------------------------------------------------------------------
@@ -100,6 +100,7 @@ def iniciar_campanha(campaign_id: str) -> dict:
100
100
  campaign.setdefault("erros", 0)
101
101
  campaign.setdefault("eventos", [])
102
102
  storage.upsert_campaign(campaign)
103
+ disparar_webhook("campanha.iniciada", {"campanha_id": campaign_id, "nome": campaign["nome"]})
103
104
 
104
105
  threading.Thread(target=_executar_campanha, args=(campaign_id,), daemon=True).start()
105
106
  return {"ok": True, "mensagem": "Campanha iniciada."}
@@ -135,7 +135,7 @@ DEFAULT_CONFIG = {
135
135
  },
136
136
  "webhook": {
137
137
  "url": "",
138
- "eventos": ["lead.criado", "lead.etapa", "campanha.concluida"],
138
+ "eventos": ["lead.criado", "campanha.iniciada", "campanha.concluida"],
139
139
  },
140
140
  }
141
141
 
@@ -189,11 +189,16 @@ def _limpar_deprecated(config: dict) -> dict:
189
189
  mudou = True
190
190
  # Evento de webhook do recurso removido.
191
191
  webhook = config.get("webhook")
192
- if isinstance(webhook, dict) and isinstance(webhook.get("eventos"), list):
193
- eventos = [e for e in webhook["eventos"] if e != "video.publicado"]
194
- if eventos != webhook["eventos"]:
195
- webhook["eventos"] = eventos
196
- mudou = True
192
+ if isinstance(webhook, dict):
193
+ if isinstance(webhook.get("eventos"), list):
194
+ eventos = [e for e in webhook["eventos"] if e != "video.publicado"]
195
+ # Migração aditiva: o evento "campanha.iniciada" passou a existir
196
+ # (0.1.21) — garantimos que configs antigas também o ofereçam.
197
+ if "campanha.iniciada" not in eventos:
198
+ eventos.append("campanha.iniciada")
199
+ if eventos != webhook["eventos"]:
200
+ webhook["eventos"] = eventos
201
+ mudou = True
197
202
  # Dados órfãos do pipeline de vídeos: o arquivo só existia para esse recurso.
198
203
  pipeline_path = storage_dir() / PIPELINE_FILE
199
204
  if pipeline_path.exists():
@@ -0,0 +1,65 @@
1
+ """Testes do Funil de Prospecção — GET /api/funnel.
2
+
3
+ Semeadura determinística no leads.json do home temporário para validar
4
+ contagens, taxas de conversão e identificação do gargalo.
5
+ """
6
+
7
+ import json
8
+
9
+ import pytest
10
+ from fastapi.testclient import TestClient
11
+
12
+ import main
13
+
14
+
15
+ @pytest.fixture()
16
+ def client(tmp_path, monkeypatch):
17
+ monkeypatch.setenv("LEADMASTER_HOME", str(tmp_path))
18
+ with TestClient(main.app) as c:
19
+ yield c
20
+
21
+
22
+ def _semear(client, distribuicao):
23
+ """Escreve leads diretamente no leads.json do home do teste."""
24
+ home = main.storage.storage_dir()
25
+ leads = []
26
+ for etapa, qtd in distribuicao.items():
27
+ for i in range(qtd):
28
+ leads.append({"id": f"{etapa}-{i}", "nome": f"Lead {etapa} {i}", "telefone": "", "email": "", "fonte": "Teste", "etapa": etapa})
29
+ (home / "leads.json").write_text(json.dumps(leads), encoding="utf-8")
30
+
31
+
32
+ def test_funil_conversoes_e_gargalo(client):
33
+ # [10, 5, 2, 1]: taxas 50%, 40%, 50% → quedas 50, 60, 50 → gargalo "Em Contato"
34
+ _semear(client, {"leads": 10, "em_contato": 5, "proposta": 2, "fechado": 1})
35
+ r = client.get("/api/funnel")
36
+ assert r.status_code == 200
37
+ corpo = r.json()
38
+ assert [e["quantidade"] for e in corpo["etapas"]] == [10, 5, 2, 1]
39
+ assert [e["nome"] for e in corpo["etapas"]] == ["Leads", "Em Contato", "Proposta", "Fechado"]
40
+ taxas = [c["taxa"] for c in corpo["conversoes"]]
41
+ assert taxas == [50.0, 40.0, 50.0]
42
+ assert corpo["gargalo"] == "Em Contato"
43
+
44
+
45
+ def test_funil_gargalo_primeira_etapa(client):
46
+ # Todos param na primeira etapa: queda 100% em Leads → Em Contato.
47
+ _semear(client, {"leads": 7, "em_contato": 0, "proposta": 0, "fechado": 0})
48
+ corpo = client.get("/api/funnel").json()
49
+ assert corpo["gargalo"] == "Leads"
50
+ assert corpo["conversoes"][0]["taxa"] == 0.0
51
+
52
+
53
+ def test_funil_sem_leads(client):
54
+ _semear(client, {})
55
+ corpo = client.get("/api/funnel").json()
56
+ assert [e["quantidade"] for e in corpo["etapas"]] == [0, 0, 0, 0]
57
+ assert all(c["taxa"] is None for c in corpo["conversoes"])
58
+ assert corpo["gargalo"] is None
59
+
60
+
61
+ def test_funil_sem_gargalo(client):
62
+ # Quedas todas zero (contagens iguais) → sem gargalo definido.
63
+ _semear(client, {"leads": 4, "em_contato": 4, "proposta": 4, "fechado": 4})
64
+ corpo = client.get("/api/funnel").json()
65
+ assert corpo["gargalo"] is None
@@ -0,0 +1,83 @@
1
+ """Testes das rotas de Webhook (backend/main.py).
2
+
3
+ GET/PUT /api/webhook (com validação de URL) e POST /api/webhook/test com o
4
+ HTTP externo mockado — o endpoint de teste nunca quebra com URL inválida ou
5
+ indisponível. HOME isolado em diretório temporário.
6
+ """
7
+
8
+ from unittest.mock import patch
9
+
10
+ import httpx
11
+ import pytest
12
+ from fastapi.testclient import TestClient
13
+
14
+ import main
15
+
16
+
17
+ @pytest.fixture()
18
+ def client(tmp_path, monkeypatch):
19
+ monkeypatch.setenv("LEADMASTER_HOME", str(tmp_path))
20
+ with TestClient(main.app) as c:
21
+ yield c
22
+
23
+
24
+ def test_obter_padrao(client):
25
+ r = client.get("/api/webhook")
26
+ assert r.status_code == 200
27
+ corpo = r.json()
28
+ assert corpo["url"] == ""
29
+ assert "lead.criado" in corpo["eventos"]
30
+ assert corpo["ultimo_teste"] is None
31
+
32
+
33
+ def test_salvar_url_e_eventos(client):
34
+ r = client.put(
35
+ "/api/webhook",
36
+ json={"url": "https://n8n.exemplo.com/webhook/abc", "eventos": ["lead.criado"]},
37
+ )
38
+ assert r.status_code == 200
39
+ corpo = r.json()
40
+ assert corpo["url"] == "https://n8n.exemplo.com/webhook/abc"
41
+ assert corpo["eventos"] == ["lead.criado"]
42
+ # Persistido no config.json
43
+ salvo = client.get("/api/webhook").json()
44
+ assert salvo["url"] == "https://n8n.exemplo.com/webhook/abc"
45
+
46
+
47
+ def test_salvar_url_invalida_400(client):
48
+ r = client.put("/api/webhook", json={"url": "nao-e-uma-url"})
49
+ assert r.status_code == 400
50
+ assert "http" in r.json()["detail"]
51
+
52
+
53
+ def _resposta_ok(*_args, **_kwargs):
54
+ return httpx.Response(200, json={"ok": True})
55
+
56
+
57
+ def test_testar_webhook_sucesso(client):
58
+ client.put("/api/webhook", json={"url": "https://exemplo.com/hook"})
59
+ with patch.object(main.agents.httpx, "post", side_effect=_resposta_ok):
60
+ r = client.post("/api/webhook/test", json={"url": "https://exemplo.com/hook"})
61
+ assert r.status_code == 200
62
+ corpo = r.json()
63
+ assert corpo["ok"] is True
64
+ assert corpo["status_http"] == 200
65
+ assert corpo["ultimo_teste"] # registrado
66
+
67
+
68
+ def test_testar_webhook_conexao_falha_nao_quebra(client):
69
+ # URL indisponível: retorna erro amigável (não 500).
70
+ with patch.object(main.agents.httpx, "post", side_effect=httpx.ConnectError("sem rede")):
71
+ r = client.post("/api/webhook/test", json={"url": "https://inexistente.invalid/hook"})
72
+ assert r.status_code == 200
73
+ assert r.json()["ok"] is False
74
+ assert "mensagem" in r.json()
75
+
76
+
77
+ def test_testar_webhook_url_invalida_400(client):
78
+ # Passa o min_length do modelo, mas falha no esquema http(s) → 400.
79
+ r = client.post("/api/webhook/test", json={"url": "nao-http-url.com/webhook"})
80
+ assert r.status_code == 400
81
+ # URL curta demais falha na validação do modelo.
82
+ r2 = client.post("/api/webhook/test", json={"url": "xyz"})
83
+ assert r2.status_code == 422