@danhachuel/prodmaker 0.1.14 → 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.
- package/CHANGELOG.md +37 -0
- package/app/api/ads.py +3 -5
- package/app/api/billing.py +63 -0
- package/app/api/email_mkt.py +65 -0
- package/app/api/fraude.py +35 -0
- package/app/api/tiktok.py +16 -0
- package/app/main.py +25 -3
- package/app/services/database.py +63 -9
- package/app/services/dbengine.py +2 -1
- package/app/services/email_mkt.py +146 -0
- package/app/services/fraude.py +174 -0
- package/app/services/publish_ads.py +3 -2
- package/app/services/publish_tiktok.py +155 -0
- package/app/services/stripe_billing.py +147 -0
- package/app/services/vendas.py +14 -0
- package/frontend/dist/assets/index-B6BGGw5j.js +80 -0
- package/frontend/dist/assets/index-Feb3evdj.css +1 -0
- package/frontend/dist/index.html +2 -2
- package/package.json +1 -1
- package/scripts/cli.mjs +1 -1
- package/seed/planos.json +56 -0
- package/frontend/dist/assets/index-B4wxnTCs.css +0 -1
- package/frontend/dist/assets/index-tkOFAfR-.js +0 -77
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,43 @@
|
|
|
3
3
|
Todas as versões seguem a etiqueta visual `MAJOR.MINOR.PATCH` com patch de dois dígitos
|
|
4
4
|
(o registry NPM normaliza `0.1.00` → `0.1.0`).
|
|
5
5
|
|
|
6
|
+
## 0.1.16 — 2026-10-31
|
|
7
|
+
|
|
8
|
+
- **Fase 12 — Billing Stripe + E-mail Marketing (AdsAcademy) + Supabase multi-tenant pronto.**
|
|
9
|
+
- **Billing:** `seed/planos.json` (Local grátis / Lançador PRO R$97 / Escala AGENCY R$297),
|
|
10
|
+
página **Plano & Assinatura** com cards, e-mail de conta e botão Assinar (abre o
|
|
11
|
+
Checkout do Stripe em nova aba); `POST /api/billing/checkout`, `/api/billing/assinatura`,
|
|
12
|
+
`/api/billing/historico` e **webhook público** `/api/billing/webhook` com verificação
|
|
13
|
+
de assinatura HMAC-SHA256 (t=...,v1=...) processando checkout.session.completed,
|
|
14
|
+
invoice.paid e customer.subscription.deleted. Sem chaves Stripe o app segue 100%
|
|
15
|
+
liberado em modo local (nenhuma trava de pré-registo).
|
|
16
|
+
- **E-mail Marketing (AdsAcademy):** sequências com gatilho (`venda_recebida` dispara
|
|
17
|
+
automaticamente a cada webhook de venda, `produto_salvo`, `manual`), variáveis
|
|
18
|
+
{{nome}}/{{produto}}/{{valor}}/{{link}}, envio pelo apprise já integrado (mailtos://,
|
|
19
|
+
tgram://...), editor de sequências, envio manual e histórico com status/erro. A
|
|
20
|
+
plataforma completa emareach fica como backlog da nuvem.
|
|
21
|
+
- **Supabase multi-tenant:** `DATABASE_URL` agora também aceita o alias **`SUPABASE_DB_URL`**
|
|
22
|
+
(connection string do Supabase) mantendo SQLite como fallback — user_id já cobre as
|
|
23
|
+
15 tabelas; a ativação do multi-tenant depende apenas do Supabase Auth (backlog).
|
|
24
|
+
- Novas tabelas: `assinaturas`, `email_sequencias`, `email_envios`.
|
|
25
|
+
|
|
26
|
+
## 0.1.15 — 2026-10-31
|
|
27
|
+
|
|
28
|
+
- **Fase 11 — TikTok Ads real + Anti-Fraude AdsPixel (Redis).**
|
|
29
|
+
- **TikTok Ads (publicação automática):** `publish_tiktok.py` — troca do auth_code por
|
|
30
|
+
access_token/refresh_token (`POST /api/tiktok/token`) e criação de campanha + grupo
|
|
31
|
+
PAUSADOS via Marketing API v1.3 (objective CONVERSIONS, budget diário US$,
|
|
32
|
+
placement automático); passo final do criativo com instrução honesta quando não há
|
|
33
|
+
mídia. Erros da API atribuídos. Card TikTok em Chaves de API ganhou auth_code +
|
|
34
|
+
botão "Trocar auth_code por token". AdsCreator passa a publicar TikTok de verdade.
|
|
35
|
+
- **Anti-Fraude (AdsPixel):** `fraude.py` seguindo o padrão do redis-datasets/fraud-detection —
|
|
36
|
+
**IP blacklist (Redis Cuckoo Filter)** + **click spamming (Sorted Set com janela de 60s e
|
|
37
|
+
limite de 10 cliques)**; **fallback SQLite automático sem Redis**; página **Anti-Fraude**
|
|
38
|
+
(/tracking/fraude) com status do motor, bloqueio/desbloqueio manual, cópia da lista e
|
|
39
|
+
métricas de spam 24h. O `/track` agora avalia IP bloqueado antes de gravar e o pixel
|
|
40
|
+
nunca quebra a página do visitante. Card **Redis** em Chaves de API (REDIS_URL).
|
|
41
|
+
- OAuth Google Ads (item 1 da Fase 11) já estava entregue na 0.1.12.
|
|
42
|
+
|
|
6
43
|
## 0.1.14 — 2026-10-31
|
|
7
44
|
|
|
8
45
|
- **Fase 10 — REVISÃO (fechamento das lacunas apontadas na criação de produto/PDF).**
|
package/app/api/ads.py
CHANGED
|
@@ -143,11 +143,9 @@ def campanhas_publicar(campanha_id: int):
|
|
|
143
143
|
elif plataforma == "meta":
|
|
144
144
|
resultado = publish_ads.publicar_meta(campanha)
|
|
145
145
|
else:
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
"observacao": "Plano manual — crie manualmente no TikTok Ads Manager.",
|
|
150
|
-
}
|
|
146
|
+
from ..services import publish_tiktok
|
|
147
|
+
|
|
148
|
+
resultado = publish_tiktok.publicar_campanha(campanha)
|
|
151
149
|
except LLMError as exc:
|
|
152
150
|
campaigns.marcar_publicada(campanha_id, {}, status="erro", erro=str(exc))
|
|
153
151
|
raise HTTPException(status_code=400, detail=str(exc))
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Billing — planos (seed), checkout Stripe, webhook público e status da assinatura."""
|
|
2
|
+
from fastapi import APIRouter, HTTPException, Request
|
|
3
|
+
from fastapi.responses import Response
|
|
4
|
+
from pydantic import BaseModel
|
|
5
|
+
|
|
6
|
+
from ..services import stripe_billing
|
|
7
|
+
from ..services.llm import LLMError
|
|
8
|
+
|
|
9
|
+
router = APIRouter(prefix="/api/billing", tags=["billing"])
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class CheckoutBody(BaseModel):
|
|
13
|
+
plano_id: str
|
|
14
|
+
email: str
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@router.get("/planos")
|
|
18
|
+
def planos():
|
|
19
|
+
return stripe_billing.planos_local()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@router.get("/assinatura")
|
|
23
|
+
def assinatura():
|
|
24
|
+
return stripe_billing.assinatura_atual()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@router.get("/historico")
|
|
28
|
+
def historico():
|
|
29
|
+
return stripe_billing.historico()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@router.post("/checkout")
|
|
33
|
+
def checkout(body: CheckoutBody, request: Request):
|
|
34
|
+
base = f"{request.url.scheme}://{request.headers.get('host', 'localhost:8188')}"
|
|
35
|
+
try:
|
|
36
|
+
return stripe_billing.criar_checkout(
|
|
37
|
+
body.plano_id,
|
|
38
|
+
body.email,
|
|
39
|
+
f"{base}/configuracoes?checkout=ok",
|
|
40
|
+
f"{base}/configuracoes?checkout=cancelado",
|
|
41
|
+
)
|
|
42
|
+
except LLMError as exc:
|
|
43
|
+
raise HTTPException(status_code=400, detail=str(exc))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@router.options("/webhook")
|
|
47
|
+
def webhook_options():
|
|
48
|
+
return Response(status_code=204)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@router.post("/webhook")
|
|
52
|
+
async def webhook_stripe(request: Request):
|
|
53
|
+
"""Webhook público do Stripe — verifica assinatura e processa o evento."""
|
|
54
|
+
body = await request.body()
|
|
55
|
+
if not stripe_billing.verificar_assinatura(body, request.headers.get("stripe-signature", "")):
|
|
56
|
+
raise HTTPException(status_code=400, detail="Assinatura do webhook inválida.")
|
|
57
|
+
import json
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
evento = json.loads(body)
|
|
61
|
+
except json.JSONDecodeError:
|
|
62
|
+
raise HTTPException(status_code=400, detail="Payload inválido.")
|
|
63
|
+
return stripe_billing.processar_webhook(evento)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""E-mail Marketing (AdsAcademy) — sequências com gatilho, envio manual e histórico."""
|
|
2
|
+
from fastapi import APIRouter, HTTPException
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
|
|
5
|
+
from ..services import email_mkt
|
|
6
|
+
from ..services.llm import LLMError
|
|
7
|
+
|
|
8
|
+
router = APIRouter(prefix="/api/email", tags=["email"])
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SequenciaBody(BaseModel):
|
|
12
|
+
nome: str
|
|
13
|
+
gatilho: str
|
|
14
|
+
assunto: str
|
|
15
|
+
corpo: str
|
|
16
|
+
ativo: bool = True
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class EnviarBody(BaseModel):
|
|
20
|
+
sequencia_id: int
|
|
21
|
+
destinos: list = []
|
|
22
|
+
variaveis: dict = {}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@router.get("/sequencias")
|
|
26
|
+
def sequencias():
|
|
27
|
+
return email_mkt.listar()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@router.post("/sequencias")
|
|
31
|
+
def criar(body: SequenciaBody):
|
|
32
|
+
try:
|
|
33
|
+
return email_mkt.salvar(body.nome, body.gatilho, body.assunto, body.corpo, body.ativo)
|
|
34
|
+
except LLMError as exc:
|
|
35
|
+
raise HTTPException(status_code=400, detail=str(exc))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@router.put("/sequencias/{sequencia_id}")
|
|
39
|
+
def atualizar(sequencia_id: int, body: SequenciaBody):
|
|
40
|
+
try:
|
|
41
|
+
return email_mkt.salvar(body.nome, body.gatilho, body.assunto, body.corpo, body.ativo, sequencia_id)
|
|
42
|
+
except LLMError as exc:
|
|
43
|
+
raise HTTPException(status_code=400, detail=str(exc))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@router.delete("/sequencias/{sequencia_id}")
|
|
47
|
+
def remover(sequencia_id: int):
|
|
48
|
+
try:
|
|
49
|
+
email_mkt.remover(sequencia_id)
|
|
50
|
+
except LLMError as exc:
|
|
51
|
+
raise HTTPException(status_code=404, detail=str(exc))
|
|
52
|
+
return {"ok": True}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@router.post("/enviar")
|
|
56
|
+
def enviar(body: EnviarBody):
|
|
57
|
+
try:
|
|
58
|
+
return email_mkt.enviar_manual(body.sequencia_id, body.destinos, body.variaveis)
|
|
59
|
+
except LLMError as exc:
|
|
60
|
+
raise HTTPException(status_code=400, detail=str(exc))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@router.get("/historico")
|
|
64
|
+
def historico():
|
|
65
|
+
return email_mkt.historico()
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Anti-Fraude (AdsPixel) — painel de IPs bloqueados, bloqueio/desbloqueio e resumo."""
|
|
2
|
+
from fastapi import APIRouter, HTTPException
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
|
|
5
|
+
from ..services import fraude
|
|
6
|
+
from ..services.llm import LLMError
|
|
7
|
+
|
|
8
|
+
router = APIRouter(prefix="/api/fraude", tags=["anti-fraude"])
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BloquearBody(BaseModel):
|
|
12
|
+
ip: str
|
|
13
|
+
motivo: str = "manual"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@router.get("/resumo")
|
|
17
|
+
def resumo():
|
|
18
|
+
return fraude.resumo()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@router.post("/bloquear")
|
|
22
|
+
def bloquear(body: BloquearBody):
|
|
23
|
+
try:
|
|
24
|
+
return fraude.bloquear_ip(body.ip, body.motivo)
|
|
25
|
+
except LLMError as exc:
|
|
26
|
+
raise HTTPException(status_code=400, detail=str(exc))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@router.delete("/ips/{ip}")
|
|
30
|
+
def desbloquear(ip: str):
|
|
31
|
+
try:
|
|
32
|
+
fraude.desbloquear_ip(ip)
|
|
33
|
+
except LLMError as exc:
|
|
34
|
+
raise HTTPException(status_code=404, detail=str(exc))
|
|
35
|
+
return {"ok": True}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""TikTok Ads — troca do auth_code por access_token (1 clique)."""
|
|
2
|
+
from fastapi import APIRouter, HTTPException
|
|
3
|
+
|
|
4
|
+
from ..services.llm import LLMError
|
|
5
|
+
from ..services.publish_tiktok import trocar_auth_code
|
|
6
|
+
|
|
7
|
+
router = APIRouter(prefix="/api/tiktok", tags=["tiktok"])
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@router.post("/token")
|
|
11
|
+
def token():
|
|
12
|
+
"""Troca o auth_code salvo em Chaves de API por access_token + refresh_token."""
|
|
13
|
+
try:
|
|
14
|
+
return trocar_auth_code()
|
|
15
|
+
except LLMError as exc:
|
|
16
|
+
raise HTTPException(status_code=400, detail=str(exc))
|
package/app/main.py
CHANGED
|
@@ -15,7 +15,7 @@ from .services.database import ensure_db, get_db, now_str, row_to_dict
|
|
|
15
15
|
from .services.llm import LLMError, analyze_product, list_providers
|
|
16
16
|
from .services.store import PRODMAKER_HOME, STORAGE, STATE, ensure_state_dir, get_master_code, get_settings, save_settings
|
|
17
17
|
|
|
18
|
-
APP_VERSION = "0.1.
|
|
18
|
+
APP_VERSION = "0.1.16"
|
|
19
19
|
COOKIE_NAME = "prodmaker_session"
|
|
20
20
|
SESSION_DAYS = 30
|
|
21
21
|
MASTER_ALIAS = "admin"
|
|
@@ -41,6 +41,10 @@ from .api import guias as guias_router # noqa: E402
|
|
|
41
41
|
from .api import contas as contas_router # noqa: E402
|
|
42
42
|
from .api import invest as invest_router # noqa: E402
|
|
43
43
|
from .api import builder as builder_router # noqa: E402
|
|
44
|
+
from .api import fraude as fraude_router # noqa: E402
|
|
45
|
+
from .api import tiktok as tiktok_router # noqa: E402
|
|
46
|
+
from .api import billing as billing_router # noqa: E402
|
|
47
|
+
from .api import email_mkt as email_mkt_router # noqa: E402
|
|
44
48
|
|
|
45
49
|
app.include_router(llm_router.router)
|
|
46
50
|
app.include_router(infoprodutos_router.router)
|
|
@@ -58,6 +62,10 @@ app.include_router(guias_router.router)
|
|
|
58
62
|
app.include_router(contas_router.router)
|
|
59
63
|
app.include_router(invest_router.router)
|
|
60
64
|
app.include_router(builder_router.router)
|
|
65
|
+
app.include_router(fraude_router.router)
|
|
66
|
+
app.include_router(tiktok_router.router)
|
|
67
|
+
app.include_router(billing_router.router)
|
|
68
|
+
app.include_router(email_mkt_router.router)
|
|
61
69
|
|
|
62
70
|
ensure_db()
|
|
63
71
|
|
|
@@ -264,7 +272,7 @@ def health():
|
|
|
264
272
|
|
|
265
273
|
@app.get("/api/version")
|
|
266
274
|
def version():
|
|
267
|
-
return {"version": APP_VERSION, "canonical": "0.1.
|
|
275
|
+
return {"version": APP_VERSION, "canonical": "0.1.16"}
|
|
268
276
|
|
|
269
277
|
|
|
270
278
|
@app.get("/api/settings")
|
|
@@ -300,6 +308,20 @@ def track(request: Request):
|
|
|
300
308
|
forwarded = request.headers.get("x-forwarded-for", "")
|
|
301
309
|
ip = forwarded.split(",")[0].strip() or (request.client.host if request.client else "")
|
|
302
310
|
known = {"event", "page", "gclid", "utm_source", "utm_medium", "utm_campaign", "ref"}
|
|
311
|
+
|
|
312
|
+
# Anti-fraude (AdsPixel): IP bloqueado nao entra no tracking; spam e sinalizado
|
|
313
|
+
from .services import fraude
|
|
314
|
+
|
|
315
|
+
try:
|
|
316
|
+
avaliacao = fraude.avaliar_visita(ip)
|
|
317
|
+
if avaliacao.get("bloqueado"):
|
|
318
|
+
# IP na blacklist: nao grava a visita (o pixel segue respondendo 204)
|
|
319
|
+
return Response(status_code=204, headers={"Access-Control-Allow-Origin": "*"})
|
|
320
|
+
if params.get("event") == "cta_click":
|
|
321
|
+
fraude.registrar_clique(ip, params.get("page", ""))
|
|
322
|
+
except Exception:
|
|
323
|
+
pass # anti-fraude nunca quebra o pixel
|
|
324
|
+
extra_params = json.dumps({k: v for k, v in params.items() if k not in known}, ensure_ascii=False)
|
|
303
325
|
try:
|
|
304
326
|
with get_db() as conn:
|
|
305
327
|
conn.execute(
|
|
@@ -314,7 +336,7 @@ def track(request: Request):
|
|
|
314
336
|
params.get("ref", ""),
|
|
315
337
|
ip,
|
|
316
338
|
request.headers.get("user-agent", ""),
|
|
317
|
-
|
|
339
|
+
extra_params,
|
|
318
340
|
now_str(),
|
|
319
341
|
),
|
|
320
342
|
)
|
package/app/services/database.py
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
|
-
"""Banco de dados —
|
|
1
|
+
"""Banco de dados — fachada sobre dbengine (SQLite padrao, PostgreSQL/Supabase via DATABASE_URL).
|
|
2
2
|
|
|
3
|
-
Esquema
|
|
4
|
-
multi-tenant — hoje vazio). `ensure_db()` cria o que falta e aplica ALTERs de
|
|
3
|
+
Esquema unico para os dois engines. Todas as tabelas carregam `user_id` (preparacao
|
|
4
|
+
multi-tenant — hoje vazio). `ensure_db()` cria o que falta e aplica ALTERs de migracao.
|
|
5
5
|
"""
|
|
6
6
|
from .dbengine import (
|
|
7
7
|
add_column_if_missing,
|
|
8
8
|
is_postgres,
|
|
9
|
-
now_str, # noqa: F401 (reexportado para os
|
|
10
|
-
row_to_dict, # noqa: F401 (reexportado para os
|
|
9
|
+
now_str, # noqa: F401 (reexportado para os servicos)
|
|
10
|
+
row_to_dict, # noqa: F401 (reexportado para os servicos)
|
|
11
11
|
)
|
|
12
12
|
|
|
13
13
|
# ---------------------------------------------------------------------------
|
|
14
|
-
# Esquema — SQLite DDL; para PostgreSQL as mesmas
|
|
14
|
+
# Esquema — SQLite DDL; para PostgreSQL as mesmas definicoes sao traduzidas.
|
|
15
15
|
# ---------------------------------------------------------------------------
|
|
16
16
|
|
|
17
17
|
TABLES = {
|
|
@@ -201,6 +201,62 @@ TABLES = {
|
|
|
201
201
|
created_at TEXT NOT NULL
|
|
202
202
|
)
|
|
203
203
|
""",
|
|
204
|
+
"ips_bloqueados": """
|
|
205
|
+
CREATE TABLE IF NOT EXISTS ips_bloqueados (
|
|
206
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
207
|
+
user_id TEXT NOT NULL DEFAULT '',
|
|
208
|
+
ip TEXT NOT NULL,
|
|
209
|
+
motivo TEXT NOT NULL DEFAULT 'manual',
|
|
210
|
+
origem TEXT NOT NULL DEFAULT 'sqlite',
|
|
211
|
+
created_at TEXT NOT NULL
|
|
212
|
+
)
|
|
213
|
+
""",
|
|
214
|
+
"fraude_eventos": """
|
|
215
|
+
CREATE TABLE IF NOT EXISTS fraude_eventos (
|
|
216
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
217
|
+
user_id TEXT NOT NULL DEFAULT '',
|
|
218
|
+
ip TEXT NOT NULL,
|
|
219
|
+
page TEXT NOT NULL DEFAULT '',
|
|
220
|
+
created_epoch REAL NOT NULL DEFAULT 0,
|
|
221
|
+
created_at TEXT NOT NULL
|
|
222
|
+
)
|
|
223
|
+
""",
|
|
224
|
+
"assinaturas": """
|
|
225
|
+
CREATE TABLE IF NOT EXISTS assinaturas (
|
|
226
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
227
|
+
user_id TEXT NOT NULL DEFAULT '',
|
|
228
|
+
plano TEXT NOT NULL DEFAULT '',
|
|
229
|
+
email TEXT NOT NULL DEFAULT '',
|
|
230
|
+
stripe_ref TEXT NOT NULL DEFAULT '',
|
|
231
|
+
status TEXT NOT NULL DEFAULT 'checkout',
|
|
232
|
+
current_period_end TEXT NOT NULL DEFAULT '',
|
|
233
|
+
created_at TEXT NOT NULL
|
|
234
|
+
)
|
|
235
|
+
""",
|
|
236
|
+
"email_sequencias": """
|
|
237
|
+
CREATE TABLE IF NOT EXISTS email_sequencias (
|
|
238
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
239
|
+
user_id TEXT NOT NULL DEFAULT '',
|
|
240
|
+
nome TEXT NOT NULL,
|
|
241
|
+
gatilho TEXT NOT NULL DEFAULT 'manual',
|
|
242
|
+
assunto TEXT NOT NULL,
|
|
243
|
+
corpo TEXT NOT NULL,
|
|
244
|
+
ativo INTEGER NOT NULL DEFAULT 1,
|
|
245
|
+
created_at TEXT NOT NULL
|
|
246
|
+
)
|
|
247
|
+
""",
|
|
248
|
+
"email_envios": """
|
|
249
|
+
CREATE TABLE IF NOT EXISTS email_envios (
|
|
250
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
251
|
+
user_id TEXT NOT NULL DEFAULT '',
|
|
252
|
+
sequencia_id INTEGER NOT NULL DEFAULT 0,
|
|
253
|
+
destino TEXT NOT NULL DEFAULT '',
|
|
254
|
+
assunto TEXT NOT NULL DEFAULT '',
|
|
255
|
+
status TEXT NOT NULL DEFAULT 'pendente',
|
|
256
|
+
erro TEXT NOT NULL DEFAULT '',
|
|
257
|
+
created_at TEXT NOT NULL
|
|
258
|
+
)
|
|
259
|
+
""",
|
|
204
260
|
}
|
|
205
261
|
|
|
206
262
|
ALL_TABLES = list(TABLES.keys())
|
|
@@ -225,11 +281,9 @@ def ensure_db() -> None:
|
|
|
225
281
|
else:
|
|
226
282
|
for ddl in TABLES.values():
|
|
227
283
|
conn.execute(ddl)
|
|
228
|
-
#
|
|
284
|
+
# Migracao: bancos criados antes da coluna user_id / da tabela investimentos
|
|
229
285
|
for table in ALL_TABLES:
|
|
230
286
|
add_column_if_missing(conn, table, "user_id TEXT NOT NULL DEFAULT ''", "user_id")
|
|
231
|
-
if "investimentos" not in [row["name"] for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")]:
|
|
232
|
-
conn.execute(TABLES["investimentos"])
|
|
233
287
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_vendas_gclid ON vendas (gclid)")
|
|
234
288
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_visitas_gclid ON visitas (gclid)")
|
|
235
289
|
|
package/app/services/dbengine.py
CHANGED
|
@@ -31,7 +31,8 @@ def row_to_dict(row) -> dict:
|
|
|
31
31
|
|
|
32
32
|
|
|
33
33
|
def dsn() -> str | None:
|
|
34
|
-
|
|
34
|
+
"""Connection string do PostgreSQL — aceita DATABASE_URL ou SUPABASE_DB_URL."""
|
|
35
|
+
return (os.environ.get("DATABASE_URL") or os.environ.get("SUPABASE_DB_URL") or "").strip() or None
|
|
35
36
|
|
|
36
37
|
|
|
37
38
|
def is_postgres() -> bool:
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""E-mail Marketing / AdsAcademy — sequências transacionais via apprise.
|
|
2
|
+
|
|
3
|
+
Emareach (plataforma completa self-hosted) fica como backlog da nuvem; aqui o
|
|
4
|
+
motor honesto é o apprise já integrado: sequências com gatilho (venda recebida,
|
|
5
|
+
produto salvo, manual), variáveis {{nome}}, {{produto}}, {{valor}}, {{link}} e
|
|
6
|
+
histórico de envios. Sem canais configurados: erro claro, nunca silencioso.
|
|
7
|
+
"""
|
|
8
|
+
import re
|
|
9
|
+
|
|
10
|
+
from .database import get_db, now_str, row_to_dict
|
|
11
|
+
from .llm import LLMError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _render(texto: str, variaveis: dict) -> str:
|
|
15
|
+
for chave, valor in (variaveis or {}).items():
|
|
16
|
+
texto = texto.replace("{{" + chave + "}}", str(valor or ""))
|
|
17
|
+
return texto
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def listar(gatilho: str = "") -> list[dict]:
|
|
21
|
+
query = "SELECT * FROM email_sequencias"
|
|
22
|
+
params: tuple = ()
|
|
23
|
+
if gatilho:
|
|
24
|
+
query += " WHERE gatilho = ?"
|
|
25
|
+
params = (gatilho,)
|
|
26
|
+
query += " ORDER BY id DESC"
|
|
27
|
+
with get_db() as conn:
|
|
28
|
+
rows = conn.execute(query, params).fetchall()
|
|
29
|
+
return [row_to_dict(row) for row in rows]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def salvar(nome: str, gatilho: str, assunto: str, corpo: str, ativo: bool = True, sequencia_id: int | None = None) -> dict:
|
|
33
|
+
if not nome.strip() or not assunto.strip() or not corpo.strip():
|
|
34
|
+
raise LLMError("Informe nome, assunto e corpo da sequência.")
|
|
35
|
+
if gatilho not in ("venda_recebida", "produto_salvo", "manual"):
|
|
36
|
+
raise LLMError("Gatilho inválido: use venda_recebida, produto_salvo ou manual.")
|
|
37
|
+
with get_db() as conn:
|
|
38
|
+
if sequencia_id:
|
|
39
|
+
cursor = conn.execute(
|
|
40
|
+
"UPDATE email_sequencias SET nome = ?, gatilho = ?, assunto = ?, corpo = ?, ativo = ? WHERE id = ?",
|
|
41
|
+
(nome.strip(), gatilho, assunto.strip(), corpo, bool(ativo), sequencia_id),
|
|
42
|
+
)
|
|
43
|
+
if cursor.rowcount == 0:
|
|
44
|
+
raise LLMError("Sequência não encontrada.")
|
|
45
|
+
else:
|
|
46
|
+
cursor = conn.execute(
|
|
47
|
+
"INSERT INTO email_sequencias (nome, gatilho, assunto, corpo, ativo, created_at) VALUES (?, ?, ?, ?, ?, ?)",
|
|
48
|
+
(nome.strip(), gatilho, assunto.strip(), corpo, bool(ativo), now_str()),
|
|
49
|
+
)
|
|
50
|
+
sequencia_id = cursor.lastrowid
|
|
51
|
+
row = conn.execute("SELECT * FROM email_sequencias WHERE id = ?", (sequencia_id,)).fetchone()
|
|
52
|
+
return row_to_dict(row)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def remover(sequencia_id: int) -> None:
|
|
56
|
+
with get_db() as conn:
|
|
57
|
+
cursor = conn.execute("DELETE FROM email_sequencias WHERE id = ?", (sequencia_id,))
|
|
58
|
+
if cursor.rowcount == 0:
|
|
59
|
+
raise LLMError("Sequência não encontrada.")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _variaveis_padrao() -> dict:
|
|
63
|
+
settings_nome = "cliente"
|
|
64
|
+
return {"nome": settings_nome, "produto": "seu produto", "valor": "", "link": ""}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def aplicar_gatilho(gatilho: str, variaveis: dict, destinos: list[str] | None = None) -> dict:
|
|
68
|
+
"""Dispara as sequências ativas do gatilho (usado pelos hooks de venda/produto)."""
|
|
69
|
+
enviados = []
|
|
70
|
+
erros = []
|
|
71
|
+
for sequencia in listar(gatilho):
|
|
72
|
+
if not sequencia["ativo"]:
|
|
73
|
+
continue
|
|
74
|
+
assunto = _render(sequencia["assunto"], variaveis)
|
|
75
|
+
corpo = _render(sequencia["corpo"], variaveis)
|
|
76
|
+
try:
|
|
77
|
+
_enviar(sequencia, destinos or [], assunto, corpo)
|
|
78
|
+
enviados.append(sequencia["id"])
|
|
79
|
+
except LLMError as exc:
|
|
80
|
+
_registrar_envio(sequencia["id"], "", assunto, "erro", str(exc))
|
|
81
|
+
erros.append(str(exc))
|
|
82
|
+
return {"enviados": enviados, "erros": erros}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def enviar_manual(sequencia_id: int, destinos: list[str], variaveis: dict) -> dict:
|
|
86
|
+
with get_db() as conn:
|
|
87
|
+
row = conn.execute("SELECT * FROM email_sequencias WHERE id = ?", (sequencia_id,)).fetchone()
|
|
88
|
+
if not row:
|
|
89
|
+
raise LLMError("Sequência não encontrada.")
|
|
90
|
+
sequencia = row_to_dict(row)
|
|
91
|
+
if not destinos:
|
|
92
|
+
raise LLMError("Informe ao menos um destino (URL apprise, ex.: mailtos://user:senha@smtp.com).")
|
|
93
|
+
assunto = _render(sequencia["assunto"], {**_variaveis_padrao(), **(variaveis or {})})
|
|
94
|
+
corpo = _render(sequencia["corpo"], {**_variaveis_padrao(), **(variaveis or {})})
|
|
95
|
+
_enviar(sequencia, destinos, assunto, corpo)
|
|
96
|
+
return {"ok": True, "assunto": assunto}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _enviar(sequencia: dict, destinos: list, assunto: str, corpo: str) -> None:
|
|
100
|
+
from . import notify
|
|
101
|
+
|
|
102
|
+
alvos = [str(item).strip() for item in (destinos or []) if str(item).strip()]
|
|
103
|
+
if not alvos:
|
|
104
|
+
config = notify.listar_config()
|
|
105
|
+
alvos = [item for item in (config.get("targets") or [])]
|
|
106
|
+
if not alvos:
|
|
107
|
+
erro = "Nenhum canal apprise configurado (settings.notifications.targets)."
|
|
108
|
+
_registrar_envio(sequencia["id"], "", assunto, "erro", erro)
|
|
109
|
+
raise LLMError(erro)
|
|
110
|
+
telegram = notify._telegram_target()
|
|
111
|
+
if telegram and telegram not in alvos:
|
|
112
|
+
alvos.insert(0, telegram)
|
|
113
|
+
try:
|
|
114
|
+
import apprise
|
|
115
|
+
|
|
116
|
+
apobj = apprise.Apprise()
|
|
117
|
+
validos = [alvo for alvo in alvos if apobj.add(alvo)]
|
|
118
|
+
if not validos:
|
|
119
|
+
erro = "Nenhum canal apprise válido."
|
|
120
|
+
_registrar_envio(sequencia["id"], ",".join(alvos), assunto, "erro", erro)
|
|
121
|
+
raise LLMError(erro)
|
|
122
|
+
if apobj.notify(title=assunto, body=corpo):
|
|
123
|
+
_registrar_envio(sequencia["id"], ",".join(validos), assunto, "enviada")
|
|
124
|
+
else:
|
|
125
|
+
erro = "apprise não conseguiu entregar."
|
|
126
|
+
_registrar_envio(sequencia["id"], ",".join(validos), assunto, "erro", erro)
|
|
127
|
+
raise LLMError(erro)
|
|
128
|
+
except ImportError as exc:
|
|
129
|
+
erro = f"apprise ausente ({exc.name}) — rode 'prodmaker install'."
|
|
130
|
+
_registrar_envio(sequencia["id"], "", assunto, "erro", erro)
|
|
131
|
+
raise LLMError(erro)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _registrar_envio(sequencia_id: int, destino: str, assunto: str, status: str, erro: str = "") -> None:
|
|
135
|
+
with get_db() as conn:
|
|
136
|
+
conn.execute(
|
|
137
|
+
"INSERT INTO email_envios (sequencia_id, destino, assunto, status, erro, created_at) VALUES (?, ?, ?, ?, ?, ?)",
|
|
138
|
+
(sequencia_id, destino, assunto[:180], status, erro[:500], now_str()),
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def historico(limit: int = 50) -> dict:
|
|
143
|
+
with get_db() as conn:
|
|
144
|
+
envios = [row_to_dict(row) for row in conn.execute("SELECT * FROM email_envios ORDER BY id DESC LIMIT ?", (limit,))]
|
|
145
|
+
totais = {row["status"]: row["n"] for row in conn.execute("SELECT status, COUNT(*) AS n FROM email_envios GROUP BY status")}
|
|
146
|
+
return {"envios": envios, "totais": totais}
|