@danhachuel/leadmaster 0.1.7 → 0.1.9

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 CHANGED
@@ -6,6 +6,54 @@ O formato é baseado no [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.
6
6
  A etiqueta visual de versão usa patch de dois dígitos (ex.: `0.1.00`); o registry NPM
7
7
  normaliza para a forma canônica (ex.: `0.1.0`).
8
8
 
9
+ ## [0.1.09] — 2026-11-08
10
+
11
+ ### Corrigido
12
+
13
+ - **Encerramento silencioso do launcher no MobaXterm (Windows)** — o app morria
14
+ sozinho por sinais espúrios (perda de foco/minimização) e por EPIPE ao fechar o
15
+ terminal. Correções no `scripts/cli.mjs`:
16
+ - **Keep-alive:** `setInterval(() => {}, 1000);` mantém o event loop ativo mesmo
17
+ sem I/O de console.
18
+ - **Absorção de sinais no Windows:** `SIGINT`/`SIGHUP` agora são apenas logados
19
+ (`[LAUNCHER] Sinal absorvido no Windows`) e **não** encerram o launcher;
20
+ `SIGTERM`/`SIGBREAK` (Ctrl+Break) seguem como paradas legítimas. No
21
+ Linux/macOS o comportamento gracioso é mantido.
22
+ - **Guarda da consola (anti-EPIPE):** `guardConsoleStream()` ignora erros `EPIPE`
23
+ no `'error'` dos streams e espelha toda a saída em
24
+ `storage/logs/launcher_console.log`; escrita original com exceção retorna
25
+ silenciosamente. Aplicada a `process.stdout`/`process.stderr` antes de
26
+ qualquer escrita do launcher.
27
+ - Isolamento mantido: worker Python com `detached: true` + `windowsHide: true`
28
+ (`CREATE_NEW_PROCESS_GROUP`).
29
+ - **AGENTS.md:** nova seção *🚨 REGRA INVIOLÁVEL: ARQUITETURA DO LAUNCHER* com as
30
+ 4 proteções obrigatórias.
31
+ - **Gate de regressão:** `backend/tests/test_launcher_protections.py` falha se
32
+ keep-alive, absorção de sinais, `guardConsoleStream` ou isolamento forem
33
+ removidos do launcher.
34
+
35
+ ## [0.1.08] — 2026-11-08
36
+
37
+ ### Adicionado
38
+
39
+ - **Módulo CRM Kanban implementado conforme especificação:**
40
+ - Rotas: `GET /api/crm/leads`, `POST /api/crm/leads` (criação manual) e
41
+ `PUT /api/crm/leads/{lead_id}/etapa` (movimentação ao soltar o card) —
42
+ persistência no `leads.json` local (seeds de demonstração mantidos).
43
+ - Kanban com **@dnd-kit/core + @dnd-kit/sortable** (multi-contêiner):
44
+ arrastar entre colunas persiste via API; reordenação visual dentro da
45
+ coluna; soltar fora das colunas reverte o movimento.
46
+ - Colunas: **Novos Leads → Em Contato → Proposta → Fechado**.
47
+ - Card mostra Nome, Telefone, Fonte e **Data de Criação**.
48
+ - Botão **"Adicionar Lead"** (gradiente) com modal de criação (campos
49
+ avançados em accordion).
50
+ - UX: card inteiro arrastável (Fitts); **opacity 0.5** no card arrastado e
51
+ **borda tracejada indigo** na coluna de destino (Zeigarnik); colunas
52
+ brancas com sombra suave (padrão do Dashboard).
53
+ - **7 novos testes pytest** para as rotas do CRM (`backend/tests/test_crm_routes.py`):
54
+ listagem, criação, persistência, validação, movimentação, 404 e etapas
55
+ inválidas — isolados com `LEADMASTER_HOME` temporário.
56
+
9
57
  ## [0.1.07] — 2026-11-08
10
58
 
11
59
  ### Adicionado
package/backend/main.py CHANGED
@@ -295,6 +295,42 @@ def aplicar_sugestao(dados: dict):
295
295
  return {"ok": True, "lead": atualizado}
296
296
 
297
297
 
298
+ # ---------------------------------------------------------------------------
299
+ # CRM — Kanban (leads por etapa)
300
+ # ---------------------------------------------------------------------------
301
+
302
+ @app.get("/api/crm/leads")
303
+ def crm_listar_leads():
304
+ """Todos os leads (persistidos em leads.json) — usado pelo quadro Kanban."""
305
+ return storage.list_leads()
306
+
307
+
308
+ @app.post("/api/crm/leads", status_code=201)
309
+ def crm_criar_lead(lead_in: LeadIn):
310
+ """Cria um lead manualmente pelo modal 'Adicionar Lead' do CRM."""
311
+ lead = lead_in.model_dump()
312
+ lead["id"] = storage._novo_id()
313
+ lead["etapa"] = _validar_etapa(lead.get("etapa", "leads"))
314
+ lead["score"] = lead.get("score") if lead.get("score") is not None else crm_agent.calcular_completude(lead)
315
+ storage.add_lead(lead)
316
+ agents.disparar_webhook("lead.criado", {"lead_id": lead["id"], "nome": lead["nome"]})
317
+ return lead
318
+
319
+
320
+ @app.put("/api/crm/leads/{lead_id}/etapa")
321
+ def crm_mover_lead(lead_id: str, dados: dict):
322
+ """Move um lead de coluna no Kanban (drag-and-drop)."""
323
+ etapa = dados.get("etapa")
324
+ if not etapa:
325
+ raise HTTPException(400, "Informe a etapa de destino.")
326
+ _validar_etapa(etapa)
327
+ atualizado = storage.update_lead(lead_id, {"etapa": etapa})
328
+ if not atualizado:
329
+ raise HTTPException(404, "Lead não encontrado.")
330
+ agents.disparar_webhook("lead.etapa", {"lead_id": lead_id, "etapa": etapa, "via": "kanban"})
331
+ return atualizado
332
+
333
+
298
334
  # ---------------------------------------------------------------------------
299
335
  # Campanhas / Disparos
300
336
  # ---------------------------------------------------------------------------
@@ -0,0 +1,64 @@
1
+ """Testes das rotas do CRM Kanban (backend/main.py).
2
+
3
+ Usam TestClient do FastAPI com LEADMASTER_HOME em diretório temporário, para
4
+ nunca tocar nos dados reais do usuário. Rodam iguais no CI (Linux) e local.
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_listar_leads(client):
21
+ r = client.get("/api/crm/leads")
22
+ assert r.status_code == 200
23
+ corpo = r.json()
24
+ assert isinstance(corpo, list) and len(corpo) >= 1 # seeds de demonstração
25
+
26
+
27
+ def test_criar_lead(client):
28
+ r = client.post("/api/crm/leads", json={"nome": "Padaria Teste", "telefone": "(11) 90000-0000", "fonte": "Manual"})
29
+ assert r.status_code == 201
30
+ lead = r.json()
31
+ assert lead["id"]
32
+ assert lead["etapa"] == "leads"
33
+ # Confirma persistência no JSON local
34
+ lista = client.get("/api/crm/leads").json()
35
+ assert any(l["id"] == lead["id"] for l in lista)
36
+
37
+
38
+ def test_criar_lead_sem_nome_invalido(client):
39
+ r = client.post("/api/crm/leads", json={"nome": ""})
40
+ assert r.status_code == 422 # validação do modelo Pydantic
41
+
42
+
43
+ def test_mover_lead_entre_colunas(client):
44
+ criado = client.post("/api/crm/leads", json={"nome": "Lead Móvel"}).json()
45
+ r = client.put(f"/api/crm/leads/{criado['id']}/etapa", json={"etapa": "proposta"})
46
+ assert r.status_code == 200
47
+ assert r.json()["etapa"] == "proposta"
48
+
49
+
50
+ def test_mover_lead_inexistente(client):
51
+ r = client.put("/api/crm/leads/nao-existe/etapa", json={"etapa": "fechado"})
52
+ assert r.status_code == 404
53
+
54
+
55
+ def test_mover_lead_etapa_invalida(client):
56
+ criado = client.post("/api/crm/leads", json={"nome": "Lead X"}).json()
57
+ r = client.put(f"/api/crm/leads/{criado['id']}/etapa", json={"etapa": "nao_existe"})
58
+ assert r.status_code == 400
59
+
60
+
61
+ def test_mover_lead_sem_etapa(client):
62
+ criado = client.post("/api/crm/leads", json={"nome": "Lead Y"}).json()
63
+ r = client.put(f"/api/crm/leads/{criado['id']}/etapa", json={})
64
+ assert r.status_code == 400
@@ -0,0 +1,46 @@
1
+ """Gate de regressão da arquitetura do launcher (scripts/cli.mjs).
2
+
3
+ Lê o launcher e falha se qualquer proteção contra encerramento silencioso no
4
+ Windows (MobaXterm) for removida:
5
+
6
+ 1. Keep-alive com setInterval (event loop ativo sem I/O de console);
7
+ 2. Absorção de SIGINT/SIGHUP no Windows (apenas log, sem encerrar);
8
+ 3. Função guardConsoleStream (anti-EPIPE com espelho em arquivo de log);
9
+ 4. Isolamento do worker Python (detached: true / CREATE_NEW_PROCESS_GROUP).
10
+ """
11
+
12
+ from pathlib import Path
13
+
14
+ CAMINHO = Path(__file__).resolve().parents[2] / "scripts" / "cli.mjs"
15
+
16
+
17
+ def _conteudo() -> str:
18
+ assert CAMINHO.exists(), f"launcher não encontrado em {CAMINHO}"
19
+ return CAMINHO.read_text(encoding="utf-8")
20
+
21
+
22
+ class TestLauncherProtections:
23
+ def test_keepalivo(self):
24
+ assert "setInterval(() => {}, 1000)" in _conteudo()
25
+
26
+ def test_absorcao_de_sinais_no_windows(self):
27
+ c = _conteudo()
28
+ assert "SIGINT" in c and "SIGTERM" in c and "SIGHUP" in c
29
+ assert "absorvido" in c
30
+
31
+ def test_guarda_anti_epipe(self):
32
+ c = _conteudo()
33
+ assert "guardConsoleStream" in c
34
+ assert "EPIPE" in c
35
+ assert "launcher_console.log" in c
36
+
37
+ def test_isolamento_do_worker(self):
38
+ c = _conteudo()
39
+ assert "detached: true" in c
40
+ assert "windowsHide: true" in c
41
+
42
+ def test_sigterm_nao_absorvido(self):
43
+ # SIGTERM/SIGBREAK são paradas legítimas: sempre encerram.
44
+ c = _conteudo()
45
+ assert "SIGTERM" in c and "SIGBREAK" in c
46
+ assert "naoAbsorver" in c