@danhachuel/leadmaster 0.1.25 → 0.1.27

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 (33) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/backend/__pycache__/main.cpython-311.pyc +0 -0
  3. package/backend/main.py +50 -1
  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_agents_routes.cpython-311-pytest-8.4.2.pyc +0 -0
  13. package/backend/tests/__pycache__/test_campaigns_routes.cpython-311-pytest-8.4.2.pyc +0 -0
  14. package/backend/tests/__pycache__/test_cnpj_extractor.cpython-311-pytest-8.4.2.pyc +0 -0
  15. package/backend/tests/__pycache__/test_config.cpython-311-pytest-8.4.2.pyc +0 -0
  16. package/backend/tests/__pycache__/test_crm_routes.cpython-311-pytest-8.4.2.pyc +0 -0
  17. package/backend/tests/__pycache__/test_extractors_real.cpython-311-pytest-8.4.2.pyc +0 -0
  18. package/backend/tests/__pycache__/test_funnel_routes.cpython-311-pytest-8.4.2.pyc +0 -0
  19. package/backend/tests/__pycache__/test_instagram_extractor.cpython-311-pytest-8.4.2.pyc +0 -0
  20. package/backend/tests/__pycache__/test_launcher_protections.cpython-311-pytest-8.4.2.pyc +0 -0
  21. package/backend/tests/__pycache__/test_lead_edit_routes.cpython-311-pytest-8.4.2.pyc +0 -0
  22. package/backend/tests/__pycache__/test_leads_export.cpython-311-pytest-8.4.2.pyc +0 -0
  23. package/backend/tests/__pycache__/test_maps_extractor.cpython-311-pytest-8.4.2.pyc +0 -0
  24. package/backend/tests/__pycache__/test_metrics.cpython-311-pytest-8.4.2.pyc +0 -0
  25. package/backend/tests/__pycache__/test_process_flags.cpython-311-pytest-8.4.2.pyc +0 -0
  26. package/backend/tests/__pycache__/test_webhook_routes.cpython-311-pytest-8.4.2.pyc +0 -0
  27. package/backend/tests/test_launcher_protections.py +12 -7
  28. package/backend/tests/test_leads_export.py +56 -0
  29. package/frontend/dist/assets/{index-BLuj5jS4.css → index-NeGNWLr8.css} +1 -1
  30. package/frontend/dist/assets/{index-C1mEC85V.js → index-pSgnrc_D.js} +13 -13
  31. package/frontend/dist/index.html +2 -2
  32. package/package.json +1 -1
  33. package/scripts/cli.mjs +23 -20
package/CHANGELOG.md CHANGED
@@ -37,6 +37,53 @@ 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.27] — 2026-11-09
41
+
42
+ ### Corrigido (crítico)
43
+
44
+ - **Launcher — Dummy Server (keep-alive infalível):** as versões anteriores
45
+ dependiam de `stdin`/timers; quando o MobaXterm fecha a entrada padrão em EOF
46
+ (perda de foco/variação de PTY), o event loop do Node esvaziava e o prompt
47
+ voltava logo após "LeadMaster no ar", derrubando o uvicorn junto. Agora o
48
+ `cmdStart` cria, **logo após o spawn do Python**, um servidor HTTP inativo
49
+ (`http.createServer` + `keepAliveServer.listen(0, '127.0.0.1')`) — o socket
50
+ aberto no sistema operacional segura o processo **independentemente do
51
+ terminal**; o fluxo principal termina em `await new Promise(() => {})`
52
+ (congelado para sempre) e `stdin.resume`/`setInterval`/`beforeExit` seguem
53
+ como camadas secundárias. **Testado ao vivo com stdin em EOF (`< NUL`):**
54
+ node vivo 1min46s+, health 200, exportação CSV 200 no ar, encerramento
55
+ limpo, zero órfãos.
56
+ - **AGENTS.md:** regra 1 do launcher passa a ser o Dummy Server (camada
57
+ principal); regra 2 rebaixa stdin/timers a camadas secundárias.
58
+ - **Gates de regressão reforçados** (`test_launcher_protections.py`): falham se
59
+ `http.createServer`, `keepAliveServer.listen(0, '127.0.0.1')` ou
60
+ `await new Promise(() => {})` desaparecerem do corpo do `cmdStart`.
61
+ Suíte completa: **83 passed**.
62
+
63
+ ## [0.1.26] — 2026-11-09
64
+
65
+ ### Adicionado
66
+
67
+ - **Busca no CRM e exportação CSV** (registro retro-ativo — publicada sem
68
+ entrada anterior):
69
+ - `GET /api/crm/leads/export`: CSV de todos os leads com **BOM UTF-8**
70
+ (`\ufeff`, Excel em português não quebra acentos), delimitador `;`,
71
+ `text/csv; charset=utf-8` e
72
+ `Content-Disposition: attachment; filename="leads_leadmaster.csv"`; campos
73
+ de `extras` **achatados** como colunas `extras_<chave>` e listas (tags) em
74
+ células separadas por `; `.
75
+ - Frontend: **barra de busca larga** no CRM com filtragem local instantânea
76
+ por **Nome, Telefone ou Email** + botão de limpar (Lei de Fitts); botão
77
+ **"Exportar CSV"** em estilo **neutro/outline** — não compete com o
78
+ "Adicionar Lead" em gradiente (von Restorff); busca sem resultados mostra
79
+ *"Nenhum lead encontrado para '...'"* (Zeigarnik).
80
+ - **Corrigido:** o modelo `LeadIn` descartava o campo `extras` — leads
81
+ criados manualmente perdiam site/avaliação. Agora o POST preserva
82
+ `extras` (`LeadIn.extras: dict`).
83
+ - **3 novos testes pytest** (`backend/tests/test_leads_export.py`): MIME +
84
+ disposition + BOM + colunas, achatamento de `extras` e exportação de base
85
+ vazia (só cabeçalho).
86
+
40
87
  ## [0.1.25] — 2026-11-09
