@danhachuel/leadmaster 0.1.17 → 0.1.19

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 (26) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/backend/__pycache__/main.cpython-311.pyc +0 -0
  3. package/backend/main.py +42 -2
  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/tests/__pycache__/conftest.cpython-311-pytest-8.4.2.pyc +0 -0
  12. package/backend/tests/__pycache__/test_campaigns_routes.cpython-311-pytest-8.4.2.pyc +0 -0
  13. package/backend/tests/__pycache__/test_cnpj_extractor.cpython-311-pytest-8.4.2.pyc +0 -0
  14. package/backend/tests/__pycache__/test_config.cpython-311-pytest-8.4.2.pyc +0 -0
  15. package/backend/tests/__pycache__/test_crm_routes.cpython-311-pytest-8.4.2.pyc +0 -0
  16. package/backend/tests/__pycache__/test_instagram_extractor.cpython-311-pytest-8.4.2.pyc +0 -0
  17. package/backend/tests/__pycache__/test_launcher_protections.cpython-311-pytest-8.4.2.pyc +0 -0
  18. package/backend/tests/__pycache__/test_maps_extractor.cpython-311-pytest-8.4.2.pyc +0 -0
  19. package/backend/tests/__pycache__/test_metrics.cpython-311-pytest-8.4.2.pyc +0 -0
  20. package/backend/tests/__pycache__/test_process_flags.cpython-311-pytest-8.4.2.pyc +0 -0
  21. package/backend/tests/test_config.py +90 -0
  22. package/backend/tests/test_metrics.py +112 -0
  23. package/frontend/dist/assets/{index-BWyDEt5a.js → index-CXuYUAoQ.js} +4 -4
  24. package/frontend/dist/assets/{index-BKo_OphM.css → index-ib1LXo9v.css} +1 -1
  25. package/frontend/dist/index.html +2 -2
  26. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -37,6 +37,76 @@ 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.19] — 2026-11-09
