@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.
- package/CHANGELOG.md +47 -0
- package/backend/__pycache__/main.cpython-311.pyc +0 -0
- package/backend/main.py +50 -1
- package/backend/services/__pycache__/__init__.cpython-311.pyc +0 -0
- package/backend/services/__pycache__/agents.cpython-311.pyc +0 -0
- package/backend/services/__pycache__/crm_agent.cpython-311.pyc +0 -0
- package/backend/services/__pycache__/extractors.cpython-311.pyc +0 -0
- package/backend/services/__pycache__/flags.cpython-311.pyc +0 -0
- package/backend/services/__pycache__/llm.cpython-311.pyc +0 -0
- package/backend/services/__pycache__/storage.cpython-311.pyc +0 -0
- package/backend/tests/__pycache__/conftest.cpython-311-pytest-8.4.2.pyc +0 -0
- package/backend/tests/__pycache__/test_agents_routes.cpython-311-pytest-8.4.2.pyc +0 -0
- package/backend/tests/__pycache__/test_campaigns_routes.cpython-311-pytest-8.4.2.pyc +0 -0
- package/backend/tests/__pycache__/test_cnpj_extractor.cpython-311-pytest-8.4.2.pyc +0 -0
- package/backend/tests/__pycache__/test_config.cpython-311-pytest-8.4.2.pyc +0 -0
- package/backend/tests/__pycache__/test_crm_routes.cpython-311-pytest-8.4.2.pyc +0 -0
- package/backend/tests/__pycache__/test_extractors_real.cpython-311-pytest-8.4.2.pyc +0 -0
- package/backend/tests/__pycache__/test_funnel_routes.cpython-311-pytest-8.4.2.pyc +0 -0
- package/backend/tests/__pycache__/test_instagram_extractor.cpython-311-pytest-8.4.2.pyc +0 -0
- package/backend/tests/__pycache__/test_launcher_protections.cpython-311-pytest-8.4.2.pyc +0 -0
- package/backend/tests/__pycache__/test_lead_edit_routes.cpython-311-pytest-8.4.2.pyc +0 -0
- package/backend/tests/__pycache__/test_leads_export.cpython-311-pytest-8.4.2.pyc +0 -0
- package/backend/tests/__pycache__/test_maps_extractor.cpython-311-pytest-8.4.2.pyc +0 -0
- package/backend/tests/__pycache__/test_metrics.cpython-311-pytest-8.4.2.pyc +0 -0
- package/backend/tests/__pycache__/test_process_flags.cpython-311-pytest-8.4.2.pyc +0 -0
- package/backend/tests/__pycache__/test_webhook_routes.cpython-311-pytest-8.4.2.pyc +0 -0
- package/backend/tests/test_launcher_protections.py +12 -7
- package/backend/tests/test_leads_export.py +56 -0
- package/frontend/dist/assets/{index-BLuj5jS4.css → index-NeGNWLr8.css} +1 -1
- package/frontend/dist/assets/{index-C1mEC85V.js → index-pSgnrc_D.js} +13 -13
- package/frontend/dist/index.html +2 -2
- package/package.json +1 -1
- 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
|
|
Binary file
|
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).
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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 (
|
|
58
|
-
# dentro do cmdStart — no install, em função morta ou em bloco
|
|
59
|
-
# não valem. A morte prematura do Node devolve o prompt do
|
|
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 "
|
|
63
|
-
assert "
|
|
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.
|
|
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
|