41
88
 
42
89
  ### Adicionado
package/backend/main.py CHANGED
@@ -13,7 +13,7 @@ import threading
13
13
  from contextlib import asynccontextmanager
14
14
  from pathlib import Path
15
15
 
16
- from fastapi import FastAPI, HTTPException
16
+ from fastapi import FastAPI, HTTPException, Response
17
17
  from fastapi.middleware.cors import CORSMiddleware
18
18
  from fastapi.responses import FileResponse
19
19
  from fastapi.staticfiles import StaticFiles
@@ -94,6 +94,8 @@ class LeadIn(BaseModel):
94
94
  observacoes: str = ""
95
95
  score: int | None = None
96
96
  tags: list[str] = []
97
+ extras: dict = {}
98
+ extras: dict | None = None
97
99
 
98
100
 
99
101
  class LeadsImportIn(BaseModel):
@@ -344,6 +346,53 @@ def crm_mover_lead(lead_id: str, dados: dict):
344
346
  return atualizado
345
347
 
346
348
 
349
+ @app.get("/api/crm/leads/export")
350
+ def crm_exportar_leads_csv():
351
+ """Exporta todos os leads em CSV (UTF-8 com BOM, amigável ao Excel)."""
352
+ import csv
353
+ import io
354
+
355
+ leads = storage.list_leads()
356
+
357
+ def _valor_celula(valor) -> str:
358
+ if valor is None:
359
+ return ""
360
+ if isinstance(valor, (list, tuple)):
361
+ return "; ".join(str(v) for v in valor)
362
+ return str(valor)
363
+
364
+ # Colunas fixas + união das chaves de `extras` (achatadas com prefixo).
365
+ colunas = [
366
+ "id", "nome", "telefone", "email", "fonte", "etapa", "empresa",
367
+ "cnpj", "endereco", "cidade", "observacoes", "score", "tags",
368
+ "criado_em", "atualizado_em",
369
+ ]
370
+ extras_keys: list[str] = []
371
+ for lead in leads:
372
+ for chave in (lead.get("extras") or {}):
373
+ coluna = f"extras_{chave}"
374
+ if coluna not in extras_keys:
375
+ extras_keys.append(coluna)
376
+ colunas.extend(sorted(extras_keys))
377
+
378
+ buffer = io.StringIO()
379
+ escritor = csv.writer(buffer, delimiter=";", quoting=csv.QUOTE_MINIMAL)
380
+ escritor.writerow(colunas)
381
+ for lead in leads:
382
+ linha = [_valor_celula(lead.get(coluna)) for coluna in colunas[: len(colunas) - len(extras_keys)]]
383
+ extras = lead.get("extras") or {}
384
+ for coluna in sorted(extras_keys):
385
+ linha.append(_valor_celula(extras.get(coluna[len("extras_"):])))
386
+ escritor.writerow(linha)
387
+
388
+ conteudo = "\ufeff" + buffer.getvalue() # BOM: Excel em pt-BR lê os acentos
389
+ return Response(
390
+ content=conteudo.encode("utf-8"),
391
+ media_type="text/csv; charset=utf-8",
392
+ headers={"Content-Disposition": 'attachment; filename="leads_leadmaster.csv"'},
393
+ )
394
+
395
+
347
396
  @app.put("/api/crm/leads/{lead_id}")
348
397
  def crm_editar_lead(lead_id: str, dados: dict):