41
+
42
+ ### Adicionado
43
+
44
+ - **`POST /api/config`** — alias do `PUT /api/config` (frontend continua usando
45
+ PUT; clientes externos podem usar POST, como na especificação da tela de
46
+ Ferramentas).
47
+ - **Prevenção de erros (Nielsen) — trim automático:** todo texto do payload de
48
+ configuração recebe `strip()` antes de gravar (recursivo, inclui arrays e
49
+ objetos aninhados) — espaço acidental em API Key ou URL não vira falha
50
+ misteriosa depois.
51
+ - **Validação de payload (422):** seção que é objeto no padrão (`llm`,
52
+ `extratores`, `integracoes`, `agentes`, `webhook`, `perfil`) recebendo valor
53
+ escalar é rejeitada com mensagem acionável, em vez de corromper o
54
+ `config.json` e a UI.
55
+ - **Ícone de olho no campo de API Key** (`CampoSecreto` em Ferramentas):
56
+ alterna visibilidade da chave (von Restorff) — conferir/colar sem apagar o
57
+ campo. Continua com placeholder mascarado e aviso "deixe vazio para manter".
58
+ - **Testes (`backend/tests/test_config.py`)** — 7 novos testes de
59
+ `GET/POST/PUT /api/config`: estrutura padrão, segredos nunca expostos ( GET
60
+ devolve vazio + `_configurado` e o corpo cru não contém a chave),
61
+ persistência + trim, segredo vazio não apaga o atual, PUT funcional,
62
+ payload inválido → 422 e trim em arrays aninhados.
63
+ Suíte completa: **51 passed**.
64
+
65
+ ### Auditoria
66
+
67
+ - **Tela de Ferramentas:** auditada contra a especificação — a página
68
+ (`frontend/src/pages/Ferramentas.tsx`, rota `/ferramentas` já no menu) já
69
+ cobria LLM (provedor/base URL/modelo/API Key), Extratores (Maps, igmapper,
70
+ templates em accordion, CNPJ/Scrapling), Perfil e o botão gradiente "Salvar
71
+ configurações" com toast. Gaps entregues acima: POST, trim, validação, olho.
72
+ - **Integrações:** a seção cobre **OpenOutreach** (disparo) e **pipecat**
73
+ (atendimento por voz), que são as integrações do produto. **YouTube/Meta não
74
+ foram implementados** — o AGENTS.md (§1) é explícito: produção/upload de
75
+ vídeos **não faz parte** do LeadMaster (o storage já remove chaves desses
76
+ sistemas em migração). Se o pipeline de vídeos entrar no roadmap um dia, será
77
+ como novo módulo com decisão de produto registrada.
78
+
79
+ ## [0.1.18] — 2026-11-09
80
+
81
+ ### Adicionado
82
+
83
+ - **Zero states no Painel (Efeito Zeigarnik)** — com base vazia a tela não
84
+ mostra mais gráficos em branco:
85
+ - `LeadsStageChart`: **"Nenhum dado ainda. Comece extraindo leads!"** com
86
+ orientação aos extratores (Maps/Instagram/CNPJ);
87
+ - `CampaignsDoughnut`: **"Nenhuma campanha disparada ainda."** com orientação
88
+ a Disparos;
89
+ - ambos em bloco tracejado discreto, sem quebrar o layout (altura preservada).
90
+ - **Testes das métricas (`backend/tests/test_metrics.py`)** — 4 novos testes da
91
+ rota `GET /api/metrics` com dados conhecidos em `LEADMASTER_HOME` temporário
92
+ (seeds de demo neutralizados):
93
+ - base vazia: totais zerados e taxas padrão (100% entrega / 0% erro);
94
+ - leads conhecidos: `leads_total` e `leads_por_etapa` exatos por coluna;
95
+ - consolidação de campanhas: enviadas/erros somados e taxas proporcionais
96
+ (150/162 → 92,6% e 7,4%);
97
+ - reflexo do Kanban: mover lead de coluna via `PUT /api/crm/leads/{id}/etapa`
98
+ recalcula `leads_por_etapa` na hora.
99
+ - Suíte completa: **44 passed**.
100
+
101
+ ### Auditoria
102
+
103
+ - **`GET /api/metrics` (dinâmica):** auditada contra a especificação — já lê
104
+ `leads.json`/`campaigns.json` em tempo real e devolve `leads_total`,
105
+ `leads_por_etapa` (leads/em_contato/proposta/fechado), totais consolidados
106
+ das campanhas, `taxa_entrega`/`taxa_erro` (fallback 100%/0% sem campanhas) e
107
+ `conexao` (geral/llm/openoutreach). Nenhuma alteração de cálculo necessária —
108
+ agora com cobertura de testes e zero states na UI.
109
+
40
110
  ## [0.1.17] — 2026-11-09
41
111
 
42
112
  ### Corrigido
package/backend/main.py CHANGED
@@ -558,13 +558,53 @@ def buscar_cnpj(dados: CnpjSearchIn):
558
558
 
559
559
  @app.get("/api/config")
560
560
  def obter_config():
561
+ """Configuração local (config.json) — segredos NUNCA são expostos (regra do
562
+ AGENTS.md): campo devolvido vazio + flag `<campo>_configurado`."""
563
+ return storage.redact_config(storage.get_config())
564
+
565
+
566
+ def _validar_patch(patch: dict) -> None:
567
+ """Rejeita payload que quebraria a estrutura da config.
568
+
569
+ Seções que são objetos no padrão (llm, extratores, integracoes...) precisam
570
+ continuar objetos — um valor escalar no lugar corromperia o arquivo e a UI.
571
+ Nenhum campo é obrigatório: o app roda com os defaults e valida credenciais
572
+ apenas no momento da ação (regra do AGENTS.md — sem travas de pré-registo).
573
+ """
574
+ for chave, valor in patch.items():
575
+ padrao = storage.DEFAULT_CONFIG.get(chave)
576
+ if isinstance(padrao, dict) and not isinstance(valor, dict):
577
+ raise HTTPException(422, f"O campo '{chave}' deve ser um objeto com os subcampos.")
578
+
579
+
580
+ def _aparar_textos(valor):
581
+ """Nielsen (prevenção de erros): trim automático em textos antes de salvar —
582
+ espaços acidentais no início/fim (copiar-e-colar de chaves) não quebram a
583
+ credencial nem disparam falsos "inválido" depois."""
584
+ if isinstance(valor, str):
585
+ return valor.strip()
586
+ if isinstance(valor, dict):
587
+ return {k: _aparar_textos(v) for k, v in valor.items()}
588
+ if isinstance(valor, list):
589
+ return [_aparar_textos(v) for v in valor]
590
+ return valor
591
+
592
+
593
+ def _salvar_config(patch: dict):
594
+ _validar_patch(patch)
595
+ storage.update_config(_aparar_textos(patch))
561
596
  return storage.redact_config(storage.get_config())
