@danhachuel/leadmaster 0.1.15 → 0.1.16

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 +38 -0
  2. package/backend/__pycache__/main.cpython-311.pyc +0 -0
  3. package/backend/main.py +24 -0
  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 +4 -1
  12. package/backend/tests/__pycache__/conftest.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_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_process_flags.cpython-311-pytest-8.4.2.pyc +0 -0
  20. package/backend/tests/test_campaigns_routes.py +71 -0
  21. package/backend/tests/test_launcher_protections.py +12 -0
  22. package/frontend/dist/assets/{index-Bf28IekR.css → index-BKo_OphM.css} +1 -1
  23. package/frontend/dist/assets/index-BTNs0Fhp.js +90 -0
  24. package/frontend/dist/index.html +2 -2
  25. package/package.json +1 -1
  26. package/frontend/dist/assets/index-CAvEEMWg.js +0 -90
package/CHANGELOG.md CHANGED
@@ -37,6 +37,44 @@ 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.16] — 2026-11-09
41
+
42
+ ### Adicionado
43
+
44
+ - **Módulo de Disparos (Campanhas) revisado conforme especificação:**
45
+ - Rota `PUT /api/campaigns/{id}/status` — altera status
46
+ (`rascunho`/`ativa`/`pausada`/`concluida`; `ativa` normalize para
47
+ `em_andamento`; status inválido → 400, campanha inexistente → 404).
48
+ - `POST /api/campaigns` agora aceita **`filtro_etapa`**: sem `lead_ids`
49
+ explícitos, seleciona automaticamente os leads da etapa do CRM escolhida
50
+ (ex.: "Novos Leads"), e **`mensagem`** (texto do template) — usada pelo
51
+ agente de disparo quando presente.
52
+ - Página `frontend/src/pages/Disparos.tsx` reformulada em **tabela**
53
+ (Nome · Status · Total de Leads · Data de Criação · Ações), botão
54
+ **"Nova Campanha"** grande no topo (Fitts + von Restorff) e modal com nome,
55
+ **template da mensagem** (textarea), canal e **dropdown de filtro por etapa
56
+ do CRM**. Ações na tabela: **Iniciar** (gradiente), **Pausar** e **Parar**.
57
+ - UX: badges de status vibrantes (verde "Ativa", amarelo "Pausada", cinza
58
+ "Rascunho" — von Restorff) e **barra de progresso + spinner** para
59
+ campanhas ativas (Zeigarnik, com polling react-query a cada 4s).
60
+ - **5 novos testes pytest** (`backend/tests/test_campaigns_routes.py`):
61
+ listagem, criação com filtro de etapa + persistência, payload inválido,
62
+ alteração de status (ativa/pausada) e erros (status inválido 400 /
63
+ inexistente 404).
64
+
65
+ ### Corrigido
66
+
67
+ - **Launcher — kill switch (6ª regra inviolável):** o launcher NUNCA mais
68
+ ignora uma porta ocupada por instância anterior do LeadMaster — antes de
69
+ subir, encerra a instância antiga confirmada (PID dono da porta via
70
+ `netstat -ano` no Windows / `lsof -ti:porta -sTCP:LISTEN` no Unix, com
71
+ fallback `ps`), `taskkill /F /T /PID` ou `kill -9`, aguardando até 5s a
72
+ liberação. A mensagem "já está rodando" foi **removida** (impedia
73
+ atualizações e causava `EBUSY` no npx). Processos de outros projetos nunca
74
+ são tocados (avança para a próxima porta). Roda no `install` (libera
75
+ arquivos) e no `start`. Gate de regressão reforçado com as asserções do
76
+ kill switch.
77
+
40
78
  ## [0.1.15] — 2026-11-08
41
79
 
42
80
  ### Corrigido
package/backend/main.py CHANGED
@@ -104,7 +104,9 @@ class CampaignIn(BaseModel):
104
104
  nome: str = Field(min_length=1)
105
105
  canal: str = "texto"
106
106
  template: str = "texto"
107
+ mensagem: str = ""
107
108
  lead_ids: list[str] = []
109
+ filtro_etapa: str | None = None
108
110
 
109
111
 
110
112
  class InstagramIn(BaseModel):
@@ -354,12 +356,34 @@ def listar_campanhas():
354
356
  @app.post("/api/campaigns", status_code=201)
355
357
  def criar_campanha(campaign_in: CampaignIn):
356
358
  campaign = campaign_in.model_dump()
359
+ # Filtro por etapa do CRM: sem lead_ids explícitos, seleciona os leads da
360
+ # etapa escolhida (ex.: "Novos Leads").
361
+ if not campaign["lead_ids"] and campaign.get("filtro_etapa"):
362
+ _validar_etapa(campaign["filtro_etapa"])
363
+ campaign["lead_ids"] = [l["id"] for l in storage.list_leads() if l.get("etapa") == campaign["filtro_etapa"]]
357
364
  campaign["id"] = storage._novo_id()
358
365
  campaign.update({"status": "rascunho", "total": len(campaign["lead_ids"]), "enviadas": 0, "erros": 0, "eventos": [], "criado_em": storage.now_iso()})
359
366
  storage.upsert_campaign(campaign)
360
367
  return campaign
361
368
 
362
369
 