349
398
  """Edita os dados principais do lead (modal de detalhes do CRM).
@@ -54,13 +54,18 @@ class TestLauncherProtections:
54
54
  assert "já está rodando" not in c
55
55
 
56
56
  def test_keepalive_no_fluxo_do_start(self):
57
- # Gate de FLUXO (regressão da 0.1.21): as proteções precisam existir
58
- # dentro do cmdStart — no install, em função morta ou em bloco errado
59
- # não valem. A morte prematura do Node devolve o prompt do MobaXterm.
57
+ # Gate de FLUXO (regressões da 0.1.21/0.1.24): as proteções precisam
58
+ # existir dentro do cmdStart — no install, em função morta ou em bloco
59
+ # errado não valem. A morte prematura do Node devolve o prompt do
60
+ # MobaXterm. A camada PRINCIPAL é o Dummy Server (socket do SO);
61
+ # setInterval/stdin/beforeExit são secundárias.
60
62
  corpo = _corpo_cmd_start()
61
63
  assert "spawn(" in corpo # worker Python é spawnado no start
62
- assert "process.stdin.resume();" in corpo # terminal preso
63
- assert "setInterval(() => {}, 1000)" in corpo # keep-alive
64
+ assert "http.createServer" in corpo # Dummy Server: camada principal
65
+ assert "keepAliveServer.listen(0, '127.0.0.1')" in corpo
66
+ assert "await new Promise(() => {})" in corpo # fluxo principal congelado
67
+ assert "process.stdin.resume();" in corpo # camada secundária
68
+ assert "setInterval(() => {}, 1000)" in corpo # camada secundária
64
69
  assert "beforeExit" in corpo # rede de segurança do event loop
65
70
  assert "child.on('exit'" in corpo or 'child.on("exit"' in corpo
66
71
  assert "child.on('close'" in corpo or 'child.on("close"' in corpo
@@ -69,10 +74,10 @@ class TestLauncherProtections:
69
74
  def test_start_nunca_chama_exit_apos_terminal_preso(self):
70
75
  # Após o terminal ser preso, nenhum caminho de sucesso chama
71
76
  # process.exit — a saída só ocorre pelo ciclo de vida do filho
72
- # (child exit/close) ou por sinais de parada legítima.
77
+ # (child exit/close) ou por sinais de parada legítima (SIGTERM/SIGBREAK).
73
78
  corpo = _corpo_cmd_start()
74
79
  pos = corpo.index("process.stdin.resume();")
75
- fim_iife = corpo.index("})().catch(") # catch de erro fatal fica de fora
80
+ fim_iife = corpo.rindex("process.exit(1);") # catch de erro fatal, no fim
76
81
  assert "process.exit(" not in corpo[pos:fim_iife]
77
82
 
78
83
  def test_ciclo_de_vida_vinculado(self):
@@ -0,0 +1,56 @@
1
+ """Testes da exportação CSV de leads — GET /api/crm/leads/export.
2
+
3
+ Valida MIME, Content-Disposition, BOM (acentos no Excel), colunas esperadas e
4
+ presença dos dados dos leads no conteúdo.
5
+ """
6
+
7
+ import pytest
8
+ from fastapi.testclient import TestClient
9
+
10
+ import main
11
+
12
+
13
+ @pytest.fixture()
14
+ def client(tmp_path, monkeypatch):
15
+ monkeypatch.setenv("LEADMASTER_HOME", str(tmp_path))
16
+ with TestClient(main.app) as c:
17
+ yield c
18
+
19
+
20
+ def test_exportar_csv(client):
21
+ r = client.get("/api/crm/leads/export")
22
+ assert r.status_code == 200
23
+ assert r.headers["content-type"].startswith("text/csv")
24
+ assert 'attachment; filename="leads_leadmaster.csv"' in r.headers["content-disposition"]
25
+
26
+ conteudo = r.content.decode("utf-8-sig") # remove o BOM para inspecionar
27
+ assert r.content.startswith(b"\xef\xbb\xbf") # BOM presente (Excel pt-BR)
28
+ linhas = [l for l in conteudo.splitlines() if l.strip()]
29
+ cabecalho = linhas[0].split(";")
30
+ for coluna in ("id", "nome", "telefone", "email", "fonte", "etapa"):
31
+ assert coluna in cabecalho
32
+ # Um lead de demonstração (seed) aparece no conteúdo.
33
+ assert any("Bella Massa" in l or "Horizonte" in l for l in linhas[1:])
34
+
35
+
36
+ def test_exportar_csv_achata_extras(client):
37
+ # Cria um lead com extras (dados do scraper real).
38
+ client.post(
39
+ "/api/crm/leads",
40
+ json={"nome": "Empresa com Extras", "telefone": "(11) 95555-0000", "extras": {"site": "https://empresa.com", "avaliacao": 4.7}},
41
+ )
42
+ r = client.get("/api/crm/leads/export")
43
+ assert r.status_code == 200
44
+ conteudo = r.content.decode("utf-8-sig")
45
+ assert "extras_site" in conteudo.splitlines()[0]
46
+ assert "https://empresa.com" in conteudo
47
+
48
+
49
+ def test_exportar_csv_sem_leads(client):
50
+ # Base vazia: só o cabeçalho.
51
+ home = main.storage.storage_dir()
52
+ (home / "leads.json").write_text("[]", encoding="utf-8")
53
+ r = client.get("/api/crm/leads/export")
54
+ assert r.status_code == 200
55
+ linhas = [l for l in r.content.decode("utf-8-sig").splitlines() if l.strip()]
56
+ assert len(linhas) == 1 # apenas o cabeçalho