@danhachuel/leadmaster 0.1.15 → 0.1.17

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 (27) hide show
  1. package/CHANGELOG.md +63 -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 +25 -0
  22. package/frontend/dist/assets/{index-Bf28IekR.css → index-BKo_OphM.css} +1 -1
  23. package/frontend/dist/assets/index-BWyDEt5a.js +90 -0
  24. package/frontend/dist/index.html +2 -2
  25. package/package.json +1 -1
  26. package/scripts/cli.mjs +66 -5
  27. package/frontend/dist/assets/index-CAvEEMWg.js +0 -90
package/CHANGELOG.md CHANGED
@@ -37,6 +37,69 @@ 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.17] — 2026-11-09
41
+
42
+ ### Corrigido
43
+
44
+ - **Erro `EBUSY` do npx — migração para Runtime Permanente.** A 0.1.15 tentou
45
+ resolver com kill switch dentro do launcher, o que é impossível: o `EBUSY`
46
+ acontece no gerenciador de arquivos do `npx` (`_npx\...`) **antes** de o
47
+ `cli.mjs` ser carregado, então o kill switch nunca rodava. Causa raiz: o
48
+ `uvicorn` era spawnado com `cwd` dentro do cache do npx, segurando o
49
+ diretório. Nova arquitetura:
50
+ - Pasta permanente **`<home>/.runtime`** guarda `backend/` + `frontend/dist/`.
51
+ - `install`: kill switch (libera arquivos) → apaga o `.runtime` anterior →
52
+ copia esta versão do pacote para ele. O cache do npx nunca mais é tocado
53
+ por processos nossos.
54
+ - `start`: spawna o Python a partir de **`<home>/.runtime/backend`** (nunca
55
+ de `__dirname`); sem `.runtime` pronto, auto-aprovisiona; com pronto,
56
+ reutiliza sem recopiar.
57
+ - Launcher faz `process.chdir(home)` na entrada — cwd fora do cache.
58
+ - Regra inviolável documentada no AGENTS.md ("terminantemente proibido
59
+ executar o servidor a partir da pasta temporária do npx") + gate de
60
+ regressão (`.runtime`, `copiarParaRuntime`, `cpSync`, `rmSync`,
61
+ `path.join(runtime, 'backend')`, `process.chdir(homeLauncher)`).
62
+ - `.runtime/` adicionado ao `.gitignore`; `doctor` reporta o status do
63
+ runtime.
64
+
65
+ ## [0.1.16] — 2026-11-09
66
+
67
+ ### Adicionado
68
+
69
+ - **Módulo de Disparos (Campanhas) revisado conforme especificação:**
70
+ - Rota `PUT /api/campaigns/{id}/status` — altera status
71
+ (`rascunho`/`ativa`/`pausada`/`concluida`; `ativa` normalize para
72
+ `em_andamento`; status inválido → 400, campanha inexistente → 404).
73
+ - `POST /api/campaigns` agora aceita **`filtro_etapa`**: sem `lead_ids`
74
+ explícitos, seleciona automaticamente os leads da etapa do CRM escolhida
75
+ (ex.: "Novos Leads"), e **`mensagem`** (texto do template) — usada pelo
76
+ agente de disparo quando presente.
77
+ - Página `frontend/src/pages/Disparos.tsx` reformulada em **tabela**
78
+ (Nome · Status · Total de Leads · Data de Criação · Ações), botão
79
+ **"Nova Campanha"** grande no topo (Fitts + von Restorff) e modal com nome,
80
+ **template da mensagem** (textarea), canal e **dropdown de filtro por etapa
81
+ do CRM**. Ações na tabela: **Iniciar** (gradiente), **Pausar** e **Parar**.
82
+ - UX: badges de status vibrantes (verde "Ativa", amarelo "Pausada", cinza
83
+ "Rascunho" — von Restorff) e **barra de progresso + spinner** para
84
+ campanhas ativas (Zeigarnik, com polling react-query a cada 4s).
85
+ - **5 novos testes pytest** (`backend/tests/test_campaigns_routes.py`):
86
+ listagem, criação com filtro de etapa + persistência, payload inválido,
87
+ alteração de status (ativa/pausada) e erros (status inválido 400 /
88
+ inexistente 404).
89
+
90
+ ### Corrigido
91
+
92
+ - **Launcher — kill switch (6ª regra inviolável):** o launcher NUNCA mais
93
+ ignora uma porta ocupada por instância anterior do LeadMaster — antes de
94
+ subir, encerra a instância antiga confirmada (PID dono da porta via
95
+ `netstat -ano` no Windows / `lsof -ti:porta -sTCP:LISTEN` no Unix, com
96
+ fallback `ps`), `taskkill /F /T /PID` ou `kill -9`, aguardando até 5s a
97
+ liberação. A mensagem "já está rodando" foi **removida** (impedia
98
+ atualizações e causava `EBUSY` no npx). Processos de outros projetos nunca
99
+ são tocados (avança para a próxima porta). Roda no `install` (libera
100
+ arquivos) e no `start`. Gate de regressão reforçado com as asserções do
101
+ kill switch.
102
+
40
103
  ## [0.1.15] — 2026-11-08
41
104
 
42
105
  ### 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,31 @@ 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
+
81
+ def test_runtime_permanente(self):
82
+ # Arquitetura anti-EBUSY: executar de <home>/.runtime, nunca do cache npx.
83
+ c = _conteudo()
84
+ assert ".runtime" in c
85
+ assert "copiarParaRuntime" in c
86
+ assert "runtimeDir" in c
87
+ assert "cpSync" in c # cópia recursiva para o runtime
88
+ assert "rmSync" in c # limpeza do runtime anterior
89
+ # O spawn do servidor usa o runtime, não o PKG_ROOT.
90
+ assert "path.join(runtime, 'backend')" in c
91
+ # O launcher sai do cache do npx logo na entrada.
92
+ assert "process.chdir(homeLauncher)" in c
93
+
69
94
  def test_sigterm_nao_absorvido(self):
70
95
  # SIGTERM/SIGBREAK são paradas legítimas: sempre encerram.
71
96
  c = _conteudo()