370
+ @app.put("/api/campaigns/{campaign_id}/status")
371
+ def alterar_status_campanha(campaign_id: str, dados: dict):
372
+ """Altera o status de uma campanha (rascunho/ativa/pausada/concluida)."""
373
+ status = (dados.get("status") or "").strip().lower()
374
+ # Aliases aceitos: "ativa" equivale a "em_andamento" no ciclo interno.
375
+ aliases = {"ativa": "em_andamento", "em_andamento": "em_andamento", "pausada": "pausada", "pausado": "pausada", "rascunho": "rascunho", "concluida": "concluida"}
376
+ interno = aliases.get(status)
377
+ if not interno:
378
+ raise HTTPException(400, 'Status inválido. Use: rascunho, ativa, pausada ou concluida.')
379
+ campaign = storage.get_campaign(campaign_id)
380
+ if not campaign:
381
+ raise HTTPException(404, "Campanha não encontrada.")
382
+ campaign["status"] = interno
383
+ storage.upsert_campaign(campaign)
384
+ return campaign
385
+
386
+
363
387
  @app.post("/api/campaigns/{campaign_id}/start")
364
388
  def iniciar_campanha(campaign_id: str):
365
389
  resultado = agents.iniciar_campanha(campaign_id)
@@ -152,7 +152,10 @@ def _executar_campanha(campaign_id: str) -> None:
152
152
  storage.upsert_campaign(campaign)
153
153
  continue
154
154
 
155
- mensagem = render_template(templates.get(campaign.get("canal", "texto"), templates.get("texto", "")), lead)
155
+ mensagem = render_template(
156
+ campaign.get("mensagem") or templates.get(campaign.get("canal", "texto"), templates.get("texto", "")),
157
+ lead,
158
+ )
156
159
  try:
157
160
  r = httpx.post(
158
161
  f"{base}/{send_path}",
@@ -0,0 +1,71 @@
1
+ """Testes do módulo de Campanhas/Disparos (backend/main.py).
2
+
3
+ Cobrem listagem, criação (com filtro por etapa do CRM) e alteração de status
4
+ via PUT /api/campaigns/{id}/status. LEADMASTER_HOME em diretório temporário.
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_campanhas(client):
21
+ r = client.get("/api/campaigns")
22
+ assert r.status_code == 200
23
+ assert isinstance(r.json(), list)
24
+ assert len(r.json()) >= 1 # seeds de demonstração
25
+
26
+
27
+ def test_criar_campanha_com_filtro_etapa(client):
28
+ # Total de leads na etapa "leads" (seeds).
29
+ leads_leads = [l for l in client.get("/api/crm/leads").json() if l["etapa"] == "leads"]
30
+ r = client.post(
31
+ "/api/campaigns",
32
+ json={
33
+ "nome": "Campanha Filtro",
34
+ "canal": "texto",
35
+ "template": "texto",
36
+ "mensagem": "Olá {{primeiro_nome}}, tudo bem?",
37
+ "filtro_etapa": "leads",
38
+ },
39
+ )
40
+ assert r.status_code == 201
41
+ campanha = r.json()
42
+ assert campanha["nome"] == "Campanha Filtro"
43
+ assert campanha["status"] == "rascunho"
44
+ assert campanha["total"] == len(leads_leads)
45
+ assert len(campanha["lead_ids"]) == len(leads_leads)
46
+ # Persistida
47
+ lista = client.get("/api/campaigns").json()
48
+ assert any(c["id"] == campanha["id"] for c in lista)
49
+
50
+
51
+ def test_criar_campanha_payload_invalido(client):
52
+ r = client.post("/api/campaigns", json={"canal": "texto"})
53
+ assert r.status_code == 422 # nome obrigatório
54
+
55
+
56
+ def test_alterar_status_ativa_e_pausada(client):
57
+ criada = client.post("/api/campaigns", json={"nome": "Status Teste", "lead_ids": []}).json()
58
+ r = client.put(f"/api/campaigns/{criada['id']}/status", json={"status": "ativa"})
59
+ assert r.status_code == 200
60
+ assert r.json()["status"] == "em_andamento"
61
+ r2 = client.put(f"/api/campaigns/{criada['id']}/status", json={"status": "pausada"})
62
+ assert r2.status_code == 200
63
+ assert r2.json()["status"] == "pausada"
64
+
65
+
66
+ def test_alterar_status_invalido_e_inexistente(client):
67
+ criada = client.post("/api/campaigns", json={"nome": "Status X", "lead_ids": []}).json()
68
+ r = client.put(f"/api/campaigns/{criada['id']}/status", json={"status": "nao_existe"})
69
+ assert r.status_code == 400
70
+ r2 = client.put("/api/campaigns/nao-existe/status", json={"status": "ativa"})
71
+ assert r2.status_code == 404
@@ -66,6 +66,18 @@ class TestLauncherProtections:
66
66
  assert "detached: true" in c
67
67
  assert "windowsHide: true" in c
68
68
 
69
+ def test_kill_switch_presente(self):
70
+ c = _conteudo()
71
+ assert "encerrarInstanciaAnterior" in c
72
+ assert "netstat" in c # descoberta do PID no Windows
73
+ assert "lsof" in c # descoberta do PID no Linux/macOS
74
+ assert "taskkill" in c and "/F" in c # kill switch Windows
75
+
76
+ def test_nunca_avisa_ja_esta_rodando(self):
77
+ # Mensagem proibida: impede atualizações e deixa a versão antiga no ar.
78
+ assert "já está rodando" not in _conteudo().lower()
79
+ assert "ja esta rodando" not in _conteudo().lower()
80
+
69
81
  def test_sigterm_nao_absorvido(self):
70
82
  # SIGTERM/SIGBREAK são paradas legítimas: sempre encerram.
71
83
  c = _conteudo()