562
597
 
563
598
 
564
599
  @app.put("/api/config")
600
+ @app.post("/api/config")
565
601
  def salvar_config(patch: dict):
566
- storage.update_config(patch)
567
- return storage.redact_config(storage.get_config())
602
+ """Salva a configuração em config.json (merge profundo, atômico, sob lock).
603
+
604
+ POST e PUT são equivalentes (o frontend usa PUT). Segredos vazios mantêm o
605
+ valor atual; textos recebem trim automático. Devolve a config mascarada.
606
+ """
607
+ return _salvar_config(patch)
568
608
 
569
609
 
570
610
  @app.post("/api/config/test/llm")
@@ -0,0 +1,90 @@
1
+ """Testes das rotas de configuração — GET/POST/PUT /api/config.
2
+
3
+ Valida a estrutura padrão, o sigulo absoluto dos segredos (regra do
4
+ AGENTS.md: GET nunca expõe chaves), a persistência em config.json, o trim
5
+ automático de textos (prevenção de erros — Nielsen) e a rejeição de payloads
6
+ quebrados. LEADMASTER_HOME em diretório temporário.
7
+ """
8
+
9
+ from fastapi.testclient import TestClient
10
+
11
+ import main
12
+ from services import storage
13
+
14
+
15
+ def _client(tmp_path, monkeypatch):
16
+ monkeypatch.setenv("LEADMASTER_HOME", str(tmp_path))
17
+ return TestClient(main.app)
18
+
19
+
20
+ def test_get_retorna_estrutura_padrao(tmp_path, monkeypatch):
21
+ with _client(tmp_path, monkeypatch) as client:
22
+ c = client.get("/api/config").json()
23
+ for secao in ("llm", "extratores", "integracoes", "agentes", "webhook", "perfil"):
24
+ assert secao in c
25
+ assert c["llm"]["base_url"] # default do OpenAI-compat
26
+ assert c["llm"]["api_key"] == "" # nunca exposto
27
+ assert c["extratores"]["google_maps_cli"] == ""
28
+ assert c["integracoes"]["openoutreach"]["enabled"] is False
29
+
30
+
31
+ def test_get_nunca_expõe_segredos(tmp_path, monkeypatch):
32
+ with _client(tmp_path, monkeypatch) as client:
33
+ client.post("/api/config", json={"llm": {"api_key": "sk-super-secreta"}})
34
+ corpo = client.get("/api/config").json()
35
+ assert corpo["llm"]["api_key"] == ""
36
+ assert corpo["llm"]["api_key_configurado"] is True
37
+ # Nem o corpo cru da resposta pode conter a chave.
38
+ assert "sk-super-secreta" not in client.get("/api/config").text
39
+
40
+
41
+ def test_post_persiste_e_aplica_trim(tmp_path, monkeypatch):
42
+ with _client(tmp_path, monkeypatch) as client:
43
+ r = client.post(
44
+ "/api/config",
45
+ json={"llm": {"base_url": " https://api.exemplo.com/v1 ", "api_key": " sk-123 "}},
46
+ )
47
+ assert r.status_code == 200
48
+ gravado = storage.get_config()["llm"]
49
+ assert gravado["base_url"] == "https://api.exemplo.com/v1" # trim aplicado
50
+ assert gravado["api_key"] == "sk-123"
51
+ # Round-trip pelo GET (mascarado) confirma a persistência.
52
+ get = client.get("/api/config").json()
53
+ assert get["llm"]["base_url"] == "https://api.exemplo.com/v1"
54
+ assert get["llm"]["api_key_configurado"] is True
55
+
56
+
57
+ def test_post_segredo_vazio_mantem_atual(tmp_path, monkeypatch):
58
+ with _client(tmp_path, monkeypatch) as client:
59
+ client.post("/api/config", json={"llm": {"api_key": "sk-original"}})
60
+ client.post("/api/config", json={"llm": {"api_key": "", "modelo": "gpt-4o"}})
61
+ gravado = storage.get_config()["llm"]
62
+ assert gravado["api_key"] == "sk-original" # vazio não apaga
63
+ assert gravado["modelo"] == "gpt-4o"
64
+
65
+
66
+ def test_put_continua_funcionando(tmp_path, monkeypatch):
67
+ with _client(tmp_path, monkeypatch) as client:
68
+ r = client.put("/api/config", json={"extratores": {"igmapper_cli": " C:\\ferramentas\\igmapper.exe "}})
69
+ assert r.status_code == 200
70
+ assert storage.get_config()["extratores"]["igmapper_cli"] == "C:\\ferramentas\\igmapper.exe"
71
+
72
+
73
+ def test_payload_invalido_422(tmp_path, monkeypatch):
74
+ with _client(tmp_path, monkeypatch) as client:
75
+ # Lista no lugar de objeto: rejeitado pelo FastAPI.
76
+ assert client.post("/api/config", json=["nao", "e", "objeto"]).status_code == 422
77
+ # Seção que deve ser objeto recebendo escalar: rejeitado pela validação.
78
+ r = client.post("/api/config", json={"llm": "string-no-lugar-de-objeto"})
79
+ assert r.status_code == 422
80
+ assert "objeto" in r.json()["detail"]
81
+ # A configuração anterior (padrão) permanece íntegra.
82
+ assert storage.get_config()["llm"]["base_url"] != "string-no-lugar-de-objeto"
83
+
84
+
85
+ def test_post_trim_em_arrays_e_aninhados(tmp_path, monkeypatch):
86
+ with _client(tmp_path, monkeypatch) as client:
87
+ client.post("/api/config", json={"webhook": {"url": " https://hook.exemplo.com/x ", "eventos": [" lead.criado "]}})
88
+ wh = storage.get_config()["webhook"]
89
+ assert wh["url"] == "https://hook.exemplo.com/x"
90
+ assert wh["eventos"][0] == "lead.criado"
@@ -0,0 +1,112 @@
1
+ """Testes das métricas do Painel — GET /api/metrics.
2
+
3
+ Popula os JSONs de teste (LEADMASTER_HOME em diretório temporário) com dados
4
+ conhecidos e verifica o cálculo em tempo real: totais de leads, distribuição
5
+ por etapa, consolidação das campanhas (enviadas/erros/taxas) e status da
6
+ conexão.
7
+ """
8
+
9
+ from fastapi.testclient import TestClient
10
+
11
+ import main
12
+ from services import storage
13
+
14
+
15
+ def _client(tmp_path, monkeypatch):
16
+ monkeypatch.setenv("LEADMASTER_HOME", str(tmp_path))
17
+ # Cria os arquivos vazios ANTES do lifespan: evita os seeds de demo que o
18
+ # backend semeia na primeira execução — assim o teste parte de base zerada.
19
+ storage.save_leads([])
20
+ storage.save_campaigns([])
21
+ return TestClient(main.app)
22
+
23
+
24
+ def _lead(nome: str, etapa: str, telefone: str) -> dict:
25
+ return {
26
+ "nome": nome,
27
+ "telefone": telefone,
28
+ "email": "",
29
+ "fonte": "teste",
30
+ "etapa": etapa,
31
+ "empresa": "",
32
+ "cidade": "",
33
+ "endereco": "",
34
+ "score": 50,
35
+ "tags": [],
36
+ "observacoes": "",
37
+ "extras": {},
38
+ "criado_em": storage.now_iso(),
39
+ "atualizado_em": storage.now_iso(),
40
+ }
41
+
42
+
43
+ def test_metrics_sem_dados(tmp_path, monkeypatch):
44
+ # Base vazia: nada de quebrar — taxas padrão (100% entrega, 0% erro).
45
+ with _client(tmp_path, monkeypatch) as client:
46
+ r = client.get("/api/metrics")
47
+ assert r.status_code == 200
48
+ m = r.json()
49
+ assert m["leads_total"] == 0
50
+ assert m["leads_por_etapa"] == {"leads": 0, "em_contato": 0, "proposta": 0, "fechado": 0}
51
+ assert m["total_enviadas"] == 0
52
+ assert m["total_erros"] == 0
53
+ assert m["taxa_entrega"] == 100.0
54
+ assert m["taxa_erro"] == 0.0
55
+ assert m["campanhas"] == {"enviadas": 0, "erros": 0, "total": 0}
56
+ assert m["conexao"]["geral"] is True
57
+ assert isinstance(m["conexao"]["llm"], bool)
58
+ assert isinstance(m["conexao"]["openoutreach"], bool)
59
+
60
+
61
+ def test_metrics_com_leads_conhecidos(tmp_path, monkeypatch):
62
+ monkeypatch.setenv("LEADMASTER_HOME", str(tmp_path))
63
+ storage.save_leads([])
64
+ storage.save_campaigns([])
65
+ storage.import_leads(
66
+ [
67
+ _lead("Lead A", "leads", "(11) 90000-0001"),
68
+ _lead("Lead B", "leads", "(11) 90000-0002"),
69
+ _lead("Lead C", "em_contato", "(11) 90000-0003"),
70
+ _lead("Lead D", "proposta", "(11) 90000-0004"),
71
+ _lead("Lead E", "fechado", "(11) 90000-0005"),
72
+ ],
73
+ dedup=False,
74
+ )
75
+ with TestClient(main.app) as client:
76
+ m = client.get("/api/metrics").json()
77
+ assert m["leads_total"] == 5
78
+ assert m["leads_por_etapa"] == {"leads": 2, "em_contato": 1, "proposta": 1, "fechado": 1}
79
+
80
+
81
+ def test_metrics_consolida_campanhas(tmp_path, monkeypatch):
82
+ monkeypatch.setenv("LEADMASTER_HOME", str(tmp_path))
83
+ storage.save_leads([])
84
+ storage.save_campaigns(
85
+ [
86
+ {"id": "c1", "nome": "Campanha 1", "status": "concluida", "enviadas": 90, "erros": 10},
87
+ {"id": "c2", "nome": "Campanha 2", "status": "concluida", "enviadas": 60, "erros": 2},
88
+ ]
89
+ )
90
+ with TestClient(main.app) as client:
91
+ m = client.get("/api/metrics").json()
92
+ assert m["total_enviadas"] == 150
93
+ assert m["total_erros"] == 12
94
+ assert m["taxa_entrega"] == 92.6 # 150 / 162 * 100
95
+ assert m["taxa_erro"] == 7.4 # 12 / 162 * 100
96
+ assert m["campanhas"] == {"enviadas": 150, "erros": 12, "total": 2}
97
+
98
+
99
+ def test_metrics_reflete_alteracoes_no_kanban(tmp_path, monkeypatch):
100
+ # Mover um lead de etapa (rota do CRM) muda a contagem no /api/metrics.
101
+ with _client(tmp_path, monkeypatch) as client:
102
+ criado = client.post(
103
+ "/api/crm/leads",
104
+ json={"nome": "Lead Kanban", "telefone": "(11) 90000-0009", "fonte": "teste"},
105
+ ).json()
106
+ m1 = client.get("/api/metrics").json()
107
+ assert m1["leads_por_etapa"]["leads"] == 1
108
+
109
+ client.put(f"/api/crm/leads/{criado['id']}/etapa", json={"etapa": "fechado"})
110
+ m2 = client.get("/api/metrics").json()
111
+ assert m2["leads_por_etapa"] == {"leads": 0, "em_contato": 0, "proposta": 0, "fechado": 1}
112
+ assert m2["leads_total"] == 1